Skip to content

Commit 3cac737

Browse files
committed
Introduce pyls.source_roots configuration
Before this commit, 'setup.py' or 'pyproject.toml' should be created to specify source root(s) other than the workspace root, even if such file is not actually needed. In order to explicitly specify source root(s) in the workspace, this commit introduces pyls.source_roots configuration, and makes workspace.source_roots() return its value if configured. Path in pyls.source_roots is ignored, if it refers outside of the workspace. This configuration is also useful in the case that the workspace consists of multiple (independent) services and a library shared by them, for example. In such case, N + 1 source roots are listed in pyls.source_roots configuration. BTW, this commit puts some utilities into _util.py for reuse in the future. Especially, os.path.commonprefix() for checking inside-ness in find_parents() below should be replaced with is_inside_of(), because the former returns unintentional value in some cases. if not os.path.commonprefix((root, path)): log.warning("Path %s not in %s", path, root) return [] For example: - commonprefix(('/foo', '/bar')) returns not false value but '/' - commonprefix(('/foo', '/foobar')) returns not false value but '/foo'
1 parent 78a3484 commit 3cac737

File tree

5 files changed

+205
-2
lines changed

5 files changed

+205
-2
lines changed

pyls/_utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,48 @@ def find_parents(root, path, names):
8383
return []
8484

8585

86+
def is_inside_of(path, root, strictly=True):
87+
"""Return whether path is inside of root or not
88+
89+
It is assumed that both path and root are absolute.
90+
91+
If strictly=False, os.path.normcase and os.path.normpath are not
92+
applied on path and root for efficiency. This is useful if
93+
ablsolute "path" is made from relative one and "root" (and already
94+
normpath-ed), for example.
95+
"""
96+
if strictly:
97+
path = os.path.normcase(os.path.normpath(path))
98+
root = os.path.normcase(os.path.normpath(root))
99+
100+
return path == root or path.startswith(root + os.path.sep)
101+
102+
103+
def normalize_paths(paths, basedir, inside_only):
104+
"""Normalize each elements in paths
105+
106+
Relative elements in paths are treated as relative to basedir.
107+
108+
This function yields "(path, validity)" tuple as normalization
109+
result for each elements in paths. If inside_only is specified and path is
110+
not so, validity is False. Otherwise, path is already normalized
111+
as absolute path, and validity is True.
112+
"""
113+
for path in paths:
114+
full_path = os.path.normpath(os.path.join(basedir, path))
115+
if (not inside_only or
116+
# If "inside_only" is specified, path must (1) not be
117+
# absolute (= be relative to basedir), (2) not have
118+
# drive letter (on Windows), and (3) be descendant of
119+
# the root (= "inside_only").
120+
(not os.path.isabs(path) and
121+
not os.path.splitdrive(path)[0] and
122+
is_inside_of(full_path, inside_only, strictly=False))):
123+
yield full_path, True
124+
else:
125+
yield path, False
126+
127+
86128
def match_uri_to_workspace(uri, workspaces):
87129
if uri is None:
88130
return None

pyls/config/pyls_conf.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import logging
22
import os
3-
from pyls._utils import find_parents, merge_dicts
4-
from .source import ConfigSource
3+
from pyls._utils import find_parents, get_config_by_path, merge_dicts, normalize_paths
4+
from .source import ConfigSource, _set_opt
55

66
log = logging.getLogger(__name__)
77

88
CONFIG_KEY = 'pyls'
99
PROJECT_CONFIGS = ['setup.cfg', 'tox.ini']
1010

1111
OPTIONS = [
12+
('source_roots', 'source_roots', list),
13+
]
14+
15+
# list of (config_path, inside_only) tuples for path normalization
16+
NORMALIZED_CONFIGS = [
17+
('source_roots', True),
1218
]
1319

1420

@@ -58,6 +64,23 @@ def project_config(self, document_path):
5864
if not parsed:
5965
continue # no pyls specific configuration
6066

67+
self.normalize(parsed, os.path.dirname(files[0]))
68+
6169
settings = merge_dicts(settings, parsed)
6270

6371
return settings
72+
73+
def normalize(self, config, basedir):
74+
for config_path, inside_only in NORMALIZED_CONFIGS:
75+
paths = get_config_by_path(config, config_path)
76+
if not paths:
77+
continue # not specified (or empty)
78+
79+
normalized = []
80+
for path, valid in normalize_paths(paths, basedir, inside_only and self._norm_root_path):
81+
if valid:
82+
normalized.append(path)
83+
else:
84+
log.warning("Ignoring path '%s' for pyls.%s", path, config_path)
85+
86+
_set_opt(config, config_path, normalized)

pyls/workspace.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ def show_message(self, message, msg_type=lsp.MessageType.Info):
9797

9898
def source_roots(self, document_path):
9999
"""Return the source roots for the given document."""
100+
source_roots = self._config and self._config.settings(document_path=document_path).get('source_roots')
101+
if source_roots:
102+
# Use specified source_roots without traversing upper directories
103+
return source_roots
104+
100105
files = _utils.find_parents(self._root_path, document_path, ['setup.py', 'pyproject.toml']) or []
101106
return list(set((os.path.dirname(project_file) for project_file in files))) or [self._root_path]
102107

test/test_workspace.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
import pytest
66

77
from pyls import uris
8+
from pyls.python_ls import PythonLanguageServer
89

910
PY2 = sys.version_info.major == 2
1011

1112
if PY2:
1213
import pathlib2 as pathlib
14+
from StringIO import StringIO
1315
else:
1416
import pathlib
17+
from io import StringIO
1518

1619

