44
55import contextlib
66import functools
7+ import importlib .machinery
78import importlib .util
89import itertools
910import os
@@ -200,19 +201,31 @@ def _resolve_pkg_root_and_module_name(path: Path) -> tuple[Path, str]:
200201
201202 Passing the full path to `models.py` will yield Path("src") and "app.core.models".
202203
204+ This function also handles namespace packages (directories without __init__.py)
205+ by walking up the directory tree and checking if Python's import system can
206+ resolve the computed module name to the given path. This prevents double-imports
207+ when task files import each other using Python's standard import mechanism.
208+
203209 Raises CouldNotResolvePathError if the given path does not belong to a package
204- (missing any __init__.py files).
210+ (missing any __init__.py files) and no valid namespace package root is found .
205211
206212 """
213+ # First, try to find a regular package (with __init__.py files).
207214 pkg_path = _resolve_package_path (path )
208215 if pkg_path is not None :
209216 pkg_root = pkg_path .parent
210-
211- names = list (path .with_suffix ("" ).relative_to (pkg_root ).parts )
212- if names [- 1 ] == "__init__" :
213- names .pop ()
214- module_name = "." .join (names )
215- return pkg_root , module_name
217+ module_name = _compute_module_name (pkg_root , path )
218+ if module_name :
219+ return pkg_root , module_name
220+
221+ # No regular package found. Check for namespace packages by walking up the
222+ # directory tree and verifying that Python's import system would resolve
223+ # the computed module name to this file.
224+ for candidate in (path .parent , * path .parent .parents ):
225+ module_name = _compute_module_name (candidate , path )
226+ if module_name and _is_importable (module_name , path ):
227+ # Found a root where Python's import system agrees with our module name.
228+ return candidate , module_name
216229
217230 msg = f"Could not resolve for { path } "
218231 raise CouldNotResolvePathError (msg )
@@ -222,6 +235,81 @@ class CouldNotResolvePathError(Exception):
222235 """Custom exception raised by _resolve_pkg_root_and_module_name."""
223236
224237
238+ def _spec_matches_module_path (
239+ module_spec : importlib .machinery .ModuleSpec | None , module_path : Path
240+ ) -> bool :
241+ """Return true if the given ModuleSpec can be used to import the given module path.
242+
243+ Handles both regular modules (via origin) and namespace packages
244+ (via submodule_search_locations).
245+
246+ """
247+ if module_spec is None :
248+ return False
249+
250+ if module_spec .origin :
251+ return Path (module_spec .origin ) == module_path
252+
253+ # For namespace packages, check submodule_search_locations.
254+ # https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations
255+ if module_spec .submodule_search_locations :
256+ for location in module_spec .submodule_search_locations :
257+ if Path (location ) == module_path :
258+ return True
259+
260+ return False
261+
262+
263+ def _is_importable (module_name : str , module_path : Path ) -> bool :
264+ """Check if a module name would resolve to the given path using Python's import.
265+
266+ This verifies that importing `module_name` via Python's standard import mechanism
267+ (as if typed in the REPL) would load the file at `module_path`.
268+
269+ Note: find_spec() has a side effect of creating parent namespace packages in
270+ sys.modules. We clean these up to avoid polluting the module namespace.
271+ """
272+ # Track modules before the call to clean up side effects
273+ modules_before = set (sys .modules .keys ())
274+
275+ try :
276+ spec = importlib .util .find_spec (module_name )
277+ except (ImportError , ValueError , ImportWarning ):
278+ return False
279+ finally :
280+ # Clean up any modules that were added as side effects.
281+ # find_spec() can create parent namespace packages in sys.modules.
282+ modules_added = set (sys .modules .keys ()) - modules_before
283+ for mod_name in modules_added :
284+ sys .modules .pop (mod_name , None )
285+
286+ return _spec_matches_module_path (spec , module_path )
287+
288+
289+ def _compute_module_name (root : Path , module_path : Path ) -> str | None :
290+ """Compute a module name based on a path and a root anchor.
291+
292+ Returns None if the module name cannot be computed.
293+
294+ """
295+ try :
296+ path_without_suffix = module_path .with_suffix ("" )
297+ except ValueError :
298+ return None
299+
300+ try :
301+ relative = path_without_suffix .relative_to (root )
302+ except ValueError :
303+ return None
304+
305+ names = list (relative .parts )
306+ if not names :
307+ return None
308+ if names [- 1 ] == "__init__" :
309+ names .pop ()
310+ return "." .join (names ) if names else None
311+
312+
225313def _import_module_using_spec (
226314 module_name : str , module_path : Path , module_location : Path
227315) -> ModuleType | None :
@@ -234,7 +322,11 @@ def _import_module_using_spec(
234322 # Checking with sys.meta_path first in case one of its hooks can import this module,
235323 # such as our own assertion-rewrite hook.
236324 for meta_importer in sys .meta_path :
237- spec = meta_importer .find_spec (module_name , [str (module_location )])
325+ try :
326+ spec = meta_importer .find_spec (module_name , [str (module_location )])
327+ except (ImportError , KeyError , ValueError ):
328+ # Some meta_path finders raise exceptions when parent modules don't exist.
329+ continue
238330 if spec is not None :
239331 break
240332 else :
@@ -243,6 +335,7 @@ def _import_module_using_spec(
243335 mod = importlib .util .module_from_spec (spec )
244336 sys .modules [module_name ] = mod
245337 spec .loader .exec_module (mod ) # type: ignore[union-attr]
338+ _insert_missing_modules (sys .modules , module_name )
246339 return mod
247340
248341 return None
0 commit comments