Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/typestats/index.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import enum
import os
from typing import Final

import anyio
Expand Down Expand Up @@ -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.
Expand All @@ -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)
39 changes: 39 additions & 0 deletions tests/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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