Skip to content

Commit ff806b2

Browse files
authored
importlib: set children as attribute of parent modules (#12208)
Now `importlib` mode will correctly set the imported modules as an attribute of their parent modules. As helpfully posted on #12194, that's how the Python import module works so we should follow suit. In addition, we also try to import the parent modules as part of the process of importing a child module, again mirroring how Python importing works. Fix #12194
1 parent ad95d59 commit ff806b2

File tree

3 files changed

+166
-4
lines changed

3 files changed

+166
-4
lines changed

changelog/12194.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed a bug with ``--importmode=importlib`` and ``--doctest-modules`` where child modules did not appear as attributes in parent modules.

src/_pytest/pathlib.py

+32-4
Original file line numberDiff line numberDiff line change
@@ -620,10 +620,6 @@ def _import_module_using_spec(
620620
:param insert_modules:
621621
If True, will call insert_missing_modules to create empty intermediate modules
622622
for made-up module names (when importing test files not reachable from sys.path).
623-
Note: we can probably drop insert_missing_modules altogether: instead of
624-
generating module names such as "src.tests.test_foo", which require intermediate
625-
empty modules, we might just as well generate unique module names like
626-
"src_tests_test_foo".
627623
"""
628624
# Checking with sys.meta_path first in case one of its hooks can import this module,
629625
# such as our own assertion-rewrite hook.
@@ -636,9 +632,41 @@ def _import_module_using_spec(
636632

637633
if spec_matches_module_path(spec, module_path):
638634
assert spec is not None
635+
# Attempt to import the parent module, seems is our responsibility:
636+
# https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311
637+
parent_module_name, _, name = module_name.rpartition(".")
638+
parent_module: Optional[ModuleType] = None
639+
if parent_module_name:
640+
parent_module = sys.modules.get(parent_module_name)
641+
if parent_module is None:
642+
# Find the directory of this module's parent.
643+
parent_dir = (
644+
module_path.parent.parent
645+
if module_path.name == "__init__.py"
646+
else module_path.parent
647+
)
648+
# Consider the parent module path as its __init__.py file, if it has one.
649+
parent_module_path = (
650+
parent_dir / "__init__.py"
651+
if (parent_dir / "__init__.py").is_file()
652+
else parent_dir
653+
)
654+
parent_module = _import_module_using_spec(
655+
parent_module_name,
656+
parent_module_path,
657+
parent_dir,
658+
insert_modules=insert_modules,
659+
)
660+
661+
# Find spec and import this module.
639662
mod = importlib.util.module_from_spec(spec)
640663
sys.modules[module_name] = mod
641664
spec.loader.exec_module(mod) # type: ignore[union-attr]
665+
666+
# Set this module as an attribute of the parent module (#12194).
667+
if parent_module is not None:
668+
setattr(parent_module, name, mod)
669+
642670
if insert_modules:
643671
insert_missing_modules(sys.modules, module_name)
644672
return mod

testing/test_pathlib.py

+133
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,139 @@ def test_safe_exists(tmp_path: Path) -> None:
11261126
assert safe_exists(p) is False
11271127

11281128

1129+
def test_import_sets_module_as_attribute(pytester: Pytester) -> None:
1130+
"""Unittest test for #12194."""
1131+
pytester.path.joinpath("foo/bar/baz").mkdir(parents=True)
1132+
pytester.path.joinpath("foo/__init__.py").touch()
1133+
pytester.path.joinpath("foo/bar/__init__.py").touch()
1134+
pytester.path.joinpath("foo/bar/baz/__init__.py").touch()
1135+
pytester.syspathinsert()
1136+
1137+
# Import foo.bar.baz and ensure parent modules also ended up imported.
1138+
baz = import_path(
1139+
pytester.path.joinpath("foo/bar/baz/__init__.py"),
1140+
mode=ImportMode.importlib,
1141+
root=pytester.path,
1142+
consider_namespace_packages=False,
1143+
)
1144+
assert baz.__name__ == "foo.bar.baz"
1145+
foo = sys.modules["foo"]
1146+
assert foo.__name__ == "foo"
1147+
bar = sys.modules["foo.bar"]
1148+
assert bar.__name__ == "foo.bar"
1149+
1150+
# Check parent modules have an attribute pointing to their children.
1151+
assert bar.baz is baz
1152+
assert foo.bar is bar
1153+
1154+
# Ensure we returned the "foo.bar" module cached in sys.modules.
1155+
bar_2 = import_path(
1156+
pytester.path.joinpath("foo/bar/__init__.py"),
1157+
mode=ImportMode.importlib,
1158+
root=pytester.path,
1159+
consider_namespace_packages=False,
1160+
)
1161+
assert bar_2 is bar
1162+
1163+
1164+
def test_import_sets_module_as_attribute_without_init_files(pytester: Pytester) -> None:
1165+
"""Similar to test_import_sets_module_as_attribute, but without __init__.py files."""
1166+
pytester.path.joinpath("foo/bar").mkdir(parents=True)
1167+
pytester.path.joinpath("foo/bar/baz.py").touch()
1168+
pytester.syspathinsert()
1169+
1170+
# Import foo.bar.baz and ensure parent modules also ended up imported.
1171+
baz = import_path(
1172+
pytester.path.joinpath("foo/bar/baz.py"),
1173+
mode=ImportMode.importlib,
1174+
root=pytester.path,
1175+
consider_namespace_packages=False,
1176+
)
1177+
assert baz.__name__ == "foo.bar.baz"
1178+
foo = sys.modules["foo"]
1179+
assert foo.__name__ == "foo"
1180+
bar = sys.modules["foo.bar"]
1181+
assert bar.__name__ == "foo.bar"
1182+
1183+
# Check parent modules have an attribute pointing to their children.
1184+
assert bar.baz is baz
1185+
assert foo.bar is bar
1186+
1187+
# Ensure we returned the "foo.bar.baz" module cached in sys.modules.
1188+
baz_2 = import_path(
1189+
pytester.path.joinpath("foo/bar/baz.py"),
1190+
mode=ImportMode.importlib,
1191+
root=pytester.path,
1192+
consider_namespace_packages=False,
1193+
)
1194+
assert baz_2 is baz
1195+
1196+
1197+
def test_import_sets_module_as_attribute_regression(pytester: Pytester) -> None:
1198+
"""Regression test for #12194."""
1199+
pytester.path.joinpath("foo/bar/baz").mkdir(parents=True)
1200+
pytester.path.joinpath("foo/__init__.py").touch()
1201+
pytester.path.joinpath("foo/bar/__init__.py").touch()
1202+
pytester.path.joinpath("foo/bar/baz/__init__.py").touch()
1203+
f = pytester.makepyfile(
1204+
"""
1205+
import foo
1206+
from foo.bar import baz
1207+
foo.bar.baz
1208+
1209+
def test_foo() -> None:
1210+
pass
1211+
"""
1212+
)
1213+
1214+
pytester.syspathinsert()
1215+
result = pytester.runpython(f)
1216+
assert result.ret == 0
1217+
1218+
result = pytester.runpytest("--import-mode=importlib", "--doctest-modules")
1219+
assert result.ret == 0
1220+
1221+
1222+
def test_import_submodule_not_namespace(pytester: Pytester) -> None:
1223+
"""
1224+
Regression test for importing a submodule 'foo.bar' while there is a 'bar' directory
1225+
reachable from sys.path -- ensuring the top-level module does not end up imported as a namespace
1226+
package.
1227+
1228+
#12194
1229+
https://github.com/pytest-dev/pytest/pull/12208#issuecomment-2056458432
1230+
"""
1231+
pytester.syspathinsert()
1232+
# Create package 'foo' with a submodule 'bar'.
1233+
pytester.path.joinpath("foo").mkdir()
1234+
foo_path = pytester.path.joinpath("foo/__init__.py")
1235+
foo_path.touch()
1236+
bar_path = pytester.path.joinpath("foo/bar.py")
1237+
bar_path.touch()
1238+
# Create top-level directory in `sys.path` with the same name as that submodule.
1239+
pytester.path.joinpath("bar").mkdir()
1240+
1241+
# Import `foo`, then `foo.bar`, and check they were imported from the correct location.
1242+
foo = import_path(
1243+
foo_path,
1244+
mode=ImportMode.importlib,
1245+
root=pytester.path,
1246+
consider_namespace_packages=False,
1247+
)
1248+
bar = import_path(
1249+
bar_path,
1250+
mode=ImportMode.importlib,
1251+
root=pytester.path,
1252+
consider_namespace_packages=False,
1253+
)
1254+
assert foo.__name__ == "foo"
1255+
assert bar.__name__ == "foo.bar"
1256+
assert foo.__file__ is not None
1257+
assert bar.__file__ is not None
1258+
assert Path(foo.__file__) == foo_path
1259+
assert Path(bar.__file__) == bar_path
1260+
1261+
11291262
class TestNamespacePackages:
11301263
"""Test import_path support when importing from properly namespace packages."""
11311264

0 commit comments

Comments
 (0)