From 9fd4aab5da01e84a8524da48f604c04e55058e4c Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Thu, 1 Sep 2022 09:39:23 -0300 Subject: [PATCH 1/8] Find pytest fixtures from external plugins registered via setuptools entry points Using setuptools entry points is probably the main pytest mechanism of plugin discovery. See https://docs.pytest.org/en/stable/how-to/writing_plugins.html#setuptools-entry-points This extends the functionality of #791 and maybe eliminates the need for #1786. --- jedi/plugins/pytest.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/jedi/plugins/pytest.py b/jedi/plugins/pytest.py index c78bdb4f..6752b7da 100644 --- a/jedi/plugins/pytest.py +++ b/jedi/plugins/pytest.py @@ -1,8 +1,9 @@ +import importlib.metadata from pathlib import Path from parso.tree import search_ancestor from jedi.inference.cache import inference_state_method_cache -from jedi.inference.imports import load_module_from_path +from jedi.inference.imports import goto_import, load_module_from_path from jedi.inference.filters import ParserTreeFilter from jedi.inference.base_value import NO_VALUES, ValueSet from jedi.inference.helpers import infer_call_of_leaf @@ -131,6 +132,21 @@ def _is_pytest_func(func_name, decorator_nodes): or any('fixture' in n.get_code() for n in decorator_nodes) +def _find_pytest_plugin_modules(): + """ + Finds pytest plugin modules hooked by setuptools entry points + + See https://docs.pytest.org/en/stable/how-to/writing_plugins.html#setuptools-entry-points + """ + entry_points = ( + ep + for dist in importlib.metadata.distributions() + for ep in dist.entry_points + if ep.group == "pytest11" + ) + return [ep.value.split(".") for ep in entry_points] + + @inference_state_method_cache() def _iter_pytest_modules(module_context, skip_own_module=False): if not skip_own_module: @@ -159,7 +175,7 @@ def _iter_pytest_modules(module_context, skip_own_module=False): break last_folder = folder # keep track of the last found parent name - for names in _PYTEST_FIXTURE_MODULES: + for names in _PYTEST_FIXTURE_MODULES + _find_pytest_plugin_modules(): for module_value in module_context.inference_state.import_module(names): yield module_value.as_context() @@ -167,7 +183,19 @@ def _iter_pytest_modules(module_context, skip_own_module=False): class FixtureFilter(ParserTreeFilter): def _filter(self, names): for name in super()._filter(names): - funcdef = name.parent + + # resolve possible import before checking for a fixture + if name.parent.type == "import_from": + imported_names = goto_import(self.parent_context, name) + if not imported_names: + continue + # asssume the import leads to only one name + [imported_name] = imported_names + target_name = imported_name.tree_name + else: + target_name = name + + funcdef = target_name.parent # Class fixtures are not supported if funcdef.type == 'funcdef': decorated = funcdef.parent From 27e13e4072f871c04fd58be130e6179f50953fbf Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Thu, 1 Sep 2022 14:39:54 -0300 Subject: [PATCH 2/8] Allow for multiple returns from goto_import --- jedi/plugins/pytest.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/jedi/plugins/pytest.py b/jedi/plugins/pytest.py index 6752b7da..c29894fe 100644 --- a/jedi/plugins/pytest.py +++ b/jedi/plugins/pytest.py @@ -183,26 +183,25 @@ def _iter_pytest_modules(module_context, skip_own_module=False): class FixtureFilter(ParserTreeFilter): def _filter(self, names): for name in super()._filter(names): - - # resolve possible import before checking for a fixture + # resolve possible imports before checking for a fixture if name.parent.type == "import_from": imported_names = goto_import(self.parent_context, name) - if not imported_names: - continue - # asssume the import leads to only one name - [imported_name] = imported_names - target_name = imported_name.tree_name - else: - target_name = name - - funcdef = target_name.parent - # Class fixtures are not supported - if funcdef.type == 'funcdef': - decorated = funcdef.parent - if decorated.type == 'decorated' and self._is_fixture(decorated): + if any( + self._is_fixture(imported_name.tree_name) + for imported_name in imported_names + ): yield name + elif self._is_fixture(name): + yield name - def _is_fixture(self, decorated): + def _is_fixture(self, name): + funcdef = name.parent + # Class fixtures are not supported + if funcdef.type != "funcdef": + return False + decorated = funcdef.parent + if decorated.type != "decorated": + return False decorators = decorated.children[0] if decorators.type == 'decorators': decorators = decorators.children From 8447d7f3e4bd5f89254be2a2709f9985968623d3 Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Fri, 2 Sep 2022 17:34:44 -0300 Subject: [PATCH 3/8] Discard imports of modules as pytest fixtures --- jedi/plugins/pytest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jedi/plugins/pytest.py b/jedi/plugins/pytest.py index c29894fe..312556b4 100644 --- a/jedi/plugins/pytest.py +++ b/jedi/plugins/pytest.py @@ -186,9 +186,12 @@ class FixtureFilter(ParserTreeFilter): # resolve possible imports before checking for a fixture if name.parent.type == "import_from": imported_names = goto_import(self.parent_context, name) + # discard imports of whole modules, that have no tree_name + imported_tree_names = ( + iname.tree_name for iname in imported_names if iname.tree_name + ) if any( - self._is_fixture(imported_name.tree_name) - for imported_name in imported_names + self._is_fixture(tree_name) for tree_name in imported_tree_names ): yield name elif self._is_fixture(name): From fa1e9ce9a72a5ebf79f5f667fb166b657ab6b807 Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Sat, 3 Sep 2022 16:56:07 -0300 Subject: [PATCH 4/8] Simplify entry points enumeration --- jedi/plugins/pytest.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/jedi/plugins/pytest.py b/jedi/plugins/pytest.py index 312556b4..d1280774 100644 --- a/jedi/plugins/pytest.py +++ b/jedi/plugins/pytest.py @@ -1,4 +1,4 @@ -import importlib.metadata +from importlib.metadata import entry_points from pathlib import Path from parso.tree import search_ancestor @@ -138,13 +138,7 @@ def _find_pytest_plugin_modules(): See https://docs.pytest.org/en/stable/how-to/writing_plugins.html#setuptools-entry-points """ - entry_points = ( - ep - for dist in importlib.metadata.distributions() - for ep in dist.entry_points - if ep.group == "pytest11" - ) - return [ep.value.split(".") for ep in entry_points] + return [ep.value.split(".") for ep in entry_points(group="pytest11")] @inference_state_method_cache() From ec425ed2af5d9d745263f1987a56d9ab2fab683a Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Sat, 3 Sep 2022 17:12:05 -0300 Subject: [PATCH 5/8] Add tests to find pytest fixtures from external plugins --- test/completion/pytest.py | 25 +++++++++++++++++++ .../pytest_plugin/fixtures.py | 6 +++++ .../pytest_plugin/plugin.py | 18 +++++++++++++ test/test_integration.py | 17 +++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 test/examples/pytest_plugin_package/pytest_plugin/fixtures.py create mode 100644 test/examples/pytest_plugin_package/pytest_plugin/plugin.py diff --git a/test/completion/pytest.py b/test/completion/pytest.py index a900dcda..a39b3fb9 100644 --- a/test/completion/pytest.py +++ b/test/completion/pytest.py @@ -183,3 +183,28 @@ def with_annot() -> Generator[float, None, None]: def test_with_annot(inheritance_fixture, with_annot): #? float() with_annot + +# ----------------- +# pytest external plugins +# ----------------- + +#? ['admin_user', 'admin_client'] +def test_z(admin + +#! 15 ['def admin_client'] +def test_p(admin_client): + #? ['login', 'logout'] + admin_client.log + +@pytest.fixture +@some_decorator +#? ['admin_user'] +def bla(admin_u + return + +@pytest.fixture +@some_decorator +#! 12 ['def admin_user'] +def bla(admin_user): + pass + diff --git a/test/examples/pytest_plugin_package/pytest_plugin/fixtures.py b/test/examples/pytest_plugin_package/pytest_plugin/fixtures.py new file mode 100644 index 00000000..a27cfc71 --- /dev/null +++ b/test/examples/pytest_plugin_package/pytest_plugin/fixtures.py @@ -0,0 +1,6 @@ +from pytest import fixture + + +@fixture() +def admin_user(): + pass diff --git a/test/examples/pytest_plugin_package/pytest_plugin/plugin.py b/test/examples/pytest_plugin_package/pytest_plugin/plugin.py new file mode 100644 index 00000000..569d38db --- /dev/null +++ b/test/examples/pytest_plugin_package/pytest_plugin/plugin.py @@ -0,0 +1,18 @@ +import pytest +from pytest import fixture + + +from pytest_plugin.fixtures import admin_user # noqa + + +class Client: + def login(self, **credentials): + ... + + def logout(self): + ... + + +@pytest.fixture() +def admin_client(): + return Client() diff --git a/test/test_integration.py b/test/test_integration.py index 2e5703aa..7f482db4 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -1,4 +1,5 @@ import os +from collections import namedtuple import pytest @@ -42,6 +43,22 @@ def test_completion(case, monkeypatch, environment, has_django): if (not has_django) and case.path.endswith('django.py'): pytest.skip('Needs django to be installed to run this test.') + + if case.path.endswith("pytest.py"): + # to test finding pytest fixtures from external plugins + # add a stub pytest plugin to the project sys_path... + pytest_plugin_dir = str(helpers.get_example_dir("pytest_plugin_package")) + case._project.added_sys_path = [pytest_plugin_dir] + + # ... and mock setuptools entry points to include it + # see https://docs.pytest.org/en/stable/how-to/writing_plugins.html#setuptools-entry-points + def mock_entry_points(group): + assert group == "pytest11" + EntryPoint = namedtuple("EntryPoint", ["value"]) + return [EntryPoint(value="pytest_plugin.plugin")] + + monkeypatch.setattr("jedi.plugins.pytest.entry_points", mock_entry_points) + repo_root = helpers.root_dir monkeypatch.chdir(os.path.join(repo_root, 'jedi')) case.run(assert_case_equal, environment) From 1a306fddbf8039f6c6043965a9660de684515465 Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Sun, 4 Sep 2022 12:59:50 -0300 Subject: [PATCH 6/8] Fix check pytest fixture from import on the right context --- jedi/plugins/pytest.py | 23 ++++++++++--------- .../pytest_plugin/plugin.py | 12 ++++------ 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/jedi/plugins/pytest.py b/jedi/plugins/pytest.py index d1280774..25e15f28 100644 --- a/jedi/plugins/pytest.py +++ b/jedi/plugins/pytest.py @@ -177,21 +177,21 @@ def _iter_pytest_modules(module_context, skip_own_module=False): class FixtureFilter(ParserTreeFilter): def _filter(self, names): for name in super()._filter(names): - # resolve possible imports before checking for a fixture + # look for fixture definitions of imported names if name.parent.type == "import_from": imported_names = goto_import(self.parent_context, name) - # discard imports of whole modules, that have no tree_name - imported_tree_names = ( - iname.tree_name for iname in imported_names if iname.tree_name - ) if any( - self._is_fixture(tree_name) for tree_name in imported_tree_names + self._is_fixture(iname.parent_context, iname.tree_name) + for iname in imported_names + # discard imports of whole modules, that have no tree_name + if iname.tree_name ): yield name - elif self._is_fixture(name): + + elif self._is_fixture(self.parent_context, name): yield name - def _is_fixture(self, name): + def _is_fixture(self, context, name): funcdef = name.parent # Class fixtures are not supported if funcdef.type != "funcdef": @@ -215,11 +215,12 @@ class FixtureFilter(ParserTreeFilter): last_leaf = last_trailer.get_last_leaf() if last_leaf == ')': values = infer_call_of_leaf( - self.parent_context, last_leaf, cut_own_trailer=True) + context, last_leaf, cut_own_trailer=True + ) else: - values = self.parent_context.infer_node(dotted_name) + values = context.infer_node(dotted_name) else: - values = self.parent_context.infer_node(dotted_name) + values = context.infer_node(dotted_name) for value in values: if value.name.get_qualified_names(include_module_names=True) \ == ('_pytest', 'fixtures', 'fixture'): diff --git a/test/examples/pytest_plugin_package/pytest_plugin/plugin.py b/test/examples/pytest_plugin_package/pytest_plugin/plugin.py index 569d38db..e427ca97 100644 --- a/test/examples/pytest_plugin_package/pytest_plugin/plugin.py +++ b/test/examples/pytest_plugin_package/pytest_plugin/plugin.py @@ -1,8 +1,11 @@ import pytest -from pytest import fixture + +from .fixtures import admin_user # noqa -from pytest_plugin.fixtures import admin_user # noqa +@pytest.fixture() +def admin_client(): + return Client() class Client: @@ -11,8 +14,3 @@ class Client: def logout(self): ... - - -@pytest.fixture() -def admin_client(): - return Client() From e25750ecefe1747ae631fb4264236784784a91e6 Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Mon, 5 Sep 2022 17:01:02 -0300 Subject: [PATCH 7/8] Make code compatible with python < 3.8 --- jedi/plugins/pytest.py | 5 +++-- test/test_integration.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/jedi/plugins/pytest.py b/jedi/plugins/pytest.py index 25e15f28..0f6b320d 100644 --- a/jedi/plugins/pytest.py +++ b/jedi/plugins/pytest.py @@ -1,4 +1,3 @@ -from importlib.metadata import entry_points from pathlib import Path from parso.tree import search_ancestor @@ -138,7 +137,9 @@ def _find_pytest_plugin_modules(): See https://docs.pytest.org/en/stable/how-to/writing_plugins.html#setuptools-entry-points """ - return [ep.value.split(".") for ep in entry_points(group="pytest11")] + from pkg_resources import iter_entry_points + + return [ep.module_name.split(".") for ep in iter_entry_points(group="pytest11")] @inference_state_method_cache() diff --git a/test/test_integration.py b/test/test_integration.py index 7f482db4..2d8e118c 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -52,12 +52,12 @@ def test_completion(case, monkeypatch, environment, has_django): # ... and mock setuptools entry points to include it # see https://docs.pytest.org/en/stable/how-to/writing_plugins.html#setuptools-entry-points - def mock_entry_points(group): + def mock_iter_entry_points(group): assert group == "pytest11" - EntryPoint = namedtuple("EntryPoint", ["value"]) - return [EntryPoint(value="pytest_plugin.plugin")] + EntryPoint = namedtuple("EntryPoint", ["module_name"]) + return [EntryPoint("pytest_plugin.plugin")] - monkeypatch.setattr("jedi.plugins.pytest.entry_points", mock_entry_points) + monkeypatch.setattr("pkg_resources.iter_entry_points", mock_iter_entry_points) repo_root = helpers.root_dir monkeypatch.chdir(os.path.join(repo_root, 'jedi')) From c243608ac60d296f2ca29a957d2b0c5ea68f5b9c Mon Sep 17 00:00:00 2001 From: Marcio Mazza Date: Mon, 5 Sep 2022 17:31:14 -0300 Subject: [PATCH 8/8] Add your name to AUTHORS.txt --- AUTHORS.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS.txt b/AUTHORS.txt index b8b10a93..b86030e3 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -1,4 +1,4 @@ -Main Authors +Main Authors ------------ - David Halter (@davidhalter) @@ -62,6 +62,7 @@ Code Contributors - Andrii Kolomoiets (@muffinmad) - Leo Ryu (@Leo-Ryu) - Joseph Birkner (@josephbirkner) +- Márcio Mazza (@marciomazza) And a few more "anonymous" contributors.