diff --git a/campus/__init__.py b/campus/__init__.py deleted file mode 100644 index d48fab68..00000000 --- a/campus/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""campus - -Campus namespace package - contains all Campus ecosystem modules. - -This package serves as a unified namespace for all Campus services: -- campus.auth: Authentication and authorization service -- campus.api: Main API endpoints -- campus.audit: Audit and tracing service -- campus.common: Shared utilities and schemas -- campus.model: Data models -- campus.storage: Storage abstraction layer - -Note: This is a namespace package using pkgutil.extend_path(). -Submodules are not imported here to avoid circular dependencies. -They are discovered dynamically at runtime and should be explicitly -imported when needed (e.g., "from campus.auth import resources"). -""" - -# Namespace package marker - allows extending with campus-python package -__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/campus/yapper/backends/__init__.py b/campus/yapper/backends/__init__.py new file mode 100644 index 00000000..cb6da04e --- /dev/null +++ b/campus/yapper/backends/__init__.py @@ -0,0 +1,4 @@ +"""campus.yapper.backends + +Storage backends for yapper event databases (SQLite and PostgreSQL). +""" diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 3edf78b8..1d8c0175 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -46,6 +46,38 @@ yapper → (nothing — self-contained) database URI arrives via argument, provider callable, or environment ([yapper.md](yapper.md)) +## Namespace Package Rules (PEP 420) + +The `campus` import namespace is a **native namespace package**: no +distribution ships a `campus/__init__.py`. Python merges every `campus/` +directory found on `sys.path` into one virtual package, so each +distribution owns its own subtree and consumers import one flat namespace: + +- `campus-suite` ships `campus/auth/`, `campus/api/`, `campus/model/`, ... +- `campus-common` (planned, #970 phase 3) will ship `campus/common/` + +Rules: + +- Never add `campus/__init__.py` to any distribution. One stale regular + package shadows every other dist's portions **silently** — imports + resolve from the wrong place with no error at install time. +- Each distribution's own subpackages (`campus/common/`, `campus/auth/`, + ...) remain regular packages with their own `__init__.py`; only the + shared ancestor level is init-less. +- After `poetry build`, audit the wheels: + + ```bash + python scripts/audit_wheel_namespace.py + ``` + +- When upgrading environments across this conversion, rebuild venvs + instead of layering installs: leftover `campus/` directories or + `__pycache__`-only portion dirs from older installs keep serving stale + imports under the merged namespace. + +The `campus_python` client library uses a separate top-level namespace and +is unaffected by these rules. + ## Installation ### For Production Use diff --git a/scripts/audit_wheel_namespace.py b/scripts/audit_wheel_namespace.py new file mode 100644 index 00000000..84abef4e --- /dev/null +++ b/scripts/audit_wheel_namespace.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Audit built wheels: verify they ship init-less PEP 420 namespace portions. + +`campus` is a native namespace package (PEP 420): every distribution that +contributes subpackages under `campus/` MUST NOT ship `campus/__init__.py`. +A single stale init file silently shadows the namespace - whichever dist +ships it wins, and every other dist's submodules stop importing, with no +error at install time. This audit turns that failure mode into a build-time +exit code. + +Checks per wheel: +1. No `campus/__init__.py` (the shared ancestor level must stay init-less). +2. Every subdirectory of `campus/` containing .py files ships an + `__init__.py` (each dist's own subpackages stay regular packages). +3. The wheel ships something under `campus/` at all (catches a `packages` + include that drifted empty). + +Usage: + python scripts/audit_wheel_namespace.py # audit dist/*.whl + python scripts/audit_wheel_namespace.py dist/foo.whl # audit specific wheels + +Exit code 0 when all wheels pass; 1 on any violation or missing wheels. +""" + +import sys +import zipfile +from pathlib import Path + +NAMESPACE = "campus" + + +def audit_wheel(wheel: Path) -> list[str]: + """Return a list of namespace violations for one built wheel.""" + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + + problems: list[str] = [] + + top_init = f"{NAMESPACE}/__init__.py" + if top_init in names: + problems.append(f"{wheel.name}: ships {top_init} - a regular package here shadows all other dists' portions") + + # Subpackage dirs shipped by this dist must remain regular packages; + # only the shared ancestor level is allowed to be init-less. + py_dirs = {n.rsplit("/", 1)[0] for n in names if n.endswith(".py") and "/" in n} + shipped = {d for d in py_dirs if d.startswith(f"{NAMESPACE}/") and d != NAMESPACE} + for directory in sorted(shipped): + if f"{directory}/__init__.py" not in names: + problems.append(f"{wheel.name}: {directory}/ ships .py files without __init__.py") + + if not shipped and not any(n.startswith(f"{NAMESPACE}/") for n in names): + problems.append(f"{wheel.name}: ships nothing under {NAMESPACE}/ - packages include is misconfigured") + + return problems + + +def main(argv: list[str]) -> int: + wheels = [Path(a) for a in argv[1:]] or sorted(Path("dist").glob("*.whl")) + if not wheels: + print("no wheels found: run `poetry build` first (or pass paths explicitly)", file=sys.stderr) + return 1 + + failures = 0 + for wheel in wheels: + problems = audit_wheel(wheel) + if problems: + failures += len(problems) + for problem in problems: + print(f"FAIL {problem}") + else: + print(f"OK {wheel.name}: init-less {NAMESPACE}/ portion, subpackages intact") + + if failures: + print(f"\n{failures} namespace violation(s) across {len(wheels)} wheel(s)", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))