From 78e87d0ab8f1aecff66bd73ae0e2b396772985e7 Mon Sep 17 00:00:00 2001 From: Dave Halter Date: Tue, 20 Oct 2020 01:00:22 +0200 Subject: [PATCH] Relative imports should work even if they are not within the project --- jedi/inference/imports.py | 33 ++++++++++++++++++++++++++++- test/test_api/test_usages.py | 2 +- test/test_inference/test_imports.py | 21 ++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/jedi/inference/imports.py b/jedi/inference/imports.py index 5ae5819f..d360f787 100644 --- a/jedi/inference/imports.py +++ b/jedi/inference/imports.py @@ -245,7 +245,38 @@ class Importer: ) def follow(self): - if not self.import_path or not self._infer_possible: + if not self.import_path: + if self._fixed_sys_path: + # This is a bit of a special case, that maybe should be + # revisited. If the project path is wrong or the user uses + # relative imports the wrong way, we might end up here, where + # the `fixed_sys_path == project.path` in that case we kind of + # use the project.path.parent directory as our path. This is + # usually not a problem, except if imorts in other places are + # using the same names. Example: + # + # foo/ < #1 + # - setup.py + # - foo/ < #2 + # - __init__.py + # - foo.py < #3 + # + # If the top foo is our project folder and somebody uses + # `from . import foo` in `setup.py`, it will resolve to foo #2, + # which means that the import for foo.foo is cached as + # `__init__.py` (#2) and not as `foo.py` (#3). This is usually + # not an issue, because this case is probably pretty rare, but + # might be an issue for some people. + from jedi.inference.value.namespace import ImplicitNamespaceValue + import_path = (os.path.basename(self._fixed_sys_path[0]),) + ns = ImplicitNamespaceValue( + self._inference_state, + string_names=import_path, + paths=self._fixed_sys_path, + ) + return ValueSet({ns}) + return NO_VALUES + if not self._infer_possible: return NO_VALUES # Check caches first diff --git a/test/test_api/test_usages.py b/test/test_api/test_usages.py index e3868333..82aed0fd 100644 --- a/test/test_api/test_usages.py +++ b/test/test_api/test_usages.py @@ -3,7 +3,7 @@ import pytest def test_import_references(Script): s = Script("from .. import foo", path="foo.py") - assert [usage.line for usage in s.get_references(line=1, column=18)] == [1] + assert [usage.line for usage in s.get_references()] == [1] def test_exclude_builtin_modules(Script): diff --git a/test/test_inference/test_imports.py b/test/test_inference/test_imports.py index f1eb3f57..36016b16 100644 --- a/test/test_inference/test_imports.py +++ b/test/test_inference/test_imports.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import jedi from jedi.file_io import FileIO from jedi.inference import compiled from jedi.inference import imports @@ -468,3 +469,23 @@ def test_relative_import_star(Script): script = Script(source, path='export.py') assert script.complete(3, len("furl.c")) + + +@pytest.mark.parametrize('with_init', [False, True]) +def test_relative_imports_without_path_and_setup_py( + Script, inference_state, environment, tmpdir, with_init): + # Contrary to other tests here we create a temporary folder that is not + # part of a folder with a setup py that signifies + tmpdir.join('file1.py').write('do_foo = 1') + other_path = tmpdir.join('other_files') + other_path.join('file2.py').write('def do_nothing():\n pass', ensure=True) + if with_init: + other_path.join('__init__.py').write('') + + for name, code in [('file2', 'from . import file2'), + ('file1', 'from .. import file1')]: + for func in (jedi.Script.goto, jedi.Script.infer): + n, = func(Script(code, path=other_path.join('test1.py').strpath)) + assert n.name == name + assert n.type == 'module' + assert n.line == 1