Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,19 @@ Every link in `README.md` must be absolute: `https://github.com/modern-python/<r
or `.../tree/main/<path>` for a directory. Never a relative path: `README.md` is also the PyPI long
description, and PyPI does not rewrite relative links, so a relative one 404s on the package page.

## Agent docs
## Agent skills

- Issue tracker: GitHub issues on `modern-python/lite-bootstrap` via `gh`. `docs/agents/issue-tracker.md`.
- Triage labels: five canonical roles, each label string equal to its name. `docs/agents/triage-labels.md`.
- Domain docs: single-context, `CONTEXT.md` and `docs/adr/` at the repo root. `docs/agents/domain.md`.
### Issue tracker

GitHub issues on `modern-python/lite-bootstrap`, via `gh`. See `docs/agents/issue-tracker.md`.

### Triage labels

The five canonical roles, each label string equal to its name. See `docs/agents/triage-labels.md`.

### Domain docs

Single-context: `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.

## Code style

Expand Down
41 changes: 13 additions & 28 deletions docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
# FastMCP teardown attaches through a Provider lifespan

**Decision:** `FastMcpBootstrapper` wires its `teardown` into FastMCP's shutdown by registering a
`_TeardownProvider` via the public `FastMCP.add_provider()`, whose `async def lifespan(self)` runs
teardown on the exit branch. We will not reach into `FastMCP._lifespan`, rebuild the user's
`FastMCP`, or leave teardown manual.

FastMCP is the one supported framework with no `on_shutdown`-shaped hook. `FastMCP.lifespan` is a
bound method on the `AggregateProvider` mixin, not a settable attribute: assigning `app.lifespan`
succeeds and has zero runtime effect, because the transport runners read the private `_lifespan`
attribute that is only set at construction time. `add_provider()` is the sole public,
post-construction hook whose callback is invoked by the server's ASGI lifespan.

Rejected, with the reasoning that would otherwise be re-litigated:

- **Mutate `app._lifespan` directly.** Works today, but it is private API on a fast-moving
dependency, and a rename ships as a silent no-teardown rather than an error.
- **Rebuild the user's `FastMCP` with a composed `lifespan=`.** Breaks the contract every other
bootstrapper keeps — the user owns the application object they passed in, and gets the same
object back.
- **No automatic wiring; document a manual `teardown()` call.** Adopted briefly and reverted once
`add_provider` was found. It makes FastMCP the only framework where shutdown is the user's job.

The accepted cost is semantic: `Provider` is FastMCP's general extension abstraction for tools,
resources and prompts, and using one purely for a shutdown callback is thin. A one-line comment at
the registration site says so.

**Revisit trigger:** FastMCP grows a first-class shutdown hook (an `on_shutdown` API, or a
documented public way to compose a lifespan post-construction). At that point the provider is the
indirect route and should be replaced by the direct one.
FastMCP is the one supported framework with no `on_shutdown`-shaped hook: `FastMCP.lifespan` is a
bound method on the `AggregateProvider` mixin, so assigning to it succeeds and does nothing — the
transport runners read the private `_lifespan`, set only at construction — which leaves
`add_provider()` as the single public post-construction hook the server's ASGI lifespan actually
invokes. `FastMcpBootstrapper` therefore registers a `_TeardownProvider` whose `lifespan` runs
teardown on the exit branch, accepting that `Provider` is FastMCP's extension abstraction for tools,
resources and prompts and that using one for a shutdown callback is thin.

Rejected: mutating `app._lifespan` (private API on a fast-moving dependency, where a rename ships as
silent no-teardown rather than an error); rebuilding the user's `FastMCP` with a composed `lifespan=`
(every other bootstrapper hands back the object it was given); and documenting a manual `teardown()`
call (adopted briefly, reverted once `add_provider` was found). If FastMCP grows a real shutdown
hook, the provider becomes the indirect route and should be replaced.
23 changes: 23 additions & 0 deletions docs/adr/0002-cross-cutting-logic-stays-put.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Cross-cutting logic stays where it is: per-instrument, and in one method

Two re-organizations have been proposed and rejected for the same reason — each moves complexity
rather than concentrating it, and the matrix is inherently O(instruments × frameworks) whichever axis
it is cut along.

