diff --git a/tests/test_gated_reason.py b/tests/test_gated_reason.py index 94acd73..fb179d5 100644 --- a/tests/test_gated_reason.py +++ b/tests/test_gated_reason.py @@ -282,24 +282,32 @@ def _first_party_imports(path: Path) -> set[str]: """Every `backend.app.*` module this file imports, as a dotted path relative to `backend/app` — absolute and relative forms both.""" tree = ast.parse(path.read_text()) - here = path.relative_to(_APP).with_suffix("").parts - if here and here[-1] == "__init__": - # A package's `__init__` IS the package, so `from .x import y` inside it - # resolves one level shallower than the file path suggests. Getting this - # wrong silently under-resolves every relative import in every package - # and would make the guard below unable to fail. - here = here[:-1] + # The dotted parts of the package CONTAINING this module. `parts[:-1]` is + # right for both forms without a special case: `services/foo.py` drops + # `foo` to leave `services`, and `api/__init__.py` drops `__init__` to + # leave `api` — which is exactly what `from .` means inside each. + pkg = path.relative_to(_APP).with_suffix("").parts[:-1] out: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): if node.level: - # `from ..models import X` -> walk up from this module's package - base = list(here[: len(here) - node.level]) - mod = base + (node.module.split(".") if node.module else []) + # `from ..models import X` -> walk up from the containing + # package. Clamped at 0: a level that climbs past `backend.app` + # leaves this tree, and an unclamped negative index would wrap + # and silently resolve to the wrong module. + up = max(len(pkg) - (node.level - 1), 0) + mod = list(pkg[:up]) + (node.module.split(".") if node.module else []) elif node.module and node.module.startswith("backend.app."): mod = node.module[len("backend.app."):].split(".") else: continue + if not mod: + # `from . import x` in a module sitting directly under + # `backend/app` (celery_app.py does this): the package is the + # app root, so the alias alone is the module's dotted name. + for alias in node.names: + out.add(alias.name) + continue out.add(".".join(mod)) # `from .membership_roster import x` and # `from . import membership_roster` must both resolve to the module. @@ -320,8 +328,11 @@ def _reachable_from(roots: list[str]) -> set[str]: if mod in seen: continue seen.add(mod) - for candidate in (_APP / Path(*mod.split(".")) / "__init__.py", - _APP / Path(*mod.split(".")).with_suffix(".py")): + parts = [p for p in mod.split(".") if p] + if not parts: + continue + for candidate in (_APP.joinpath(*parts) / "__init__.py", + _APP.joinpath(*parts).with_suffix(".py")): if candidate.is_file(): queue.extend(_first_party_imports(candidate) - seen) break