8000 Support configuration from multiple sources (#187) · mrclary/python-lsp-server@3c1f183 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3c1f183

Browse files
authored
Support configuration from multiple sources (python-lsp#187)
* Config * Config * Configs * Configs * Configs * Configs * Configs * Configs * Configs * Configs * Configs * Configuration cache * Configuration cache * Configuration cache * Add lgeiger's failing config test * Add lgeiger's failing config test * Add lgeiger's failing config test
1 parent 55a5fd6 commit 3c1f183

21 files changed

+526
-275
lines changed

README.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ Installing these plugins will add extra functionality to the language server:
2929
* pyls-mypy_ Mypy type checking for Python 3
3030
* pyls-isort_ Isort import sort code formatting
3131

32+
Configuration
33+
-------------
34+
35+
Configuration is loaded from zero or more configuration sources. Currently implemented are:
36+
37+
* pycodestyle: discovered in ~/.config/pycodestyle, setup.cfg, tox.ini and pycodestyle.cfg.
38+
* flake8: discovered in ~/.config/flake8, setup.cfg, tox.ini and flake8.cfg
39+
40+
The default configuration source is pycodestyle. Change the `pyls.configurationSources` setting to `['flake8']` in
41+
order to respect flake8 configuration instead.
42+
43+
Overall configuration is computed first from user configuration (in home directory), overridden by configuration
44+
passed in by the language client, and then overriden by configuration discovered in the workspace.
45+
3246
Language Server Features
3347
------------------------
3448

pyls/_utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright 2017 Palantir Technologies, Inc.
22
import functools
33
import logging
4+
import os
45
import re
56
import threading
67

@@ -28,10 +29,66 @@ def camel_to_underscore(string):
2829
return ALL_CAP_RE.sub(r'\1_\2', s1).lower()
2930

3031

32+
def find_parents(root, path, names):
33+
"""Find files matching the given names relative to the given path.
34+
35+
Args:
36+
path (str): The file path to start searching up from.
37+
names (List[str]): The file/directory names to look for.
38+
root (str): The directory at which to stop recursing upwards.
39+
40+
Note:
41+
The path MUST be within the root.
42+
"""
43+
if not root:
44+
return []
45+
46+
if not os.path.commonprefix((root, path)):
47+
log.warning("Path %s not in %s", path, root)
48+
return []
49+
50+
# Split the relative by directory, generate all the parent directories, then check each of them.
51+
# This avoids running a loop that has different base-cases for unix/windows
52+
# e.g. /a/b and /a/b/c/d/e.py -> ['/a/b', 'c', 'd']
53+
dirs = [root] + os.path.relpath(os.path.dirname(path), root).split(os.path.sep)
54+
55+
# Search each of /a/b/c, /a/b, /a
56+
while dirs:
57+
search_dir = os.path.join(*dirs)
58+
existing = list(filter(os.path.exists, [os.path.join(search_dir, n) for n in names]))
59+
if existing:
60+
return existing
61+
dirs.pop()
62+
63+
# Otherwise nothing
64+
return []
65+
66+
3167
def list_to_string(value):
3268
return ",".join(value) if type(value) == list else value
3369

3470

71+
def merge_dicts(dict_a, dict_b):
72+
"""Recursively merge dictionary b into dictionary a.
73+
74+
If override_nones is True, then
75+
"""
76+
def _merge_dicts_(a, b):
77+
for key in set(a.keys()).union(b.keys()):
78+
if key in a and key in b:
79+
if isinstance(a[key], dict) and isinstance(b[key], dict):
80+
yield (key, dict(_merge_dicts_(a[key], b[key])))
81+
elif b[key] is not None:
82+
yield (key, b[key])
83+
else:
84+
yield (key, a[key])
85+
elif key in a:
86+
yield (key, a[key])
87+
elif b[key] is not None:
88+
yield (key, b[key])
89+
return dict(_merge_dicts_(dict_a, dict_b))
90+
91+
3592
def race_hooks(hook_caller, pool, **kwargs):
3693
"""Given a pluggy hook spec, execute impls in parallel returning the first non-None result.
3794

pyls/config.py

Lines changed: 0 additions & 139 deletions
This file was deleted.

pyls/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Copyright 2017 Palantir Technologies, Inc.

pyls/config/config.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2017 Palantir Technologies, Inc.
2+
import logging
3+
import pluggy
4+
5+
from pyls import _utils, hookspecs, uris, PYLS
6+
from .flake8_conf import Flake8Config
7+
from .pycodestyle_conf import PyCodeStyleConfig
8+
9+
10+
log = logging.getLogger(__name__)
11+
12+
# Sources of config, first source overrides next source
13+
DEFAULT_CONFIG_SOURCES = ['pycodestyle']
14+
15+
16+
class Config(object):
17+
18+
def __init__(self, root_uri, init_opts):
19+
self._root_path = uris.to_fs_path(root_uri)
20+
self._root_uri = root_uri
21+
self._init_opts = init_opts
22+
23+
self._disabled_plugins = []
24+
self._settings = {}
25+
self._plugin_settings = {}
26+
27+
self._config_sources = {
28+
'flake8': Flake8Config(self._root_path),
29+
'pycodestyle': PyCodeStyleConfig(self._root_path)
30+
}
31+
32+
self._pm = pluggy.PluginManager(PYLS)
33+
self._pm.trace.root.setwriter(log.debug)
34+
self._pm.enable_tracing()
35+
self._pm.add_hookspecs(hookspecs)
36+
self._pm.load_setuptools_entrypoints(PYLS)
37+
38+
for name, plugin in self._pm.list_name_plugin():
39+
log.info("Loaded pyls plugin %s from %s", name, plugin)
40+
41+
for plugin_conf in self._pm.hook.pyls_settings(config=self):
42+
self._plugin_settings = _utils.merge_dicts(self._plugin_settings, plugin_conf)
43+
44+
@property
45+
def disabled_plugins(self):
46+
return self._disabled_plugins
47+
48+
@property
49+
def plugin_manager(self):
50+
return self._pm
51+
52+
@property
53+
def init_opts(self):
54+
return self._init_opts
55+
56+
@property
57+
def root_uri(self):
58+
return self._root_uri
59+
60+
def settings(self, document_path=None):
61+
"""Settings are constructed from a few sources:
62+
63+
1. User settings, found in user's home directory
64+
2. Plugin settings, reported by PyLS plugins
65+
3. LSP settings, given to us from didChangeConfiguration
66+
4. Project settings, found in config files in the current project.
67+
"""
68+
settings = {}
69+
sources = self._settings.get('configurationSources', DEFAULT_CONFIG_SOURCES)
70+
71+
for source_name in reversed(sources):
72+
source = self._config_sources[source_name]
73+
source_conf = source.user_config()
74+
log.debug("Got user config from %s: %s", source.__class__.__name__, source_conf)
75+
settings = _utils.merge_dicts(settings, source_conf)
76+
log.debug("With user configuration: %s", settings)
77+
78+
settings = _utils.merge_dicts(settings, self._plugin_settings)
79+
log.debug("With plugin configuration: %s", settings)
80+
81+
settings = _utils.merge_dicts(settings, self._settings)
82+
log.debug("With lsp configuration: %s", settings)
83+
84+
for source_name in reversed(sources):
85+
source = self._config_sources[source_name]
86+
source_conf = source.project_config(document_path or self._root_path)
87+
log.debug("Got project config from %s: %s", source.__class__.__name__, source_conf)
88+
settings = _utils.merge_dicts(settings, source_conf)
89+
log.debug("With project configuration: %s", settings)
90+
91+
return settings
92+
93+
def find_parents(self, path, names):
94+
root_path = uris.to_fs_path(self._root_uri)
95+
return _utils.find_parents(root_path, path, names)
96+
97+
def plugin_settings(self, plugin, document_path=None):
98+
return self.settings(document_path=document_path).get('plugins', {}).get(plugin, {})
99+
100+
def update(self, settings):
101+
"""Recursively merge the given settings into the current settings."""
102+
self._settings = settings
103+
log.info("Updated settings to %s", self._settings)
104+
105+
# All plugins default to enabled
106+
self._disabled_plugins = [
CE7 107+
plugin for name, plugin in self.plugin_manager.list_name_plugin()
108+
if not self._settings.get('plugins', {}).get(name, {}).get('enabled', True)
109+
]
110+
log.info("Disabled plugins: %s", self._disabled_plugins)

pyls/config/flake8_conf.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2017 Palantir Technologies, Inc.
2+
import logging
3+
import os
4+
from .source import ConfigSource
5+
from pyls._utils import find_parents
6+
7+
log = logging.getLogger(__name__)
8+
9+
CONFIG_KEY = 'flake8'
10+
PROJECT_CONFIGS = ['.flake8', 'setup.cfg', 'tox.ini']
11+
12+
OPTIONS = [
13+
# mccabe
14+
('max-complexity', 'plugins.mccabe.threshold', int),
15+
# pycodestyle
16+
('exclude', 'plugins.pycodestyle.exclude', list),
17+
('filename', 'plugins.pycodestyle.filename', list),
18+
('hang-closing', 'plugins.pycodestyle.hangClosing', bool),
19+
('ignore', 'plugins.pycodestyle.ignore', list),
20+
('max-line-length', 'plugins.pycodestyle.maxLineLength', int),
21+
('select', 'plugins.pycodestyle.select', list),
22+
]
23+
24+
25+
class Flake8Config(ConfigSource):
26+
"""Parse flake8 configurations."""
27+
28+
def user_config(self):
29+
config_file = self._user_config_file()
30+
config = self.read_config_from_files([config_file])
31+
return self.parse_config(config, CONFIG_KEY, OPTIONS)
32+
33+
def _user_config_file(self):
34+
if self.is_windows:
35+
return os.path.expanduser('~\\.flake8')
36+
else:
37+
return os.path.join(self.xdg_home, 'flake8')
38+
39+
def project_config(self, document_path):
40+
files = find_parents(self.root_path, document_path, PROJECT_CONFIGS)
41+
config = self.read_config_from_files(files)
42+
return self.parse_config(config, CONFIG_KEY, OPTIONS)

0 commit comments

Comments
 (0)
0