From 1a32663f8578071c204e1201fb30f3ed52d63595 Mon Sep 17 00:00:00 2001 From: Dave Halter Date: Thu, 28 Feb 2019 09:42:30 +0100 Subject: [PATCH 1/3] The calculation of dotted paths from normal paths was completely wrong --- jedi/api/__init__.py | 5 ++--- jedi/evaluate/imports.py | 2 +- jedi/evaluate/sys_path.py | 21 +++++++++++++-------- test/test_evaluate/test_sys_path.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/jedi/api/__init__.py b/jedi/api/__init__.py index 9e568cf8..86ab8ac4 100644 --- a/jedi/api/__init__.py +++ b/jedi/api/__init__.py @@ -32,7 +32,7 @@ from jedi.evaluate import imports from jedi.evaluate import usages from jedi.evaluate.arguments import try_iter_content from jedi.evaluate.helpers import get_module_names, evaluate_call_of_leaf -from jedi.evaluate.sys_path import dotted_path_in_sys_path +from jedi.evaluate.sys_path import transform_path_to_dotted from jedi.evaluate.filters import TreeNameDefinition, ParamName from jedi.evaluate.syntax_tree import tree_name_to_contexts from jedi.evaluate.context import ModuleContext @@ -107,7 +107,6 @@ class Script(object): self._evaluator = Evaluator( project, environment=environment, script_path=self.path ) - self._project = project debug.speed('init') self._module_node, source = self._evaluator.parse_and_get_code( code=source, @@ -145,7 +144,7 @@ class Script(object): def _get_module(self): name = '__main__' if self.path is not None: - import_names = dotted_path_in_sys_path(self._evaluator.get_sys_path(), self.path) + import_names = transform_path_to_dotted(self._evaluator.get_sys_path(), self.path) if import_names is not None: name = '.'.join(import_names) diff --git a/jedi/evaluate/imports.py b/jedi/evaluate/imports.py index 3b1df31a..c7938101 100644 --- a/jedi/evaluate/imports.py +++ b/jedi/evaluate/imports.py @@ -559,7 +559,7 @@ def get_modules_containing_name(evaluator, modules, name): code = python_bytes_to_unicode(f.read(), errors='replace') if name in code: e_sys_path = evaluator.get_sys_path() - import_names = sys_path.dotted_path_in_sys_path(e_sys_path, path) + import_names = sys_path.transform_path_to_dotted(e_sys_path, path) module = _load_module( evaluator, path, code, sys_path=e_sys_path, diff --git a/jedi/evaluate/sys_path.py b/jedi/evaluate/sys_path.py index 8fb1843f..1544acc2 100644 --- a/jedi/evaluate/sys_path.py +++ b/jedi/evaluate/sys_path.py @@ -196,30 +196,35 @@ def _get_buildout_script_paths(search_path): continue -def dotted_path_in_sys_path(sys_path, module_path): +def transform_path_to_dotted(sys_path, module_path): """ - Returns the dotted path inside a sys.path as a list of names. + Returns the dotted path inside a sys.path as a list of names. e.g. + + >>> calculate_dotted_from_path(["/foo"], '/foo/bar/baz.py') + ['bar', 'baz'] + + Returns None if the path doesn't really resolve to anything. """ # First remove the suffix. for suffix in all_suffixes(): if module_path.endswith(suffix): module_path = module_path[:-len(suffix)] - break + break else: # There should always be a suffix in a valid Python file on the path. return None - if module_path.startswith(os.path.sep): - # The paths in sys.path most of the times don't end with a slash. - module_path = module_path[1:] - for p in sys_path: if module_path.startswith(p): rest = module_path[len(p):] + if rest.startswith(os.path.sep): + # Remove a slash in cases it's still there. + rest = rest[1:] + if rest: split = rest.split(os.path.sep) for string in split: - if not string or '.' in string: + if not string: return None return split diff --git a/test/test_evaluate/test_sys_path.py b/test/test_evaluate/test_sys_path.py index dab4f471..a43e5262 100644 --- a/test/test_evaluate/test_sys_path.py +++ b/test/test_evaluate/test_sys_path.py @@ -3,6 +3,8 @@ from glob import glob import sys import shutil +import pytest + from jedi.evaluate import sys_path from jedi.api.environment import create_environment @@ -59,3 +61,29 @@ def test_venv_and_pths(venv_path): # Ensure that none of venv dirs leaked to the interpreter. assert not set(sys.path).intersection(ETALON) + + +_s = ['/a', '/b', '/c/d/'] + + +@pytest.mark.parametrize( + 'sys_path_, module_path, result', [ + (_s, '/a/b', None), + (_s, '/a/b/c', None), + (_s, '/a/b.py', ['b']), + (_s, '/a/b/c.py', ['b', 'c']), + (_s, '/x/b.py', None), + (_s, '/c/d/x.py', ['x']), + (_s, '/c/d/x.py', ['x']), + (_s, '/c/d/x/y.py', ['x', 'y']), + # If dots are in there they also resolve. These are obviously illegal + # in Python, but Jedi can handle them. Give the user a bit more freedom + # that he will have to correct eventually. + (_s, '/a/b.c.py', ['b.c']), + (_s, '/a/b.d/foo.bar.py', ['b.d', 'foo.bar']), + + (_s, '/a/.py', None), + (_s, '/a/c/.py', None), + ]) +def test_calculate_dotted_from_path(sys_path_, module_path, result): + assert sys_path.transform_path_to_dotted(sys_path_, module_path) == result From 8aca357de6abe4451201d8635dd41c32b4d1b657 Mon Sep 17 00:00:00 2001 From: Dave Halter Date: Thu, 28 Feb 2019 09:51:47 +0100 Subject: [PATCH 2/3] Write a test for #1209 Relative imports were failing in nested Python packages. With the fix to transforming paths to dotted paths this should already be a lot better, still here's a regression test. --- test/examples/issue1209/__init__.py | 0 test/examples/issue1209/api/__init__.py | 0 test/examples/issue1209/api/whatever/__init__.py | 0 .../examples/issue1209/api/whatever/api_test1.py | 0 test/examples/issue1209/whatever/__init__.py | 0 test/examples/issue1209/whatever/test.py | 0 test/test_evaluate/test_imports.py | 16 +++++++++++++++- 7 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 test/examples/issue1209/__init__.py create mode 100644 test/examples/issue1209/api/__init__.py create mode 100644 test/examples/issue1209/api/whatever/__init__.py create mode 100644 test/examples/issue1209/api/whatever/api_test1.py create mode 100644 test/examples/issue1209/whatever/__init__.py create mode 100644 test/examples/issue1209/whatever/test.py diff --git a/test/examples/issue1209/__init__.py b/test/examples/issue1209/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/examples/issue1209/api/__init__.py b/test/examples/issue1209/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/examples/issue1209/api/whatever/__init__.py b/test/examples/issue1209/api/whatever/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/examples/issue1209/api/whatever/api_test1.py b/test/examples/issue1209/api/whatever/api_test1.py new file mode 100644 index 00000000..e69de29b diff --git a/test/examples/issue1209/whatever/__init__.py b/test/examples/issue1209/whatever/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/examples/issue1209/whatever/test.py b/test/examples/issue1209/whatever/test.py new file mode 100644 index 00000000..e69de29b diff --git a/test/test_evaluate/test_imports.py b/test/test_evaluate/test_imports.py index ec349c01..dbf02c57 100644 --- a/test/test_evaluate/test_imports.py +++ b/test/test_evaluate/test_imports.py @@ -9,7 +9,8 @@ import pytest from jedi._compatibility import find_module_py33, find_module from jedi.evaluate import compiled -from ..helpers import cwd_at +from jedi.api.project import Project +from ..helpers import cwd_at, get_example_dir @pytest.mark.skipif('sys.version_info < (3,3)') @@ -251,3 +252,16 @@ def test_compiled_import_none(monkeypatch, Script): """ monkeypatch.setattr(compiled, 'load_module', lambda *args, **kwargs: None) assert not Script('import sys').goto_definitions() + + +def test_relative_imports_with_multiple_similar_directories(Script): + dir = get_example_dir('issue1209') + script = Script( + "from .", + path=os.path.join(dir, 'api/whatever/test_this.py') + ) + # TODO pass this project to the script as a param once that's possible. + script._evaluator.project = Project(dir) + name, import_ = script.completions() + assert import_.name == 'import' + assert name.name == 'api_test1' From ffd9a6b4845080a77e33f6e55757a1ed216f67d1 Mon Sep 17 00:00:00 2001 From: Dave Halter Date: Thu, 28 Feb 2019 20:04:17 +0100 Subject: [PATCH 3/3] Make it possible to complete in non-Python files --- jedi/evaluate/sys_path.py | 6 +++--- test/test_evaluate/test_imports.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/jedi/evaluate/sys_path.py b/jedi/evaluate/sys_path.py index 1544acc2..01fe64af 100644 --- a/jedi/evaluate/sys_path.py +++ b/jedi/evaluate/sys_path.py @@ -210,9 +210,9 @@ def transform_path_to_dotted(sys_path, module_path): if module_path.endswith(suffix): module_path = module_path[:-len(suffix)] break - else: - # There should always be a suffix in a valid Python file on the path. - return None + # Once the suffix was removed we are using the files as we know them. This + # means that if someone uses an ending like .vim for a Python file, .vim + # will be part of the returned dotted part. for p in sys_path: if module_path.startswith(p): diff --git a/test/test_evaluate/test_imports.py b/test/test_evaluate/test_imports.py index dbf02c57..ff38efca 100644 --- a/test/test_evaluate/test_imports.py +++ b/test/test_evaluate/test_imports.py @@ -254,11 +254,13 @@ def test_compiled_import_none(monkeypatch, Script): assert not Script('import sys').goto_definitions() -def test_relative_imports_with_multiple_similar_directories(Script): +@pytest.mark.parametrize( + 'path', ('api/whatever/test_this.py', 'api/whatever/file')) +def test_relative_imports_with_multiple_similar_directories(Script, path): dir = get_example_dir('issue1209') script = Script( "from .", - path=os.path.join(dir, 'api/whatever/test_this.py') + path=os.path.join(dir, path) ) # TODO pass this project to the script as a param once that's possible. script._evaluator.project = Project(dir)