The instrument × framework matrix keeps its **per-instrument axis**: base instruments own the hoisted
logic and each framework is a thin `bootstrap()` subclass, rather than per-framework adapters driving
generic instruments. The bindings genuinely differ — FastAPI imperative (`add_middleware`,
`include_router`), Litestar declarative before the app is built, FastStream attaching to a broker,
FastMCP via `custom_route` — so a uniform `add_route`/`add_middleware` interface would leak; and
framework-locality already exists, because all of a framework's subclasses live in its one
bootstrapper file. Reconsider if the per-cell bindings start converging, or if a new framework shares
an existing one's attach mechanism exactly.

`_build_excluded_urls` likewise keeps the whole trace-exclusion policy in **one method** that reads
its siblings' paths through `getattr(..., None)` — those sibling configs are genuinely optional, so
the defensive read is correct rather than a smell. A per-instrument contribution mechanism would
spread the policy across `PrometheusConfig`, `HealthChecksConfig` and OpenTelemetry while still
needing `opentelemetry_generate_health_check_spans`, so the coupling would not even disappear. The
real risk — renaming `prometheus_metrics_path` and silently stopping the exclusion — is answered by a
test pinning each sibling path in the built set. Reconsider when a user-supplied instrument needs
paths the policy cannot know about.
49 changes: 0 additions & 49 deletions docs/adr/0002-keep-per-instrument-axis.md

This file was deleted.

68 changes: 18 additions & 50 deletions docs/adr/0003-teardown-marker-accepted-limits.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,20 @@
# The teardown-attach guard is an attribute marker, and its two limits are accepted

