Skip to content

fix: importing the FastAPI adapter without its extra names the extra, not starlette - #54

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/fastapi-adapter-import-guard
Sep 7, 2026
Merged

fix: importing the FastAPI adapter without its extra names the extra, not starlette#54
AlexeyShalaev merged 1 commit into
masterfrom
fix/fastapi-adapter-import-guard

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

Closes #51

The problem

In a bare pip install servicewright, three of the four adapter subpackages told you what to install and the fourth named a package you never asked for:

servicewright.adapters.fastapi  -> ModuleNotFoundError: No module named 'starlette'
servicewright.adapters.litestar -> ImportError: Litestar support requires servicewright[litestar]; install it.
servicewright.adapters.grpc     -> ImportError: gRPC support requires servicewright[grpc]; install it.
servicewright.adapters.dishka   -> ImportError: Dishka support requires servicewright[dishka]; install it.

starlette is in no extras table, and nothing maps it back to servicewright[fastapi]. docs/agents.md promises the opposite in two places: the adapters section ("importing one without it raises ImportError naming what to install") and the errors table ("the message names the extra").

The cause

adapters/fastapi/_imports.py guards the stack correctly and states in its own docstring that "every HTTP submodule imports its third-party symbols from here". Eight module-level imports had drifted out of it:

Module Import
middlewares/context.py from starlette.datastructures import Headers
middlewares/logging.py from starlette.datastructures import Headers
middlewares/processing_time.py from starlette.datastructures import MutableHeaders
schemas.py from pydantic import BaseModel, ConfigDict, Field
headers.py from fastapi import Header
unit_scope.py from fastapi import Depends, Request
exceptions.py from deadline_budget import DeadlineExceededError
exceptions.py from fastapi.exceptions import RequestValidationError

Whichever runs first is the message the user sees, and the first one the package reaches is middlewares/context.py. I parsed every extra-gated adapter the same way: litestar, grpc, dishka, settings, apscheduler3 and apscheduler4 have no runtime third-party import outside their guard, which is exactly why their messages were right.

The fix

Route all eight back through _imports.py, and add the seven names they need to its __all__. The guard is now the single door structurally, not by import order. TYPE_CHECKING-only imports keep importing from starlette directly — they never run — and the deferred starlette.requests.Request inside ContextMiddleware.__call__ goes through the guard too (fastapi.Request is the same class object).

No public name changes: _imports is private and every subpackage __all__ is untouched, so docs/agents.md needs no edit. The docs already describe the behaviour this PR makes true — docs/operations/runbooks.md even says "the message always names the extra".

What I rejected

  • Add starlette to the guard's try: block. That is the symptom. pydantic and deadline_budget were unguarded too, and the next import that drifts out re-opens the same hole.
  • Import ._imports first in adapters/fastapi/__init__.py. One line, and it would have worked — Python runs the package __init__ before any submodule, so every import of the subpackage passes through it. But it is a fix that lives in the order of an import block: one reordering, one __init__ refactor, and it is silently gone, with the docstring's claim still false. (I said in my plan comment that a direct submodule import would defeat it — that was wrong, and it is why I checked before writing the test rather than after.)
  • A shared require_extra(...) helper across all adapters. A new concept for every reader, replacing a per-adapter _imports.py pattern that already works in six of seven adapters. The one that drifted needs discipline, not a framework.

The test

tests/unit/test_adapter_extras.py: one case per extra-gated subpackage (fastapi, litestar, grpc, apscheduler4, apscheduler3, dishka, settings). The dev environment installs every extra, so absence is simulated honestly — a subprocess with a sys.meta_path finder that refuses everything the extra puts on the path, then the import, then an assertion that the exception is an ImportError and not the bare ModuleNotFoundError, and that the message names servicewright[<extra>].

On master it fails for fastapi only:

AssertionError: ModuleNotFoundError: No module named 'starlette'
FAILED tests/unit/test_adapter_extras.py::...[fastapi]
6 passed, 1 failed

Verification

make check clean, make test737 passed, 14 skipped, coverage 93.00% (730 passed before).

The reporter's scenario, re-run against this branch installed with no extras into the venv that produced the report:

servicewright.adapters.fastapi  -> ImportError: FastAPI support requires servicewright[fastapi]; install it.
servicewright.adapters.litestar -> ImportError: Litestar support requires servicewright[litestar]; install it.
servicewright.adapters.grpc     -> ImportError: gRPC support requires servicewright[grpc]; install it.
servicewright.adapters.dishka   -> ImportError: Dishka support requires servicewright[dishka]; install it.

Nothing changes for anyone who has the extra installed: the symbols are the same objects, reached one module earlier.

On servicewright.settings

Not a bug, and not fixed here: that module has never existed. The models live at servicewright.adapters.settings — which is what docs/agents.md, the settings guide and the API reference all point at — and it already raised the right thing before this PR:

servicewright.adapters.settings -> ImportError: Settings models require servicewright[settings]; install it.

It is now covered by the same test under its real name.

… not starlette

In a bare install, `import servicewright.adapters.fastapi` raised
`ModuleNotFoundError: No module named 'starlette'` — a package the user never
asked for and that appears in no extras table — while litestar, grpc, dishka
and settings all named theirs.

`adapters/fastapi/_imports.py` guards the stack and says in its own docstring
that every HTTP submodule imports its third-party symbols from there, but eight
module-level imports had drifted out of it: starlette in three middlewares,
fastapi in `headers` and `unit_scope`, pydantic in `schemas`, and fastapi plus
deadline_budget in `exceptions`. Whichever ran first was the message, and the
first one reached is `middlewares/context.py`.

Route all of them back through the guard, so it is the single door structurally
rather than by import order, and add a test per extra-gated subpackage that
imports it in a subprocess with everything the extra installs blocked by a
`sys.meta_path` finder, asserting an `ImportError` that names the extra.
@AlexeyShalaev
AlexeyShalaev merged commit af1e0e1 into master Sep 7, 2026
6 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the fix/fastapi-adapter-import-guard branch September 7, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Importing the FastAPI adapter without its extra says 'No module named starlette' instead of naming the extra

1 participant