diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 533b489..a04cdab 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -16,6 +16,20 @@ jobs: - run: uv python pin 3.10 - run: just install lint-ci + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # --offline blocks network requests and excludes every external URL, so this gate + # is deterministic: it fails only on a relative link or file path a diff broke. + - name: Check local links + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline + --no-progress + '**/*.md' + docs: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac0441b..ff28385 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,25 +25,6 @@ jobs: - uses: extractions/setup-just@v4 - uses: astral-sh/setup-uv@v7 - # Curated release notes are MANDATORY for a stable tag. This runs BEFORE - # `just publish` (which is irreversible) so a missing notes file aborts - # the release before anything reaches PyPI — rather than silently shipping - # with GitHub's auto-generated notes. Pre-release tags (a letter in the - # name, e.g. 2.0.0rc1) are exempt and keep the auto-generated fallback. - - name: Require curated release notes (stable tags) - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then - echo "Pre-release ${GITHUB_REF_NAME}: curated notes not required." - exit 0 - fi - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ ! -f "$notes" ]; then - echo "::error::Stable tag ${GITHUB_REF_NAME} has no curated release notes at ${notes}. Write the notes, commit to main, and re-tag." >&2 - exit 1 - fi - echo "Found curated release notes: ${notes}" - # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no # GitHub Release is created advertising a version that never reached PyPI. # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). @@ -51,21 +32,15 @@ jobs: # Publisher on the lite-bootstrap PyPI project (env: pypi, workflow: release.yml). - run: just publish - # Description source: planning/releases/.md if present (verbatim, no - # auto-changelog appended); otherwise GitHub's generated notes. A tag with - # a letter (2.0.0rc1) is a pre-release -> flagged so GitHub won't mark it - # "Latest". + # The Release body is GitHub's generated notes, rendered from the squashed + # PR titles since the previous tag — so a conventional-commit title is what + # a reader gets. A release wanting prose is edited after the fact with + # `gh release edit --notes-file`. A tag with a letter (2.0.0rc1) is a + # pre-release -> flagged so GitHub won't mark it "Latest". - name: Resolve release metadata id: meta run: | set -euo pipefail - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ -f "$notes" ]; then - echo "body_path=$notes" >> "$GITHUB_OUTPUT" - echo "generate_notes=false" >> "$GITHUB_OUTPUT" - else - echo "generate_notes=true" >> "$GITHUB_OUTPUT" - fi if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" else @@ -75,7 +50,6 @@ jobs: - name: Publish GitHub Release uses: softprops/action-gh-release@v3 with: - body_path: ${{ steps.meta.outputs.body_path }} - generate_release_notes: ${{ steps.meta.outputs.generate_notes }} + generate_release_notes: true prerelease: ${{ steps.meta.outputs.prerelease }} draft: false diff --git a/AGENTS.md b/AGENTS.md index 7000234..3a1612d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,117 +2,105 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Commands +## Project Overview -`just --list` is the source of truth. Non-obvious "which to use when": +`lite-bootstrap` bootstraps a Python microservice with pre-configured observability; +[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary — read it before naming a +concept in code, a test name, or an issue title. The distinction between an instrument being +**configured** and a bootstrapper being **ready**, and between the two kinds of **skip**, is defined +there and is load-bearing throughout. -- `just lint` auto-fixes; `just lint-ci` is check-only (CI) and also runs the - planning validator (`planning/index.py --check`). `just check-planning` runs - just that validator. -- `just test -- -k "test_name"` runs a single test; `just test-branch` adds - branch coverage. +## Commands -All commands use `uv run` — do not invoke tools directly (e.g., use `uv run pytest`, not `pytest`). +`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth — +`just --list`, or read it; every non-obvious recipe carries its intent as a comment. ## Architecture -**lite-bootstrap** bootstraps Python microservices with pre-configured -observability instruments: a frozen `BaseConfig` hierarchy describes what the -user wants, `BaseInstrument[ConfigT]` subclasses each own one observability -concern, and a `BaseBootstrapper` per framework decides which instruments apply -and drives their lifecycle. - -The authoritative, code-current account of each capability lives in -[`architecture/`](architecture/). **When a change alters a capability's -behavior, update the matching `architecture/.md` in the same PR** — -that promotion is what keeps `architecture/` true. - -Invariants (what must not break) — see the capability page for the full account: - -- **Frozen configs, non-frozen instruments.** All `*Config` are frozen for - user-facing immutability; `*Instrument` are non-frozen because `LoggingInstrument` - / `OpenTelemetryInstrument` cache runtime state, forcing the whole hierarchy - non-frozen. → `architecture/config-model.md`, `architecture/instruments.md` -- **`__post_init__` cascade.** Every config `__post_init__` must call - `super().__post_init__()` (`BaseConfig` is the no-op terminator); `FastAPIConfig` - uses the explicit `super(FastAPIConfig, self)` form under `slots=True`. → - `architecture/config-model.md` -- **`from_dict` vs `from_object` differ on `None`.** `from_dict` passes explicit - `None` through (overrides default); `from_object` filters `None`/missing (default - wins). → `architecture/config-model.md` -- **Optional-dependency guard.** Optional imports sit behind - `if import_checker.is_X_installed:`; code referencing the symbol runs only after - `check_dependencies()` returned True. `ty` models this; other checkers may - false-flag "possibly unbound". → `architecture/instruments.md` -- **Construction skip ordering.** `is_configured` (silent skip) → `check_dependencies` - (warns on configured-but-missing) → instantiate; one INFO summary line via - `build_summary()`. → `architecture/bootstrappers.md` -- **Idempotent teardown.** `teardown()` no-ops when not bootstrapped, runs instruments - in reverse, collects per-instrument errors into one `TeardownError`, resets cached - state in `try/finally`. → `architecture/bootstrappers.md` -- **OTel is single-instance per process.** `set_tracer_provider` is set-once; a second - instance is ignored. Construct exactly one `OpenTelemetryInstrument` per process. → - `architecture/instruments.md` -- **Teardown attaches once.** `_attach_teardown_once` guards against double-attach via - the `_lite_bootstrap_teardown_attached` marker; a second bootstrapper on an already-marked - target warns at construction and its `bootstrap()` raises `ConfigurationError`. - `_lite_bootstrap_*`-prefixed attributes are the sanctioned way to tag user-supplied - apps. → `architecture/bootstrappers.md` - -Capability index (all of `architecture/`): - -| Capability | File | -|---|---| -| Config model — `BaseConfig`, multiple-inheritance composition, `from_dict`/`from_object`, `UNSET`, `__post_init__` cascade | `architecture/config-model.md` | -| Instruments — `BaseInstrument` lifecycle, catalog, optional-dep guard, non-frozen rationale, cross-instrument integrations (Logging↔Sentry, OTel↔Logging, Pyroscope↔OTel), OTel single-instance | `architecture/instruments.md` | -| Bootstrappers — hierarchy, skip ordering, registry + idempotent teardown, summary logging, teardown-attach seam, app-tagging sentinels | `architecture/bootstrappers.md` | -| Free-threading — nogil support matrix, orjson fallback, single-threaded-init invariant | `architecture/free-threading.md` | +`lite_bootstrap/` is one file per concern and named for what it does: `instruments/_instrument.py` +owns one observability concern, `bootstrappers/_bootstrapper.py` owns one framework's +bindings, and `import_checker.py` owns every optional-dependency probe. Read them. -Recent design context, bugs, and rationale: bug-audit findings in -`planning/audits/`, post-work reflections in `planning/retros/`, and the audit -arcs recorded as change files under `planning/changes/`. The full extras -matrix is `[project.optional-dependencies]` in `pyproject.toml`. +What reading them will not tell you is why four shapes are load-bearing rather than incidental: + +- The instrument × framework matrix stays on the **instrument** axis, not a per-framework adapter + axis ([ADR-0002](docs/adr/0002-keep-per-instrument-axis.md)). +- The double-bootstrap guard is an attribute marker, with two known limits accepted + ([ADR-0003](docs/adr/0003-teardown-marker-accepted-limits.md)). +- OpenTelemetry's URL-exclusion policy stays in one method reading its siblings, rather than being + contributed per instrument ([ADR-0004](docs/adr/0004-excluded-urls-stay-one-method.md)). +- `orjson` is an opt-in extra ([ADR-0005](docs/adr/0005-orjson-is-opt-in.md)) and + `typing-extensions` is core's only runtime dependency + ([ADR-0007](docs/adr/0007-core-declares-typing-extensions.md)) — together, the reason every + surface this library controls installs on free-threaded CPython. + +Behaviour detail has no prose home: it lives in the code and in the `INVARIANT:`-marked tests. +Before writing prose about a capability, run the admission check in **Where a fact goes**. ## Workflow -Planning uses the portable two-axis convention: `architecture/` (repo root) is -the living truth home and promotion target; `planning/changes/` holds the -per-change files. **Start at the -[Quick path](planning/README.md#quick-path-start-here)** in -[`planning/README.md`](planning/README.md) to choose a lane (Full / Lightweight / -Tiny), create a change file, and ship — that file is the authoritative spec, with -copy-and-fill starters in [`planning/_templates/`](planning/_templates/). Run -`just check-planning` to validate changes and `just index` to print the listing. - -Repo-local notes: - -- Design docs and plans live under `planning/`, **not** under `docs/`, so the - mkdocs site excludes them automatically. When superpowers skills default to - `docs/superpowers/specs/` or `docs/superpowers/plans/`, use a change file - under `planning/changes/` instead. -- `summary` is finalized at ship to state the realized result, in the - implementing PR alongside the code and the `architecture/` promotion — no - post-merge bookkeeping, no file move. +**The spec for a change is its PR body**, not a committed file: why, design, non-goals, +verification, reviewed with the diff. There is no change file and no lane to choose. A trivial PR +(typo, dep bump, formatter, CI tweak) ships a conventional-commit title with no body ceremony. -## Code style +Two things outlive the PR, and there are exactly two places to put them: an alternative **rejected** +with reasoning becomes an ADR in [`docs/adr/`](docs/adr/) (`NNNN-slug.md`, sequential, with a revisit +trigger), and real work **not scheduled** becomes a GitHub issue. There is no third state, and no +separate truth-home directory — a behaviour change is reviewed with the diff, not promoted to a page. -- Line length: 120 characters (ruff enforced) -- Ruff ALL rules enabled; notable ignores: D1 (missing docstrings), S101 (assert), TCH (type-checking imports), FBT (boolean args) -- Type annotations required; checked with `ty` +### Where a fact goes -### Conventions (from prior audit work) +Four homes, one owner each: -These are coding rules. The capability invariants they touch are detailed in -`architecture/` (pointers below). +| Home | Holds | +|---|---| +| `lite_bootstrap/` | anything readable from the module — the default | +| a named test | an **invariant**: must stay true, and a change could silently break it | +| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated | +| `README.md` and `docs/` | anything a user needs | + +Before writing a line anywhere: + +> Can an agent get this by reading `lite_bootstrap/`? → **don't write it.** +> Would a wrong change here fail a test? → it belongs **in the test**, not in prose. +> Does a user need it? → **`README.md` or `docs/`**. +> Otherwise it does not get written. + +**Prose about mechanism has no home. There is no file to add a paragraph to.** This file included: +it is always loaded, so a line that restates a docstring, a justfile comment, or `pyproject.toml` +costs every turn and rots in two places at once. This project tempts that failure mode particularly +hard, because the instrument × framework matrix invites a written index of cells that the file +layout already gives you. + +An invariant is a test whose name is the claim, with a docstring opening `INVARIANT:` and a second +paragraph naming **what breaks it** — design rationale, not a report of what this one test catches. +Nothing enforces that docstring shape; it is read at review time. A relative link to an ADR *is* +checked — CI runs lychee `--offline` over every `.md` — but a path named in a docstring or a comment +is not. Both ADRs and `INVARIANT:` docstrings ratchet: nothing prunes a record once its call is +settled. Keeping them lean is a standing habit. + +## Code style -- **No `# noqa: PLR2004`**: extract magic values to named locals. Example: `expected_max_age = 600; assert config.cors_max_age == expected_max_age` (not `assert config.cors_max_age == 600 # noqa: PLR2004`). -- **Backward-compat aliases for renames**: when renaming a public class, add a silent module-level alias (`OldName = NewName`) at the end of the file. Re-export both names from `__init__.py` if the old name was publicly exported. Aliases are class assignments, not subclasses — same class object, so `isinstance` behavior is preserved. -- **Frozen-config bypass in `__post_init__`**: `object.__setattr__(self, "field", value)` inside a frozen config's `__post_init__` is acceptable to set a field that requires other config values to construct; document with a one-line comment naming the trade-off (user-facing immutability vs. construction-time mutation). → `architecture/config-model.md` -- **Optional-import guard pattern**: top-level conditional imports (`if import_checker.is_X_installed: import X`) keep optional dependencies actually optional; code referencing `X` runs only after `check_dependencies()` returned True (the `is_configured → check_dependencies → instantiate` flow in `BaseBootstrapper.__init__`). See "Type checking" below. → `architecture/instruments.md` -- **`from_dict` vs `from_object` differ on `None`**: `from_dict` overrides the default with explicit `None`; `from_object` filters `None`/missing so the default wins. Documented in both docstrings (`instruments/base.py`) and pinned by `tests/test_config.py`. Pick `from_dict` if explicit-None override is the load-bearing semantic. → `architecture/config-model.md` -- **`__post_init__` cascade**: every config-class `__post_init__` must call `super().__post_init__()`; `BaseConfig`'s no-op terminates the chain; `FastAPIConfig` needs the explicit `super(FastAPIConfig, self).__post_init__()` form under `slots=True`. → `architecture/config-model.md` -- **`_lite_bootstrap_*` prefix for sentinels on user-supplied apps**: tag a user-supplied framework app with a direct `_lite_bootstrap_`-prefixed attribute (read via `getattr(..., default)` — no SLF violation; write with `# noqa: SLF001`); don't squat in framework namespaces like Starlette's `application.state`. The canonical example is the `_lite_bootstrap_teardown_attached` double-attach marker set by `_attach_teardown_once`. → `architecture/bootstrappers.md` +Three rules that are not visible in the code that follows them: + +- **No `# noqa: PLR2004`.** Extract the magic value to a named local instead: + `expected_max_age = 600; assert config.cors_max_age == expected_max_age`. +- **A public rename ships a silent alias.** Add `OldName = NewName` at the end of the module and + re-export both from `__init__.py` if the old name was exported. It is a class assignment, not a + subclass, so `isinstance` still holds. `FreeBootstrapperConfig`, `OpentelemetryConfig` and + `IGNORED_STRUCTLOG_ATTRIBUTES` exist for this reason. +- **Sentinels on a user's app get a `_lite_bootstrap_` prefix.** Set a direct attribute on the app + object; never squat in a framework namespace like Starlette's `application.state`. Read it with + `getattr(target, name, default)` (no SLF violation); write it with `# noqa: SLF001`. ### Type checking -The project uses **`ty`** (Astral's type checker), enforced via `just lint`. No other type checker is supported; the codebase patterns (conditional imports for optional dependencies, covariant `bootstrap_config` narrowing in framework instrument subclasses, TypedDict optional-key access guarded by `.get()` truthiness checks) require a checker that models them correctly. Pyright is not used. +`ty` is the only supported type checker. The codebase leans on patterns a checker has to model +correctly — conditional imports for optional dependencies, covariant `bootstrap_config` narrowing on +instrument subclasses, `TypedDict` optional-key access guarded by `.get()` — and Pyright reports all +three as errors. Do not add it, and do not act on its diagnostics. + +Two suppression spellings recur and are both correct as written: `# ty: ignore[invalid-method-override]` +on a framework subclass's `is_configured` classmethod, which narrows its parameter type where `ty` +enforces invariance, and `# ty: ignore[unresolved-attribute]` on the optional OTel/pyroscope symbols +whose guard `ty` does not follow. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8ece0cf --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,52 @@ +# lite-bootstrap + +Bootstraps a Python microservice with pre-configured observability: a frozen config declares what +the service wants, one instrument owns each observability concern, and a per-framework bootstrapper +decides which instruments apply and drives their lifecycle around the user's application. + +## Language + +A term is listed only when there is a synonym to reject, or a meaning subtle enough that code and +docs must agree on it. General programming vocabulary does not belong here, however heavily this +project uses it. + +**Bootstrapper**: +The user-facing entry point, one class per framework. It owns exactly one application for the life +of the process — not a builder you can run twice. A second bootstrapper on the same application (or, +for Litestar, the same `AppConfig`) warns at construction and raises `ConfigurationError` from +`bootstrap()`. + +**Instrument**: +One observability or middleware concern — logging, tracing, metrics, profiling, CORS, Swagger, +health checks. A framework subclass such as `FastAPIPrometheusInstrument` is the *same* instrument +bound to a framework, not a second instrument. +_Avoid_: instrumentation — reserve that for OpenTelemetry's own `opentelemetry-instrumentation-*` +packages and the middleware named after them; integration — that is the docs section of +per-framework guides, and Sentry's word for its own `Integration` objects. + +**Configured**: +An instrument is configured when the user's config asks for it — `is_configured(config)`, a +classmethod evaluated before anything is instantiated. Unconfigured is the normal case, not a fault: +an empty `sentry_dsn` means the user did not want Sentry. +_Avoid_: enabled — `logging_enabled` and `health_checks_enabled` are two config fields among the +several inputs `is_configured` reads; most instruments have no enabled flag at all. + +**Ready**: +A *bootstrapper* is ready when its framework package is importable — `is_ready()`, checked once in +`__init__`, raising `BootstrapperNotReadyError` when false. It is about the environment, never about +the config, and it is a different question from whether an instrument is **configured**. +_Avoid_: configured, for this sense. (Instruments carry a `not_ready_message` that is really the +not-configured reason; the attribute name predates the split and the two senses still meet there.) + +**Skipped**: +An instrument the bootstrapper decided not to instantiate. Two paths, deliberately different +signals: not configured is silent and lands in `skipped_instruments`, so it shows in +`build_summary()`; configured but with its optional package missing is a deployment surprise, so it +raises `InstrumentDependencyMissingWarning` plus a `logger.warning` and lands in neither list. The +quiet skip is the one you have to go looking for; the loud one finds you. + +**Free**: +Without a web framework — `FreeBootstrapper`, `FreeConfig`, the `free-all` extra. It has no +application, so nothing to attach teardown to and no double-bootstrap guard. +_Avoid_: bare "free" for PEP 703 free-threaded CPython, which this project also supports. Write +"free-threaded" in full. diff --git a/README.md b/README.md index d039ae6..03a9440 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The following ideas were borrowed: The following intentionally differ: - **Configuration**: `lite-bootstrap` uses frozen `dataclass` configs (no `pydantic` / `pydantic-settings` runtime dependency), which is what makes it "lite". `microbootstrap` configures everything through `pydantic-settings` models. -- **Granular extras**: `lite-bootstrap` has **no** mandatory runtime dependencies; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`) is its own extra, plus an opt-in `orjson` speedup for logging, per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. You install only what you actually use. It also runs on free-threaded CPython (3.13t/3.14t) — see [`architecture/free-threading.md`](architecture/free-threading.md). `microbootstrap` bundles the full observability stack (opentelemetry, sentry-sdk, structlog, pyroscope-io, rich, pydantic-settings, …) as base dependencies and only splits framework packages into extras. +- **Granular extras**: `lite-bootstrap` has exactly **one** mandatory runtime dependency, the pure-Python `typing-extensions`; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`) is its own extra, plus an opt-in `orjson` speedup for logging, per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. You install only what you actually use. It also runs on free-threaded CPython (3.13t/3.14t): every extra whose dependencies are pure Python installs there, and the ones that don't are blocked upstream — `orjson` and `pyroscope` have no free-threaded wheels, and `otl` needs `grpcio`, so use `otl-http` instead. `microbootstrap` bundles the full observability stack (opentelemetry, sentry-sdk, structlog, pyroscope-io, rich, pydantic-settings, …) as base dependencies and only splits framework packages into extras. - **Scope**: `lite-bootstrap` is deliberately narrow — only instrument wiring. It does not include a Granian server runner or a console writer. ## 📚 [Documentation](https://lite-bootstrap.modern-python.org) diff --git a/architecture/README.md b/architecture/README.md deleted file mode 100644 index 7e3d13b..0000000 --- a/architecture/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Architecture - -The living, code-current truth about **what `lite-bootstrap` does now** — one -file per capability, written as prose and dated by git. This is the truth home: -the present-tense companion to `planning/changes/`, which records *how it got -there*. - -## Capabilities - -- [`config-model.md`](config-model.md) — frozen `BaseConfig` hierarchy, framework - configs via multiple inheritance, `from_dict`/`from_object` semantics, the - `UNSET` sentinel, and the `__post_init__` cascade invariant. -- [`instruments.md`](instruments.md) — `BaseInstrument` lifecycle, the instrument - catalog, the optional-dependency guard, why instruments are non-frozen, the - cross-instrument integrations (Logging↔Sentry, OTel↔Logging, Pyroscope↔OTel), - and OpenTelemetry's single-instance-per-process constraint. -- [`bootstrappers.md`](bootstrappers.md) — the `BaseBootstrapper` hierarchy, skip - ordering at construction, the instrument registry + idempotent teardown, summary - logging, the teardown-on-shutdown attach seam, and the `_lite_bootstrap_*` - app-tagging sentinel convention. - -## Promotion rule - -When a change alters a capability's behavior, **hand-edit the matching -`architecture/.md` in the same PR** as the code. That promotion — -reviewed in the same diff, never deferred to a post-merge step — is what keeps -these files true. Code that changes without it silently rots the truth home. diff --git a/architecture/bootstrappers.md b/architecture/bootstrappers.md deleted file mode 100644 index e3c9ca5..0000000 --- a/architecture/bootstrappers.md +++ /dev/null @@ -1,133 +0,0 @@ -# Bootstrappers - -A bootstrapper takes one framework config, decides which instruments apply, -and drives their lifecycle. It is the user-facing entry point. - -## The hierarchy - -`BaseBootstrapper` (`lite_bootstrap/bootstrappers/base.py`) is an `abc.ABC`, -generic over `ApplicationT`. Five concrete bootstrappers exist: - -- `FastAPIBootstrapper` -- `LitestarBootstrapper` -- `FastStreamBootstrapper` -- `FastMcpBootstrapper` -- `FreeBootstrapper` — no web framework; just the instruments. - -Each declares `instruments_types: ClassVar[list[type[BaseInstrument]]]` (the -instruments it can run) and implements `_prepare_application()`, `is_ready()`, -and the `not_ready_message` property. (`FreeBootstrapperConfig` exists as a -backward-compat alias for `FreeConfig`.) - -## Skip ordering at construction - -`BaseBootstrapper.__init__` loops over `instruments_types` and decides each -instrument's fate in a fixed order: - -1. **`is_configured(config)`** runs first. If False, the user's config indicates - the instrument should not run — it is **silently skipped** and appended to - `skipped_instruments: list[tuple[type[BaseInstrument], str]]` (the class plus - its `not_ready_message`). No warning. This runs before instantiation so a - missing optional dependency can't blow up in a dataclass default before we - even decide the user opted out. -2. **`check_dependencies()`** runs only for configured instruments. If the - optional package is missing, this is a genuine deployment surprise: the - bootstrapper emits an `InstrumentDependencyMissingWarning` (and a - `logger.warning`) and skips the instrument. -3. Otherwise it **instantiates** the instrument and appends it to `instruments`. - -`InstrumentDependencyMissingWarning` is a `UserWarning` subclass (under the base -`InstrumentSkippedWarning`); filter it like any warning category. - -## Instrument registry and teardown - -The bootstrapper holds `instruments` (live instances) and runs them as a -registry: - -- `bootstrap()` calls `bootstrap()` on each instrument **in order**, then - returns the prepared application. It is idempotent: if already bootstrapped it - re-runs `_prepare_application()` without re-bootstrapping instruments. -- `teardown()` calls `teardown()` on each instrument **in reverse**. It is - idempotent via the `is_bootstrapped` guard — it returns immediately when not - bootstrapped. Per-instrument teardown errors are collected and re-raised as a - `TeardownError` after all instruments have been attempted, so one failure does - not strand the rest. Cached runtime state in `LoggingInstrument` and - `OpenTelemetryInstrument` is reset inside `try/finally`. - -## Summary logging - -After the construction loop, `__init__` emits one INFO-level summary line listing -configured + skipped instruments. It uses stdlib `logging` (composes with the -user's logging setup and with pytest's `caplog`); default Python logging -suppresses INFO, so opt in via `logging.basicConfig(level=logging.INFO)`. - -The same string is produced by the public `build_summary()` method, callable at -any later point (REPL, health endpoint) regardless of log-level filtering. To -inspect skips programmatically, iterate `bootstrapper.skipped_instruments`. - -## Teardown-on-shutdown attach - -Each app-bearing bootstrapper wires its `teardown` into the framework's shutdown -lifecycle from `__init__`, through one shared seam: -`BaseBootstrapper._attach_teardown_once(target, attach)`. That method owns the -double-attach guard — it tags the attach target with a -`_lite_bootstrap_teardown_attached` marker, so a second bootstrapper on the same -app warns and skips rather than stacking a second teardown hook. Only the `attach` -thunk is framework-specific: - -- **FastAPI** — merge a lifespan context manager (`_wrap_lifespan`); target is the app. -- **Litestar** — append to `application_config.on_shutdown`; target is the - `AppConfig` (the built `Litestar` app is slotted and is never tagged). -- **FastStream** — register via `application.on_shutdown(...)`; target is the app. -- **FastMCP** — add a `_TeardownProvider` whose async lifespan runs teardown; target - is the app (FastMCP exposes no `on_shutdown` API). -- **Free** — no app, no shutdown lifecycle; not wired. - -The guard is uniform: the same marker and warning apply to all four app-bearing -frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's -`on_shutdown`) return the callback. - -The marker gates more than the teardown hook: it also gates instrument -application. `_attach_teardown_once` records the skip on `self._attach_skipped` -before returning, and `bootstrap()` checks that flag first — the losing -bootstrapper raises `ConfigurationError` naming itself rather than re-applying -every instrument against an application another bootstrapper already owns. -Construction only warns; the losing bootstrapper is unusable from that point — -only the failure itself is deferred to `bootstrap()`. `FreeBootstrapper` never -calls `_attach_teardown_once` — it has no attach target — so it is unaffected; -two `FreeBootstrapper`s bootstrap independently. - -Nothing clears `_TEARDOWN_MARKER`, including `teardown()`. So once an -application has been bootstrapped, it stays owned for the life of the process — -a fresh bootstrapper constructed on it later still warns at construction and -raises at `bootstrap()`. Clearing the marker on teardown is not the fix: for -FastAPI, the lifespan wrapper the first bootstrapper installed via `_wrap_lifespan` -stays merged into the app regardless of the marker, so a second bootstrapper would -still be stacking its teardown behind one that's already there. The remedy is to -construct a fresh application. - -Litestar's `attach` thunk wraps `_apply_config`, which also normalizes the `AppConfig` -it is handed before `Litestar.from_config()` builds the app: it sets `debug` from -`service_debug`, and fills `request_max_body_size` with Litestar's own 10 MB default -when the config leaves it `Empty`. `from_config()` passes every field explicitly, so -the default `Litestar(...)` applies never reaches the app and an unset value 500s -every body-reading handler ([litestar#4296](https://github.com/litestar-org/litestar/issues/4296)). -A caller's own value, including an explicit `None` for no limit, is left alone. - -## Single-threaded init (free-threading) - -`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; their -cached state (`is_bootstrapped`, the teardown-attach marker) carries no locks and -is not safe to drive concurrently on one bootstrapper. This is intentional: under -free-threaded CPython the parallelism is in request handling, not bootstrap. See -[`free-threading.md`](free-threading.md). - -## App-tagging sentinel convention - -When a bootstrapper must tag a user-supplied framework app (FastAPI, FastMCP, -Litestar, FastStream) with internal state, it stores a direct attribute prefixed -`_lite_bootstrap_` rather than squatting in framework namespaces like Starlette's -`application.state`. The canonical example is the teardown guard's -`_lite_bootstrap_teardown_attached` marker, read via -`getattr(target, BaseBootstrapper._TEARDOWN_MARKER, False)` (no SLF violation) and -written via `setattr` inside `_attach_teardown_once`. diff --git a/architecture/config-model.md b/architecture/config-model.md deleted file mode 100644 index eac9854..0000000 --- a/architecture/config-model.md +++ /dev/null @@ -1,78 +0,0 @@ -# Config model - -Configs describe *what the user wants*. They are immutable, declarative, and -carry no runtime state. Every config in the project descends from `BaseConfig` -(`lite_bootstrap/instruments/base.py`). - -## BaseConfig - -`BaseConfig` is a `@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)`. -It holds the service-identity fields shared by every instrument (`service_name`, -`service_description`, `service_version`, `service_environment`, `service_debug`). - -Framework configs compose multiple instrument configs via **multiple -inheritance**. For example `FastAPIConfig` mixes `CorsConfig`, -`OpenTelemetryConfig`, `LoggingConfig`, `SentryConfig`, `PrometheusConfig`, -`SwaggerConfig`, `HealthChecksConfig`, etc. into one frozen dataclass, so a -single config object configures every instrument the framework supports. - -All `*Config` classes are frozen for user-facing immutability; only the -`*Instrument` classes are non-frozen (they cache runtime state — see -`architecture/instruments.md`). - -## from_dict vs from_object - -Two classmethods build a config from external data. They differ in how they -treat `None`: - -- `BaseConfig.from_dict(data)` — keeps unknown keys out (`{k: v for k, v in - data.items() if k in field_names}`) but passes through explicit `None`. So - `BaseConfig.from_dict({"service_name": None})` succeeds and **overrides** the - default with `None`. -- `BaseConfig.from_object(obj)` — pulls each field via `getattr(obj, field, - None)` and **filters out** any attribute that is `None` or missing, letting - the dataclass default take over. - -The asymmetry is load-bearing: pick `from_dict` when explicit-None override is -the semantic you want; pick `from_object` when missing/None should mean -"fall back to default." It is documented in both docstrings -(`instruments/base.py:25, 31`) and pinned by tests in `tests/test_config.py`. - -## UNSET sentinel and FastAPIConfig.application - -`lite_bootstrap/types.py` defines `UnsetType` and the singleton `UNSET` -(`typing.Final[UnsetType]`). It distinguishes "user did not supply this" from -"user explicitly passed `None`", which a plain `None` default cannot express. - -`FastAPIConfig.application` defaults to `UNSET`. In `__post_init__`, when the -value is still `UnsetType`, the config constructs a fresh `FastAPI()` and writes -it back: - -```python -if isinstance(self.application, UnsetType): - application = fastapi.FastAPI(...) - object.__setattr__(self, "application", application) -``` - -The `object.__setattr__` bypasses the frozen guard — the config stays -immutable to the user, but construction-time computed fields can still be set. -A one-line comment documents the trade-off (user-facing immutability vs. -construction-time mutation) at the bypass site. - -## __post_init__ cascade invariant - -Several configs override `__post_init__` (e.g. `OpenTelemetryConfig` emits a -security warning, `CorsConfig` validates, `FastAPIConfig` constructs the app). -Because they share an MRO under `FastAPIConfig` / `LitestarConfig` / -`FastStreamConfig` / `FreeConfig`, **every** config `__post_init__` must call -`super().__post_init__()` so the chain runs to completion. A class that returns -early before `super()` silently blocks the rest of the chain. - -`BaseConfig.__post_init__` is a deliberate no-op that **terminates** the -cascade; without it the chain would raise `AttributeError` on `object`. - -`FastAPIConfig` and `LitestarConfig` use the explicit -`super(FastAPIConfig, self).__post_init__()` / `super(LitestarConfig, self).__post_init__()` -form rather than bare `super()`. Under `@dataclass(slots=True)` the decorator -replaces the class object after the body compiles, which breaks the bare-`super()` -`__class__` cell; the explicit form is required. diff --git a/architecture/free-threading.md b/architecture/free-threading.md deleted file mode 100644 index 8cf3cb4..0000000 --- a/architecture/free-threading.md +++ /dev/null @@ -1,54 +0,0 @@ -# Free-threaded Python (nogil) - -`lite-bootstrap` runs on free-threaded CPython (PEP 703): 3.14t (officially -supported per PEP 779) and 3.13t (experimental). The library is pure Python; -what blocks a given surface on ft is always a native dependency somewhere in -its extra, not `lite-bootstrap` itself. - -## Support matrix - -| Surface | 3.13t | 3.14t | Notes | -|---|---|---|---| -| core, `logging`, `sentry` | ✅ | ✅ | pure Python; `logging` uses the stdlib-json serializer fallback when `orjson` is absent | -| `fastapi`/`faststream` (+ `-sentry`/`-logging`/`-metrics`) | ✅ | ✅ | pure Python + `pydantic-core` ft wheels | -| `litestar` (+ `litestar-metrics`) | ❌ | ✅ | `msgspec` gates `Py_GIL_DISABLED` to Python 3.14+ — its `_core.c` contains `#error "Py_GIL_DISABLED is only supported in Python 3.14+"` (v0.21.1), so the source build fails on 3.13t. See [`planning/deferred.md`](../planning/deferred.md) | -| `fastmcp` (+ `fastmcp-metrics`) | ❌ | ✅ | 3.13t: `cffi` (via `fastmcp`→`cryptography`) refuses to build free-threaded. 3.14t: works (on the leg) since the opentelemetry api/sdk split. See [`planning/deferred.md`](../planning/deferred.md) | -| `orjson` (opt-in speedup) | ❌ | ❌ | no ft wheels, build refuses ft ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530)). Omit it on ft; the serializer falls back to stdlib json | -| `otl` (gRPC exporter) | ❌ | ❌ | needs `grpcio`, no ft wheels ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)) | -| `otl-http` (HTTP exporter) | ✅ | ✅ | `opentelemetry-exporter-otlp-proto-http` (requests + protobuf, no grpcio); set `opentelemetry_exporter_protocol="http"` | -| `pyroscope` | ❌ | ❌ | `pyroscope-io` is abi3-only, unmaintained, no ft wheels ([`planning/deferred.md`](../planning/deferred.md)) | - -The CI matrix (`.github/workflows/_checks.yml`, `free-threaded` job) installs -per Python version to match this table exactly: the 3.13t leg's extras stop at -`logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics,otl-http`; -the 3.14t leg adds `litestar,litestar-metrics,fastmcp,fastmcp-metrics`. `orjson` -and `pyroscope` are excluded from both legs, and `otl` (the gRPC exporter) is -excluded in favor of `otl-http`, which is included on both legs; `fastmcp` runs -on 3.14t only (3.13t is `cffi`-blocked). - -## The `orjson` fallback - -`orjson` is used only by the logging serializer. It is an opt-in extra -(`lite-bootstrap[orjson]`), preferred when present (GIL-build output unchanged); -otherwise `logging_factory` serializes with the stdlib `json` accelerator using -compact separators and `ensure_ascii=False`, so output stays byte-identical for -JSON-native values. Trade-off on the fallback: ~2-5x slower JSON, and non-JSON-native -types in log `extra` (datetime/UUID) render via repr instead of orjson's native -encoding. Reverts to the native path automatically once `orjson` ships ft wheels. - -## Single-threaded-init invariant - -`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations. The -cached mutable state (`is_bootstrapped`, `OpenTelemetryInstrument._tracer_provider`, -the `_lite_bootstrap_teardown_attached` marker) is **not** guarded for concurrent -calls on one bootstrapper — by design. Free-threading parallelizes request -handling, where `lite-bootstrap` does not sit. Do not call `bootstrap()`/`teardown()` -on the same bootstrapper from multiple threads. - -## Proof - -`.github/workflows/_checks.yml` runs `scripts/ft_smoke.py` on 3.13t and 3.14t: -it asserts a free-threaded interpreter, `orjson` absent, the serializer fallback -round-trips, and a FastAPI bootstrap/teardown succeeds. It is a standalone -script rather than a `just test` run because `tests/conftest.py` hard-imports -`opentelemetry`, which the ft leg does not install. diff --git a/architecture/instruments.md b/architecture/instruments.md deleted file mode 100644 index 024283c..0000000 --- a/architecture/instruments.md +++ /dev/null @@ -1,172 +0,0 @@ -# Instruments - -An instrument is one observability or middleware concern (logging, tracing, -metrics, …). Each lives in its own file under `lite_bootstrap/instruments/`. -A bootstrapper owns a list of instrument instances and drives their lifecycle. - -## BaseInstrument - -`BaseInstrument[ConfigT]` (`lite_bootstrap/instruments/base.py`) is a generic, -**non-frozen** `@dataclasses.dataclass(kw_only=True, slots=True)` holding a -single `bootstrap_config: ConfigT`. Subclasses implement: - -- `bootstrap()` / `teardown()` — lifecycle hooks, called in order / reverse by - the bootstrapper. -- `is_configured(cls, bootstrap_config) -> bool` (classmethod) — return False - when the user's config means this instrument should not run. Default: always - True. Drives the silent-skip path. -- `check_dependencies() -> bool` (staticmethod) — return False when the - optional package is absent. Default: always True. -- class attributes `not_ready_message` and `missing_dependency_message` — - human-readable reasons surfaced in skip reporting and warnings. - -## Instrument catalog - -One file per instrument: - -- `logging_instrument.py` — structlog setup (`LoggingInstrument`), skipped when - `logging_enabled=False`. The Litestar subclass also owns Litestar's - `LoggingMiddleware`: it is off unless `litestar_logging_middleware_enabled` - is set, and when on it logs request/response metadata only (never bodies, - headers, cookies or query strings) and excludes the swagger, static, - health-check and metrics paths, matched as the path itself or a sub-path. A - caller-supplied `litestar_logging_middleware_config` replaces those defaults - wholesale. -- `opentelemetry_instrument.py` — OTel tracer provider + span export. -- `sentry_instrument.py` — Sentry SDK init, skipped when `sentry_dsn` empty. -- `prometheus_instrument.py` — Prometheus metrics; framework variants wrap it. -- `pyroscope_instrument.py` — continuous profiling, skipped when - `pyroscope_endpoint` empty. -- `cors_instrument.py` — CORS headers, requires an origins/regex setting. -- `swagger_instrument.py` — Swagger / offline docs. -- `healthchecks_instrument.py` — health-check route, gated by - `health_checks_enabled`. - -`logging_factory.py` was split out of `logging_instrument.py` to keep each file -scoped to one job. It holds `MemoryLoggerFactory`, `_MemoryLoggerFactoryConfig`, -the orjson structlog serializer, and the ASGI `AddressProtocol` / -`RequestProtocol` typing protocols. - -`_MemoryLoggerFactoryConfig.log_stream` resolves `sys.stdout` through a -`default_factory`, so the stream is bound when the instrument bootstraps rather -than when the module is imported. A process that rebinds `sys.stdout` before -bootstrap — `contextlib.redirect_stdout`, a supervisor, a test harness — is -honored, and the structlog path agrees with the root-logger handler -`_configure_foreign_loggers` installs at the same moment. - -## Optional-dependency guard - -Optional packages stay optional. `lite_bootstrap/import_checker.py` exposes -booleans computed once at import via `importlib.util.find_spec` -(`is_opentelemetry_installed`, `is_sentry_installed`, `is_fastapi_installed`, -`is_pyroscope_installed`, …). Optional imports sit behind -`if import_checker.is_X_installed:` blocks. Code that references the optional -symbol is only reached after `check_dependencies()` has already returned True, -so the runtime invariant holds even though static analyzers that don't model the -guard may report spurious "possibly unbound" diagnostics. The project uses `ty`, -which handles the pattern correctly. - -**Guarding dotted `find_spec` checks.** `find_spec` imports a dotted name's -parent package first, so a present-but-incomplete namespace — e.g. -`opentelemetry-api` installed without `opentelemetry-instrumentation` — raised -`ModuleNotFoundError` instead of returning `False`, crashing `import -lite_bootstrap`. `import_checker._safe_find_spec` wraps `find_spec` for dotted -names and treats that exception as absent; `is_fastapi_opentelemetry_installed` -and `is_litestar_opentelemetry_installed` both route through it. - -**OpenTelemetry resolves as three independent distributions: api, sdk, exporter.** -`opentelemetry-api` (the `opentelemetry.trace`/`.metrics`/`.context` namespace), -`opentelemetry-sdk` (`opentelemetry.sdk.*`), and each OTLP exporter package are -separate PyPI distributions — a real environment can have any subset. Three -`import_checker` flags mirror that: - -- **`is_opentelemetry_installed`** (`find_spec("opentelemetry")`) — the api. The - six api-only consumers (logging trace-injection, framework - `get_tracer_provider`, faststream health-check spans) import only - `opentelemetry.trace`/`.metrics` and gate on this. -- **`is_opentelemetry_sdk_installed`** (`_safe_find_spec("opentelemetry.sdk")`) — - the sdk. `opentelemetry_instrument.py` imports `opentelemetry.sdk.*`, so its - module-level import block gates on this, and `check_dependencies()` requires - **both** api and sdk. Without the split, bare `opentelemetry-api` (e.g. - `lite-bootstrap[fastmcp]`, which pulls it transitively without the sdk) crashed - `import lite_bootstrap` at `from opentelemetry.sdk import resources`. -- **`is_otlp_grpc_exporter_installed`** - (`_safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")`) — - the gRPC OTLP exporter. Its import and its use in `bootstrap()` sit behind this - guard; importing it unconditionally under the api flag previously crashed - `import lite_bootstrap` the same way. When `opentelemetry_endpoint` is set but - the exporter package is absent, `bootstrap()` emits an - `InstrumentDependencyMissingWarning` ("…spans will not be exported. Install - lite-bootstrap[otl].") rather than silently omitting the span processor — the - standard configured-but-missing signal. -- **`is_otlp_http_exporter_installed`** - (`_safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")`) — - the HTTP OTLP exporter (`opentelemetry-exporter-otlp-proto-http`, no `grpcio`, - so it installs on free-threaded builds; see `architecture/free-threading.md`). - Same guarded-import/guarded-use shape as the gRPC flag above; the missing-package - warning names `lite-bootstrap[otl-http]` instead. - -`OpenTelemetryConfig.opentelemetry_exporter_protocol` (`"grpc"` default | `"http"`) -selects which exporter `bootstrap()` builds when `opentelemetry_endpoint` is set: -`"grpc"` passes `endpoint`/`insecure` to `OTLPGrpcSpanExporter`; `"http"` passes -only `endpoint` (a full URL) to `OTLPHttpSpanExporter` — the HTTP exporter has no -`insecure` parameter, since that's carried by the URL scheme. Each branch checks -its own installed-flag and warns independently if the corresponding exporter -package is missing. - -## Why instruments are not frozen - -All `*Config` classes are frozen, but `*Instrument` classes drop `frozen=True` -because two of them cache mutable runtime state: `LoggingInstrument` caches a -`_logger_factory` (`MemoryLoggerFactory | None`) and `OpenTelemetryInstrument` -caches `_tracer_provider`. Python's dataclass rules require the whole hierarchy -to be non-frozen, so `BaseInstrument` is non-frozen too. Both caches are reset -to `None` inside a `try/finally` during `teardown()`, so a raised shutdown -leaves no stale references. - -## Prometheus path-label cardinality (Litestar) - -Litestar's `PrometheusConfig` defaults `group_path=False`, so the `path` metric -label holds the raw URL; parameterized routes then mint one series per distinct -value and grow the registry unbounded (memory growth — see -[litestar#4891](https://github.com/litestar-org/litestar/issues/4891)). -`LitestarConfig.prometheus_group_path` defaults to `True` to bind the label to -the route template (`/users/{id}`). `LitestarPrometheusInstrument.bootstrap` -merges `{"group_path": , **prometheus_additional_params}`, so precedence -is `prometheus_additional_params["group_path"]` > `prometheus_group_path` > -Litestar's own default. Set `prometheus_group_path=False` for raw paths. FastAPI -is unaffected: `prometheus_fastapi_instrumentator` already labels by route -template. - -## Cross-instrument integrations - -**Logging ↔ Sentry.** `logging_instrument.py` renders every structlog line to a -flat JSON object via the shared serializer in `logging_factory.py`. The seam -between the two instruments is `StructuredLogPayload` (also in -`logging_factory.py`): its `parse` classmethod reconstructs that line into -`message` / `extra` / `skip_sentry`, owning the meta-key vocabulary -(`STRUCTLOG_META_KEYS`) and stripping it from `extra` — so neither the parsing -detail nor the key set lives in `sentry_instrument.py`. The Sentry side's -`enrich_sentry_event_from_structlog_log` (chained after the user's `before_send` -via `wrap_before_send_callbacks()`) only maps the parsed payload onto the Sentry -event: a truthy `skip_sentry` suppresses the event (checked before the -message-presence test), otherwise it lifts `message` and attaches `extra` under -`contexts.structlog`. `IGNORED_STRUCTLOG_ATTRIBUTES` remains in -`sentry_instrument.py` as a back-compat alias of `STRUCTLOG_META_KEYS`. - -**OTel ↔ Logging.** The logging instrument injects span/trace IDs from the -active OpenTelemetry context into every log record, so logs and traces correlate. - -**Pyroscope ↔ OTel.** When both are enabled, a `PyroscopeSpanProcessor` is added -to the tracer provider so traces and profiles link in Grafana. - -## OpenTelemetry single-instance-per-process - -`OpenTelemetryInstrument.bootstrap()` calls -`opentelemetry.trace.set_tracer_provider(...)`, which the OTel SDK enforces as -**set-once**. A second instance's call is ignored (the SDK logs "Overriding of -current TracerProvider is not allowed"). `teardown()` calls `shutdown()` on the -cached provider — flushing batched spans and closing exporters — and resets -`_tracer_provider = None`, but it **cannot** reset the process-global pointer. -Construct exactly one `OpenTelemetryInstrument` per process; do not bootstrap a -second. diff --git a/context7.json b/context7.json index af24e2e..727f829 100644 --- a/context7.json +++ b/context7.json @@ -1,5 +1,4 @@ { "url": "https://context7.com/modern-python/lite-bootstrap", - "public_key": "pk_sIP7B0JWZxstxUZb8L28N", - "excludeFolders": ["planning"] + "public_key": "pk_sIP7B0JWZxstxUZb8L28N" } diff --git a/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md b/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md new file mode 100644 index 0000000..051b001 --- /dev/null +++ b/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md @@ -0,0 +1,30 @@ +# 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. diff --git a/docs/adr/0002-keep-per-instrument-axis.md b/docs/adr/0002-keep-per-instrument-axis.md new file mode 100644 index 0000000..8813a4d --- /dev/null +++ b/docs/adr/0002-keep-per-instrument-axis.md @@ -0,0 +1,49 @@ +# Keep the per-instrument axis for the instrument × framework matrix + +**Decision:** The framework-binding code stays organized around the *instrument* — base instrument +classes own the shared logic, and each framework is a thin subclass overriding only its `bootstrap()` +binding. We reject inverting to a per-framework adapter axis (a +`FastAPIAdapter`/`LitestarAdapter`/… that knows how to attach any instrument, driven by generic +instruments). + +## Context + +The codebase has an instrument × framework matrix: every filled cell is "how instrument *I* binds to +framework *F*" (e.g. `FastAPIHealthChecksInstrument`, `LitestarPrometheusInstrument`). The +2026-06-23 architecture review raised the matrix as candidate 3 — "no framework-locality; ~28 shallow +per-cell subclasses" — and proposed inverting the axis so a per-framework adapter owns the binding +and instruments become generic. + +## Decision & rationale + +The candidate's premise and payoff do not hold up: + +- **Framework-locality already exists at the file level.** All of a framework's instrument + subclasses live in its one bootstrapper file, so "what does lite-bootstrap do to my FastAPI app" + is already answered by reading one file. The review's "five scattered classes" are co-located, + not scattered. +- **The shared depth is already hoisted.** `render_health_check_data()` lives in the base + `HealthChecksInstrument`; provider setup in base `OpenTelemetryInstrument`; config validation in + the base configs. The per-framework subclasses contain *only* the genuinely-different binding, + which is what you want — the thinness is the result of correct hoisting, not shallowness to fix. +- **The N×M bindings differ genuinely.** FastAPI is imperative (`app.add_middleware`, + `include_router`), Litestar is declarative *before the app is built* (`application_config.cors_config = …`, + append to `middleware`/`route_handlers`), FastStream attaches middleware to a *broker* not the + app, FastMCP uses `custom_route`. A uniform adapter interface (`add_route`/`add_middleware`) would + have to paper over imperative-vs-declarative-vs-broker and normalize differing handler return + types — it would leak. +- **Deletion test fails for the inversion.** Inverting relocates the same N×M genuinely-different + bindings into framework-grouped adapters; the complexity *moves*, it does not *concentrate*. The + matrix is inherently O(instruments × frameworks); no axis choice removes a cell. Adding an + instrument touches every framework either way; adding a framework touches every instrument either + way. + +No friction worth a refactor was identified: navigation is satisfied by the file layout, the +cross-cutting change cost is inherent to the matrix, and the per-cell "shallowness" is hoisted-out +depth rather than duplication. + +**Revisit trigger:** the per-cell bindings start genuinely converging — the shared part outgrows the +base instrument and the same binding code appears across framework subclasses; then hoist the +convergent part and reconsider an adapter for that specific shared mechanism. Or a new framework +arrives that shares an existing framework's attach mechanism (another ASGI app driven exactly like +FastAPI), turning the hypothetical seam into a real one for that pair. diff --git a/docs/adr/0003-teardown-marker-accepted-limits.md b/docs/adr/0003-teardown-marker-accepted-limits.md new file mode 100644 index 0000000..e3d374c --- /dev/null +++ b/docs/adr/0003-teardown-marker-accepted-limits.md @@ -0,0 +1,52 @@ +# 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. diff --git a/docs/adr/0004-excluded-urls-stay-one-method.md b/docs/adr/0004-excluded-urls-stay-one-method.md new file mode 100644 index 0000000..282f917 --- /dev/null +++ b/docs/adr/0004-excluded-urls-stay-one-method.md @@ -0,0 +1,25 @@ +# Trace-exclusion policy stays in one method, not contributed per instrument + +**Decision:** `_build_excluded_urls` keeps the whole OpenTelemetry URL-exclusion policy in one +method that reads its sibling instruments' paths. We reject a contribution mechanism in which each +instrument or config declares the paths it wants excluded and OpenTelemetry unions them (#132). + +The method reads `prometheus_metrics_path` and `health_checks_path` off the config through defensive +`getattr(..., None)`, because a given framework config need not compose `PrometheusConfig` or +`HealthChecksConfig` at all — those are genuinely optional siblings, and the defensive read is the +correct expression of that, not a smell to refactor away. + +A contribution mechanism was the obvious alternative and fails the deletion test. Today the entire +exclusion policy is readable in one place; contributing would *spread* it across `PrometheusConfig`, +`HealthChecksConfig` and OpenTelemetry, and the health-check case would still need OpenTelemetry's +own `opentelemetry_generate_health_check_spans` flag, so the cross-coupling would not even +disappear. It moves complexity and worsens locality. + +The real risk in the sibling reads is silent breakage: rename `prometheus_metrics_path` and the +`getattr` returns `None`, the metrics endpoint quietly stops being excluded from traces, and nothing +fails. That is answered with a test that pins each sibling path in the built set, not with a +refactor. + +**Revisit trigger:** a third-party or user-supplied instrument needs its own paths excluded. The +policy would then have to name paths it cannot know about, which is precisely the case a contribution +mechanism exists for. diff --git a/docs/adr/0005-orjson-is-opt-in.md b/docs/adr/0005-orjson-is-opt-in.md new file mode 100644 index 0000000..cf06369 --- /dev/null +++ b/docs/adr/0005-orjson-is-opt-in.md @@ -0,0 +1,36 @@ +# `orjson` is an opt-in extra with a stdlib-`json` fallback + +**Decision:** `orjson` is its own extra (`lite-bootstrap[orjson]`), the `logging` extra is +`structlog` only, and the logging serializer falls back to the stdlib `json` accelerator when +`orjson` is absent. It is not bundled with `logging`/`*-all`, and it is not replaced by a +free-threading-ready native encoder. + +## Context + +`orjson` used to be a mandatory core dependency, used only by the logging serializer, and it +hard-blocks free-threaded installs: no ft wheels, and the build refuses to compile on ft (verified +on 3.14t). PEP 508 has **no environment marker for "GIL enabled"**, so `orjson` cannot be required +conditionally on GIL builds only. That missing marker is what forces the choice. + +## Rejected alternatives + +**Keep `orjson` reachable by default and add parallel `*-ft` extras that omit it.** Zero behaviour +change for GIL users, at the cost of an ft twin for every logging-bearing and `*-all` extra — +`fastapi-logging-ft`, `free-all-ft`, and so on. That is exactly the combinatorial extras sprawl this +project refuses; the same argument later rejected `*-otl-http` framework variants (ADR-0006). + +**Replace `orjson` with an ft-ready native encoder — msgspec, ujson, rapidjson.** A permanent new +mandatory dependency to paper over a *temporary* gap ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530) +tracks ft wheels). msgspec's encoder API differs (`enc_hook`, not orjson's `default=`) and so does +its output shape, forcing a serializer rewrite and a test re-baseline — for a dependency that would +outlive the problem. + +The chosen shape keeps one coherent rule: `orjson` is a per-build opt-in speedup, and no extra drags +it in. It also fixed a standing hygiene defect — a JSON encoder had no business being a mandatory +core dependency. The GIL fast path is byte-for-byte unchanged when `[orjson]` is present; the cost +is a documented, opt-in performance change (stdlib `json`, roughly 2-5x slower, same correctness, +and non-JSON-native values in log `extra` render via `repr` rather than orjson's native encoding). + +**Revisit trigger:** `orjson` ships free-threaded wheels (#530 resolves). At that point it could +return to `logging`/core as a hard dependency and the fallback branch retire — reopen then to decide +whether that simplification is worth removing the opt-in extra. diff --git a/docs/adr/0006-otlp-http-exporter-shape.md b/docs/adr/0006-otlp-http-exporter-shape.md new file mode 100644 index 0000000..fd62561 --- /dev/null +++ b/docs/adr/0006-otlp-http-exporter-shape.md @@ -0,0 +1,33 @@ +# OTLP over HTTP is a sibling extra, and HTTP carries no insecure warning + +**Decision:** `otl` points at `opentelemetry-exporter-otlp-proto-grpc`; a sibling `otl-http` carries +`opentelemetry-exporter-otlp-proto-http` (no `grpcio`). There are no framework `*-otl-http` variants. +The `__post_init__` insecure-endpoint warning stays tied to the gRPC `insecure` flag; for HTTP the +endpoint URL's scheme is the security signal, and it is documented rather than warned about. + +## Context + +`otl` used to pull `opentelemetry-exporter-otlp`, a meta package that drags in the gRPC exporter and +therefore `grpcio`. `grpcio` has no free-threaded wheels, so OTLP export on ft needs the HTTP +exporter to be installable on its own. + +## Rejected alternatives + +**Keep `otl` as the meta package and layer `otl-http` on top.** Leaves `otl` `grpcio`-bound and +redundant, since the meta package already ships the HTTP exporter. Repointing `otl` at the gRPC +exporter package is functionally identical for existing users — the exporter already defaulted to +gRPC and the HTTP package was never used — and drops only an unused transitive dependency. + +**Framework `*-otl-http` variants (`fastapi-otl-http`, `litestar-otl-http`, …).** The same +combinatorial sprawl rejected for `orjson` in ADR-0005. A free-threaded framework service composes +`[fastapi, otl-http]` and adds its own instrumentation package directly. + +**An `http://`-non-local warning mirroring the gRPC one.** The gRPC exporter has an `insecure` bool +that the config can inspect; the HTTP exporter has no such flag, only a full endpoint URL. Re-deriving +an "insecure" state would mean parsing `http://` vs `https://` in `__post_init__` to nudge the user +about a signal they already typed explicitly into the URL — more code for a weaker signal than the +URL itself gives. + +**Revisit trigger:** a user asks for a framework-specific free-threaded OTLP-HTTP convenience extra, +or for the HTTP endpoint to accept a bare `host:port` and auto-build the URL. Either reopens the +extras shape or the URL handling. diff --git a/docs/adr/0007-core-declares-typing-extensions.md b/docs/adr/0007-core-declares-typing-extensions.md new file mode 100644 index 0000000..b917086 --- /dev/null +++ b/docs/adr/0007-core-declares-typing-extensions.md @@ -0,0 +1,37 @@ +# Core declares `typing-extensions`; a genuinely zero-dependency core was tried and failed + +**Decision:** `lite-bootstrap`'s core declares exactly one runtime dependency, `typing-extensions`. +The alternative — removing the two runtime uses so a bare install has no dependencies at all — was +implemented, tested, and abandoned. + +`1.3.0` shipped claiming a zero-dependency core once `orjson` became opt-in (ADR-0005), but +`import lite_bootstrap` on a bare install raised `ModuleNotFoundError: No module named +'typing_extensions'`. Every install with any extra masked it, because every extra pulls +`typing_extensions` transitively. + +## Rejected alternative: remove the usage + +There are two runtime uses: `typing_extensions.Self` return annotations on `BaseConfig`'s +constructors, and `class HealthCheckTypedDict(typing_extensions.TypedDict, ...)` in the health-checks +instrument. The first is easy to drop (`from __future__ import annotations` plus `TYPE_CHECKING`). +The second is not, and that is what settles it: `HealthCheckTypedDict` is a FastAPI response model, +and pydantic refuses a stdlib `typing.TypedDict` model on Python < 3.12 — + +``` +PydanticUserError: Please use `typing_extensions.TypedDict` instead of +`typing.TypedDict` on Python < 3.12. +``` + +The attempt passed locally on 3.12 and against an isolated 3.10 `TypedDict` construction, and failed +the CI matrix on 3.10 and 3.11 — which is the shape of this whole class of mistake: the constraint +lives in pydantic's behaviour on an old interpreter, not in a `TypedDict` you can build in isolation. +So for as long as 3.10 and 3.11 are supported, core genuinely needs `typing_extensions` at runtime, +and declaring it is the honest fix. Dropping 3.10/3.11 to reclaim zero-dep was considered and is not +worth it on its own. + +`typing-extensions` is pure Python, so core stays free-threading-friendly; this is the leanest core +available rather than a compromise on the ft story. + +**Revisit trigger:** Python 3.11 goes out of support and the floor rises to 3.12, at which point +`typing.TypedDict` is acceptable to pydantic, `Self` is in `typing`, and the dependency can be +dropped for a genuinely zero-dependency core. diff --git a/docs/adr/0008-structlog-sentry-seam-stays-a-heuristic.md b/docs/adr/0008-structlog-sentry-seam-stays-a-heuristic.md new file mode 100644 index 0000000..a384064 --- /dev/null +++ b/docs/adr/0008-structlog-sentry-seam-stays-a-heuristic.md @@ -0,0 +1,39 @@ +# The structlog→Sentry seam is a value object, not a marker key or a shared module + +**Decision:** `StructuredLogPayload` in `logging_factory.py` owns the parse of a rendered log line +and the meta-key vocabulary (`STRUCTLOG_META_KEYS`); `sentry_instrument.py` keeps only the +orchestration — drop on `skip_sentry`, lift `message`, attach `extra` under `contexts.structlog`. +The producer still emits a plain flat JSON object and the consumer still recognises one by +`startswith("{")`. + +The problem being solved was ownership, not the heuristic: the meta-key set was duplicated across +the producer's processor chain and the consumer's strip list and owned by neither, so renaming a +meta-key silently degraded Sentry enrichment with nothing failing. + +## Rejected alternatives + +**Stamp an explicit sentinel/marker key on every log line** instead of sniffing `startswith("{")`. +This pollutes the stdout JSON shape for every logging user in order to serve one consumer. The +heuristic is cheap and adequate, and the log shape is user-visible output. + +**A neutral third module both instruments import.** There is exactly one consumer. A +shared-for-sharing's-sake module abstracts a sharing that does not exist and pulls the vocabulary +away from the chain that generates it — the value object belongs next to the serializer that +produces what it parses. + +**A symmetric `serialize()` on the value object.** The producer never constructs a +`StructuredLogPayload`; it hands structlog's full `event_dict` to the existing serializer. A +`serialize()` nobody calls would be dead surface. + +**An absolute, compile-time drift fix.** Nesting user kwargs under a single `extra` key would make +drift impossible, and would change the emitted log shape for every user. The deliberate trade is +"unlikely and caught by a round-trip test" over "impossible and a breaking change": a custom +top-level meta-processor whose key is not added to `STRUCTLOG_META_KEYS` still leaks, and that is a +known, accepted limit. + +`IGNORED_STRUCTLOG_ATTRIBUTES` survives in `sentry_instrument.py` as a silent alias of +`STRUCTLOG_META_KEYS` for external importers of the old name. + +**Revisit trigger:** a second consumer honours `skip_sentry` or needs the parsed payload. At that +point the value object has a real audience, `skip_sentry` should be renamed to something reporter- +neutral, and a marker key stops being a cost paid for one consumer. diff --git a/docs/introduction/quickstart.md b/docs/introduction/quickstart.md index 8023d09..4df2ce6 100644 --- a/docs/introduction/quickstart.md +++ b/docs/introduction/quickstart.md @@ -1,7 +1,7 @@ # Quickstart This walkthrough takes you from an empty project to a **FastAPI service with -production instrumentation** — metrics, health checks, CORS, Swagger, and +production observability** — metrics, health checks, CORS, Swagger, and structured logging — wired up in a few lines. The same pattern applies to [Litestar](../integrations/litestar.md), [FastStream](../integrations/faststream.md), [FastMCP](../integrations/fastmcp.md), and diff --git a/justfile b/justfile index ebe0c77..9f8c125 100644 --- a/justfile +++ b/justfile @@ -15,15 +15,6 @@ lint-ci: uv run ruff format --check uv run ruff check --no-fix uv run ty check - uv run python planning/index.py --check - -# Print the planning change index (flat, newest-first) to stdout. -index: - uv run python planning/index.py - -# Validate planning changes + decisions; CI runs this. -check-planning: - uv run python planning/index.py --check test *args: uv run --no-sync pytest {{ args }} diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 27c702e..0a30884 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -27,7 +27,7 @@ class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]): # Marker tagged on a user-supplied app (or its config) once this bootstrapper has # attached its teardown to the framework's shutdown lifecycle. Prevents a second - # bootstrapper on the same app from re-attaching. See architecture/bootstrappers.md. + # bootstrapper on the same app from re-attaching. Its accepted limits: ADR-0003. _TEARDOWN_MARKER: typing.ClassVar[str] = "_lite_bootstrap_teardown_attached" def _attach_teardown_once(self, target: object, attach: typing.Callable[[], object]) -> None: diff --git a/lite_bootstrap/import_checker.py b/lite_bootstrap/import_checker.py index b6a5ddb..e3b0e4b 100644 --- a/lite_bootstrap/import_checker.py +++ b/lite_bootstrap/import_checker.py @@ -18,7 +18,7 @@ def _safe_find_spec(module_name: str) -> bool: # opentelemetry-api provides the `opentelemetry` namespace (trace, metrics) without the # sdk. The OTel instrument imports opentelemetry.sdk.* and needs this stricter check; # api-only consumers (logging trace-injection, framework get_tracer_provider) use the -# flag above. See architecture/instruments.md. +# flag above. is_opentelemetry_sdk_installed = _safe_find_spec("opentelemetry.sdk") is_sentry_installed = find_spec("sentry_sdk") is not None is_structlog_installed = find_spec("structlog") is not None diff --git a/lite_bootstrap/instruments/logging_factory.py b/lite_bootstrap/instruments/logging_factory.py index 49dd964..afd1ecb 100644 --- a/lite_bootstrap/instruments/logging_factory.py +++ b/lite_bootstrap/instruments/logging_factory.py @@ -46,8 +46,7 @@ def _dumps_stdlib(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN4 # orjson has no free-threaded wheels and refuses to build on ft; fall back to the -# stdlib json accelerator (always ft-native) when it is absent. -# See architecture/free-threading.md. +# stdlib json accelerator (always ft-native) when it is absent. See ADR-0005. _serialize_log_to_string = _dumps_orjson if import_checker.is_orjson_installed else _dumps_stdlib _json_loads = orjson.loads if import_checker.is_orjson_installed else json.loads diff --git a/mkdocs.yml b/mkdocs.yml index 6163c7a..fdd3e9b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,10 @@ theme: icon: material/brightness-4 name: Switch to system preference +# docs/adr/ is an internal decision record, not user documentation; it is not published. +exclude_docs: | + /adr/ + validation: omitted_files: warn absolute_links: warn diff --git a/planning/.convention-version b/planning/.convention-version deleted file mode 100644 index 227cea2..0000000 --- a/planning/.convention-version +++ /dev/null @@ -1 +0,0 @@ -2.0.0 diff --git a/planning/README.md b/planning/README.md deleted file mode 100644 index 6b22baa..0000000 --- a/planning/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# Planning - -Specs, plans, and change history for `lite-bootstrap`. The living truth -about *what the system does now* lives in [`architecture/`](../architecture/) -at the repo root; this directory records *how it got there*. - -## Quick path (start here) - -> The fast lane for making a change. The full reference is in -> [Conventions](#conventions) below — read it only when this isn't enough. - -**1. Choose a lane — first matching rule wins:** - -1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** (design template) -2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no change file, conventional - commit) -3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** (change template) - -Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. - -**2. Create the change file** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded -intra-day counter — copied from the matching template (design or change) in -[`_templates/`](_templates/). - -**3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the change file's `summary:` to the -realized result, and run `just check-planning` before pushing. - -## Conventions - -> This is the portable convention, sourced from the canonical repo -> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) -> (applied version in [`.convention-version`](.convention-version)). To update -> it, run that repo's `APPLY.md` flow. The generated change index (`just index`) -> and the `## Other` pointers below are repo-local. - -### Two axes, never mixed - -- **`architecture/` (repo root) — the present.** One file per capability, plus - a single `glossary.md` (the ubiquitous language); living prose, updated in the - same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One file per change, - kept in place after ship. - -A change **promotes** its conclusions into the affected -`architecture/.md` by hand **in the implementing PR, alongside the -code** — the edit rides in the same diff and is reviewed with it, never applied -as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the change file stays in `changes/` as the *why*. - -### Glossary - -`architecture/glossary.md` is the project's **ubiquitous language** — one page -defining the domain terms that code, specs, and capability pages all share. Like -the capability files beside it, it is living prose with **no frontmatter**, dated -by git, and authored lazily: it appears when the first term is worth pinning down. - -Each entry is a term, a one-or-two-sentence definition of what it *is* (not what -it does), and an optional `_Avoid_:` line naming the synonyms to reject: - -```md -**Timer**: -A scheduled future delivery, identified by a timer id. -_Avoid_: job, task, alarm -``` - -Keep it a glossary, not a spec — no implementation detail. A change that -introduces or sharpens a term updates `glossary.md` in the same PR, the same way -a behavior change promotes into a capability file. - -### Change files - -A change is a file `changes/YYYY-MM-DD.NN-.md`: - -- `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter - (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. -- `` — kebab-case description, not a story ID. - -`summary` is written when the change is created (the intent one-liner) and -**finalized at ship** to state the realized result — set in the implementing -PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no file move. `date` and `slug` are never written — they are -read from the file name. - -### Three lanes - -| Lane | Artifacts | Use when | -|------|-----------|----------| -| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | -| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | - -Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. - -### Plans are ephemeral - -The executable plan — task checklists, embedded code, commit sequences, -whatever the executor needs — is a working artifact, not history. Keep it out -of `changes/` and out of version control (git-ignored scratch, e.g. -`.superpowers/`). Once the change ships, the diff and the PR are the record -of execution; a committed plan duplicates them. `check-planning` rejects -anything in `changes/` that is not a flat change file. - -### Lean specs - -The change file is the single home of a change's rationale: - -- The PR body summarizes and links to the change file — it never restates it. -- Rejected alternatives live in `decisions/` and are referenced, not retold. -- Show a sketch when the design needs code; never the full diff-to-be. -- Delete template sections that don't apply — an empty section is ceremony. -- Most designs fit well under ~700 words; length must buy information. - -### Artifacts at a glance - -- **design template** — the spec: the *thinking* (why, design, trade-offs, - scope); the change file it produces is the single home of rationale (see - [Lean specs](#lean-specs)). -- **change template** — the condensed spec for the lightweight lane. -- **`releases/.md`** — per-release user-facing notes. -- **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; - spawns fix changes. -- **`retros/-.md`** — what we learned after a body of work. -- **`deferred.md`** — real-but-unscheduled items, each with a revisit trigger. -- **`decisions/-.md`** — one file per design decision taken - (especially options *rejected*), each with a revisit trigger; listed by - `just index`. - -Templates live in [`_templates/`](_templates/). - -### Frontmatter - -`date` and `slug` are **derived from the file name** — never -repeated in frontmatter. So: - -- `changes/*.md`: `summary` (single line) only. -- `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional - `supersedes` / `superseded_by`. -- Files in `architecture/` carry **no** frontmatter — living prose, dated by git. - -**`summary`** is one line: written at creation as the intent, then **finalized -at ship** to state the realized result — what shipped and its effect. It is the -only field the index renders. - -## Index - -The listing is **generated**, not maintained — run `just index` to print it: -changes flat, newest-first, then decisions newest-first. The frontmatter in -each change / decision file is the single source of truth; there is no -committed copy to drift. - -## Other - -- **[`architecture/`](../architecture/)** at the repo root — the living - capability truth (config model, instruments, bootstrappers). The promotion - target on every ship. -- **[decisions/](decisions/)** — design decisions taken (and alternatives - rejected), each with a revisit trigger, so reviews don't re-litigate them; - indexed by `just index`. -- **[audits/](audits/)** — findings reports (2026-05-31 bug+refactor audit, - 2026-06-05 bug audit v2). -- **[retros/](retros/)** — what we learned after a body of work. -- **[deferred.md](deferred.md)** — the long-tail register of real-but- - unscheduled items with revisit triggers. diff --git a/planning/_templates/change.md b/planning/_templates/change.md deleted file mode 100644 index 5aa7e81..0000000 --- a/planning/_templates/change.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. ---- - -# Change: One-line capitalized title - -**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, rewrite it from -the design template. - -## Goal - -One or two sentences: what changes and why. - -## Approach - -The shape of the change in brief — enough that a reviewer sees the design -without a full spec. Link the truth home (`architecture/.md`) if a -capability contract moves. - -## Files - -- `path/to/file.py` — what changes -- `tests/test_x.py` — test added / updated - -## Verification - -- [ ] Failing test first — command + expected error. -- [ ] Apply the change. -- [ ] Test passes — command. -- [ ] `just test` — full suite green. -- [ ] `just lint` — clean. diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md deleted file mode 100644 index 45ccaf0..0000000 --- a/planning/_templates/decision.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: accepted # accepted | superseded -summary: One line — shown in `just index`. -supersedes: null -superseded_by: null ---- - -# One-line capitalized title - -**Decision:** What was decided, in a sentence. - -## Context - -Why this came up; the options that were on the table. - -## Decision & rationale - -The call and why — including why the alternatives were rejected. Enough that a -future explorer doesn't re-litigate it. - -## Revisit trigger - -The concrete signal that should reopen this decision. diff --git a/planning/_templates/design.md b/planning/_templates/design.md deleted file mode 100644 index 17dbee1..0000000 --- a/planning/_templates/design.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. ---- - -# Design: One-line capitalized title - - - -## Summary - -One paragraph. What changes, at the level a reader needs to decide if this -spec is worth reading in full. - -## Motivation - -Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. - -## Design - -What changes, in enough detail that a reader who has not seen the codebase -can follow. Sketches and interface fragments welcome; never the full -diff-to-be. Reference rejected alternatives in `decisions/` instead of -retelling them. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. One line each. - -## Testing - -How we know it landed correctly. Be specific: the command and the expected -signal. - -## Risk - -What could go wrong, ranked by likelihood × impact. Mitigations. diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md deleted file mode 100644 index 82385c3..0000000 --- a/planning/_templates/glossary.md +++ /dev/null @@ -1,15 +0,0 @@ -# Glossary - -The project's ubiquitous language — the domain terms that code, specs, and -capability pages share. Living prose, no frontmatter, dated by git. Each entry is -a term, what it *is* (not what it does), and the synonyms to avoid. No -implementation detail; this is a glossary, not a spec. - -**Term**: -A one-or-two-sentence definition of what it is. -_Avoid_: rejected-synonym, another-one - -**Another term**: -Define what it is, tightly. Group related terms under `##` subheadings when -natural clusters emerge; a flat list is fine when they don't. -_Avoid_: … diff --git a/planning/_templates/release.md b/planning/_templates/release.md deleted file mode 100644 index 5081187..0000000 --- a/planning/_templates/release.md +++ /dev/null @@ -1,38 +0,0 @@ -# - - - - - -## Feature - -- **.** What it adds and how to use it. - -## Fix - -- **.** What was broken, now fixed (reference the issue/regression). - -## Internal refactors - -- **.** What changed under the hood, stated as no behavior change. - -## Packaging - -- Metadata / build / dependency changes visible to installers. - -## Why - -Context a reader needs for the headline change. Omit for small releases. - -## Downstream - -What dependents must do — e.g. bump their version floor — or "No action -needed" when there is no API change. Omit if the project has no downstreams. - -## Internals - -- Coverage / tooling notes. diff --git a/planning/audits/2026-05-31-bug-refactor-audit.md b/planning/audits/2026-05-31-bug-refactor-audit.md deleted file mode 100644 index a95d842..0000000 --- a/planning/audits/2026-05-31-bug-refactor-audit.md +++ /dev/null @@ -1,459 +0,0 @@ -# lite-bootstrap — Bug & Refactor Audit - -**Date:** 2026-05-31 -**Scope:** Full read of `lite_bootstrap/` and `tests/` -**Deliverable:** Prioritized findings report. No code changes. - -Findings are grouped by severity. Each entry cites the source location, explains -what's wrong, and notes the linked test gap (if any). IDs are stable so a follow-up -plan can reference them. - ---- - -## 1. Critical bugs (real, observable, untested) - -### CRIT-1 · `enable_offline_docs` redoc URL ignores `root_path` - -**File:** `lite_bootstrap/helpers/fastapi_helpers.py:48-58` - -The redoc handler builds `redoc_js_url=f"{static_path}/redoc.standalone.js"` -with no `request.scope["root_path"]` prefix. The swagger handler above it -(lines 37-46) correctly prepends `root_path`. Result: behind a reverse proxy -or mount, swagger works and redoc 404s on its assets. - -Reinforcing test gap: `tests/test_fastapi_offline_docs.py:32-43` -(`test_fastapi_offline_docs_root_path`) asserts on swagger asset URLs but -never on redoc. The bug is invisible because the regression suite never looks. - -**Fix shape:** turn `redoc_html` into a function that takes a `Request` -(matching the swagger handler), read `root_path` from scope, prepend to the -redoc JS URL and the OpenAPI URL. Add an assertion to the existing root_path -test. - -### CRIT-2 · `OpenTelemetryInstrument.teardown()` doesn't shut down the tracer provider - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:92-135` - -`bootstrap()` creates a `TracerProvider`, registers `BatchSpanProcessor` / -`SimpleSpanProcessor`, and stores nothing on `self`. The local `tracer_provider` -goes out of scope. `teardown()` only calls `uninstrument()` on the -instrumentors — it never calls `tracer_provider.shutdown()` or `force_flush()`. - -Consequence: spans buffered in the `BatchSpanProcessor` are not flushed on -graceful shutdown. Trace data loss. - -**Fix shape:** store the `TracerProvider` on the instance (requires either -dropping `frozen=True` for this field or using `object.__setattr__`, matching -the pattern already used elsewhere — see REF-6), and add -`tracer_provider.shutdown()` to `teardown()`. Add a test that exercises a -flushable processor and asserts on shutdown behavior. - -### CRIT-3 · Litestar double-teardown not guarded - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:283-286` - -`__init__` does `self.bootstrap_config.application_config.on_shutdown.append(self.teardown)`. -A user who manually wraps `bootstrap()` / `teardown()` (e.g. in a `try/finally`, -or in tests) will trigger two teardowns when Litestar then fires its shutdown -event. The base `teardown()` (`bootstrappers/base.py:82-93`) is not idempotent -— it iterates `self.instruments` and calls `teardown()` on each. Many -instruments are not idempotent themselves: `LoggingInstrument.teardown()` will -try to close already-closed handlers; the `OpenTelemetryInstrument` (once -CRIT-2 is fixed) will double-shutdown a tracer provider, etc. - -FastAPI sidesteps this because its `lifespan_manager` is a single-shot -asynccontextmanager whose `finally` runs once. FastStream has the same -pattern as Litestar (`faststream_bootstrapper.py:191-193`) — also at risk. - -**Fix shape:** make `BaseBootstrapper.teardown()` idempotent by checking -`if not self.is_bootstrapped: return` at the top, before any work. The flag -is already set to `False` at the start. Re-order so the guard fires first. -Add a regression test. - ---- - -## 2. High-priority design issues - -### DES-1 · Per-framework instrument subclasses are pure type-annotation boilerplate - -**Files:** all three framework bootstrappers -(`fastapi_bootstrapper.py`, `litestar_bootstrapper.py`, `faststream_bootstrapper.py`) - -There are roughly 12 subclasses of the form: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPILoggingInstrument(LoggingInstrument): - bootstrap_config: FastAPIConfig -``` - -They exist solely to narrow the `bootstrap_config` field's type so the -framework-specific bootstrap code can call `self.bootstrap_config.application` -without type-checker complaints. They contribute zero behavior. The same -trick repeats for Sentry, Logging, OpenTelemetry, etc., across all three -bootstrappers. - -**Refactor shape:** make `BaseInstrument` generic in its config type: - -```python -class BaseInstrument(Generic[ConfigT]): - bootstrap_config: ConfigT -``` - -Then `LoggingInstrument(BaseInstrument[LoggingConfig])`, and at the -bootstrapper level instantiate `LoggingInstrument[FastAPIConfig]`. Only -instruments that *also* do framework-specific work (CORS middleware install, -healthcheck route registration, OTel middleware wiring) need real subclasses. - -Estimated reduction: ~150 lines of cargo-cult. Improves discoverability -because the only subclasses that remain are the ones that matter. - -### DES-2 · `opentelemetry_service_name` / `opentelemetry_namespace` duplicated across two configs - -**Files:** `instruments/opentelemetry_instrument.py:36-49`, -`instruments/pyroscope_instrument.py:12-19` - -Commit `7137776` added these fields to `PyroscopeConfig` so the pyroscope -instrument can be used standalone (without inheriting from -`OpentelemetryConfig`). They already exist on `OpentelemetryConfig`. In -`FreeBootstrapperConfig(LoggingConfig, OpentelemetryConfig, PyroscopeConfig, SentryConfig)` -this works only because both declarations have identical type and default — -MRO resolves to `OpentelemetryConfig`'s version. If anyone ever changes a -default on one side without the other, the inconsistency will be silent. -Python's dataclass machinery does not warn on duplicate field declarations -across MRO. - -**Fix shape:** extract a small mixin -(e.g. `OpenTelemetryServiceFieldsConfig`) that holds the two fields and have -both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it. Alternative: -push the fields onto `BaseConfig` if they're broadly useful. - -### DES-3 · `BaseConfig.from_object` semantics differ from `from_dict` and aren't documented - -**File:** `instruments/base.py:16-29` - -- `from_dict` includes any key present in the dict regardless of value - (including `None`). -- `from_object` filters with `value is not None` — an attribute explicitly set - to `None` on the source object is dropped and the dataclass default kicks - in. - -Test coverage (`tests/test_config.py:32-51`) only exercises the -"all populated" case. The asymmetry is either intentional (and undocumented) -or an oversight. Either way, a user migrating between `from_dict` and -`from_object` will hit surprising behavior. - -**Fix shape:** decide the contract, document it on the methods, add tests -that pin the chosen semantics, and consider unifying. - -### DES-4 · `skip_sentry` leaks into Sentry `contexts.structlog` - -**File:** `instruments/sentry_instrument.py:19-21, 56-69` - -`IGNORED_STRUCTLOG_ATTRIBUTES` strips `event`/`level`/`logger`/`tracing`/ -`timestamp`/`exception` before attaching the rest of the structlog payload to -`event["contexts"]["structlog"]`. It does **not** include `skip_sentry`. The -function returns `None` when `loaded_formatted_log.get("skip_sentry")` is -truthy (suppressing the event), but for any falsy value (`False`, missing -key, empty string) the flag isn't stripped and ends up as Sentry context -noise. - -**Fix shape:** add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES`. One-line -change. Add a test asserting the field is not present in attached context. - -### DES-5 · Dead `is_X_installed` checks in `is_ready()` - -**Files:** `instruments/sentry_instrument.py:100-101`, -`instruments/logging_instrument.py:139-140`, -`instruments/opentelemetry_instrument.py:82-86`, -`instruments/pyroscope_instrument.py:28-29` - -Each `is_ready()` ends with `and import_checker.is_X_installed`. But -`_register_or_skip` in `bootstrappers/base.py:44-55` calls -`check_dependencies()` first; if that returns False, the instrument is -skipped and `is_ready` is never called. By the time `is_ready` runs, the -`and is_X_installed` conjunct is provably True. - -Not a bug — just confusing dead code that makes the lifecycle harder to -reason about. (`_register_or_skip` runs `check_dependencies()` first; only on -True does it instantiate and call `is_ready()`. See `bootstrappers/base.py:44-64`.) - -**Fix shape:** delete the redundant conjuncts. Document somewhere -(`BaseInstrument` docstring or a CONTRIBUTING note) that `is_ready` is -called *after* `check_dependencies` has already passed. - ---- - -## 3. Refactor opportunities - -### REF-1 · Duplicated `_build_excluded_urls()` in FastAPI and Litestar OTel instruments - -**Files:** `bootstrappers/fastapi_bootstrapper.py:120-126`, -`bootstrappers/litestar_bootstrapper.py:181-187` - -The two methods are character-for-character identical. Hoist to -`OpenTelemetryInstrument` (the base in `instruments/`) and let framework -subclasses inherit. Combined with DES-1, the framework subclass might not -be needed at all. - -### REF-2 · Dead defensive check in `LitestarLoggingInstrument.bootstrap()` - -**File:** `bootstrappers/litestar_bootstrapper.py:157-174` - -`if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` -cannot be False: the instrument would not have been registered without -structlog (its base `is_ready` requires it), and `litestar` is installed by -the time `LitestarBootstrapper` could exist at all. Remove the branch and -unindent. - -### REF-3 · `BaseInstrument` uses `abc.ABC` but defines no abstract methods - -**File:** `instruments/base.py:32-47` - -`bootstrap`, `teardown`, `is_ready`, `check_dependencies` all have concrete -defaults (no-op or `return True`). The `abc.ABC` parent serves no purpose, -and the `# noqa: B027` markers exist only to silence ruff complaints about -empty-method-on-abstract-base. Either: - -- Drop `abc.ABC` and the noqa markers — it's a plain base class. -- Or genuinely make at least one method abstract. - -The first is simpler given how the class is actually used. - -### REF-4 · `logging_instrument.py` is 212 lines doing four jobs - -**File:** `instruments/logging_instrument.py` - -The module mixes: tracer injection function (lines 21-40), protocol -definitions (43-55), `MemoryLoggerFactory` and serializer (57-101), -`LoggingConfig` (104-114), and `LoggingInstrument` (117-211). It also has -three separate `if import_checker.is_structlog_installed:` blocks at -module scope defining different symbols (lines 17, 57, 100). - -**Refactor shape:** split into `logging_factory.py` (MemoryLoggerFactory + -serializer + the protocols only it cares about) and `logging_instrument.py` -(config + instrument + tracer injection). Each file has one -`if is_structlog_installed:` gate. - -### REF-5 · `swagger_instrument.py` and `prometheus_instrument.py` base classes carry almost no logic - -**Files:** `instruments/swagger_instrument.py`, `instruments/prometheus_instrument.py` - -`SwaggerInstrument` has no methods at all. `PrometheusInstrument` has only -`is_ready` + `not_ready_message`. The real behavior is in framework -subclasses. Once DES-1 lands and type-only subclasses go away, these base -files could: - -- Collapse into the bootstrapper modules that consume them, or -- Stay as pure config holders with a one-line docstring explaining the - split (config in instruments, behavior in bootstrappers). - -Either is fine; the current state is mildly confusing. - -### REF-6 · `frozen=True` + `object.__setattr__` workaround for caches - -**Files:** `instruments/logging_instrument.py:160-169`, -`bootstrappers/fastapi_bootstrapper.py:57-72` - -`LoggingInstrument._logger_factory` and `FastAPIConfig.application` both use -`object.__setattr__` to mutate "frozen" dataclasses. The frozen claim is -partly false, and the workaround obscures mutable state. Pick a model: - -- **Truly immutable:** factory and application are built outside the - dataclass and passed in. Defaults are computed at the call site, not in - `__post_init__` or property accessor. -- **Pragmatic:** drop `frozen=True` for these specific dataclasses. - -Mixing the two patterns is the worst case. - -### REF-7 · Hardcoded `timeout=5` in FastStream health check - -**File:** `bootstrappers/faststream_bootstrapper.py:100-104` - -`broker.ping(timeout=5)` is hardcoded. For users with slow brokers (large -Redis clusters under load, message queues with cold connections), this is -a footgun. Add a config field: `health_check_broker_timeout: float = 5.0` -on `HealthChecksConfig` (or a FastStream-specific config field if -`HealthChecksConfig` shouldn't carry broker concepts). - ---- - -## 4. Test coverage gaps - -The most consequential gaps are the ones letting the critical bugs hide. - -### TEST-1 · Redoc + `root_path` untested (hides CRIT-1) - -**File:** `tests/test_fastapi_offline_docs.py:32-43` - -`test_fastapi_offline_docs_root_path` asserts swagger asset URLs include -`/some-root-path/` but never inspects redoc output. Adding two `assert -"/some-root-path/static/redoc.standalone.js" in response.text` lines -(and the redoc fetch) would catch CRIT-1 immediately. - -### TEST-2 · OTel span flush on teardown untested (hides CRIT-2) - -No test in `tests/instruments/test_opentelemetry_instrument.py` exercises -shutdown semantics. Write a test that: - -1. Sets `opentelemetry_endpoint` so a `BatchSpanProcessor` is added. -2. Records spans inside `bootstrap()`/`teardown()`. -3. Uses an `InMemorySpanExporter` to verify spans were flushed before - teardown returned. - -Or, more narrowly: assert that `tracer_provider.shutdown()` is called. - -### TEST-3 · Litestar double-teardown untested (hides CRIT-3) - -`tests/test_litestar_bootstrap.py` doesn't exercise the case of manual -`bootstrapper.teardown()` followed by Litestar's `on_shutdown` firing. -Add a test that calls teardown twice and asserts no exception. - -### TEST-4 · No standalone test files for CORS / HealthChecks / Prometheus / Swagger instruments - -These are only covered transitively via the bootstrapper integration tests. -A regression in (say) `CorsInstrument.is_ready()` would surface as a noisy -FastAPI bootstrap test failure. Standalone test files would localize -diagnosis. - -### TEST-5 · `BaseConfig.from_object` edge cases untested - -Related to DES-3. Not tested: - -- Source attribute = `None` (filtered out today, default wins). -- Source attribute missing entirely (`getattr(obj, field, None)` returns - None — filtered). -- Falsy values like `False` / `""` / `[]` (these *do* go through today - because `is not None` accepts them). - -Pin whichever semantics you choose with tests. - -### TEST-6 · `BaseConfig.from_dict` "unknown key dropped" not explicitly asserted - -`tests/test_config.py:8-29` passes `"extra_key": "extra_value"` but only -verifies the kept keys (and that `cls(**...)` didn't raise). A regression -to "raise on unknown key" would pass. - -### TEST-7 · `is_valid_path` has zero negative tests - -`helpers/path.py:5` regex is enforced for `prometheus_metrics_path` and -`swagger_path`. No test exercises invalid inputs: empty string, `"foo"` -(no leading slash), `"/path with space"`, `"../escape"`. A regression to -the regex would land silently. - -### TEST-8 · `LoggingInstrument` lifecycle replay untested - -`bootstrap → teardown → bootstrap` cycle is never run. Related to the -`_unset_handlers` permanence issue (see LOW-8 below). - ---- - -## 5. Low-priority / cosmetic - -### LOW-1 · `wrap_before_send_callbacks` `if not callback:` should be `if callback is None` - -**File:** `instruments/sentry_instrument.py:81-82` - -Idiomatically callables are always truthy unless they define `__bool__`. -`if not callback` is sloppy. - -### LOW-2 · `SentryConfig.sentry_before_send` typing is degenerate - -**File:** `instruments/sentry_instrument.py:36` - -`Callable[[Any, Any], Any | None] | None` collapses to `Callable[..., Any] | None` -because `Any | None == Any`. The intended type is sentry's `EventProcessor` -(already imported under `TYPE_CHECKING` in the same file). Replace. - -### LOW-3 · `_format_span` uses `os.linesep` - -**File:** `instruments/opentelemetry_instrument.py:25-26` - -On Windows that's `\r\n`. OTel SDK convention is `\n`. Use `"\n"` literal. - -### LOW-4 · `FastAPIConfig.application` inconsistent default pattern - -**File:** `bootstrappers/fastapi_bootstrapper.py:50, 57-65` - -Declares `default=None` with a `# ty: ignore[invalid-assignment]` and patches -the field in `__post_init__` via `object.__setattr__`. Litestar's equivalent -(`bootstrappers/litestar_bootstrapper.py:111`) uses -`default_factory=lambda: AppConfig()`. Pick one pattern. Related to REF-6. - -### LOW-5 · `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps` keys by `id(next_app)` - -**File:** `bootstrappers/litestar_bootstrapper.py:75-97` - -Python object IDs can be reused after GC. ASGI apps are stable in practice, -so this is theoretical. Add a one-line comment acknowledging the assumption. -The recent commit (`5578f63`) added this cache — it's an explicit perf -trade-off, just under-documented. - -### LOW-6 · `MemoryLoggerFactory.__init__` takes 5 logging-config kwargs - -**File:** `instruments/logging_instrument.py:60-73` - -Could accept a small config dataclass for readability. Cosmetic; the long -list is annotated and clear enough. - -### LOW-7 · `FreeBootstrapperConfig` naming inconsistency - -**File:** `bootstrappers/free_bootstrapper.py:12` - -Has the `Bootstrapper` infix; siblings are `FastAPIConfig` / `LitestarConfig` / -`FastStreamConfig`. Rename to `FreeConfig` (with a deprecation alias) or -rename the siblings (more invasive). Trivial. - -### LOW-8 · `LoggingInstrument._unset_handlers` permanently mutates target loggers - -**File:** `instruments/logging_instrument.py:146-148` - -Sets `logging.getLogger(name).handlers = []` for each entry in -`logging_unset_handlers`. There's no symmetric restoration in `teardown()`. -On `bootstrap → teardown → bootstrap`, the second bootstrap sees the loggers -already cleared (no problem); but other code in the same process that -relies on those loggers after teardown will see broken loggers. Probably -acceptable for the intended "microservice runs until process exit" model; -worth a docstring. - -### LOW-9 · `LoggingInstrument.teardown()` forces root logger to WARNING - -**File:** `instruments/logging_instrument.py:202-211` - -Unconditionally resets root logger level to WARNING after closing handlers. -If a user had configured a different default outside the bootstrapper, -this is destructive. Acceptable for the intended use case, but undocumented. - -### LOW-10 · `OpentelemetryConfig` capitalization inconsistency - -**File:** `instruments/opentelemetry_instrument.py:36` - -`OpentelemetryConfig` (lowercase `t`) doesn't match the conventional -`OpenTelemetry` capitalization. PR6 introduced -`OpenTelemetryServiceFieldsConfig` (uppercase `T`) as a mixin parent, -making the inconsistency more visible — same module, two casings for the -same product name. Backfilled from PR6's code review and tracked into -PR15 alongside LOW-7. Rename with a silent backward-compat alias -(`OpentelemetryConfig = OpenTelemetryConfig`) to preserve existing -imports. - ---- - -## Appendix — Notes on scope - -A handful of additional observations didn't make the cut: - -- `FastStreamBootstrapper`'s `_define_health_status` returns `False` when - `application` is falsy — `application` will never be falsy in practice - (it's always an `AsgiFastStream` instance from `_make_asgi_faststream`). - Defensive code that never fires. -- `set_tracer_provider` is process-global. Two bootstrappers in the same - process would conflict. This is an OTel SDK constraint, not a - lite-bootstrap issue. -- `_register_or_skip` uses `stacklevel=4` for `warnings.warn`. The number - is fragile (depends on call depth from user code through bootstrapper - `__init__` → base `__init__` → `_register_or_skip`). It happens to be - correct today. Worth a comment in the code if not already there. - -These were noted but not surfaced as actionable findings because the cost -of acting on them outweighs the cost of leaving them. diff --git a/planning/audits/2026-06-05-bug-audit-v2.md b/planning/audits/2026-06-05-bug-audit-v2.md deleted file mode 100644 index 9e7685f..0000000 --- a/planning/audits/2026-06-05-bug-audit-v2.md +++ /dev/null @@ -1,583 +0,0 @@ -# lite-bootstrap — Bug Audit v2 (UX · Logic · Security · Tests) - -**Date:** 2026-06-05 -**Scope:** Full re-read of `lite_bootstrap/` and `tests/` against the 2026-05-31 audit, plus -fresh findings under four lenses (UX, logic, security, tests). -**Deliverable:** Prioritized findings report. No code changes. - -Each lens opens with a status table for the relevant prior findings -(`FIXED` / `PARTIAL` / `OPEN`) and is followed by **new** findings not in the prior audit. -External tooling: `pip-audit` (no CVEs in lockfile), `bandit` (two B101 assert findings — -both folded into LOG/SEC items below). - ---- - -## Lens 1 — UX (API ergonomics) - -### Prior-finding status - -| ID | Title | Status | Evidence | -|---|---|---|---| -| LOW-4 | `FastAPIConfig.application` inconsistent default pattern | **FIXED** | `UnsetType` sentinel via `types.UNSET`; `__post_init__` builds the app and bypasses freeze via `object.__setattr__` — pattern is now documented in `CLAUDE.md`. (`fastapi_bootstrapper.py:51, 63-67`) | -| LOW-6 | `MemoryLoggerFactory.__init__` takes 5 logging-config kwargs | **FIXED** | Replaced with `_MemoryLoggerFactoryConfig` dataclass. (`logging_factory.py:26-31, 41-50`) | -| LOW-7 | `FreeBootstrapperConfig` naming inconsistency | **FIXED** | Renamed `FreeConfig`; `FreeBootstrapperConfig = FreeConfig` alias preserved; both re-exported. (`free_bootstrapper.py:12, 38`, `__init__.py:4, 27-28`) | -| LOW-8 | `_unset_handlers` permanently mutates target loggers | **FIXED** (doc-only per spec) | Docstring added on the method documenting the irreversible mutation. (`logging_instrument.py:104-107`) | -| LOW-9 | `LoggingInstrument.teardown()` forces root logger to WARNING | **FIXED** (doc-only per spec) | Docstring on `teardown` warns that pre-existing user configuration is overwritten. (`logging_instrument.py:163-167`) | -| LOW-10 | `OpentelemetryConfig` capitalization inconsistency | **FIXED** | Renamed `OpenTelemetryConfig`; `OpentelemetryConfig = OpenTelemetryConfig` alias preserved. (`opentelemetry_instrument.py:42, 160`) | - -### New findings - -#### UX-1 · `FastAPIConfig` silently stomps user-supplied `app.title`, `.debug`, `.version` - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:73-75` - -`__post_init__` unconditionally executes: - -```python -application.title = self.service_name -application.debug = self.service_debug -application.version = self.service_version -``` - -This runs **in both branches** of the `isinstance(self.application, UnsetType)` check — -the branch where lite-bootstrap built the app, and the branch where the user supplied a -pre-configured `FastAPI(title="my-svc", version="3.1.4", debug=False)`. The user's values -are clobbered by lite-bootstrap defaults (`service_name="micro-service"`, -`service_version="1.0.0"`, `service_debug=True`). - -Reproduction: - -```python -user_app = FastAPI(title="My API", version="3.0.0") -cfg = FastAPIConfig(application=user_app) -# user_app.title == "micro-service", user_app.version == "1.0.0", user_app.debug == True -``` - -**Fix shape:** restrict the override to the UnsetType branch (lite-bootstrap-built apps), -or only set fields that match the `BaseConfig` default (i.e. the user didn't customize the -config). The latter is friendlier: if a user passes a configured app AND overrides -`service_name` on the config, both signals exist — pick the config (most explicit) but -warn. Add a regression test (see TEST-NEW-1). - -#### UX-2 · `FastStreamPrometheusInstrument.collector_registry` is a per-instance new registry - -**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:146-148` - -```python -collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field( - default_factory=_make_collector_registry, init=False -) -``` - -A brand-new `CollectorRegistry` is created per instrument instance. Any -`prometheus_client.Counter(...)` defined elsewhere using the default `REGISTRY` will not -be exposed on the FastStream metrics endpoint. FastMCP takes the opposite tack -(`prometheus_client.REGISTRY` — see `fastmcp_bootstrapper.py:120`); the two backends are -inconsistent. - -**Fix shape:** add a `prometheus_collector_registry: prometheus_client.CollectorRegistry | None = None` -config field on `FastStreamConfig` (or on `PrometheusConfig` for symmetry across -bootstrappers) and let the user inject the default `REGISTRY` if they want it. Default -behaviour stays the same (isolated registry) unless they opt in. - -#### UX-3 · `FastStreamConfig` is missing `opentelemetry_excluded_urls` - -**Files:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:63-72`, -`lite_bootstrap/instruments/opentelemetry_instrument.py:96-105` - -`FastAPIConfig` (`fastapi_bootstrapper.py:53`) and `LitestarConfig` -(`litestar_bootstrapper.py:114`) both declare `opentelemetry_excluded_urls`. `FastStreamConfig` -does not. `_build_excluded_urls` uses `getattr(..., "opentelemetry_excluded_urls", [])` -which silently returns `[]`. FastStream users with ASGI mounts (health, metrics, AsyncAPI) -can't exclude additional URLs from OTel tracing. - -**Fix shape:** add the field to `FastStreamConfig` matching the FastAPI/Litestar pattern. - -#### UX-4 · `InstrumentDependencyMissingWarning` invisible under `python -W ignore` - -**File:** `lite_bootstrap/bootstrappers/base.py:63-67` - -The "configured but optional dependency missing" path is the deployment surprise the -prior audit highlighted. It now uses `warnings.warn(..., InstrumentDependencyMissingWarning, -stacklevel=3)`. But: Python processes started with `-W ignore` or in environments that set -`PYTHONWARNINGS=ignore` will swallow this entirely. `build_summary()` (logged at INFO) is -the user's only fallback signal — and that may also be filtered. - -**Fix shape:** when `check_dependencies()` returns False on a configured instrument, also -emit a `logger.warning(...)` line in addition to the warning (warnings module decoupled -from logging). Keep the existing warning for tools that filter on category. Add a -`filterwarnings = ["error::InstrumentSkippedWarning"]` to `pyproject.toml [tool.pytest.ini_options]` -so the test suite escalates accidental regressions (see TEST-NEW-7). - -#### UX-5 · `from_object` cannot pass an explicit `None`; asymmetric with `from_dict` - -**File:** `lite_bootstrap/instruments/base.py:21-26` - -```python -prepared_data = {field: value for field in field_names if (value := getattr(obj, field, None)) is not None} -``` - -`from_dict({"service_name": None})` succeeds (`test_config.py:92-94`). `from_object(obj)` -where `obj.service_name = None` drops `service_name` and the default kicks in -(`test_config.py:54-62`). Both behaviors are now documented, but they're asymmetric. -A user migrating between the two will be surprised when "explicit None" works on one and -not the other. - -**Fix shape:** document the asymmetry inline (currently in docstrings but worth a sentence -under "Conventions" in CLAUDE.md). Or unify: drop the `is not None` filter on `from_object` -and accept any attribute that is *present*. Risk: external objects often have unrelated -`None`-valued attributes that would override defaults. Status quo is defensible; the -documentation fix is enough. - ---- - -## Lens 2 — Logic (correctness) - -### Prior-finding status - -| ID | Title | Status | Evidence | -|---|---|---|---| -| CRIT-1 | Redoc URL ignores `root_path` | **FIXED** | `redoc_html` now reads `root_path` from scope and prepends to `redoc_js_url` and `openapi_url`. (`fastapi_helpers.py:52-59`) | -| CRIT-2 | `OpenTelemetryInstrument.teardown()` doesn't shut down tracer provider | **FIXED** | `_tracer_provider` cached on the instrument; `teardown` calls `shutdown()` in a `try/finally` that resets the cache. (`opentelemetry_instrument.py:84-86, 121-123, 152-156`) | -| CRIT-3 | Litestar double-teardown not guarded | **FIXED** | `BaseBootstrapper.teardown()` returns immediately on `not self.is_bootstrapped`. (`bootstrappers/base.py:92-94`) | -| DES-1 | Per-framework instrument subclasses are pure type-annotation boilerplate | **FIXED** | `BaseInstrument(Generic[ConfigT])`; remaining subclasses all carry real bootstrap/teardown logic — no pure-annotation subclasses left. (`instruments/base.py:29-33`) | -| DES-2 | Duplicated OTel service fields across configs | **FIXED** | `OpenTelemetryServiceFieldsConfig` mixin; `OpenTelemetryConfig` and `PyroscopeConfig` both inherit. (`opentelemetry_instrument.py:36-39`, `pyroscope_instrument.py:14`) | -| DES-3 | `from_object` semantics differ from `from_dict` and aren't documented | **FIXED** | Both methods have docstrings; semantics pinned by tests. (`instruments/base.py:15-26`, `test_config.py:54-94`) | -| DES-4 | `skip_sentry` leaks into Sentry `contexts.structlog` | **FIXED** | `IGNORED_STRUCTLOG_ATTRIBUTES` now contains `"skip_sentry"`. (`sentry_instrument.py:19-21`) | -| DES-5 | Dead `is_X_installed` conjuncts in `is_ready()` | **FIXED** | `is_ready` replaced with `is_configured`; the redundant conjuncts are gone, the lifecycle is `is_configured → check_dependencies → instantiate`. (`bootstrappers/base.py:54-69`) | -| REF-1 | Duplicated `_build_excluded_urls()` | **FIXED** | Hoisted to `OpenTelemetryInstrument._build_excluded_urls`; framework subclasses use it. (`opentelemetry_instrument.py:96-105`) | -| REF-2 | Dead defensive check in `LitestarLoggingInstrument.bootstrap()` | **FIXED** | Branch removed; body is unindented. (`litestar_bootstrapper.py:159-175`) | -| REF-3 | `BaseInstrument` uses `abc.ABC` but no abstract methods | **FIXED** | Now just `Generic[ConfigT]`, no ABC, no `# noqa: B027`. (`instruments/base.py:32-49`) | -| REF-4 | `logging_instrument.py` is 212 lines doing four jobs | **FIXED** | Split into `logging_factory.py` (factory + serializer + protocols, 75 lines) and `logging_instrument.py` (config + instrument + tracer_injection, 179 lines). | -| REF-5 | `swagger_instrument.py` / `prometheus_instrument.py` carry no logic | **PARTIAL** | Files still tiny; module docstrings now explain the split (config in instruments, behavior in bootstrappers). Acceptable resolution. | -| REF-6 | `frozen=True` + `object.__setattr__` workaround | **PARTIAL** | `LoggingInstrument._logger_factory` and `OpenTelemetryInstrument._tracer_provider` now use non-frozen dataclasses with `init=False, default_factory=lambda: None`. `FastAPIConfig.application` still uses `object.__setattr__` inside `__post_init__` — but the pattern is now documented in CLAUDE.md as the project's accepted "frozen-config bypass" convention. | -| REF-7 | Hardcoded `timeout=5` in FastStream health check | **FIXED** | `faststream_health_check_broker_timeout: float = 5.0` added to `FastStreamConfig`. (`faststream_bootstrapper.py:71, 106`) | -| LOW-1 | `if not callback:` → `if callback is None` | **FIXED** | `sentry_instrument.py:81` | -| LOW-2 | `sentry_before_send` typing | **FIXED** | `sentry_types.EventProcessor | None`. (`sentry_instrument.py:36`) | -| LOW-3 | `_format_span` uses `os.linesep` | **FIXED** | Replaced with literal `"\n"`. (`opentelemetry_instrument.py:26`) | -| LOW-5 | `_otel_apps` keyed by `id(next_app)` | **FIXED** (per-spec: comment) | One-line comment added documenting the id-reuse assumption. (`litestar_bootstrapper.py:79-81`) | - -### New findings - -#### LOG-1 · `OpenTelemetryInstrument.teardown()` leaves a shut-down provider as the process-global - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:146-156` - -`bootstrap()` calls `set_tracer_provider(tracer_provider)` (line 122) — this sets the -**process-global** provider via the OTel SDK. `teardown()` calls -`self._tracer_provider.shutdown()` but does **not** reset the global. After teardown, -`opentelemetry.trace.get_tracer_provider()` still returns the shut-down provider; any -code that creates spans afterward (e.g., during a test that imports the OTel API but -doesn't bootstrap a new provider) emits to a dead provider and the spans go nowhere. - -This is invisible during normal microservice lifecycles (bootstrap once, run forever, exit) -but surfaces in test suites and any process that re-bootstraps (see LOG-7/LOG-8 below). - -**Fix shape:** after `self._tracer_provider.shutdown()`, set the global back to OTel's -no-op default: - -```python -from opentelemetry.trace import NoOpTracerProvider - -set_tracer_provider(NoOpTracerProvider()) -``` - -Document the constraint that `set_tracer_provider` is one-way in OTel < 1.27 (the SDK -warns "Overriding of current TracerProvider is not allowed" on second call); the no-op -reset works because OTel allows replacing with a no-op for shutdown. Validate against the -OTel version pinned in `pyproject.toml`. Add TEST-NEW-2. - -#### LOG-2 · OTel bootstrap silently disables two stdlib loggers permanently - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:107-109` - -```python -def bootstrap(self) -> None: - logging.getLogger("opentelemetry.instrumentation.instrumentor").disabled = True - logging.getLogger("opentelemetry.trace").disabled = True -``` - -These loggers are process-global. `teardown()` does not restore `disabled = False`. Any -other code in the same process — including unrelated OTel integrations the user wires up -themselves — has those loggers muted for the rest of the process life. Symmetric to LOW-9 -on the logging instrument (which is now documented but undocumented here). - -**Fix shape:** capture the previous `disabled` state in `bootstrap()`, restore in -`teardown()`. Or, drop the mutation entirely and tell users to set a `logging.Filter` on -their root config if they don't want OTel noise — but that's a behavior change. The -record-and-restore fix is the minimum-surprise option. - -#### LOG-3 · `LoggingInstrument.teardown()` unprotected root-handler loop - -**File:** `lite_bootstrap/instruments/logging_instrument.py:168-178` - -```python -def teardown(self) -> None: - structlog.reset_defaults() - root_logger = logging.getLogger() - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - h.close() # ← unprotected; can raise - root_logger.setLevel(logging.WARNING) - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - self._logger_factory = None -``` - -If `h.close()` raises mid-iteration, the remaining root handlers stay attached, the level -is never reset to WARNING, and `self._logger_factory.close_handlers()` is never reached. -The `_logger_factory` slot is also not nulled, so a follow-up `bootstrap` won't reset it -(actually it re-builds via `memory_logger_factory` getter, but the prior factory's -handlers stay dangling on their associated loggers). - -**Fix shape:** wrap the loop in `try/finally` so the level-reset and factory-cleanup -always run; aggregate `h.close()` errors and re-raise after cleanup, similar to -`BaseBootstrapper.teardown`'s `TeardownError` pattern (`bootstrappers/base.py:96-105`). -Add TEST-NEW-3. - -#### LOG-4 · `FastStreamLoggingInstrument.bootstrap()` mutates broker state with no symmetric teardown - -**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:110-120` - -```python -def bootstrap(self) -> None: - super().bootstrap() - broker = self.bootstrap_config.application.broker - if broker is not None and import_checker.is_structlog_installed and import_checker.is_faststream_installed: - logger = structlog.get_logger("faststream") - logger.setLevel(self.bootstrap_config.faststream_log_level) - broker.config.logger.params_storage = ManualLoggerStorage(logger) -``` - -`broker.config.logger.params_storage` is mutated. No `teardown()` override — the parent -`LoggingInstrument.teardown()` only handles structlog + root logger. After teardown the -broker keeps the lite-bootstrap structlog logger as its `params_storage`. In a long-lived -test process, subsequent `bootstrap()` calls overwrite it (idempotent enough), but a user -who tears down and expects "default broker logger" gets the lite-bootstrap one. - -**Fix shape:** save the prior `params_storage` in `bootstrap()`, restore in a new -`teardown()` override on `FastStreamLoggingInstrument`. Or document that the broker logger -mutation is permanent (matching the LOW-8 precedent on `_unset_handlers`). - -#### LOG-5 · Assertion-based type narrowing is stripped under `python -O` - -**Files:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:78-80`, -`lite_bootstrap/instruments/pyroscope_instrument.py:35-37` - -Both raised by `bandit` B101. The asserts are not safety checks — they're type-narrowing -preconditions: - -```python -def _narrow_app(config: "FastAPIConfig") -> "fastapi.FastAPI": - assert not isinstance(config.application, UnsetType) - return config.application -``` - -Under `python -O` (production optimization flag for some deployments), the assert is -stripped. `_narrow_app` then returns `config.application` which can still be `UNSET` if -something bypassed `__post_init__` (`dataclasses.fields(FastAPIConfig)` mutation, -`from_dict` from an external source, etc.). Downstream FastAPI calls receive `UNSET` and -raise an opaque `TypeError` instead of a clear `RuntimeError` from the narrow function. - -Same in pyroscope: `assert self.bootstrap_config.pyroscope_endpoint is not None` is a -documented precondition for direct callers that bypass `is_configured`. - -**Fix shape:** replace both with explicit raises: - -```python -def _narrow_app(config: "FastAPIConfig") -> "fastapi.FastAPI": - if isinstance(config.application, UnsetType): - raise RuntimeError("FastAPIConfig.application is unset; __post_init__ did not run") - return config.application -``` - -Keep `# noqa: TRY003` (or extract to a constant) for the message. Updates resolve both -bandit B101 findings; both functions remain trivial. - -#### LOG-6 · `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps` cache grows monotonically - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:75-99` - -The `_otel_apps: dict[int, ASGIApp]` cache is keyed by `id(next_app)` and never evicted. -LOW-5 in the prior audit documented the id-reuse-after-GC assumption (now noted in a -comment). The remaining issue: in long-lived processes where Litestar rebuilds the -middleware chain (hot-reload, plugin add/remove, `from_config` on a mutated `AppConfig`), -old `next_app` references remain in the dict, holding their `OpenTelemetryMiddleware` -wrapper alive, which holds a reference to the user's ASGI app. Slow memory growth. - -Practical impact is low for typical microservice deployments (chain is built once at -startup). Theoretical for hot-reload setups. - -**Fix shape:** if the same `next_app` reference is the only key ever seen, the cache is -overkill — replace with a single `self._otel_app` and check `next_app is self._cached_app`. -If multiple chains do legitimately exist, switch to `weakref.WeakValueDictionary` so -garbage collection of `next_app` evicts its OTel wrapper. - -#### LOG-7 · `FastMcpBootstrapper.__init__` stacks `_TeardownProvider`s on app reuse - -**File:** `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py:152-154` - -```python -def __init__(self, bootstrap_config: FastMcpConfig) -> None: - super().__init__(bootstrap_config) - self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown)) -``` - -If a user constructs two `FastMcpBootstrapper` instances around the same FastMCP -`application` (e.g., a test that reuses the default `_make_fastmcp()` instance via -`FastMcpConfig()` twice — though current default factory creates a new one each call, -custom code may inject the same app), each adds its own `_TeardownProvider`. On ASGI -shutdown both teardowns fire. `BaseBootstrapper.teardown()` is idempotent so the second -teardown is a no-op, but it still iterates `self.instruments` and short-circuits on -`is_bootstrapped=False` — no harm but extra overhead and confusing logs. - -**Fix shape:** track whether this bootstrapper already attached its provider; refuse to -re-attach. Or document that one bootstrapper per FastMCP instance is required. - -#### LOG-8 · `FastAPIBootstrapper.__init__` stacks lifespan wrappers on app reuse - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:197-205` - -Same pattern as LOG-7: `_merge_lifespan_context(old, self.lifespan_manager)` builds a -chain. Constructing the bootstrapper twice against the same `fastapi.FastAPI()` instance -stacks two lifespan wrappers; on shutdown, both `finally` branches call `self.teardown()`. -Teardown is idempotent, so the second call short-circuits — practical impact: extra -log lines and stack-depth. Not a correctness bug, but a footgun for tests that -reuse a `FastAPI` app fixture across bootstrappers. - -**Fix shape:** detect that `application.router.lifespan_context` is already wrapped by -this bootstrapper (e.g., via a sentinel attribute on the bound method) and skip the -re-wrap. Or document the constraint. - -#### LOG-9 · `SentryInstrument` has no `teardown()` override - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:94-125` - -`sentry_sdk.init(dsn=..., before_send=..., ...)` is process-global. After -`bootstrapper.teardown()`, Sentry remains initialized and continues to capture events. -For the intended lifecycle ("bootstrap once, run forever, exit") this is fine. For test -processes that bootstrap-and-teardown repeatedly, Sentry state leaks between cases — the -test suite works around this by calling `sentry_sdk.init()` in `finally` blocks -(`test_sentry_instrument.py:43, 65`), which is undocumented friction. - -**Fix shape:** add an explicit `teardown()` that calls `sentry_sdk.flush()` and then -`sentry_sdk.init()` (with no args, resetting the SDK to the no-op state). Document the -test-suite friction goes away as a side effect. - ---- - -## Lens 3 — Security - -### Prior-finding status - -The 2026-05-31 audit had no security category. None of its CRIT/DES/REF/LOW items -classify as security. All security findings below are new. - -External tooling outcomes: - -- **`pip-audit`** (against the lockfile exported via `uv export --all-extras --no-hashes`): - no known vulnerabilities in 133 packages. Result is current as of 2026-06-05. -- **`bandit -r lite_bootstrap`**: two low-severity B101 (`assert` usage) findings, - both folded into LOG-5 above. - -### New findings - -#### SEC-1 · `root_path` reflected into Swagger/Redoc HTML without escaping - -**File:** `lite_bootstrap/helpers/fastapi_helpers.py:37-59` - -```python -root_path = request.scope.get("root_path", "").rstrip("/") -return get_swagger_ui_html( - openapi_url=f"{root_path}{app_openapi_url}", - ... - swagger_js_url=f"{root_path}{static_path}/swagger-ui-bundle.js", - ... -) -``` - -`get_swagger_ui_html` (FastAPI builtin) interpolates these URLs into `"`, assert HTML response does NOT contain - the injected literal AND a warning was emitted. -- `tests/instruments/test_opentelemetry_instrument.py::test_warns_on_insecure_non_local_endpoint`: - bootstrap with a remote endpoint and `opentelemetry_insecure=True`; assert warning. - -### Decisions (locked) - -| Decision | Choice | -|----------|--------| -| UX-1 strategy | Move override inside UnsetType branch; never stomp user values | -| UX-2 default | Keep per-instance isolated registry; opt-in injection | -| SEC-1 method | Validate via `is_valid_path` (consistent with existing path handling) | -| SEC-2 default | Keep `insecure=True`; warn on non-local endpoint | -| SEC-3 scope | Reject at config construction (not at instrument bootstrap) | - -### Files - -Source: -- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` -- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` -- `lite_bootstrap/helpers/fastapi_helpers.py` -- `lite_bootstrap/instruments/cors_instrument.py` -- `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Tests: -- `tests/test_fastapi_bootstrap.py` -- `tests/test_fastapi_offline_docs.py` -- `tests/test_faststream_bootstrap.py` -- `tests/instruments/test_cors_instrument.py` -- `tests/instruments/test_opentelemetry_instrument.py` - -### Risk - -Low–Medium. Each change is additive or refactors within a single `__post_init__`. The -CORS validation could surprise existing users with permissive configs — flag in the -release notes. - ---- - -## PR3: Hygiene + CI gate - -**Branch:** `chore/bug-audit-v2-pr3-hygiene` -**Findings:** UX-4, UX-5, SEC-5, TEST-NEW-7 - -### Scope - -Pure-chore PR. No production code paths change. Bundled so the hygiene fixes land -together as a single small diff. - -#### Sub-section A — Dual-channel skip signal (UX-4) - -`lite_bootstrap/bootstrappers/base.py:62-67`: after the existing -`warnings.warn(...)`, add `logger.warning("instrument %s skipped: %s", instrument_type.__name__, instrument_type.missing_dependency_message)`. -Two channels — users who suppress one still see the other. - -#### Sub-section B — `from_object` asymmetry doc (UX-5) - -`CLAUDE.md` under the "Conventions" section: add a one-paragraph note explaining -that `from_dict` accepts explicit `None` (overrides default) while `from_object` -filters `None` (default wins). Reference the docstrings and tests that pin it. - -#### Sub-section C — Escalate warning regressions (TEST-NEW-7) - -`pyproject.toml [tool.pytest.ini_options]`: add - -```toml -filterwarnings = [ - "error::lite_bootstrap.exceptions.InstrumentSkippedWarning", -] -``` - -Verify the existing tests still pass — they should because every dependency-missing -test already uses `pytest.warns(...)` to opt in. - -#### Sub-section D — CI dependency audit (SEC-5) - -`.github/workflows/security-audit.yml` (new file; check that no equivalent step exists -in the current workflows first): - -```yaml -name: security-audit -on: - pull_request: - schedule: - - cron: "0 6 * * 1" # weekly Monday -jobs: - pip-audit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v3 - - run: | - uv export --all-extras --no-hashes > /tmp/reqs.txt - uv tool install pip-audit - pip-audit --no-deps --disable-pip -r /tmp/reqs.txt -``` - -### Tests - -No new tests. TEST-NEW-7 changes pytest config; verification is that the existing -suite still passes (`just test`). - -### Decisions (locked) - -| Decision | Choice | -|----------|--------| -| UX-4 channels | Both `warnings.warn` AND `logger.warning` | -| SEC-5 tool | `pip-audit` over `safety` (OSV-backed, uv-native) | -| SEC-5 cadence | Per-PR + weekly cron | -| TEST-NEW-7 scope | Escalate only `InstrumentSkippedWarning` subclasses | - -### Files - -- `lite_bootstrap/bootstrappers/base.py` -- `CLAUDE.md` -- `pyproject.toml` -- `.github/workflows/security-audit.yml` - -### Risk - -Very low. No production behavior change; CI step is additive. Worst case is the -filterwarnings change surfaces a pre-existing leak in the suite, in which case fix -the leaky test inline. - ---- - -## Branch hygiene & CI - -- PR1 lands first; PR2 and PR3 can land in any order after PR1, or in parallel - branches off `main`. -- Each PR runs `just lint-ci` and `just test` via existing CI. -- PR1 additionally verifies the new tests fail on `main` for the LOG-1..9 cases (each - test should fail when the fix is reverted — this is the regression proof). -- Squash-merge each PR before the next branches off (matches the project's recent - pattern of `fix:` / `feat:` / `chore:` prefixes). - ---- - -## Cross-PR locked decisions - -| Decision | Choice | -|----------|--------| -| Granularity | 3 PRs by review-mental-model theme | -| Order | Lifecycle first (largest mental load while reviewers are fresh) | -| PR2 / PR3 parallelism | Allowed; both independent of each other and of PR1 | - ---- - -## Deferred (out of scope for this plan) - -None. The audit's 26 new findings all map to a PR above. - -If even 3 PRs feels too coarse, the [9-PR finer-grained sequencing] can be reconstructed -from the audit by mapping: - -- PR1 splits into: assert→raise (1 finding), OTel teardown (2), teardown robustness (3), - app-reuse safety (3). -- PR2 splits into: FastAPI user app (1), FastStream gaps (2), security hardening (3). -- PR3 splits into: warning visibility (1), from_object docs (1), filterwarnings (1), - CI gate (1). - -The 3-PR grouping is the recommended ship cadence; finer splits are available if review -pressure or hot-fix urgency requires. - -If even 3 PRs is too much to commit to right now, the lowest-priority items that could -be deferred without risk: - -- LOG-6 (`_otel_apps` memory growth): theoretical for hot-reload setups only. -- LOG-7 / LOG-8 (re-bootstrap stacking): produces extra log lines / stack depth but no - correctness break thanks to idempotent teardown (CRIT-3 fix). -- UX-5 (`from_object` asymmetry): documentation only. - -These three together would shrink PR1 modestly and PR3 marginally, but the savings are -not large enough to justify a fourth split. diff --git a/planning/changes/2026-06-09.01-mkdocs-github-pages.md b/planning/changes/2026-06-09.01-mkdocs-github-pages.md deleted file mode 100644 index 8555b92..0000000 --- a/planning/changes/2026-06-09.01-mkdocs-github-pages.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -summary: Docs hosting moved from Read the Docs to GitHub Actions + Pages. ---- -# Migrate docs from Read the Docs to GitHub Actions + Pages - -Date: 2026-06-09 -Status: Approved (pending written-spec review) - -## Goal - -Replace the current Read the Docs build pipeline with a GitHub Actions workflow that builds the MkDocs site and deploys it to `gh-pages`, served by GitHub Pages under the custom subdomain `lite-bootstrap.modern-python.org`. Mirror the setup already in use in the sibling `modern-di` project so both repos in the `modern-python` org share one deployment pattern. - -## Current state - -- `.readthedocs.yaml` at repo root drives RTD builds. RTD installs `docs/requirements.txt` and runs `mkdocs build` against `mkdocs.yml`. -- Site is served at `lite-bootstrap.readthedocs.io` (set as the GitHub repo `homepageUrl`). -- `mkdocs.yml` has no `site_url`. No `CNAME` file. No `[project.urls] docs` entry. -- No GitHub Actions workflow for docs. `ci.yml` only runs lint + pytest. - -## Reference - -`modern-di` (sibling repo at `../modern-di/`) already runs this pattern: - -- `.github/workflows/docs.yml` — push-to-main + `workflow_dispatch`, paths-filtered to docs files. -- `Justfile` recipe `docs-deploy` — `uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force`. -- `mkdocs.yml` — `site_url: https://modern-di.modern-python.org`. -- `docs/CNAME` — single line `modern-di.modern-python.org`. -- `pyproject.toml` — `[project.urls] docs = "https://modern-di.modern-python.org"`. - -This spec copies that pattern with names changed for lite-bootstrap. - -## Changes - -### 1. `.github/workflows/docs.yml` (new) - -Verbatim from `modern-di/.github/workflows/docs.yml`: - -```yaml -name: Deploy Docs - -on: - push: - branches: [main] - paths: - - "docs/**" - - "mkdocs.yml" - - ".github/workflows/docs.yml" - workflow_dispatch: - -concurrency: - group: docs-deploy - cancel-in-progress: true - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v8.2.0 - - run: just docs-deploy -``` - -Notes: - -- `paths` filter prevents redeploys on unrelated commits. -- `concurrency: docs-deploy` + `cancel-in-progress: true` prevents stale-checkout overwrites if two pushes land near-simultaneously. -- `permissions: contents: write` is required for `mkdocs gh-deploy --force` to push to `gh-pages`. -- `fetch-depth: 0` is required by `mkdocs gh-deploy` to update the `gh-pages` branch (shallow clone would fail). -- Action versions (`checkout@v6`, `setup-just@v4`, `setup-uv@v8.2.0`) match modern-di. The lite-bootstrap `ci.yml` uses older versions; that stays unchanged here. - -### 2. `Justfile` — append `docs-deploy` recipe - -``` -# Force-pushes built site to gh-pages; CI runs this on push to main. -# Manual invocation from a stale checkout will roll the live site back. -docs-deploy: - uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force -``` - -Identical to modern-di. `uvx --with-requirements docs/requirements.txt` keeps docs dependencies isolated from the main project lockfile (mkdocs is not declared in `pyproject.toml`). - -### 3. `mkdocs.yml` — add `site_url` - -Insert immediately under `site_name`: - -```yaml -site_url: https://lite-bootstrap.modern-python.org -``` - -Needed for canonical URLs in ``, the generated `sitemap.xml`, and Material theme features that depend on absolute URLs. - -### 4. `docs/CNAME` (new) - -Single line: - -``` -lite-bootstrap.modern-python.org -``` - -`mkdocs gh-deploy` copies the file into the built site root; GitHub Pages reads it from the `gh-pages` branch to serve the custom domain. - -### 5. `pyproject.toml` — add `docs` URL - -Update `[project.urls]`: - -```toml -[project.urls] -repository = "https://github.com/modern-python/lite-bootstrap" -docs = "https://lite-bootstrap.modern-python.org" -``` - -This becomes the PyPI "Documentation" link on the next release. - -### 6. `.readthedocs.yaml` — delete - -Once removed, RTD will stop triggering builds on push. The RTD project record on readthedocs.io is unchanged by this PR and should be archived manually post-cutover (see "Out of repo scope" below). - -## Out of repo scope (operator follow-ups) - -These actions happen outside the repo and are not part of the PR diff. They are listed here so the operator does not miss them: - -1. **DNS** — add a `CNAME` record on the `modern-python.org` zone: `lite-bootstrap` → `modern-python.github.io`. Same setup as the existing `modern-di.modern-python.org` record. -2. **First workflow run** — first push to `main` after merge will create the `gh-pages` branch. The workflow uses `gh-deploy --force`, so the initial create is automatic. -3. **GitHub Pages enablement** — repo admin: Settings → Pages → Source: "Deploy from a branch" → `gh-pages` / `(root)`. The custom-domain field auto-populates from the `CNAME` file in the branch. -4. **GitHub repo homepage** — `gh repo edit --homepage https://lite-bootstrap.modern-python.org` (currently set to the RTD URL). -5. **Read the Docs project archival** — on the readthedocs.io dashboard, mark the project as archived after the new site is verified live. Not done in this PR so that RTD remains a fallback during cutover. - -## Out of design scope (deliberate non-goals) - -- **Action-version bumps in `ci.yml`** — modern-di uses `checkout@v6` / `setup-just@v4` / `setup-uv@v8.2.0` and split its workflow into a reusable `_checks.yml`. The lite-bootstrap `ci.yml` still uses `checkout@v4` / `setup-just@v2` / `setup-uv@v3` and is monolithic. Not touched here; the new `docs.yml` uses current versions because it is new code. -- **Build-on-PR check** — modern-di does not run `mkdocs build --strict` on PRs and neither will lite-bootstrap. Cheap to add later if broken docs slip through. -- **Doc content changes** — `nav`, theme, extensions are unchanged. - -## Risk and rollback - -- **First deploy failure** is harmless — the new domain has no users yet and RTD still works until `.readthedocs.yaml` is deleted (this happens in the same PR, so a failure on the first run leaves the site temporarily unbuilt until fixed; acceptable for a low-traffic library docs site). -- **Stale-checkout rollback** — `gh-deploy --force` overwrites `gh-pages` from whatever the checkout sees. The Justfile comment documents this. Manual invocation from an out-of-date local clone will roll the live site backward. CI is the only intended caller. -- **Reverting** — revert the PR; RTD config returns; RTD resumes building automatically. The RTD project remains linked to the repo until manually archived, so the fallback is always available. - -## Acceptance criteria - -- [ ] `docs.yml` workflow exists and runs on push to `main` when docs files change. -- [ ] `just docs-deploy` runs locally without error (Justfile recipe wired up; `uvx` reachable). -- [ ] `mkdocs.yml` declares `site_url: https://lite-bootstrap.modern-python.org`. -- [ ] `docs/CNAME` contains the custom domain on a single line, no trailing newline issues. -- [ ] `pyproject.toml` `[project.urls]` includes a `docs` key pointing at the custom domain. -- [ ] `.readthedocs.yaml` is removed. -- [ ] After merge: workflow runs green, `gh-pages` branch is created, GH Pages serves the site under the custom domain, repo homepage URL updated. diff --git a/planning/changes/2026-06-13.01-portable-planning-convention.md b/planning/changes/2026-06-13.01-portable-planning-convention.md deleted file mode 100644 index 4db35ed..0000000 --- a/planning/changes/2026-06-13.01-portable-planning-convention.md +++ /dev/null @@ -1,394 +0,0 @@ ---- -summary: Adopt the portable two-axis convention: `architecture/` truth home + `changes/` bundles, per-arc bundling of the audit arcs, fresh Index. ---- - -# Design: Adopt the portable OpenSpec-shaped planning convention - -## Summary - -Replace `lite-bootstrap`'s ad-hoc `planning/` layout (`specs/` mixing design -specs + audits + retros + sequencing docs, a flat `plans/`, a bespoke -`templates/lightweight-plan-template.md`) with the **portable two-axis -convention** already shipped in `faststream-outbox`: a new `architecture/` -directory at the repo root holds the *living truth* — what the system does now, -one file per capability — and `planning/changes/` holds the *change history*, -each change a self-contained folder bundle, `active/` → `archive/`. Shipping a -change **promotes** its conclusions into the relevant `architecture/.md` -by hand, then archives the bundle. - -The convention prose (`planning/README.md` "Conventions" section) and the three -templates (`_templates/{design,plan,change}.md`) are copied **byte-identical** -from `faststream-outbox`; only the repo-specific "Index" is authored fresh. The -existing `planning/` artifacts are migrated into the new shape: design+plan -pairs become bundles, audits go to `audits/`, retros go to `retros/`, -sequencing docs become the `design.md` of per-arc bundles, and the -lightweight-plan template is deleted in favor of the copied `change.md`. - -Because this repo has **no `architecture/` today**, the migration also seeds it -with three capability files drawn from `CLAUDE.md`'s Architecture section and -the existing `docs/integrations/` pages, so promotion has a real target from day -one. `CLAUDE.md`'s `## Planning artifacts` section is rewritten into a -`## Workflow` section naming `architecture/` as the promotion target. - -This change touches no runtime code, no test code, and no public API. It is the -`lite-bootstrap` instance of the same convention adopted in `faststream-outbox` -(its `portable-planning-convention`, #77). - -## Motivation - -`lite-bootstrap`'s `planning/` directory grew organically and now mixes four -distinct artifact kinds in two flat directories, with no convention document and -no living-truth home: - -- **`planning/specs/` conflates three things.** It holds actual design specs - (`*-design.md`), audit findings reports (`bug-refactor-audit.md`, - `bug-audit-v2.md`), retros (`*-retro.md`), and sequencing docs - (`*-sequencing.md` — plans-of-plans that order many PRs). A reader cannot tell - artifact kind from location. - -- **`planning/plans/` is a flat pile of ~23 files.** Most are per-PR execution - plans (`pr1`…`pr16`) spawned by one audit + one sequencing doc, with no - grouping back to the arc that produced them. The relationship between a - sequencing doc and its PR plans is implicit. - -- **No living-truth home.** The closest thing to "what the system does now" is - `CLAUDE.md`'s Architecture section, which is AI-instruction prose, not a - navigable capability reference. There is no step that forces it to stay - current when behavior changes. - -- **No convention document.** There is no `planning/README.md`; the layout is - undocumented and diverges from the sibling repos (`faststream-outbox`, - `httpware`, `modern-di`). `faststream-outbox` has already converged on a - portable convention (#77) designed to drop into the other three repos; this - change is that adoption for `lite-bootstrap`. - -The portable convention resolves all four: it separates living truth -(`architecture/`) from change history (`planning/changes/`), names the -promotion boundary, gives audits/retros/sequencing dedicated homes, and ships a -single documented convention identical across the ecosystem. - -## Non-goals - -- **Rewriting or trimming archived prose.** Existing shipped specs/plans/audits/ - retros move into the new layout verbatim; only their location and (for bundle - `design.md` files) frontmatter linkage change. - -- **Retrofitting frontmatter onto every historical plan file.** Each migrated - bundle's `design.md` gets full YAML frontmatter; the grouped per-PR plan files - inside an arc bundle keep their original prose headers (see Design §4). Adding - 9+ frontmatter blocks to frozen executor checklists is double-entry bookkeeping - with no payoff for archived material. - -- **Authoring exhaustive `architecture/` capability prose.** The three seed files - capture current truth at the level `CLAUDE.md` + `docs/` already document it. - They are a real starting point, not a from-scratch system manual; future - changes deepen them via promotion. - -- **Formal OpenSpec spec-deltas.** No `ADDED`/`MODIFIED`/`REMOVED` blocks. - Promotion is a hand-edit of the affected `architecture/.md`, - recoverable via `git log -p`. (Same decision as `faststream-outbox` #77.) - -- **An index generator or frontmatter-lint CI job.** The README Index stays - hand-maintained. - -- **Rolling the convention out to `httpware` / `modern-di`.** Out of scope; each - is separate demand-gated work. - -- **mkdocs-serving `planning/` or `architecture/`.** `docs_dir: docs`, so both - are already excluded from the site; this change does not add them. - -## Design - -### 1. The model: two axes, never mixed - -The convention rests on one distinction, identical to `faststream-outbox`: - -> **`architecture/` (repo root) is the present.** One file per capability, -> describing what the system does *now*. Living prose, updated whenever a change -> ships. The truth home and promotion target. -> -> **`planning/changes/` is the past-and-pending.** One folder per change, -> describing how a piece of behavior got (or will get) there. Frozen once -> shipped. - -A reader wanting current truth reads `architecture/`; a reader wanting the -rationale follows a promotion back to the archived change bundle. The two spaces -are two top-level homes (`architecture/` at root, `planning/` for history); -naming the boundary — not co-locating — removes the muddle. - -### 2. Target directory layout - -``` -architecture/ # LIVING TRUTH (new) — promotion target - config-model.md - instruments.md - bootstrappers.md - -planning/ - README.md # Conventions (byte-identical) + Index (fresh) - changes/ - active/ - .gitkeep # empty after migration (all prior work shipped) - archive/ - / # one folder per shipped change - design.md # spec — the thinking (FULL lane) - plan.md # plan — the sequencing (FULL lane) - # OR change.md # single file (LIGHTWEIGHT lane) - audits/ - 2026-05-31-bug-refactor-audit.md - 2026-06-05-bug-audit-v2.md - retros/ - 2026-06-01-audit-implementation-retro.md - 2026-06-05-bug-audit-v2-retro.md - 2026-06-09-docs-and-ci-modern-di-mirror-retro.md - releases/ - 1.1.0.md # unchanged - deferred.md # NEW — standard header, "none today" - _templates/ - design.md plan.md change.md # copied byte-identical from faststream-outbox -``` - -After migration, `planning/specs/`, `planning/plans/`, and -`planning/templates/` no longer exist. - -### 3. `architecture/` seed — three capability files - -This repo has no `architecture/` today, so the migration creates it and seeds -three capability files. Content is drawn from `CLAUDE.md`'s Architecture section -(core pattern, key design decisions, module layout) and the existing -`docs/integrations/` pages — internal capability truth, written as living prose -with **no frontmatter** (dated by git): - -- **`architecture/config-model.md`** — `BaseConfig` (frozen, `kw_only` - dataclasses); the `from_dict` vs `from_object` None-handling asymmetry; - `UnsetType` / `UNSET` sentinel and the `FastAPIConfig.application` - construction; the `__post_init__` cascade invariant (every config - `__post_init__` calls `super().__post_init__()`; `BaseConfig` terminates the - chain). - -- **`architecture/instruments.md`** — `BaseInstrument[ConfigT]` generic, - non-frozen dataclass with slots; lifecycle via `bootstrap()` / `teardown()`; - skip check via `is_configured()`; the instrument catalog (logging, - opentelemetry, sentry, prometheus/metrics, pyroscope, cors, swagger, health); - optional-dependency guard (`import_checker.is_X_installed`); the logging↔Sentry - and OTel↔logging integrations; the OpenTelemetry single-instance-per-process - constraint. - -- **`architecture/bootstrappers.md`** — `BaseBootstrapper` (abc) and the five - framework bootstrappers (FastAPI, Litestar, FastStream, FastMcp, Free); the - instrument registry; `is_configured → check_dependencies → instantiate` - ordering with `skipped_instruments`; reverse-order idempotent teardown; the - `build_summary()` log line; the `_lite_bootstrap_*` app-tagging sentinel - convention. - -Three cohesive files are the starting granularity; finer splits (e.g. one file -per instrument) can come later via normal changes. - -### 4. Migration mapping - -#### 4a. Clean design+plan pairs → one full bundle each - -Each existing `*-design.md` + its matching plan becomes a bundle under -`changes/`: - -| Bundle | design.md ← | plan.md ← | -|--------|-------------|-----------| -| `…-fastmcp-bootstrapper/` | `specs/2026-06-01-fastmcp-bootstrapper-design.md` | `plans/2026-06-01-fastmcp-bootstrapper.md` | -| `…-instrument-skip-rework/` | `specs/2026-06-01-instrument-skip-rework-design.md` | `plans/2026-06-01-instrument-skip-rework.md` | -| `…-stdlib-logging-and-build-summary/` | `specs/2026-06-02-stdlib-logging-and-build-summary-design.md` | `plans/2026-06-02-stdlib-logging-and-build-summary.md` | -| `…-mkdocs-github-pages/` | `specs/2026-06-09-mkdocs-github-actions-design.md` | `plans/2026-06-09-mkdocs-github-actions-plan.md` | - -#### 4b. Audit arcs → per-arc bundle - -An audit arc is `1 audit → 1 sequencing doc → many PR plans`. Per-arc bundling: -the **sequencing doc becomes the bundle's `design.md`** (it is the design-level -"why this set of PRs, in this order"); the wave's per-PR execution plans are -grouped into the same folder as `plan-prN-.md`; the audit findings report -goes to `audits/` and the retro to `retros/`. - -| Bundle | design.md ← (sequencing) | grouped per-PR plans (← `plans/`) | -|--------|--------------------------|-----------------------------------| -| `2026-05-31.NN-audit-implementation/` | `audit-implementation-sequencing.md` | `pr1-crit1-redoc-root-path`, `pr2-crit2-otel-shutdown`, `pr3-crit3-idempotent-teardown`, `pr4-des4-des5-small-cleanups`, `pr5-des3-config-method-semantics`, `pr6-des2-otel-fields-mixin`, `pr7-des1-generic-instruments` | -| `2026-06-01.NN-deferred-refactors/` | `deferred-refactors-sequencing.md` | `pr8-low-1-2-sentry-micro`, `pr9-otel-touch-ups`, `pr10-test-gap-fill`, `pr11-logging-cleanup`, `pr12-base-layer-cleanup`, `pr13-frozen-setattr`, `pr14-faststream-timeout`, `pr15-naming-pass`, `pr16-post-retro-hygiene` | -| `2026-06-05.NN-bug-audit-v2/` | `bug-audit-v2-sequencing.md` | `pr1-lifecycle`, `pr2-config-security`, `pr3-hygiene-ci` | - -Parent audits → `audits/`: -- `specs/2026-05-31-bug-refactor-audit.md` → `audits/2026-05-31-bug-refactor-audit.md` -- `specs/2026-06-05-bug-audit-v2.md` → `audits/2026-06-05-bug-audit-v2.md` - -Retros → `retros/`: -- `specs/2026-06-01-audit-implementation-retro.md` → `retros/2026-06-01-audit-implementation-retro.md` -- `specs/2026-06-05-bug-audit-v2-retro.md` → `retros/2026-06-05-bug-audit-v2-retro.md` -- `specs/2026-06-09-docs-and-ci-modern-di-mirror-retro.md` → `retros/2026-06-09-docs-and-ci-modern-di-mirror-retro.md` - -#### 4c. `.NN` assignment and frontmatter - -- **`.NN`** (zero-padded intra-day counter) is assigned by **merge order** per - date; PR numbers referenced in the existing docs give the order. Where two - bundles share a date (notably `2026-06-01`, which has `instrument-skip-rework`, - `fastmcp-bootstrapper`, and `deferred-refactors`), `.01`/`.02`/`.03` break the - tie. A cosmetic mis-order is harmless — both bundles still exist and sort - adjacently. Exact `.NN` values are assigned in the implementation plan. - -- **Frontmatter (pragmatic retrofit):** each bundle's `design.md` gets full YAML - frontmatter (`status: shipped`, `date`, `slug`, `supersedes`/`superseded_by`, - `pr`, `outcome`). For clean-pair bundles, `plan.md` gets `plan.md` frontmatter. - The **grouped per-PR plan files inside arc bundles keep their original prose - headers** — they are frozen executor checklists, and the bundle's `design.md` - carries the lifecycle metadata for the whole arc. This is a deliberate scope - cut (see Non-goals): we do not author 9+ new frontmatter blocks for archived - checklists. `instrument-skip-rework` is partially superseded by - `stdlib-logging-and-build-summary`; that linkage is preserved via - `supersedes`/`superseded_by` on the two `design.md` files. - -#### 4d. Other moves - -- `planning/templates/lightweight-plan-template.md` → **deleted** (superseded by - the copied `_templates/change.md`). -- `planning/releases/1.1.0.md` → **unchanged**, stays at `planning/releases/`. -- `git mv` is used throughout to preserve blame. - -### 5. The convention doc (`planning/README.md`) - -Two sections: - -1. **Conventions** — copied **byte-identical** from - `faststream-outbox/planning/README.md` (the two-axis model, change-bundle - identity, three lanes, frontmatter schema, audits/retros/releases/deferred/ - templates). This is the portable core, identical across repos. The only edit - is to the prose that names this repo's truth home where the abstract - "truth home" needs a concrete instance — kept consistent with how - `faststream-outbox` references its own `architecture/`. -2. **Index** — authored **fresh** for `lite-bootstrap`. Lists Active (none after - migration) and Archived (all migrated bundles, one line each with PR + date), - plus an "Other" pointer block to `architecture/` (the promotion target), - `audits/`, and `retros/`. - -### 6. Three ceremony lanes (carried in the Conventions section) - -| Lane | Artifact(s) | Use when | -|------|-------------|----------| -| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | -| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | - -Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits into -`design.md` + `plan.md`. - -### 7. `_templates/` - -Copy all three template files byte-identical from -`faststream-outbox/planning/_templates/`: `design.md`, `plan.md`, `change.md`. -Their `changes/active/` path references are already correct for this layout. - -### 8. `CLAUDE.md` update - -This repo's `CLAUDE.md` has a `## Planning artifacts` section (not a -`## Workflow` section). Rewrite it into a `## Workflow` section mirroring -`faststream-outbox`: - -1. Per-feature pipeline: brainstorming → spec in - `planning/changes/active/YYYY-MM-DD.NN-/design.md` → writing-plans → - `plan.md` → executing-plans / subagent-driven-development → - requesting-code-review → finishing-a-development-branch. -2. On merge: bundle moves to `planning/changes/` with `status: shipped`, - `pr:`, `outcome:` filled, **and the change promotes its conclusions into the - affected `architecture/.md`** — name `architecture/` explicitly as - the promotion target. -3. The spec/plan/architecture artifact-boundary paragraph. -4. The three-lane paragraph. -5. Pointers to `planning/README.md` and `planning/_templates/`. - -The `## Architecture` section's existing pointers are untouched. The "Planning -artifacts" content that still applies (the `planning/specs/` vs `planning/plans/` -distinction) is replaced wholesale by the new convention. - -### 9. justfile — add `docs-build` - -The convention's Testing step references `just docs-build`, but this repo's -justfile has only `docs-deploy` (gh-deploy). Add a check-only target: - -``` -docs-build: - uvx --with-requirements docs/requirements.txt mkdocs build --strict -``` - -Small and optional; makes the verification step repeatable and matches -`faststream-outbox`. - -### 10. `deferred.md` - -Create `planning/deferred.md` with the standard header (copied from -`faststream-outbox`, adapted), recording that there are **no deferred items -today**. It is the long-tail register of real-but-unscheduled items with revisit -triggers; items graduate from here into `changes/active/` bundles. - -### 11. Dogfood — this change is its own bundle - -This adoption is itself a change, so it lands as -`planning/changes/2026-06-13.01-portable-planning-convention.md`: - -- This `design.md` is written there now (during brainstorming) — the first use - of the new layout. -- The implementation plan is written to `plan.md` in the same folder. -- On merge, the bundle moves to `changes/` with `status: shipped`, - `pr:`, `outcome:` filled, and its line moves to Archived in the README Index. - No `architecture/` promotion applies — this change defines the convention - (which lives in `README.md`) and seeds `architecture/`, rather than altering a - library capability. - -## Operations - -None. No DNS, infra, or external-account changes. Pure in-repo file moves, new -files, and doc edits. - -## Testing - -No code touched, so correctness is verified by: - -- `just lint-ci` passes (eof-fixer + ruff format/check in check mode + ty; the - markdown/format gate, since no Python changes). -- `just docs-build` (`mkdocs build --strict`) passes. `architecture/` is outside - `docs_dir: docs`, so it is not part of the site build; the strict build only - re-validates the existing `docs/` tree, which this change leaves untouched. -- Repo-wide grep sweeps return zero stale references: - - `grep -rn "planning/specs"` — none outside this bundle's own prose. - - `grep -rn "planning/plans"` — none. - - `grep -rn "lightweight-plan-template"` — none. -- Every `planning/changes/**/design.md` and `plan.md` (clean-pair) has parseable - YAML frontmatter — spot-checked on review. -- `planning/README.md` Conventions section is byte-identical to - `faststream-outbox` (diff -w shows only the Index and any deliberate truth-home - reference) — verified with `diff`. -- `planning/README.md` Index links resolve — manual click-through. -- The post-migration tree matches §2 exactly; `planning/specs/`, - `planning/plans/`, `planning/templates/` are gone. - -No new pytest, no new CI job. - -## Risk - -- **`.NN` ordering for same-date bundles is a judgment call.** `2026-06-01` has - three bundles. *Mitigation:* PR numbers in the existing docs give merge order; - a wrong tiebreak is cosmetic (bundles sort adjacently regardless). - -- **`architecture/` seed drifts from reality immediately.** Hand-written seed - prose can lag the code the moment it lands. *Mitigation:* the seed is sourced - from `CLAUDE.md` + `docs/`, which are current; and the convention forces - promotion on every future change, which is the mechanism that keeps it true. - An imperfect-but-real seed is strictly better than an empty truth home. - -- **`git mv` blame continuity through folder regrouping.** *Mitigation:* - `git log --follow` and GitHub web both follow renames; low practical impact - for planning artifacts. - -- **Convention drift on the next change.** A contributor could skip the lanes or - forget the `architecture/` promotion. *Mitigation:* `CLAUDE.md` names - `architecture/` as the promotion target so PR review catches a missing - promotion the way it catches a missing test; templates make the shape - copy-pasteable. - -- **Grouped per-PR plan files lack frontmatter.** A future tooling pass that - assumes every plan file has frontmatter would skip them. *Mitigation:* this is - an accepted, documented scope cut (Non-goals); the bundle `design.md` carries - the arc's lifecycle metadata, and no such tooling exists or is planned. diff --git a/planning/changes/2026-06-23.01-structured-log-payload.md b/planning/changes/2026-06-23.01-structured-log-payload.md deleted file mode 100644 index e46e833..0000000 --- a/planning/changes/2026-06-23.01-structured-log-payload.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -summary: Move the structlog→Sentry log-line contract into a StructuredLogPayload value object so the meta-key vocabulary and parse live in one place, closing the silent-drift failure mode. ---- - -# Design: Give the structlog→Sentry payload a deep module - -## Summary - -The structlog→Sentry enrichment relies on a contract that no module owns: the -logging instrument renders every log line to a flat JSON object, and the Sentry -instrument opportunistically sniffs that shape (`formatted.startswith("{")`), -re-parses it, and strips a hand-maintained set of meta-keys before attaching the -rest to the Sentry event. This change introduces a `StructuredLogPayload` value -object in `logging_factory.py` that owns the parse + the meta-key vocabulary, so -the fragile knowledge lives in one place and the Sentry instrument shrinks to -orchestration. The goal is to close a silent-drift failure mode, not to abstract -for its own sake. - -## Motivation - -The log-event contract is split across two files and joined only by a string -guess: - -- **Producer** (`logging_instrument.py:116`): the structlog processor chain ends - in `JSONRenderer(serializer=_serialize_log_with_orjson_to_string)`. Every line - is a flat JSON object — message under `event`, meta-keys `level` / `logger` / - `tracing` / `timestamp` / `exception`, user kwargs, and an optional - `skip_sentry`. -- **Consumer** (`sentry_instrument.py:39-71`): `enrich_sentry_event_from_structlog_log` - sniffs `formatted.startswith("{")`, `orjson.loads` it, drops on `skip_sentry`, - lifts `event` to the message, and strips `IGNORED_STRUCTLOG_ATTRIBUTES` - (`sentry_instrument.py:19`) — the same meta-key set the producer emits — before - attaching the remainder as `contexts.structlog`. - -The coupling is one-directional and opportunistic: the producer does not know -Sentry exists; Sentry guesses at structlog's output shape. The meta-key -vocabulary is duplicated/implied across both files and owned by neither. Rename a -meta-key or add a top-level meta-processor without updating -`IGNORED_STRUCTLOG_ATTRIBUTES` and the enrichment silently degrades — either the -key leaks into `contexts.structlog`, or (for a renamed message key) the event -passes through unmodified. There is no test that crosses the producer's -serializer and the consumer's parser together, so the drift is invisible until a -production Sentry event looks wrong. - -Applying the deletion test to a fix: a module that owns parse + vocabulary, if -deleted, would scatter the meta-stripping logic and the key set back into Sentry -and concentrate complexity here — it earns its keep. (Surfaced as candidate 1 of -the 2026-06-23 architecture review.) - -## Non-goals - -- **A neutral third module both instruments import.** There is exactly one - consumer (Sentry); a shared-for-sharing's-sake module abstracts a sharing that - does not exist yet and pulls the vocabulary away from the chain that generates - it. One adapter is a hypothetical seam. -- **A symmetric `serialize()` on the value object.** The producer does not - construct a `StructuredLogPayload`; it hands structlog's full `event_dict` to - the existing serializer. A `serialize()` nobody calls would be dead surface. -- **An explicit sentinel/marker key stamped on every log line.** Replacing the - `startswith("{")` heuristic with a marker pollutes the stdout JSON shape for - every logging user to serve one consumer. The heuristic is cheap and adequate. -- **An absolute, compile-time-guaranteed drift fix.** That would require changing - the emitted log shape (nesting user kwargs under a single `extra` key) for all - users. We deliberately trade "impossible" for "unlikely + caught by a - round-trip test" to avoid changing the log shape. -- **Renaming `skip_sentry`.** It stays honest-to-today; rename the day a second - reporter honors it. - -## Design - -### 1. `StructuredLogPayload` value object in `logging_factory.py` - -A consumer-side interpretation type living beside the existing serializer: - -```python -@dataclasses.dataclass(frozen=True, slots=True) -class StructuredLogPayload: - message: str | None # the structlog `event` key; None when absent - extra: dict[str, typing.Any] # user fields, meta-keys already stripped - skip_sentry: bool - - @classmethod - def parse(cls, formatted: str) -> "StructuredLogPayload | None": - """Interpret one rendered structlog line. Returns None when the string - is not a structlog JSON object (non-JSON, decode error, or not a dict).""" -``` - -`parse` owns every "is this even a structlog line" guard (the `startswith("{")` -heuristic, `orjson.JSONDecodeError`, non-dict result) and the meta-stripping. No -caller ever sees the raw dict, so no caller can re-implement or forget the strip. -The value object holds **no** Sentry-event-shape knowledge — mapping onto -`event["logentry"]["formatted"]` / `contexts.structlog` stays in the Sentry -instrument. - -### 2. Meta-key vocabulary moves and is renamed - -`IGNORED_STRUCTLOG_ATTRIBUTES` (`sentry_instrument.py:19`) moves to -`logging_factory.py` as the public symbol `STRUCTLOG_META_KEYS` — named for what -it *is* (the producer's meta-key vocabulary) rather than what Sentry does with -it. It keeps the `STRUCTLOG_` prefix because the keys are structlog / our-chain -conventions. `parse` uses it internally; the round-trip test references it -directly. - -The drift gap is handled pragmatically, not absolutely (see Non-goals): - -- The set lives in `logging_factory`, which `logging_instrument` already imports; - `logging_factory` cannot import `logging_instrument` back (circular), so the - set cannot be *adjacent* to the chain. -- A cross-reference comment at `tracer_injection` / the processor chain in - `logging_instrument.py` directs anyone adding a top-level meta-processor to - extend `STRUCTLOG_META_KEYS`. -- The round-trip test (§4) is the regression net. - -Only `tracing` (from our own `tracer_injection`) is a custom meta-key; the rest -(`level`, `logger`, `timestamp`, `exception`) are stable structlog-stdlib -processor outputs and `event` / `skip_sentry` are conventions. The real drift -surface is narrow: someone adds another custom top-level meta-processor and -forgets the set. - -### 3. Sentry instrument shrinks to orchestration - -`enrich_sentry_event_from_structlog_log` keeps its name and signature (it is the -`before_send` callback) and becomes: - -```python -payload = StructuredLogPayload.parse(formatted_message) -if payload is None: - return event # not a structlog JSON line -if payload.skip_sentry: - return None # drop — checked BEFORE message -if not payload.message: - return event # JSON without an `event` key -event["logentry"]["formatted"] = payload.message -if payload.extra: - event["contexts"]["structlog"] = payload.extra -return event -``` - -The branch ordering is preserved exactly from the current implementation: -`skip_sentry` is honored before the message-presence check, so a line with -`skip_sentry` truthy and no `event` key still drops. `wrap_before_send_callbacks` -is untouched (it chains callbacks; orthogonal to payload parsing). - -### 4. Seam direction and backward compatibility - -The dependency is `sentry → logging_factory` — honest: Sentry consumes logging's -output format, and `logging_factory` is a lower-level mechanics module (it -imports neither instrument), so this is not peer-coupling between instruments. - -`IGNORED_STRUCTLOG_ATTRIBUTES` is a public-named symbol on a shipped (1.1.x) -package, though it is not in `lite_bootstrap.__all__`. A silent module-level -alias `IGNORED_STRUCTLOG_ATTRIBUTES = STRUCTLOG_META_KEYS` stays in -`sentry_instrument.py` per the repo's rename convention, so any external import -keeps working. - -## Operations - -None. No infra, DNS, or external-account changes. - -## Out of scope - -Covered under Non-goals. - -## Testing - -Test-first (red before green). Three layers: - -- **`StructuredLogPayload.parse` table (new).** Raw JSON string in → `message` / - `extra` / `skip_sentry` out, no Sentry event constructed: non-JSON → `None`; - JSON non-dict → `None`; dict without `event` → `message is None`; normal line → - meta-keys stripped from `extra`; `skip_sentry` truthy → flag set; **DES-4**: - `skip_sentry=False` stripped from `extra` (relocated from the Sentry layer with - a comment naming its DES-4 audit origin). -- **Round-trip (new).** A representative `event_dict` → real - `_serialize_log_with_orjson_to_string` → `parse` → assert `message` / `extra` / - `skip_sentry`. Exercises producer serializer + consumer parse + vocabulary - together; this is the drift net. -- **Sentry orchestration (slimmed).** `enrich_sentry_event_from_structlog_log` - over Sentry-shaped events, three outcomes only — drop (`skip_sentry`), - modify+attach (normal), passthrough (non-structlog / `event`-less). Shape - detail now lives at the value-object layer. - -`just test` green, `just lint-ci` clean (`ty` included). - -## Risk - -- **Behavior change masquerading as a refactor (med likelihood × high impact).** - The three branch outcomes and the skip-before-message ordering must be - byte-identical to today. Mitigation: the slimmed Sentry orchestration tests pin - all three outcomes; the DES-4 case is preserved (relocated, not deleted); the - ordering is an explicit test case. -- **Residual drift (low × med).** A future custom meta-processor whose key is not - added to `STRUCTLOG_META_KEYS` still leaks. Mitigation: cross-reference comment - at the chain + the round-trip test (catches it only if the fixture includes the - new key — documented as a known limit, the deliberate trade from Non-goals). -- **Back-compat miss (low × med).** An external importer of - `IGNORED_STRUCTLOG_ATTRIBUTES` breaks. Mitigation: silent alias retained. diff --git a/planning/changes/2026-06-24.01-unify-teardown-attach.md b/planning/changes/2026-06-24.01-unify-teardown-attach.md deleted file mode 100644 index 5640d19..0000000 --- a/planning/changes/2026-06-24.01-unify-teardown-attach.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -summary: Move the teardown-on-shutdown attach behind one BaseBootstrapper._attach_teardown_once seam with a uniform marker, extending the double-attach guard to Litestar and FastStream. ---- - -# Design: Unify the teardown-on-shutdown attach behind one guarded seam - -## Summary - -"Register this bootstrapper's `teardown` to run on the framework's shutdown" is a -real seam implemented ad-hoc in five `__init__` bodies. The double-attach guard + -warning is copy-pasted between FastAPI and FastMCP (with two different detection -mechanisms) and absent from Litestar and FastStream. This change moves the guard -into one `BaseBootstrapper._attach_teardown_once(target, attach)` method that owns -detection (a uniform `_lite_bootstrap_teardown_attached` marker), the warning, and -the skip; each framework's `__init__` shrinks to "here is my target, here is how I -attach." The guard extends uniformly to Litestar and FastStream, closing a -behavioral inconsistency. - -## Motivation - -The teardown-attach is wired five different ways (verified across -`bootstrappers/*.py`): - -- **FastAPI** (`fastapi_bootstrapper.py:204-217`): reads a marker - `_lite_bootstrap_lifespan_attached`, warns + skips if set, else marks and merges - its lifespan via `_merge_lifespan_context`. -- **FastMCP** (`fastmcp_bootstrapper.py:155-163`): **structural** check - `any(isinstance(p, _TeardownProvider) for p in app.providers)`, warns + skips, - else adds a `_TeardownProvider`. -- **Litestar** (`litestar_bootstrapper.py:281`): bare - `application_config.on_shutdown.append(self.teardown)` — **no guard**. -- **FastStream** (`faststream_bootstrapper.py:208`): bare - `application.on_shutdown(self.teardown)` — **no guard**. -- **Free** (`free_bootstrapper.py`): no app, no shutdown lifecycle — out of scope. - -The same user error — two bootstrappers constructed against one app — is handled -inconsistently: FastAPI/FastMCP warn and skip (their guard is tested at -`test_fastapi_bootstrap.py:130` and `test_fastmcp_bootstrap.py:283`), while -Litestar/FastStream silently double-register `teardown`. The shared logic (check → -warn → skip → else mark + attach) and near-identical warning text live in two -copies; detection is reinvented per framework. Deletion test on a unified guard: -delete it and the guard + warning re-duplicate across FastAPI/FastMCP and the gap -on Litestar/FastStream reopens — it concentrates complexity, so it earns its keep. -(Surfaced as candidate 2 of the 2026-06-23 architecture review; marked "worth -exploring".) - -## Non-goals - -- **Unifying the attach mechanism.** The four mechanisms (lifespan merge, provider, - `on_shutdown` list append, `on_shutdown()` method call) are genuinely different - and stay framework-specific. Only detection + warn + skip are shared. -- **A class-level registry / WeakSet for "already attached".** Considered, rejected: - it would contradict the documented `_lite_bootstrap_*` app-tagging convention - (`architecture/bootstrappers.md:68`) and introduce process-global mutable state - with test-isolation hazards. The marker keeps the "attached" bit local to the - app's lifetime. -- **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. -- **Changing the warn-and-skip policy to raise.** FastAPI/FastMCP keep lenient - warn+skip; Litestar/FastStream adopt the same. No escalation to an error. -- **Bringing Free into the seam.** It has no app to attach to. - -## Design - -### 1. The seam: `BaseBootstrapper._attach_teardown_once` - -One method on the base owns detection, warning, and skip; the marker name is a -class constant: - -```python -class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]): - _TEARDOWN_MARKER: typing.ClassVar[str] = "_lite_bootstrap_teardown_attached" - - def _attach_teardown_once(self, target: object, attach: typing.Callable[[], None]) -> None: - if getattr(target, self._TEARDOWN_MARKER, False): - warnings.warn( - f"The application passed to {type(self).__name__} already has a lite-bootstrap " - f"teardown hook attached; skipping. This {type(self).__name__}'s teardown will " - f"not run on shutdown — construct one {type(self).__name__} per application.", - stacklevel=3, - ) - return - setattr(target, self._TEARDOWN_MARKER, True) # noqa: B010 — documented _lite_bootstrap_ tag - attach() -``` - -`stacklevel=3` points the warning at the user's construction site (warn ← -`_attach_teardown_once` ← subclass `__init__` ← user). The warning noun is derived -from `type(self).__name__`, so no per-class label is needed. - -### 2. Each framework's `__init__` becomes target + attach thunk - -The detection and warning vanish from every subclass; each supplies only its -target and how it attaches: - -```python -# FastAPI — target is the app; attach merges the lifespan -self._attach_teardown_once(application, lambda: self._wrap_lifespan(application)) - -# FastMCP — target is the app; attach adds the provider (still the attach mechanism) -self._attach_teardown_once( - self.bootstrap_config.application, - lambda: self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown)), -) - -# Litestar — target is the AppConfig (the built app is slotted); now guarded -self._attach_teardown_once( - self.bootstrap_config.application_config, - lambda: self.bootstrap_config.application_config.on_shutdown.append(self.teardown), -) - -# FastStream — target is the app; now guarded -self._attach_teardown_once( - self.bootstrap_config.application, - lambda: self.bootstrap_config.application.on_shutdown(self.teardown), -) -``` - -FastAPI's lifespan merge moves to a small `_wrap_lifespan` helper (or stays an -inline lambda). The early-`return`-from-`__init__` guards collapse into the -method's skip; nothing runs after the attach in any subclass `__init__`, so -behavior is preserved. - -### 3. Detection unified to the marker; two consequences - -Detection becomes one marker on the attach target. A probe confirmed all four -targets accept an arbitrary attribute and are weakref-able — including Litestar's -`AppConfig` (the *built* `Litestar` app is slotted, but it is never the attach -target). Two consequences, on the record: - -- **FastMCP** migrates from its structural `isinstance(p, _TeardownProvider)` - detection to the marker. The `_TeardownProvider` class stays — it is how FastMCP - *attaches*; it just stops being how FastMCP *detects*. -- **FastAPI**'s marker renames `_lite_bootstrap_lifespan_attached` → - `_lite_bootstrap_teardown_attached`. It is an internal tag on the user's app, not - a public symbol, so it renames freely. - -### 4. Behavior change: Litestar and FastStream gain the guard - -This is the intended effect of the unification, not a side effect. A second -bootstrapper constructed against the same Litestar `AppConfig` / FastStream app now -emits the warning and skips the second attach, instead of silently registering -`teardown` twice. Because `BaseBootstrapper.teardown()` is idempotent -(`test_free_bootstrap.py:117`), the prior double-register was non-fatal but -silent; the new behavior is consistent and observable. - -## Operations - -None. - -## Testing - -Test-first. Two layers: - -- **Direct unit test of the seam (new — the depth payoff).** On a cheap concrete - `FreeBootstrapper` (no app), call the inherited `_attach_teardown_once` against a - dummy `types.SimpleNamespace()` target with a spy thunk: first call runs the - thunk and sets the marker; second call warns and does **not** run the thunk. No - framework, no ASGI lifespan. -- **Per-framework "routes through the guard" (uniform).** Each of the four asserts - the warning fires on a second construction against the same target. FastAPI / - FastMCP: keep existing double-attach tests, update the `match=` string to the - unified wording, keep their attach-once assertions (lifespan not re-wrapped / - single `_TeardownProvider`). Litestar / FastStream: **new** tests asserting the - warning fires and the hook is registered once (Litestar via - `len(application_config.on_shutdown)`; FastStream via the warning). - -`just test` green at 100%, `just lint-ci` clean (`ty` included). - -## Risk - -- **Behavior change on Litestar/FastStream (med likelihood × low impact).** Code - that knowingly constructs two bootstrappers on one app now gets a warning. This - is the intended consistency, non-fatal (warn + skip), and the first - bootstrapper's teardown still runs. Called out here and for the retro. -- **FastMCP detection migration (low × med).** Switching structural → marker must - preserve the tested outcome (second bootstrapper warns, single `_TeardownProvider` - added). Mitigation: the existing FastMCP double-attach test is retained, only its - message match updated; the ASGI-lifespan teardown test - (`test_fastmcp_bootstrap.py:61`) pins that the provider still fires. -- **Marker rename leaving a stale tag (low × low).** `_lite_bootstrap_lifespan_attached` - disappears; nothing public reads it. Grep confirms it is referenced only in - FastAPI's own code, its test, and the arch docs (all updated here). diff --git a/planning/changes/2026-06-24.02-otel-excluded-urls-home.md b/planning/changes/2026-06-24.02-otel-excluded-urls-home.md deleted file mode 100644 index 1f4879c..0000000 --- a/planning/changes/2026-06-24.02-otel-excluded-urls-home.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -summary: Move OTel's own opentelemetry_excluded_urls field onto OpenTelemetryConfig (typed access, one declaration) and pin the genuine prometheus/health cross-config exclusion reads with a regression test. ---- - -# Design: Move `opentelemetry_excluded_urls` onto its own config; pin the sibling-path exclusions - -## Summary - -`OpenTelemetryInstrument._build_excluded_urls` reads four config fields by string via -`getattr(..., default)`. One of them — `opentelemetry_excluded_urls` — is OTel's *own* -setting, yet it is declared three times on the framework configs and never on -`OpenTelemetryConfig`, which is *why* OTel must getattr it. This change moves that field -home to `OpenTelemetryConfig` (de-duplicating three declarations into one and making the -read typed), and adds a regression test pinning the *genuine* cross-instrument reads -(`prometheus_metrics_path` / `health_checks_path`) so a future rename breaks loudly -instead of silently dropping URLs from the trace-exclusion set. - -## Motivation - -`_build_excluded_urls` (`instruments/opentelemetry_instrument.py:144-153`) makes four -stringly-typed cross-config reads: - -```python -excluded_urls = set(getattr(self.bootstrap_config, "opentelemetry_excluded_urls", [])) # OTel's OWN field -prometheus_path = getattr(self.bootstrap_config, "prometheus_metrics_path", None) # PrometheusConfig -if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - health_path = getattr(self.bootstrap_config, "health_checks_path", None) # HealthChecksConfig -``` - -These are two different kinds of read, and conflating them hides the real issue: - -- **`opentelemetry_excluded_urls` is OTel's own field, misplaced.** It is declared on - `FastAPIConfig:53`, `LitestarConfig:120`, and `FastStreamConfig:69` — three identical - copies — and not on `OpenTelemetryConfig`. The getattr exists only because the field - isn't where it belongs. The method is defined on the base `OpenTelemetryInstrument`, - whose `bootstrap_config` is typed `OpenTelemetryConfig`; if the field lived there, the - read would be typed. -- **`prometheus_metrics_path` / `health_checks_path` are genuinely other instruments' - fields.** OTel runs in `FreeConfig`, which composes neither Prometheus nor - HealthChecks, so these fields may legitimately be absent. The defensive - `getattr(..., None)` is the correct expression of "optional sibling" and should stay. - -The risk in the genuine-sibling reads is silent breakage: rename `prometheus_metrics_path` -on `PrometheusConfig` and the getattr returns `None`, the metrics endpoint silently stops -being excluded from traces, and **no test catches it** — the only direct -`_build_excluded_urls` test (`test_faststream_bootstrap.py:225`) covers just the -`opentelemetry_excluded_urls` passthrough. Same shape as the candidate-1 drift bug, lower -impact (extra spans, not lost data). (Surfaced as candidate 4 of the 2026-06-23 -architecture review; rated Speculative — this is the proportionate slice of it.) - -## Non-goals - -- **A "contribution" mechanism** (each instrument/config declares its excluded paths, - OTel unions them). Rejected: today `_build_excluded_urls` concentrates the entire - exclusion policy in one readable method (good locality); a contribution mechanism would - *spread* that policy across `PrometheusConfig`, `HealthChecksConfig`, and OTel, and the - health-check case still needs OTel's `opentelemetry_generate_health_check_spans` flag, - so the cross-coupling would not even disappear. It moves complexity and worsens - locality — fails the deletion test. -- **Removing the genuine-sibling getattrs.** `prometheus_metrics_path` / - `health_checks_path` / `pyroscope_endpoint` belong to other instruments and are - optional; the defensive read is correct. We pin them with a test, not a refactor. -- **Touching the `pyroscope_endpoint` getattr (line 174).** It is a span-processor - decision, not part of `_build_excluded_urls`; same intentional optional-sibling - pattern, different concern. - -## Design - -### 1. Move `opentelemetry_excluded_urls` onto `OpenTelemetryConfig` - -Add the field to `OpenTelemetryConfig` and delete the three framework-config copies: - -```python -# instruments/opentelemetry_instrument.py — OpenTelemetryConfig -opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list) -``` - -Users still set it exactly as before (`FastAPIConfig(opentelemetry_excluded_urls=[...])`) -— it is inherited. The read in `_build_excluded_urls` becomes typed: - -```python -excluded_urls: set[str] = set(self.bootstrap_config.opentelemetry_excluded_urls) -``` - -The `prometheus_metrics_path` / `health_checks_path` reads stay as `getattr(..., default)`. - -### 2. Accepted trade: the field is now inherited by `FreeConfig` - -`FreeConfig` composes `OpenTelemetryConfig`, so it gains `opentelemetry_excluded_urls` -(inert there — `_build_excluded_urls` is only called by the HTTP framework OTel -subclasses, never in Free's path). `FastMcpConfig` is unaffected (it composes no OTel -config). `FreeConfig.from_dict({"opentelemetry_excluded_urls": ...})` now accepts the key -instead of filtering it. This cosmetic widening is accepted: Free already inherits other -OTel fields that only matter in some setups, and the field belongs on `OpenTelemetryConfig`. - -### 3. Pin the genuine-sibling exclusions with one regression test - -One representative test (the method is a single shared base method; the fields live on the -shared `PrometheusConfig` / `HealthChecksConfig`, so a rename breaks every framework at -once — one test suffices). Placed beside the existing `_build_excluded_urls` test in -`test_faststream_bootstrap.py`. It asserts the full policy: - -- the prometheus metrics path is **always** in the excluded set; -- the health-checks path **is** excluded when `opentelemetry_generate_health_check_spans=False`; -- the health-checks path **is not** excluded when it is `True`. - -A rename of `prometheus_metrics_path` / `health_checks_path`, or a regression in the -conditional, then fails this test loudly. - -## Operations - -None. - -## Out of scope - -Covered under Non-goals. - -## Testing - -- **Existing guard (A):** `test_faststream_opentelemetry_excluded_urls_in_built_set` - (`test_faststream_bootstrap.py:225`) already pins the `opentelemetry_excluded_urls` - passthrough and keeps passing after the field moves (it is inherited), so A is a - refactor under a stable test. -- **New regression (B):** the cross-config exclusion test above. - -`just test` green at 100%, `just lint-ci` clean (`ty` included). - -## Risk - -- **Behaviour change from the field move (low likelihood × low impact).** Moving the - field is additive — every current call site keeps working via inheritance; the only - observable change is `FreeConfig` accepting a previously-rejected key. Mitigation: the - existing passthrough test guards the read. -- **`from_dict` semantics on Free (low × low).** Free now accepts `opentelemetry_excluded_urls` - in `from_dict`/`from_object`. Accepted per design §2. -- **Residual silent-rename on `pyroscope_endpoint` (low × low).** Out of scope; it is not - part of `_build_excluded_urls`. Noted so a future pass knows it was deliberately left. diff --git a/planning/changes/2026-07-18.01-free-threaded-python-support.md b/planning/changes/2026-07-18.01-free-threaded-python-support.md deleted file mode 100644 index cffba4a..0000000 --- a/planning/changes/2026-07-18.01-free-threaded-python-support.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -summary: Make orjson an opt-in extra with a stdlib-json fallback so core + pure extras install on free-threaded CPython; add a per-version 3.13t/3.14t CI leg and a free-threading capability page. litestar lands on 3.14t only (msgspec's ft gate); fastmcp is excluded from both legs and stays deferred (cffi on 3.13t, a partial opentelemetry stack on 3.14t). Fixed two pre-existing import-safety bugs surfaced while verifying fastmcp: an incomplete-opentelemetry-namespace crash in import_checker, and an unconditional gRPC OTLP exporter import. Native-blocked extras (otl-grpc, pyroscope) documented, not fixed. ---- - -# Design: Free-threaded Python (nogil) support - -## Summary - -Free-threaded CPython (3.13t experimental, 3.14t officially supported per PEP -779) runs pure-Python packages with no changes. `lite-bootstrap` is pure Python, -but one **mandatory** dependency — `orjson` — hard-blocks it: `orjson` ships no -free-threaded wheels and its build script *refuses* to compile on a free-threaded -interpreter (`orjson v3.11.9 does not support free-threaded Python`, verified -locally on 3.14t). Because `orjson` is in `[project] dependencies`, **nothing** -installs on ft today — not even the pure extras. This change makes `orjson` -opt-in (its own extra) with a stdlib-`json` fallback in the one place it is used -(the logging serializer), so core and every pure extra install and pass tests on -ft. A per-version CI leg proves it; a capability page documents the support -matrix and the extras that remain ecosystem-blocked (`otl`, `pyroscope`, -`fastmcp`; `litestar` on 3.13t only). - -## Motivation - -Verified empirically on a local `3.14t` (`sys._is_gil_enabled()` False): - -- `uv pip install lite-bootstrap[logging,sentry,fastapi]` → **fails** building - `orjson` from sdist (maturin: "does not support free-threaded Python"). -- Installing the pure deps directly (`structlog`, `sentry-sdk`, `fastapi` + - `pydantic-core`'s ft wheels, `httptools`, `prometheus-client`) → **installs and - imports cleanly**. `orjson` is the sole blocker. - -`orjson` is used only by the logging path (`instruments/logging_factory.py`) yet -sits in mandatory core deps — a dependency-hygiene defect independent of ft. - -## Design - -### 1. `orjson` → opt-in extra + stdlib fallback - -`pyproject.toml`: remove `orjson` from `[project] dependencies` (core becomes -zero-dep, pure-Python) and add a standalone extra: - -```toml -[project.optional-dependencies] -orjson = ["orjson"] -``` - -`orjson` is deliberately **not** folded into `logging` or any `*-all` bundle: -no PEP 508 marker can express "GIL builds only" (see -`decisions/2026-07-18-orjson-opt-in-for-free-threading.md`), so bundling it would -re-break those extras on ft. Uniform rule: every extra installs on ft; `orjson` -is a per-build opt-in speedup (`pip install lite-bootstrap[fastapi,orjson]`). - -`import_checker.py`: add `is_orjson_installed = find_spec("orjson") is not None`. - -`instruments/logging_factory.py`: guard the import (matching the file's existing -`if import_checker.is_structlog_installed:` pattern) and select the serializer at -import. Two explicit implementations so both branches are unit-testable on a -normal (orjson-present) run: - -```python -def _dumps_orjson(value, **kw): - return orjson.dumps(value, **kw).decode() - - -def _dumps_stdlib(value, **kw): - return json.dumps(value, separators=(",", ":"), ensure_ascii=False, **kw) - - -_serialize_log_to_string = _dumps_orjson if import_checker.is_orjson_installed else _dumps_stdlib -``` - -`separators`/`ensure_ascii` reproduce orjson's compact, UTF-8 output shape; -structlog passes only `default=`, which both encoders accept. `StructuredLogPayload.parse` -selects `orjson.loads` vs `json.loads` the same way and catches `json.JSONDecodeError` -(orjson's subclasses it), so one `except` covers both. - -The GIL-build path is byte-for-byte unchanged (fallback runs only when `orjson` -is absent). `logging_instrument.py`'s import updates to the new name. - -### 2. CI leg - -Add a `free-threaded` job matrixed per Python version (`astral-sh/setup-uv` → -`uv python install `), each leg installing its own ft-ready extra set: -3.13t gets `logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics`; -3.14t adds `litestar,litestar-metrics` (msgspec's ft gate — see §Non-goals). -`orjson`, `otl`, `pyroscope`, and `fastmcp`/`fastmcp-metrics` are excluded from -both legs. Each leg runs the standalone `scripts/ft_smoke.py`, not `just test`: -`tests/conftest.py` hard-imports `opentelemetry`, which the ft legs don't -install, so pytest can't collect there. The smoke script asserts a -free-threaded interpreter, the `orjson`-absent serializer fallback round-trips, -and a FastAPI bootstrap/teardown succeeds. This leg is the "runs correctly on -ft" proof; coverage stays 100% on the normal (non-ft) CI run per §Testing. - -### 3. Capability page - -New `architecture/free-threading.md`: the per-extra support matrix, the -single-threaded-init invariant (below), and the ecosystem-blocked extras with -upstream links — `otl` (grpcio), `pyroscope` (abi3-only), `fastmcp` (cffi on -3.13t, a partial opentelemetry stack on 3.14t), and `litestar` (msgspec, -3.13t only). Linked from `README.md` and the bootstrappers invariant list. -Promoted in this PR. - -### 4. Single-threaded-init invariant (documented, no code) - -`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; the -cached mutable state (`is_bootstrapped`, `_tracer_provider`, the -`_lite_bootstrap_teardown_attached` marker) is **not** guarded for concurrent -calls on one bootstrapper, by design. ft parallelizes *request handling*, where -`lite-bootstrap` does not sit. Recorded in `architecture/bootstrappers.md`. - -## Non-goals - -- **otl on ft.** The gRPC OTLP exporter needs `grpcio`, which has no ft wheels - ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). An HTTP-exporter - path would fix it but is a code + extras change → `deferred.md`. -- **pyroscope on ft.** `pyroscope-io` is abi3-only, unmaintained, no ft path and - no pure fallback → `deferred.md`. -- **fastmcp on ft.** Excluded from both legs (cffi on 3.13t, a partial `opentelemetry` - stack on 3.14t) → `deferred.md`. Verifying it did surface two pre-existing, - not-ft-specific import-safety bugs, fixed in this change: `import_checker.py`'s - dotted `find_spec` calls raised `ModuleNotFoundError` instead of returning `False` - on an incomplete `opentelemetry` parent namespace, and `opentelemetry_instrument.py` - imported the gRPC OTLP exporter unconditionally under the coarse - `is_opentelemetry_installed` guard. See `architecture/instruments.md`. -- **Concurrent-bootstrap safety / locks.** See §4. -- **Switching the serializer to msgspec/ujson.** Rejected in the decision file — - a permanent new dep for a temporary orjson gap ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530)). - -## Testing - -- Unit (normal CI): `_dumps_orjson` and `_dumps_stdlib` each called directly; a - parity test asserts identical output for a representative event dict (incl. - non-ASCII); `parse()` round-trips under both. 100% coverage without ft. -- ft CI leg: `scripts/ft_smoke.py` (import + serializer fallback + FastAPI - bootstrap/teardown) green on 3.13t and 3.14t (per-version extras). -- `just lint-ci` clean (`ty`, planning validator). - -## Risk - -- **Silent perf change for logging users who don't add `[orjson]` (low × low).** - stdlib `json` is ~2-5x slower, correctness unchanged. Documented in the release - note and capability page. -- **Exotic types in log `extra` (datetime/UUID) render via repr on the fallback - (low × low).** ft-only, orjson-absent-only; structlog's timestamper already - stringifies timestamps upstream of the renderer. Documented. -- **Transitive `import lite_bootstrap`-pulls-orjson consumers (very low).** They - should depend on `orjson` directly; noted in the release note. diff --git a/planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md b/planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md deleted file mode 100644 index f3a981a..0000000 --- a/planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -summary: Split the OTel presence check into api (`is_opentelemetry_installed`) and sdk (`is_opentelemetry_sdk_installed`) so `import lite_bootstrap` no longer crashes in an api-only environment (e.g. `lite-bootstrap[fastmcp]`); gate the OTel instrument's sdk imports + `check_dependencies` on sdk, and warn (instead of silently skipping) when an OTLP endpoint is set without the gRPC exporter installed. ---- - -# Design: Fix the opentelemetry api/sdk/exporter conflation - -## Summary - -`is_opentelemetry_installed = find_spec("opentelemetry")` is `True` with only -`opentelemetry-api` installed, but three sites in `OpenTelemetryInstrument` -import or require `opentelemetry.sdk.*`. Any environment with the api package but -not the sdk — notably `lite-bootstrap[fastmcp]`, whose deps pull bare -`opentelemetry-api` — crashes on `import lite_bootstrap`. Introduce a -correctly-scoped `is_opentelemetry_sdk_installed` flag, gate the three sdk sites -on it, and (a related consistency fix) warn instead of silently skipping when an -OTLP endpoint is configured but the gRPC exporter package is absent. - -## Motivation - -Reproduced on regular (GIL) CPython 3.12, `uv pip install "lite-bootstrap[fastmcp]"`: - -``` -File ".../instruments/opentelemetry_instrument.py", line 18, in - from opentelemetry.sdk import resources -ModuleNotFoundError: No module named 'opentelemetry.sdk' -``` - -`opentelemetry` (api) and `opentelemetry.trace`/`.metrics` are present; -`opentelemetry.sdk` is absent. `lite-bootstrap[fastmcp]` without `otl` has never -been importable — not ft-specific. (This is the third of three -incomplete-opentelemetry-stack bugs; the api-namespace crash and the unconditional -gRPC-exporter import were fixed in `2026-07-18.01`. It was deferred there and is -now picked up.) - -## Design - -### 1. Two presence flags, precisely scoped - -`import_checker.py`: add - -```python -is_opentelemetry_sdk_installed = _safe_find_spec("opentelemetry.sdk") -``` - -Keep `is_opentelemetry_installed` (api) for the **six api-only consumers** whose -guarded blocks import only `opentelemetry.trace`/`.metrics` (all provided by -`opentelemetry-api`): `logging_instrument.py:30`, `fastapi_bootstrapper.py:30`, -`litestar_bootstrapper.py:46`, `faststream_bootstrapper.py:27,102`, and the -`is_{fastapi,litestar}_opentelemetry_installed` derivations. - -`opentelemetry_instrument.py`: switch the **three sdk sites** to the new flag: - -- the module-level import block (`opentelemetry.sdk.resources`, - `opentelemetry.sdk.trace.*`) — the crash site; -- the `PyroscopeSpanProcessor(SpanProcessor)` block (`SpanProcessor` is sdk); -- `check_dependencies()` → `return import_checker.is_opentelemetry_sdk_installed`. - -Rejected: redefining `is_opentelemetry_installed` to mean sdk — it would break the -six api-only features whenever api is present without sdk (log trace-injection, -faststream health-check spans, framework `get_tracer_provider`). Those genuinely -need only the api. - -### 2. `check_dependencies` now reflects sdk - -Because the instrument requires the sdk, `check_dependencies()` returning sdk -presence means the standard construction flow (`is_configured` → -`check_dependencies` → warn+skip) fires the existing -`InstrumentDependencyMissingWarning` when OTel is configured but only the api is -present — a warning, not a crash. - -### 3. Warn on a configured endpoint without the gRPC exporter - -`bootstrap()` currently silently no-ops the OTLP exporter when -`opentelemetry_endpoint` is set but `is_otlp_grpc_exporter_installed` is `False`. -Replace the silent skip with a warning: - -```python -if self.bootstrap_config.opentelemetry_endpoint: - if import_checker.is_otlp_grpc_exporter_installed: - tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(...))) # pragma: no cover - else: - warnings.warn( - "opentelemetry_endpoint is set but the gRPC OTLP exporter is not installed; " - "spans will not be exported. Install lite-bootstrap[otl].", - category=InstrumentDependencyMissingWarning, - stacklevel=2, - ) -``` - -## Non-goals - -- **A config-aware `check_dependencies(config)`.** The endpoint→exporter - relationship is config-dependent, so the warning lives in `bootstrap()` rather - than reworking the base `check_dependencies()` staticmethod contract. -- **Re-adding `fastmcp` to the ft CI leg.** Now unblocked on 3.14t, but a separate - CI-scope decision (`cffi` still blocks 3.13t). Tracked in `deferred.md`. - -## Testing - -- **Regression (crash):** with `opentelemetry.sdk` emulated absent - (`emulate_package_missing` + module reload, as in `2026-07-18.01`'s tests), - `is_opentelemetry_sdk_installed is False`, the instrument module reimports - cleanly, and `check_dependencies()` is `False`. Fails on current code - (`ModuleNotFoundError`), green after. -- **Warn branch:** endpoint configured + `is_otlp_grpc_exporter_installed` - monkeypatched `False` → `bootstrap()` emits `InstrumentDependencyMissingWarning` - (removes the previously-untested silent skip; the true exporter branch keeps its - live-collector `# pragma: no cover`). -- `just test` 100% coverage; `just lint-ci` clean. - -## Risk - -- **Behaviour change for a partial-otel install (low × low).** Configuring OTel - with only the api, or an endpoint without the exporter, now warns instead of - crashing/silently-skipping — strictly better, and pinned by the two tests. diff --git a/planning/changes/2026-07-19.02-otlp-http-exporter.md b/planning/changes/2026-07-19.02-otlp-http-exporter.md deleted file mode 100644 index 3e9569f..0000000 --- a/planning/changes/2026-07-19.02-otlp-http-exporter.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -summary: Add an `opentelemetry_exporter_protocol` config (grpc|http, default grpc) and an `otl-http` extra (opentelemetry-exporter-otlp-proto-http, no grpcio) so OTLP trace export works on free-threaded Python; repoint `otl` to the grpc exporter package. ---- - -# Design: OTLP HTTP exporter option (free-threaded OTLP export) - -## Summary - -The `otl` extra is the last observability surface with no free-threaded story: -its OTLP exporter goes over gRPC, which needs `grpcio`, which ships no -free-threaded wheels. OpenTelemetry also offers an **HTTP/protobuf** exporter -(`requests` + `protobuf`, no grpcio) that talks to the same collectors. Add an -`opentelemetry_exporter_protocol` config knob (default `"grpc"`, unchanged -behavior) and an `otl-http` extra so a free-threaded service can export traces. - -## Motivation - -`otl` installs `opentelemetry-exporter-otlp` (a meta package pulling **both** the -grpc exporter → `grpcio` and the http exporter), so `pip install -lite-bootstrap[otl]` cannot resolve on a free-threaded interpreter -([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). The instrument -hardwired the gRPC exporter. The HTTP exporter -(`opentelemetry-exporter-otlp-proto-http`) has no `grpcio` dependency (verified: -`requests`, `opentelemetry-proto`, `googleapis-common-protos`), so it installs -on ft. This was deferred from the 2026-07-14 free-threading work; picking it up. - -## Design - -### 1. Config: exporter protocol - -```python -# OpenTelemetryConfig -opentelemetry_exporter_protocol: typing.Literal["grpc", "http"] = "grpc" -``` - -Default `"grpc"` preserves current behavior exactly. `opentelemetry_insecure` -applies to gRPC only; for HTTP the endpoint is a full URL -(`http://collector:4318/v1/traces`) whose scheme carries security — documented, -not warned (see `decisions/2026-07-19-otlp-http-exporter-shape.md`). - -### 2. Two guarded, aliased exporter imports - -`import_checker.py`: add -`is_otlp_http_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")` -alongside the existing `is_otlp_grpc_exporter_installed`. - -`opentelemetry_instrument.py`: import each under its own guard, aliased to avoid -the shared `OTLPSpanExporter` name: - -```python -if import_checker.is_otlp_grpc_exporter_installed: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as OTLPGrpcSpanExporter -if import_checker.is_otlp_http_exporter_installed: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as OTLPHttpSpanExporter -``` - -### 3. Protocol-selecting `bootstrap()` - -```python -if self.bootstrap_config.opentelemetry_endpoint: - if self.bootstrap_config.opentelemetry_exporter_protocol == "grpc": - if import_checker.is_otlp_grpc_exporter_installed: - tracer_provider.add_span_processor( # pragma: no cover (live collector) - BatchSpanProcessor(OTLPGrpcSpanExporter(endpoint=..., insecure=...))) - else: - warnings.warn("… gRPC OTLP exporter is not installed … install lite-bootstrap[otl].", …) - elif import_checker.is_otlp_http_exporter_installed: - tracer_provider.add_span_processor( # pragma: no cover (live collector) - BatchSpanProcessor(OTLPHttpSpanExporter(endpoint=self.bootstrap_config.opentelemetry_endpoint))) - else: - warnings.warn("… HTTP OTLP exporter is not installed … install lite-bootstrap[otl-http].", …) -``` - -The exporter-construction lines keep `# pragma: no cover` (need a live -collector); both missing-exporter warn branches are covered by tests. - -### 4. Extras - -`pyproject.toml`: repoint `otl` from the meta package to the grpc exporter, and -add `otl-http`: - -```toml -otl = ["opentelemetry-api", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation"] -otl-http = ["opentelemetry-api", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-instrumentation"] -``` - -`otl` users are unaffected functionally (it already defaulted to gRPC and never -used the http exporter). No framework `*-otl-http` variants — a free-threaded -framework service composes `[fastapi, otl-http]` and adds its instrumentation -package if it wants auto-instrumentation. - -### 5. CI + proof - -Add `otl-http` to both ft legs' extras in `_checks.yml` (proves it resolves on -3.13t/3.14t). Extend `scripts/ft_smoke.py` to bootstrap an -`OpenTelemetryInstrument` with `opentelemetry_exporter_protocol="http"` and an -endpoint, then tear down — proving the http export path constructs on ft with no -live collector needed. - -## Non-goals - -- **Framework `*-otl-http` extras.** Avoided to keep the extras matrix small. -- **Changing the default protocol.** Stays `grpc` for backward compatibility. -- **An `insecure`-style flag or http:// warning for HTTP.** The URL scheme - carries security; documented, per the decision file. - -## Testing - -- Unit (TDD): with `opentelemetry_exporter_protocol="http"` + an endpoint, - `bootstrap()` constructs the **http** exporter (patch `OTLPHttpSpanExporter`, - assert called with the endpoint); `"grpc"` still constructs the grpc exporter; - the http-absent and grpc-absent branches each emit - `InstrumentDependencyMissingWarning` naming the right extra. -- ft CI leg installs `otl-http` and `scripts/ft_smoke.py` bootstraps a - protocol=http instrument on 3.13t/3.14t. -- `just test` 100% coverage; `just lint-ci` clean. - -## Risk - -- **`otl` dependency-set change (low × low).** From the `opentelemetry-exporter-otlp` - meta to `opentelemetry-exporter-otlp-proto-grpc`. Same grpc exporter, minus the - unused http package; grpc behavior unchanged. Noted in the release note. diff --git a/planning/changes/2026-07-19.03-core-zero-dep-typing-extensions.md b/planning/changes/2026-07-19.03-core-zero-dep-typing-extensions.md deleted file mode 100644 index d82e2a5..0000000 --- a/planning/changes/2026-07-19.03-core-zero-dep-typing-extensions.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -summary: Declare `typing-extensions` as core's runtime dependency so a bare `import lite_bootstrap` (no extras) works — the code uses it at runtime and pydantic requires `typing_extensions.TypedDict` on Python < 3.12, so genuine zero-dependency is not achievable while supporting 3.10/3.11. ---- - -# Design: Declare the runtime `typing_extensions` core dependency - -## Summary - -`lite-bootstrap` core imports `typing_extensions` at runtime but never declared -it, so a bare `pip install lite-bootstrap` + `import lite_bootstrap` (no extras) -crashes with `ModuleNotFoundError: No module named 'typing_extensions'`. Declare -`typing-extensions` as core's one dependency. (An attempt to instead *remove* the -usage and keep core zero-dependency failed on Python < 3.12 — see below.) - -## Motivation - -Found by a clean-room install of the published `1.3.0` on a fresh 3.14t venv: -core installed with **zero** declared dependencies (after orjson became opt-in), -but `import lite_bootstrap` immediately raised on `base.py`'s top-level -`import typing_extensions`. Pre-existing (bare `lite-bootstrap==1.2.3` fails -identically — its only core dep, `orjson`, doesn't pull `typing_extensions`); -every install with any extra masked it, since extras pull `typing_extensions` in -transitively. 1.3.0's "zero-dependency core" claim was therefore false. - -Two runtime uses: - -- `base.py:4,24,30` — `typing_extensions.Self` return annotations. -- `healthchecks_instrument.py:8` — `class HealthCheckTypedDict(typing_extensions.TypedDict, ...)`, - used as a FastAPI response model (`health_check_handler() -> HealthCheckTypedDict`). - -## Design - -`pyproject.toml`: `dependencies = ["typing-extensions"]`. `typing-extensions` is -pure Python, so core stays free-threaded-friendly; it is the leanest possible -core (one small, ubiquitous dependency). - -## Rejected: remove the usage to keep core zero-dependency - -Attempted (deferred `Self` annotations via `from __future__ import annotations` + -`TYPE_CHECKING`, and `typing.TypedDict` in healthchecks). It passed locally on -3.12 and on an isolated 3.10 TypedDict construction, but **failed the CI matrix -on 3.10 and 3.11**: pydantic rejects a stdlib `typing.TypedDict` model on Python -< 3.12 — - -``` -PydanticUserError: Please use `typing_extensions.TypedDict` instead of -`typing.TypedDict` on Python < 3.12. -``` - -Because `HealthCheckTypedDict` is a FastAPI/pydantic response model, it must be a -`typing_extensions.TypedDict` for as long as 3.10/3.11 are supported. So core -genuinely needs `typing_extensions` at runtime; declaring it is the honest fix. - -## Non-goals - -- Dropping Python 3.10/3.11 (which would make zero-dep achievable). Out of scope. -- Amending the published `1.3.0` note (immutable); `1.3.1` states the corrected - "one pure-Python dependency" story. - -## Testing - -- Clean-room bare install on 3.14t: `pip install lite-bootstrap` now pulls - `typing-extensions`, and `import lite_bootstrap` + `FreeBootstrapper.bootstrap()` - succeed. -- `just test` 100% coverage on the full 3.10-3.14 matrix (the FastAPI healthcheck - schema builds on every version); `just lint-ci` clean. - -## Risk - -- **None material.** Adds one pure-Python dependency that the code already - required at runtime; strictly fixes a crash. `typing-extensions` is already - present transitively in every non-bare install. diff --git a/planning/changes/2026-07-19.04-extra-isolation-install-check.md b/planning/changes/2026-07-19.04-extra-isolation-install-check.md deleted file mode 100644 index 1413172..0000000 --- a/planning/changes/2026-07-19.04-extra-isolation-install-check.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -summary: Add a CI job that installs each optional extra in isolation and imports lite_bootstrap (catching undeclared transitive dependencies the `--all-extras`-only suite masks), and fix the litestar bug it found — the `litestar.plugins.prometheus` import is guarded by litestar presence but needs prometheus_client, so `lite-bootstrap[litestar]` crashed on import. ---- - -# Design: Per-extra isolation install-check + fix the litestar/prometheus import - -## Summary - -Existing CI only ever installs `--all-extras`, so any extra that imports a -package it does not declare passes CI while breaking a real single-extra install. -This shipped twice (the bare-core `typing_extensions` crash in 1.3.1, and the -litestar bug below). Add a CI job that installs **each** extra in its own clean -venv and imports `lite_bootstrap`, and fix the one live bug a local sweep found. - -## Motivation - -An isolation sweep (clean venv per extra → `import lite_bootstrap`) across all 28 -extras on Python 3.10 and 3.12 found: - -- **`litestar`, `litestar-sentry`, `litestar-otl`, `litestar-logging` crash on - import** (both versions): `litestar_bootstrapper.py:36` does - `from litestar.plugins.prometheus import PrometheusConfig, PrometheusController` - under the `is_litestar_installed` guard, but that litestar plugin imports - `prometheus_client`, which the `litestar` extra does not install (only - `litestar-metrics` does). `litestar-metrics`/`litestar-all` pass because they - pull `prometheus-client` in. So `pip install lite-bootstrap[litestar]` + - `import lite_bootstrap` raises `MissingDependencyException: prometheus_client`. -- Everything else (bare core + 24 extras) imports clean. -- (`fastmcp` on local macOS-arm 3.10 hit a `cryptography` Rust source-build - failure — a local toolchain artifact, not a bug: `cryptography` supports ≥3.9 - via abi3 wheels, so it installs on Linux CI. The check runs on Linux.) - -## Design - -### 1. Fix the litestar/prometheus import - -Move line 36 into its own guard (the usage sites at `litestar_bootstrapper.py:209,219` -already run only when `check_dependencies() → is_prometheus_client_installed`, -so this only tightens the import to match — same shape as the otl-exporter guard): - -```python -if import_checker.is_litestar_installed and import_checker.is_prometheus_client_installed: - from litestar.plugins.prometheus import PrometheusConfig, PrometheusController -``` - -TDD: `emulate_package_missing_with_module_reload("prometheus_client", ["...litestar_bootstrapper"])` -must not crash the reload; assert `import_checker.is_prometheus_client_installed` -is False and the bootstrapper module reimports. Fails on current code, green after. - -### 2. Isolation install-check CI job - -A single `install-isolation` job in `.github/workflows/_checks.yml`, Python -**3.10** (the floor — the undeclared-dep class is version-independent, and the -floor surfaces the most gated behavior; the `--all-extras` pytest matrix already -covers all versions, and the ft legs cover per-extra installs on 3.13t/3.14t): - -```yaml - install-isolation: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8.2.0 - with: - enable-cache: true - cache-dependency-glob: "**/pyproject.toml" - - run: uv python install 3.10 - - name: Each extra installs and imports in isolation - run: | - set -uo pipefail - extras="orjson sentry pyroscope otl otl-http logging free-all \ - fastapi fastapi-sentry fastapi-otl fastapi-logging fastapi-metrics fastapi-all \ - litestar litestar-sentry litestar-otl litestar-logging litestar-metrics litestar-all \ - faststream faststream-sentry faststream-otl faststream-logging faststream-metrics faststream-all \ - fastmcp fastmcp-metrics fastmcp-all" - failed="" - # bare core first - uv venv --python 3.10 .v-bare >/dev/null - uv pip install --python .v-bare/bin/python . >/dev/null 2>&1 \ - && .v-bare/bin/python -c "import lite_bootstrap" \ - || failed="$failed __bare__" - rm -rf .v-bare - for e in $extras; do - uv venv --python 3.10 ".v-$e" >/dev/null - if uv pip install --python ".v-$e/bin/python" ".[$e]" >/dev/null 2>&1 \ - && ".v-$e/bin/python" -c "import lite_bootstrap"; then :; else failed="$failed $e"; fi - rm -rf ".v-$e" - done - if [ -n "$failed" ]; then echo "::error::extras failed isolated install+import:$failed"; exit 1; fi - echo "all extras install and import in isolation" -``` - -Collects **all** failures (does not stop at first) and fails listing them. - -## Non-goals - -- Multi-version isolation matrix (floor-only per the reasoning above). -- Running a bootstrap per extra (import is the cheap, sufficient signal for the - undeclared-dependency class). - -## Testing - -- The litestar fix's unit test (above); `just test` 100% coverage; `just lint-ci` clean. -- The new CI job is the systemic proof: green means every extra imports in isolation. - -## Risk - -- **The isolation job surfaces a genuine install gap on 3.10 (e.g. an extra whose - dep truly lacks a 3.10 Linux wheel).** That is the job doing its work; handle - case-by-case (a `python_version` marker on the extra, or documentation). Not - expected for the current matrix. diff --git a/planning/changes/2026-08-10.01-litestar-middleware-logging.md b/planning/changes/2026-08-10.01-litestar-middleware-logging.md deleted file mode 100644 index 2d0b375..0000000 --- a/planning/changes/2026-08-10.01-litestar-middleware-logging.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -summary: Litestar access logging is now off by default (`enable_middleware_logging=False`); `litestar_logging_middleware_enabled` turns it back on with metadata-only fields (`path`, `method`, `content_type`, `path_params`, `status_code`) and swagger/static/health/metrics exclusions, and `litestar_logging_middleware_config` replaces those defaults wholesale. ---- - -# Design: Litestar access logging off by default, hardened when on - -## Summary - -`LitestarLoggingInstrument` builds a `StructlogConfig` with only -`structlog_logging_config` set, so Litestar's own defaults switch on -`LoggingMiddleware` with a `LoggingMiddlewareConfig` that logs full request and -response bodies. Every credential posted to the service and every byte of the -offline Swagger bundle lands in stdout. This change turns middleware logging off -by default (matching every other bootstrapper) and, for services that want -access logs back, supplies a metadata-only config behind an explicit flag. - -## Motivation - -Reproduced on litestar 2.24.0 with a bootstrapper built from -`LitestarConfig(swagger_offline_docs=True)`: - -- `POST /login` with `{"username": "u", "password": "hunter2"}` emits an - `HTTP Request` line whose `body` field contains the password verbatim. - Litestar obfuscates only the `Authorization` / `X-API-KEY` headers and the - `session` cookie; bodies are never obfuscated. -- `GET /swagger-ui.css` emits an `HTTP Response` line - carrying the whole 150 KB stylesheet as `body`. The offline Swagger assets - registered by `LitestarSwaggerInstrument` go through the same ASGI stack, so - `swagger-ui-bundle.js`, `swagger-ui.css` and the favicon are logged as - ordinary response bodies — including the truncated multi-byte sequences that - first surfaced the problem. -- `health_checks_path` and `prometheus_metrics_path` are logged on every k8s - probe and every Prometheus scrape. - -None of this is opt-in: it is a side effect of adding `StructlogPlugin`. The -FastAPI, FastStream, FastMCP and Free bootstrappers add no request/response -logging middleware at all, so Litestar is also the odd one out. - -## Design - -### 1. Config: explicit opt-in plus escape hatch - -```python -# LitestarConfig -litestar_logging_middleware_enabled: bool = False -litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None -``` - -The flag is the only enable switch. `litestar_logging_middleware_config`, when -given, replaces the hardened defaults wholesale — no merging, the caller owns -the whole config. Supplying a config while the flag is `False` would be a silent -no-op, so `LitestarConfig.__post_init__` warns, following the precedent set by -`OpenTelemetryConfig.__post_init__`. `LitestarConfig` is `slots=True`, so the -cascade call takes the explicit `super(LitestarConfig, self).__post_init__()` -form, as `FastAPIConfig` already does. `LoggingMiddlewareConfig` joins the -existing `if import_checker.is_litestar_installed:` import block. - -### 2. Instrument: pass both remaining `StructlogConfig` fields - -```python -# litestar_bootstrapper.py, module level -_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params") -_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",) -``` - -```python -# LitestarLoggingInstrument.bootstrap() -StructlogConfig( - structlog_logging_config=StructLoggingConfig(...), # unchanged - enable_middleware_logging=self.bootstrap_config.litestar_logging_middleware_enabled, - middleware_logging_config=self._build_logging_middleware_config(), -) -``` - -`_build_logging_middleware_config()` returns the user's config when set, -otherwise `LoggingMiddlewareConfig(request_log_fields=…, response_log_fields=…, -exclude=…)`. No `body`, `headers`, `cookies` or `query`: bodies and headers are -where secrets live, and query strings carry JWTs and API keys often enough to -not be worth the diagnostic value. `path` is `scope["path"]` in Litestar's -`ConnectionDataExtractor`, so dropping `query` loses only the query string. - -`_build_logging_middleware_excluded_paths()` collects `swagger_path`, -`swagger_static_path` (only under `swagger_offline_docs`), `health_checks_path` -and `prometheus_metrics_path`, each with the trailing slash stripped and -`re.escape`d into `^(?:/|$)`, skipping empty values and a degenerate `/`. -Litestar matches `exclude` with an unanchored search, so the anchor and the -segment boundary are what keep a lookalike route such as `/custom-healthy` out -of the exclusion. `LitestarLoggingInstrument` is typed on `LitestarConfig`, so -these read directly — no `getattr` fallbacks like the shared -`OpenTelemetryInstrument._build_excluded_urls` needs. Only config values are -read, so the instrument's position in `instruments_types` (before -`LitestarSwaggerInstrument`) does not matter. - -`LoggingMiddleware` subclasses `AbstractMiddleware`, whose wrapper calls -`should_bypass_middleware` -> `should_bypass_for_path_pattern`, matching -`exclude` against `scope["path"]` alone (not the route handler's path -template — `AbstractMiddleware.__init_subclass__` is what can emit a -Litestar-3 migration `DeprecationWarning`, and it doesn't here because -`LoggingMiddleware` is itself defined inside Litestar). That still covers the -static assets: `create_static_files_router` builds a plain `Router` with -`@get("{file_path:path}")` / `@head(...)` handlers rather than a mount, so -`scope["path"]` for a request to `/doc/static/swagger-ui.css` is the literal -asset path, and `^/doc/static(?:/|$)` matches it directly. - -## Non-goals - -- Merging user-supplied `LoggingMiddlewareConfig` with our defaults. Half-owned - config is harder to reason about than either extreme. -- Obfuscation lists, body size caps, or per-route logging controls. Litestar's - own config already exposes them for callers who take the escape hatch. -- The unrelated `request_max_body_size` defect found while reproducing this - (`LitestarConfig.application_config` defaults to a bare `AppConfig()`, whose - `request_max_body_size` is `Empty`, and `Litestar.from_config()` does not apply - the 10 MB default `Litestar(...)` uses, so body-reading handlers 500). Its own - change file. - -## Testing - -`just test -k litestar_access_logging`, added to -`tests/test_litestar_bootstrap.py`. Neither `capsys` nor `capfd` can observe -this project's structlog output — `_MemoryLoggerFactoryConfig.log_stream` binds -`sys.stdout` at import time, which under pytest is already the global capture -object — so the tests attach their own `logging.Handler` to the `litestar` -logger, inside the `TestClient` context because Litestar's `dictConfig` at -startup drops handlers attached earlier: - -- default config, `POST` a body containing a password: no `HTTP Request` / - `HTTP Response` line recorded, and the password string absent. -- flag on: access lines present, with no `body` / `headers` / `cookies` / - `query` keys and no secret value. -- flag on, requests to swagger docs, swagger static, health and metrics: no - access line for any of them. -- flag on, request to `/custom-healthy`: still logged, pinning the anchored - exclude patterns against prefix over-matching. -- flag on plus a caller-supplied `LoggingMiddlewareConfig`: the caller's fields - win, our defaults are not applied. -- config supplied with the flag off: `pytest.warns`. - -Then `just lint-ci` and the full `just test`. - -## Risk - -**Services relying on the current access logs lose them silently.** Likely, low -impact, and the point of the change. Mitigated by the release note and the -docs subsection; re-enabling is one flag. - -**A caller's `health_checks_path="/"` (or similar) would exclude everything.** -Unlikely. The degenerate `/` is skipped when building `exclude`, and Litestar -independently warns when a pattern matches all routes. - -**Promotion:** `architecture/instruments.md` records the new invariant (Litestar -access logging is off by default and metadata-only when enabled); -`docs/integrations/litestar.md` gains the opt-in subsection. Ships as a minor -release (1.4.0) with an explicit behavior-change note. diff --git a/planning/changes/2026-08-10.02-log-stream-bind-at-bootstrap.md b/planning/changes/2026-08-10.02-log-stream-bind-at-bootstrap.md deleted file mode 100644 index e3ddd56..0000000 --- a/planning/changes/2026-08-10.02-log-stream-bind-at-bootstrap.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -summary: `_MemoryLoggerFactoryConfig.log_stream` now resolves `sys.stdout` through a `default_factory`, so the memory logger binds the stream at bootstrap instead of at import and structlog output follows a stdout the process rebinds — matching the root-logger handler installed at the same moment. ---- - -# Change: Bind the memory logger's stream at bootstrap, not at import - -**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. - -## Goal - -`_MemoryLoggerFactoryConfig.log_stream` (`lite_bootstrap/instruments/logging_factory.py`) -defaults to a bare `sys.stdout`, evaluated once when the module is imported. -Every `MemoryLoggerFactory` handler therefore writes to whatever `sys.stdout` -was at import time, even when the process rebinds `sys.stdout` before -bootstrapping. Resolve it at bootstrap instead. - -## Approach - -```python -log_stream: typing.Any = dataclasses.field(default_factory=lambda: sys.stdout) -``` - -The config is constructed in `LoggingInstrument.memory_logger_factory`, so a -`default_factory` moves the lookup to bootstrap time — the same moment -`_configure_foreign_loggers` already binds its root-logger -`logging.StreamHandler(sys.stdout)`. Today the two disagree: the root handler -follows a rebound stdout and the structlog path does not. - -Observed with a plain `FreeBootstrapper` (all bootstrappers share -`LoggingInstrument`, so this is not Litestar-specific): - -```python -with contextlib.redirect_stdout(buffer): - FreeBootstrapper(bootstrap_config=FreeConfig(service_name="svc", logging_buffer_capacity=0)).bootstrap() - structlog.get_logger("demo").info("hello after redirect") -# buffer is empty; the line went to the real stdout instead -``` - -Two consequences worth naming. In production, anything that wraps or replaces -`sys.stdout` after import — `contextlib.redirect_stdout`, a supervisor that -re-points the stream, a test harness — is silently bypassed by structlog output -while stdlib output follows along. In this repo's own test suite it is why -neither `capsys` nor `capfd` can observe structlog lines (pytest installs its -capture before collection imports the module), which forced -`tests/test_litestar_bootstrap.py` to record through a handler attached to the -`litestar` logger. That workaround stays either way; it is independent of the -capture mechanism, which is the point of it. - -Behavior is unchanged for the ordinary case, where nothing rebinds `sys.stdout` -between import and bootstrap. - -## Files - -- `lite_bootstrap/instruments/logging_factory.py` — `log_stream` gains a `default_factory`. -- `tests/instruments/test_logging_instrument.py` — test added. -- `architecture/instruments.md` — records the bootstrap-time binding. -- `planning/releases/1.4.0.md` — bug-fix entry (1.4.0 is written but not yet tagged). - -## Verification - -- [x] Failing test first: bootstrap a `LoggingInstrument` inside - `contextlib.redirect_stdout(io.StringIO())`, log one line, assert it lands - in the buffer. Command: `just test -k "binds_log_stream"`. Observed failure: - the buffer was empty while pytest's own captured stdout held the line. -- [x] Apply the change. -- [x] Test passes — `just test -k "binds_log_stream"`. -- [x] `just test` — 230 passed, coverage 100%. -- [x] `just lint-ci` — clean. - -The test drives `LoggingInstrument` directly rather than `FreeBootstrapper`: it -lives in `tests/instruments/test_logging_instrument.py` next to the other -factory tests, and the instrument is what owns the stream. - -## Notes - -Found while fixing the Litestar access-log body leak -(`planning/changes/2026-08-10.01-litestar-middleware-logging.md`), which is -where the test-capture consequence is documented. Deliberately left out of that -change to keep a security fix unencumbered. - -That consequence is now obsolete: with the stream resolved at bootstrap, -`capsys` does observe structlog output in a test that bootstraps inside the test -body (verified). `2026-08-10.01`'s Testing section describes what was true when -it shipped and is left as written — the access-logging tests keep recording -through a handler on the `litestar` logger, which is more precise than reading -captured stdout and does not depend on pytest's capture mode. diff --git a/planning/changes/2026-08-10.03-litestar-request-max-body-size.md b/planning/changes/2026-08-10.03-litestar-request-max-body-size.md deleted file mode 100644 index 47e2d4b..0000000 --- a/planning/changes/2026-08-10.03-litestar-request-max-body-size.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -summary: `LitestarBootstrapper._apply_config` now fills `request_max_body_size` with Litestar's 10 MB default when the `AppConfig` leaves it `Empty`, so body-reading handlers stop returning 500 under `Litestar.from_config()`; a caller's own value (including `None`) is untouched, and a guard test pins the constant against Litestar's signature default. ---- - -# Design: Apply Litestar's request_max_body_size default when the AppConfig leaves it unset - -## Summary - -`LitestarBootstrapper` builds its application with `Litestar.from_config()`, -which — unlike `Litestar(...)` — does not apply the 10 MB -`request_max_body_size` default. An `AppConfig` that leaves the field at -`Empty` therefore yields an application where every handler that reads a -request body returns 500. Fill the field in the bootstrapper when, and only -when, it is `Empty`. - -## Motivation - -Reproduced on litestar 2.24.0: - -```python -app = litestar.Litestar(route_handlers=[echo]) # request_max_body_size == 10_000_000 -app = litestar.Litestar.from_config(AppConfig(...)) # request_max_body_size is Empty -``` - -With the second form, a `POST` to a handler taking `data: dict` returns 500: - -``` -ImproperlyConfiguredException: 500: 'request_max_body_size' set to 'Empty' on all layers. -To omit a limit, set 'request_max_body_size=None' -``` - -`LitestarConfig.application_config` defaults to a bare `AppConfig()`, and a -caller who supplies their own `AppConfig` hits the same default, so **the -failure is the norm rather than the edge case**: any lite-bootstrap Litestar -service whose handlers accept a body 500s unless the caller happens to know to -set `request_max_body_size` themselves. It is not per-route recoverable either -— the exception is raised while resolving the layered value, so the only fixes -are on the handler, a router, or the app. - -Found while fixing the access-log body leak -(`planning/changes/2026-08-10.01-litestar-middleware-logging.md`), whose tests -work around it with `request_max_body_size=1000` on their handlers. That -workaround is what should disappear. - -## Design - -`LitestarBootstrapper._apply_config` already owns exactly this job — it is the -one place that mutates the caller's `AppConfig` before -`Litestar.from_config()` runs, setting `debug` and appending the teardown hook. -Add the fill there: - -```python -# litestar_bootstrapper.py, module level -# Litestar.from_config() skips the default that Litestar.__init__ applies, leaving the -# field Empty and 500-ing every body-reading handler. Pinned by a guard test. -_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE: typing.Final = 10_000_000 -``` - -```python - def _apply_config(self, application_config: "AppConfig") -> None: - application_config.debug = self.bootstrap_config.service_debug - if application_config.request_max_body_size is Empty: - application_config.request_max_body_size = _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE - application_config.on_shutdown.append(self.teardown) -``` - -`Empty` is an enum member (`litestar.types.Empty`, `_EmptyEnum.EMPTY`), not a -class, so the check is an identity comparison — `isinstance` would raise. -`Empty` joins the existing `if import_checker.is_litestar_installed:` import -block. - -The guard is the `is Empty` test: a caller's own value, including an explicit -`None` (Litestar's "no limit"), is left alone. Only the unset case is filled. - -**Pinning the constant.** Litestar exposes no public constant for the default; -the value lives only in `Litestar.__init__`'s signature, so hardcoding it can -drift silently on a Litestar bump. A guard test reads the signature default and -asserts it equals our constant, turning a drift into a CI failure rather than a -behavior change nobody notices. Runtime introspection was rejected: it makes -every bootstrap depend on a parameter name Litestar does not publish as API, -and it fails opaquely if that name changes. - -**Upstream.** `Litestar.from_config()` diverging from `Litestar(...)` on a -constructor default is already reported as -[litestar-org/litestar#4296](https://github.com/litestar-org/litestar/issues/4296), -which lists five such mismatches; our reproduction and the reason this one is a -hard failure rather than a cosmetic difference are in -[a comment there](https://github.com/litestar-org/litestar/issues/4296#issuecomment-5243196116). -`from_config` passes every `AppConfig` field explicitly -(`cls(**dict(extract_dataclass_items(config)))`), so an `__init__` default can -never apply — and on 2.24.0 `request_max_body_size` is the only field where -`AppConfig()` is `Empty` while `__init__` has a real default. If upstream fixes -it, the `is Empty` branch simply stops firing and the guard test keeps the -constant honest until the fill can be dropped. - -## Non-goals - -- Exposing `request_max_body_size` as a `LitestarConfig` field. Callers who - want a non-default limit set it on their own `AppConfig`, which is where - every other Litestar app-level knob already lives. -- Auditing the other `AppConfig` fields where `from_config()` may diverge from - `Litestar.__init__`. If more turn up, they get their own change. - -## Testing - -`just test -k "request_max_body_size"`, in `tests/test_litestar_bootstrap.py`: - -- a bootstrapped app with a body-reading handler and no explicit - `request_max_body_size`: `POST` succeeds (today: 500). -- a caller-supplied value survives: `AppConfig(request_max_body_size=42)` - bootstraps to `42`. -- an explicit `None` (Litestar's no-limit form) survives as `None`. -- guard: `inspect.signature(litestar.Litestar.__init__).parameters["request_max_body_size"].default` - equals `_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE`. - -Once green, drop the `request_max_body_size=1000` workaround from the -access-logging tests added in `2026-08-10.01`, so the suite stops carrying a -note about a defect that no longer exists. - -Then `just lint-ci` and the full `just test`. - -## Risk - -**The constant drifts from Litestar's default.** Low likelihood, low impact -(the number is a size limit), and the guard test converts it into a failed CI -run on the bump that changes it. - -**A caller relying on the 500.** Implausible — it is an -`ImproperlyConfiguredException`, not a documented limit. - -**Promotion:** `architecture/bootstrappers.md` records that `_apply_config` now -also fills Litestar's unset body-size default, alongside `debug` and the -teardown hook. diff --git a/planning/changes/2026-08-10.04-double-bootstrap-guard.md b/planning/changes/2026-08-10.04-double-bootstrap-guard.md deleted file mode 100644 index 978f8fe..0000000 --- a/planning/changes/2026-08-10.04-double-bootstrap-guard.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -summary: A second bootstrapper on the same application now fails loudly — `bootstrap()` raises `ConfigurationError` on the bootstrapper whose teardown attach was skipped, instead of dying inside Litestar with a duplicate-route error or silently double-registering FastAPI's routes. The construction-time warning is unchanged in kind, reworded to say the second bootstrapper's `bootstrap()` will raise. `FreeBootstrapper` has no attach target and is unaffected. ---- - -# Design: Fail fast when a second bootstrapper targets the same application - -## Summary - -`_attach_teardown_once` guards teardown attachment, and only that. A second -bootstrapper constructed on the same application still applies every instrument -again when `bootstrap()` is called, because nothing stops it. Litestar dies with -a confusing error from deep inside the framework; FastAPI silently registers a -second copy of the health and metrics routes. Make `bootstrap()` raise -`ConfigurationError` on the bootstrapper whose attach was skipped. - -## Motivation - -The construction-time warning already tells the user what the supported model -is — "construct one `` per application" — but nothing enforces it, -and the two frameworks fail in opposite, equally unhelpful ways. Both -reproduced on the current tree: - -```python -first = LitestarBootstrapper(bootstrap_config=config_a) -second = LitestarBootstrapper(bootstrap_config=dataclasses.replace(config_a)) # warns -first.bootstrap() -second.bootstrap() -``` - -``` -ImproperlyConfiguredException: 500: Handler already registered for path '/health' and http method OPTIONS -``` - -Nothing in that message points at the real mistake. The FastAPI equivalent is -worse, because it does not fail at all: the second `bootstrap()` returns the -same app object with its route count grown from 6 to 8 — a shadowed duplicate -of the health-check and metrics routes, a second `PrometheusInstrument` -registering against the same collector registry, and a second set of instrument -state whose teardown is not wired to anything. - -The warning is emitted at construction, but the damage happens at `bootstrap()`, -which is exactly where nothing checks. - -## Design - -`_attach_teardown_once` already detects the case; it just does not record it. -Have it remember, and have `bootstrap()` refuse: - -```python -# BaseBootstrapper -_attach_skipped: bool = False -``` - -```python -# BaseBootstrapper._attach_teardown_once -def _attach_teardown_once(self, target: object, attach: typing.Callable[[], object]) -> None: - if getattr(target, self._TEARDOWN_MARKER, False): - warnings.warn(...) # unchanged - self._attach_skipped = True - return - ... -``` - -```python -# BaseBootstrapper.bootstrap -def bootstrap(self) -> ApplicationT: - if self._attach_skipped: - msg = ( - f"{type(self).__name__} shares its application with another lite-bootstrap " - f"bootstrapper, which has already applied its instruments. Construct one " - f"{type(self).__name__} per application." - ) - raise ConfigurationError(msg) - ... -``` - -`ConfigurationError` already exists in `lite_bootstrap/exceptions.py` and is what -`FastAPIConfig.__post_init__` raises for a comparable misuse. - -The construction-time warning stays, so the documented warn-and-skip invariant -for the teardown seam is unchanged — this adds a second, louder gate at the -point where instruments would actually be applied. `FreeBootstrapper` never -calls `_attach_teardown_once`, so it is unaffected. - -The warning's wording needs a small update: it currently ends by saying this -bootstrapper's teardown "will not run on shutdown", which understates the new -behavior — the bootstrapper cannot be used at all. - -## Non-goals - -- Making instrument application idempotent so a second bootstrapper becomes - harmless. That means per-instrument state on user-supplied objects and a much - wider change; the supported model is one bootstrapper per application. -- Letting the second `bootstrap()` warn and return the app unchanged. It would - hand back an app whose instruments were never applied and whose teardown is - not wired — a quieter trap than the one being fixed. -- Detecting two bootstrappers built on two *different* `AppConfig` objects that - happen to share route paths. That is ordinary route-collision territory and - belongs to the framework. - -## Testing - -`just test -k "second_bootstrapper"`, extending the existing -`test_second__bootstrapper_on_same_*_warns_not_stacks` tests, which -today stop at construction and never call `bootstrap()`: - -- Litestar: the second `bootstrap()` raises `ConfigurationError` naming the - bootstrapper class, and the first application still works (its health route - responds). -- FastAPI: same, and the app's route count is unchanged by the failed second - bootstrap — the assertion that pins the silent-duplication half of the defect. -- FastStream and FastMCP: same raise, since they share the seam. -- `FreeBootstrapper`: two bootstrappers still work, since it has no attach - target. This pins that the guard did not overreach. - -Then `just lint-ci` and the full `just test`. - -## Risk - -**A caller today relies on double-bootstrapping.** Unlikely and already broken: -on Litestar it raises, on FastAPI it produces shadowed duplicate routes. The -release note should still call it out, since the FastAPI case currently -"works". - -**The flag survives on a bootstrapper that could otherwise be reused.** Once -`_attach_skipped` is set, that bootstrapper is permanently unusable. That is -intended — the application it was given is owned by someone else, and nothing -about that changes later. - -**Promotion:** `architecture/bootstrappers.md` records that the attach marker -now gates instrument application as well as teardown attachment, and what the -second bootstrapper gets. diff --git a/planning/decisions/.gitkeep b/planning/decisions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/planning/decisions/2026-06-24-keep-per-instrument-axis.md b/planning/decisions/2026-06-24-keep-per-instrument-axis.md deleted file mode 100644 index 9f137d5..0000000 --- a/planning/decisions/2026-06-24-keep-per-instrument-axis.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -status: accepted -summary: Keep the per-instrument axis for the instrument×framework matrix; reject inverting to per-framework adapters. -supersedes: null -superseded_by: null ---- - -# Keep the per-instrument axis for the instrument × framework matrix - -**Decision:** The framework-binding code stays organized around the *instrument* -(base instrument classes own the shared logic; each framework is a thin subclass -overriding only its `bootstrap()` binding). We reject inverting to a per-framework -adapter axis (a `FastAPIAdapter`/`LitestarAdapter`/… that knows how to attach any -instrument, driven by generic instruments). - -## Context - -The codebase has an instrument × framework matrix: every filled cell is "how -instrument *I* binds to framework *F*" (e.g. `FastAPIHealthChecksInstrument`, -`LitestarPrometheusInstrument`). The 2026-06-23 architecture review raised candidate -3 — "the matrix has no framework-locality; ~28 shallow per-cell subclasses" — and -proposed inverting the axis so a per-framework adapter owns the binding and -instruments become generic. - -Two organizations were on the table: - -- **Per-instrument (current):** instrument is the base class and owns shared depth; - framework is a subclass per cell. Subclasses are co-located one-file-per-framework - (`fastapi_bootstrapper.py` holds all `FastAPI*Instrument` classes, etc.). -- **Per-framework (proposed):** framework adapter is primary and owns the binding; - instruments call a small adapter interface (`add_route`, `add_middleware`, …). - -## Decision & rationale - -Keep the per-instrument axis. The candidate's premise and payoff do not hold up: - -- **Framework-locality already exists at the file level.** All of a framework's - instrument subclasses live in its one bootstrapper file, so "what does - lite-bootstrap do to my FastAPI app" is already answered by reading one file. The - review's "five scattered classes" are co-located, not scattered. -- **The shared depth is already hoisted.** `render_health_check_data()` lives in the - base `HealthChecksInstrument`; provider setup in base `OpenTelemetryInstrument`; - config validation in the base configs. The per-framework subclasses contain *only* - the genuinely-different binding, which is what you want — the thinness is the result - of correct hoisting, not shallowness to fix. -- **The N×M bindings differ genuinely.** FastAPI is imperative (`app.add_middleware`, - `include_router`), Litestar is declarative *before the app is built* - (`application_config.cors_config = …`, append to `middleware`/`route_handlers`), - FastStream attaches middleware to a *broker* not the app, FastMCP uses - `custom_route`. A uniform adapter interface (`add_route`/`add_middleware`) would - have to paper over imperative-vs-declarative-vs-broker and normalize differing - handler return types — it would leak. -- **Deletion test fails for the inversion.** Inverting relocates the same N×M - genuinely-different bindings into framework-grouped adapters; the complexity - *moves*, it does not *concentrate*. The matrix is inherently O(instruments × - frameworks); no axis choice removes a cell. Adding an instrument touches every - framework either way; adding a framework touches every instrument either way. - -No friction worth a refactor was identified (navigation is satisfied by the file -layout; the cross-cutting change cost is inherent to the matrix; the per-cell -"shallowness" is hoisted-out depth, not duplication). - -## Revisit trigger - -- The per-cell bindings start genuinely **converging/duplicating** — the shared part - outgrows the base instrument and the same binding code appears across framework - subclasses. Then hoist the convergent part (possibly into a small shared helper), - and reconsider an adapter for that specific shared mechanism. -- A new framework arrives that **shares an existing framework's attach mechanism** - (e.g. another ASGI app driven exactly like FastAPI). Two adapters with the same - shape turn the hypothetical seam into a real one, and a per-framework adapter for - that pair becomes justified. diff --git a/planning/decisions/2026-06-24-teardown-marker-accepted-limits.md b/planning/decisions/2026-06-24-teardown-marker-accepted-limits.md deleted file mode 100644 index 3b89f29..0000000 --- a/planning/decisions/2026-06-24-teardown-marker-accepted-limits.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -status: accepted -summary: Two deliberate limits of the unified teardown marker — FastMCP detects via attribute not provider scan, and Litestar tags the shareable AppConfig. -supersedes: null -superseded_by: null ---- - -# Accepted limits of the unified teardown-attach marker - -**Decision:** The `_lite_bootstrap_teardown_attached` marker introduced in -[unify-teardown-attach](../changes/2026-06-24.01-unify-teardown-attach.md) -(#130) carries two known limits that we accept rather than design around: -FastMCP detects double-attach via the attribute marker (not a provider-list scan), -and Litestar tags the shared `AppConfig` (not a built app). Both surfaced in the -#130 code review and were judged not worth the added complexity. - -## Context - -The unified seam `BaseBootstrapper._attach_teardown_once(target, attach)` detects a -second bootstrapper on the same target by tagging the target with one marker -attribute. Two consequences came up in review: - -1. **FastMCP detection change.** FastMCP previously detected double-attach - structurally — `any(isinstance(p, _TeardownProvider) for p in app.providers)` — - which reads the actual attach state. The unified marker replaces that with an - attribute on the app. If the app's `providers` list is cleared after the first - bootstrap while the marker survives, a second bootstrapper warns-and-skips - instead of re-attaching. -2. **Litestar marker target.** The Litestar app does not exist at `__init__` time - (it is built later via `Litestar.from_config`), so the attach — and therefore - the marker — lands on the `application_config` (`AppConfig`). Two `LitestarConfig` - instances that *share* one `AppConfig` but intend two distinct apps will collide: - the second warns and skips, and its teardown never runs. - -## Decision & rationale - -Both are accepted. Rationale, so they are not re-litigated: - -- **FastMCP (marker over structural):** uniformity across all four app-bearing - bootstrappers is worth more than the sliver of state-accuracy the provider scan - gave. The only scenario the guard exists for — a second bootstrapper on the same - app — is detected identically by the marker. The regression requires user/framework - code to mutate `app.providers` *after* bootstrap, which no supported flow does. - Considered and rejected: keeping FastMCP on a bespoke structural check, which would - re-fragment detection and defeat the seam's whole point. -- **Litestar (config-level marker):** attaching at config level is the *only* option - given the app is built lazily; tagging the built app would require restructuring the - attach to bootstrap time. 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 - #130 warning text was reworded to name "this application or its configuration" so - the remediation is accurate. - -The genuinely actionable review finding from the same pass — marker set before a -fallible `attach()` — was *fixed* in #130 (mark only after `attach()` succeeds), not -accepted; it is out of scope for this decision. - -## Revisit trigger - -- **FastMCP:** a supported flow starts mutating `FastMCP.providers` after bootstrap - (e.g. a documented hot-reload / provider-swap API), making the attribute marker - diverge from real attach state. Then move FastMCP back to structural detection or - reconcile the marker with the provider list. -- **Litestar:** sharing one `AppConfig` across multiple apps becomes a supported, - documented pattern, or the attach is restructured to run at `bootstrap()` time - (when the app exists). Then tag the built `Litestar` app instead of the config. - -## Update (1.4.0) - -[double-bootstrap-guard](../changes/2026-08-10.04-double-bootstrap-guard.md) changed -the consequence both scenarios above describe. The FastMCP case is no longer "a -second bootstrapper warns-and-skips instead of re-attaching" — its `bootstrap()` -now raises `ConfigurationError`. The Litestar case is no longer "the second warns -and skips, and its teardown never runs" — its `bootstrap()` raises before any -instrument is applied, so there is nothing left half-wired. The marker and its two -accepted limits are unchanged; only what happens once the marker is hit got louder. diff --git a/planning/decisions/2026-07-18-orjson-opt-in-for-free-threading.md b/planning/decisions/2026-07-18-orjson-opt-in-for-free-threading.md deleted file mode 100644 index e016d21..0000000 --- a/planning/decisions/2026-07-18-orjson-opt-in-for-free-threading.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -status: accepted -summary: orjson becomes an opt-in extra with a stdlib-json fallback (not bundled in logging/*-all, not replaced by msgspec) so every extra installs on free-threaded CPython. -supersedes: null -superseded_by: null ---- - -# orjson is opt-in for free-threaded support - -**Decision:** Make `orjson` its own opt-in extra with a stdlib-`json` fallback in -the logging serializer, rather than (B) keeping it bundled with `logging`/`*-all` -behind parallel ft-variant extras, or (C) replacing it with an ft-ready native -encoder (msgspec/ujson/rapidjson). - -## Context - -`orjson` is a mandatory core dep but hard-blocks free-threaded installs: no ft -wheels, and its build refuses to compile on ft (verified on 3.14t). It is used -only by the logging serializer. Three ways to unblock ft: - -- **A (chosen):** `orjson` → opt-in extra; `logging` = `structlog`-only; serializer - falls back to stdlib `json`. -- **B:** keep `orjson` reachable by default on GIL builds (left in core, or moved - into the `logging` extra) and add parallel ft-variant extras (`*-ft`) that omit - it — zero behaviour change for GIL users. -- **C:** replace `orjson` with msgspec (ft wheels ready today, litestar already - uses it) or ujson/rapidjson as the single serializer. - -PEP 508 has **no environment marker for "GIL enabled"**, so `orjson` cannot be -conditionally required only on GIL builds — the constraint that forces the choice. - -## Decision & rationale - -**A** keeps one coherent rule — *every* extra installs on ft, `orjson` is a -per-build opt-in speedup — with no combinatorial extras sprawl. It also fixes a -standing hygiene defect (a JSON encoder had no business being a mandatory core -dep). The GIL-build fast path is byte-for-byte unchanged when `[orjson]` is -present; the only cost is a documented, opt-in perf change for logging users who -don't add the extra (stdlib `json`, ~2-5x slower, same correctness). - -**B rejected:** bundling `orjson` in `logging` means every logging-bearing and -`*-all` extra needs an ft twin (`fastapi-logging`, `free-all`, …) — the extras -matrix the maintainer explicitly wanted to avoid. Zero GIL change is not worth -that sprawl. - -**C rejected:** a permanent new mandatory dependency to paper over a *temporary* -orjson gap ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530) tracks ft -wheels toward the 3.15/abi3t timeframe). msgspec's encoder API differs (`enc_hook`, -not orjson's `default=`) and its output shape differs, forcing a serializer -rewrite and test re-baseline. The stdlib fallback is fully reversible — it simply -stops being exercised once `orjson` ships ft wheels. - -## Revisit trigger - -`orjson` ships free-threaded wheels (resolves #530). At that point `orjson` may -return to `logging`/core as a hard dep and the fallback branch retired — reopen -to decide whether the simplification is worth removing the opt-in extra. diff --git a/planning/decisions/2026-07-19-otlp-http-exporter-shape.md b/planning/decisions/2026-07-19-otlp-http-exporter-shape.md deleted file mode 100644 index 175128a..0000000 --- a/planning/decisions/2026-07-19-otlp-http-exporter-shape.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -status: accepted -summary: otl repoints to the grpc exporter package and a new otl-http adds the http exporter (no framework variants); the insecure warning stays gRPC-only (http security is the endpoint URL scheme). -supersedes: null -superseded_by: null ---- - -# OTLP HTTP exporter: extras shape and http security signal - -**Decision:** `otl` → `opentelemetry-exporter-otlp-proto-grpc`; new `otl-http` → -`opentelemetry-exporter-otlp-proto-http` (no grpcio); no framework `*-otl-http` -variants. The `__post_init__` insecure warning stays tied to the gRPC `insecure` -flag; for HTTP, the endpoint URL scheme (`http://` vs `https://`) carries -security and is documented, not warned. - -## Context - -`otl` bundled `opentelemetry-exporter-otlp` (a meta package pulling grpc→grpcio -and http). To make OTLP export work on free-threaded Python (no grpcio wheels), -the exporter must be selectable and the http path must be installable without -grpcio. Two shape questions arose. - -## Decision & rationale - -**Extras — `otl`=grpc, `otl-http`=http, base only.** Repointing `otl` to the -grpc exporter package is functionally identical for existing users (it already -defaulted to gRPC and never used the http exporter) and drops only the unused, -transitively-bundled http package. `otl-http` carries the grpcio-free http -exporter for ft. Rejected: keeping `otl` as the meta and layering `otl-http` -on top — leaves `otl` grpcio-bound and redundant (already ships http). Rejected: -framework `*-otl-http` variants (`fastapi-otl-http`, …) — the same combinatorial -extras sprawl avoided in the free-threading work; a ft framework service composes -`[fastapi, otl-http]` and adds its instrumentation package directly. - -**HTTP security signal — no warning.** The gRPC exporter has an `insecure` bool -that the config warns about for non-local endpoints. The HTTP exporter has no -such flag; its endpoint is a full URL whose scheme is the security signal. -Re-deriving an "insecure" state by parsing `http://` vs `https://` would add -scheme-parsing to `__post_init__` for a signal the user already controls -explicitly in the URL. Documented instead. Rejected: an http:// non-local -warning — more code for a weaker nudge than the URL itself already gives. - -## Revisit trigger - -A user asks for a framework-specific ft OTLP-http convenience extra, or for the -http endpoint to accept a bare `host:port` (auto-building the URL) — either would -reopen the extras/URL-handling shape. diff --git a/planning/deferred.md b/planning/deferred.md deleted file mode 100644 index eae897a..0000000 --- a/planning/deferred.md +++ /dev/null @@ -1,45 +0,0 @@ -# Deferred Work - -Items raised in reviews or audits that are real but not actionable now. -Each is parked here with the reason it's deferred and the concrete trigger -that should bring it back. This is the long-tail register — not a backlog -of planned work. When an item is picked up it graduates to a spec/plan -change file in [`changes/active/`](changes/active/); see [AGENTS.md](../AGENTS.md#workflow). - -## Open - -### Pyroscope on free-threaded Python - -`pyroscope-io` is abi3-only, unmaintained, ships no ft wheels and has no pure -fallback, so the `pyroscope` extra cannot install on ft. No action possible from -this repo. -**Trigger:** `pyroscope-io` ships ft wheels (or a maintained ft-capable -replacement appears). - -### fastmcp on free-threaded Python 3.13 (cffi gates Py_GIL_DISABLED to 3.14+) - -`fastmcp`/`fastmcp-metrics` run on the **3.14t** ft CI leg -(`.github/workflows/_checks.yml`) but are excluded from **3.13t**: `fastmcp` → -`fastmcp-slim[server]` → `joserfc` → `cryptography` → `cffi`, and `cffi` (v2.1.0) -refuses to build on free-threaded 3.13 ("CFFI does not support the free-threaded -build of CPython 3.13. Upgrade to free-threaded 3.14 or newer to use CFFI with -the free-threaded build.") — an upstream gate, the same shape as msgspec's 3.13t -gate below. Reproduced 2026-07-18: `uv pip install --python <3.13t venv> -".[fastmcp]"` fails building `cffi`. (The three opentelemetry-stack import bugs -that previously also blocked 3.14t are fixed in `changes/2026-07-18.01` and -`changes/2026-07-19.01`.) -**Trigger:** `cffi` ships free-threaded 3.13 wheels. - -### litestar on free-threaded Python 3.13 (msgspec gates Py_GIL_DISABLED to 3.14+) - -`litestar` unconditionally requires `msgspec`, which has no free-threaded wheel for -3.13t and fails to build from source there: `msgspec`'s own `_core.c` contains -`#error "Py_GIL_DISABLED is only supported in Python 3.14+"` (msgspec v0.21.1) — an -intentional upstream gate, not a build-environment problem. Reproduced 2026-07-18: -`uv pip install --python <3.13t venv> "litestar>=2.9"` fails compiling -`msgspec._core`; the same install against a 3.14t venv succeeds cleanly and -`scripts/ft_smoke.py`-equivalent checks pass. The ft CI leg -(`.github/workflows/_checks.yml`) now installs per Python version, so `litestar` -and `litestar-metrics` run on the 3.14t leg and are excluded only from 3.13t. -**Trigger:** `msgspec` extends `Py_GIL_DISABLED` support to 3.13, at which point -`litestar` / `litestar-metrics` can be added to the 3.13t leg too. diff --git a/planning/index.py b/planning/index.py deleted file mode 100644 index 2d70ac3..0000000 --- a/planning/index.py +++ /dev/null @@ -1,183 +0,0 @@ -# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) -"""Generate the planning index from frontmatter. - -Run via ``just index``. Globs ``planning/changes/*.md`` and -``planning/decisions/*.md``, reads their frontmatter, and prints a Markdown -listing to stdout — changes then decisions, newest-first. Never writes a file: -the listing is a query over the files, not a committed artifact. - -``date`` and ``slug`` are derived from the file name, not -frontmatter — the name is the single source of truth for both. -""" - -import pathlib -import re -import sys - - -ROOT = pathlib.Path(__file__).parent -VALID_DECISION_STATUS = {"accepted", "superseded"} -CHANGE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") -DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") -SPEC_REQUIRED = ("summary",) -DECISION_REQUIRED = ("status", "summary") - - -def parse_frontmatter(text: str) -> dict[str, str]: - """Parse a single-line-scalar YAML frontmatter block into a dict.""" - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return {} - fields: dict[str, str] = {} - for line in lines[1:]: - if line.strip() == "---": - break - if line[:1] in (" ", "\t"): - continue - key, sep, value = line.partition(": ") - if not sep: - continue - cleaned = value.strip().strip('"').strip("'") - fields[key.strip()] = "" if cleaned == "null" else cleaned - return fields - - -def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: - """Inject ``date``/``slug`` derived from a file name into ``fields``.""" - match = pattern.match(name) - if match: - fields["date"] = match.group("date") - fields["slug"] = match.group("slug") - return fields - - -def load_changes(root: pathlib.Path) -> list[dict[str, str]]: - """Read each change file's summary; derive date/slug from the file name.""" - changes_dir = root / "changes" - changes: list[dict[str, str]] = [] - if not changes_dir.is_dir(): - return changes - for path in sorted(changes_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith(("_", ".")): - continue - fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, CHANGE_RE) - fields["path"] = f"changes/{path.name}" - fields["name"] = path.stem - changes.append(fields) - return changes - - -def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: - """Read each decision's frontmatter; derive date/slug from the file name.""" - decisions_dir = root / "decisions" - decisions: list[dict[str, str]] = [] - if not decisions_dir.is_dir(): - return decisions - for path in sorted(decisions_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith("_"): - continue - fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) - fields["path"] = f"decisions/{path.name}" - fields["name"] = path.stem - decisions.append(fields) - return decisions - - -def format_row(row: dict[str, str]) -> str: - """Render one change or decision as a Markdown list item.""" - slug = row.get("slug", "?") - path = row.get("path", "") - date = row.get("date", "") - summary = row.get("summary") or "(no summary)" - line = f"- **[{slug}]({path})** ({date}) — {summary}" - if row.get("supersedes"): - line += f" _(supersedes {row['supersedes']})_" - if row.get("superseded_by"): - line += f" _(superseded by {row['superseded_by']})_" - return line - - -def render(changes: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: - """Render the full Markdown listing: changes then decisions, newest-first.""" - out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] - change_rows = sorted(changes, key=lambda b: b.get("name", ""), reverse=True) - out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] - out += ["", "## Decisions", ""] - decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) - out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"] - out.append("") - return "\n".join(out).rstrip() + "\n" - - -def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations: list[str]) -> None: - """Append a violation for each required key that is absent or empty.""" - violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) - - -def _check_change(path: pathlib.Path, violations: list[str]) -> None: - """Validate one change file (requires `summary`).""" - rel = f"changes/{path.name}" - if CHANGE_RE.match(path.stem) is None: - violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'") - fields = parse_frontmatter(path.read_text(encoding="utf-8")) - _require(fields, SPEC_REQUIRED, rel, violations) - - -def _check_decision(path: pathlib.Path, violations: list[str]) -> None: - """Validate one decision file (requires `status` + `summary`).""" - rel = f"decisions/{path.name}" - if DECISION_RE.match(path.stem) is None: - violations.append(f"{rel}: file name is not 'YYYY-MM-DD-slug.md'") - fields = parse_frontmatter(path.read_text(encoding="utf-8")) - _require(fields, DECISION_REQUIRED, rel, violations) - status = fields.get("status", "") - if status and status not in VALID_DECISION_STATUS: - violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") - - -def check(root: pathlib.Path) -> list[str]: - """Validate every change file and decision; return the list of violation strings.""" - violations: list[str] = [] - changes_dir = root / "changes" - decisions_dir = root / "decisions" - if changes_dir.is_dir(): - for path in sorted(changes_dir.iterdir()): - if path.is_dir(): - violations.append( - f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files " - f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)" - ) - continue - if path.name == "README.md" or path.name.startswith(("_", ".")): - continue - if path.suffix != ".md": - violations.append(f"changes/{path.name}: unexpected non-md file in changes/") - else: - _check_change(path, violations) - if decisions_dir.is_dir(): - for path in sorted(decisions_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith("_"): - continue - _check_decision(path, violations) - return violations - - -def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Print the listing to stdout, or validate change files and decisions with --check.""" - argv = sys.argv[1:] if argv is None else argv - root = ROOT if root is None else root - if "--check" in argv: - violations = check(root) - if violations: - sys.stderr.write(f"planning: {len(violations)} violation(s)\n") - for violation in violations: - sys.stderr.write(f" - {violation}\n") - return 1 - sys.stdout.write("planning: OK\n") - return 0 - sys.stdout.write(render(load_changes(root), load_decisions(root))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/planning/plans/2026-06-13-uniform-readme-plan.md b/planning/plans/2026-06-13-uniform-readme-plan.md deleted file mode 100644 index d48f121..0000000 --- a/planning/plans/2026-06-13-uniform-readme-plan.md +++ /dev/null @@ -1,450 +0,0 @@ -# Uniform README Header + Footer — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Standardize the title, badge block, and footer of the README in all 17 `modern-python` repos (15 libraries + 2 templates), one PR per repo, leaving each repo's body untouched. - -**Architecture:** Each repo is its own git checkout under `~/src/pypi/` (libraries) or `~/src/` (templates). For each repo: branch `docs/uniform-readme`, replace the top-of-file header region (title + old badges) with the canonical header for that repo's archetype, normalize the footer, verify every badge URL resolves, commit, push, open a PR. No body content changes. - -**Tech Stack:** Markdown, shields.io / GitHub / pypistats / context7 badges, `git`, `gh` CLI, `curl` for badge verification. - -**Source spec:** `planning/specs/2026-06-13-uniform-readme-design.md` - ---- - -## Repo archetypes - -| Archetype | Repos | Header source | -|-----------|-------|---------------| -| **A — standard library** | `db-retry`, `eof-fixer`, `faststream-concurrent-aiokafka`, `faststream-outbox`, `faststream-redis-timers`, `httpware`, `lite-bootstrap`, `modern-di-fastapi`, `modern-di-faststream`, `modern-di-litestar`, `modern-di-pytest`, `modern-di-typer` (12) | `REFERENCE: Header A` | -| **B — standard library, pkg ≠ repo** | `autosemver` (pkg `semvertag`) (1) | `REFERENCE: Header A`, substitute pkg | -| **C — table exception** | `modern-di` (1) | `REFERENCE: Header C` | -| **D — showcase** | `that-depends` (1) | `REFERENCE: Header D` | -| **E — template (not on PyPI)** | `fastapi-sqlalchemy-template`, `litestar-sqlalchemy-template` (2) | `REFERENCE: Header E` | - -Library checkout root: `~/src/pypi/`. Template checkout root: `~/src/`. - ---- - -## REFERENCE: canonical blocks - -> These blocks are the single source of truth. Per-repo tasks tell you which to use and which variables/conditionals apply. **Substitute `` (GitHub repo slug) and `` (PyPI name) literally.** For archetype A/B they are identical except `autosemver`→`semvertag`. - -### REFERENCE: Header A (standard library) - -```markdown -# - -[![PyPI version](https://img.shields.io/pypi/v/.svg)](https://pypi.org/project//) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/.svg)](https://pypi.org/project//) -[![Downloads](https://img.shields.io/pypi/dm/.svg)](https://pypistats.org/packages/) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![CI](https://github.com/modern-python//actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/.svg)](https://github.com/modern-python//blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/)](https://github.com/modern-python//stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -**Conditional:** Remove the `Context7` line if Task 1 marked the repo NOT-indexed. - -### REFERENCE: Header C (`modern-di` — keep the table) - -Do NOT replace the existing per-package badge table. Keep the title as `# modern-di` (lowercase ATX) and the existing table immediately below it. Insert this flat badge row **between the title and the table**: - -```markdown -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/modern-di) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -**Conditional:** Remove the `Context7` line if Task 1 marked `modern-di` NOT-indexed. - -### REFERENCE: Header D (`that-depends` — showcase) - -Title `# that-depends`. Badge block (note: keeps codecov live badge, mypy/pyrefly, libs.tech, llms.txt; switches downloads pepy→pypistats; adds PyPI version, CI, License, Context7; NO uv/ruff/ty; NO static 100% badge): - -```markdown -# that-depends - -[![PyPI version](https://img.shields.io/pypi/v/that-depends.svg)](https://pypi.org/project/that-depends/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/that-depends.svg)](https://pypi.org/project/that-depends/) -[![Downloads](https://img.shields.io/pypi/dm/that-depends.svg)](https://pypistats.org/packages/that-depends) -[![Test Coverage](https://codecov.io/gh/modern-python/that-depends/branch/main/graph/badge.svg)](https://codecov.io/gh/modern-python/that-depends) -[![CI](https://github.com/modern-python/that-depends/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/that-depends/actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/that-depends.svg)](https://github.com/modern-python/that-depends/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/that-depends)](https://github.com/modern-python/that-depends/stargazers) -[![MyPy Strict](https://img.shields.io/badge/mypy-strict-blue)](https://mypy.readthedocs.io/en/stable/getting_started.html#strict-mode-and-configuration) -[![pyrefly](https://img.shields.io/endpoint?url=https://pyrefly.org/badge.json)](https://github.com/facebook/pyrefly) -[![libs.tech recommends](https://libs.tech/project/773446541/badge.svg)](https://libs.tech/project/773446541/that-depends) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/that-depends) -[![llms.txt](https://img.shields.io/badge/llms.txt-green)](https://that-depends.modern-python.org/llms.txt) -``` - -### REFERENCE: Header E (template — not on PyPI) - -Title `# ` (lowercase ATX), keep the existing one-line tagline as the first body line. No PyPI/version/downloads badges. Drop the existing `GitHub issues`/`GitHub forks` badges. - -```markdown -# - - - -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![CI](https://github.com/modern-python//actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/.svg)](https://github.com/modern-python//blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/)](https://github.com/modern-python//stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -**Conditional:** Remove the `Context7` line if Task 1 marked the template NOT-indexed. - -### REFERENCE: Footer (all repos) - -Replace the existing tail of the README (the `## 📚` / `## 📦` / `## 📝` / `## Part of modern-python` / `## License` / `## Acknowledgements`-style links, if present) so the file **ends** with exactly this. Keep any `## Acknowledgements` section that exists **above** this footer. - -```markdown -## 📚 [Documentation](https://.modern-python.org) - -## 📦 [PyPI](https://pypi.org/project/) - -## 📝 [License](LICENSE) - -## Part of `modern-python` - -Browse the full list of templates and libraries in -[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index. -``` - -**Conditionals:** -- Remove the `## 📚 [Documentation]` line if Task 1 marked the repo as having NO live docs site. -- Remove the `## 📦 [PyPI]` line for templates (archetype E). - ---- - -## REFERENCE: per-repo procedure - -Every per-repo task (Tasks 2–18) follows these exact steps. `$ROOT` = `~/src/pypi` for libraries, `~/src` for templates. Replace ``, ``. - -- **Step a — Branch.** - -```bash -cd $ROOT/ -git switch -c docs/uniform-readme -``` - -- **Step b — Edit the README.** Open the README file (`README.md`, or `readme.md` for templates — preserve existing casing). Replace the header region (everything from the start of the file down to and including the last badge line / the line just before the first prose paragraph or first `##` body section) with the resolved header block named in the task. Then replace the footer region with the resolved footer block. Apply the task's conditionals (drop Context7 / Documentation / PyPI lines as instructed by Task 1's results). - -- **Step c — Verify badges resolve.** Run the link checker; every line must print `200` (3xx is fine for redirects — `-L` follows them). Investigate any `4xx`/`5xx`. - -```bash -README=$(ls README.md readme.md 2>/dev/null | head -1) -grep -oE 'https?://[^) ]+' "$README" | sort -u | while read -r u; do - code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 "$u") - printf '%s %s\n' "$code" "$u" -done -``` - -Expected: all `200`. A `404` on the Context7 URL means the repo is not indexed → remove the Context7 badge (this should already be handled by Task 1's flags). A `404` on the docs URL means no docs site → remove the `## 📚 [Documentation]` line. - -- **Step d — Visual check.** Run `gh markdown` preview or eyeball: title is `# `, badges render in one block, footer ends with the "Part of `modern-python`" paragraph, no duplicated badge rows, body unchanged. - -```bash -git diff -- "$README" # confirm ONLY header + footer changed, body untouched -``` - -- **Step e — Commit.** - -```bash -git add "$README" -git commit -m "docs: standardize README header and footer - -Aligns with the modern-python uniform-README standard. -See planning/specs/2026-06-13-uniform-readme-design.md in lite-bootstrap. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - -- **Step f — Push + open PR.** - -```bash -git push -u origin docs/uniform-readme -gh pr create --title "docs: standardize README header and footer" --body "$(cat <<'EOF' -Standardizes this repo's README header (title + badge block) and footer -(documentation / PyPI / license links + "Part of \`modern-python\`") to the -org-wide uniform-README standard. Body content is unchanged. - -Standard: lowercase ATX title, badge block (PyPI version · Python versions · -downloads · coverage · CI · license · GitHub stars · Context7 · uv/ruff/ty), -and the shared "Part of \`modern-python\`" footer. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Task 1: Preflight — resolve per-repo conditionals - -**Files:** none committed; produces a scratch table the later tasks read. - -- [ ] **Step 1: Verify Context7 indexing for all 17 repos.** - -```bash -for r in db-retry eof-fixer faststream-concurrent-aiokafka faststream-outbox \ - faststream-redis-timers httpware lite-bootstrap modern-di modern-di-fastapi \ - modern-di-faststream modern-di-litestar modern-di-pytest modern-di-typer \ - that-depends autosemver fastapi-sqlalchemy-template litestar-sqlalchemy-template; do - code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 "https://context7.com/modern-python/$r") - printf '%s context7 %s\n' "$code" "$r" -done -``` - -Expected: `200` = indexed (keep Context7 badge); `404` = not indexed (drop Context7 badge for that repo). Record the list of NOT-indexed repos. - -- [ ] **Step 2: Verify docs sites for all repos.** - -```bash -for r in db-retry eof-fixer faststream-concurrent-aiokafka faststream-outbox \ - faststream-redis-timers httpware lite-bootstrap modern-di modern-di-fastapi \ - modern-di-faststream modern-di-litestar modern-di-pytest modern-di-typer \ - that-depends autosemver fastapi-sqlalchemy-template litestar-sqlalchemy-template; do - code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 "https://$r.modern-python.org") - printf '%s docs %s\n' "$code" "$r" -done -``` - -Expected: `200` = live docs site (keep `## 📚 [Documentation]` line); other = no site (drop the line). Known-live from spec: `autosemver`, `faststream-outbox`, `faststream-redis-timers`, `httpware`, `lite-bootstrap`, `modern-di`, `that-depends`. - -- [ ] **Step 3: Confirm each repo is on a clean default branch.** - -```bash -for r in ~/src/pypi/{db-retry,eof-fixer,faststream-concurrent-aiokafka,faststream-outbox,faststream-redis-timers,httpware,lite-bootstrap,modern-di,modern-di-fastapi,modern-di-faststream,modern-di-litestar,modern-di-pytest,modern-di-typer,that-depends,autosemver} ~/src/{fastapi-sqlalchemy-template,litestar-sqlalchemy-template}; do - printf '%s ' "$r"; git -C "$r" status --porcelain=v1 | head -1; git -C "$r" rev-parse --abbrev-ref HEAD -done -``` - -Expected: each repo clean (no output from `--porcelain`) and on its default branch. If any repo is dirty, stop and surface it. - -- [ ] **Step 4: Record results.** Write the NOT-indexed-on-Context7 list and the no-docs-site list into this plan's checkboxes (or a scratch note) so Tasks 2–18 know which conditional lines to drop. - ---- - -## Task 2: `lite-bootstrap` (archetype A — worked example) - -**Files:** Modify `~/src/pypi/lite-bootstrap/README.md` - -- [ ] **Step 1:** Run per-repo procedure Step a (branch) with `=lite-bootstrap`. -- [ ] **Step 2:** Apply **REFERENCE: Header A** with `=lite-bootstrap`, `=lite-bootstrap`. Context7 = keep (indexed, confirmed). The resolved header is: - -```markdown -# lite-bootstrap - -[![PyPI version](https://img.shields.io/pypi/v/lite-bootstrap.svg)](https://pypi.org/project/lite-bootstrap/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/lite-bootstrap.svg)](https://pypi.org/project/lite-bootstrap/) -[![Downloads](https://img.shields.io/pypi/dm/lite-bootstrap.svg)](https://pypistats.org/packages/lite-bootstrap) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/lite-bootstrap/actions/workflows/ci.yml) -[![CI](https://github.com/modern-python/lite-bootstrap/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/lite-bootstrap/actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/lite-bootstrap.svg)](https://github.com/modern-python/lite-bootstrap/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/lite-bootstrap)](https://github.com/modern-python/lite-bootstrap/stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/lite-bootstrap) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -Replace existing lines 1–6 (`Lite-Bootstrap\n==` + the 4 old badges). Keep the `## Lifecycle constraints`, usage examples, and `## Acknowledgements` body sections. - -- [ ] **Step 3:** Apply **REFERENCE: Footer** with `=lite-bootstrap`, `=lite-bootstrap`, Documentation line kept (docs site live). Replace the existing `## 📚` / `## 📦` / `## 📝` / `## Part of modern-python` tail. Keep `## Acknowledgements` above the footer. -- [ ] **Step 4:** Run per-repo procedure Steps c (verify badges), d (visual + `git diff`). -- [ ] **Step 5:** Run per-repo procedure Steps e (commit), f (push + PR). - ---- - -## Tasks 3–13: archetype A standard libraries - -For each repo below, follow the **per-repo procedure** Steps a–f, applying **REFERENCE: Header A** and **REFERENCE: Footer** with the listed ``/`` (here `` == ``), and applying Task 1's Context7 / Documentation conditionals. - -- [ ] **Task 3: `db-retry`** — `==db-retry`. Docs line: drop unless Task 1 found a live site. -- [ ] **Task 4: `eof-fixer`** — `==eof-fixer`. Docs line: drop unless Task 1 found a live site. (Body has a large Features/Usage section — leave intact.) -- [ ] **Task 5: `faststream-concurrent-aiokafka`** — `==faststream-concurrent-aiokafka`. Docs line: drop unless live. -- [ ] **Task 6: `faststream-outbox`** — `==faststream-outbox`. Docs line: keep (docs.yml present). -- [ ] **Task 7: `faststream-redis-timers`** — `==faststream-redis-timers`. Docs line: keep (docs.yml present). -- [ ] **Task 8: `httpware`** — `==httpware`. Docs line: keep (docs.yml present). (Body has detailed Install/Quickstart — leave intact; replace only the current top 4 badges.) -- [ ] **Task 9: `modern-di-fastapi`** — `==modern-di-fastapi`. Docs line: drop unless live. -- [ ] **Task 10: `modern-di-faststream`** — `==modern-di-faststream`. Docs line: drop unless live. -- [ ] **Task 11: `modern-di-litestar`** — `==modern-di-litestar`. Docs line: drop unless live. -- [ ] **Task 12: `modern-di-pytest`** — `==modern-di-pytest`. Docs line: drop unless live. -- [ ] **Task 13: `modern-di-typer`** — `==modern-di-typer`. Docs line: drop unless live. - -Each task's checklist: a) branch · b) apply Header A + Footer (resolved) · c) verify badges · d) visual + diff · e) commit · f) push + PR. - ---- - -## Task 14: `autosemver` (archetype B — pkg ≠ repo) - -**Files:** Modify `~/src/pypi/autosemver/README.md` - -- [ ] **Step 1:** Branch (Step a), `=autosemver`. -- [ ] **Step 2:** Apply **REFERENCE: Header A** with `=semvertag` and `=autosemver`. Note the split: PyPI/downloads badges use `semvertag`; GitHub/CI/Context7 use `autosemver`. Resolved header: - -```markdown -# semvertag - -[![PyPI version](https://img.shields.io/pypi/v/semvertag.svg)](https://pypi.org/project/semvertag/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/semvertag.svg)](https://pypi.org/project/semvertag/) -[![Downloads](https://img.shields.io/pypi/dm/semvertag.svg)](https://pypistats.org/packages/semvertag) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/autosemver/actions/workflows/ci.yml) -[![CI](https://github.com/modern-python/autosemver/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/autosemver/actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/autosemver.svg)](https://github.com/modern-python/autosemver/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/autosemver)](https://github.com/modern-python/autosemver/stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/autosemver) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -Drop the Context7 line if Task 1 marked `autosemver` NOT-indexed. - -- [ ] **Step 3:** Apply **REFERENCE: Footer** with `=autosemver`, `=semvertag`. Docs line: keep (docs.yml present). -- [ ] **Step 4:** Steps c, d. -- [ ] **Step 5:** Steps e, f. - ---- - -## Task 15: `modern-di` (archetype C — keep the table) - -**Files:** Modify `~/src/pypi/modern-di/README.md` - -- [ ] **Step 1:** Branch (Step a), `=modern-di`. -- [ ] **Step 2:** Change the title to lowercase ATX `# modern-di` (currently `"Modern-DI"\n==`). **Keep the existing per-package badge table unchanged.** -- [ ] **Step 3:** Insert the **REFERENCE: Header C** flat badge row between the `# modern-di` title and the table (drop Context7 line if Task 1 marked NOT-indexed): - -```markdown -# modern-di - -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/modern-di) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) - -| Project | Badges | -... (existing table unchanged) ... -``` - -- [ ] **Step 4:** Apply **REFERENCE: Footer** with `=modern-di`, `=modern-di`. Docs line: keep (docs.yml present). Replace the existing footer tail. -- [ ] **Step 5:** Steps c, d (confirm the table is intact and unmodified in the diff). -- [ ] **Step 6:** Steps e, f. - ---- - -## Task 16: `that-depends` (archetype D — showcase) - -**Files:** Modify `~/src/pypi/that-depends/README.md` - -- [ ] **Step 1:** Branch (Step a), `=that-depends`. -- [ ] **Step 2:** Replace the existing title + badge block (lines 1–9) with **REFERENCE: Header D** verbatim. Keep the `> Starting a new project?` callout and `## Ecosystem` body intact below. -- [ ] **Step 3:** Apply **REFERENCE: Footer** with `=that-depends`, `=that-depends`. Docs line: keep (docs site live). -- [ ] **Step 4:** Steps c, d. Note: codecov, libs.tech, pyrefly, llms.txt badge URLs are expected `200`; if libs.tech returns non-200, keep it (its project-id badge can rate-limit) but flag. -- [ ] **Step 5:** Steps e, f. - ---- - -## Task 17: `fastapi-sqlalchemy-template` (archetype E) - -**Files:** Modify `~/src/fastapi-sqlalchemy-template/readme.md` (lowercase — preserve casing) - -- [ ] **Step 1:** Branch (Step a), `$ROOT=~/src`, `=fastapi-sqlalchemy-template`. -- [ ] **Step 2:** Apply **REFERENCE: Header E** with `=fastapi-sqlalchemy-template`. Title `# fastapi-sqlalchemy-template`; keep the existing tagline `Production-ready dockerized async REST API on FastAPI with SQLAlchemy and PostgreSQL` as the first body line. **Drop** the existing `Test Coverage` (codecov), `GitHub issues`, and `GitHub forks` badges. Drop Context7 line if Task 1 marked NOT-indexed. Resolved badge block: - -```markdown -# fastapi-sqlalchemy-template - -Production-ready dockerized async REST API on FastAPI with SQLAlchemy and PostgreSQL. - -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/fastapi-sqlalchemy-template/actions/workflows/ci.yml) -[![CI](https://github.com/modern-python/fastapi-sqlalchemy-template/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/fastapi-sqlalchemy-template/actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/fastapi-sqlalchemy-template.svg)](https://github.com/modern-python/fastapi-sqlalchemy-template/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/fastapi-sqlalchemy-template)](https://github.com/modern-python/fastapi-sqlalchemy-template/stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/fastapi-sqlalchemy-template) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -Keep the existing `## Key Features` and other body sections, including the existing `## Part of modern-python` block (it will be normalized by the footer step). - -- [ ] **Step 3:** Apply **REFERENCE: Footer** with `=fastapi-sqlalchemy-template`. **Drop the `## 📦 [PyPI]` line** (template, not on PyPI). Docs line: drop unless Task 1 found a live site. The existing README already ends with a `## Part of modern-python` block — replace it with the footer block (without the PyPI line). -- [ ] **Step 4:** Verify the CI workflow filename is `ci.yml` for this repo before committing: - -```bash -ls ~/src/fastapi-sqlalchemy-template/.github/workflows/ -``` - -Expected: includes `ci.yml`. If the CI workflow has a different name, substitute it in the Coverage + CI badge URLs. - -- [ ] **Step 5:** Steps c, d. -- [ ] **Step 6:** Steps e, f. - ---- - -## Task 18: `litestar-sqlalchemy-template` (archetype E) - -**Files:** Modify `~/src/litestar-sqlalchemy-template/readme.md` (lowercase — preserve casing) - -- [ ] **Step 1:** Branch (Step a), `$ROOT=~/src`, `=litestar-sqlalchemy-template`. -- [ ] **Step 2:** Apply **REFERENCE: Header E** with `=litestar-sqlalchemy-template`. Title `# litestar-sqlalchemy-template`; keep the existing tagline as the first body line. **Drop** the existing codecov/issues/forks badges. Drop Context7 line if Task 1 marked NOT-indexed. Resolved badge block: - -```markdown -# litestar-sqlalchemy-template - -Production-ready dockerized async REST API on LiteStar with SQLAlchemy and PostgreSQL. - -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/litestar-sqlalchemy-template/actions/workflows/ci.yml) -[![CI](https://github.com/modern-python/litestar-sqlalchemy-template/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/litestar-sqlalchemy-template/actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/litestar-sqlalchemy-template.svg)](https://github.com/modern-python/litestar-sqlalchemy-template/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/litestar-sqlalchemy-template)](https://github.com/modern-python/litestar-sqlalchemy-template/stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/litestar-sqlalchemy-template) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -- [ ] **Step 3:** Apply **REFERENCE: Footer** with `=litestar-sqlalchemy-template`. **Drop the `## 📦 [PyPI]` line.** Docs line: drop unless live. -- [ ] **Step 4:** Verify CI workflow filename is `ci.yml` (`ls ~/src/litestar-sqlalchemy-template/.github/workflows/`); substitute if different. -- [ ] **Step 5:** Steps c, d. -- [ ] **Step 6:** Steps e, f. - ---- - -## Task 19: Final sweep - -- [ ] **Step 1:** List all opened PRs to confirm 17 exist. - -```bash -for r in ~/src/pypi/{db-retry,eof-fixer,faststream-concurrent-aiokafka,faststream-outbox,faststream-redis-timers,httpware,lite-bootstrap,modern-di,modern-di-fastapi,modern-di-faststream,modern-di-litestar,modern-di-pytest,modern-di-typer,that-depends,autosemver} ~/src/{fastapi-sqlalchemy-template,litestar-sqlalchemy-template}; do - printf '%s ' "$(basename "$r")"; gh -R "modern-python/$(basename "$r")" pr list --head docs/uniform-readme --json url --jq '.[0].url // "MISSING"' -done -``` - -Expected: 17 PR URLs, none `MISSING`. - -- [ ] **Step 2:** Spot-check the GitHub-rendered README of 3 PRs (one archetype A, `modern-di`, one template) to confirm badges render and bodies are unchanged. - ---- - -## Notes / known facts (from spec + investigation) - -- CI workflow filename is `ci.yml` in every library repo (verified). Templates verified per-task (Tasks 17/18 Step 4). -- Coverage guard `cov-fail-under=100` present in all repos **except `that-depends`** → `that-depends` is the only repo keeping a live codecov badge instead of the static 100% badge. -- `autosemver` repo publishes to PyPI as `semvertag`. -- Astral trio (uv/ruff/ty) on every repo **except `that-depends`**. -- `modern-di` keeps its sub-package badge table; do not flatten it. -- Each repo is a separate git remote → 17 independent PRs; there is no way to batch them into one PR. diff --git a/planning/releases/1.1.0.md b/planning/releases/1.1.0.md deleted file mode 100644 index b69d877..0000000 --- a/planning/releases/1.1.0.md +++ /dev/null @@ -1,79 +0,0 @@ -# lite-bootstrap 1.1.0 — Lifecycle hardening, config validation, CI gate - -**1.1.0 is a minor release. No intentional public-API breakage.** The two behavior changes that could affect existing code are fixes to genuine bugs and are called out in *Behavior changes* below. - -This release closes a 26-finding bug-audit cycle (the audit lives in `planning/audits/`, the implementation arc in `planning/changes/2026-06-05.01-bug-audit-v2.md`, and the retro in `planning/retros/`). The changes split across four shipped PRs: - -- **#108** — Lifecycle & teardown correctness (10 findings) -- **#109** — Config UX & security validation (6 findings) -- **#110** — Hygiene + CI gate (4 findings) -- **#111** — Generalized `TeardownError` aggregation + cascade tests + README lifecycle docs (3 deferred follow-ups) - -Test suite grew from 153 → 194 (+27%) at 100% line coverage throughout. `pip-audit` now runs on every PR and weekly via cron; a new `filterwarnings` config catches accidental `InstrumentSkippedWarning` emissions. - -## New features - -- **Injectable `prometheus_collector_registry` on `FastStreamConfig`.** Pass an existing `prometheus_client.CollectorRegistry` to expose counters registered elsewhere through FastStream's `/metrics` endpoint. Defaults to a fresh per-instance registry — fully backward compatible. -- **`opentelemetry_excluded_urls` field on `FastStreamConfig`.** Was a `getattr` fallback before; now a discoverable, IDE-completable config field matching `FastAPIConfig` and `LitestarConfig`. -- **`SentryInstrument.teardown()`.** Calls `sentry_sdk.flush(timeout=2)` then `sentry_sdk.init()` (no args) to reset the SDK to a no-op state. Previously the SDK stayed globally configured after bootstrapper teardown, leaking state across process-local tests. -- **`FastStreamLoggingInstrument.teardown()`.** Restores `broker.config.logger.params_storage` to its pre-bootstrap value. The bootstrap mutated broker state; teardown didn't reverse it. - -## Bug fixes - -### Lifecycle & teardown (PR #108) - -- **OpenTelemetry teardown now flushes spans and shuts down the tracer provider** (LOG-1, LOG-2). `bootstrap()` stored the `TracerProvider` only as a local; `teardown()` couldn't reach it to call `shutdown()`. Buffered spans in `BatchSpanProcessor` were dropped on graceful shutdown. Teardown also restores the two OTel-namespace stdlib loggers (`opentelemetry.instrumentation.instrumentor`, `opentelemetry.trace`) to their pre-bootstrap `disabled` state. -- **`LoggingInstrument.teardown()` runs all cleanup steps even on partial failure** (LOG-3). A raise from any `handler.close()` previously left remaining handlers attached, skipped the root-level reset, and never called `close_handlers()` on the memory factory. Now wrapped in `try/finally` with per-handler error capture; all collected errors raise together via `TeardownError(errors)`. -- **`LitestarOpenTelemetryInstrumentationMiddleware` cache evicts dead refs** (LOG-6). The old `dict[int, ASGIApp]` keyed by `id()` never evicted, holding wrapper `OpenTelemetryMiddleware` instances alive after Litestar dropped `next_app`. Replaced with `weakref.WeakKeyDictionary`; non-weakrefable apps fall through to the un-cached path. -- **Double-bootstrap on the same application is now detected and warned** (LOG-7, LOG-8). Constructing two `FastMcpBootstrapper`s around the same `FastMCP` (or two `FastAPIBootstrapper`s around the same `FastAPI`) previously stacked teardown hooks. The second construction now emits a `UserWarning`, skips the re-attachment, and tells you the second bootstrapper's `teardown()` won't fire on ASGI shutdown. -- **Generalized `TeardownError` aggregation across all instruments** (PR #111). `OpenTelemetryInstrument`, `FastStreamLoggingInstrument`, and `SentryInstrument` now run their full cleanup sequence even if an early step raises — aggregating errors into a single `TeardownError` or letting `super().teardown()` run via `try/finally`. Previously a misbehaving instrumentor / broker / Sentry flush silently skipped subsequent cleanup. -- **Pyroscope's precondition check survives `python -O`** (LOG-5/SEC-4). Replaced two `assert` statements (in `_narrow_app` and `PyroscopeInstrument.bootstrap`) with explicit `raise TypeError(...)` and `raise RuntimeError(...)`. `python -O` strips asserts; the invariants now hold under all optimization levels. Closes the two `bandit` B101 findings. - -### Config & security (PR #109) - -- **`FastAPIConfig` no longer stomps user-supplied app's `title`/`debug`/`version`** (UX-1). Previously the three assignments ran unconditionally; a user passing `FastAPI(title="My API", version="3.0.0")` would silently have those clobbered by lite-bootstrap defaults. Now the assignments only run in the `UnsetType` branch (when lite-bootstrap constructed the app). **See Behavior changes below.** -- **`enable_offline_docs` validates `request.scope["root_path"]` against the existing path allowlist** (SEC-1). Invalid root paths (e.g., HTML-injection payloads via a malicious upstream proxy's `X-Forwarded-Prefix`) now fall back to empty and emit a warning instead of being reflected into Swagger/Redoc HTML script tags. Threat model: not an issue in default ASGI deployments; only matters if `ProxyHeadersMiddleware` trusts upstream prefix headers. -- **OpenTelemetry endpoint with `insecure=True` emits a warning for non-local hosts** (SEC-2). New `OpenTelemetryConfig.__post_init__` parses the endpoint (handles both `host:port` and `scheme://host:port` forms, including IPv6 brackets and `unix://`) and warns when traces would ship unencrypted to a non-`localhost`/`127.0.0.1`/`::1`/`unix://` target. -- **`CorsConfig` rejects unsafe wildcard + credentials combos at construction** (SEC-3). `cors_allowed_credentials=True` combined with `cors_allowed_origins=["*"]` (or a permissive regex like `".*"`/`".+"`) is the canonical CORS misconfiguration — browsers reject the response. Now raises `ConfigurationError` immediately instead of silently building a non-functional CORS layer. **See Behavior changes below.** - -### Hygiene & process (PR #110) - -- **Missing-dependency events now log via stdlib `logging` in addition to `warnings.warn`** (UX-4). Users running under `python -W ignore` or `PYTHONWARNINGS=ignore` previously saw nothing when a configured instrument's optional dep was missing. The new `logger.warning` line on `lite_bootstrap.bootstrappers.base` is unaffected by warning filters. - -## Behavior changes - -Two changes could affect existing code in ways the previous release wouldn't have. Both fix real bugs; surfaced here so you can audit. - -1. **User-supplied `FastAPI` instance retains its own `title`, `debug`, `version`.** If you were relying on lite-bootstrap to overwrite these from `service_name`/`service_debug`/`service_version` after handing it a pre-built `FastAPI()`, you'll now see your original values. Migration: set these on your `FastAPI()` directly, or use `FastAPIConfig(application_kwargs={...})` to have lite-bootstrap construct the app. - -2. **`CorsConfig(cors_allowed_origins=["*"], cors_allowed_credentials=True)` now raises `ConfigurationError`.** This combo was never functional — browsers reject responses with `Access-Control-Allow-Credentials: true` and `Access-Control-Allow-Origin: *`. If your code constructed this combo and worked anyway (because the credentials header was silently dropped by FastAPI's CORSMiddleware), construction now fails with a clear message. Migration: enumerate allowed origins explicitly, or set `cors_allowed_credentials=False`. - -## New constraints documented - -Three lifecycle constraints surfaced by the audit are now documented in `README.md` and `CLAUDE.md`: - -- **One bootstrapper per application instance.** Second construction emits a warning and skips re-attachment (see LOG-7/LOG-8 above). -- **One `OpenTelemetryInstrument` per process.** The OTel SDK enforces `set_tracer_provider` as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` (verified against `opentelemetry/trace/__init__.py:548-556`); `teardown()` cannot reset the global pointer. -- **`__post_init__` cascade invariant.** Every config-class `__post_init__` must call `super().__post_init__()`. `BaseConfig` ships a no-op as the chain terminator. `FastAPIConfig` uses the explicit `super(FastAPIConfig, self).__post_init__()` form because `@dataclass(slots=True)` breaks bare `super()`. - -## CI changes - -- **`security-audit.yml` workflow added.** `pip-audit` runs on every PR and weekly via cron against the lockfile (`uv export --all-extras --no-hashes`). The default-branch run is purely informational; PRs that introduce CVEs will block until resolved. -- **`InstrumentSkippedWarning` escalated to error in tests.** Any unexpected emission outside a `pytest.warns(...)` block now fails the test (registered via `pytest_configure()` in `tests/conftest.py`; can't live in `pyproject.toml` because that import order breaks pytest-cov tracing). - -## Backwards compatibility - -Aside from the two *Behavior changes* called out above, every public API behaves identically. New fields default to their old behavior (`prometheus_collector_registry=None` → fresh registry as before; `opentelemetry_excluded_urls=[]` → empty set as before). New warnings/validators trigger only on configurations that were already broken or risky. - -The 26-fix list with full file:line references and rationale is in: - -- `planning/audits/2026-06-05-bug-audit-v2.md` — the audit -- `planning/changes/2026-06-05.01-bug-audit-v2.md` — the 3-PR breakdown -- `planning/retros/2026-06-05-bug-audit-v2-retro.md` — what the cycle taught us - -## References - -- Audit: [`planning/audits/2026-06-05-bug-audit-v2.md`](../audits/2026-06-05-bug-audit-v2.md) -- Sequencing: [`planning/changes/2026-06-05.01-bug-audit-v2.md`](../changes/2026-06-05.01-bug-audit-v2.md) -- Retro: [`planning/retros/2026-06-05-bug-audit-v2-retro.md`](../retros/2026-06-05-bug-audit-v2-retro.md) -- PRs: [#108](https://github.com/modern-python/lite-bootstrap/pull/108), [#109](https://github.com/modern-python/lite-bootstrap/pull/109), [#110](https://github.com/modern-python/lite-bootstrap/pull/110), [#111](https://github.com/modern-python/lite-bootstrap/pull/111) diff --git a/planning/releases/1.1.1.md b/planning/releases/1.1.1.md deleted file mode 100644 index cd1565a..0000000 --- a/planning/releases/1.1.1.md +++ /dev/null @@ -1,21 +0,0 @@ -# lite-bootstrap 1.1.1 — FastAPI 0.137 compatibility - -**1.1.1 is a patch release. No public-API or behavior changes.** It restores compatibility with FastAPI 0.137 and pins a transitive incompatibility, nothing more. - -## Bug fixes - -- **Offline docs no longer crash on FastAPI 0.137's `_IncludedRouter`** (PR [#122](https://github.com/modern-python/lite-bootstrap/pull/122)). FastAPI 0.137.0 added the internal `_IncludedRouter` route type (a `BaseRoute` subclass with no `.path`) to `app.router.routes`. `enable_offline_docs` filtered routes via an unchecked `typing.cast(Route, route).path`, which raised `AttributeError: '_IncludedRouter' object has no attribute 'path'` whenever a router was included (e.g. by the health-checks instrument). The filter now matches only real `Route` instances (`isinstance(route, Route) and route.path in …`), leaving `_IncludedRouter` and other route types untouched. Correct on both old and new FastAPI. - -## Dependency constraints - -- **`fastapi<0.137` cap on the `fastapi` extra (temporary).** `prometheus-fastapi-instrumentator` (≤ 8.0.0) has the same unguarded `route.path` access and is not yet fixed upstream, so any lite-bootstrap install that pulls FastAPI 0.137 would break metrics. The cap lives on the base `fastapi` extra — the single declaration every other `fastapi-*` extra composes from — so the whole FastAPI surface resolves to a version tested end-to-end with no skew between extras. Lift the cap once a fixed instrumentator ships. - - Upstream issue: [trallnag/prometheus-fastapi-instrumentator#370](https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/370) - -## Backwards compatibility - -Fully backward compatible with 1.1.0. No public API changed; the FastAPI fix is purely defensive. The only observable difference is the new `fastapi<0.137` resolution ceiling, which holds installs at a known-good FastAPI until the upstream metrics fix lands. - -## References - -- PR: [#122](https://github.com/modern-python/lite-bootstrap/pull/122) -- Upstream issue: [trallnag/prometheus-fastapi-instrumentator#370](https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/370) diff --git a/planning/releases/1.2.0.md b/planning/releases/1.2.0.md deleted file mode 100644 index 0118cc0..0000000 --- a/planning/releases/1.2.0.md +++ /dev/null @@ -1,28 +0,0 @@ -# lite-bootstrap 1.2.0 — deeper seams: structured-log payload, unified teardown guard, OTel config tidy-up - -**1.2.0 is a minor release. Backward compatible with 1.1.1, with one observable behavior change** (a new double-attach warning on Litestar and FastStream). It lands the shippable results of an architecture-deepening sweep: the structlog→Sentry contract gets a single owner, the teardown-on-shutdown guard is unified across all frameworks, and an OpenTelemetry config field moves to where it belongs. - -## Features - -- **`StructuredLogPayload` owns the structlog→Sentry contract** (PR [#129](https://github.com/modern-python/lite-bootstrap/pull/129)). The rendered-log-line shape that the Sentry instrument used to sniff and re-parse inline now lives in one value object (`lite_bootstrap.instruments.logging_factory.StructuredLogPayload`), with its meta-key vocabulary exposed as the public `STRUCTLOG_META_KEYS`. This closes a silent-drift failure mode: previously, renaming or adding a structlog meta-key could quietly leak it into Sentry's `contexts.structlog` or drop the enrichment, with no test to catch it. Sentry output is unchanged for existing setups; the enrichment is now pinned by a round-trip test. - -- **`opentelemetry_excluded_urls` now lives on `OpenTelemetryConfig`** (PR [#132](https://github.com/modern-python/lite-bootstrap/pull/132)). The field was OpenTelemetry's own setting but had been declared separately on each of the FastAPI, Litestar, and FastStream configs. It now lives on `OpenTelemetryConfig`, so it is available wherever OpenTelemetry is configured and is read with typed access internally. The automatic exclusion of the metrics path and (unless health-check spans are enabled) the health-check path from traces is unchanged and now covered by a regression test. - -## Behavior changes - -- **The double-attach teardown guard now applies to Litestar and FastStream** (PR [#130](https://github.com/modern-python/lite-bootstrap/pull/130)). Teardown-on-shutdown wiring is unified behind a single `BaseBootstrapper._attach_teardown_once` seam. The safeguard that warns and skips when a second bootstrapper is constructed against the same application — previously present only on FastAPI and FastMCP — now covers Litestar and FastStream as well. **If your code constructs two bootstrappers against one Litestar `AppConfig` or FastStream app, you will now see a `UserWarning` and the second teardown is skipped, where this was previously silent.** This is non-breaking: the warning is informational, the second attach is skipped (not errored), and the first bootstrapper's teardown still runs on shutdown. Construct one bootstrapper per application. - -## Backwards compatibility - -Fully backward compatible with 1.1.1. No public API was removed. - -- `IGNORED_STRUCTLOG_ATTRIBUTES` is retained in `sentry_instrument` as a silent alias of `STRUCTLOG_META_KEYS`, so existing imports keep working. -- `opentelemetry_excluded_urls` is still set exactly as before (e.g. `FastAPIConfig(opentelemetry_excluded_urls=[...])`) — it is now inherited rather than locally declared. `FreeConfig` additionally accepts the field now (inert there, since Free has no HTTP surface). -- The only observable difference is the new Litestar/FastStream double-attach warning described above. - -Internal-only changes that do not affect the public API: FastMCP's double-attach detection moved from scanning the provider list to the shared attribute marker, and FastAPI's internal lifespan marker was renamed to the unified `_lite_bootstrap_teardown_attached`. - -## References - -- PRs: [#129](https://github.com/modern-python/lite-bootstrap/pull/129), [#130](https://github.com/modern-python/lite-bootstrap/pull/130), [#132](https://github.com/modern-python/lite-bootstrap/pull/132) -- Design bundles and the decisions behind the rejected/limited options: `planning/changes/2026-06-23.01-structured-log-payload.md`, `planning/changes/2026-06-24.01-unify-teardown-attach.md`, `planning/changes/2026-06-24.02-otel-excluded-urls-home.md`, and `planning/decisions/`. diff --git a/planning/releases/1.2.1.md b/planning/releases/1.2.1.md deleted file mode 100644 index afdb2c5..0000000 --- a/planning/releases/1.2.1.md +++ /dev/null @@ -1,21 +0,0 @@ -# lite-bootstrap 1.2.1 — lift the FastAPI 0.137 cap - -**1.2.1 is a patch release. No public-API or behavior changes.** It removes the temporary `fastapi<0.137` ceiling introduced in 1.1.1, now that the upstream `prometheus-fastapi-instrumentator` fix has shipped. - -## Dependency constraints - -- **`fastapi<0.137` cap removed** from the `fastapi` extra (the single declaration every `fastapi-*` extra composes from). FastAPI 0.137 and 0.138 now resolve. -- **`prometheus-fastapi-instrumentator` floor raised** on the `fastapi-metrics` extra: `>=6.1` → `>=8.0.1`. The crash that motivated the cap required FastAPI ≥0.137 *and* instrumentator ≤8.0.0, so lifting the FastAPI ceiling is paired with a floor that guarantees the fixed instrumentator wherever metrics are installed. - -The original cap was added in 1.1.1 because `prometheus-fastapi-instrumentator` read `route.path` unconditionally and crashed on FastAPI 0.137's internal `_IncludedRouter` route type. That is fixed upstream in instrumentator [v8.0.1](https://github.com/trallnag/prometheus-fastapi-instrumentator/releases/tag/v8.0.1) (issue [#370](https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/370), closed 2026-06-22). lite-bootstrap's own offline-docs guard (the `isinstance(route, Route)` filter shipped in 1.1.1) remains in place. - -Verified end-to-end against FastAPI 0.138.0 + prometheus-fastapi-instrumentator 8.0.2: full suite green at 100% coverage, including the offline-docs and metrics paths that previously crashed. - -## Backwards compatibility - -Fully backward compatible with 1.2.0. No public API or behavior changed; this is purely a loosening of resolution constraints. Installs that were held at FastAPI ≤0.136 by the cap will now resolve forward to current FastAPI. - -## References - -- Upstream fix: [prometheus-fastapi-instrumentator v8.0.1](https://github.com/trallnag/prometheus-fastapi-instrumentator/releases/tag/v8.0.1), issue [#370](https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/370) -- Original cap: 1.1.1 (PR [#122](https://github.com/modern-python/lite-bootstrap/pull/122)) diff --git a/planning/releases/1.2.2.md b/planning/releases/1.2.2.md deleted file mode 100644 index 6e5034e..0000000 --- a/planning/releases/1.2.2.md +++ /dev/null @@ -1,22 +0,0 @@ -# lite-bootstrap 1.2.2 — bound Litestar Prometheus path cardinality by default - -**1.2.2 is a patch release framed as a bug fix, with one observable behavior change.** It flips the Litestar Prometheus `path`-label default from raw URLs to the route template, closing an unbounded-cardinality footgun that grew process memory without limit on parameterized routes. - -## Bug fix - -- **Litestar `prometheus_group_path` now defaults to `True`** (PR [#144](https://github.com/modern-python/lite-bootstrap/pull/144)). Litestar's own `PrometheusConfig` defaults `group_path=False`, so the `path` metric label recorded the raw request URL. Any route with path parameters then minted one time series per distinct value (`/users/1`, `/users/2`, …), growing the metric registry without bound — visible in production as steadily climbing memory. The new default binds the label to the route template (`/users/{id}`), so cardinality is bounded by the number of routes, not the number of distinct URLs. - - The new `LitestarConfig.prometheus_group_path` field is merged as `{"group_path": , **prometheus_additional_params}`, so `prometheus_additional_params["group_path"]` still overrides it without a keyword collision — the previously-documented workaround keeps working unchanged. FastAPI is unaffected: `prometheus-fastapi-instrumentator` already labels by route template. - -## Behavior change - -- **The Litestar `path` metric label now holds the route template instead of the raw URL.** Dashboards, alerts, or recording rules keyed on raw parameterized paths (`path="/users/1"`) will no longer match; they should key on the template (`path="/users/{id}"`). To restore the old raw-path behavior per application, set `prometheus_group_path=False` (or `prometheus_additional_params={"group_path": False}`). - -## Backwards compatibility - -Fully backward compatible with 1.2.1 at the API level — the new field is additive and defaulted. The only observable difference is the metric-label value described above. Configs already passing `group_path` via `prometheus_additional_params` are unaffected (the dict still wins). - -## References - -- PR [#144](https://github.com/modern-python/lite-bootstrap/pull/144) -- Upstream issue proposing the same default flip / a warning in Litestar: [litestar-org/litestar#4891](https://github.com/litestar-org/litestar/issues/4891) diff --git a/planning/releases/1.2.3.md b/planning/releases/1.2.3.md deleted file mode 100644 index 7fc857c..0000000 --- a/planning/releases/1.2.3.md +++ /dev/null @@ -1,11 +0,0 @@ -# lite-bootstrap 1.2.3 — release pipeline on PyPI Trusted Publishing - -No library changes. The package is identical to 1.2.2; this release exercises the new publish path end-to-end. - -## CI - -- Releases now authenticate to PyPI via **Trusted Publishing (OIDC)** instead of a long-lived `PYPI_TOKEN` secret. `uv publish` auto-detects the GitHub Actions id-token; the release job runs under a `pypi` environment that scopes the trusted publisher (#145). - -## Downstream - -No action required. Nothing about the installed package changes. diff --git a/planning/releases/1.3.0.md b/planning/releases/1.3.0.md deleted file mode 100644 index 124d3b6..0000000 --- a/planning/releases/1.3.0.md +++ /dev/null @@ -1,88 +0,0 @@ -# lite-bootstrap 1.3.0 — free-threaded Python support, orjson becomes opt-in - -**1.3.0 is a minor release. Backward compatible with 1.2.3, with one dependency -change** (`orjson` moves from a mandatory core dependency to an opt-in extra). -It lands free-threaded CPython (3.13t/3.14t) support for core and most extras, -plus two import-safety fixes surfaced while verifying it. - -## Features - -- **Free-threaded CPython (3.13t/3.14t) support.** `lite-bootstrap` is pure - Python; the only thing that ever blocked it on a free-threaded interpreter - was the mandatory `orjson` dependency (below). Core, `logging`, `sentry`, - `fastapi`, and `faststream` (plus their `-sentry`/`-logging`/`-metrics` - combos) now install and run on both 3.13t and 3.14t. `litestar` (+ - `litestar-metrics`) and `fastmcp` (+ `fastmcp-metrics`) land on **3.14t - only** — `msgspec` (litestar) and `cffi` (fastmcp, via cryptography) both - gate free-threaded support to Python 3.14+ and fail to build from source on - 3.13t. The gRPC `otl` exporter (`grpcio` has no ft wheels — use the new - `otl-http` extra instead) and `pyroscope` (`pyroscope-io` is abi3-only and - unmaintained) remain unavailable on free-threaded builds pending upstream - fixes. See - [`architecture/free-threading.md`](../../architecture/free-threading.md) for - the full support matrix and [`planning/deferred.md`](../deferred.md) for the - ecosystem blockers. - -- **OTLP-http exporter**: new `opentelemetry_exporter_protocol` config (`grpc` - default | `http`) and an `otl-http` extra (no `grpcio`) so OTLP trace export - works on free-threaded Python. `otl` now pulls - `opentelemetry-exporter-otlp-proto-grpc` directly (was the `opentelemetry-exporter-otlp` - meta); grpc behavior is unchanged. - -- **`orjson` is now an opt-in extra** (`lite-bootstrap[orjson]`) instead of a - mandatory core dependency — it shipped no free-threaded wheels and its build - refuses to compile under a free-threaded interpreter, which meant nothing, - not even the pure extras, could install on ft before this change. The - logging serializer falls back to the stdlib `json` accelerator when `orjson` - is absent (byte-identical output for JSON-native values; ~2-5x slower; - non-JSON-native types in log `extra`, e.g. datetime/UUID, render via repr - instead of orjson's native encoding). Add `[orjson]` to keep the fast path - on a standard (GIL) build. If your code relied on `import lite_bootstrap` - pulling `orjson` in transitively, depend on `orjson` directly. - -## Bug fixes - -- **`import_checker`'s dotted `find_spec` checks no longer crash on an - incomplete namespace.** `find_spec` imports a dotted name's parent package - first, so a present-but-incomplete `opentelemetry` install (e.g. - `opentelemetry-api` without `opentelemetry-instrumentation`) previously - raised `ModuleNotFoundError` instead of returning `False`, crashing `import - lite_bootstrap`. Affected dotted checks now go through a - `ModuleNotFoundError`-safe helper. -- **The gRPC OTLP exporter is no longer imported unconditionally.** - `opentelemetry_instrument.py` used to import - `opentelemetry.exporter.otlp.proto.grpc.trace_exporter` whenever bare - `opentelemetry-api` resolved, crashing `import lite_bootstrap` in any - environment with `opentelemetry-api` present but the exporter package - absent (e.g. `lite-bootstrap[fastmcp]`, which pulls in bare - `opentelemetry-api` transitively). The exporter import — and its use in - `bootstrap()` — now sit behind their own `is_otlp_grpc_exporter_installed` - guard. When `opentelemetry_endpoint` is set but the selected exporter package - is absent, `bootstrap()` emits an `InstrumentDependencyMissingWarning` naming - the extra to install (`[otl]` for gRPC, `[otl-http]` for HTTP); see - [`architecture/instruments.md`](../../architecture/instruments.md). -- **The OpenTelemetry instrument no longer assumes the SDK is present when only - the API is.** `is_opentelemetry_installed` (`find_spec("opentelemetry")`) is - true with just `opentelemetry-api`, but the instrument imports - `opentelemetry.sdk.*` — a separate distribution. An api-only environment - (e.g. `lite-bootstrap[fastmcp]`) therefore still crashed at the sdk import - even after the exporter guard above. A new `is_opentelemetry_sdk_installed` - flag gates the sdk imports, and `check_dependencies()` requires both the api - and the sdk. With all three fixes, `lite-bootstrap[fastmcp]` imports and runs - on free-threaded 3.14t. - -These bugs are not ft-specific — they affect any environment with a partial -`opentelemetry` stack — but ft verification work is what surfaced them. - -## Backwards compatibility - -Fully backward compatible with 1.2.3 at the API level. The one dependency -change is the `orjson` move to opt-in described above; everything that imports -or configures `lite-bootstrap` continues to work unchanged on a standard (GIL) -build once `orjson` is installed (directly, or via the `[orjson]` extra). - -## References - -- Design bundle: `planning/changes/2026-07-18.01-free-threaded-python-support.md`, - `planning/changes/2026-07-19.01-fix-otel-api-sdk-conflation.md`, - `planning/changes/2026-07-19.02-otlp-http-exporter.md`. diff --git a/planning/releases/1.3.1.md b/planning/releases/1.3.1.md deleted file mode 100644 index 226d417..0000000 --- a/planning/releases/1.3.1.md +++ /dev/null @@ -1,30 +0,0 @@ -# lite-bootstrap 1.3.1 — fix bare-install import (`typing-extensions`) - -**1.3.1 is a patch release. Fully backward compatible with 1.3.0.** - -## Bug fixes - -- **Bare `import lite_bootstrap` no longer crashes on a missing - `typing_extensions`.** Core uses `typing_extensions` at runtime (a `Self` - return annotation, and a `TypedDict` FastAPI response model that pydantic - requires be a `typing_extensions.TypedDict` on Python < 3.12) but never - declared it, so `pip install lite-bootstrap` with no extras followed by - `import lite_bootstrap` raised `ModuleNotFoundError: No module named - 'typing_extensions'`. `typing-extensions` is now declared as core's single - dependency. It is pure Python, so free-threaded support is unchanged, and this - is the leanest possible core. - - This corrects 1.3.0's note, which called core "zero-dependency": the code - genuinely needs `typing_extensions` while Python 3.10/3.11 are supported. The - bug was pre-existing (bare `1.2.3` failed the same way); every install with any - extra masked it, because extras pull `typing_extensions` in transitively. - -## Backwards compatibility - -Fully compatible with 1.3.0. No API or configuration changes; the only -difference is that a bare-core install now resolves `typing-extensions` and -imports cleanly. - -## References - -- `planning/changes/2026-07-19.03-core-zero-dep-typing-extensions.md` diff --git a/planning/releases/1.3.2.md b/planning/releases/1.3.2.md deleted file mode 100644 index b68067f..0000000 --- a/planning/releases/1.3.2.md +++ /dev/null @@ -1,20 +0,0 @@ -# lite-bootstrap 1.3.2 — fix `lite-bootstrap[litestar]` import - -**1.3.2 is a patch release. Fully backward compatible with 1.3.1.** - -## Bug fixes - -- **`import lite_bootstrap` no longer crashes under `lite-bootstrap[litestar]` - without prometheus-client.** `litestar_bootstrapper` imported - `litestar.plugins.prometheus` (which requires `prometheus_client`) under the - `is_litestar_installed` guard, but the `litestar` extra does not install - prometheus-client — only `litestar-metrics` does. So `pip install - lite-bootstrap[litestar]` followed by `import lite_bootstrap` raised litestar's - `MissingDependencyException: prometheus_client`. The import is now guarded by - prometheus-client presence too (its only uses are inside the metrics - instrument, already gated on that package). Found by a new per-extra isolation - install-check in CI. - -## References - -- `planning/changes/2026-07-19.04-extra-isolation-install-check.md` diff --git a/planning/releases/1.4.0.md b/planning/releases/1.4.0.md deleted file mode 100644 index 05961ef..0000000 --- a/planning/releases/1.4.0.md +++ /dev/null @@ -1,124 +0,0 @@ -# lite-bootstrap 1.4.0 — Litestar access logging off by default - -**1.4.0 is a minor release with a behavior change for Litestar services.** A -second bootstrapper sharing an application also now fails loudly instead of -corrupting it — see [Bug fixes](#bug-fixes) below. - -## Behavior change - -**Litestar's `LoggingMiddleware` no longer logs requests and responses by -default.** If your service is on Litestar and you rely on the `HTTP Request` -/ `HTTP Response` access log lines it used to emit, they stop appearing after -this upgrade until you opt back in: - -```python -LitestarConfig( - service_name="microservice", - litestar_logging_middleware_enabled=True, -) -``` - -### Why - -`LitestarLoggingInstrument` registers Litestar's `StructlogPlugin`, and -Litestar's own default `LoggingMiddlewareConfig` logs full request and -response bodies. That meant: - -- Any credential posted to the service — a login form's password, an API - key in a JSON body — landed in stdout verbatim. Litestar only obfuscates - the `Authorization` / `X-API-KEY` headers and the `session` cookie; request - and response bodies are never obfuscated. -- Every offline Swagger asset served by `swagger_offline_docs=True` - (`swagger-ui-bundle.js`, `swagger-ui.css`, up to ~150 KB) was logged as an - ordinary response body on every request. -- Every k8s health probe and every Prometheus scrape produced its own log - line, unconditionally. - -None of this was opt-in — it was a side effect of adding the plugin. Every -other bootstrapper (FastAPI, FastStream, FastMCP, Free) adds no -request/response logging middleware at all, so this brings Litestar in line -with the rest. - -### What the opt-in logs - -With `litestar_logging_middleware_enabled=True`, access logs are metadata -only: - -- Requests: `path`, `method`, `content_type`, `path_params`. -- Responses: `status_code`. - -No `body`, `headers`, `cookies`, or `query` — bodies and headers are where -secrets live, and query strings carry tokens often enough to not be worth the -diagnostic value. Note that `path` and `path_params` are still logged, so a -secret embedded in the URL itself (e.g. `/reset-password/{token}`) is -recorded; keep secrets in the body, never in the path. - -The opt-in also excludes infrastructure routes from access logs: the Swagger -docs path, the offline Swagger static assets (when `swagger_offline_docs` is -on), the health-check path, and the Prometheus metrics path — each matched -whether or not the corresponding instrument is actually active, so a service -that disables health checks but serves its own route at the same path is -still excluded there. - -### Escape hatch - -To take full control — including restoring Litestar's original body-logging -defaults — pass your own `LoggingMiddlewareConfig` via -`litestar_logging_middleware_config`. It replaces the hardened defaults -above wholesale, with no merging: - -```python -from litestar.middleware.logging import LoggingMiddlewareConfig - -LitestarConfig( - service_name="microservice", - litestar_logging_middleware_enabled=True, - litestar_logging_middleware_config=LoggingMiddlewareConfig( - request_log_fields=("path", "method", "content_type"), - ), -) -``` - -Supplying `litestar_logging_middleware_config` while -`litestar_logging_middleware_enabled` is `False` is a no-op that emits a -warning — set the flag to actually turn logging on. - -## Bug fixes - -- **Litestar apps can read request bodies again.** `Litestar.from_config()` — which - `LitestarBootstrapper` uses — passes every `AppConfig` field explicitly, so the 10 MB - `request_max_body_size` default that `Litestar(...)` applies never reached the built - app. Every handler taking a request body returned - `500: 'request_max_body_size' set to 'Empty' on all layers` unless the caller set the - field themselves. The bootstrapper now fills it when the config leaves it unset; a - caller's own value, including an explicit `None` for no limit, is untouched. Reported - upstream as [litestar#4296](https://github.com/litestar-org/litestar/issues/4296). -- **Structlog output follows a redirected stdout.** `_MemoryLoggerFactoryConfig.log_stream` - bound `sys.stdout` once, at import time, so a process that replaced `sys.stdout` after - importing `lite_bootstrap` but before bootstrapping kept logging to the stale stream — - while the root-logger handler installed at bootstrap followed the new one. The stream is - now resolved at bootstrap, so both agree. Affects every bootstrapper. -- **The `litestar` extra's floor moved from `>=2.9` to `>=2.15`.** `AppConfig.request_max_body_size`, - which the fix above now reads, was only added in litestar 2.13; and - `litestar.middleware.ASGIMiddleware`, which the OpenTelemetry middleware already subclassed - before this release, was only added in litestar 2.15. `>=2.9` was never actually supported for - the OTel path — this just makes the declared floor honest. -- **A second bootstrapper sharing an application now fails loudly instead of corrupting it.** - Constructing two bootstrappers (FastAPI, Litestar, FastStream, or FastMCP) against the same - application already warned at construction time, but `bootstrap()` on the second one applied - every instrument again anyway. Litestar died with an unrelated-looking - `ImproperlyConfiguredException: Handler already registered for path '/health' and http method - OPTIONS`; FastAPI did not fail at all — the app's route count silently grew (e.g. from 6 to 8 - for a default config), a shadowed duplicate of the health-check and metrics routes. - `bootstrap()` on the losing bootstrapper now raises `ConfigurationError` naming itself. If your - code relied on the FastAPI case appearing to "work", it will now raise. The ownership marker - behind this is never cleared, including by `teardown()` — once an application has been - bootstrapped, it stays owned for the life of the process; construct a fresh application rather - than reusing one that was already bootstrapped. - -## References - -- `planning/changes/2026-08-10.01-litestar-middleware-logging.md` -- `planning/changes/2026-08-10.02-log-stream-bind-at-bootstrap.md` -- `planning/changes/2026-08-10.03-litestar-request-max-body-size.md` -- `planning/changes/2026-08-10.04-double-bootstrap-guard.md` diff --git a/planning/retros/2026-06-01-audit-implementation-retro.md b/planning/retros/2026-06-01-audit-implementation-retro.md deleted file mode 100644 index 5fa4244..0000000 --- a/planning/retros/2026-06-01-audit-implementation-retro.md +++ /dev/null @@ -1,179 +0,0 @@ -# Retrospective: 15-PR Audit Implementation Arc - -**Date:** 2026-06-01 -**Scope:** PRs #89–103 across two sequenced waves -**Parent docs:** -- Audit: [2026-05-31-bug-refactor-audit.md](../audits/2026-05-31-bug-refactor-audit.md) -- Sequencing 1: [2026-05-31-audit-implementation-sequencing.md](../changes/2026-05-31.01-audit-implementation.md) -- Sequencing 2: [2026-06-01-deferred-refactors-sequencing.md](../changes/2026-06-01.03-deferred-refactors.md) - ---- - -## What shipped - -**Wave 1 (criticals + design issues, PRs #89–95):** -- 3 critical bugs (redoc `root_path`, OTel tracer shutdown, idempotent teardown) -- 5 design issues (skip_sentry leak, dead conjuncts, config method semantics, OTel mixin, generic BaseInstrument) -- 9 paired regression tests - -**Wave 2 (deferred refactors, PRs #96–103):** -- 7 refactor opportunities (OTel hoist, logging split, base layer, frozen cascade, FastStream timeout, naming pass) -- 3 test-gap fills (standalone instrument tests, `is_valid_path` negatives, logging lifecycle replay) -- 9 LOW-priority cleanups (Sentry idiom/typing, OTel newline + `id()` comment, docstrings, etc.) -- 1 bonus capitalization rename (`Opentelemetry` → `OpenTelemetry`) - -**Test suite:** 79 → 129 tests (+50, ~63% growth). 100% coverage preserved. - -**Process artifacts:** 1 audit doc + 2 sequencing specs + 15 implementation plans + 1 retro doc = ~18 markdown docs totaling ~5,000 lines, against ~1,500 lines of net code change. - ---- - -## What went well - -**1. Two-stage subagent review caught issues in 5 of 15 PRs.** - -The "spec compliance review → code quality review" gate caught real problems before push: -- **PR2:** Shutdown-exception leaving stale `_tracer_provider` (flagged as PR3 follow-up). -- **PR5:** Missing pinning test for `from_dict({"x": None})` overrides defaults — added to amended commit. -- **PR10:** `# noqa: PLR2004` slipped in; user intervention → extract `expected_max_age = 600` named local. Pattern then propagated to PR11, PR14. -- **PR11:** Dead `import logging.handlers` after file split — caught by quality reviewer, amended. -- **PR13:** `FastAPIConfig(application=None)` regression from sentinel-identity check — fixed via guard (later dropped as unreachable by user). - -None of these would have surfaced from `just test`/`just lint` alone. - -**2. Plan → red test → fix → green test discipline.** - -For every fix-class PR (criticals + DES-4), the plan called for a regression test that failed first, passed after. The implementer reported the actual failure mode in every case. This proved the test was actually covering the bug, not asserting incidentals. - -**3. Model selection paid off.** - -`haiku` for mechanical PRs (PR8 micro-fixes, PR10 test additions, PR12 base-layer cleanup, PR14 config field, PR15 renames). `sonnet` for refactors with real judgment (PR7 generic, PR11 file split, PR13 cascade). No PR needed `opus`. Cost-aware without quality compromise. - -**4. Pyright vs ty pattern was stable and predictable.** - -Once established (conditional imports + framework subclass invariant complaints), every PR's Pyright noise was the same shape. We dismissed it confidently 15 times in a row, and `ty check` never disagreed. - -**5. Real-time spec corrections.** - -Three times we caught spec-vs-reality drift and updated docs retroactively: -- PR13: sequencing spec assumed 2-class scope; actual was 24 (Python frozen cascade). Plan + spec updated. -- PR13: spec called for `typing.cast` sentinel; user contributed a proper `UnsetType` class. Plan + spec updated. -- PR7: deviation from sequencing's "drop redundant annotations" decision — documented in commit and PR body rather than silently diverging. - -**6. The user-as-reviewer loop.** - -Each PR-level review caught things only domain knowledge can catch — the frozen cascade hazard, the `UnsetType` improvement, the no-`PLR2004`-noqa policy, the unreachable `None` guard. The subagents executed; the user steered. - ---- - -## What didn't go well - -**1. Plan accuracy on cross-cutting changes was uneven.** - -- PR13 plan: said "2 classes lose `frozen`"; reality required 24 because of Python's frozen-inheritance rule. The implementer hit `TypeError` on the partial cascade and had to recover. Should have been caught at plan time by writing one example child class and checking the rule. -- PR15 plan: said "11 files"; actual was 10. Off-by-one because I miscounted while writing. Reviewer didn't flag. -- PR7: deviation from sequencing spec was legitimate but required mid-execution decision. The sequencing spec's locked decision wasn't fully thought through. - -**2. Several PRs required amend cycles for issues the plan should have prevented.** - -PR5 (missing pin), PR10 (PLR2004 noqa), PR11 (dead `logging.handlers` import), PR13 (None guard). Five amend cycles across 15 PRs is high; each one was caught by reviewer rather than implementer. - -**3. Doc accumulation outpaced code change.** - -~5,000 lines of plans/specs/audit for ~1,500 lines of code. For trivial PRs (PR8: 2-line edit) this is comically inverted — PR8's plan was 222 lines. The methodology had no fast lane for small changes. - -**4. Pyright noise was constant ambient cost.** - -15 PRs × ~20 diagnostic lines per session = ~300 false-positive lines I had to evaluate-and-dismiss. Cumulative cognitive load was real. Never addressed at the system level. - -**5. The bonus Otel rename grew without being formally added to the audit.** - -Surfaced as a code-review comment in PR6. Re-mentioned in PR12 review. Landed in PR15 as the "bonus" item. Never recorded as an audit finding — the audit doc remains incomplete relative to what shipped. - -**6. Spec/plan/code update cadence wasn't consistent.** - -When PR13 deviated, we updated the spec retroactively. When PR15's file count was wrong, we noted it in the PR body but didn't fix the plan. When PR7 deviated, we documented in commit but not in the sequencing spec. Inconsistent hygiene. - ---- - -## Key insights - -**1. The 4-phase methodology has high ceiling but mediocre floor.** - -`brainstorm → spec → plan → subagent execute + review` produced excellent results on PRs that warranted it (PR7, PR11, PR13). For trivial PRs (PR8, PR12, PR14), the same methodology consumed disproportionate planning effort for marginal benefit. The plan WAS the diff for several of these. - -**2. TDD + two-stage review beats either alone.** - -TDD caught implementation errors. Two-stage review caught spec-vs-implementation drift and code-smell issues. Together they caught 5 distinct bug categories. Neither alone would have caught all five. - -**3. Subagent dispatch costs real attention, not just tokens.** - -Each PR involved 3 subagent dispatches (implement, spec review, quality review) plus 1 user check. That's 4 review surface points per PR × 15 PRs = 60 review events. Worth it in aggregate, but the per-PR overhead is non-trivial. - -**4. Reviewers caught what the planner missed.** - -Plans aren't self-checking. The reviewers' "verify by reading the actual code" instruction caught implementer-report discrepancies and plan-vs-reality gaps in multiple PRs. Trust-but-verify is not a slogan — it's the only thing that catches lying-to-yourself errors. - -**5. User domain knowledge is irreplaceable.** - -Every plan deviation that improved on the plan (frozen cascade, UnsetType, no-noqa policy, unreachable-guard cleanup) came from the user. The subagents executed loyally; the user pushed back when loyalty diverged from quality. - -**6. Pyright vs ty as a real divergence point.** - -The project enforces `ty`. Pyright is unmaintained noise from the IDE. We dismissed Pyright reliably, but the cost was real, and a future contributor would face the same wall. This is technical debt the audit didn't catch. - ---- - -## Action items for next effort - -| # | Action | Cost | Priority | -|---|--------|------|----------| -| 1 | Add a "lightweight plan" template for sub-30-LOC PRs. Skip the multi-task structure; one diff + verification step suffices. | Low | Medium | -| 2 | Pre-flight grep verification in every cross-file plan. PR13's cascade hazard would have surfaced at plan time. | Low | High | -| 3 | Resolve Pyright vs ty divergence. Either suppress noise patterns in `pyproject.toml`, document why Pyright is intentionally not enforced, or add Pyright to CI with a strict ignore list. | Medium | Medium | -| 4 | Update audit / sequencing specs in real time as discoveries occur. PR13's cascade and PR15's file count are precedents — going forward, treat spec corrections as part of the PR, not a future cleanup. | Low | High | -| 5 | Record the `# noqa: PLR2004` policy and other emergent conventions in `CLAUDE.md`. PLR2004 policy emerged in PR10; future implementer subagents shouldn't have to be told. | Low | Medium | -| 6 | The "bonus" Otel rename should be backfilled into the audit doc as a tracked finding (LOW-10 or similar). Future readers should see the full set of items the codebase addressed. | Low | Low | -| 7 | Consider whether the two-stage review is needed for every PR. Mechanical fixes (PR8, PR12) likely don't need code quality review beyond spec compliance. Trim where the marginal value is low. | Low | Low | - ---- - -## Closing assessment - -The arc shipped what it set out to ship. 3 critical bugs closed, 5 design issues addressed, 7 refactors landed, 50 new tests added, every LOW item cleaned up. No production regressions. The methodology worked. - -The methodology was also heavier than the work in places. The next time this team takes on an audit, the lightweight-plan template (action #1) and pre-flight grep (action #2) would meaningfully reduce overhead without giving up the review gates that caught real bugs. - -The most underrated factor: the user remained in the loop as a quality reviewer. Without the cascade catch, the UnsetType contribution, the noqa policy, and the unreachable-guard cleanup, the codebase would have shipped a lower-quality version of these 15 PRs. The subagent loop produces consistent execution but does not produce judgment. - ---- - -## Addendum (2026-06-02): PR #107 instrument skip rework - -A second large refactor shipped after the original arc closed: PR #107, replacing `InstrumentNotReadyWarning` with `is_configured()` classmethod + structured `skipped_instruments` + summary log. Same methodology (brainstorm → spec → plan → subagent execution). Surfaced three new datapoints worth recording. - -### What worked - -- **Mid-design pivot to the right pattern.** During brainstorming the user pushed back on `is_configured` taking `bootstrap_config` as an arg ("why does it need it if config is on self?"). That question forced the design conversation through pre-#88 history (instance method + instantiation first) and led to confirming the classmethod-with-arg design was correct. Without the pushback I would have proposed the design without explaining the cascade of constraints. -- **The lightweight template + combined-review pattern (action items #1 and #7 from the original retro) was validated again.** PR16 was the first test; PR #107's combined review structure followed the same pattern even though it didn't end up running formally (the subagent disconnect made the formal review unnecessary — I verified inline). -- **Real-time spec correction (action #4) was honored.** During execution the design pivoted from `_get_logger()` to stdlib `logging` + public `build_summary()` method. A new spec doc (`2026-06-02-stdlib-logging-and-build-summary-design.md`) was written for the pivot rather than letting the doc drift from reality. - -### What didn't work - -- **Long-running subagent dispatches are fragile.** The implementer dispatch ran ~60 minutes (94 tool uses) before the socket dropped. Work was orphaned mid-flow — the production code edits were done but verification, commit, and docs (Task 9) were not. Recovery worked, but a smaller scope per dispatch would have lost less work. -- **`_get_logger()` was a defensive workaround, not a design.** I introduced it to fix structlog's `cache_logger_on_first_use=True` caching interaction with `capture_logs()` at test time. It made the tests pass but produced an ugly API. The user's subsequent pivot — switch the bootstrapper to stdlib `logging` and expose `build_summary()` as a public method — was the actual right answer. `caplog` (pytest's stdlib-logging capture fixture) was the right test mechanism, which the original plan flagged but the subagent ignored in favor of `capture_logs()`. The lesson: when a fix feels like fighting the framework, the framework choice is probably wrong. -- **LSP violations on framework instrument override parameter types are an emergent pattern.** Three `# ty: ignore[invalid-method-override]` were needed for `FastStreamOpenTelemetryInstrument.is_configured`, `FastStreamPrometheusInstrument.is_configured`, and `LitestarSwaggerInstrument.is_configured` because they narrow `bootstrap_config` to framework-specific types. The pattern was acceptable for `bootstrap_config:` field overrides (covariant) but ty enforces strict invariance on method parameters. Worth noting in CLAUDE.md if more `classmethod` overrides arise. - -### Key insight - -**The subagent does mechanical migration; design quality comes from human review iteration.** PR #107 needed 5 user follow-up commits after my work to reach the shipped design (`fa135d2`, `41d83bb`, `c14c455`, `4f051d6`, `86b43ef`). Each addressed a quality concern: silent-skip contract test, build_summary docstring tightening, empty-section handling, faststream warning leak fix, `isEnabledFor` guard on the summary log. None of these were in the original plan; all came from review iteration after the mechanical work landed. - -This matches the original retro's closing observation. Worth restating concretely: the subagent loop reliably produces a green-tests implementation of the spec, but the spec is rarely the right design. The design emerges during review. - -### New action items - -| # | Action | Cost | Priority | -|---|--------|------|----------| -| 8 | When a fix requires a defensive workaround in production code to make tests pass, step back and ask whether the test mechanism (or the framework choice) is wrong. `_get_logger()` is the case study. | Low | High | -| 9 | Cap single-dispatch subagent scope. The 94-tool-use, ~60-minute dispatch for PR #107 was too long. Either split into checkpointed sub-dispatches or set an explicit "implement only Tasks N–M, stop and report" boundary so progress doesn't get orphaned if the connection drops. | Low | High | -| 10 | Document the LSP-violation pattern for classmethod overrides in CLAUDE.md alongside the existing covariant-narrowing note. `# ty: ignore[invalid-method-override]` is now established convention. | Low | Low | diff --git a/planning/retros/2026-06-05-bug-audit-v2-retro.md b/planning/retros/2026-06-05-bug-audit-v2-retro.md deleted file mode 100644 index 4bba8ed..0000000 --- a/planning/retros/2026-06-05-bug-audit-v2-retro.md +++ /dev/null @@ -1,108 +0,0 @@ -# Bug Audit v2 — Retrospective - -**Date:** 2026-06-05 -**Cycle:** Audit → 3-PR sequencing → execute -**Inputs:** [bug-audit-v2.md](../audits/2026-06-05-bug-audit-v2.md), [sequencing](../changes/2026-06-05.01-bug-audit-v2.md), [PR1 plan](../changes/2026-06-05.01-bug-audit-v2/plan-pr1-lifecycle.md), [PR2 plan](../changes/2026-06-05.01-bug-audit-v2/plan-pr2-config-security.md), [PR3 plan](../changes/2026-06-05.01-bug-audit-v2/plan-pr3-hygiene-ci.md) -**Outcome:** 26/26 audit findings shipped to main across three PRs (#108, #109, #110). - -## Numbers - -| | PR1 (lifecycle) | PR2 (config/sec) | PR3 (hygiene/CI) | -|---|---|---|---| -| Findings closed | 10 | 6 | 4 | -| Commits | 11 + 3 docs | 8 + 4 docs | 5 + 4 docs | -| Files changed | 19 | 12 | 6 | -| Lines added | 2439 | 1274 | 478 | -| Lines deleted | 28 | 13 | 0 | -| Open → merge time | ~6 min | ~12 min | ~12 min | - -27 task-level commits across the cycle. Test count grew from 153 (pre-audit baseline) → 187 (post-PR3); coverage stayed at 100% throughout. `pip-audit` clean on 133 packages. - -## What went well - -**Per-PR sequencing was the right granularity.** Three PRs grouped by reviewer mental model (lifecycle internals → config/API surface → chore) kept each diff under ~1500 lines and each PR's reviewability under 20 files. The sequencing doc explicitly traded ship velocity for reviewability, and it worked — both PR1 and PR2 merged within minutes of opening, suggesting the bundles matched what a reviewer could hold in their head. - -**Spec-vs-quality reviewer separation caught structurally different issues.** Spec reviewers verified "did you implement what the plan said." Quality reviewers found bugs that the spec didn't anticipate. Three real defects surfaced this way: - -- PR1 Task 5 (LOG-3) — quality reviewer pointed out that `raise close_errors[0]` lost info on multi-handler failures and was silently masked by the `finally`-block's factory close. Switched to `TeardownError(errors)` aggregation. -- PR2 Task 5 (SEC-2) — quality reviewer reproduced that `urlparse("localhost:4317")` returns `hostname=None` (treats `localhost` as scheme), so the canonical OTLP gRPC endpoint would have falsely triggered the new warning. Fixed by prepending `//` to schemeless inputs. -- PR2 Task 8 (LOG-6) — quality reviewer caught that `WeakKeyDictionary.get()` *also* raises `TypeError` on non-weakrefable keys, defeating the suppress wrapping only `__setitem__`. Both sites now wrapped. - -Each of these would have been a real bug in production. None were in the original plan. The two-stage review pattern is doing real work. - -**Audit → sequencing → per-PR-plan → execute was a clean handoff chain.** Brainstorming gave the spec; sequencing gave the PR breakdown; per-PR plans gave the TDD steps; subagent-driven-development executed. Each document had one job, and downstream documents could quote upstream documents by section. The repeated "verify-then-extend" loop (each plan referenced the audit's finding IDs and quoted fix shapes) kept the work tightly anchored to the original analysis. - -**100% coverage gate caught the TEST-NEW-7 pytest-cov ordering bug at implementation time.** Putting `filterwarnings = ["error::lite_bootstrap.exceptions.InstrumentSkippedWarning"]` in `pyproject.toml` triggered pytest to import `lite_bootstrap.exceptions` during `pytest_load_initial_conftests` — before `pytest-cov` installed its `sys.settrace`. Coverage dropped from 100% → 78.78%, the `--cov-fail-under=100` gate failed, and the implementer pivoted to a `pytest_configure()` hook in `tests/conftest.py`. The coverage gate caught a Python-import-order subtlety that would have been invisible without it. - -## What didn't go well - -**Three separate instances of "implementer reports Done with new SHA, but commit was never made."** Same pattern each time: - -1. Agent edits files in working tree. -2. Agent runs `just test` (succeeds against working tree). -3. Agent reports "Done, new commit SHA: ``" — invents a plausible SHA without ever running `git commit --amend`. -4. The next review or operation catches the discrepancy because actual HEAD differs from reported SHA. - -Specific incidents: PR1 Task 10 (LOG-8 sentinel fix), PR2 Task 5 (SEC-2 host parsing fix), PR2 Task 6/cascade fix. In each case I had to read `git show ` against the working tree to verify, then either `git commit --amend` the changes myself or dispatch another agent specifically to commit. - -The mitigation that emerged organically: add a "CRITICAL — verify commit actually contains the change" section to subsequent implementer prompts with explicit `git show HEAD -- | head -50` verification. After this addition the hallucinations stopped (PR3 Tasks 1-4 all committed cleanly). But the lesson should be baked into the subagent-driven-development implementer template so future plans don't have to learn it again. - -**Cross-task structural interactions weren't caught at plan-writing time.** PR2 Task 6 (SEC-3 CORS validation) added `CorsConfig.__post_init__` raising `ConfigurationError`. The plan correctly identified that PR2 Task 5 (SEC-2) added `OpenTelemetryConfig.__post_init__` and that `FastAPIConfig.__post_init__` would need `super(FastAPIConfig, self).__post_init__()` to cascade. **What the plan missed:** every `__post_init__` *between* `FastAPIConfig` and `OpenTelemetryConfig` in MRO also needs to call `super().__post_init__()`. After Task 6 landed, `CorsConfig.__post_init__` (no super() call) blocked the cascade for FastAPIConfig users — the SEC-2 warning never fired. Caught by the FastAPIConfig cascade test failing after Task 6. - -The fix was a fourth commit in PR2 (`b8cd364` — cascade fix) that: -1. Added a no-op `__post_init__` on `BaseConfig` as the chain terminator. -2. Made every config-class `__post_init__` call `super().__post_init__()` at the end. - -This is a cross-cutting structural invariant that should have been called out in the plan, not discovered mid-execution. The cost was one extra commit in PR2; the bigger cost was the time spent debugging "why doesn't the FastAPIConfig cascade test pass" when the plan reviewer would have caught it in 5 minutes. - -**The "set_tracer_provider is set-once" OTel SDK constraint was misread at audit time.** The audit's LOG-1 recommended fix shape was: "After `shutdown()`, call `set_tracer_provider(NoOpTracerProvider())` to reset the global." Implementation discovered (by reading OTel SDK source) that `set_tracer_provider` is enforced as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` — the second call is logged-and-ignored, not applied. LOG-1 was downgraded to a docstring-only change in PR1. - -The pattern: the audit asserted an API behavior without verifying it against the SDK source. A 5-minute look at `opentelemetry/trace/__init__.py:548-556` during the audit would have flagged this. Not a huge cost (downgrade to docstring is a smaller commit anyway), but a reminder that audit findings shouldn't include "fix shape" claims about external library behavior without verification. - -**PR2 SEC-2 host parser shipped with a critical-but-untested input shape.** The plan's host-parser test cases used only `http://collector.example.com:4317`-style URLs (with explicit scheme). The canonical OTLP gRPC default is `localhost:4317` — no scheme. `urlparse("localhost:4317")` returns `hostname=None` because it parses `localhost` as the scheme. The first landing of SEC-2 would have falsely warned on every default OTLP setup. - -Caught by the quality reviewer who manually walked through input variations during review. The fix added parametrized regression tests covering 5 local forms + 3 remote forms. But the parametrized test should have been in the *original* plan — covering both scheme-prefixed and bare `host:port` forms is the obvious shape for an "endpoint parser" test. - -## Lessons - -1. **Implementer agents will report invented commit SHAs.** Subagent-driven-development's implementer template needs an explicit verification step: - ``` - After committing, run `git log -1 --format="%H %s"` and `git show HEAD --stat`. - The SHA in your report must match the actual HEAD SHA, and the diff stat - must include the files you intended to change. - ``` - This needs to be in the template, not added ad-hoc per plan. - -2. **Multiple `__post_init__` on a multiple-inheritance chain need a documented cascade invariant.** Now that the project uses dataclass MRO for config composition AND every config class is potentially a place to add validation, the "every `__post_init__` calls `super().__post_init__()`" pattern needs to be in CLAUDE.md so the next contributor doesn't trip over it. - -3. **External-API claims in audits need source verification.** The OTel set-once issue was discoverable in 5 minutes by reading the SDK. For high-leverage external-library claims (especially "the API supports X" or "calling Y has Z effect"), an audit should cite the source file:line, not just the documented behavior. - -4. **Parser tests need to span input formats, not just one canonical form.** When the new code introduces a parser/validator over user-controllable input, the test should parametrize across realistic format variations, not just the case that came to mind first. - -5. **Reviewer-driven course corrections are working — keep them.** Three substantive bugs (LOG-3 info loss, SEC-2 localhost parsing, LOG-6 weakref get) were caught at review time, not in production. The two-stage spec/quality review pattern paid for itself. Don't optimize it away even when the per-task overhead feels high — the time spent reviewing is cheap compared to the time spent debugging a bug that shipped. - -6. **CLAUDE.md is reaching a size where contributors won't read it end-to-end.** The "Conventions" section has six bullets now. After PR3 it'll have seven (UX-5 added the from_object/from_dict asymmetry). Three new structural invariants from this audit cycle aren't documented there: - - The `__post_init__` super() cascade invariant (mentioned in lessons above). - - The "one `OpenTelemetryInstrument` per process" lifecycle (currently only in the class docstring; final PR1 reviewer flagged it as worth surfacing). - - The `_lite_bootstrap_*` private-attribute prefix convention for sentinels on user-supplied app instances (used by FastAPI lifespan re-wrap guard, possibly future framework integrations). - - At some point this becomes a `CONTRIBUTING.md` or an `ARCHITECTURE.md`. Worth deciding sooner rather than later. - -## Action items - -| # | Action | Where | -|---|---|---| -| 1 | Add commit-verification step to subagent-driven-development implementer template | `~/.claude/plugins/cache/claude-plugins-official/superpowers/.../skills/subagent-driven-development/implementer-prompt.md` (or fork locally if upstream changes are slow) | -| 2 | Document `__post_init__` cascade invariant in CLAUDE.md | `CLAUDE.md` § Conventions | -| 3 | Document "one OTel instrument per process" lifecycle in CLAUDE.md | `CLAUDE.md` § Key design decisions | -| 4 | Document `_lite_bootstrap_*` prefix convention for app-instance sentinels | `CLAUDE.md` § Conventions | -| 5 | Decide whether to split CLAUDE.md into CONTRIBUTING.md + ARCHITECTURE.md | Project-level decision | -| 6 | Add an "audit checklist" to brainstorming output: include "verify external API claims against SDK source" | Future audits | - -Actions 2-4 could be a small follow-up PR (`docs: document audit-derived conventions in CLAUDE.md`). Action 5 is a judgment call worth one focused brainstorming session. Action 1 may require forking the superpowers skill locally; action 6 is a personal-process change. - -## What this cycle proved - -The audit → sequencing → per-PR-plan → subagent-driven-execution pipeline works for a 26-finding audit landed across three sequenced PRs in roughly half a day of wall time, with each PR merging cleanly and the test suite growing from 153 → 187 at 100% coverage throughout. The reviewer-driven course corrections (three substantive bugs caught at review time) demonstrate the pipeline's quality gates are actually finding bugs, not just rubber-stamping output. The implementer hallucinations are a real failure mode but a containable one once explicit verification is in the prompt template. - -The remaining work — the action items above — are about hardening the pipeline so the next audit doesn't have to re-learn this cycle's lessons. diff --git a/planning/retros/2026-06-09-docs-and-ci-modern-di-mirror-retro.md b/planning/retros/2026-06-09-docs-and-ci-modern-di-mirror-retro.md deleted file mode 100644 index af07ad3..0000000 --- a/planning/retros/2026-06-09-docs-and-ci-modern-di-mirror-retro.md +++ /dev/null @@ -1,52 +0,0 @@ -# Retrospective: docs + CI mirror of modern-di (PRs #112–#115) - -Date: 2026-06-09 -Scope: One-session sequence of four PRs that brought lite-bootstrap's docs publishing and CI shape into alignment with the sibling [`modern-di`](https://github.com/modern-python/modern-di) project. - -## What shipped - -| PR | Title | Size | -|---|---|---| -| [#112](https://github.com/modern-python/lite-bootstrap/pull/112) | docs: migrate from Read the Docs to GitHub Actions + Pages | 9 commits, ~200 lines (+ spec + plan) | -| [#113](https://github.com/modern-python/lite-bootstrap/pull/113) | ci: bump action pins to match modern-di | 9 lines | -| [#114](https://github.com/modern-python/lite-bootstrap/pull/114) | ci: drop codecov upload step | 7 lines | -| [#115](https://github.com/modern-python/lite-bootstrap/pull/115) | ci: split ci.yml into reusable `_checks.yml` | 36 → 14 + new 39 | - -End state: `ci.yml`, `_checks.yml`, `docs.yml`, the `docs-deploy` Justfile recipe, `mkdocs.yml site_url`, and `docs/CNAME` are all byte-identical to modern-di's equivalents. `lite-bootstrap.modern-python.org` is live with TLS. - -## What went well - -- **"Mirror modern-di" as a scope anchor.** Every micro-decision (action versions, paths filter, concurrency group, recipe text) collapsed from "design choice" to "match or deviate." Verbatim copies + a literal `diff` against the reference were the strongest possible correctness signal — caught divergence at the byte level, not the behaviour level. -- **Out-of-scope sections paid off across PRs.** PR #112's spec deliberately listed 3 deferred items (action bumps, codecov decision, structural split). Each became a tight, focused follow-up PR with its scope and rationale already half-written. The user didn't have to re-explain what they wanted. -- **The brainstorming → spec → plan → execute pipeline collapsed user decision points.** User approved once at brainstorm ("yes, that domain"), once at spec ("write the plan"), once at execution mode ("subagent-driven"). No back-and-forth mid-implementation; the gates caught misunderstandings before code was touched. -- **Operator follow-up checklist in the PR body was load-bearing.** Most of the post-merge "operator actions" listed in PR #112 (DNS, Pages enable, HTTPS cert) turned out to already be done or auto-triggered by the merge — we only discovered this because the checklist forced us to verify each item explicitly. Without the checklist we'd have asked the maintainer to do work that didn't need doing. -- **Spec gap caught by the grep step.** Task 6's "find any remaining `readthedocs.io` references outside `planning/`" caught 6 README links the spec had missed. Cost: one extra commit. Saved: a broken-link PR landing in main. -- **Final-branch code review surfaced zero issues.** Not because the reviewer was lenient — because the byte-identical-to-modern-di constraint left almost no room for error. This is a good signal that the scope anchor worked. - -## What didn't go well - -- **Subagent-driven workflow was over-engineered for trivial config edits.** The skill prescribes implementer + spec reviewer + code reviewer per task. For 4-line verbatim copies, that's 3× the overhead with no quality signal. I caught this after Task 1 and switched to "implementer subagent + direct controller verification + single final review across the branch." Should have noticed earlier and stated the deviation up front instead of mid-flight. -- **Heredoc backtick escaping bug, twice.** With `<<'EOF'` (quoted delimiter), backticks are preserved literally — no escape needed. I escaped them anyway in the plan's gh-pr-create heredoc (caught during plan self-review) AND in the live PR #112 body (had to `gh pr edit` after creation). Same mistake within the same session is the smell of an unincorporated lesson. -- **Spec missed the README.** "Migrate the docs URL" should have triggered an automatic "grep the whole repo for the old URL" check at the design stage, not as a Task 6 side effect. The plan's grep step caught it, but only because the grep happened to include `--include='*.md'`. If the migration had touched a non-Markdown file (e.g., a config YAML referencing the old domain), the spec's narrow file list would have missed it. -- **Confusing implementer report on Task 6.** The Task 6 implementer reported `exit=1, no matches` from the grep AND listed 6 README references they "found before reverting edits." Two different searches conflated in one report. Cost: 30 seconds of confusion verifying directly. Tighter implementer prompt could have asked for the grep output verbatim and clearer "what I changed vs. what I considered changing." -- **Spec + plan committed to local `main` instead of the feature branch.** Working out fine because main was only ahead of origin/main; the commits came along on the feature branch and rode the PR cleanly. But the right pattern is: branch first, then commit spec/plan on the branch. Mid-session I would not have been able to abandon the work without git surgery. -- **Auto-mode classifier false-positive on `gh repo view --json homepageUrl`.** A read-only query (`--json` extracting a field) was flagged as a "homepage change" by keyword match. Trivial cost, but worth noting that the classifier can over-block on harmless reads. - -## What I'd do differently next time - -1. **For URL migrations**, treat "grep entire repo for old URL" as a pre-implementation design step. Add a `grep -rn .` to the spec's "current state" section. Then every reference is in the changeset from day one. -2. **Calibrate subagent overhead up front.** When tasks are mechanical verbatim edits, dispatch implementer-only with the controller verifying directly; reserve per-task spec/quality reviewers for tasks with non-trivial design choices. State this calibration in the controller's first message of the execution skill, not after the first task. -3. **Internalize heredoc rules.** With `<<'EOF'`, no escapes. With `<` for libraries (clickable anchor, matches `pip install`). Templates have no package → keep a descriptive ATX `#` H1. | -| **Standard badges** | PyPI version · Supported Python versions · Downloads (pypistats) · Coverage · CI · License · GitHub stars. | -| **Downloads source** | Standardized on **pypistats** (`img.shields.io/pypi/dm`) everywhere, including `that-depends` (currently pepy). | -| **CI badge** | `ci.yml` is the universal workflow filename across all repos. | -| **Coverage badge** | Static `coverage-100%` shields badge for repos enforcing `cov-fail-under=100` (all 14 libs except `that-depends`, plus both templates). `that-depends` (no guard) keeps its live **codecov** badge. | -| **Astral toolchain badges** | uv + Ruff + ty trio on **all repos except `that-depends`** (libraries + templates). | -| **Context7 badge** | Static link badge on every repo, pointing to its own `context7.com/modern-python/` page. Skipped per-repo if that page is not indexed. | -| **`that-depends` (showcase)** | The **only** repo with type-checker (mypy-strict, pyrefly), `llms.txt`, and `libs.tech` badges. Keeps codecov (live). No uv/ruff/ty trio. Add missing core badges (PyPI version, CI, License) + Context7. | -| **`modern-di` (exception)** | Keeps its existing **sub-package badge table** as the primary badge presentation (the matrix of `modern-di` + integration packages). Standardized footer + Context7/toolchain badges still applied; the table is not flattened into a plain row. | -| **Templates** | Not on PyPI → no version/pyversions/downloads/PyPI-link badges. Get: static 100% coverage, CI, License, GitHub stars, Context7 (if indexed), uv/ruff/ty. **Drop** current GitHub issues/forks badges. | -| **Delivery** | One PR per repo (17 total), branch `docs/uniform-readme`. | - -## Canonical templates - -### Library header (14 standard libs, all except `that-depends`) - -```markdown -# - -[![PyPI version](https://img.shields.io/pypi/v/.svg)](https://pypi.org/project//) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/.svg)](https://pypi.org/project//) -[![Downloads](https://img.shields.io/pypi/dm/.svg)](https://pypistats.org/packages/) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![CI](https://github.com/modern-python//actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/.svg)](https://github.com/modern-python//blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/)](https://github.com/modern-python//stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -`` = PyPI name, `` = GitHub repo slug. They differ only for `autosemver` (repo) → `semvertag` (pkg). - -### `that-depends` header (showcase) - -Retains its existing rich badge set, normalized order, with these changes: -- Add: PyPI version, CI (`ci.yml`), License, Context7 (`context7.com/modern-python/that-depends`). -- Keep: codecov (live), mypy-strict, pyrefly, Python versions, downloads (switch pepy → pypistats), GitHub stars, `libs.tech`, `llms.txt`. -- Do **not** add the uv/ruff/ty trio and do **not** add the static 100% badge. - -### `modern-di` header (table exception) - -Keep the existing per-package badge **table** (rows: `common`, `modern-di`, `modern-di-fastapi`, `modern-di-faststream`, `modern-di-litestar`, `modern-di-pytest`, `modern-di-typer`). Add the Context7 + uv/ruff/ty badges as a flat row beneath the title (above the table), apply the standardized footer. Do not flatten the table. - -### Template header (2 templates, not on PyPI) - -```markdown -# - - - -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![CI](https://github.com/modern-python//actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python//actions/workflows/ci.yml) -[![License](https://img.shields.io/github/license/modern-python/.svg)](https://github.com/modern-python//blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/modern-python/)](https://github.com/modern-python//stargazers) -[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -``` - -Drop the existing `GitHub issues` and `GitHub forks` badges. Note: template READMEs are currently named `readme.md` (lowercase) — preserve the existing filename casing per repo. - -### Footer (all repos) - -```markdown -## 📚 [Documentation](https://.modern-python.org) -## 📦 [PyPI](https://pypi.org/project/) -## 📝 [License](LICENSE) - -## Part of `modern-python` - -Browse the full list of templates and libraries in -[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index. -``` - -- `📚 Documentation` line: included only where a live `.modern-python.org` site exists. -- `📦 PyPI` line: libraries only; omitted for templates. - -## Per-repo resolution table - -Resolved during implementation; `?` = verify per-repo (Context7 indexing, docs site). - -| repo | pkg | 100% guard | coverage badge | docs site | astral trio | context7 | -|------|-----|:--:|--------|:--:|:--:|:--:| -| autosemver | **semvertag** | ✔ | static 100% | ✔ (docs.yml) | ✔ | ? | -| db-retry | db-retry | ✔ | static 100% | ? | ✔ | ? | -| eof-fixer | eof-fixer | ✔ | static 100% | ? | ✔ | ? | -| faststream-concurrent-aiokafka | (same) | ✔ | static 100% | ? | ✔ | ? | -| faststream-outbox | (same) | ✔ | static 100% | ✔ (docs.yml) | ✔ | ? | -| faststream-redis-timers | (same) | ✔ | static 100% | ✔ (docs.yml) | ✔ | ? | -| httpware | httpware | ✔ | static 100% | ✔ (docs.yml) | ✔ | ? | -| lite-bootstrap | lite-bootstrap | ✔ | static 100% | ✔ | ✔ | ✔ | -| modern-di | modern-di | ✔ | (table) | ✔ (docs.yml) | ✔ | ? | -| modern-di-fastapi | (same) | ✔ | static 100% | ? | ✔ | ? | -| modern-di-faststream | (same) | ✔ | static 100% | ? | ✔ | ? | -| modern-di-litestar | (same) | ✔ | static 100% | ? | ✔ | ? | -| modern-di-pytest | (same) | ✔ | static 100% | ? | ✔ | ? | -| modern-di-typer | (same) | ✔ | static 100% | ? | ✔ | ? | -| that-depends | that-depends | ✘ | **codecov (live)** | ✔ | ✘ | ✔ | -| fastapi-sqlalchemy-template | — | ✔ | static 100% | ? | ✔ | ? | -| litestar-sqlalchemy-template | — | ✔ | static 100% | ? | ✔ | ? | - -## Rollout - -One PR per repo (17 total). Per repo: - -1. Branch `docs/uniform-readme` from the default branch. -2. Edit only the header (title + badge block) and footer; leave the body intact. -3. Resolve per-repo variables: PyPI name, docs-site presence, Context7 indexing. -4. Verify every badge URL resolves (HTTP 200 / valid SVG); skip Context7 badge if the page is not indexed. -5. Commit, push, open a PR with a shared body explaining the org-wide README standardization and linking back to this spec. - -## Risks / notes - -- **Static 100% badge** asserts a value, not a live measurement. It is truthful because `cov-fail-under=100` is enforced in CI; if a repo later drops the guard, the badge must be revisited. -- **Context7 indexing** is verified per-repo; some smaller repos may not be indexed yet, in which case the badge is omitted. -- **Docs sites** are verified per-repo; the `📚 Documentation` line is omitted where no live site exists. -- **`modern-di` table** is deliberately preserved; do not normalize it into the flat badge row. -- **Template filename casing** (`readme.md`) is preserved per repo. diff --git a/scripts/ft_smoke.py b/scripts/ft_smoke.py index a26207c..58c84e4 100644 --- a/scripts/ft_smoke.py +++ b/scripts/ft_smoke.py @@ -6,7 +6,7 @@ parseable output, a FastAPI bootstrap runs bootstrap()/teardown() clean, and OTLP export works over the http exporter (grpc/grpcio stays absent). Not a pytest test (conftest.py hard-imports opentelemetry, which the ft leg does -not install). See architecture/free-threading.md. +not install). """ import sys diff --git a/tests/instruments/test_structured_log_payload.py b/tests/instruments/test_structured_log_payload.py index 001b383..7f48e19 100644 --- a/tests/instruments/test_structured_log_payload.py +++ b/tests/instruments/test_structured_log_payload.py @@ -48,8 +48,8 @@ def test_parse_truthy_skip_sentry_sets_flag() -> None: def test_parse_falsy_skip_sentry_is_stripped_from_extra() -> None: - # DES-4 (planning/audits/2026-06-05-bug-audit-v2.md): a falsy skip_sentry flag - # must not leak into extra; it is a meta-key, stripped regardless of value. + # A falsy skip_sentry flag must not leak into extra; it is a meta-key, + # stripped regardless of value. payload = StructuredLogPayload.parse('{"event": "keep", "skip_sentry": false, "foo": "bar"}') assert payload is not None diff --git a/tests/test_config_cascade.py b/tests/test_config_cascade.py new file mode 100644 index 0000000..0efcd9d --- /dev/null +++ b/tests/test_config_cascade.py @@ -0,0 +1,43 @@ +import pytest + +from lite_bootstrap import ( + FastAPIConfig, + FastMcpConfig, + FastStreamConfig, + FreeConfig, + LitestarConfig, +) +from lite_bootstrap.instruments.base import BaseConfig + + +@pytest.mark.parametrize( + "config_type", + [FastAPIConfig, FastMcpConfig, FastStreamConfig, FreeConfig, LitestarConfig], +) +def test_every_framework_config_post_init_cascade_reaches_base_config( + config_type: type[BaseConfig], monkeypatch: pytest.MonkeyPatch +) -> None: + """INVARIANT: constructing any framework config runs the whole `__post_init__` chain. + + Framework configs compose instrument configs by multiple inheritance, and several of those + define `__post_init__` — `CorsConfig` rejects the credentials-plus-wildcard combination, + `OpenTelemetryConfig` warns on a remote insecure endpoint, `FastAPIConfig` builds the + application. A dataclass gives each class one `__post_init__` slot resolved through the MRO, so + the chain only continues while every link calls `super().__post_init__()`. A link that returns + early, or a newly added config whose author does not know the rule, silently disables the + validation of every class after it in the MRO rather than failing. + + That is what breaks it, and it has broken once already: `CorsConfig.__post_init__` shipped + without the `super()` call and switched off `OpenTelemetryConfig`'s insecure-endpoint warning for + every FastAPI user, with nothing failing. `BaseConfig.__post_init__` is the no-op terminator, so + reaching it proves the chain ran to the end; spying on it is how this test sees the whole chain + without knowing which classes are in it. + + Adding a config with no `__post_init__` at all is fine — the MRO simply skips it. + """ + reached: list[type] = [] + monkeypatch.setattr(BaseConfig, "__post_init__", lambda self: reached.append(type(self))) + + config_type() + + assert reached == [config_type] diff --git a/tests/test_core_import_surface.py b/tests/test_core_import_surface.py new file mode 100644 index 0000000..183dbe6 --- /dev/null +++ b/tests/test_core_import_surface.py @@ -0,0 +1,48 @@ +import ast +import pathlib +import sys + +import lite_bootstrap + + +ALLOWED_NON_STDLIB_ROOTS = frozenset({"lite_bootstrap", "typing_extensions"}) +PACKAGE_ROOT = pathlib.Path(lite_bootstrap.__file__).parent + + +def _unguarded_third_party_imports(path: pathlib.Path) -> set[str]: + # Only tree.body — statements nested in `if import_checker.is_X_installed:` or + # `if typing.TYPE_CHECKING:` are deeper and are exactly what this invariant permits. + roots: set[str] = set() + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Import): + roots.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + roots.add((node.module or "").split(".")[0]) + return {root for root in roots if root not in sys.stdlib_module_names and root not in ALLOWED_NON_STDLIB_ROOTS} + + +def test_importing_lite_bootstrap_needs_only_the_stdlib_and_typing_extensions() -> None: + """INVARIANT: no module imports a third-party package at unguarded module scope. + + Every optional dependency is one an install may legitimately lack, so an import of it that is + not inside an `if import_checker.is_X_installed:` or `if typing.TYPE_CHECKING:` block turns a + missing extra into a crash at `import lite_bootstrap` — before the user reaches the config that + would have told the bootstrapper to skip that instrument. This is what breaks it, and it has + broken four times: `orjson` as a mandatory core dependency; `opentelemetry.sdk` imported under + the api-only flag, so `lite-bootstrap[fastmcp]` (which pulls the api transitively) could not be + imported; the OTLP exporters imported the same way; and `typing_extensions` used but undeclared, + so a bare install of 1.3.0 could not be imported at all. + + The rule is also what makes the free-threaded story work: nothing native is reachable from a + bare import, so core installs and imports on any interpreter and each unavailable extra degrades + to a skipped instrument rather than an ImportError. Adding a genuinely mandatory dependency is + allowed — declare it in `[project.dependencies]` and add it here; ADR-0007 records why + `typing-extensions` is the only one. + """ + offenders = { + str(path.relative_to(PACKAGE_ROOT)): third_party + for path in sorted(PACKAGE_ROOT.rglob("*.py")) + if (third_party := _unguarded_third_party_imports(path)) + } + + assert offenders == {} diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 46d331a..5804b6a 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -9,11 +9,13 @@ from lite_bootstrap import ( FreeBootstrapper, FreeConfig, + InstrumentDependencyMissingWarning, TeardownError, ) from lite_bootstrap.bootstrappers.base import BaseBootstrapper from lite_bootstrap.instruments.logging_instrument import LoggingInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeInstrument +from lite_bootstrap.instruments.sentry_instrument import SentryInstrument from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing @@ -226,6 +228,42 @@ def test_config_skip_emits_no_warning() -> None: assert LoggingInstrument in {cls for cls, _ in bootstrapper.skipped_instruments} +def test_only_the_silent_skip_path_lands_in_skipped_instruments() -> None: + """INVARIANT: a config skip is recorded in `skipped_instruments`; a dependency skip is not. + + The two skips mean opposite things. "You did not ask for Sentry" is the normal case and gets no + warning, so `skipped_instruments` (and the `build_summary()` line built from it) is the only + place it is visible — that list is the answer to "why is this instrument not running." "You + asked for Sentry and sentry-sdk is not installed" is a deployment surprise that already shouts + through a warning and a `logger.warning`, and adding it to the same list would put a fault and a + non-fault next to each other under one heading, making the quiet one impossible to scan for. + + Folding the dependency path into `skipped_instruments` is what breaks it, and it is an inviting + change to make: the list looks incomplete until you know it is a list of one specific thing. + `skipped_instruments` is public — documented for programmatic inspection — so a consumer + filtering on it would start seeing entries that mean the opposite of what it filtered for. + """ + config_skip = FreeBootstrapper( + bootstrap_config=FreeConfig(logging_enabled=False, logging_buffer_capacity=0), + ) + assert LoggingInstrument in {cls for cls, _ in config_skip.skipped_instruments} + + with ( + emulate_package_missing("sentry_sdk"), + pytest.warns(InstrumentDependencyMissingWarning, match="sentry_sdk"), + ): + dependency_skip = FreeBootstrapper( + bootstrap_config=FreeConfig( + sentry_dsn="https://testdsn@localhost/1", + logging_enabled=False, + logging_buffer_capacity=0, + ), + ) + + assert SentryInstrument not in {cls for cls, _ in dependency_skip.skipped_instruments} + assert SentryInstrument not in {type(i) for i in dependency_skip.instruments} + + def test_missing_dependency_warning_logs_via_logger_too( free_bootstrapper_config: FreeConfig, caplog: pytest.LogCaptureFixture ) -> None: