From 36a3191014d0c40b1291301a795f6c37ba4d8363 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 25 Dec 2025 20:50:22 +0000 Subject: [PATCH] fix(python): guard package init imports --- src/_pytest/python.py | 29 +++++++++++++-------- testing/python/collect.py | 52 ++++++++++++++++++++++++++++++++++++++ testing/test_collection.py | 4 +-- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 306e5f217ce..88aec1a7ca3 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -568,19 +568,23 @@ def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None): self.fspath = fspath def setup(self): + if not self._should_import_init(): + return + # not using fixtures to call setup_module here because autouse fixtures # from packages are not called automatically (#4085) + package_obj = self.obj setup_module = _get_first_non_fixture_func( - self.obj, ("setUpModule", "setup_module") + package_obj, ("setUpModule", "setup_module") ) if setup_module is not None: - _call_with_optional_argument(setup_module, self.obj) + _call_with_optional_argument(setup_module, package_obj) teardown_module = _get_first_non_fixture_func( - self.obj, ("tearDownModule", "teardown_module") + package_obj, ("tearDownModule", "teardown_module") ) if teardown_module is not None: - func = partial(_call_with_optional_argument, teardown_module, self.obj) + func = partial(_call_with_optional_argument, teardown_module, package_obj) self.addfinalizer(func) def _recurse(self, dirpath): @@ -639,13 +643,9 @@ def isinitpath(self, path): return path in self.session._initialpaths def collect(self): - self._mount_obj_if_needed() this_path = self.fspath.dirpath() - init_module = this_path.join("__init__.py") - if init_module.check(file=1) and path_matches_patterns( - init_module, self.config.getini("python_files") - ): - yield Module(init_module, self) + if self._should_import_init(): + yield Module(self.fspath, self) pkg_prefixes = set() for path in this_path.visit(rec=self._recurse, bf=True, sort=True): # We will visit our own __init__.py file, in which case we skip it. @@ -669,6 +669,15 @@ def collect(self): elif path.join("__init__.py").check(file=1): pkg_prefixes.add(path) + def _should_import_init(self): + init_module = self.fspath + if not init_module.check(file=1): + return False + if self.isinitpath(init_module): + return True + python_files = self.config.getini("python_files") + return path_matches_patterns(init_module, python_files) + def _call_with_optional_argument(func, arg): """Call the given function with the given argument if func accepts one argument, otherwise diff --git a/testing/python/collect.py b/testing/python/collect.py index f0c12df1672..80cbecb1cc7 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1336,3 +1336,55 @@ def test_package_ordering(testdir): # Execute from . result = testdir.runpytest("-v", "-s") result.assert_outcomes(passed=3) + + +def test_package_init_not_imported_when_not_a_test_module(testdir): + testdir.makepyfile( + **{ + "pkg/__init__.py": 'assert False, "should not be imported"\n', + "tests/test_mod.py": "def test_ok():\n pass\n", + } + ) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) + assert "should not be imported" not in result.stdout.str() + + +def test_package_init_collected_when_enabled_via_python_files(testdir): + testdir.makeini( + """ + [pytest] + python_files = *.py + """ + ) + testdir.makepyfile( + **{ + "pkg/__init__.py": "def test_from_init():\n assert True\n", + } + ) + + result = testdir.runpytest("-k", "test_from_init") + result.assert_outcomes(passed=1) + + +def test_src_layout_package_init_not_imported(testdir): + testdir.makepyfile( + **{ + "src/pkg/__init__.py": 'assert False, "should not be imported"\n', + "tests/test_ok.py": "def test_ok():\n pass\n", + } + ) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) + assert "should not be imported" not in result.stdout.str() + + +def test_namespace_package_without_init_collects_normally(testdir): + testdir.makepyfile( + **{"ns_pkg/test_mod.py": "def test_namespace():\n pass\n"} + ) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) diff --git a/testing/test_collection.py b/testing/test_collection.py index dee07d5c715..fe533556069 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1187,7 +1187,7 @@ def test_collect_pkg_init_and_file_in_args(testdir): result = testdir.runpytest("-v", str(init), str(p)) result.stdout.fnmatch_lines( [ - "sub/test_file.py::test_file PASSED*", + "sub/__init__.py::test_init PASSED*", "sub/test_file.py::test_file PASSED*", "*2 passed in*", ] @@ -1209,7 +1209,7 @@ def test_collect_pkg_init_only(testdir): init.write("def test_init(): pass") result = testdir.runpytest(str(init)) - result.stdout.fnmatch_lines(["*no tests ran in*"]) + result.stdout.fnmatch_lines(["*1 passed in*"]) result = testdir.runpytest("-v", "-o", "python_files=*.py", str(init)) result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"])