1720
DOC_URI = uris.from_fs_path(__file__)
@@ -119,3 +122,124 @@ def test_multiple_workspaces(tmpdir, pyls):
119122
pyls.m_workspace__did_change_workspace_folders(
120123
added=[], removed=[added_workspaces[0]])
121124
assert workspace1_uri not in pyls.workspaces
125+
126+
127+
def _make_paths_dir_relative(paths, dirname):
128+
"""Make paths relative to dirname
129+
130+
This method assumes below for simplicity:
131+
132+
- if a path in paths is relative, it is relative to "root"
133+
- dirname must be relative to "root"
134+
- empty dirname means "root" itself
135+
- neither "." nor ".." is allowed for dirname
136+
"""
137+
to_root = os.path.join(*(['..'] * len(dirname.split(os.path.sep)))) if dirname else ''
138+
return list(p if os.path.isabs(p) else os.path.join(to_root, p) for p in paths)
139+
140+
141+
@pytest.mark.parametrize('metafile', [
142+
'setup.cfg',
143+
'tox.ini',
144+
'service/foo/setup.cfg',
145+
'service/foo/tox.ini',
146+
])
147+
def test_source_roots_config(tmpdir, metafile):
148+
"""Examine that source_roots config is intentionaly read in.
149+
150+
This test also examines below for entries in source_roots:
151+
152+
* absolute path is ignored
153+
* relative path is:
154+
- ignored, if it does not refer inside of the workspace
155+
- otherwise, it is treated as relative to config file location, and
156+
- normalized into absolute one
157+
"""
158+
root_path = str(tmpdir)
159+
160+
invalid_roots = ['/invalid/root', '../baz']
161+
source_roots = ['service/foo', 'service/bar'] + invalid_roots
162+
doc_root = source_roots[0]
163+
164+
if metafile:
165+
dirname = os.path.dirname(metafile)
166+
if dirname:
167+
os.makedirs(os.path.join(root_path, dirname))
168+
169+
# configured by metafile at pyls startup
170+
with open(os.path.join(root_path, metafile), 'w+') as f:
171+
f.write('[pyls]\nsource_roots=\n %s\n' %
172+
',\n '.join(_make_paths_dir_relative(source_roots, dirname)))
173+
174+
pyls = PythonLanguageServer(StringIO, StringIO)
175+
pyls.m_initialize(
176+
processId=1,
177+
rootUri=uris.from_fs_path(root_path),
178+
initializationOptions={}
179+
)
180+
181+
# put new document under ROOT/service/foo
182+
test_uri = uris.from_fs_path(os.path.join(root_path, doc_root, 'hello/test.py'))
183+
pyls.workspace.put_document(test_uri, 'assert true')
184+
test_doc = pyls.workspace.get_document(test_uri)
185+
186+
# apply os.path.normcase() on paths below, because case-sensitive
187+
# comparison on Windows causes unintentional failure for case
188+
# instability around drive letter
189+
190+
sys_path = [os.path.normcase(p) for p in test_doc.sys_path()]
191+
for raw_path in source_roots:
192+
full_path = os.path.normcase(os.path.join(root_path, raw_path))
193+
if raw_path in invalid_roots:
194+
assert os.path.normpath(full_path) not in sys_path
195+
assert full_path not in sys_path # check for safety
196+
else:
197+
assert os.path.normpath(full_path) in sys_path
198+
199+
200+
@pytest.mark.parametrize('metafile', ['setup.cfg', 'tox.ini'])
201+
def test_pyls_config_readin(tmpdir, metafile):
202+
"""Examine that pyls config in the workspace root is always read in.
203+
204+
This test creates two config files. One is created in the
205+
workspace root, and another is created in ascendant of the target
206+
document. Only the former has source_roots config.
207+
208+
Then, this test examines that the former is always read in,
209+
regardless of existence of the latter, by checking source_roots
210+
config.
211+
"""
212+
root_path = str(tmpdir)
213+
214+
source_roots = ['service/foo', 'service/bar']
215+
216+
with open(os.path.join(root_path, metafile), 'w+') as f:
217+
f.write('[pyls]\nsource_roots=\n %s\n' %
218+
',\n '.join(source_roots))
219+
220+
doc_root = source_roots[0]
221+
222+
os.makedirs(os.path.join(root_path, doc_root))
223+
with open(os.path.join(root_path, doc_root, metafile), 'w+') as f:
224+
f.write('\n')
225+
226+
pyls = PythonLanguageServer(StringIO, StringIO)
227+
pyls.m_initialize(
228+
processId=1,
229+
rootUri=uris.from_fs_path(root_path),
230+
initializationOptions={}
231+
)
232+
233+
# put new document under root/service/foo
234+
test_uri = uris.from_fs_path(os.path.join(root_path, doc_root, 'hello/test.py'))
235+
pyls.workspace.put_document(test_uri, 'assert True')
236+
test_doc = pyls.workspace.get_document(test_uri)
237+
238+
# apply os.path.normcase() on paths below, because case-sensitive
239+
# comparison on Windows causes unintentional failure for case
240+
# instability around drive letter
241+
242+
sys_path = [os.path.normcase(p) for p in test_doc.sys_path()]
243+
for raw_path in source_roots:
244+
full_path = os.path.normcase(os.path.join(root_path, raw_path))
245+
assert os.path.normpath(full_path) in sys_path

vscode-client/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@
3535
},
3636
"uniqueItems": true
3737
},
38+
"pyls.source_roots": {
39+
"type": "array",
40+
"default": [],
41+
"description": "List of source roots in the working space.",
42+
"items": {
43+
"type": "string"
44+
},
45+
"uniqueItems": true
46+
},
3847
"pyls.plugins.jedi.extra_paths": {
3948
"type": "array",
4049
"default": [],

0 commit comments

Comments
 (0)