**Decision:** `BaseBootstrapper._attach_teardown_once` detects a second bootstrapper by tagging the
attach target with the `_lite_bootstrap_teardown_attached` attribute (#130). Two consequences of
that choice — FastMCP detecting via the attribute rather than a provider-list scan, and Litestar
tagging the shared `AppConfig` rather than a built app — are accepted rather than designed around.

## Why an attribute on the target

Rejected: **a class-level registry or `WeakSet` of already-attached applications.** It would
contradict the `_lite_bootstrap_*` app-tagging convention the codebase already follows, and it
introduces process-global mutable state with the test-isolation hazards that come with it. The
marker keeps the "attached" bit local to the application's own lifetime, which is exactly the
lifetime the fact is true for.

Rejected: **a free-function helper module.** The guard is bootstrapper-lifecycle logic and belongs on
`BaseBootstrapper` next to `teardown()`, where `type(self).__name__` is available for the warning.

## The two accepted limits

1. **FastMCP detects via the marker, not structurally.** FastMCP previously detected double-attach
by scanning `any(isinstance(p, _TeardownProvider) for p in app.providers)`, which reads the actual
attach state. If the providers list is cleared after the first bootstrap while the marker
survives, the second bootstrapper is refused rather than re-attaching. Accepted: uniformity across
all four app-bearing bootstrappers is worth more than the sliver of state-accuracy the scan gave,
the only scenario the guard exists for is detected identically either way, and the regression
requires user or framework code to mutate `app.providers` after bootstrap, which no supported flow
does. Keeping FastMCP on a bespoke structural check would re-fragment detection and defeat the
seam's whole point.

2. **Litestar tags the `AppConfig`.** The Litestar app does not exist at `__init__` time — it is
built later by `Litestar.from_config()` — so the attach, and therefore the marker, lands on
`application_config`. Two `LitestarConfig` instances that *share* one `AppConfig` but intend two
distinct apps will collide. Accepted: config-level is the only option while the app is built
lazily, and sharing one mutable `AppConfig` across two intended apps is already broken
independently of teardown — instrument bootstrap mutates the shared config's `cors_config`,
`route_handlers` and `openapi_config`. The marker collision is one symptom of an
already-unsupported pattern, not a new hazard.

The genuinely actionable finding from the same review — the marker being set before a fallible
`attach()` — was *fixed*, not accepted: the target is tagged only after `attach()` returns.

Since #167 the consequence of hitting the marker is louder in both cases: the losing bootstrapper's
`bootstrap()` raises `ConfigurationError` before any instrument is applied, rather than warning and
leaving a half-wired application whose teardown never runs. The marker and these two limits are
unchanged.

**Revisit trigger:** for FastMCP, a supported flow starts mutating `FastMCP.providers` after
bootstrap (a documented hot-reload or provider-swap API), making the attribute diverge from real
attach state — then move back to structural detection or reconcile the two. For Litestar, sharing one
`AppConfig` across multiple apps becomes a supported pattern, or the attach is restructured to run at
`bootstrap()` time when the app exists — then tag the built `Litestar` app instead.
`BaseBootstrapper._attach_teardown_once` detects a second bootstrapper by tagging the attach target
with `_lite_bootstrap_teardown_attached`, so the "attached" bit lives exactly as long as the
application it describes — rather than in a class-level registry or `WeakSet`, which would mean
process-global mutable state and would contradict the `_lite_bootstrap_*` tagging convention the
codebase already follows. The target is tagged only after `attach()` returns, and since #167 hitting
the marker raises `ConfigurationError` from `bootstrap()` rather than warning and leaving a half-wired
application.

Two consequences are accepted rather than designed around:

- **FastMCP detects via the marker, not by scanning `app.providers`.** If that list is cleared after
the first bootstrap, the second bootstrapper is refused instead of re-attaching. No supported flow
mutates it, and uniformity across the four app-bearing bootstrappers is worth more than the sliver
of state-accuracy the structural check gave.
- **Litestar tags the shared `AppConfig`**, because the app does not exist until
`Litestar.from_config()`. Two configs sharing one `AppConfig` collide — but that pattern is already
broken independently, since instrument bootstrap mutates the shared config's `cors_config`,
`route_handlers` and `openapi_config`.
25 changes: 0 additions & 25 deletions docs/adr/0004-excluded-urls-stay-one-method.md

This file was deleted.

20 changes: 20 additions & 0 deletions docs/adr/0004-native-dependency-extras-are-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Native-dependency extras are opt-in, and never per-framework variants

Free-threaded CPython has wheels for neither `orjson` nor `grpcio`, and PEP 508 has no environment
marker for "GIL enabled", so neither can be required conditionally. Both are therefore opt-in:
`orjson` is its own extra (`logging` is `structlog` only) with the logging serializer falling back to
the stdlib `json` accelerator, and OTLP over HTTP is a sibling `otl-http` extra beside a gRPC-only
`otl`. The fallback is a documented performance change, not a correctness one — roughly 2-5x slower,
with non-JSON-native values in log `extra` rendering via `repr`; the GIL fast path is byte-for-byte
unchanged when `[orjson]` is installed.

Both refuse the combinatorial alternative: `*-ft` twins of every logging-bearing and `*-all` extra,
or `fastapi-otl-http`-style framework variants. A free-threaded service composes `[fastapi, otl-http]`
itself. Replacing `orjson` with msgspec, ujson or rapidjson was also rejected — a permanent mandatory
dependency, with a different encoder API and output shape, to paper over a temporary gap
([ijl/orjson#530](https://github.com/ijl/orjson/issues/530) tracks ft wheels); if that resolves,
`orjson` could return to core and the fallback branch retire.

The HTTP exporter deliberately carries no insecure-endpoint warning mirroring the gRPC one: it has no
`insecure` flag to inspect, only a full URL whose scheme is a stronger signal than anything
`__post_init__` could re-derive, and the user typed it explicitly.
12 changes: 12 additions & 0 deletions docs/adr/0005-core-declares-typing-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Core declares `typing-extensions`

A genuinely zero-dependency core was implemented, tested and abandoned. `HealthCheckTypedDict` is a
FastAPI response model, and pydantic refuses a stdlib `typing.TypedDict` model on Python < 3.12
(`PydanticUserError`) — a constraint that lives in pydantic's behaviour on an old interpreter, not in
a `TypedDict` you can build in isolation, so the attempt passed locally on 3.12 and failed the CI
matrix on 3.10 and 3.11. `1.3.0` had already shipped claiming a zero-dependency core and raised
`ModuleNotFoundError` on a bare install, masked everywhere because every extra pulls
`typing_extensions` transitively.

It is pure Python, so core stays free-threading-friendly, and the dependency can be dropped once the
floor reaches 3.12 — where `typing.TypedDict` is acceptable to pydantic and `Self` is in `typing`.
Loading
Loading