fix(proxy): lazy-import server to avoid fastapi crash (#442)

## 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 <claude-code@smartwatermelon.github>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andrew Rich 2026-06-10 10:44:23 -07:00 committed by GitHub
parent 74392b238e
commit 93c69372e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 102 additions and 11 deletions

View file

@ -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

View file

@ -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}")

View file

@ -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:

View file

@ -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(
"""

View file

@ -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")