55import contextlib
66import functools
77import importlib .util
8+ import itertools
89import os
910import sys
1011from pathlib import Path
@@ -125,11 +126,25 @@ def find_case_sensitive_path(path: Path, platform: str) -> Path:
125126def import_path (path : Path , root : Path ) -> ModuleType :
126127 """Import and return a module from the given path.
127128
128- The function is taken from pytest when the import mode is set to ``importlib``. It
129- pytest's recommended import mode for new projects although the default is set to
130- ``prepend``. More discussion and information can be found in :issue:`373`.
129+ The functions are taken from pytest when the import mode is set to ``importlib``. It
130+ was assumed to be the new default import mode but insurmountable tradeoffs caused
131+ the default to be set to ``prepend``. More discussion and information can be found
132+ in :issue:`373`.
131133
132134 """
135+ try :
136+ pkg_root , module_name = _resolve_pkg_root_and_module_name (path )
137+ except CouldNotResolvePathError :
138+ pass
139+ else :
140+ # If the given module name is already in sys.modules, do not import it again.
141+ with contextlib .suppress (KeyError ):
142+ return sys .modules [module_name ]
143+
144+ mod = _import_module_using_spec (module_name , path , pkg_root )
145+ if mod is not None :
146+ return mod
147+
133148 module_name = _module_name_from_path (path , root )
134149 with contextlib .suppress (KeyError ):
135150 return sys .modules [module_name ]
@@ -147,42 +162,134 @@ def import_path(path: Path, root: Path) -> ModuleType:
147162 return mod
148163
149164
165+ def _resolve_package_path (path : Path ) -> Path | None :
166+ """Resolve package path.
167+
168+ Return the Python package path by looking for the last directory upwards which still
169+ contains an ``__init__.py``.
170+
171+ Returns None if it can not be determined.
172+
173+ """
174+ result = None
175+ for parent in itertools .chain ((path ,), path .parents ):
176+ if parent .is_dir ():
177+ if not (parent / "__init__.py" ).is_file ():
178+ break
179+ if not parent .name .isidentifier ():
180+ break
181+ result = parent
182+ return result
183+
184+
185+ def _resolve_pkg_root_and_module_name (path : Path ) -> tuple [Path , str ]:
186+ """Resolve the root package directory and module name for the given Python file.
187+
188+ Return the path to the directory of the root package that contains the given Python
189+ file, and its module name:
190+
191+ .. code-block:: text
192+
193+ src/
194+ app/
195+ __init__.py core/
196+ __init__.py models.py
197+
198+ Passing the full path to `models.py` will yield Path("src") and "app.core.models".
199+
200+ Raises CouldNotResolvePathError if the given path does not belong to a package
201+ (missing any __init__.py files).
202+
203+ """
204+ pkg_path = _resolve_package_path (path )
205+ if pkg_path is not None :
206+ pkg_root = pkg_path .parent
207+
208+ names = list (path .with_suffix ("" ).relative_to (pkg_root ).parts )
209+ if names [- 1 ] == "__init__" :
210+ names .pop ()
211+ module_name = "." .join (names )
212+ return pkg_root , module_name
213+
214+ msg = f"Could not resolve for { path } "
215+ raise CouldNotResolvePathError (msg )
216+
217+
218+ class CouldNotResolvePathError (Exception ):
219+ """Custom exception raised by _resolve_pkg_root_and_module_name."""
220+
221+
222+ def _import_module_using_spec (
223+ module_name : str , module_path : Path , module_location : Path
224+ ) -> ModuleType | None :
225+ """Import a module using its specification.
226+
227+ Tries to import a module by its canonical name, path to the .py file, and its parent
228+ location.
229+
230+ """
231+ # Checking with sys.meta_path first in case one of its hooks can import this module,
232+ # such as our own assertion-rewrite hook.
233+ for meta_importer in sys .meta_path :
234+ spec = meta_importer .find_spec (module_name , [str (module_location )])
235+ if spec is not None :
236+ break
237+ else :
238+ spec = importlib .util .spec_from_file_location (module_name , str (module_path ))
239+ if spec is not None :
240+ mod = importlib .util .module_from_spec (spec )
241+ sys .modules [module_name ] = mod
242+ spec .loader .exec_module (mod ) # type: ignore[union-attr]
243+ return mod
244+
245+ return None
246+
247+
150248def _module_name_from_path (path : Path , root : Path ) -> str :
151249 """Return a dotted module name based on the given path, anchored on root.
152250
153- For example: path="projects/src/project /task_foo.py" and root="/projects", the
154- resulting module name will be "src.project .task_foo".
251+ For example: path="projects/src/tasks /task_foo.py" and root="/projects", the
252+ resulting module name will be "src.tasks .task_foo".
155253
156254 """
157255 path = path .with_suffix ("" )
158256 try :
159257 relative_path = path .relative_to (root )
160258 except ValueError :
161- # If we can't get a relative path to root, use the full path, except for the
162- # first part ("d:\\" or "/" depending on the platform, for example).
259+ # If we can't get a relative path to root, use the full path, except
260+ # for the first part ("d:\\" or "/" depending on the platform, for example).
163261 path_parts = path .parts [1 :]
164262 else :
165263 # Use the parts for the relative path to the root path.
166264 path_parts = relative_path .parts
167265
168- # Module name for packages do not contain the __init__ file, unless the
169- # `__init__.py` file is at the root.
266+ # Module name for packages do not contain the __init__ file, unless
267+ # the `__init__.py` file is at the root.
170268 if len (path_parts ) >= 2 and path_parts [- 1 ] == "__init__" : # noqa: PLR2004
171269 path_parts = path_parts [:- 1 ]
172270
271+ # Module names cannot contain ".", normalize them to "_". This prevents a directory
272+ # having a "." in the name (".env.310" for example) causing extra intermediate
273+ # modules. Also, important to replace "." at the start of paths, as those are
274+ # considered relative imports.
275+ path_parts = tuple (x .replace ("." , "_" ) for x in path_parts )
276+
173277 return "." .join (path_parts )
174278
175279
176280def _insert_missing_modules (modules : dict [str , ModuleType ], module_name : str ) -> None :
177- """Insert missing modules when importing modules with :func:`import_path` .
281+ """Insert missing modules in sys. modules.
178282
179- When we want to import a module as ``src.project.task_foo `` for example, we need to
180- create empty modules ``src`` and `` src.project`` after inserting
181- `` src.project.task_foo``, otherwise `` src.project.task_foo`` is not importable by
182- ``__import__``.
283+ Used by ``import_path `` to create intermediate modules when using mode=importlib.
284+ When we want to import a module as " src.tasks.task_foo" for example, we need to
285+ create empty modules " src" and " src.tasks" after inserting "src.tasks.task_foo",
286+ otherwise "src.tasks.task_foo" is not importable by ``__import__``.
183287
184288 """
185289 module_parts = module_name .split ("." )
290+ child_module : ModuleType | None = None
291+ module : ModuleType | None = None
292+ child_name : str = ""
186293 while module_name :
187294 if module_name not in modules :
188295 try :
@@ -192,13 +299,20 @@ def _insert_missing_modules(modules: dict[str, ModuleType], module_name: str) ->
192299 # creating a dummy module.
193300 if not sys .meta_path :
194301 raise ModuleNotFoundError # noqa: TRY301
195- importlib .import_module (module_name )
302+ module = importlib .import_module (module_name )
196303 except ModuleNotFoundError :
197304 module = ModuleType (
198305 module_name ,
199- doc = "Empty module created by pytask ." ,
306+ doc = "Empty module created by pytest's importmode=importlib ." ,
200307 )
201- modules [module_name ] = module
308+ else :
309+ module = modules [module_name ]
310+ # Add child attribute to the parent that can reference the child modules.
311+ if child_module and not hasattr (module , child_name ):
312+ setattr (module , child_name , child_module )
313+ modules [module_name ] = module
314+ # Keep track of the child module while moving up the tree.
315+ child_module , child_name = module , module_name .rpartition ("." )[- 1 ]
202316 module_parts .pop (- 1 )
203317 module_name = "." .join (module_parts )
204318
0 commit comments