diff --git a/AGENTS.md b/AGENTS.md index a8c10695..df06b1ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -`that-depends` is a zero-dependency, typed dependency-injection framework for Python +`that-depends` is a typed dependency-injection framework for Python 3.10+. It is the org's most-used package and the only repo here with real external contributor traffic: most merged PRs come from someone other than the maintainer. Assume a human reviews your diff. diff --git a/README.md b/README.md index 72d99bc3..886eda17 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ It is production-ready and gives you the following: - Dependency context management with scopes. - Overriding dependencies for tests. - Injecting dependencies in functions and coroutines without wiring. -- Package with zero dependencies. ### Installation diff --git a/docs/dev/main-decisions.md b/docs/dev/main-decisions.md index c0549975..cc387c95 100644 --- a/docs/dev/main-decisions.md +++ b/docs/dev/main-decisions.md @@ -8,3 +8,6 @@ 3. Focus on maximum compatibility with mypy: - no need for `# type: ignore` - no need for `typing.cast` +4. The only runtime dependency is `typing-extensions`: + - it backports `override`, `Self`, `TypeIs` and `TypeVar` defaults to Python 3.10; + - integrations declare their own imports in extras, and `tests/test_packaging.py` checks both. diff --git a/pyproject.toml b/pyproject.toml index a37d3df9..7f9dd40c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,17 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] version = "0" +dependencies = [ + "typing-extensions>=4.14.1", +] [project.optional-dependencies] fastapi = [ "fastapi>=0.115.3", ] faststream = [ - "faststream>=0.3.14" + "faststream>=0.5.19", + "packaging", ] [project.urls] @@ -45,11 +49,11 @@ dev = [ "pytest-repeat", "ruff", "mypy", - "typing-extensions", "pre-commit", "litestar", "faststream[nats]", "pytest-randomly", + "tomli", "pyrefly>=0.61.1", ] diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..ce521263 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,52 @@ +import ast +import pathlib +import re +import sys + +import that_depends + + +if sys.version_info >= (3, 11): # pragma: no cover + import tomllib +else: # pragma: no cover + import tomli as tomllib + +_PACKAGE_DIR = pathlib.Path(that_depends.__file__).parent +_INTEGRATIONS_DIR = _PACKAGE_DIR / "integrations" +_PROJECT = tomllib.loads((_PACKAGE_DIR.parent / "pyproject.toml").read_text())["project"] + + +def _top_level_imports(path: pathlib.Path) -> set[str]: + roots: set[str] = set() + for node in ast.parse(path.read_text()).body: + if isinstance(node, ast.Import): + roots.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + roots.add(node.module.split(".")[0]) + return roots + + +def _normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _third_party_imports(paths: list[pathlib.Path]) -> set[str]: + imported = set().union(*(_top_level_imports(path) for path in paths)) + return {_normalize(name) for name in imported if name not in sys.stdlib_module_names and name != "that_depends"} + + +def _declared(*, include_extras: bool) -> set[str]: + requirements = list(_PROJECT.get("dependencies", [])) + if include_extras: + requirements += [req for extra in _PROJECT.get("optional-dependencies", {}).values() for req in extra] + return {_normalize(re.split(r"[\s;<>=!~\[]", requirement, maxsplit=1)[0]) for requirement in requirements} + + +def test_core_runtime_imports_are_unconditional_dependencies() -> None: + core = [path for path in _PACKAGE_DIR.rglob("*.py") if _INTEGRATIONS_DIR not in path.parents] + assert _third_party_imports(core) <= _declared(include_extras=False) + + +def test_integration_runtime_imports_are_declared_dependencies() -> None: + integrations = list(_INTEGRATIONS_DIR.rglob("*.py")) + assert _third_party_imports(integrations) <= _declared(include_extras=True)