From 93c69372e614f2b04873bed75602a88d2256a7fc Mon Sep 17 00:00:00 2001 From: Andrew Rich <676392+smartwatermelon@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:44:23 -0700 Subject: [PATCH] fix(proxy): lazy-import server to avoid fastapi crash (#442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Lazy-import `create_app`/`run_server` in `headroom/proxy/__init__.py` via PEP 562 `__getattr__` to prevent CLI crash when `fastapi` is not installed (i.e., installed without `[proxy]` extras) - Fix `.pre-commit-config.yaml` to use `python3` instead of `python` (unavailable on macOS Homebrew) - Add graceful `ImportError` skip in `scripts/sync-plugin-versions.py` for environments without dev dependencies Fixes #441 ## Test plan - [x] `headroom --help` works without `[proxy]` extras installed - [x] `headroom proxy --help` works with `[proxy]` extras installed - [x] `headroom proxy --port 18787` starts and serves traffic - [x] Lazy imports resolve correctly: `from headroom.proxy import create_app, run_server` - [x] `AttributeError` raised for invalid attributes on `headroom.proxy` - [x] Pre-commit hooks pass (ruff, ruff-format, mypy, sync-plugin-versions) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Code Bot Co-authored-by: Claude Opus 4.6 --- .pre-commit-config.yaml | 2 +- headroom/proxy/__init__.py | 12 +++++-- scripts/sync-plugin-versions.py | 20 +++++++----- tests/test_package_init_lazy.py | 25 +++++++++++++++ tests/test_proxy_package_init.py | 54 ++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 tests/test_proxy_package_init.py 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")