diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ecbcd2b1d..867691d9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: hooks: - id: sync-plugin-versions name: Sync plugin versions - entry: python scripts/sync-plugin-versions.py + entry: python3 scripts/sync-plugin-versions.py language: system pass_filenames: false always_run: true diff --git a/headroom/proxy/__init__.py b/headroom/proxy/__init__.py index 3d1c39745..49c424dc7 100644 --- a/headroom/proxy/__init__.py +++ b/headroom/proxy/__init__.py @@ -14,6 +14,14 @@ Usage: Set base URL in Cursor settings to http://localhost:8787 """ -from .server import create_app, run_server - __all__ = ["create_app", "run_server"] + + +def __getattr__(name: str) -> object: + if name in ("create_app", "run_server"): + from .server import create_app, run_server # noqa: F811 + + globals()["create_app"] = create_app + globals()["run_server"] = run_server + return globals()[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/scripts/sync-plugin-versions.py b/scripts/sync-plugin-versions.py index 347c61830..8870680c0 100644 --- a/scripts/sync-plugin-versions.py +++ b/scripts/sync-plugin-versions.py @@ -25,14 +25,18 @@ ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from headroom.release_version import ( # noqa: E402 - compute_release_version, - determine_bump_level, - find_latest_release_tag, - get_canonical_version, - list_release_commits, - list_release_tags, -) +try: + from headroom.release_version import ( # noqa: E402 + compute_release_version, + determine_bump_level, + find_latest_release_tag, + get_canonical_version, + list_release_commits, + list_release_tags, + ) +except ImportError: + print("skip: headroom deps not installed (run from a dev venv to enable)") + sys.exit(0) def compute_repo_semver(root: Path) -> str: diff --git a/tests/test_package_init_lazy.py b/tests/test_package_init_lazy.py index 72445f908..be56fee46 100644 --- a/tests/test_package_init_lazy.py +++ b/tests/test_package_init_lazy.py @@ -74,6 +74,31 @@ def test_version_prefers_source_tree_release_history() -> None: package_version.assert_not_called() +def test_proxy_package_import_does_not_eagerly_load_server() -> None: + script = textwrap.dedent( + """ + import json + import sys + + import headroom.proxy + + print(json.dumps({ + "server_loaded": "headroom.proxy.server" in sys.modules, + })) + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + ) + + data = json.loads(result.stdout.strip()) + assert data["server_loaded"] is False + + def test_proxy_server_import_skips_litellm_backend() -> None: script = textwrap.dedent( """ diff --git a/tests/test_proxy_package_init.py b/tests/test_proxy_package_init.py new file mode 100644 index 000000000..575828af6 --- /dev/null +++ b/tests/test_proxy_package_init.py @@ -0,0 +1,54 @@ +"""Unit tests for headroom.proxy lazy __getattr__ (PEP 562).""" + +from __future__ import annotations + +import importlib +import sys +import types + +import pytest + + +def test_proxy_getattr_resolves_create_app_and_caches_it(monkeypatch) -> None: + sentinel = object() + fake_server = types.SimpleNamespace(create_app=sentinel, run_server=object()) + monkeypatch.setitem(sys.modules, "headroom.proxy.server", fake_server) + + import headroom.proxy as proxy + + module = importlib.reload(proxy) + module.__dict__.pop("create_app", None) + module.__dict__.pop("run_server", None) + # Register teardown: monkeypatch notes these keys are absent now and will + # remove them after the test, preventing sentinel leakage to later tests. + monkeypatch.delitem(module.__dict__, "create_app", raising=False) + monkeypatch.delitem(module.__dict__, "run_server", raising=False) + + result = module.__getattr__("create_app") + assert result is sentinel + assert module.__dict__["create_app"] is sentinel + + +def test_proxy_getattr_resolves_run_server(monkeypatch) -> None: + sentinel = object() + fake_server = types.SimpleNamespace(create_app=object(), run_server=sentinel) + monkeypatch.setitem(sys.modules, "headroom.proxy.server", fake_server) + + import headroom.proxy as proxy + + module = importlib.reload(proxy) + module.__dict__.pop("create_app", None) + module.__dict__.pop("run_server", None) + monkeypatch.delitem(module.__dict__, "create_app", raising=False) + monkeypatch.delitem(module.__dict__, "run_server", raising=False) + + result = module.__getattr__("run_server") + assert result is sentinel + assert module.__dict__["run_server"] is sentinel + + +def test_proxy_getattr_raises_for_unknown_attribute() -> None: + import headroom.proxy as proxy + + with pytest.raises(AttributeError, match="has no attribute 'nonexistent'"): + proxy.__getattr__("nonexistent")