From 595135159f304cd84709096e6511d42e0c0ca4ba Mon Sep 17 00:00:00 2001 From: jorenham Date: Mon, 22 Jun 2026 11:02:47 +0200 Subject: [PATCH] fix `py.typed` discovery for mutli-package projects --- src/typestats/index.py | 34 +++++++++++++++++++++++++++++----- tests/test_index.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/typestats/index.py b/src/typestats/index.py index 1a7ed0df..f086b165 100644 --- a/src/typestats/index.py +++ b/src/typestats/index.py @@ -1,5 +1,4 @@ import enum -import os from typing import Final import anyio @@ -39,14 +38,25 @@ def sort_key(self) -> int: EXCLUDED_FILE_NAMES: Final[frozenset[str]] = frozenset({"conftest.py", "setup.py"}) -async def get_py_typed(sources: StrPaths, /) -> PyTyped: - """Determine the `py.typed` status from a list of source paths.""" - assert sources +async def _is_package_dir(path: anyio.Path, /) -> bool: + """Whether *path* is an importable regular package (has an `__init__`).""" + return ( + await (path / "__init__.py").exists() or await (path / "__init__.pyi").exists() + ) + - root = anyio.Path(os.path.commonpath(sources)) +async def _package_root(source: str, /) -> anyio.Path: + """Top-level package dir of *source*, e.g. `_pytest/_code/x.py` -> `_pytest`.""" + root = anyio.Path(source) if await root.is_file(): root = root.parent + while await _is_package_dir(root.parent): + root = root.parent + return root + +async def _py_typed_for_root(root: anyio.Path, /) -> PyTyped: + """Determine the `py.typed` status of a single top-level package directory.""" py_typed = root / "py.typed" if not await py_typed.exists(): # PEP 561: stub-only packages use *-stubs directory naming. @@ -57,3 +67,17 @@ async def get_py_typed(sources: StrPaths, /) -> PyTyped: return PyTyped.PARTIAL return PyTyped.YES + + +async def get_py_typed(sources: StrPaths, /) -> PyTyped: + """Determine the `py.typed` status from a list of source paths. + + A distribution may ship several top-level packages (e.g. `pytest` ships both + `pytest` and `_pytest`); each is inspected independently and the most-typed + status wins, so an untyped private package can't mask a typed public one. + """ + assert sources + + roots = {str(await _package_root(str(s))) for s in sources} + statuses = [await _py_typed_for_root(anyio.Path(r)) for r in roots] + return min(statuses, key=PyTyped.sort_key) diff --git a/tests/test_index.py b/tests/test_index.py index 84187331..f8eced9a 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -34,3 +34,42 @@ async def test_partial(self, tmp_path: Path) -> None: (pkg / "__init__.py").write_text("") (pkg / "py.typed").write_text("partial\n") assert await get_py_typed([pkg / "__init__.py"]) == PyTyped.PARTIAL + + async def test_nested_module_resolves_to_top_level(self, tmp_path: Path) -> None: + pkg = tmp_path / "mypkg" + sub = pkg / "sub" + sub.mkdir(parents=True) + (pkg / "__init__.py").write_text("") + (pkg / "py.typed").write_text("") + (sub / "__init__.py").write_text("") + (sub / "mod.py").write_text("") + assert await get_py_typed([sub / "mod.py"]) == PyTyped.YES + + async def test_multiple_top_level_packages(self, tmp_path: Path) -> None: + # gh-415: pytest ships both `pytest` and `_pytest`, each with py.typed. + for name in ("pkg", "_pkg"): + pkg = tmp_path / name + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "py.typed").write_text("") + sources = [ + tmp_path / "pkg" / "__init__.py", + tmp_path / "_pkg" / "__init__.py", + ] + assert await get_py_typed(sources) == PyTyped.YES + + async def test_private_package_without_marker_does_not_mask( + self, tmp_path: Path + ) -> None: + typed = tmp_path / "pkg" + typed.mkdir() + (typed / "__init__.py").write_text("") + (typed / "py.typed").write_text("") + private = tmp_path / "_pkg" + private.mkdir() + (private / "__init__.py").write_text("") + sources = [ + typed / "__init__.py", + private / "__init__.py", + ] + assert await get_py_typed(sources) == PyTyped.YES