fix(proxy): allow request_scope import without fastapi (#2562)

## Description

Base installs without the `proxy` extra crash during CLI command
registration because `headroom.proxy.request_scope` imported FastAPI at
module import time. That import is only needed for typing on
`normalize_request_path`.

This change keeps the FastAPI `Request` import under `TYPE_CHECKING` so
the CLI path used by `headroom --help` no longer requires FastAPI.

Closes #2561

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Performance improvement
- [ ] Test update
- [ ] Build/CI change
- [ ] Other (please describe):

## Changes Made

- Make the FastAPI `Request` import type-checking only in
`headroom/proxy/request_scope.py`
- Add a subprocess regression test that imports `request_scope` and
`project_context` with FastAPI blocked and verifies
`normalize_scope_path`

## Testing

### Test commands run

```bash
PYTHONPATH=. python3 -m pytest tests/test_proxy_request_scope.py tests/test_request_scope_no_fastapi.py -q
ruff format --check headroom/proxy/request_scope.py tests/test_request_scope_no_fastapi.py
ruff check headroom/proxy/request_scope.py tests/test_request_scope_no_fastapi.py
```

### Test Output

```text
========================= 5 passed, 1 warning in 0.93s =========================
2 files already formatted
All checks passed!
```

## Real Behavior Proof

### Environment

- Linux x86_64, Python 3.11.15
- Shallow sparse checkout of headroom main at commit parent of this PR
- System/Hermes venv Python with pytest and ruff available

### Exact command

```bash
PYTHONPATH=. python3 - <<'PY'
import builtins, sys
real = builtins.__import__
def imp(name, *a, **k):
    if name == "fastapi" or name.startswith("fastapi."):
        raise ModuleNotFoundError("No module named 'fastapi'")
    return real(name, *a, **k)
builtins.__import__ = imp
import headroom.proxy.request_scope as rs
import headroom.proxy.project_context as pc
rs.normalize_scope_path({"path": "/a"}, "/b")
print("ok", "fastapi" not in sys.modules, hasattr(pc, "with_project_prefix"))
PY
```

### Observed result

```text
ok True True
```

Importing the request-scope helpers no longer requires FastAPI, and
scope path normalization still works.

### Not tested

- Full base `pip install headroom-ai` (no extras) end-to-end on a clean
venv without the monorepo source tree
- Full monorepo `make ci-precheck` / cargo workspace
- Live proxy traffic or FastAPI request path behavior beyond the
existing unit test for `normalize_request_path`

## Review Readiness

- [x] I have tested these changes locally
- [x] I have added/updated tests where applicable
- [x] I have updated documentation if needed (N/A)
- [x] My code follows the project's style guidelines
- [x] I have run linting/formatting checks
- [x] I have considered security implications
- [x] This PR is ready for review
This commit is contained in:
AxelRay 2026-07-26 04:17:40 +07:00 committed by GitHub
parent 58555c5be0
commit 4bd121493d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 45 additions and 2 deletions

View file

@ -3,10 +3,11 @@
from __future__ import annotations
from collections.abc import MutableMapping
from typing import Any
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
from fastapi import Request
if TYPE_CHECKING:
from fastapi import Request
def normalize_scope_path(scope: MutableMapping[str, Any], path: str) -> None:

View file

@ -0,0 +1,42 @@
"""Regression tests for importing request-scope helpers without FastAPI."""
import subprocess
import sys
import textwrap
def test_request_scope_and_project_context_import_without_fastapi() -> None:
script = textwrap.dedent(
"""
import builtins
import sys
real_import = builtins.__import__
def import_without_fastapi(name, *args, **kwargs):
if name == "fastapi" or name.startswith("fastapi."):
raise ModuleNotFoundError("No module named 'fastapi'")
return real_import(name, *args, **kwargs)
builtins.__import__ = import_without_fastapi
import headroom.proxy.request_scope as request_scope
import headroom.proxy.project_context
assert not any(
name == "fastapi" or name.startswith("fastapi.") for name in sys.modules
)
scope = {"path": "/a", "raw_path": b"/a"}
request_scope.normalize_scope_path(scope, "/b c")
assert scope == {"path": "/b c", "raw_path": b"/b%20c"}
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr