diff --git a/CLAUDE.md b/CLAUDE.md index e886f7b..ecf4466 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,29 +68,29 @@ Capability index (all of `architecture/`): Recent design context, bugs, and rationale: bug-audit findings in `planning/audits/`, post-work reflections in `planning/retros/`, and the audit -arcs bundled under `planning/changes/`. The full extras matrix is -`[project.optional-dependencies]` in `pyproject.toml`. +arcs recorded as change files under `planning/changes/`. The full extras +matrix is `[project.optional-dependencies]` in `pyproject.toml`. ## 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 bundles. **Start at 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 bundle, and ship — that file is the authoritative spec, with +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 bundles and `just index` to print the listing. +`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 the change bundle + `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 folder move. + post-merge bookkeeping, no file move. ## Code style diff --git a/Justfile b/Justfile index c4e4dc6..ebe0c77 100644 --- a/Justfile +++ b/Justfile @@ -21,7 +21,7 @@ lint-ci: index: uv run python planning/index.py -# Validate planning bundles + decisions; CI runs this. +# Validate planning changes + decisions; CI runs this. check-planning: uv run python planning/index.py --check diff --git a/planning/.convention-version b/planning/.convention-version index 3eefcb9..227cea2 100644 --- a/planning/.convention-version +++ b/planning/.convention-version @@ -1 +1 @@ -1.0.0 +2.0.0 diff --git a/planning/README.md b/planning/README.md index c4efd05..6b22baa 100644 --- a/planning/README.md +++ b/planning/README.md @@ -4,9 +4,35 @@ 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 section is the portable convention, sourced from the canonical repo +> 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`) @@ -14,20 +40,41 @@ at the repo root; this directory records *how it got there*. ### Two axes, never mixed -- **`architecture/` (repo root) — the present.** One file per capability, - living prose, updated in the same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One folder per change, +- **`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 bundle stays in `changes/` as the *why*. +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: -### Change bundles +```md +**Timer**: +A scheduled future delivery, identified by a timer id. +_Avoid_: job, task, alarm +``` -A change is a folder `changes/YYYY-MM-DD.NN-/`: +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. @@ -36,43 +83,61 @@ A change is a folder `changes/YYYY-MM-DD.NN-/`: `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 folder move. `date` and `slug` are never written — they are -read from the bundle's directory name. +bookkeeping, no file move. `date` and `slug` are never written — they are +read from the file name. ### Three lanes | Lane | Artifacts | 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 | +| **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 `change.md` that outgrows its lane splits -into `design.md` + `plan.md`. +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.md`** — the spec: the *thinking* (why, design, trade-offs, scope). -- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). -- **`change.md`** — both, condensed, for the lightweight lane. -- **`decisions/-.md`** — one file per design decision taken - (especially options *rejected*), each with a revisit trigger, so reviews don't - re-litigate them; listed by `just index`. +- **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 directory / file name** — never +`date` and `slug` are **derived from the file name** — never repeated in frontmatter. So: -- `design.md` / `change.md`: `summary` (single line) only. -- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `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. @@ -85,7 +150,7 @@ only field the index renders. The listing is **generated**, not maintained — run `just index` to print it: changes flat, newest-first, then decisions newest-first. The frontmatter in -each bundle / decision file is the single source of truth; there is no +each change / decision file is the single source of truth; there is no committed copy to drift. ## Other diff --git a/planning/_templates/change.md b/planning/_templates/change.md index d4c8962..5aa7e81 100644 --- a/planning/_templates/change.md +++ b/planning/_templates/change.md @@ -5,8 +5,8 @@ summary: One line — shown in the generated index. Written at creation; finaliz # 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, split into -`design.md` + `plan.md`. +change, a single straightforward test. If it outgrows this, rewrite it from +the design template. ## Goal diff --git a/planning/_templates/design.md b/planning/_templates/design.md index d63e22d..17dbee1 100644 --- a/planning/_templates/design.md +++ b/planning/_templates/design.md @@ -4,6 +4,10 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Design: One-line capitalized title + + ## Summary One paragraph. What changes, at the level a reader needs to decide if this @@ -12,37 +16,23 @@ spec is worth reading in full. ## Motivation Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. Link to memory entries or earlier specs when relevant. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. Each item is -a sentence; one line each. +abstract complaints. ## Design -### 1. - What changes, in enough detail that a reader who has not seen the codebase -can follow. Code samples / diagrams welcome. +can follow. Sketches and interface fragments welcome; never the full +diff-to-be. Reference rejected alternatives in `decisions/` instead of +retelling them. -### 2. - -... - -## Operations - -Out-of-repo steps (DNS, infra, external account changes). Omit if none. - -## Out of scope +## Non-goals -Already covered above under Non-goals if appropriate. Repeat-list of -explicitly-excluded follow-ups belongs here when the list is long. +What is deliberately out of scope and (when nontrivial) why. One line each. ## Testing -How we know it landed correctly. New pytest? Smoke check on live URL? -Lint pass? Be specific. +How we know it landed correctly. Be specific: the command and the expected +signal. ## Risk diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md new file mode 100644 index 0000000..82385c3 --- /dev/null +++ b/planning/_templates/glossary.md @@ -0,0 +1,15 @@ +# 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/plan.md b/planning/_templates/plan.md deleted file mode 100644 index 132d720..0000000 --- a/planning/_templates/plan.md +++ /dev/null @@ -1,46 +0,0 @@ -# — 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:** One sentence — what shipping this plan achieves. No design -rationale; link to the spec for that. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) - -**Commit strategy:** Per-task commits / single commit / squash on merge. -Whichever fits. - ---- - -### Task 1: - -**Files:** -- Modify: `path/to/file.py` -- Create: `path/to/new.py` - -One sentence on what this task accomplishes. No deeper reasoning — that's -in the spec. - -- [ ] **Step 1: ** - - Run / edit / verify command. Expected output. - -- [ ] **Step 2: ** - - ... - -- [ ] **Step 3: Commit** - - ```bash - git add path/to/file.py - git commit -m ": " - ``` - ---- - -### Task 2: ... diff --git a/planning/changes/2026-05-31.01-audit-implementation/design.md b/planning/changes/2026-05-31.01-audit-implementation.md similarity index 100% rename from planning/changes/2026-05-31.01-audit-implementation/design.md rename to planning/changes/2026-05-31.01-audit-implementation.md diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan.md b/planning/changes/2026-05-31.01-audit-implementation/plan.md deleted file mode 100644 index 2e33b10..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan.md +++ /dev/null @@ -1,2695 +0,0 @@ -# 31.01-audit-implementation — implementation plan - -> Multi-PR plan: this change shipped as a sequence of PRs. Each section below was an independent per-PR plan; they are preserved verbatim here as the bundle's single `plan.md` (the spec is [`design.md`](./design.md)). - - ---- - -# PR1: Redoc `root_path` Fix 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:** Make the offline-docs `redoc_html` handler honor `root_path`, so redoc loads its JS and OpenAPI spec correctly when the FastAPI app is mounted behind a reverse proxy. Add a regression test that fails on `main` and passes after the fix. - -**Architecture:** The `enable_offline_docs` helper installs three handlers — one for swagger, one for swagger oauth2 redirect, one for redoc. The swagger handler already reads `request.scope["root_path"]` and prefixes asset/OpenAPI URLs. The redoc handler does not. Make the redoc handler match the swagger pattern. No public API change. - -**Tech Stack:** FastAPI, Starlette, pytest, fastapi.testclient. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR1 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-1, TEST-1). - ---- - -## File Structure - -Two existing files modified. No new files. - -- Modify: `lite_bootstrap/helpers/fastapi_helpers.py:52-58` — change `redoc_html` signature and URL construction. -- Modify: `tests/test_fastapi_offline_docs.py:32-43` — extend `test_fastapi_offline_docs_root_path` to fetch redoc and assert prefixing. - ---- - -## Task 1: Create branch - -**Files:** -- (no files; git branch only) - -- [ ] **Step 1: Create the feature branch** - -From `main` (clean working tree expected): - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/crit-1-redoc-root-path -``` - -Expected: `Switched to a new branch 'fix/crit-1-redoc-root-path'`. - ---- - -## Task 2: Add the failing regression test - -**Files:** -- Modify: `tests/test_fastapi_offline_docs.py:32-43` - -The existing `test_fastapi_offline_docs_root_path` exercises swagger under `root_path` but never fetches redoc. Extend it to fetch redoc and assert that both the redoc JS URL and the OpenAPI URL in the rendered HTML carry the `/some-root-path` prefix. The default `redoc_url` for a FastAPI app is `/redoc` (no override in the test setup), and the default `openapi_url` is `/openapi.json`. - -- [ ] **Step 1: Modify `test_fastapi_offline_docs_root_path`** - -Replace the existing function body (lines 33-43) with: - -```python -def test_fastapi_offline_docs_root_path() -> None: - app: FastAPI = FastAPI(title="Tests", root_path="/some-root-path", docs_url="/custom_docs") - enable_offline_docs(app, static_path="/static") - - with TestClient(app, root_path="/some-root-path") as client: - response = client.get("/custom_docs") - assert response.status_code == HTTPStatus.OK - assert "/some-root-path/static/swagger-ui.css" in response.text - assert "/some-root-path/static/swagger-ui-bundle.js" in response.text - - response = client.get("/some-root-path/static/swagger-ui.css") - assert response.status_code == HTTPStatus.OK - - response = client.get("/redoc") - assert response.status_code == HTTPStatus.OK - assert "/some-root-path/static/redoc.standalone.js" in response.text - assert "/some-root-path/openapi.json" in response.text -``` - -The four added lines: the redoc GET, the status assertion, the redoc JS URL assertion, the OpenAPI URL assertion. - -- [ ] **Step 2: Run the test and verify it FAILS** - -Run: - -```bash -just test -- tests/test_fastapi_offline_docs.py::test_fastapi_offline_docs_root_path -v -``` - -Expected: **FAIL** with an assertion error on one of the two new asserts — most likely -`assert "/some-root-path/static/redoc.standalone.js" in response.text` fails because the -rendered HTML contains `/static/redoc.standalone.js` (no `/some-root-path/` prefix). - -If the test does not fail, stop and investigate — either the assertion is wrong, or the bug -isn't present (which would mean the audit is stale). - ---- - -## Task 3: Implement the redoc fix - -**Files:** -- Modify: `lite_bootstrap/helpers/fastapi_helpers.py:52-58` - -The swagger handler at lines 37-46 is the pattern to mirror: it takes `request: Request`, -reads `root_path` from the ASGI scope, and prefixes asset URLs and the OpenAPI URL. - -- [ ] **Step 1: Replace the `redoc_html` handler** - -Replace lines 52-58 of `lite_bootstrap/helpers/fastapi_helpers.py` with: - -```python - @app.get(redoc_url, include_in_schema=False) - async def redoc_html(request: Request) -> HTMLResponse: - root_path = request.scope.get("root_path", "").rstrip("/") - return get_redoc_html( - openapi_url=f"{root_path}{app_openapi_url}", - title=f"{app.title} - ReDoc", - redoc_js_url=f"{root_path}{static_path}/redoc.standalone.js", - ) -``` - -Notes: -- `Request` is already imported at line 9. -- `root_path` handling matches the swagger handler exactly (`request.scope.get("root_path", "").rstrip("/")`). -- Both `openapi_url` and `redoc_js_url` get the prefix. The audit (CRIT-1) flagged the JS URL; the OpenAPI URL has the same bug — the test in Task 2 catches both. - -- [ ] **Step 2: Run the previously-failing test and verify it PASSES** - -Run: - -```bash -just test -- tests/test_fastapi_offline_docs.py::test_fastapi_offline_docs_root_path -v -``` - -Expected: **PASS**. - -- [ ] **Step 3: Run the full offline-docs test file** - -Run: - -```bash -just test -- tests/test_fastapi_offline_docs.py -v -``` - -Expected: all three tests PASS — `test_fastapi_offline_docs`, -`test_fastapi_offline_docs_root_path`, `test_fastapi_offline_docs_raises_without_openapi_url`. - -This confirms the change didn't break the no-`root_path` path or the error path. - -- [ ] **Step 4: Run the full test suite** - -Run: - -```bash -just test -``` - -Expected: all tests PASS with no new failures. - -- [ ] **Step 5: Run lint** - -Run: - -```bash -just lint -``` - -Expected: no errors. The change is small and follows existing patterns, so ruff, eof-fixer, -and `ty check` should all pass. - -- [ ] **Step 6: Commit** - -Stage both modified files explicitly: - -```bash -git add lite_bootstrap/helpers/fastapi_helpers.py tests/test_fastapi_offline_docs.py -git commit -m "$(cat <<'EOF' -fix: honor root_path in offline-docs redoc handler - -The swagger handler in enable_offline_docs already reads root_path from -the ASGI scope and prefixes asset/OpenAPI URLs. The redoc handler did -not, so redoc 404'd on its JS and OpenAPI spec when the FastAPI app -ran behind a reverse proxy. Mirror the swagger pattern: take Request, -read root_path, prefix both redoc_js_url and openapi_url. - -Extends test_fastapi_offline_docs_root_path to fetch redoc and assert -both URLs carry the prefix — the test fails on the prior code. - -Closes CRIT-1, TEST-1 from the audit. -EOF -)" -``` - -Expected: commit succeeds. (No pre-commit hooks are configured in this repo — see `.pre-commit-config.yaml` absence in repo root.) - ---- - -## Task 4: Push and open PR - -**Files:** -- (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/crit-1-redoc-root-path -``` - -Expected: branch published; gh CLI may print a PR-creation URL. - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: honor root_path in offline-docs redoc handler" --body "$(cat <<'EOF' -## Summary -- Redoc handler in `enable_offline_docs` now reads `root_path` from the ASGI scope and prefixes both `redoc_js_url` and `openapi_url`, matching the existing swagger handler pattern. -- Existing `test_fastapi_offline_docs_root_path` extended to fetch redoc and assert both URLs carry the `root_path` prefix. Test fails on `main`, passes on this branch. - -Closes CRIT-1 and TEST-1 from `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md`. - -## Test plan -- [x] `just test -- tests/test_fastapi_offline_docs.py -v` — three tests pass. -- [x] `just test` — full suite passes. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the diff matches the swagger handler's `root_path` pattern. -EOF -)" -``` - -Expected: PR created; PR URL printed. - ---- - -## Self-Review - -Spec coverage check against `2026-05-31-audit-implementation-sequencing.md`, PR1 section: - -| Spec item | Task | -|-----------|------| -| Refactor `redoc_html` to accept `Request` | Task 3, Step 1 | -| Read `root_path` from `request.scope` and rstrip | Task 3, Step 1 | -| Prepend prefix to `redoc_js_url` | Task 3, Step 1 | -| Prepend prefix to `openapi_url` (also missing it) | Task 3, Step 1 | -| Extend `test_fastapi_offline_docs_root_path` to fetch redoc and assert prefix | Task 2, Step 1 | -| Verification: `just test` passes | Task 3, Steps 3-4 | -| Branch name `fix/crit-1-redoc-root-path` | Task 1, Step 1 | - -All spec items covered. No placeholders. Method signatures (`redoc_html(request: Request)`, -`request.scope.get("root_path", "")`) are consistent across Task 2's test expectations -and Task 3's implementation. The test asserts on `/some-root-path/static/redoc.standalone.js` -and `/some-root-path/openapi.json`; the implementation produces those exact strings. - - ---- - -# PR2: OpenTelemetry Tracer Provider Shutdown 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:** Make `OpenTelemetryInstrument.teardown()` shut down the `TracerProvider` that `bootstrap()` created. Today the provider goes out of scope and any spans buffered in `BatchSpanProcessor` are not flushed — trace data loss on graceful shutdown. Add a regression test that fails on `main` and passes after the fix. - -**Architecture:** `OpenTelemetryInstrument` is a frozen, slots-enabled dataclass. The existing codebase pattern for stashing mutable runtime state on a frozen dataclass uses an init-false private field plus `object.__setattr__` (see `LoggingInstrument._logger_factory`). Mirror that: add `_tracer_provider`, store the provider via `object.__setattr__` at the end of `bootstrap()`, call `self._tracer_provider.shutdown()` after the existing `uninstrument()` loop in `teardown()`, reset to `None`. - -**Tech Stack:** Python 3.10+, OpenTelemetry SDK (`opentelemetry-sdk`, `opentelemetry-api`), pytest, `unittest.mock.patch.object`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR2 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-2, TEST-2). - ---- - -## File Structure - -Two existing files modified. No new files. - -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py:76-135` — add `_tracer_provider` field on the dataclass, store the provider in `bootstrap()`, shut it down in `teardown()`. -- Modify: `tests/instruments/test_opentelemetry_instrument.py` — add a new test asserting `shutdown` is invoked. - ---- - -## Locked decisions (from sequencing spec) - -- **Storage pattern:** `object.__setattr__` (preserves `frozen=True`; matches `LoggingInstrument._logger_factory`). -- **Field name:** `_tracer_provider` (underscore = internal state, mirrors `_logger_factory`). -- **Test access:** read the private `_tracer_provider` attribute directly with `# noqa: SLF001`. Other tests in the file already use patch-based introspection (`patch("lite_bootstrap.instruments.opentelemetry_instrument.set_tracer_provider")`), so private-attribute access for verification is consistent with the existing test style. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/crit-2-otel-shutdown -``` - -Expected: `Switched to a new branch 'fix/crit-2-otel-shutdown'`. - -If PR1 (`fix/crit-1-redoc-root-path`) has not yet merged into `main`, that's fine — PR2's changes touch a different file and there will be no conflict. Branch from current `main` regardless. - ---- - -## Task 2: Add the failing regression test - -**Files:** -- Modify: `tests/instruments/test_opentelemetry_instrument.py` - -The current test file has two tests that bootstrap + teardown but assert nothing about shutdown behavior. Add a third test that bootstraps the instrument, captures the stored `TracerProvider`, patches its `shutdown` method, runs teardown, and asserts the patch was invoked exactly once. - -- [ ] **Step 1: Add imports and the new test** - -The existing imports are: - -```python -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -Add `from unittest.mock import patch` at the top (after any stdlib imports, before the project imports — ruff isort will handle ordering on save, but write it correctly the first time): - -```python -from unittest.mock import patch - -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -Append this new test to the end of the file (after `test_opentelemetry_instrument_empty_instruments`): - -```python -def test_opentelemetry_instrument_teardown_shuts_down_tracer_provider() -> None: - instrument = OpenTelemetryInstrument( - bootstrap_config=OpentelemetryConfig(opentelemetry_log_traces=True), - ) - instrument.bootstrap() - tracer_provider = instrument._tracer_provider # noqa: SLF001 - assert tracer_provider is not None - - with patch.object(tracer_provider, "shutdown") as mock_shutdown: - instrument.teardown() - - mock_shutdown.assert_called_once_with() - assert instrument._tracer_provider is None # noqa: SLF001 -``` - -This test asserts three things: -1. The instrument exposes its tracer provider as `_tracer_provider` after bootstrap. -2. Teardown calls `shutdown()` on that provider exactly once. -3. Teardown resets `_tracer_provider` to `None` so a subsequent bootstrap starts clean. - -- [ ] **Step 2: Run the new test and verify it FAILS** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_shuts_down_tracer_provider -v -``` - -Expected: **FAIL** with `AttributeError: 'OpenTelemetryInstrument' object has no attribute '_tracer_provider'` (or similar — the attribute doesn't exist yet on the dataclass). - -If the test passes, stop and investigate — either the attribute already exists (which would mean someone else implemented the fix already) or the assertion is wrong. - ---- - -## Task 3: Implement the shutdown fix - -**Files:** -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` - -The current `OpenTelemetryInstrument` dataclass (lines 76-80) has no init-false field for the provider. Add one. Then update `bootstrap()` to stash the locally-created provider on the instance, and update `teardown()` to shut it down and clear the field. - -- [ ] **Step 1: Add `_tracer_provider` field to the dataclass** - -Current dataclass body (lines 76-80): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument): - bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument): - bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) -``` - -Notes: -- The string annotation `"TracerProvider | None"` is a forward reference. `TracerProvider` is imported conditionally inside `if import_checker.is_opentelemetry_installed:` at module top (line 17), so the string form avoids NameError if opentelemetry isn't installed. -- `default_factory=lambda: None` matches the `LoggingInstrument._logger_factory` precedent. Do not use `default=None` — the existing codebase normalized on `default_factory=lambda: None` for these fields (see commit `8db9be3`). -- `init=False, repr=False, compare=False` matches the precedent: this is internal runtime state, not part of the instrument's identity. - -- [ ] **Step 2: Stash the provider in `bootstrap()`** - -Current `bootstrap()` ends at line 128 with the instrumentor loop. Just before that loop, after `set_tracer_provider(tracer_provider)` (line 107) and the span-processor setup (lines 108-120), add the stash. The simplest placement: right after `set_tracer_provider(tracer_provider)`. - -Current code around line 106-107: - -```python - tracer_provider = TracerProvider(resource=resource) - set_tracer_provider(tracer_provider) -``` - -Replace with: - -```python - tracer_provider = TracerProvider(resource=resource) - set_tracer_provider(tracer_provider) - object.__setattr__(self, "_tracer_provider", tracer_provider) -``` - -This makes the locally-constructed provider reachable from `teardown()`. - -- [ ] **Step 3: Shut down the provider in `teardown()`** - -Current `teardown()` (lines 130-135): - -```python - def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() -``` - -Replace with: - -```python - def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() - if self._tracer_provider is not None: - self._tracer_provider.shutdown() - object.__setattr__(self, "_tracer_provider", None) -``` - -Order matters: uninstrument first (so instrumentors release any references to the provider), then shutdown (so the provider flushes buffered spans and disposes of processors). - -- [ ] **Step 4: Run the previously-failing test and verify it PASSES** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_shuts_down_tracer_provider -v -``` - -Expected: **PASS**. - -- [ ] **Step 5: Run the full OTel test file** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py -v -``` - -Expected: all three tests PASS — the two existing tests should be unaffected by the change. - -- [ ] **Step 6: Run the full test suite** - -```bash -just test -``` - -Expected: all tests PASS. The change touches a hot code path used by every framework bootstrapper's OTel integration (FastAPI, Litestar, FastStream, Free) — the bootstrapper-level tests will all exercise the new shutdown call. Watch for any unexpected failures in `test_fastapi_bootstrap.py`, `test_litestar_bootstrap.py`, `test_faststream_bootstrap.py`, `test_free_bootstrap.py`. - -If any pre-existing test fails because of double-shutdown (a teardown getting called twice somewhere — Litestar registers `self.teardown` on `on_shutdown`), that's CRIT-3 territory and will be handled in PR3. Note the failure in your report but do not attempt to fix CRIT-3 in this PR. If you see a `RuntimeError: TracerProvider has already been shut down` (or similar) in a test that wasn't failing before, that's the signal — flag it and continue. - -- [ ] **Step 7: Run lint** - -```bash -just lint -``` - -Expected: no errors. The `# noqa: SLF001` in the test handles private-member-access; everything else follows existing patterns. - -- [ ] **Step 8: Commit** - -Stage both modified files explicitly: - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py tests/instruments/test_opentelemetry_instrument.py -git commit -m "$(cat <<'EOF' -fix: shut down TracerProvider in OpenTelemetryInstrument.teardown - -The instrument's bootstrap() created a TracerProvider, registered span -processors against it, and called set_tracer_provider — but never stored -a reference. teardown() only uninstrumented the instrumentors; the -provider was never shut down. Spans buffered in BatchSpanProcessor were -lost on graceful shutdown. - -Stash the provider on the instrument via object.__setattr__ (mirroring -the LoggingInstrument._logger_factory pattern for runtime state on a -frozen dataclass), shut it down after the uninstrument loop, reset the -field to None so a subsequent bootstrap starts clean. - -Regression test asserts shutdown is called exactly once on the stored -provider and the field is reset. - -Closes CRIT-2, TEST-2 from the audit. -EOF -)" -``` - -Expected: commit succeeds. - ---- - -## Task 4: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/crit-2-otel-shutdown -``` - -Expected: branch published. - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: shut down TracerProvider in OpenTelemetryInstrument.teardown" --body "$(cat <<'EOF' -## Summary -- `OpenTelemetryInstrument.bootstrap()` now stashes the `TracerProvider` it created on the instance (via `object.__setattr__`, matching `LoggingInstrument._logger_factory`). -- `teardown()` calls `self._tracer_provider.shutdown()` after the existing instrumentor uninstrument loop, then resets the field to `None`. Buffered spans in `BatchSpanProcessor` are now flushed on graceful shutdown. -- New regression test `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider` patches `shutdown` on the stored provider and asserts it's invoked exactly once. Test fails on `main`, passes on this branch. - -Closes CRIT-2 and TEST-2 from an internal audit of the codebase. - -## Test plan -- [x] `just test -- tests/instruments/test_opentelemetry_instrument.py -v` — three tests pass. -- [x] `just test` — full suite passes. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the field placement and `object.__setattr__` usage match the `LoggingInstrument._logger_factory` precedent. -EOF -)" -``` - -Expected: PR created; URL printed. - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR2 section) and audit (CRIT-2, TEST-2): - -| Spec item | Task | -|-----------|------| -| Store `TracerProvider` on instance via `object.__setattr__` | Task 3, Step 2 | -| Declare `_tracer_provider: "TracerProvider \| None"` init-false field | Task 3, Step 1 | -| Call `shutdown()` in `teardown()` after `uninstrument()` loop | Task 3, Step 3 | -| Reset field to `None` after shutdown | Task 3, Step 3 | -| Add test asserting `shutdown` is invoked | Task 2, Step 1 | -| Test uses `patch.object` (mock approach, not real BatchSpanProcessor) | Task 2, Step 1 | -| Branch name `fix/crit-2-otel-shutdown` | Task 1, Step 1 | -| Verification: `just test` + `just lint` pass | Task 3, Steps 6-7 | - -All spec items covered. No placeholders. Field-name (`_tracer_provider`) and assertion-text consistency holds across Task 2 (test expectations) and Task 3 (implementation). - -**Cross-PR awareness:** Task 3 Step 6 notes that pre-existing tests could surface CRIT-3 (double-teardown) once the shutdown call exists. That failure mode is explicitly out of scope for this PR — flag and continue, do not attempt to fix it here. - - ---- - -# PR3: Idempotent Teardown 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:** Make `BaseBootstrapper.teardown()` idempotent so a second call returns immediately without re-tearing-down instruments. Concretely fixes the Litestar and FastStream cases where the bootstrapper registers `self.teardown` on a framework shutdown hook *and* gets called manually — today, second teardown re-invokes every instrument's `teardown()`, which is unsafe because many instruments aren't idempotent themselves. - -While we're touching teardown robustness, also bundle the `try/finally` follow-up that PR2's code review flagged: in both `OpenTelemetryInstrument` and `LoggingInstrument`, a raise inside the shutdown call leaves the cached reference non-None. Wrap both in `try/finally` so the cached reference is reset regardless of whether shutdown succeeded. - -**Architecture:** Three small behavioral changes — one perimeter guard (the bootstrapper-level idempotency check) and two defense-in-depth changes (instrument-level cleanup robustness). Three new regression tests, one per change. No new files; no API changes. - -**Tech Stack:** Python 3.10+, pytest, `unittest.mock.patch.object`, `unittest.mock.MagicMock`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR3 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-3, TEST-3). -**Follow-up from:** PR2 code review (https://github.com/modern-python/lite-bootstrap/pull/90 — "Known follow-up" section in the PR body). - ---- - -## File Structure - -Three production files modified; three test files modified. - -- Modify: `lite_bootstrap/bootstrappers/base.py:82-93` — add idempotency guard at top of `BaseBootstrapper.teardown()`. -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — wrap `self._tracer_provider.shutdown()` in `try/finally` so the field resets even on raise. -- Modify: `lite_bootstrap/instruments/logging_instrument.py` — wrap `self._logger_factory.close_handlers()` in `try/finally` so the field resets even on raise. -- Modify: `tests/test_free_bootstrap.py` — add idempotency test using mocked instruments. -- Modify: `tests/instruments/test_opentelemetry_instrument.py` — add `try/finally` regression test using `side_effect=RuntimeError`. -- Modify: `tests/instruments/test_logging_instrument.py` — add `try/finally` regression test using `side_effect=RuntimeError`. - ---- - -## Locked decisions - -- **Bundling:** All three changes ship in one commit/PR. They're all "teardown robustness" with high cohesion; PR2's reviewer explicitly suggested doing the `try/finally` work alongside CRIT-3. -- **Test placement:** Idempotency test goes in `test_free_bootstrap.py` (matches sequencing-spec decision; co-located with existing `test_teardown_error_isolation` and `test_teardown_error_aggregates_all_failures`). Instrument-level tests go in the per-instrument test files. -- **Deferred:** A Litestar-specific test exercising the manual-teardown + `on_shutdown`-fires-teardown path is *not* included. The `test_free_bootstrap.py` idempotency test pins the contract; the Litestar path is downstream of that contract. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/crit-3-idempotent-teardown -``` - -Expected: `Switched to a new branch 'fix/crit-3-idempotent-teardown'`. - -If PR2 (`fix/crit-2-otel-shutdown`) has not yet merged into `main`, that's a real problem for this PR — Task 4 (the OTel `try/finally`) depends on the `_tracer_provider` field that PR2 introduced. **Verify before starting that `lite_bootstrap/instruments/opentelemetry_instrument.py` contains the `_tracer_provider` field declaration.** If it doesn't, stop and report — PR2 must merge first. - -```bash -grep -n "_tracer_provider" lite_bootstrap/instruments/opentelemetry_instrument.py -``` - -Expected output: at least three matches (field declaration, `bootstrap()` setattr, `teardown()` reference). If zero matches, PR2 is not merged — stop. - ---- - -## Task 2: Add three failing regression tests - -Add all three failing tests before any production-code change, so the TDD red→green transition is visible per test. - -### Test A: `BaseBootstrapper.teardown()` is idempotent - -**File:** `tests/test_free_bootstrap.py` - -The existing file already imports `MagicMock` from `unittest.mock`. No new imports needed. - -- [ ] **Step 1: Append new test to `tests/test_free_bootstrap.py`** - -Add at the end of the file (after `test_free_bootstrapper_with_missing_instrument_dependency`): - -```python -def test_teardown_is_idempotent(free_bootstrapper_config: FreeBootstrapperConfig) -> None: - bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) - bootstrapper.bootstrap() - - first = MagicMock() - second = MagicMock() - bootstrapper.instruments = [first, second] - - bootstrapper.teardown() - bootstrapper.teardown() - - first.teardown.assert_called_once() - second.teardown.assert_called_once() - assert not bootstrapper.is_bootstrapped -``` - -The contract: after the first `teardown()`, `is_bootstrapped` is False; the second call must observe that and return immediately, so each mocked instrument's `teardown()` is called exactly once across both bootstrapper-level calls. - -- [ ] **Step 2: Run the test and verify it FAILS** - -```bash -just test -- tests/test_free_bootstrap.py::test_teardown_is_idempotent -v -``` - -Expected: **FAIL** because the current `BaseBootstrapper.teardown()` doesn't check `is_bootstrapped`. Both mocked instruments will have `teardown()` called twice. The assertion `first.teardown.assert_called_once()` raises: - -``` -AssertionError: Expected 'teardown' to have been called once. Called 2 times. -``` - -### Test B: `OpenTelemetryInstrument.teardown()` resets `_tracer_provider` when `shutdown()` raises - -**File:** `tests/instruments/test_opentelemetry_instrument.py` - -The file (after PR2's merge) already imports `patch` from `unittest.mock`. Need to add `pytest` import. - -- [ ] **Step 3: Add `pytest` to the test file imports** - -Current top of file: - -```python -from unittest.mock import patch - -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -Replace with: - -```python -from unittest.mock import patch - -import pytest - -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -`pytest` goes in the third-party group, between stdlib and first-party. - -- [ ] **Step 4: Append new test to the file** - -Append at the end: - -```python -def test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises() -> None: - instrument = OpenTelemetryInstrument( - bootstrap_config=OpentelemetryConfig(opentelemetry_log_traces=True), - ) - instrument.bootstrap() - tracer_provider = instrument._tracer_provider # noqa: SLF001 - assert tracer_provider is not None - - with patch.object(tracer_provider, "shutdown", side_effect=RuntimeError("boom")): - with pytest.raises(RuntimeError, match="boom"): - instrument.teardown() - - assert instrument._tracer_provider is None # noqa: SLF001 -``` - -Contract: even if `shutdown()` raises, the cached reference must be cleared so the instrument can be re-bootstrapped cleanly. - -- [ ] **Step 5: Run the test and verify it FAILS** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises -v -``` - -Expected: **FAIL** because the current `teardown()` has `shutdown()` then `setattr(None)` as two sequential statements (no `try/finally`); the RuntimeError propagates, the reset never runs, and the final `assert instrument._tracer_provider is None` fails. - -### Test C: `LoggingInstrument.teardown()` resets `_logger_factory` when `close_handlers()` raises - -**File:** `tests/instruments/test_logging_instrument.py` - -Need to add `pytest` and `patch` imports. - -- [ ] **Step 6: Add `patch` and `pytest` to the test file imports** - -Current top: - -```python -import logging -from io import StringIO - -import structlog -from opentelemetry.trace import get_tracer - -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -from tests.conftest import LoggingMock -``` - -Replace with: - -```python -import logging -from io import StringIO -from unittest.mock import patch - -import pytest -import structlog -from opentelemetry.trace import get_tracer - -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -from tests.conftest import LoggingMock -``` - -- [ ] **Step 7: Append new test to the file** - -Append at the end: - -```python -def test_logging_instrument_teardown_resets_factory_when_close_handlers_raises() -> None: - instrument = LoggingInstrument( - bootstrap_config=LoggingConfig(logging_buffer_capacity=0), - ) - instrument.bootstrap() - factory = instrument._logger_factory # noqa: SLF001 - assert factory is not None - - with patch.object(factory, "close_handlers", side_effect=RuntimeError("boom")): - with pytest.raises(RuntimeError, match="boom"): - instrument.teardown() - - assert instrument._logger_factory is None # noqa: SLF001 -``` - -Contract: same shape as Test B — the cached factory reference must be cleared even when `close_handlers()` raises. - -- [ ] **Step 8: Run the test and verify it FAILS** - -```bash -just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_resets_factory_when_close_handlers_raises -v -``` - -Expected: **FAIL** because the current `LoggingInstrument.teardown()` has `close_handlers()` then `setattr(None)` as two sequential statements; the RuntimeError propagates, the reset never runs, the final `assert instrument._logger_factory is None` fails. - ---- - -## Task 3: Implement the three fixes - -### Fix 1: Idempotency guard on `BaseBootstrapper.teardown()` - -- [ ] **Step 1: Add guard at top of `BaseBootstrapper.teardown()`** - -**File:** `lite_bootstrap/bootstrappers/base.py:82-93` - -Current code: - -```python - def teardown(self) -> None: - self.is_bootstrapped = False - errors: list[tuple[str, BaseException]] = [] - for one_instrument in reversed(self.instruments): - try: - one_instrument.teardown() - except Exception as e: # noqa: BLE001, PERF203 - name = type(one_instrument).__name__ - logger.warning(f"Error tearing down {name}: {e}") - errors.append((name, e)) - if errors: - raise TeardownError(errors) from errors[0][1] -``` - -Replace with: - -```python - def teardown(self) -> None: - if not self.is_bootstrapped: - return - self.is_bootstrapped = False - errors: list[tuple[str, BaseException]] = [] - for one_instrument in reversed(self.instruments): - try: - one_instrument.teardown() - except Exception as e: # noqa: BLE001, PERF203 - name = type(one_instrument).__name__ - logger.warning(f"Error tearing down {name}: {e}") - errors.append((name, e)) - if errors: - raise TeardownError(errors) from errors[0][1] -``` - -Only the two-line guard at top is added. Everything else is byte-identical. - -### Fix 2: `try/finally` in `OpenTelemetryInstrument.teardown()` - -- [ ] **Step 2: Wrap `shutdown()` in `try/finally`** - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Current `teardown()` (after PR2): - -```python - def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() - if self._tracer_provider is not None: - self._tracer_provider.shutdown() - object.__setattr__(self, "_tracer_provider", None) -``` - -Replace the last block (the `if self._tracer_provider is not None:` block) with: - -```python - if self._tracer_provider is not None: - try: - self._tracer_provider.shutdown() - finally: - object.__setattr__(self, "_tracer_provider", None) -``` - -### Fix 3: `try/finally` in `LoggingInstrument.teardown()` - -- [ ] **Step 3: Wrap `close_handlers()` in `try/finally`** - -**File:** `lite_bootstrap/instruments/logging_instrument.py:202-211` - -Current code: - -```python - def teardown(self) -> None: - structlog.reset_defaults() - root_logger = logging.getLogger() - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - h.close() - root_logger.setLevel(logging.WARNING) - if self._logger_factory is not None: - self._logger_factory.close_handlers() - object.__setattr__(self, "_logger_factory", None) -``` - -Replace the last block with: - -```python - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - object.__setattr__(self, "_logger_factory", None) -``` - -### Verification - -- [ ] **Step 4: Run the three new tests and verify each PASSES** - -```bash -just test -- tests/test_free_bootstrap.py::test_teardown_is_idempotent tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_resets_factory_when_close_handlers_raises -v -``` - -Expected: all three PASS. - -- [ ] **Step 5: Run the full test suite** - -```bash -just test -``` - -Expected: all tests PASS. The idempotency guard is additive and shouldn't change any existing behavior — existing tests call `teardown()` exactly once and that path is unchanged. The two `try/finally` changes are non-observable to callers who don't trigger the exception path. - -Watch carefully for failures in the existing teardown-error tests in `test_free_bootstrap.py` (`test_teardown_error_isolation`, `test_teardown_error_aggregates_all_failures`) — these tests deliberately make instruments raise during teardown and assert on the resulting `TeardownError`. The guard doesn't affect them because they only call `teardown()` once. Confirm they still pass. - -- [ ] **Step 6: Run lint** - -```bash -just lint -``` - -Expected: no errors. - -- [ ] **Step 7: Commit** - -Stage the six modified files explicitly: - -```bash -git add \ - lite_bootstrap/bootstrappers/base.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - tests/test_free_bootstrap.py \ - tests/instruments/test_opentelemetry_instrument.py \ - tests/instruments/test_logging_instrument.py -git commit -m "$(cat <<'EOF' -fix: make teardown idempotent and exception-safe - -BaseBootstrapper.teardown() now returns immediately when not bootstrapped. -This fixes Litestar and FastStream, both of which register self.teardown -on a framework shutdown hook while also being callable manually — a user -who explicitly calls teardown() would otherwise re-invoke every instrument's -teardown when the framework shutdown fired second. Most instruments are -not idempotent themselves. - -Also wrap the shutdown calls inside OpenTelemetryInstrument and -LoggingInstrument in try/finally so the cached _tracer_provider / -_logger_factory references are reset even when shutdown raises. Without -this, a failed shutdown leaves the instrument in a state where a second -bootstrap reuses a stale reference. The bootstrapper-level guard -prevents the immediate symptom but doesn't help when instruments are -used standalone. - -Regression tests: -- test_teardown_is_idempotent: bootstrap, teardown twice, assert each - instrument's teardown was called exactly once. -- test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises: - patch shutdown to raise, assert field resets to None. -- test_logging_instrument_teardown_resets_factory_when_close_handlers_raises: - patch close_handlers to raise, assert field resets to None. - -Closes CRIT-3 and TEST-3 from the audit. Resolves the try/finally -follow-up flagged in PR #90's code review. -EOF -)" -``` - ---- - -## Task 4: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/crit-3-idempotent-teardown -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: make teardown idempotent and exception-safe" --body "$(cat <<'EOF' -## Summary -- `BaseBootstrapper.teardown()` now returns immediately when `not self.is_bootstrapped`. Fixes the Litestar/FastStream case where manual teardown + framework shutdown hook both fire, double-tearing-down instruments (many of which aren't idempotent). -- `OpenTelemetryInstrument.teardown()` and `LoggingInstrument.teardown()` wrap their shutdown calls in `try/finally` so the cached internal-state references reset even when shutdown raises. Resolves the follow-up flagged in PR #90's code review. - -Three regression tests added — one per fix — all fail on `main` and pass on this branch. - -Closes CRIT-3 and TEST-3 from an internal audit of the codebase. - -## Test plan -- [x] `just test -- tests/test_free_bootstrap.py -v` — all teardown tests pass. -- [x] `just test -- tests/instruments/test_opentelemetry_instrument.py -v` — all OTel tests pass. -- [x] `just test -- tests/instruments/test_logging_instrument.py -v` — all logging tests pass. -- [x] `just test` — full suite passes. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the bootstrapper guard placement (top of teardown, before `is_bootstrapped = False`) — order matters so the second call observes `is_bootstrapped` as `False` from the first call and returns immediately. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR3 section), audit (CRIT-3, TEST-3), and PR #90's review follow-up: - -| Spec item | Task | -|-----------|------| -| Guard at top of `BaseBootstrapper.teardown()` (`if not self.is_bootstrapped: return`) | Task 3, Step 1 | -| Test: bootstrap → teardown → teardown again, assert no double-invoke | Task 2 Test A (Steps 1-2) | -| Test placement in `test_free_bootstrap.py` | Task 2 Test A, Step 1 | -| Branch name `fix/crit-3-idempotent-teardown` | Task 1, Step 1 | -| OTel `try/finally` (follow-up from PR2 review) | Task 3, Step 2 | -| Logging `try/finally` (follow-up from PR2 review) | Task 3, Step 3 | -| OTel regression test for shutdown-raises | Task 2 Test B (Steps 3-5) | -| Logging regression test for close_handlers-raises | Task 2 Test C (Steps 6-8) | -| Verification: `just test` + `just lint` clean | Task 3, Steps 5-6 | - -All spec items covered. No placeholders. Field-name consistency holds (`_tracer_provider`, `_logger_factory`, `is_bootstrapped`) across tests and implementations. Each `try/finally` uses the same shape: `try: ; finally: object.__setattr__(self, "", None)`. - -**Deferred (not in this PR):** -- Litestar-specific test exercising the manual-teardown + `on_shutdown` path. -- FastStream-specific test of the same pattern. -- Adding a `try/finally` review across other instruments (none currently store cached internal state that needs resetting on teardown). - - ---- - -# PR4: Sentry `skip_sentry` Leak Fix + Dead `is_X_installed` Conjuncts Cleanup - -> **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:** Ship two small audit cleanups in one PR: - -- **DES-4:** Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` so the flag stops leaking into Sentry's `contexts.structlog` when set to falsy (the function already suppresses the event for truthy values, but doesn't strip the field for falsy ones). -- **DES-5:** Delete the dead `and import_checker.is_X_installed` conjuncts from four instruments' `is_ready()` methods. They're provably unreachable: `_register_or_skip` runs `check_dependencies()` before instantiating the instrument; if `check_dependencies()` returns False, `is_ready()` is never called. - -**Architecture:** Five files modified — four production deletions/additions and one test case. No new abstractions. No API changes. - -**Tech Stack:** Python 3.10+, pytest parametrized tests, sentry_sdk types. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR4 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-4, DES-5). - ---- - -## File Structure - -Four production files modified; one test file modified. - -- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` (DES-4); drop dead conjunct from `SentryInstrument.is_ready()` (DES-5). -- Modify: `lite_bootstrap/instruments/logging_instrument.py:139-140` — drop dead conjunct from `LoggingInstrument.is_ready()` (DES-5). -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py:82-86` — drop dead conjunct from `OpenTelemetryInstrument.is_ready()` (DES-5). -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py:28-29` — drop dead conjunct from `PyroscopeInstrument.is_ready()` (DES-5). -- Modify: `tests/instruments/test_sentry_instrument.py` — add parametrize case to `TestSentryEnrichEventFromStructlog::test_modify` covering the `skip_sentry=False` case. - ---- - -## Locked decisions - -- **Bundling DES-4 + DES-5:** They're independent in scope but both trivial and both touch instrument files. Reviewing them together is cheaper than two PRs. -- **DES-4 test placement:** Extend the existing `test_modify` parametrize block in `TestSentryEnrichEventFromStructlog`. Same shape as adjacent cases; no new test method. -- **DES-5 testing:** No new tests. Pure dead-code deletion. The existing test suite already exercises the `is_ready()` paths via the framework integration tests; any breakage shows up there. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/des-4-5-small-cleanups -``` - -Expected: `Switched to a new branch 'fix/des-4-5-small-cleanups'`. - -If PR3 (`fix/crit-3-idempotent-teardown`) has not yet merged, that's fine — PR4 touches different files. Branch from current `main` regardless. - ---- - -## Task 2: Add the failing regression test (DES-4) - -**File:** `tests/instruments/test_sentry_instrument.py` - -The file already has a class `TestSentryEnrichEventFromStructlog` with a parametrized `test_modify` method. Add a third case to its parametrize list that covers the `skip_sentry=False` scenario. - -- [ ] **Step 1: Add the new parametrize case** - -Current `test_modify` (around line 92 of the file) has two cases in its parametrize list. The list looks like: - -```python - @pytest.mark.parametrize( - ("event_before", "event_after"), - [ - ( - {"logentry": {"formatted": '{"event": "event name"}'}, "contexts": {}}, - {"logentry": {"formatted": "event name"}, "contexts": {}}, - ), - ( - { - "logentry": { - "formatted": '{"event": "event name", "timestamp": 1, "level": "error", "logger": "event.logger", "tracing": {}, "foo": "bar"}' # noqa: E501 - }, - "contexts": {}, - }, - { - "logentry": {"formatted": "event name"}, - "contexts": {"structlog": {"foo": "bar"}}, - }, - ), - ], - ) - def test_modify(self, event_before: "sentry_types.Event", event_after: "sentry_types.Event") -> None: - assert enrich_sentry_event_from_structlog_log(event_before, {}) == event_after -``` - -Add a third tuple to the parametrize list, after the existing two cases (preserving trailing comma in the list): - -```python - ( - { - "logentry": { - "formatted": '{"event": "event name", "skip_sentry": false, "foo": "bar"}' - }, - "contexts": {}, - }, - { - "logentry": {"formatted": "event name"}, - "contexts": {"structlog": {"foo": "bar"}}, - }, - ), -``` - -The contract: when a structlog payload contains `skip_sentry=false` (a falsy value that doesn't trigger event suppression), the resulting `contexts.structlog` should contain `{"foo": "bar"}` only — `skip_sentry` should be stripped. - -- [ ] **Step 2: Run the test and verify it FAILS** - -```bash -just test -- 'tests/instruments/test_sentry_instrument.py::TestSentryEnrichEventFromStructlog::test_modify' -v -``` - -Expected: one of the three parametrize cases (the new one) **FAILS** because the current `IGNORED_STRUCTLOG_ATTRIBUTES` set doesn't include `"skip_sentry"`. The actual `contexts.structlog` will be `{"skip_sentry": False, "foo": "bar"}`, which doesn't equal the expected `{"foo": "bar"}`. The other two cases should still PASS. - -If the new case passes, stop and investigate — either the assertion is wrong, or the bug isn't present. - ---- - -## Task 3: Implement all changes - -Five small edits across four production files. Apply them all, run tests, lint, commit. - -### Fix 1 (DES-4): Strip `skip_sentry` from Sentry context - -- [ ] **Step 1: Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES`** - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:19-21` - -Current: - -```python -IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset( - {"event", "level", "logger", "tracing", "timestamp", "exception"} -) -``` - -Replace with: - -```python -IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset( - {"event", "level", "logger", "tracing", "timestamp", "exception", "skip_sentry"} -) -``` - -### Fix 2 (DES-5): Drop dead conjunct from `SentryInstrument.is_ready()` - -- [ ] **Step 2: Simplify `SentryInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:100-101` - -Current: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) and import_checker.is_sentry_installed -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) -``` - -### Fix 3 (DES-5): Drop dead conjunct from `LoggingInstrument.is_ready()` - -- [ ] **Step 3: Simplify `LoggingInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/logging_instrument.py:139-140` - -Current: - -```python - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled and import_checker.is_structlog_installed -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled -``` - -### Fix 4 (DES-5): Drop dead conjunct from `OpenTelemetryInstrument.is_ready()` - -- [ ] **Step 4: Simplify `OpenTelemetryInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:82-86` - -Current: - -```python - def is_ready(self) -> bool: - return ( - bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) - and import_checker.is_opentelemetry_installed - ) -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) -``` - -### Fix 5 (DES-5): Drop dead conjunct from `PyroscopeInstrument.is_ready()` - -- [ ] **Step 5: Simplify `PyroscopeInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/pyroscope_instrument.py:28-29` - -Current: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) and import_checker.is_pyroscope_installed -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) -``` - -### Verify and commit - -- [ ] **Step 6: Run the previously-failing test, verify PASS** - -```bash -just test -- 'tests/instruments/test_sentry_instrument.py::TestSentryEnrichEventFromStructlog' -v -``` - -Expected: all three `test_modify` cases PASS. - -- [ ] **Step 7: Run the full test suite** - -```bash -just test -``` - -Expected: all tests PASS. The dead-conjunct deletions are provably no-ops at runtime (the `_register_or_skip` flow in `bootstrappers/base.py` checks `check_dependencies()` before any `is_ready()` call), so existing tests that exercise the missing-dependency path — e.g., `test_fastapi_bootstrapper_with_missing_instrument_dependency`, `test_litestar_bootstrapper_with_missing_instrument_dependency`, `test_free_bootstrapper_with_missing_instrument_dependency` — should still pass unchanged. If any of those fail, stop and investigate: the invariant we're relying on may not hold somewhere. - -- [ ] **Step 8: Run lint** - -```bash -just lint -``` - -Expected: no errors. The four `import_checker` references being removed leave the import statement still used elsewhere in each file (e.g., `bootstrap()` methods), so no unused-import warnings should fire. Confirm. - -If a file ends up with `from lite_bootstrap import import_checker` no longer referenced anywhere, ruff `F401` will flag it. In that case, also remove the import. Most likely candidate is `pyroscope_instrument.py` (verify by reading the file). - -Actually, all four instrument files use `import_checker` in their `check_dependencies()` method as well, so the import will remain needed. Just confirm with `just lint`. - -- [ ] **Step 9: Commit (stage exactly 5 files)** - -```bash -git add \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - tests/instruments/test_sentry_instrument.py -git commit -m "$(cat <<'EOF' -fix: strip skip_sentry from Sentry context; drop dead is_X_installed conjuncts - -DES-4: enrich_sentry_event_from_structlog_log was already returning None -(suppressing the event) when skip_sentry was truthy, but for falsy values -(False, missing, "") the flag itself was not stripped from the structlog -payload before it was attached to event["contexts"]["structlog"]. Add -"skip_sentry" to IGNORED_STRUCTLOG_ATTRIBUTES so the field never leaks -into Sentry context noise. Regression test added as a parametrize case -on the existing test_modify. - -DES-5: each affected instrument's is_ready() returned ` and -import_checker.is_X_installed`. The conjunct is provably dead: BaseBootstrapper -calls check_dependencies() in _register_or_skip before instantiating the -instrument, and only invokes is_ready() if check_dependencies() returned True. -Drop the redundant conjunct from SentryInstrument, LoggingInstrument, -OpenTelemetryInstrument, and PyroscopeInstrument. Behavior is unchanged. - -Closes DES-4 and DES-5 from the audit. -EOF -)" -``` - ---- - -## Task 4: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/des-4-5-small-cleanups -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: strip skip_sentry from Sentry context; drop dead is_X_installed conjuncts" --body "$(cat <<'EOF' -## Summary -Two small audit cleanups bundled: - -- **DES-4 (Sentry):** \`skip_sentry\` was already triggering event suppression when truthy, but for falsy values (False/missing/"") the flag itself wasn't stripped from the structlog payload and ended up as noise in \`event["contexts"]["structlog"]\`. Add \`"skip_sentry"\` to \`IGNORED_STRUCTLOG_ATTRIBUTES\`. Regression test added as a parametrize case on the existing \`test_modify\`. -- **DES-5 (dead conjuncts):** Four instruments' \`is_ready()\` methods ended with \`and import_checker.is_X_installed\`. That conjunct is provably unreachable — \`BaseBootstrapper._register_or_skip\` calls \`check_dependencies()\` first and only invokes \`is_ready()\` if it returned True. Behavior is unchanged. Cleanup makes the lifecycle easier to reason about. - -Closes DES-4 and DES-5 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_sentry_instrument.py -v\` — pass. -- [x] \`just test\` — full suite passes. -- [x] \`just lint\` — clean (no unused-import warnings from the conjunct removals). -- [ ] Reviewer: confirm the invariant claim — that \`is_ready()\` is only called after \`check_dependencies()\` has returned True — by reading \`bootstrappers/base.py:44-64\`. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR4 section) and audit (DES-4, DES-5): - -| Spec item | Task | -|-----------|------| -| Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` | Task 3, Step 1 | -| Regression test asserting `skip_sentry` doesn't appear in context | Task 2, Step 1 | -| Delete dead conjunct from `SentryInstrument.is_ready()` | Task 3, Step 2 | -| Delete dead conjunct from `LoggingInstrument.is_ready()` | Task 3, Step 3 | -| Delete dead conjunct from `OpenTelemetryInstrument.is_ready()` | Task 3, Step 4 | -| Delete dead conjunct from `PyroscopeInstrument.is_ready()` | Task 3, Step 5 | -| Branch name `fix/des-4-5-small-cleanups` | Task 1, Step 1 | -| Verification: `just test` + `just lint` pass | Task 3, Steps 7-8 | - -All spec items covered. No placeholders. Parametrize-case shape matches adjacent cases byte-for-byte except for the payload values. - -**Deferred:** -- Documenting the lifecycle invariant on `BaseInstrument` (mentioned as "optional" in the sequencing spec) — skip for now to keep the PR focused. Worth noting somewhere later (a `CONTRIBUTING.md`, or class docstrings as part of REF-3). - - ---- - -# PR5: Document and Pin `BaseConfig.from_dict` / `from_object` Semantics - -> **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:** Document the intentional asymmetry between `BaseConfig.from_dict` and `BaseConfig.from_object` (the audit's DES-3 finding) and pin it with regression tests. No behavior change. The current "skip None" behavior in `from_object` is preserved (locked decision from the sequencing spec). - -**Architecture:** Pure documentation + test PR. Add one-line docstrings to the two classmethods explaining their semantics; add four pinning tests in `tests/test_config.py` that lock in the current contract. No TDD red→green here — the tests pass today; their value is preventing future regressions that "unify" the methods without realizing the asymmetry is intentional. - -**Tech Stack:** Python 3.10+ dataclasses, pytest. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR5 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-3, TEST-5, TEST-6). - ---- - -## File Structure - -Two files modified. - -- Modify: `lite_bootstrap/instruments/base.py:16-29` — add one-line docstrings to `from_dict` and `from_object`. -- Modify: `tests/test_config.py` — add four pinning tests. - ---- - -## Locked decisions (from sequencing spec) - -- **`from_object` semantics:** Keep current "skip None" behavior. Document it. Pin with tests. Minimal change; preserves any user code that depends on it. -- **No TDD:** This PR documents and pins existing behavior. The new tests are pinning tests, not TDD red→green tests. They will pass before and after the docstring additions. Their value is preventing future regressions, not driving a bug fix. -- **Docstring length:** One short line each. Project style is terse (no docstrings on most code; one-line docstrings on the exception classes). Multi-paragraph docstrings would be inconsistent. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/des-3-config-method-semantics -``` - -Expected: `Switched to a new branch 'fix/des-3-config-method-semantics'`. - -If PR4 has not yet merged, that's fine — PR5 touches different files. - ---- - -## Task 2: Add docstrings and pinning tests, verify, commit - -### Step 1: Add docstrings to `BaseConfig.from_dict` and `from_object` - -**File:** `lite_bootstrap/instruments/base.py:16-29` - -Current code: - -```python - @classmethod - def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: - field_names = {f.name for f in dataclasses.fields(cls)} - return cls(**{k: v for k, v in data.items() if k in field_names}) - - @classmethod - def from_object(cls, obj: object) -> typing_extensions.Self: - prepared_data = {} - field_names = {f.name for f in dataclasses.fields(cls)} - - for field in field_names: - if (value := getattr(obj, field, None)) is not None: - prepared_data[field] = value - return cls(**prepared_data) -``` - -Replace with: - -```python - @classmethod - def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: - """Build a config from a dict; unknown keys are silently dropped, explicit None overrides defaults.""" - field_names = {f.name for f in dataclasses.fields(cls)} - return cls(**{k: v for k, v in data.items() if k in field_names}) - - @classmethod - def from_object(cls, obj: object) -> typing_extensions.Self: - """Build a config by merging non-None attributes from obj; None or missing attributes fall back to defaults.""" - field_names = {f.name for f in dataclasses.fields(cls)} - prepared_data = {field: value for field in field_names if (value := getattr(obj, field, None)) is not None} - return cls(**prepared_data) -``` - -Notes: -- Two docstring additions. -- The body of `from_object` is also condensed from a 5-line imperative form to a single comprehension. **Functionally identical.** The walrus-operator-inside-comprehension form is a more idiomatic match for the "filter non-None" intent and matches the dict-comprehension already used by `from_dict`. The condensation is a quality cleanup; verify behavior with the new pinning tests. - -If the reviewer pushes back on the body condensation, the alternative is to leave the body as-is and only add the docstring. The docstring is the spec-required change; the body cleanup is opportunistic. - -### Step 2: Add four pinning tests to `tests/test_config.py` - -**File:** `tests/test_config.py` - -The file currently has two tests (`test_config_from_dict`, `test_config_from_object`). Append the four new tests at the end of the file. - -Current top of file: - -```python -import dataclasses - -from lite_bootstrap import FastAPIConfig -from lite_bootstrap.instruments.base import BaseConfig -from tests.conftest import CustomInstrumentor -``` - -No new imports needed. - -Append at the end of the file: - -```python -def test_from_object_skips_none_attribute() -> None: - @dataclasses.dataclass - class Source: - service_name: str | None = None - service_version: str = "2.0.0" - - config = BaseConfig.from_object(Source()) - assert config.service_name == "micro-service" - assert config.service_version == "2.0.0" - - -def test_from_object_skips_missing_attribute() -> None: - class Source: - pass - - config = BaseConfig.from_object(Source()) - assert config.service_name == "micro-service" - assert config.service_version == "1.0.0" - assert config.service_debug is True - - -def test_from_object_preserves_falsy_values() -> None: - @dataclasses.dataclass - class Source: - service_name: str = "" - service_debug: bool = False - - config = BaseConfig.from_object(Source()) - assert config.service_name == "" - assert config.service_debug is False - - -def test_from_dict_drops_unknown_keys_silently() -> None: - config = BaseConfig.from_dict({"service_name": "test", "unknown_key": "value"}) - assert config.service_name == "test" - assert config.service_version == "1.0.0" -``` - -Contracts pinned: -- `test_from_object_skips_none_attribute` — explicit `None` attribute on source falls back to dataclass default. -- `test_from_object_skips_missing_attribute` — missing attribute on source falls back to dataclass default. -- `test_from_object_preserves_falsy_values` — empty string and `False` are not stripped (they're not `None`). -- `test_from_dict_drops_unknown_keys_silently` — unknown keys don't raise; known keys are honored. - -### Step 3: Run the new tests, verify PASS - -These tests pin existing behavior; they should pass before and after the docstring additions. - -```bash -just test -- tests/test_config.py -v -``` - -Expected: all six tests in `tests/test_config.py` PASS (two pre-existing + four new). - -If any of the four new tests fails, stop and investigate — the audit's claim about `from_object` behavior may be inaccurate, or the docstring body condensation may have introduced a regression. - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: all tests PASS. Total should be 88 (84 prior + 4 new). - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: no errors. The dict-comprehension form may trigger ruff's preference for one style or another — confirm. If ruff auto-formats the comprehension, accept the formatting and re-stage. - -### Step 6: Commit - -Stage both modified files explicitly: - -```bash -git add lite_bootstrap/instruments/base.py tests/test_config.py -git commit -m "$(cat <<'EOF' -docs: document and pin BaseConfig.from_dict / from_object semantics - -The two builder classmethods on BaseConfig have intentionally asymmetric -semantics that aren't obvious from reading the code: - -- from_dict includes any key present in the dict (explicit None overrides - the default); unknown keys are silently dropped. -- from_object includes only attributes whose value is not None; attributes - set to None or missing entirely fall back to the dataclass default. - Falsy non-None values (False, "", []) are preserved. - -Add one-line docstrings capturing each method's contract. Condense the -from_object body to a single dict-comprehension matching from_dict's style; -behavior is identical (verified by the new pinning tests). - -Add four pinning tests on top of the two pre-existing tests: -- test_from_object_skips_none_attribute -- test_from_object_skips_missing_attribute -- test_from_object_preserves_falsy_values -- test_from_dict_drops_unknown_keys_silently - -Closes DES-3, TEST-5, TEST-6 from the audit. -EOF -)" -``` - -Expected: commit succeeds. - ---- - -## Task 3: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/des-3-config-method-semantics -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "docs: document and pin BaseConfig.from_dict / from_object semantics" --body "$(cat <<'EOF' -## Summary -Document the intentional asymmetry between \`BaseConfig.from_dict\` and \`BaseConfig.from_object\` (DES-3 from an internal audit): - -- \`from_dict\` includes any key present in the dict (explicit \`None\` overrides defaults); unknown keys are silently dropped. -- \`from_object\` includes only non-\`None\` attributes (\`None\` or missing falls back to dataclass defaults); falsy non-\`None\` values are preserved. - -Each method gains a one-line docstring capturing its contract. The \`from_object\` body is condensed to a single dict-comprehension matching \`from_dict\`'s style; behavior is identical and locked in by the new pinning tests. - -Four pinning tests added (TEST-5, TEST-6 from the audit): -- \`test_from_object_skips_none_attribute\` -- \`test_from_object_skips_missing_attribute\` -- \`test_from_object_preserves_falsy_values\` -- \`test_from_dict_drops_unknown_keys_silently\` - -These pass before and after the docstring additions — they pin existing behavior to prevent future regressions where someone "unifies" the two methods without realizing the asymmetry is intentional. - -Closes DES-3, TEST-5, TEST-6 from an internal audit. - -## Test plan -- [x] \`just test -- tests/test_config.py -v\` — six tests pass. -- [x] \`just test\` — full suite passes (88 expected). -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the \`from_object\` body condensation (5 lines → 1 dict-comprehension) is functionally identical. If you'd rather see the docstring change land without the body cleanup, request a revert. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR5 section) and audit (DES-3, TEST-5, TEST-6): - -| Spec item | Task | -|-----------|------| -| Docstring on `from_dict` describing semantics | Task 2, Step 1 | -| Docstring on `from_object` describing semantics | Task 2, Step 1 | -| Test: `from_object` with `None` attribute falls back to default | Task 2, Step 2 (test_from_object_skips_none_attribute) | -| Test: `from_object` with missing attribute falls back to default | Task 2, Step 2 (test_from_object_skips_missing_attribute) | -| Test: `from_object` preserves falsy non-None | Task 2, Step 2 (test_from_object_preserves_falsy_values) | -| Test: `from_dict` drops unknown keys silently | Task 2, Step 2 (test_from_dict_drops_unknown_keys_silently) | -| Branch name `fix/des-3-config-method-semantics` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | - -All spec items covered. No placeholders. Test names and contracts are consistent with the audit's TEST-5 and TEST-6 descriptions. - -**Caveats noted in PR description:** -- Body condensation in `from_object` is an opportunistic cleanup, not spec-required. If the reviewer prefers a docstring-only change, the body cleanup can be reverted with a one-character edit. - - ---- - -# PR6: Extract OpenTelemetry Service Fields Mixin - -> **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:** Extract `opentelemetry_service_name` and `opentelemetry_namespace` into a shared mixin dataclass so both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it instead of duplicating the field declarations (the audit's DES-2 finding). Today the two configs declare these fields identically; in the framework configs they survive only because Python's MRO picks one and the defaults happen to match. The mixin makes the shared identity explicit. - -**Architecture:** Pure refactor PR. New tiny dataclass `OpenTelemetryServiceFieldsConfig(BaseConfig)` in `opentelemetry_instrument.py`. Both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it instead of `BaseConfig`. The two duplicate field declarations are removed. No behavior change; existing tests verify MRO continues to resolve correctly across the four framework configs (`FreeBootstrapperConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`) that inherit from both. - -**Tech Stack:** Python 3.10+ dataclasses with `kw_only=True, frozen=True`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR6 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-2). - ---- - -## File Structure - -Two files modified. No new files. - -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — declare `OpenTelemetryServiceFieldsConfig` mixin before `OpentelemetryConfig`; change `OpentelemetryConfig` to inherit from the mixin; remove the two duplicate field declarations. -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py` — add an import for `OpenTelemetryServiceFieldsConfig`; change `PyroscopeConfig` to inherit from the mixin; remove the two duplicate field declarations. - ---- - -## Locked decisions (from sequencing spec) - -- **Mixin location:** Inline in `opentelemetry_instrument.py`. Fewer files; the mixin is small; `pyroscope_instrument` already imports otel-adjacent symbols (via the SpanProcessor integration in the OTel module). -- **Mixin name:** `OpenTelemetryServiceFieldsConfig`. -- **No new tests:** This is a pure refactor. Existing tests — particularly `test_pyroscope_standalone_config_accepts_otel_fields` in `tests/instruments/test_pyroscope_instrument.py` — already exercise the inheritance path. If those pass, MRO is still working. - ---- - -## Cross-module dependency note - -After this PR, `pyroscope_instrument.py` will import `OpenTelemetryServiceFieldsConfig` from `opentelemetry_instrument.py`. This is a new module-level dependency direction (pyroscope → opentelemetry). Verify there's no circular import: - -- `opentelemetry_instrument.py` imports the `pyroscope` *package* (external) inside an `if import_checker.is_pyroscope_installed:` guard. It does NOT import `lite_bootstrap.instruments.pyroscope_instrument`. -- After PR6, `pyroscope_instrument.py` will import from `lite_bootstrap.instruments.opentelemetry_instrument`. No cycle. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/des-2-otel-fields-mixin -``` - -Expected: `Switched to a new branch 'fix/des-2-otel-fields-mixin'`. - ---- - -## Task 2: Apply the refactor, verify, commit - -### Step 1: Add the mixin to `opentelemetry_instrument.py` and update `OpentelemetryConfig` - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Current `OpentelemetryConfig` (around lines 35-49): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpentelemetryConfig(BaseConfig): - opentelemetry_service_name: str | None = None - opentelemetry_container_name: str | None = dataclasses.field( - default_factory=lambda: os.environ.get("HOSTNAME") or None - ) - opentelemetry_endpoint: str | None = None - opentelemetry_namespace: str | None = None - opentelemetry_insecure: bool = True - opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field( - default_factory=list - ) - opentelemetry_log_traces: bool = False - opentelemetry_generate_health_check_spans: bool = True -``` - -Replace with (add the mixin class **before** `OpentelemetryConfig`, then change `OpentelemetryConfig` to inherit from it and remove the two duplicate field declarations): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpenTelemetryServiceFieldsConfig(BaseConfig): - opentelemetry_service_name: str | None = None - opentelemetry_namespace: str | None = None - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpentelemetryConfig(OpenTelemetryServiceFieldsConfig): - opentelemetry_container_name: str | None = dataclasses.field( - default_factory=lambda: os.environ.get("HOSTNAME") or None - ) - opentelemetry_endpoint: str | None = None - opentelemetry_insecure: bool = True - opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field( - default_factory=list - ) - opentelemetry_log_traces: bool = False - opentelemetry_generate_health_check_spans: bool = True -``` - -Two changes: -1. New `OpenTelemetryServiceFieldsConfig` dataclass declared above `OpentelemetryConfig` with `opentelemetry_service_name` and `opentelemetry_namespace`. -2. `OpentelemetryConfig` parent changed from `BaseConfig` to `OpenTelemetryServiceFieldsConfig`; the two fields it used to declare are removed. - -### Step 2: Update `PyroscopeConfig` in `pyroscope_instrument.py` - -**File:** `lite_bootstrap/instruments/pyroscope_instrument.py` - -Current top of file: - -```python -import dataclasses -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -if import_checker.is_pyroscope_installed: - import pyroscope -``` - -Replace with (add `OpenTelemetryServiceFieldsConfig` import; drop the now-unused `BaseConfig` import): - -```python -import dataclasses -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.instruments.base import BaseInstrument -from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryServiceFieldsConfig - - -if import_checker.is_pyroscope_installed: - import pyroscope -``` - -**Verify before staging:** is `BaseConfig` still used elsewhere in this file? Search with `grep "BaseConfig" lite_bootstrap/instruments/pyroscope_instrument.py`. If the only use was in the `PyroscopeConfig` parent (which is being changed to `OpenTelemetryServiceFieldsConfig`), drop the import. If it's used elsewhere, keep it. - -Based on the current file structure, `BaseConfig` is only used as the `PyroscopeConfig` parent — drop the import. `just lint` will catch any mistake (F401 unused import or F821 undefined name). - -Current `PyroscopeConfig` (around lines 12-19): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class PyroscopeConfig(BaseConfig): - pyroscope_endpoint: str | None = None - pyroscope_sample_rate: int = 100 - pyroscope_tags: dict[str, str] = dataclasses.field(default_factory=dict) - pyroscope_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - opentelemetry_service_name: str | None = None - opentelemetry_namespace: str | None = None -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class PyroscopeConfig(OpenTelemetryServiceFieldsConfig): - pyroscope_endpoint: str | None = None - pyroscope_sample_rate: int = 100 - pyroscope_tags: dict[str, str] = dataclasses.field(default_factory=dict) - pyroscope_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) -``` - -Two changes: -1. Parent changed from `BaseConfig` to `OpenTelemetryServiceFieldsConfig`. -2. The two duplicate field declarations (`opentelemetry_service_name`, `opentelemetry_namespace`) removed. - -### Step 3: Run the OTel + Pyroscope test files - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_pyroscope_instrument.py -v -``` - -Expected: all tests PASS. Watch specifically for: - -- `test_pyroscope_standalone_config_accepts_otel_fields` — this is THE key test for the mixin's correctness. It constructs `PyroscopeConfig(service_name="fallback", pyroscope_endpoint=..., opentelemetry_service_name="otel-name", opentelemetry_namespace="my-ns")`. If MRO breaks, this test fails first. -- `test_pyroscope_bootstrap_uses_opentelemetry_service_name` and `test_pyroscope_bootstrap_merges_namespace_tag` — these exercise the shared fields via `FreeBootstrapperConfig`. - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: all tests PASS (89 total). The framework configs all inherit from both `OpentelemetryConfig` and `PyroscopeConfig`; with the mixin, Python's MRO resolves the shared fields once via diamond inheritance. If any framework config test fails (e.g., `test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap`, `test_free_bootstrap`), STOP and investigate — MRO interaction is the most likely cause. - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: no errors. Watch for: - -- F401 unused import warnings on the `BaseConfig` import in `pyroscope_instrument.py` (should be already removed per Step 2). -- F401 unused import warnings on the `OpenTelemetryServiceFieldsConfig` import (should be referenced in `class PyroscopeConfig(...)`). -- `ty check` should be clean — the inheritance change is type-correct. - -### Step 6: Commit - -Stage both modified files: - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py lite_bootstrap/instruments/pyroscope_instrument.py -git commit -m "$(cat <<'EOF' -refactor: extract OpenTelemetryServiceFieldsConfig mixin - -opentelemetry_service_name and opentelemetry_namespace were declared -identically on both OpentelemetryConfig and PyroscopeConfig. In the four -framework configs (Free, FastAPI, Litestar, FastStream) that inherit -from both parents, Python's MRO happened to pick one declaration; the -fact that defaults matched is what kept behavior consistent. Without -the mixin, drifting defaults on one side would silently misbehave on -the framework configs. - -Extract OpenTelemetryServiceFieldsConfig(BaseConfig) — a tiny mixin -declaring just those two fields. Both OpentelemetryConfig and -PyroscopeConfig now inherit from it (no longer from BaseConfig -directly). The duplicate declarations are removed. - -PyroscopeConfig's standalone use case (without OpentelemetryConfig in -the MRO) is preserved — exercised by -test_pyroscope_standalone_config_accepts_otel_fields. - -Closes DES-2 from the audit. -EOF -)" -``` - -Expected: commit succeeds. - ---- - -## Task 3: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/des-2-otel-fields-mixin -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: extract OpenTelemetryServiceFieldsConfig mixin" --body "$(cat <<'EOF' -## Summary -\`opentelemetry_service_name\` and \`opentelemetry_namespace\` were declared identically on both \`OpentelemetryConfig\` and \`PyroscopeConfig\`. In the four framework configs (Free, FastAPI, Litestar, FastStream) that inherit from both parents, Python's MRO happened to pick one declaration; the fact that defaults matched is what kept behavior consistent. Without the mixin, drifting defaults on one side would silently misbehave on the framework configs. - -Extract \`OpenTelemetryServiceFieldsConfig(BaseConfig)\` — a tiny mixin declaring just those two fields. Both \`OpentelemetryConfig\` and \`PyroscopeConfig\` now inherit from it (no longer from \`BaseConfig\` directly). The duplicate declarations are removed. - -No behavior change. Existing tests verify MRO continues to resolve correctly, especially \`test_pyroscope_standalone_config_accepts_otel_fields\` (the key test for the mixin's correctness) and the framework-level integration tests. - -Closes DES-2 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_pyroscope_instrument.py -v\` — pass. -- [x] \`just test\` — full suite 89/89. -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the new dependency direction (\`pyroscope_instrument\` → \`opentelemetry_instrument\`) is acceptable and doesn't introduce a circular import (it doesn't — \`opentelemetry_instrument\` imports the \`pyroscope\` package, not \`pyroscope_instrument\`). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR6 section) and audit (DES-2): - -| Spec item | Task | -|-----------|------| -| Declare `OpenTelemetryServiceFieldsConfig(BaseConfig)` mixin with the two fields | Task 2, Step 1 | -| Mixin inlined in `opentelemetry_instrument.py` | Task 2, Step 1 | -| `OpentelemetryConfig` inherits from mixin; duplicate fields removed | Task 2, Step 1 | -| `PyroscopeConfig` inherits from mixin (via import); duplicate fields removed | Task 2, Step 2 | -| Verify MRO still works via existing tests (especially `test_pyroscope_standalone_config_accepts_otel_fields`) | Task 2, Steps 3-4 | -| Branch name `fix/des-2-otel-fields-mixin` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | - -All spec items covered. No placeholders. The mixin's name and the import path in `pyroscope_instrument.py` are consistent. - -**Deferred:** -- Renaming `OpentelemetryConfig` to `OpenTelemetryConfig` (capital `T`) for capitalization consistency with the new mixin name — out of scope; would be a separate naming-cleanup PR with deprecation aliases. - - ---- - -# PR7: Make `BaseInstrument` Generic; Delete Pure-Annotation Subclasses - -> **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:** Close the audit's DES-1 finding by deleting the four pure type-annotation framework subclasses that exist solely to narrow `bootstrap_config`. Make `BaseInstrument` generic in its config type so the base instruments can be parameterized (`LoggingInstrument(BaseInstrument[LoggingConfig])`); framework subclasses with real behavior keep their existing structure. - -**Architecture revision from the sequencing spec:** The locked decision in the sequencing spec called for "dropping redundant `bootstrap_config:` annotations on kept subclasses." Working through the Python typing implications, this would require a **two-level generic** pattern (each base instrument carries its own bounded `TypeVar` so framework subclasses can re-parameterize). The complexity isn't worth it — the annotations on framework subclasses with real `bootstrap()` overrides are **not** redundant under a single-level-generic design; they provide essential type narrowing for framework-specific field access (e.g., `self.bootstrap_config.application` inside `FastAPICorsInstrument.bootstrap()`). - -This plan implements the **simpler single-level-generic approach**: `BaseInstrument` is generic; each base instrument is concretely parameterized; framework subclasses with real bootstrap code keep their `bootstrap_config: FrameworkConfig` annotations. The deletion target remains the same — only the four pure-annotation subclasses go. This delivers the audit's stated goal (eliminating boilerplate) without the two-level-generic complexity. - -**Tech Stack:** Python 3.10+ generics (`typing.TypeVar`, `typing.Generic`), frozen dataclasses with `slots=True`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR7 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-1). - ---- - -## File Structure - -12 files modified. - -**Instruments (9 files — make each base instrument concretely parameterize the new generic `BaseInstrument`):** -- Modify: `lite_bootstrap/instruments/base.py` — `BaseInstrument` becomes generic `[ConfigT]`. -- Modify: `lite_bootstrap/instruments/cors_instrument.py` — `CorsInstrument(BaseInstrument[CorsConfig])`. -- Modify: `lite_bootstrap/instruments/healthchecks_instrument.py` — `HealthChecksInstrument(BaseInstrument[HealthChecksConfig])`. -- Modify: `lite_bootstrap/instruments/logging_instrument.py` — `LoggingInstrument(BaseInstrument[LoggingConfig])`. -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — `OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig])`. -- Modify: `lite_bootstrap/instruments/prometheus_instrument.py` — `PrometheusInstrument(BaseInstrument[PrometheusConfig])`. -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py` — `PyroscopeInstrument(BaseInstrument[PyroscopeConfig])`. -- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — `SentryInstrument(BaseInstrument[SentryConfig])`. -- Modify: `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument(BaseInstrument[SwaggerConfig])`. - -In each of the above, the existing `bootstrap_config: ` field declaration is **removed** — it's now provided by the generic parent. - -**Bootstrappers (3 files — delete the 4 pure-annotation subclasses, update `instruments_types` lists to reference base names):** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — delete `FastAPILoggingInstrument` and `FastAPISentryInstrument`; replace their entries in `instruments_types` with `LoggingInstrument` / `SentryInstrument`. -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — delete `LitestarSentryInstrument`; replace entry with `SentryInstrument`. -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — delete `FastStreamSentryInstrument`; replace entry with `SentryInstrument`. - -The `FreeBootstrapper` already uses the base instrument names directly — no changes needed there. - -**Framework subclasses with real bootstrap code stay AS-IS** (their `bootstrap_config: ` annotations are still useful for type narrowing). The list (15 subclasses kept): - -- FastAPI: `FastAPICorsInstrument`, `FastAPIHealthChecksInstrument`, `FastAPIOpenTelemetryInstrument`, `FastAPIPrometheusInstrument`, `FastAPISwaggerInstrument`. -- Litestar: `LitestarCorsInstrument`, `LitestarHealthChecksInstrument`, `LitestarLoggingInstrument`, `LitestarOpenTelemetryInstrument`, `LitestarPrometheusInstrument`, `LitestarSwaggerInstrument`. -- FastStream: `FastStreamHealthChecksInstrument`, `FastStreamLoggingInstrument`, `FastStreamOpenTelemetryInstrument`, `FastStreamPrometheusInstrument`. - ---- - -## Locked decisions (revised from sequencing spec) - -- **Single-level generic:** `BaseInstrument` is generic; base instruments concretely parameterize. **Revised** from the sequencing spec's two-level approach. -- **Kept framework subclasses retain `bootstrap_config: ` annotations.** **Revised** from "drop redundant annotations." Under single-level-generic, these are not redundant — they provide essential type narrowing for framework field access. -- **Deletions unchanged:** the four pure-annotation subclasses (`FastAPILoggingInstrument`, `FastAPISentryInstrument`, `LitestarSentryInstrument`, `FastStreamSentryInstrument`) still get deleted. -- **Pyroscope handling:** `PyroscopeInstrument` is used as-is across all bootstrappers (no framework subclass). It still gets parameterized as `PyroscopeInstrument(BaseInstrument[PyroscopeConfig])` for symmetry. -- **No new tests.** This is a behavior-preserving refactor; existing tests verify correctness. - ---- - -## Cross-cutting concerns to verify during implementation - -1. **Generic + slots + frozen dataclass interaction.** Python 3.10 has historically had subtle issues with `@dataclasses.dataclass(slots=True)` combined with `typing.Generic`. If `just test` or `just lint` (ty check) surfaces errors related to slot conflicts or generic metaclass issues on `BaseInstrument`, fall back to dropping `slots=True` from `BaseInstrument` only. Document the change. - -2. **Field declaration removal.** When we remove `bootstrap_config: ` from each base instrument's body, the dataclass machinery should inherit the parent's `bootstrap_config: ConfigT` declaration. Verify by running `just test` after each instrument file change — any broken instantiation will surface immediately. - -3. **`instruments_types` ClassVar typing.** `BaseBootstrapper.instruments_types: typing.ClassVar[list[type[BaseInstrument]]]` — after `BaseInstrument` becomes generic, this becomes `list[type[BaseInstrument[Any]]]` semantically. Should still work because `type[BaseInstrument]` accepts subclasses regardless of parameterization. If `ty check` complains, may need to adjust the annotation. - -4. **`abc.ABC` + `typing.Generic`.** `BaseInstrument(abc.ABC, typing.Generic[ConfigT])` requires the MRO to be: BaseInstrument → ABC → Generic → object. Both `abc.ABC` and `typing.Generic` are designed to work together, but verify the order doesn't break dataclass machinery. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/des-1-generic-instruments -``` - -Expected: `Switched to a new branch 'refactor/des-1-generic-instruments'`. - -This is the only PR in the sequence that uses the `refactor/` branch prefix (not `fix/`), because it's the only one that's a pure refactor with no fix component. - ---- - -## Task 2: Make `BaseInstrument` generic - -**File:** `lite_bootstrap/instruments/base.py` - -- [ ] **Step 1: Add the `ConfigT` TypeVar and parameterize `BaseInstrument`** - -Current (lines 1-46): - -```python -import abc -import dataclasses -import typing - -import typing_extensions - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseConfig: - ... - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(abc.ABC): - bootstrap_config: BaseConfig - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... # noqa: B027 - - def teardown(self) -> None: ... # noqa: B027 - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Replace the `BaseInstrument` block (lines 30-45) with: - -```python -ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... # noqa: B027 - - def teardown(self) -> None: ... # noqa: B027 - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Two changes: -1. Add `ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig)` between the two dataclasses. -2. `BaseInstrument` now inherits from `abc.ABC, typing.Generic[ConfigT]`; the `bootstrap_config` field type changes from `BaseConfig` to `ConfigT`. - -- [ ] **Step 2: Quick smoke check** - -```bash -just test -- tests/test_free_bootstrap.py -v -``` - -Expected: PASS. The `FreeBootstrapper` uses the base instruments directly with `FreeBootstrapperConfig`; if generic+slots+frozen has an issue, this test surfaces it first. - -If this test fails with a slots/generic-related error, fall back to dropping `slots=True` from `BaseInstrument`: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): - ... -``` - -Re-run the test. Note the change in the eventual commit message if so. - ---- - -## Task 3: Parameterize each base instrument - -For each instrument file, change the class signature from `class XInstrument(BaseInstrument):` to `class XInstrument(BaseInstrument[XConfig]):` and remove the redundant `bootstrap_config: XConfig` field declaration from the class body. - -### Step 1: `lite_bootstrap/instruments/cors_instrument.py` - -Current (around lines 17-25): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class CorsInstrument(BaseInstrument): - bootstrap_config: CorsConfig - not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.cors_allowed_origins) or bool( - self.bootstrap_config.cors_allowed_origin_regex, - ) -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class CorsInstrument(BaseInstrument[CorsConfig]): - not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.cors_allowed_origins) or bool( - self.bootstrap_config.cors_allowed_origin_regex, - ) -``` - -Single change: parameterize the base, drop the redundant `bootstrap_config: CorsConfig` field. - -### Step 2: `lite_bootstrap/instruments/healthchecks_instrument.py` - -Current (around lines 29-38): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class HealthChecksInstrument(BaseInstrument): - bootstrap_config: HealthChecksConfig - not_ready_message = "health_checks_enabled is False" - - def is_ready(self) -> bool: - return self.bootstrap_config.health_checks_enabled - - def render_health_check_data(self) -> HealthCheckTypedDict: - return self.bootstrap_config.health_check_data -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class HealthChecksInstrument(BaseInstrument[HealthChecksConfig]): - not_ready_message = "health_checks_enabled is False" - - def is_ready(self) -> bool: - return self.bootstrap_config.health_checks_enabled - - def render_health_check_data(self) -> HealthCheckTypedDict: - return self.bootstrap_config.health_check_data -``` - -### Step 3: `lite_bootstrap/instruments/logging_instrument.py` - -Current (around lines 117-145): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class LoggingInstrument(BaseInstrument): - bootstrap_config: LoggingConfig - not_ready_message = "logging_enabled is False" - missing_dependency_message = "structlog is not installed" - _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Change only the class signature and remove the `bootstrap_config: LoggingConfig` line: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class LoggingInstrument(BaseInstrument[LoggingConfig]): - not_ready_message = "logging_enabled is False" - missing_dependency_message = "structlog is not installed" - _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Everything below the field declarations stays unchanged. - -### Step 4: `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Current (around lines 80-90): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument): - bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Replace the class signature and drop `bootstrap_config: OpentelemetryConfig`: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Rest of the class unchanged. - -### Step 5: `lite_bootstrap/instruments/prometheus_instrument.py` - -Current (around lines 13-21): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PrometheusInstrument(BaseInstrument): - bootstrap_config: PrometheusConfig - not_ready_message = "prometheus_metrics_path is empty or not valid" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PrometheusInstrument(BaseInstrument[PrometheusConfig]): - not_ready_message = "prometheus_metrics_path is empty or not valid" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -### Step 6: `lite_bootstrap/instruments/pyroscope_instrument.py` - -Current (around lines 21-46, after PR6's mixin landed): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PyroscopeInstrument(BaseInstrument): - bootstrap_config: PyroscopeConfig - not_ready_message = "pyroscope_endpoint is empty" - missing_dependency_message = "pyroscope is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) - ... -``` - -Replace class signature and drop the field: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PyroscopeInstrument(BaseInstrument[PyroscopeConfig]): - not_ready_message = "pyroscope_endpoint is empty" - missing_dependency_message = "pyroscope is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) - ... -``` - -Rest unchanged. - -### Step 7: `lite_bootstrap/instruments/sentry_instrument.py` - -Current (around lines 94-105): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SentryInstrument(BaseInstrument): - bootstrap_config: SentryConfig - not_ready_message = "sentry_dsn is empty" - missing_dependency_message = "sentry_sdk is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) - ... -``` - -Replace: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SentryInstrument(BaseInstrument[SentryConfig]): - not_ready_message = "sentry_dsn is empty" - missing_dependency_message = "sentry_sdk is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) - ... -``` - -### Step 8: `lite_bootstrap/instruments/swagger_instrument.py` - -Current (around lines 13-16): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument): - bootstrap_config: SwaggerConfig -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument[SwaggerConfig]): - pass -``` - -The body becomes empty — replace with `pass`. This is the smallest instrument class. - -### Step 9: Intermediate verification - -After all nine instrument files are updated: - -```bash -just test -- tests/instruments/ -v -just test -- tests/test_free_bootstrap.py -v -``` - -Expected: all pass. The instrument-level tests + the free-bootstrapper test exercise every base instrument directly. If any fail, debug before moving to Task 4. - ---- - -## Task 4: Delete the four pure-annotation framework subclasses - -### Step 1: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` - -Locate `FastAPILoggingInstrument` (around lines 111-113): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPILoggingInstrument(LoggingInstrument): - bootstrap_config: FastAPIConfig -``` - -Delete the entire block. - -Locate `FastAPISentryInstrument` (around lines 141-143): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPISentryInstrument(SentryInstrument): - bootstrap_config: FastAPIConfig -``` - -Delete the entire block. - -In `FastAPIBootstrapper.instruments_types`: - -```python - instruments_types: typing.ClassVar = [ - FastAPICorsInstrument, - FastAPIOpenTelemetryInstrument, - PyroscopeInstrument, - FastAPISentryInstrument, # ← replace with SentryInstrument - FastAPIHealthChecksInstrument, - FastAPILoggingInstrument, # ← replace with LoggingInstrument - FastAPIPrometheusInstrument, - FastAPISwaggerInstrument, - ] -``` - -Replace `FastAPISentryInstrument` with `SentryInstrument` and `FastAPILoggingInstrument` with `LoggingInstrument`. - -### Step 2: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` - -Locate `LitestarSentryInstrument` (around lines 199-201): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class LitestarSentryInstrument(SentryInstrument): - bootstrap_config: LitestarConfig -``` - -Delete the entire block. - -In `LitestarBootstrapper.instruments_types`, replace `LitestarSentryInstrument` with `SentryInstrument`. - -### Step 3: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` - -Locate `FastStreamSentryInstrument` (around lines 135-137): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastStreamSentryInstrument(SentryInstrument): - bootstrap_config: FastStreamConfig -``` - -Delete the entire block. - -In `FastStreamBootstrapper.instruments_types`, replace `FastStreamSentryInstrument` with `SentryInstrument`. - -### Step 4: Verify imports - -After the deletions, the framework bootstrappers no longer reference the deleted classes by name. Any imports of `FastAPILoggingInstrument` / `FastAPISentryInstrument` / `LitestarSentryInstrument` / `FastStreamSentryInstrument` from other test files or modules would break. - -Run a sanity grep: - -```bash -grep -rn "FastAPILoggingInstrument\|FastAPISentryInstrument\|LitestarSentryInstrument\|FastStreamSentryInstrument" . -``` - -Expected: only matches in the plan/spec docs (which describe the deletion). Zero matches in `lite_bootstrap/` or `tests/`. If any test file imports a deleted class, update it to use the base class name. - ---- - -## Task 5: Verify and commit - -- [ ] **Step 1: Run the full test suite** - -```bash -just test -``` - -Expected: 89/89 pass. No behavior change should result from this refactor. - -If any framework integration test (`test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap`) fails, the most likely cause is type-narrowing surprise on a framework subclass that needs to access framework-specific config fields. Verify the kept framework subclasses still declare `bootstrap_config: ` where they override `bootstrap()`. - -- [ ] **Step 2: Run lint** - -```bash -just lint -``` - -Expected: clean. Watch for: - -- F401 unused-import warnings — possible if a bootstrapper imports something it no longer uses after the deletions. -- `ty check` complaints about the new generic parameterization — should be silent, but if not, narrow the issue and address. - -- [ ] **Step 3: Sanity-check the diff** - -```bash -git diff --stat -``` - -Expected: 12 files changed. Approximate line counts (rough): -- `base.py`: +4 / -1 (TypeVar + Generic + bootstrap_config type) -- 8 instrument files: roughly -1 line each (drop the redundant field declaration) -- 3 bootstrapper files: ~-3 lines per deleted subclass + 1-2 line changes in `instruments_types` - -Total net: roughly -10 to -20 lines. - -- [ ] **Step 4: Commit** - -Stage exactly the 12 modified files: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/cors_instrument.py \ - lite_bootstrap/instruments/healthchecks_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/instruments/swagger_instrument.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py -git commit -m "$(cat <<'EOF' -refactor: make BaseInstrument generic; delete pure-annotation subclasses - -Closes DES-1: the four pure type-annotation framework subclasses -(FastAPILoggingInstrument, FastAPISentryInstrument, LitestarSentryInstrument, -FastStreamSentryInstrument) existed only to narrow bootstrap_config. They -contributed no behavior. - -Make BaseInstrument generic in ConfigT (TypeVar bound to BaseConfig). -Each base instrument concretely parameterizes the generic -(LoggingInstrument(BaseInstrument[LoggingConfig]), etc.) and drops its -own redundant bootstrap_config field declaration — the type is now -provided by the parent's generic parameter. - -Delete the four pure-annotation framework subclasses. In the three -affected bootstrappers' instruments_types lists, replace the deleted -class references with the base names (SentryInstrument, LoggingInstrument). - -Framework subclasses that override bootstrap() with real framework-specific -logic (FastAPICorsInstrument, LitestarLoggingInstrument, etc. — 15 in -total) keep their existing structure including the bootstrap_config: - annotation. These annotations are NOT redundant under -the single-level-generic design adopted here — they provide essential -type narrowing for framework field access (e.g., -self.bootstrap_config.application inside FastAPICorsInstrument.bootstrap). - -This deviates from the sequencing spec's "drop redundant annotations -on kept subclasses" decision: a two-level-generic design would be needed -to make those annotations truly redundant, and the complexity isn't -worth it. The audit's stated goal (eliminate the pure-annotation -boilerplate) is fully addressed regardless. - -No behavior change. Existing tests verify correctness. - -Closes DES-1 from the audit. -EOF -)" -``` - ---- - -## Task 6: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/des-1-generic-instruments -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: make BaseInstrument generic; delete pure-annotation subclasses" --body "$(cat <<'EOF' -## Summary -Closes DES-1 from an internal audit: the four pure type-annotation framework subclasses (`FastAPILoggingInstrument`, `FastAPISentryInstrument`, `LitestarSentryInstrument`, `FastStreamSentryInstrument`) existed only to narrow `bootstrap_config`. They contributed no behavior. - -- `BaseInstrument` is now generic in `ConfigT` (`TypeVar` bound to `BaseConfig`). -- Each base instrument concretely parameterizes the generic (`LoggingInstrument(BaseInstrument[LoggingConfig])`, etc.) and drops its redundant `bootstrap_config` field declaration — the type is provided by the parent. -- The four pure-annotation framework subclasses are deleted. The three affected bootstrappers' `instruments_types` lists now reference the base names directly (`SentryInstrument`, `LoggingInstrument`). -- Framework subclasses that override `bootstrap()` with framework-specific logic (15 in total) keep their existing structure. - -No behavior change. Existing tests verify correctness. - -## Deviation from sequencing spec -The sequencing spec called for "dropping redundant `bootstrap_config:` annotations on kept subclasses." Under a single-level-generic design those annotations are NOT redundant — they provide essential type narrowing for framework field access (e.g. `self.bootstrap_config.application` in `FastAPICorsInstrument.bootstrap()`). Making them truly redundant would require a two-level-generic design (each base instrument with its own bounded `TypeVar`); the complexity isn't worth the marginal cleanup. The audit's stated goal — eliminating the pure-annotation boilerplate — is fully addressed regardless. - -## Test plan -- [x] `just test` — full suite passes (89/89). -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the generic + slots + frozen dataclass combination works on Python 3.10. If `BaseInstrument`'s `slots=True` had to be dropped during implementation, the commit message will note it. -- [ ] Reviewer: confirm the kept framework subclasses (`FastAPICorsInstrument`, `LitestarLoggingInstrument`, etc.) still access framework-specific config fields correctly. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR7 section) and audit (DES-1): - -| Spec item | Task | -|-----------|------| -| `BaseInstrument` becomes generic in `ConfigT` (bound to `BaseConfig`) | Task 2, Step 1 | -| Each base instrument concretely parameterizes | Task 3, Steps 1-8 | -| Each base instrument drops its redundant `bootstrap_config:` field declaration | Task 3, Steps 1-8 | -| Delete `FastAPILoggingInstrument` | Task 4, Step 1 | -| Delete `FastAPISentryInstrument` | Task 4, Step 1 | -| Delete `LitestarSentryInstrument` | Task 4, Step 2 | -| Delete `FastStreamSentryInstrument` | Task 4, Step 3 | -| Update `FastAPIBootstrapper.instruments_types` to reference base names | Task 4, Step 1 | -| Update `LitestarBootstrapper.instruments_types` | Task 4, Step 2 | -| Update `FastStreamBootstrapper.instruments_types` | Task 4, Step 3 | -| Branch name `refactor/des-1-generic-instruments` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 5, Steps 1-2 | - -**Deviations from sequencing spec (documented in commit and PR body):** -- Single-level generic rather than two-level. -- Kept framework subclasses retain their `bootstrap_config:` annotations. - -**Deferred:** -- REF-1 (`_build_excluded_urls` duplication between FastAPI and Litestar OTel instruments) — fold into a quick PR8 if PR7's diff stays manageable; otherwise separate follow-up. -- REF-2 (dead defensive check in `LitestarLoggingInstrument.bootstrap()`) — same. -- REF-5 (collapse near-empty `swagger_instrument.py` and `prometheus_instrument.py` base files) — out of scope. - -These three are explicitly noted in the sequencing spec as follow-ups to PR7. Decide once PR7's actual diff lands whether to bundle them or split. diff --git a/planning/changes/2026-06-01.01-instrument-skip-rework/design.md b/planning/changes/2026-06-01.01-instrument-skip-rework.md similarity index 100% rename from planning/changes/2026-06-01.01-instrument-skip-rework/design.md rename to planning/changes/2026-06-01.01-instrument-skip-rework.md diff --git a/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md b/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md deleted file mode 100644 index a34fea6..0000000 --- a/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md +++ /dev/null @@ -1,941 +0,0 @@ -# Instrument Skip Rework Implementation Plan - -> **Note (2026-06-02):** the `_get_logger()` fresh-per-call decision documented below was revised by `docs/superpowers/specs/2026-06-02-stdlib-logging-and-build-summary-design.md`. The summary-log goal is unchanged; the implementation switched to stdlib `logging` with a public `build_summary()` method. - -> **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:** Replace `InstrumentNotReadyWarning` with a pre-instantiation `is_configured` classmethod check. Skipped-due-to-config becomes silent at the warning level; structured `bootstrapper.skipped_instruments` introspection + one INFO summary log provide diagnostic visibility. `InstrumentDependencyMissingWarning` continues to fire for the genuine "configured but dependency missing" deployment-surprise case. - -**Architecture:** `BaseInstrument.is_ready(self) -> bool` (instance method) is removed and replaced with `BaseInstrument.is_configured(cls, bootstrap_config) -> bool` (classmethod). The bootstrapper's `_register_or_skip` helper is inlined; the new flow checks `is_configured(config)` BEFORE `check_dependencies()` BEFORE instantiation. Silent skip on is_configured False; warning on check_dependencies False; otherwise instantiate. After the loop, emit one INFO log summarizing configured + skipped instruments. Bootstrapper-level `is_ready` methods (framework availability checks like "fastapi is not installed") are unchanged. - -**Tech Stack:** Python 3.10+, dataclasses, stdlib `warnings` + `logging`, pytest (caplog), structlog (transitive). - -**Parent spec:** `./design.md` - ---- - -## File Structure - -15 production files modified, 6 test files modified, 2 docs modified, 0 new files. - -**Production (12 instrument-side + 3 bootstrapper-side files):** - -| File | Change | -|------|--------| -| `lite_bootstrap/instruments/base.py` | `BaseInstrument.is_ready` → `is_configured` classmethod | -| `lite_bootstrap/instruments/cors_instrument.py` | `CorsInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/healthchecks_instrument.py` | `HealthChecksInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/logging_instrument.py` | `LoggingInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/opentelemetry_instrument.py` | `OpenTelemetryInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/prometheus_instrument.py` | `PrometheusInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/pyroscope_instrument.py` | `PyroscopeInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/sentry_instrument.py` | `SentryInstrument.is_ready` → `is_configured` | -| `lite_bootstrap/instruments/swagger_instrument.py` | (none — uses default) | -| `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` | `FastStreamOpenTelemetryInstrument.is_ready` and `FastStreamPrometheusInstrument.is_ready` → `is_configured`. `FastStreamBootstrapper.is_ready` (bootstrapper-level) **unchanged**. | -| `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` | `LitestarSwaggerInstrument.is_ready` → `is_configured`. `LitestarBootstrapper.is_ready` **unchanged**. | -| `lite_bootstrap/bootstrappers/base.py` | Replace `_register_or_skip` with inline flow; add `skipped_instruments`; emit summary log; remove `InstrumentNotReadyWarning` import. | -| `lite_bootstrap/exceptions.py` | Remove `InstrumentNotReadyWarning` class. | -| `lite_bootstrap/__init__.py` | Remove `InstrumentNotReadyWarning` import + `__all__` entry. | - -**Tests (6 files):** - -| File | Change | -|------|--------| -| `tests/test_free_bootstrap.py` | `test_free_bootstrap_logging_disabled` rewritten. New `test_bootstrap_emits_summary_log`. | -| `tests/instruments/test_cors_instrument.py` | `is_ready()` → `is_configured(config)` | -| `tests/instruments/test_healthchecks_instrument.py` | same | -| `tests/instruments/test_prometheus_instrument.py` | same | -| `tests/instruments/test_pyroscope_instrument.py` | same | -| `tests/instruments/test_swagger_instrument.py` | same | - -**Docs:** - -| File | Change | -|------|--------| -| `docs/introduction/configuration.md` | Revise the PR #86 warning subclasses section (introduced ~31 lines; updated to reflect single dep-missing warning + `skipped_instruments` + summary log). | -| `CLAUDE.md` | Update "Optional dependencies" key design decision to mention `is_configured` ordering. | - ---- - -## Pre-flight grep verification - -Before starting, confirm scope matches reality: - -```bash -grep -rn "def is_ready\|\.is_ready(" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected count: -- 8 `def is_ready(self)` in `lite_bootstrap/instruments/` (one per base instrument file). -- 3 `def is_ready(self)` instrument-level overrides in `lite_bootstrap/bootstrappers/` (FastStream OTel, FastStream Prometheus, Litestar Swagger). -- 5 `def is_ready(self)` BOOTSTRAPPER-LEVEL methods (FastAPI, Litestar, FastStream, Free, FastMcp Bootstrappers) — these are NOT migrated. -- 1 `def is_ready(self)` abstract on `BaseBootstrapper` — NOT migrated. -- 2 `self.is_ready()` calls in `bootstrappers/base.py` (one in `__init__` bootstrapper check at line 34, one in `_register_or_skip` instrument check at line 57) — only the line 57 call migrates. -- ~14 `instrument.is_ready()` calls in test files — all migrate. - -```bash -grep -rn "not_ready_message" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: `not_ready_message` is preserved as a class attribute (used in `skipped_instruments` tuples and bootstrapper-level `BootstrapperNotReadyError`). Only its emission in the deleted `InstrumentNotReadyWarning` goes away. - -```bash -grep -rn "InstrumentNotReadyWarning" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: 5 matches (declaration in `exceptions.py`, import + emission in `bootstrappers/base.py`, import + export in `__init__.py`, import + usage in `tests/test_free_bootstrap.py`). All are removed by this plan. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** (NOT the current feat/fastmcp-bootstrapper branch — this refactor is independent) - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/instrument-skip-rework -``` - -Expected: `Switched to a new branch 'refactor/instrument-skip-rework'`. - -Note: the design spec (`docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md`) was committed to `feat/fastmcp-bootstrapper`. This plan does not depend on it being on main; the plan is self-contained. - ---- - -## Task 2: Migrate `BaseInstrument` to `is_configured` classmethod - -**File:** `lite_bootstrap/instruments/base.py` - -- [ ] **Step 1: Replace the `BaseInstrument` body** - -Current (lines 32-47): - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class BaseInstrument(typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... - - def teardown(self) -> None: ... - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class BaseInstrument(typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... - - def teardown(self) -> None: ... - - @classmethod - def is_configured(cls, bootstrap_config: ConfigT) -> bool: - """Return True if config indicates this instrument should be active. Default: always active.""" - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Two changes: -1. Remove the `is_ready` instance method. -2. Add `is_configured` classmethod with default `return True`. - -The class attribute `not_ready_message` stays. - ---- - -## Task 3: Migrate each base instrument's `is_ready` to `is_configured` - -Eight instrument files, mechanical migration. Each step is one file. - -- [ ] **Step 1: `lite_bootstrap/instruments/cors_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.cors_allowed_origins) or bool( - self.bootstrap_config.cors_allowed_origin_regex, - ) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "CorsConfig") -> bool: - return bool(bootstrap_config.cors_allowed_origins) or bool(bootstrap_config.cors_allowed_origin_regex) -``` - -- [ ] **Step 2: `lite_bootstrap/instruments/healthchecks_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return self.bootstrap_config.health_checks_enabled -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "HealthChecksConfig") -> bool: - return bootstrap_config.health_checks_enabled -``` - -- [ ] **Step 3: `lite_bootstrap/instruments/logging_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "LoggingConfig") -> bool: - return bootstrap_config.logging_enabled -``` - -- [ ] **Step 4: `lite_bootstrap/instruments/opentelemetry_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "OpenTelemetryConfig") -> bool: - return bool(bootstrap_config.opentelemetry_endpoint or bootstrap_config.opentelemetry_log_traces) -``` - -- [ ] **Step 5: `lite_bootstrap/instruments/prometheus_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "PrometheusConfig") -> bool: - return bool(bootstrap_config.prometheus_metrics_path) and is_valid_path(bootstrap_config.prometheus_metrics_path) -``` - -- [ ] **Step 6: `lite_bootstrap/instruments/pyroscope_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "PyroscopeConfig") -> bool: - return bool(bootstrap_config.pyroscope_endpoint) -``` - -- [ ] **Step 7: `lite_bootstrap/instruments/sentry_instrument.py`** - -Find: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "SentryConfig") -> bool: - return bool(bootstrap_config.sentry_dsn) -``` - -- [ ] **Step 8: `lite_bootstrap/instruments/swagger_instrument.py`** - -`SwaggerInstrument` doesn't override `is_ready` (uses the default `return True` from the base). No change needed in this file. - -- [ ] **Step 9: Smoke check — instruments module imports cleanly** - -```bash -uv run python -c "from lite_bootstrap.instruments.cors_instrument import CorsInstrument, CorsConfig; assert CorsInstrument.is_configured(CorsConfig()) is False; print('ok')" -``` - -Expected: `ok`. If `AttributeError: type object 'CorsInstrument' has no attribute 'is_configured'` — Task 3 has a missed file. - ---- - -## Task 4: Migrate framework instrument-level overrides - -Three instrument-level `is_ready` overrides live in bootstrapper files. Migrate them. - -- [ ] **Step 1: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — FastStreamOpenTelemetryInstrument** - -Find (around line 128-129): - -```python - def is_ready(self) -> bool: - return super().is_ready() and bool(self.bootstrap_config.opentelemetry_middleware_cls) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: - return super().is_configured(bootstrap_config) and bool(bootstrap_config.opentelemetry_middleware_cls) -``` - -- [ ] **Step 2: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — FastStreamPrometheusInstrument** - -Find (around line 151-155): - -```python - def is_ready(self) -> bool: - return ( - super().is_ready() - and import_checker.is_prometheus_client_installed - and bool(self.bootstrap_config.prometheus_middleware_cls) - ) -``` - -Replace with (dropping the dead `import_checker.is_prometheus_client_installed` conjunct per DES-5): - -```python - @classmethod - def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: - return super().is_configured(bootstrap_config) and bool(bootstrap_config.prometheus_middleware_cls) -``` - -The `import_checker.is_prometheus_client_installed` check is dead because `_register_or_skip` will call `check_dependencies()` AFTER `is_configured()` returns True, and `check_dependencies()` already checks `import_checker.is_prometheus_client_installed`. - -- [ ] **Step 3: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — LitestarSwaggerInstrument** - -Find (around line 221, the instrument-level — NOT the bootstrapper-level at line 276): - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.swagger_path) and is_valid_path(self.bootstrap_config.swagger_path) -``` - -Replace with: - -```python - @classmethod - def is_configured(cls, bootstrap_config: "LitestarConfig") -> bool: - return bool(bootstrap_config.swagger_path) and is_valid_path(bootstrap_config.swagger_path) -``` - -- [ ] **Step 4: Sanity grep — no leftover instrument-level is_ready** - -```bash -grep -n "def is_ready" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/ -``` - -Expected matches: only bootstrapper-level overrides (`FastAPIBootstrapper.is_ready` in `fastapi_bootstrapper.py`, `LitestarBootstrapper.is_ready` at `litestar_bootstrapper.py:276`, `FastStreamBootstrapper.is_ready` at `faststream_bootstrapper.py:186`, `FreeBootstrapper.is_ready` in `free_bootstrapper.py`, `FastMcpBootstrapper.is_ready` in `fastmcp_bootstrapper.py`, and the abstract `BaseBootstrapper.is_ready`). No instrument-level matches. - ---- - -## Task 5: Rewrite `BaseBootstrapper` flow - -**File:** `lite_bootstrap/bootstrappers/base.py` - -- [ ] **Step 1: Remove `InstrumentNotReadyWarning` import** - -Find (lines 6-11): - -```python -from lite_bootstrap.exceptions import ( - BootstrapperNotReadyError, - InstrumentDependencyMissingWarning, - InstrumentNotReadyWarning, - TeardownError, -) -``` - -Replace with: - -```python -from lite_bootstrap.exceptions import ( - BootstrapperNotReadyError, - InstrumentDependencyMissingWarning, - TeardownError, -) -``` - -- [ ] **Step 2: Inline the new flow in `__init__` and add `skipped_instruments` attribute** - -Find the existing class header + `__init__` + `_register_or_skip` (lines 27-64): - -```python -class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]): - instruments_types: typing.ClassVar[list[type[BaseInstrument]]] - instruments: list[BaseInstrument] - bootstrap_config: BaseConfig - - def __init__(self, bootstrap_config: BaseConfig) -> None: - self.is_bootstrapped = False - if not self.is_ready(): - msg = f"{type(self).__name__} is not ready: {self.not_ready_message}" - raise BootstrapperNotReadyError(msg) - - self.bootstrap_config = bootstrap_config - self.instruments = [] - for instrument_type in self.instruments_types: - if (instrument := self._register_or_skip(instrument_type)) is not None: - self.instruments.append(instrument) - - def _register_or_skip(self, instrument_type: type[BaseInstrument]) -> BaseInstrument | None: - # Check dependencies before instantiation: an instrument's __init__ - # may reference symbols gated behind an optional import (e.g. a - # default_factory that calls into the missing package), which would - # raise NameError before the check_dependencies skip could run. - if not instrument_type.check_dependencies(): - warnings.warn( - instrument_type.missing_dependency_message, - category=InstrumentDependencyMissingWarning, - stacklevel=4, - ) - return None - instrument = instrument_type(bootstrap_config=self.bootstrap_config) - if not instrument.is_ready(): - warnings.warn( - f"{instrument_type.__name__} is not ready: {instrument.not_ready_message}", - category=InstrumentNotReadyWarning, - stacklevel=4, - ) - return None - return instrument -``` - -Replace with: - -```python -class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]): - instruments_types: typing.ClassVar[list[type[BaseInstrument]]] - instruments: list[BaseInstrument] - skipped_instruments: list[tuple[type[BaseInstrument], str]] - bootstrap_config: BaseConfig - - def __init__(self, bootstrap_config: BaseConfig) -> None: - self.is_bootstrapped = False - if not self.is_ready(): - msg = f"{type(self).__name__} is not ready: {self.not_ready_message}" - raise BootstrapperNotReadyError(msg) - - self.bootstrap_config = bootstrap_config - self.instruments = [] - self.skipped_instruments = [] - for instrument_type in self.instruments_types: - # Config-level skip first: silent (no warning). Runs before instantiation so a - # missing-optional-dep doesn't fail in a dataclass default_factory before we - # can decide the user opted out. - if not instrument_type.is_configured(self.bootstrap_config): - self.skipped_instruments.append((instrument_type, instrument_type.not_ready_message)) - continue - # Dep-missing for a CONFIGURED instrument is a genuine deployment surprise. - if not instrument_type.check_dependencies(): - warnings.warn( - instrument_type.missing_dependency_message, - category=InstrumentDependencyMissingWarning, - stacklevel=3, - ) - continue - self.instruments.append(instrument_type(bootstrap_config=self.bootstrap_config)) - - logger.info( - f"{type(self).__name__}: " - f"configured={[type(i).__name__ for i in self.instruments]}, " - f"skipped={[(cls.__name__, reason) for cls, reason in self.skipped_instruments]}" - ) -``` - -Five changes: -1. Add `skipped_instruments: list[tuple[type[BaseInstrument], str]]` class-level annotation. -2. Initialize `self.skipped_instruments = []` in `__init__`. -3. Inline the new flow: `is_configured` first (silent skip + append to skipped_instruments), then `check_dependencies` (warning, no skipped_instruments entry), then instantiate. -4. Delete the `_register_or_skip` helper method entirely. -5. Emit one `logger.info(...)` summary after the loop. - -The `stacklevel=3` is reduced from `stacklevel=4` because the warning call is now in `__init__` directly (one fewer frame than via `_register_or_skip`). - -The `logger` reference is the existing module-level logger declared at line 16-21 (structlog if available, stdlib otherwise) — no new import needed. - -The `not_ready_message` is accessed on the **class** (not an instance), since `is_configured` runs without instantiation. - -- [ ] **Step 3: Smoke check — bootstrapper module imports cleanly** - -```bash -uv run python -c "from lite_bootstrap.bootstrappers.base import BaseBootstrapper; print('ok')" -``` - -Expected: `ok`. If `ImportError: cannot import name 'InstrumentNotReadyWarning'` — Task 5 Step 1 wasn't applied. - ---- - -## Task 6: Migrate tests to new API - -Six test files. Mechanical `is_ready()` → `is_configured(config)` migration plus the `test_free_bootstrap_logging_disabled` rewrite. - -- [ ] **Step 1: `tests/instruments/test_cors_instrument.py`** - -The file has several `instrument.is_ready()` calls. Find each `assert instrument.is_ready()` and `assert not instrument.is_ready()` pattern and rewrite to use the classmethod on the config used. - -Concretely, the pattern: - -```python -def test_cors_instrument_not_ready_without_origins_or_regex() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig()) - assert not instrument.is_ready() - assert instrument.not_ready_message == "cors_allowed_origins or cors_allowed_origin_regex must be provided" -``` - -becomes: - -```python -def test_cors_instrument_not_configured_without_origins_or_regex() -> None: - config = CorsConfig() - assert not CorsInstrument.is_configured(config) - assert CorsInstrument.not_ready_message == "cors_allowed_origins or cors_allowed_origin_regex must be provided" -``` - -Apply the same pattern to all `is_ready()` calls in the file. Rename the test function from `*_not_ready_*` to `*_not_configured_*` (and `*_ready_*` to `*_configured_*` for positive cases) to match the new semantics. - -- [ ] **Step 2: `tests/instruments/test_healthchecks_instrument.py`** - -Same pattern. Rename `*_ready_*` → `*_configured_*`. Construct config, pass to `HealthChecksInstrument.is_configured(config)`. - -- [ ] **Step 3: `tests/instruments/test_prometheus_instrument.py`** - -Same pattern. Use `PrometheusInstrument.is_configured(config)`. - -- [ ] **Step 4: `tests/instruments/test_pyroscope_instrument.py`** - -Same pattern. Use `PyroscopeInstrument.is_configured(config)`. - -- [ ] **Step 5: `tests/instruments/test_swagger_instrument.py`** - -Same pattern. Use `SwaggerInstrument.is_configured(config)`. (The default returns True, so positive cases simply assert True.) - -- [ ] **Step 6: `tests/test_free_bootstrap.py::test_free_bootstrap_logging_disabled`** - -Find the current test: - -```python -def test_free_bootstrap_logging_disabled() -> None: - with pytest.warns(InstrumentNotReadyWarning) as records: - FreeBootstrapper( - bootstrap_config=FreeBootstrapperConfig( - logging_enabled=False, - opentelemetry_instrumentors=[CustomInstrumentor()], - opentelemetry_log_traces=True, - sentry_dsn="https://testdsn@localhost/1", - sentry_additional_params={"transport": SentryTestTransport()}, - logging_buffer_capacity=0, - ), - ) - messages = [str(r.message) for r in records] - assert "LoggingInstrument is not ready: logging_enabled is False" in messages - assert "PyroscopeInstrument is not ready: pyroscope_endpoint is empty" in messages -``` - -Replace with: - -```python -def test_free_bootstrap_logging_disabled() -> None: - bootstrapper = FreeBootstrapper( - bootstrap_config=FreeBootstrapperConfig( - logging_enabled=False, - opentelemetry_instrumentors=[CustomInstrumentor()], - opentelemetry_log_traces=True, - sentry_dsn="https://testdsn@localhost/1", - sentry_additional_params={"transport": SentryTestTransport()}, - logging_buffer_capacity=0, - ), - ) - skipped_classes = {cls for cls, _ in bootstrapper.skipped_instruments} - assert LoggingInstrument in skipped_classes - assert PyroscopeInstrument in skipped_classes -``` - -Also at the top of the file: -- Add `from lite_bootstrap.instruments.logging_instrument import LoggingInstrument` -- Add `from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeInstrument` -- Remove `InstrumentNotReadyWarning` from the `from lite_bootstrap import (...)` import block - -- [ ] **Step 7: Verify all instrument tests pass** - -```bash -just test -- tests/instruments/ tests/test_free_bootstrap.py -v -``` - -Expected: all pass. If any test references `is_ready()` it's a missed migration — fix and re-run. - ---- - -## Task 7: Add summary-log test - -- [ ] **Step 1: Append to `tests/test_free_bootstrap.py`** - -Add: - -```python -def test_free_bootstrap_emits_summary_log(caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.INFO, logger="lite_bootstrap.bootstrappers.base"): - FreeBootstrapper( - bootstrap_config=FreeBootstrapperConfig( - sentry_dsn="https://testdsn@localhost/1", - sentry_additional_params={"transport": SentryTestTransport()}, - ), - ) - summary_records = [r for r in caplog.records if "FreeBootstrapper" in r.getMessage()] - assert summary_records, "expected a summary log line mentioning FreeBootstrapper" - summary = summary_records[-1].getMessage() - assert "configured=" in summary - assert "skipped=" in summary -``` - -Also add `import logging` at the top if not already present, and add `pytest` to imports if needed (already in test_free_bootstrap.py as `import pytest`). - -- [ ] **Step 2: Run the new test** - -```bash -just test -- tests/test_free_bootstrap.py::test_free_bootstrap_emits_summary_log -v -``` - -Expected: PASS. - ---- - -## Task 8: Remove `InstrumentNotReadyWarning` class - -- [ ] **Step 1: `lite_bootstrap/exceptions.py`** - -Find (lines 30-31 approx): - -```python -class InstrumentNotReadyWarning(InstrumentSkippedWarning): - """Emitted when an instrument is skipped because its config indicates it should not run.""" -``` - -Delete those two lines and the blank line immediately before/after as appropriate. Make sure `InstrumentSkippedWarning` and `InstrumentDependencyMissingWarning` definitions remain. - -- [ ] **Step 2: `lite_bootstrap/__init__.py`** - -Find and update the import block (lines 6-14 approx): - -```python -from lite_bootstrap.exceptions import ( - BootstrapperNotReadyError, - ConfigurationError, - InstrumentDependencyMissingWarning, - InstrumentNotReadyWarning, - InstrumentSkippedWarning, - LiteBootstrapError, - TeardownError, -) -``` - -Remove the `InstrumentNotReadyWarning,` line so it becomes: - -```python -from lite_bootstrap.exceptions import ( - BootstrapperNotReadyError, - ConfigurationError, - InstrumentDependencyMissingWarning, - InstrumentSkippedWarning, - LiteBootstrapError, - TeardownError, -) -``` - -Find the `__all__` list and remove `"InstrumentNotReadyWarning",` (alphabetically between `InstrumentDependencyMissingWarning` and `InstrumentSkippedWarning`). - -- [ ] **Step 3: Sanity grep — no leftover references** - -```bash -grep -rn "InstrumentNotReadyWarning" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: zero matches. If any remain, they're missed migrations. - -- [ ] **Step 4: Full test suite** - -```bash -just test -``` - -Expected: all tests pass. Existing dep-missing tests (e.g., `test_fastapi_bootstrapper_with_missing_instrument_dependency`) still fire `InstrumentDependencyMissingWarning` and continue to pass. - -```bash -just lint -``` - -Expected: clean. The `_register_or_skip` removal also removes any reference to its name; no orphan symbols. - ---- - -## Task 9: Update docs - -- [ ] **Step 1: `CLAUDE.md` — update Optional dependencies design-decision bullet** - -Find the "Key design decisions" section, the bullet starting with `**Optional dependencies**:`. Append after the existing sentence about Pyright (or replace the trailing static-analyzer note): - -Add a new bullet immediately after the "Optional dependencies" one: - -```markdown -- **Instrument skip ordering**: `BaseBootstrapper.__init__` runs `instrument_type.is_configured(config)` first (silent skip if the user's config indicates the instrument shouldn't run — populates `bootstrapper.skipped_instruments`); then `check_dependencies()` (emits `InstrumentDependencyMissingWarning` only for configured-but-dep-missing — the genuine deployment surprise); then instantiates. One `logger.info` summary line at the end lists configured + skipped instruments. -``` - -- [ ] **Step 2: `docs/introduction/configuration.md` — revise the warning-subclasses section** - -Open the file and locate the section that PR #86 introduced (search for `InstrumentNotReadyWarning` or `InstrumentSkippedWarning`). This section documents how users can filter / capture the warning subclasses. - -Replace the section content with text reflecting the new behavior: -- Only `InstrumentDependencyMissingWarning` is emitted by the bootstrapper. -- It fires only when the instrument is configured AND its optional dependency is missing. -- Instruments skipped due to config (`is_configured` False) appear in `bootstrapper.skipped_instruments: list[tuple[type, str]]` and in the INFO-level summary log. - -Keep `InstrumentSkippedWarning` as the documented base for forward-compatibility (if additional skip categories arise in future). - -Remove any code samples referencing `InstrumentNotReadyWarning`. - -- [ ] **Step 3: Verify docs build** - -```bash -uv run --with mkdocs --with mkdocs-material mkdocs build --strict > /dev/null 2>&1; echo "exit: $?" -``` - -Expected: `exit: 0`. (The audit's earlier docs/superpowers/ exclusion remains in mkdocs.yml.) - ---- - -## Task 10: Final verification and commit - -- [ ] **Step 1: Run the full test suite once more** - -```bash -just test -``` - -Expected: all pass (count = 129 prior + 1 new summary-log test = 130). - -- [ ] **Step 2: Run lint** - -```bash -just lint -``` - -Expected: clean. - -- [ ] **Step 3: Pre-flight grep for any leftover old-API references** - -```bash -grep -rn "def is_ready\|\.is_ready(" lite_bootstrap/ tests/ --include="*.py" | grep -v "Bootstrapper\b" -``` - -Expected: only the abstract `def is_ready(self) -> bool: ...` on `BaseBootstrapper` (line 74) and the bootstrapper-level overrides in framework files. Any instrument-level match is a missed migration. - -```bash -grep -rn "InstrumentNotReadyWarning" . -``` - -Expected: only matches in `docs/superpowers/specs/` and `docs/superpowers/plans/` (this plan and the spec). Production code and tests should have zero matches. - -- [ ] **Step 4: Commit** - -Stage all touched files explicitly: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/cors_instrument.py \ - lite_bootstrap/instruments/healthchecks_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/bootstrappers/base.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/exceptions.py \ - lite_bootstrap/__init__.py \ - tests/test_free_bootstrap.py \ - tests/instruments/test_cors_instrument.py \ - tests/instruments/test_healthchecks_instrument.py \ - tests/instruments/test_prometheus_instrument.py \ - tests/instruments/test_pyroscope_instrument.py \ - tests/instruments/test_swagger_instrument.py \ - CLAUDE.md \ - docs/introduction/configuration.md -git commit -m "$(cat <<'EOF' -refactor: replace InstrumentNotReadyWarning with is_configured classmethod + summary log - -PR #86 introduced InstrumentNotReadyWarning, escalating the not-ready -skip path from a (silently dropped) logger.info call to a real -warnings.warn. The escalation surfaced expected-path events as -warnings: every service using a subset of available instruments emits -N warnings on bootstrap, and lite-bootstrap's own tests can't suppress -without breaking the warning assertions. - -Replace is_ready (instance method, ran after instantiation) with -is_configured (classmethod taking config, runs before instantiation). -The bootstrapper now: - -1. is_configured(config) False → silent skip; append to new - bootstrapper.skipped_instruments: list[tuple[type, str]]. -2. check_dependencies() False → InstrumentDependencyMissingWarning - (kept; this is the genuine "configured but dep missing" deployment - surprise). -3. Otherwise instantiate and register. - -After the loop, emit one INFO-level summary log listing configured + -skipped instruments. Default Python logging suppresses INFO, so silent -by default with an opt-in path via logging.basicConfig. - -PR #88's constraint (check_dependencies before instantiation, because -some instruments have default_factory that NameErrors when optional -extras aren't installed) is preserved: is_configured is a classmethod, -so it runs without instantiation. - -Removed: -- InstrumentNotReadyWarning class (hard removal; 4 weeks old; exports - go too) -- BaseInstrument.is_ready instance method (replaced by is_configured) -- The _register_or_skip helper (flow inlined) -- The dead `import_checker.is_prometheus_client_installed` conjunct in - FastStreamPrometheusInstrument.is_configured (covered by - check_dependencies()). - -Kept: -- InstrumentSkippedWarning (base for forward-compat) -- InstrumentDependencyMissingWarning (still useful for the genuine - deployment surprise) -- not_ready_message class attribute (used in skipped_instruments - tuples and the summary log) -- Bootstrapper-level is_ready methods (framework availability checks; - unrelated) - -Tests migrated to call XInstrument.is_configured(config) instead of -instrument.is_ready(). test_free_bootstrap_logging_disabled rewritten -to assert on bootstrapper.skipped_instruments. New -test_free_bootstrap_emits_summary_log pins the summary log behavior -via caplog. - -Closes design doc 2026-06-01-instrument-skip-rework-design.md. -EOF -)" -``` - ---- - -## Task 11: Push + open PR - -- [ ] **Step 1: Push** - -```bash -git push -u origin refactor/instrument-skip-rework -``` - -- [ ] **Step 2: Open PR** - -```bash -gh pr create --title "refactor: replace InstrumentNotReadyWarning with is_configured classmethod + summary log" --body "$(cat <<'EOF' -## Summary -Replaces the post-PR-#86 `InstrumentNotReadyWarning` (which fired on every config-opt-out path, generating noise in every service) with a pre-instantiation `is_configured` classmethod check, structured `bootstrapper.skipped_instruments` introspection, and one INFO-level summary log. - -- `is_ready` (instance method) → `is_configured` (classmethod taking config). Runs **before** `check_dependencies()`, before instantiation — preserves PR #88's no-instantiation-on-missing-dep constraint. -- `InstrumentNotReadyWarning` removed. Skipped-due-to-config is silent; appears in `bootstrapper.skipped_instruments: list[tuple[type, str]]`. -- `InstrumentDependencyMissingWarning` retained and now fires ONLY for genuine deployment surprises (instrument is configured but its optional package is missing). -- One `logger.info(...)` summary at the end of `__init__` lists `configured=[...]` and `skipped=[...]`. Default Python logging suppresses INFO so silent by default. - -15 production files modified, 6 test files migrated, 2 docs updated. Bootstrapper-level `is_ready` methods (framework availability checks like "fastapi is not installed") are unchanged. - -Closes design `docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md`. - -## Backward compatibility -- `InstrumentNotReadyWarning` is publicly exported and is **hard-removed**. Users importing the name will get `ImportError`. 4 weeks old (PR #86); acceptable churn. -- `is_ready` instance method on instruments is removed. User code calling `instrument.is_ready()` migrates to `type(instrument).is_configured(config)`. Library-internal lifecycle; rare in user code. - -## Test plan -- [x] `just test` — full suite (130 expected; 129 prior + 1 new summary-log test). -- [x] `just lint` — clean. -- [x] Existing dep-missing tests (`test_*_bootstrapper_with_missing_instrument_dependency`) still pass: `InstrumentDependencyMissingWarning` continues to fire for the configured + dep-missing case. -- [ ] Reviewer: confirm the inline `__init__` flow correctly populates `skipped_instruments` only for `is_configured`-False instruments (not for dep-missing ones, which only get the warning). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -Spec coverage check against `docs/superpowers/specs/2026-06-01-instrument-skip-rework-design.md`: - -| Spec section | Task | -|--------------|------| -| API change: `is_configured` classmethod replaces `is_ready` | Task 2 (base) + Task 3 (8 instruments) + Task 4 (3 framework overrides) | -| Bootstrapper flow reorder (is_configured → check_dependencies → instantiate) | Task 5, Step 2 | -| `skipped_instruments` attribute | Task 5, Step 2 | -| Summary log | Task 5, Step 2 | -| `is_ready` instance method removed | Task 2, Task 3, Task 4 | -| `not_ready_message` retained as class attribute | (preserved by inaction — no edits to this field across the migration) | -| `InstrumentNotReadyWarning` class removed | Task 8, Step 1 | -| `__init__.py` export removed | Task 8, Step 2 | -| Existing dep-missing tests continue to work | Task 10, Step 1 (verified by full test run) | -| New test: summary log via caplog | Task 7 | -| `test_free_bootstrap_logging_disabled` rewritten | Task 6, Step 6 | -| Instrument tests migrated (5 files from PR10) | Task 6, Steps 1-5 | -| Docs: `docs/introduction/configuration.md` | Task 9, Step 2 | -| Docs: `CLAUDE.md` | Task 9, Step 1 | -| Pre-flight grep | "Pre-flight grep verification" section + Task 4 Step 4 + Task 8 Step 3 + Task 10 Step 3 | - -All spec items have a corresponding task. No placeholders. - -Type consistency check: `is_configured(cls, bootstrap_config: ConfigT) -> bool` is the signature used throughout (Task 2 introduces, Task 3/4 apply). `skipped_instruments: list[tuple[type[BaseInstrument], str]]` is the type used in Task 5's class annotation and the test reads it as `{cls for cls, _ in bootstrapper.skipped_instruments}` (Task 6 Step 6). - -Cross-PR awareness: the `import_checker.is_prometheus_client_installed` conjunct dropped in Task 4 Step 2 is documented in the audit as DES-5; this PR opportunistically completes that cleanup since the migration is touching the same method body. - -Risk assessment: medium. The change is mechanical but cross-cutting (~20 files). The full test suite is the safety net. If a single instrument's `is_configured` migration is wrong (e.g., typo in field name), only that instrument's tests fail — no cascade. The bootstrapper flow change is the highest-risk single point; Task 5's smoke test catches the import case, full-suite run catches the behavioral case. diff --git a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/design.md b/planning/changes/2026-06-01.02-fastmcp-bootstrapper.md similarity index 100% rename from planning/changes/2026-06-01.02-fastmcp-bootstrapper/design.md rename to planning/changes/2026-06-01.02-fastmcp-bootstrapper.md diff --git a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md b/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md deleted file mode 100644 index 8e8a935..0000000 --- a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md +++ /dev/null @@ -1,1148 +0,0 @@ -# FastMCP Bootstrapper 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:** Ship a `FastMcpBootstrapper` and `FastMcpConfig` that wire the lite-bootstrap instrument stack (Sentry, Pyroscope, structlog logging with an MCP-aware access middleware, JSON health check, Prometheus metrics) onto a FastMCP server, with teardown plumbed through the FastMCP lifespan. - -**Architecture:** Single-file bootstrapper module mirroring `faststream_bootstrapper.py` (Approach A from brainstorming). Frozen dataclass config composed via multiple inheritance over the shared instrument configs (no OTel/CORS/Swagger). Framework-specific instrument subclasses. Teardown wired by replacing `FastMCP.lifespan` with `fastmcp.utilities.lifespan.combine_lifespans(existing, teardown_lifespan)`. Two new pyproject extras (`fastmcp`, `fastmcp-metrics`); no composite/rollup extras per the project rule. - -**Tech Stack:** Python 3.10+ dataclasses, `fastmcp`, `structlog`, `prometheus_client`, `starlette.responses`, `pytest-asyncio` (auto mode), `httpx2` test client, `unittest.mock.MagicMock` / `pytest.MonkeyPatch`. - -**Parent spec:** `./design.md` -**Reference PR:** [microbootstrap PR #141](https://github.com/community-of-python/microbootstrap/pull/141). - ---- - -## File Structure - -Two new files, six modified files. - -- **Create:** `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` — `_make_fastmcp`, `_build_teardown_lifespan`, `FastMcpConfig`, `FastMcpLoggingMiddleware`, `FastMcpHealthChecksInstrument`, `FastMcpLoggingInstrument`, `FastMcpPrometheusInstrument`, `FastMcpBootstrapper`. -- **Create:** `tests/test_fastmcp_bootstrap.py` — full test suite (13 tests; see spec §Tests). -- **Create:** `docs/integrations/fastmcp.md` — integration usage page. -- **Modify:** `lite_bootstrap/import_checker.py` — add `is_fastmcp_installed`. -- **Modify:** `lite_bootstrap/__init__.py` — re-export `FastMcpBootstrapper`, `FastMcpConfig`. -- **Modify:** `pyproject.toml` — add extras, append `"fastmcp"` keyword. -- **Modify:** `README.md` — append FastMCP to framework list. -- **Modify:** `docs/index.md` — append FastMCP to framework list. -- **Modify:** `docs/introduction/installation.md` — new column in extras table + composition note. -- **Modify:** `CLAUDE.md` — `FastMcpBootstrapper` in Core-pattern tree. - ---- - -## Locked decisions (from spec) - -- **Instrument set:** Sentry, Pyroscope, structlog logging + MCP middleware, health, prometheus. No OTel/CORS/Swagger. -- **Middleware default:** mounted on; `logging_turn_off_middleware: bool = False` flag to opt out. -- **Prometheus route:** `application.custom_route` at `prometheus_metrics_path`, always-on (no per-bootstrapper opt-out flag). -- **Teardown:** wired automatically via `FastMCP.add_provider(_TeardownProvider(self.teardown))` in `FastMcpBootstrapper.__init__`. `_TeardownProvider` is an empty `Provider` subclass whose `async def lifespan(self)` calls the teardown callable on exit. This is the only **public, post-construction** lifecycle hook FastMCP exposes — `FastMCP.lifespan` is a read-only bound method and `_lifespan` is private. Two earlier approaches considered and rejected: wrapping `FastMCP.lifespan` via `combine_lifespans` (impossible — `lifespan` is read-only) and manual teardown (briefly adopted then reverted when `add_provider` was identified). See spec §"Teardown via Provider.lifespan". -- **Extras:** `fastmcp`, `fastmcp-metrics`, and `fastmcp-all` rollup (matches `fastapi-all` / `litestar-all` / `faststream-all`). No `fastmcp-sentry` / `fastmcp-logging` because per-pair composites add no new direct dependencies. -- **Default config app:** `default_factory=_make_fastmcp` where `_make_fastmcp()` returns `FastMCP()`. No `UnsetType` sentinel — `FastMCP()` needs no derived config. -- **Middleware default registry:** `prometheus_client.REGISTRY`. No fastmcp-specific registry config field. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b feat/fastmcp-bootstrapper -``` - -Expected: `Switched to a new branch 'feat/fastmcp-bootstrapper'`. - ---- - -## Task 2: Add `is_fastmcp_installed` to import_checker - -**Files:** -- Modify: `lite_bootstrap/import_checker.py` - -- [ ] **Step 1: Add the spec check** - -Current file ends at line 19 with `is_pyroscope_installed`. Append a new line after it: - -```python -is_fastmcp_installed = find_spec("fastmcp") is not None -``` - -After the change the relevant tail of `lite_bootstrap/import_checker.py` reads: - -```python -is_pyroscope_installed = find_spec("pyroscope") is not None -is_fastmcp_installed = find_spec("fastmcp") is not None -``` - -- [ ] **Step 2: Quick smoke-import** - -Run: `uv run --no-sync python -c "from lite_bootstrap.import_checker import is_fastmcp_installed; print(is_fastmcp_installed)"` - -Expected: prints `False` (fastmcp is not yet installed — Task 3 installs it). No `ImportError`. - -- [ ] **Step 3: Commit** - -```bash -git add lite_bootstrap/import_checker.py -git commit -m "feat: detect fastmcp via import_checker" -``` - ---- - -## Task 3: Add fastmcp extras and install - -**Files:** -- Modify: `pyproject.toml` - -- [ ] **Step 1: Append the `fastmcp` extra** - -In `pyproject.toml` under `[project.optional-dependencies]`, immediately after the `faststream-all` block (line 114–117 currently), add: - -```toml -fastmcp = [ - "fastmcp", -] -fastmcp-metrics = [ - "lite-bootstrap[fastmcp]", - "prometheus-client>=0.20", -] -``` - -Do NOT add `fastmcp-sentry`, `fastmcp-logging`, or `fastmcp-all` — composite extras that don't pull in a new dependency are excluded by the project rule. - -- [ ] **Step 2: Append the `"fastmcp"` keyword** - -In `pyproject.toml` line 10–21, the `keywords` list ends with `"structlog",`. Insert `"fastmcp",` before the closing bracket so it joins the existing list: - -```toml -keywords = [ - "python", - "microservice", - "bootstrap", - "opentelemetry", - "sentry", - "error-tracing", - "fastapi", - "litestar", - "faststream", - "structlog", - "fastmcp", -] -``` - -- [ ] **Step 3: Sync dependencies** - -Run: `just install` - -Expected: `uv lock --upgrade` updates `uv.lock` to include `fastmcp` (and its transitive deps: `starlette`, `mcp`, etc.). `uv sync --all-extras --frozen --group lint` installs them. No errors. - -- [ ] **Step 4: Verify fastmcp is importable** - -Run: `uv run --no-sync python -c "from fastmcp import FastMCP; print(FastMCP)"` - -Expected: prints `` (or similar — confirms install). - -Run: `uv run --no-sync python -c "from lite_bootstrap.import_checker import is_fastmcp_installed; print(is_fastmcp_installed)"` - -Expected: prints `True`. - -- [ ] **Step 5: Commit** - -```bash -git add pyproject.toml uv.lock -git commit -m "feat: add fastmcp and fastmcp-metrics extras" -``` - ---- - -## Task 4: Scaffold `fastmcp_bootstrapper.py` with config + module helpers - -**Files:** -- Create: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` -- Create: `tests/test_fastmcp_bootstrap.py` - -This task lands the module skeleton: imports, optional-import guards, `_make_fastmcp`, `_build_teardown_lifespan`, `FastMcpConfig`, and a minimal `FastMcpBootstrapper` with `instruments_types = []`. Tests at the end of this task: default-factory yields a `FastMCP`, `bootstrap()` returns the same `FastMCP`, "not ready when fastmcp missing" raises. - -### Step 1: Write the failing tests - -- [ ] Create `tests/test_fastmcp_bootstrap.py` with the following content: - -```python -import contextlib -import time -import typing - -import prometheus_client -import pytest -import structlog -from fastmcp import FastMCP -from fastmcp.server.middleware import Middleware, MiddlewareContext -from starlette import status -from starlette.testclient import TestClient - -from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig -from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware -from tests.conftest import emulate_package_missing - - -logger = structlog.getLogger(__name__) - - -def test_fastmcp_config_default_application() -> None: - config = FastMcpConfig() - assert isinstance(config.application, FastMCP) - - -def test_fastmcp_bootstrap_returns_same_application() -> None: - config = FastMcpConfig(service_name="test-mcp", service_version="1.2.3") - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - assert application is config.application - bootstrapper.teardown() - - -def test_fastmcp_bootstrapper_not_ready() -> None: - with emulate_package_missing("fastmcp"), pytest.raises(RuntimeError, match="fastmcp is not installed"): - FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) -``` - -### Step 2: Run tests to verify failure mode - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v` - -Expected: All three tests fail with `ImportError: cannot import name 'FastMcpBootstrapper' from 'lite_bootstrap'` (collection error). This is the failure we want — it proves the symbols don't yet exist. - -### Step 3: Create the bootstrapper module skeleton - -- [ ] Create `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` with the following content: - -```python -import dataclasses -import time -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.bootstrappers.base import BaseBootstrapper -from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument -from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument -from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument -from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument - - -if import_checker.is_fastmcp_installed: - from fastmcp import FastMCP - from fastmcp.server.middleware import Middleware, MiddlewareContext - from starlette.requests import Request - from starlette.responses import JSONResponse, Response - -if import_checker.is_structlog_installed: - import structlog - - fastmcp_access_logger: typing.Final = structlog.get_logger("mcp.access") - -if import_checker.is_prometheus_client_installed: - import prometheus_client - - -def _make_fastmcp() -> "FastMCP[typing.Any]": - return FastMCP() - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastMcpConfig( - HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp) - logging_turn_off_middleware: bool = False - - -class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): - __slots__ = "bootstrap_config", "instruments" - - instruments_types: typing.ClassVar = [] - bootstrap_config: FastMcpConfig - not_ready_message = "fastmcp is not installed" - - def is_ready(self) -> bool: - return import_checker.is_fastmcp_installed - - def _prepare_application(self) -> "FastMCP[typing.Any]": - return self.bootstrap_config.application -``` - -Note: no `__init__` override and no lifespan wiring. `FastMCP.lifespan` was empirically determined to be a read-only bound method, with the real hook stored in private `_lifespan` at construction time. Per the spec's documented fallback, teardown is manual — users call `bootstrapper.teardown()` themselves. - -### Step 4: Re-export from package __init__ - -- [ ] Update `lite_bootstrap/__init__.py`. Insert imports alphabetically (after the existing `faststream_bootstrapper` import) and add the names to `__all__`: - -```python -from lite_bootstrap.bootstrappers.fastapi_bootstrapper import FastAPIBootstrapper, FastAPIConfig -from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpBootstrapper, FastMcpConfig -from lite_bootstrap.bootstrappers.faststream_bootstrapper import FastStreamBootstrapper, FastStreamConfig -``` - -Add `"FastMcpBootstrapper"` and `"FastMcpConfig"` to `__all__`, keeping alphabetical order: - -```python -__all__ = [ - "BootstrapperNotReadyError", - "ConfigurationError", - "FastAPIBootstrapper", - "FastAPIConfig", - "FastMcpBootstrapper", - "FastMcpConfig", - "FastStreamBootstrapper", - "FastStreamConfig", - ... -] -``` - -### Step 5: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v` - -Expected: all three tests pass. - -If `test_fastmcp_bootstrapper_not_ready` errors with `Failed: DID NOT RAISE`, recheck that `FastMcpBootstrapper.is_ready()` reads `import_checker.is_fastmcp_installed` (not a cached module-level value). - -### Step 6: Commit - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py lite_bootstrap/__init__.py tests/test_fastmcp_bootstrap.py -git commit -m "feat: scaffold FastMcpBootstrapper and FastMcpConfig" -``` - ---- - -## Task 5: Verify teardown resets is_bootstrapped (direct + via ASGI lifespan) - -Initially this task covered only the direct-call assertion because the design first concluded teardown had to be manual (the `FastMCP.lifespan` read-only finding). Once `add_provider` + `Provider.lifespan` was identified as the right post-construction hook, the ASGI-lifespan replay test was restored alongside the direct test. Both land in the same task. (The earlier `test_fastmcp_bootstrap_returns_same_application` test calls `teardown()` at the end too; this task makes the assertion explicit and adds the lifespan-driven counterpart.) - -**Files:** -- Modify: `tests/test_fastmcp_bootstrap.py` - -### Step 1: Write the failing test - -- [ ] Append to `tests/test_fastmcp_bootstrap.py`: - -```python -def test_fastmcp_teardown_resets_is_bootstrapped() -> None: - bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) - bootstrapper.bootstrap() - assert bootstrapper.is_bootstrapped is True - bootstrapper.teardown() - assert bootstrapper.is_bootstrapped is False -``` - -### Step 2: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_teardown_resets_is_bootstrapped -v` - -Expected: passes immediately (the assertions only exercise the base class's idempotent teardown plumbing, which already works). - -### Step 3: Commit - -```bash -git add tests/test_fastmcp_bootstrap.py -git commit -m "test: verify FastMcpBootstrapper teardown resets state" -``` - ---- - -## Task 6: Add `FastMcpLoggingMiddleware` - -The MCP protocol-level middleware. Logs `method`, `source`, `type`, and duration to `mcp.access` structlog logger. 1:1 port of microbootstrap PR141's middleware. - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` -- Modify: `tests/test_fastmcp_bootstrap.py` - -### Step 1: Write the failing tests - -- [ ] Append to `tests/test_fastmcp_bootstrap.py`: - -```python -async def test_fastmcp_logging_middleware_logs_success(monkeypatch: pytest.MonkeyPatch) -> None: - from unittest.mock import MagicMock - - fake_logger = MagicMock() - monkeypatch.setattr( - "lite_bootstrap.bootstrappers.fastmcp_bootstrapper.fastmcp_access_logger", - fake_logger, - ) - middleware = FastMcpLoggingMiddleware() - context = MiddlewareContext( - message={"payload": "test"}, - method="tools/list", - source="client", - type="request", - ) - - async def call_next(received: MiddlewareContext[typing.Any]) -> dict[str, str]: - assert received is context - return {"status": "ok"} - - result = await middleware.on_message(context, call_next) - - assert result == {"status": "ok"} - fake_logger.info.assert_called_once() - call_kwargs = fake_logger.info.call_args - assert call_kwargs.args[0] == "tools/list" - assert call_kwargs.kwargs["mcp"] == { - "method": "tools/list", - "source": "client", - "type": "request", - } - assert isinstance(call_kwargs.kwargs["duration"], int) - - -async def test_fastmcp_logging_middleware_logs_exception(monkeypatch: pytest.MonkeyPatch) -> None: - from unittest.mock import MagicMock - - fake_logger = MagicMock() - monkeypatch.setattr( - "lite_bootstrap.bootstrappers.fastmcp_bootstrapper.fastmcp_access_logger", - fake_logger, - ) - middleware = FastMcpLoggingMiddleware() - context = MiddlewareContext( - message={"payload": "test"}, - method="tools/call", - source="client", - type="request", - ) - - class CustomError(RuntimeError): - pass - - async def call_next(_: MiddlewareContext[typing.Any]) -> None: - raise CustomError("boom") - - with pytest.raises(CustomError, match="boom"): - await middleware.on_message(context, call_next) - - fake_logger.exception.assert_called_once() - fake_logger.info.assert_not_called() -``` - -### Step 2: Run tests to verify failure mode - -Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_success tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_exception -v` - -Expected: failure with `ImportError: cannot import name 'FastMcpLoggingMiddleware' from 'lite_bootstrap.bootstrappers.fastmcp_bootstrapper'` (collection error). - -### Step 3: Add the middleware to the bootstrapper module - -- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after the `_build_teardown_lifespan` function and before the `FastMcpConfig` dataclass, add: - -```python -class FastMcpLoggingMiddleware(Middleware): - async def on_message( - self, - context: "MiddlewareContext[typing.Any]", - call_next: "typing.Callable[[MiddlewareContext[typing.Any]], typing.Awaitable[typing.Any]]", - ) -> typing.Any: # noqa: ANN401 - start_time = time.perf_counter_ns() - mcp_fields = { - "method": context.method, - "source": context.source, - "type": context.type, - } - try: - result = await call_next(context) - except Exception: - fastmcp_access_logger.exception( - context.method or "unknown", - mcp=mcp_fields, - duration=time.perf_counter_ns() - start_time, - ) - raise - - fastmcp_access_logger.info( - context.method or "unknown", - mcp=mcp_fields, - duration=time.perf_counter_ns() - start_time, - ) - return result -``` - -### Step 4: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_success tests/test_fastmcp_bootstrap.py::test_fastmcp_logging_middleware_logs_exception -v` - -Expected: both pass. - -### Step 5: Commit - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py -git commit -m "feat: add FastMcpLoggingMiddleware" -``` - ---- - -## Task 7: Add `FastMcpHealthChecksInstrument` and register it - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` -- Modify: `tests/test_fastmcp_bootstrap.py` - -### Step 1: Write the failing tests - -- [ ] Append to `tests/test_fastmcp_bootstrap.py`: - -```python -def _make_test_config(**overrides: typing.Any) -> FastMcpConfig: - base: dict[str, typing.Any] = { - "service_name": "test-mcp", - "service_version": "1.2.3", - "logging_buffer_capacity": 0, - } - base.update(overrides) - return FastMcpConfig(**base) - - -def test_fastmcp_health_check_route_serves_200_with_data() -> None: - config = _make_test_config() - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with TestClient(application.http_app()) as test_client: - response = test_client.get(config.health_checks_path) - assert response.status_code == status.HTTP_200_OK - assert response.json() == { - "health_status": True, - "service_name": "test-mcp", - "service_version": "1.2.3", - } - finally: - bootstrapper.teardown() - - -def test_fastmcp_health_check_path_is_configurable() -> None: - config = _make_test_config(health_checks_path="/healthz") - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with TestClient(application.http_app()) as test_client: - response = test_client.get("/healthz") - default_response = test_client.get("/health/") - assert response.status_code == status.HTTP_200_OK - assert default_response.status_code == status.HTTP_404_NOT_FOUND - finally: - bootstrapper.teardown() - - -def test_fastmcp_health_check_disabled_when_flag_false() -> None: - config = _make_test_config(health_checks_enabled=False) - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with TestClient(application.http_app()) as test_client: - response = test_client.get(config.health_checks_path) - assert response.status_code == status.HTTP_404_NOT_FOUND - finally: - bootstrapper.teardown() -``` - -### Step 2: Run tests to verify failure mode - -Run: `just test -- tests/test_fastmcp_bootstrap.py::test_fastmcp_health_check_route_serves_200_with_data tests/test_fastmcp_bootstrap.py::test_fastmcp_health_check_path_is_configurable tests/test_fastmcp_bootstrap.py::test_fastmcp_health_check_disabled_when_flag_false -v` - -Expected: all three fail with `404` (no health instrument registered yet). - -### Step 3: Add the instrument - -- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after `FastMcpLoggingMiddleware`, add: - -```python -@dataclasses.dataclass(kw_only=True) -class FastMcpHealthChecksInstrument(HealthChecksInstrument): - bootstrap_config: FastMcpConfig - - def bootstrap(self) -> None: - @self.bootstrap_config.application.custom_route( - self.bootstrap_config.health_checks_path, - methods=["GET"], - name="health_check", - include_in_schema=self.bootstrap_config.health_checks_include_in_schema, - ) - async def health_check_handler(_: "Request") -> "JSONResponse": - return JSONResponse(dict(self.render_health_check_data())) -``` - -- [ ] Update `FastMcpBootstrapper.instruments_types` to include the new instrument: - -```python - instruments_types: typing.ClassVar = [ - FastMcpHealthChecksInstrument, - ] -``` - -### Step 4: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "health_check"` - -Expected: all three health-check tests pass. The earlier tests (`test_fastmcp_bootstrap_returns_same_application` etc.) still pass. - -### Step 5: Commit - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py -git commit -m "feat: add FastMcpHealthChecksInstrument" -``` - ---- - -## Task 8: Add `FastMcpPrometheusInstrument` and register it - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` -- Modify: `tests/test_fastmcp_bootstrap.py` - -### Step 1: Write the failing tests - -- [ ] Append to `tests/test_fastmcp_bootstrap.py`: - -```python -def test_fastmcp_prometheus_route_exposes_registered_metric() -> None: - counter_name = "fastmcp_plan_test_requests_total" - # Counter constructors register against the default registry by default; if a - # prior test registered the same name, reuse it rather than re-registering. - try: - counter = prometheus_client.Counter(counter_name, "FastMCP plan test counter.") - except ValueError: - # Already registered from a prior test in the same process. - collector = prometheus_client.REGISTRY._names_to_collectors[counter_name] # noqa: SLF001 - counter = typing.cast(prometheus_client.Counter, collector) - counter.inc() - - config = _make_test_config() - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with TestClient(application.http_app()) as test_client: - response = test_client.get(config.prometheus_metrics_path) - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"].startswith(prometheus_client.CONTENT_TYPE_LATEST.split(";")[0]) - assert counter_name.encode() in response.content - finally: - bootstrapper.teardown() - - -def test_fastmcp_prometheus_path_is_configurable() -> None: - config = _make_test_config(prometheus_metrics_path="/m") - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with TestClient(application.http_app()) as test_client: - response = test_client.get("/m") - default_response = test_client.get("/metrics") - assert response.status_code == status.HTTP_200_OK - assert default_response.status_code == status.HTTP_404_NOT_FOUND - finally: - bootstrapper.teardown() -``` - -### Step 2: Run tests to verify failure mode - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "prometheus"` - -Expected: both fail with `404` (no prometheus instrument registered). - -### Step 3: Add the instrument - -- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after `FastMcpHealthChecksInstrument`, add: - -```python -@dataclasses.dataclass(kw_only=True) -class FastMcpPrometheusInstrument(PrometheusInstrument): - bootstrap_config: FastMcpConfig - missing_dependency_message = "prometheus_client is not installed" - - @staticmethod - def check_dependencies() -> bool: - return import_checker.is_prometheus_client_installed - - def bootstrap(self) -> None: - @self.bootstrap_config.application.custom_route( - self.bootstrap_config.prometheus_metrics_path, - methods=["GET"], - name="metrics", - include_in_schema=self.bootstrap_config.prometheus_metrics_include_in_schema, - ) - async def metrics_handler(_: "Request") -> "Response": - return Response( - prometheus_client.generate_latest(prometheus_client.REGISTRY), - headers={"content-type": prometheus_client.CONTENT_TYPE_LATEST}, - ) -``` - -- [ ] Update `FastMcpBootstrapper.instruments_types`: - -```python - instruments_types: typing.ClassVar = [ - FastMcpHealthChecksInstrument, - FastMcpPrometheusInstrument, - ] -``` - -### Step 4: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "prometheus or health_check"` - -Expected: all five tests pass. - -### Step 5: Commit - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py -git commit -m "feat: add FastMcpPrometheusInstrument" -``` - ---- - -## Task 9: Add `FastMcpLoggingInstrument` and register it - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` -- Modify: `tests/test_fastmcp_bootstrap.py` - -### Step 1: Write the failing tests - -- [ ] Append to `tests/test_fastmcp_bootstrap.py`: - -```python -def _find_mcp_logging_middleware(application: "FastMCP") -> list[FastMcpLoggingMiddleware]: - return [m for m in application.middleware if isinstance(m, FastMcpLoggingMiddleware)] - - -def test_fastmcp_logging_middleware_is_mounted_by_default() -> None: - config = _make_test_config() - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - assert len(_find_mcp_logging_middleware(application)) == 1 - finally: - bootstrapper.teardown() - - -def test_fastmcp_logging_middleware_disabled_via_flag() -> None: - config = _make_test_config(logging_turn_off_middleware=True) - bootstrapper = FastMcpBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - assert _find_mcp_logging_middleware(application) == [] - finally: - bootstrapper.teardown() -``` - -### Step 2: Run tests to verify failure mode - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v -k "logging_middleware_is_mounted or logging_middleware_disabled"` - -Expected: `test_fastmcp_logging_middleware_is_mounted_by_default` fails (`assert 0 == 1`), `test_fastmcp_logging_middleware_disabled_via_flag` passes incidentally. - -### Step 3: Add the instrument - -- [ ] In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, after `FastMcpPrometheusInstrument`, add: - -```python -@dataclasses.dataclass(kw_only=True) -class FastMcpLoggingInstrument(LoggingInstrument): - bootstrap_config: FastMcpConfig - - def bootstrap(self) -> None: - super().bootstrap() - if self.bootstrap_config.logging_turn_off_middleware: - return - if not import_checker.is_structlog_installed: - return - self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) -``` - -- [ ] Update `FastMcpBootstrapper.instruments_types` to its final form (PyroscopeInstrument, SentryInstrument, then framework-specific ones — matching `FastStreamBootstrapper`'s order): - -```python - instruments_types: typing.ClassVar = [ - PyroscopeInstrument, - SentryInstrument, - FastMcpHealthChecksInstrument, - FastMcpLoggingInstrument, - FastMcpPrometheusInstrument, - ] -``` - -### Step 4: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v` - -Expected: all tests pass. - -### Step 5: Commit - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py -git commit -m "feat: add FastMcpLoggingInstrument with MCP access middleware" -``` - ---- - -## Task 10: Add missing-dependency regression tests - -Per the established project pattern (`test_faststream_bootstrap.py::test_faststream_bootstrapper_with_missing_instrument_dependency`), every framework bootstrapper has a parametrized test that confirms missing optional dependencies surface as warnings, not crashes. - -**Files:** -- Modify: `tests/test_fastmcp_bootstrap.py` - -### Step 1: Write the failing tests - -- [ ] Update the imports at the top of `tests/test_fastmcp_bootstrap.py` to include the with-reload helper: - -```python -from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload -``` - -- [ ] Append to `tests/test_fastmcp_bootstrap.py`: - -```python -@pytest.mark.parametrize( - "package_name", - [ - "sentry_sdk", - "structlog", - "prometheus_client", - ], -) -def test_fastmcp_bootstrapper_with_missing_instrument_dependency(package_name: str) -> None: - with emulate_package_missing(package_name), pytest.warns(UserWarning, match=package_name): - FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) - - -def test_fastmcp_bootstrap_without_prometheus_client() -> None: - # Regression guard mirroring the FastStream prometheus-missing test: ensures - # FastMcpPrometheusInstrument.check_dependencies() prevents construction-time - # failure when prometheus_client is absent. - with emulate_package_missing_with_module_reload( - "prometheus_client", - ["lite_bootstrap.bootstrappers.fastmcp_bootstrapper"], - ): - with pytest.warns(UserWarning, match="prometheus_client"): - bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) - bootstrapper.bootstrap() - bootstrapper.teardown() - - -def test_fastmcp_bootstrap_without_structlog() -> None: - # Regression guard: FastMcpLoggingInstrument.bootstrap() must short-circuit - # the middleware registration when structlog is absent, because the - # middleware references fastmcp_access_logger which only exists inside - # the structlog guard. - with emulate_package_missing_with_module_reload( - "structlog", - ["lite_bootstrap.bootstrappers.fastmcp_bootstrapper"], - ): - with pytest.warns(UserWarning, match="structlog"): - bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) - bootstrapper.bootstrap() - bootstrapper.teardown() -``` - -### Step 2: Run tests to verify pass - -Run: `just test -- tests/test_fastmcp_bootstrap.py -v` - -Expected: all tests pass. If `test_fastmcp_bootstrap_without_structlog` fails with a `NameError: name 'fastmcp_access_logger' is not defined`, recheck that `FastMcpLoggingInstrument.bootstrap()` shorts on `not import_checker.is_structlog_installed` *before* instantiating `FastMcpLoggingMiddleware`. - -### Step 3: Commit - -```bash -git add tests/test_fastmcp_bootstrap.py -git commit -m "test: cover missing optional dependencies for FastMcpBootstrapper" -``` - ---- - -## Task 11: Documentation updates - -Documents the new bootstrapper end-to-end. - -**Files:** -- Modify: `README.md` -- Modify: `docs/index.md` -- Modify: `docs/introduction/installation.md` -- Modify: `CLAUDE.md` -- Create: `docs/integrations/fastmcp.md` - -### Step 1: Update `README.md` - -- [ ] In `README.md` line 22–25, append `- [FastMCP](https://lite-bootstrap.readthedocs.io/integrations/fastmcp)` to the bullet list: - -```markdown -Those instruments can be bootstrapped for: - -- [LiteStar](https://lite-bootstrap.readthedocs.io/integrations/litestar) -- [FastStream](https://lite-bootstrap.readthedocs.io/integrations/faststream) -- [FastAPI](https://lite-bootstrap.readthedocs.io/integrations/fastapi) -- [FastMCP](https://lite-bootstrap.readthedocs.io/integrations/fastmcp) -- [services and scripts without frameworks](https://lite-bootstrap.readthedocs.io/integrations/free) -``` - -### Step 2: Update `docs/index.md` - -- [ ] In `docs/index.md` line 19–22, append the matching bullet: - -```markdown -Those instruments can be bootstrapped for: - -- [LiteStar](integrations/litestar) -- [FastStream](integrations/faststream) -- [FastAPI](integrations/fastapi) -- [FastMCP](integrations/fastmcp) -- [services and scripts without frameworks](integrations/free) -``` - -### Step 3: Create `docs/integrations/fastmcp.md` - -- [ ] Write `docs/integrations/fastmcp.md` with the following content: - -````markdown -# Usage with `FastMCP` - -## 1. Install `lite-bootstrap` with the FastMCP extras and any instruments you want: - -`lite-bootstrap` does not ship a `fastmcp-all` rollup extra — compose the extras -you need explicitly. - -=== "uv" - - ```bash - uv add 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' - ``` - -=== "pip" - - ```bash - pip install 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' - ``` - -=== "poetry" - - ```bash - poetry add 'lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]' - ``` - -Read more about available extras [here](../../../introduction/installation): - -## 2. Define bootstrapper config and build your application: - -```python -from fastmcp import FastMCP -from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig - - -bootstrapper_config = FastMcpConfig( - service_name="microservice", - service_version="2.0.0", - service_environment="test", - sentry_dsn="https://testdsn@localhost/1", - prometheus_metrics_path="/custom-metrics/", - health_checks_path="/custom-health/", - logging_buffer_capacity=0, -) -bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config) -application: FastMCP = bootstrapper.bootstrap() - - -@application.tool -def greet_person(person_name: str) -> str: - return f"Hello, {person_name}!" -``` - -Set `logging_turn_off_middleware=True` on the config to disable the per-MCP-message -access log middleware. Set `health_checks_enabled=False` to omit the health route. - -## 3. Teardown - -`FastMcpBootstrapper` does not wire teardown automatically (FastMCP captures its -lifespan at construction time only). Call `bootstrapper.teardown()` yourself -during shutdown — typically from a `lifespan=` callable you pass to `FastMCP`, -from an ASGI shutdown handler, or via `atexit`: - -```python -import contextlib -from fastmcp import FastMCP - - -@contextlib.asynccontextmanager -async def lifespan(app: FastMCP): - try: - yield - finally: - bootstrapper.teardown() - - -bootstrapper_config = FastMcpConfig( - service_name="microservice", - application=FastMCP(lifespan=lifespan), -) -bootstrapper = FastMcpBootstrapper(bootstrap_config=bootstrapper_config) -application = bootstrapper.bootstrap() -``` - -Read more about available configuration options [here](../../../introduction/configuration): -```` - -### Step 4: Update `docs/introduction/installation.md` - -- [ ] In the extras table (`docs/introduction/installation.md` lines 7–17), insert a new `FastMCP` column after `FastAPI`. The table becomes: - -```markdown -| Instrument | Litestar | Faststream | FastAPI | FastMCP | Free Bootstrapper, without framework | -|---------------|--------------------|----------------------|-------------------|--------------------------|--------------------------------------| -| sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` (compose) | `sentry` | -| prometheus | `litestar-metrics` | `faststream-metrics` | `fastapi-metrics` | `fastmcp-metrics` | not used | -| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | not used | `otl` | -| pyroscope | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | -| structlog | `litestar-logging` | `faststream-logging` | `fastapi-logging` | `logging` (compose) | `logging` | -| cors | no extra | not used | no extra | not used | not used | -| swagger | no extra | not used | no extra | not used | not used | -| health-checks | no extra | no extra | no extra | no extra | not used | -| all | `litestar-all` | `faststream-all` | `fastapi-all` | no rollup (compose) | `free-all` | -``` - -Above the table (after line 5), add the note: - -```markdown -FastMCP has no per-pair (`fastmcp-sentry`, …) or rollup (`fastmcp-all`) extras because they would not pull in new dependencies. Compose what you need yourself, e.g. `lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]`. -``` - -### Step 5: Update `CLAUDE.md` - -- [ ] In `CLAUDE.md` under "Core pattern" (the `BaseBootstrapper` tree), insert `FastMcpBootstrapper` before `FreeBootstrapper`: - -``` -BaseBootstrapper (abc.ABC) - ├── FastAPIBootstrapper - ├── LitestarBootstrapper - ├── FastStreamBootstrapper - ├── FastMcpBootstrapper - └── FreeBootstrapper -``` - -Do not add a row to the "Optional dependency groups" table — there is no `fastmcp-all` rollup, and the table only lists rollups. - -### Step 6: Commit - -```bash -git add README.md docs/index.md docs/introduction/installation.md CLAUDE.md docs/integrations/fastmcp.md -git commit -m "docs: document FastMcpBootstrapper integration" -``` - ---- - -## Task 12: Final lint, full test, and PR - -**Files:** (no source changes; verification + PR) - -### Step 1: Full lint - -Run: `just lint` - -Expected: clean exit. If `ty` reports issues, fix them inline before continuing. Typical pyright false positives (`reportPossiblyUnbound`, etc.) are suppressed project-wide per the existing `[tool.pyright]` block and should not surface; `ty` is the enforcing checker. - -### Step 2: Full test suite - -Run: `just test` - -Expected: full pass. Confirm `tests/test_fastmcp_bootstrap.py` reports ~15 tests, all passing. Total suite count should be ≈ previous_count + 15 (parametrized). - -### Step 3: Branch verification - -Run: `git log --oneline main..HEAD` - -Expected: 10 commits, in order: - -``` -docs: document FastMcpBootstrapper integration -test: cover missing optional dependencies for FastMcpBootstrapper -feat: add FastMcpLoggingInstrument with MCP access middleware -feat: add FastMcpPrometheusInstrument -feat: add FastMcpHealthChecksInstrument -feat: add FastMcpLoggingMiddleware -test: verify FastMcpBootstrapper teardown via ASGI lifespan -feat: scaffold FastMcpBootstrapper and FastMcpConfig -feat: add fastmcp and fastmcp-metrics extras -feat: detect fastmcp via import_checker -``` - -(Newest first.) - -### Step 4: Push and open PR - -```bash -git push -u origin feat/fastmcp-bootstrapper -``` - -Then open the PR: - -```bash -gh pr create --title "feat: add FastMcpBootstrapper" --body "$(cat <<'EOF' -## Summary - -- Add `FastMcpBootstrapper` and `FastMcpConfig` wiring the lite-bootstrap instrument stack (Sentry, Pyroscope, structlog logging with MCP-aware access middleware, health checks, Prometheus metrics) onto a FastMCP server. -- Plumb teardown through the FastMCP lifespan via `combine_lifespans` so shutdown runs deterministically on ASGI lifespan shutdown. -- Add `fastmcp` and `fastmcp-metrics` extras. No composite or rollup extras (project rule: extras must add a direct dependency). -- Document the integration (README, docs/index, new `docs/integrations/fastmcp.md`, extras table). - -Mirrors the instrument set merged upstream in [microbootstrap PR #141](https://github.com/community-of-python/microbootstrap/pull/141) and improves on it by wiring teardown. - -Spec: `./design.md`. - -## Test plan - -- [x] `just test` — full suite passes (including 15 new fastmcp tests) -- [x] `just lint` — clean -- [ ] Manual smoke test: build a FastMCP server, register `/health/` and `/metrics`, hit them via `application.http_app()` - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Return the PR URL. - ---- - -## Self-review checklist (run before handoff) - -1. **Spec coverage:** - - Config (`FastMcpConfig` + `application` field + `logging_turn_off_middleware`) → Task 4 Step 3. - - `FastMcpLoggingMiddleware` → Task 6. - - `FastMcpHealthChecksInstrument` → Task 7. - - `FastMcpPrometheusInstrument` → Task 8. - - `FastMcpLoggingInstrument` → Task 9. - - `FastMcpBootstrapper` + teardown lifespan wiring → Task 4 Step 3 (skeleton) + Task 5 (test). - - `is_fastmcp_installed` → Task 2. - - `fastmcp` + `fastmcp-metrics` extras → Task 3. - - All 13 spec tests → distributed across Tasks 4, 5, 6, 7, 8, 9; the missing-dependency parametrized test + structlog-absent guard from spec §Edge cases → Task 10. - - All docs changes → Task 11. - - PyPI keyword → Task 3 Step 2. - -2. **Placeholder scan:** No "TBD", "TODO", or "fill in" markers. Every code step contains the actual code. No "similar to Task N" references — each task is self-contained. - -3. **Type consistency:** - - `FastMcpConfig` used consistently in all instrument `bootstrap_config:` annotations (Tasks 4, 7, 8, 9). - - `FastMcpLoggingMiddleware` referenced by full name in Tasks 6 (definition), 9 (registration), 10 (regression test) — no typo variants. - - `application.lifespan` mutation pattern in Task 4 Step 3 matches the test in Task 5 Step 1 (no signature drift). - - `_make_test_config` helper introduced in Task 7 Step 1 is referenced in Tasks 8 and 9 — confirm Task 8/9 do NOT re-import it. - - `_drive_asgi_lifespan` helper introduced in Task 5 Step 1 — only used in Task 5 tests; no later task references it. - - `_find_mcp_logging_middleware` helper introduced in Task 9 Step 1 — only used in Task 9. - -4. **No new test deps:** All tests use stdlib (`contextlib`, `time`, `typing`, `unittest.mock`), already-installed test deps (`pytest`, `structlog`, `prometheus_client`), `starlette.testclient` (transitive via `fastmcp`), and `fastmcp` itself (Task 3 install). No new dev-group entries needed. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/design.md b/planning/changes/2026-06-01.03-deferred-refactors.md similarity index 100% rename from planning/changes/2026-06-01.03-deferred-refactors/design.md rename to planning/changes/2026-06-01.03-deferred-refactors.md diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan.md b/planning/changes/2026-06-01.03-deferred-refactors/plan.md deleted file mode 100644 index 84914c8..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan.md +++ /dev/null @@ -1,3305 +0,0 @@ -# 01.03-deferred-refactors — implementation plan - -> Multi-PR plan: this change shipped as a sequence of PRs. Each section below was an independent per-PR plan; they are preserved verbatim here as the bundle's single `plan.md` (the spec is [`design.md`](./design.md)). - - ---- - -# PR8: Sentry Micro-Fixes (LOW-1 + LOW-2) - -> **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:** Two small idiom/typing fixes in `sentry_instrument.py`: -- LOW-1: Replace `if not callback:` (sloppy callable truthiness check) with `if callback is None:` in `wrap_before_send_callbacks`. -- LOW-2: Replace `SentryConfig.sentry_before_send`'s degenerate `Callable[[Any, Any], Any | None] | None` annotation with the proper `sentry_types.EventProcessor | None`. - -Both are tiny; no behavior change. - -**Architecture:** Single-file edit. Two unrelated micro-fixes bundled because both touch the same module and reviewing them separately would cost more than reviewing them together. - -**Tech Stack:** Python 3.10+, sentry_sdk types (`sentry_sdk._types.EventProcessor`). - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR8 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (LOW-1, LOW-2). - ---- - -## File Structure - -One file modified. - -- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — two changes (lines 36 and 81-82). - ---- - -## Locked decisions - -- **No new tests.** Existing Sentry tests (`tests/instruments/test_sentry_instrument.py`) cover the affected code paths. LOW-1 is an idiom change with identical runtime behavior for the only realistic input (callables and `None`); LOW-2 is type-only. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/low-1-2-sentry-micro -``` - -Expected: `Switched to a new branch 'fix/low-1-2-sentry-micro'`. - ---- - -## Task 2: Apply two micro-fixes, verify, commit - -### Step 1: Fix LOW-2 (`sentry_before_send` typing) - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:36` - -Current line: - -```python - sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None -``` - -This annotation is degenerate: `typing.Any | None` collapses to `typing.Any` (since `Any` is the top type for type-checking purposes), so the outer `| None` is the only meaningful nullability. Functionally the annotation reduces to `Callable[..., Any] | None`. - -The file already imports the proper type under `TYPE_CHECKING` at lines 10-12: - -```python -if typing.TYPE_CHECKING: - from sentry_sdk import _types as sentry_types - from sentry_sdk.integrations import Integration -``` - -And `wrap_before_send_callbacks` (later in the same file) already uses `"sentry_types.EventProcessor"` in its own signature: - -```python -def wrap_before_send_callbacks( - *callbacks: typing.Optional["sentry_types.EventProcessor"], -) -> "sentry_types.EventProcessor": -``` - -So `sentry_types.EventProcessor` is already in scope. Replace line 36 with: - -```python - sentry_before_send: "sentry_types.EventProcessor | None" = None -``` - -The string annotation form avoids a `NameError` at runtime when `sentry_sdk` isn't installed (since `sentry_types` lives under the `TYPE_CHECKING` block). - -### Step 2: Fix LOW-1 (`if not callback:` → `if callback is None:`) - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:80-82` - -Current code: - -```python - def run_before_send( - event: "sentry_types.Event", hint: "sentry_types.Hint" - ) -> typing.Optional["sentry_types.Event"]: - for callback in callbacks: - if not callback: - continue - - temp_event = callback(event, hint) - ... -``` - -The `if not callback:` is checking truthiness, but Python callables are always truthy unless they define a custom `__bool__`. The intent is clearly "skip None entries." Replace `if not callback:` with `if callback is None:` to match the intent: - -```python - def run_before_send( - event: "sentry_types.Event", hint: "sentry_types.Hint" - ) -> typing.Optional["sentry_types.Event"]: - for callback in callbacks: - if callback is None: - continue - - temp_event = callback(event, hint) - ... -``` - -Only the conditional changes; the rest of the function is unchanged. - -### Step 3: Run the Sentry test file - -```bash -just test -- tests/instruments/test_sentry_instrument.py -v -``` - -Expected: all tests PASS. The existing tests cover both `enrich_sentry_event_from_structlog_log` (which goes through `wrap_before_send_callbacks`) and the `SentryInstrument.bootstrap()` path that uses `sentry_before_send`. - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: 89/89 (or whatever the current count is after PR7 — should be 89). No behavior change should affect any test. - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: clean. The `# ty: ignore` removal opportunity (if the original line had one — check during implementation) should also clear without warnings. - -### Step 6: Commit - -Stage the single modified file: - -```bash -git add lite_bootstrap/instruments/sentry_instrument.py -git commit -m "$(cat <<'EOF' -fix: tighten Sentry idiom and typing micro-issues - -LOW-1: wrap_before_send_callbacks used `if not callback:` to skip None -entries in *callbacks. Callables are always truthy unless they define -__bool__, so the truthiness check is semantically wrong even if it -happens to work. Use `if callback is None:` to match the intent. - -LOW-2: SentryConfig.sentry_before_send was annotated -`Callable[[Any, Any], Any | None] | None`. The inner `Any | None` -collapses to `Any`, so the annotation reduces to -`Callable[..., Any] | None` — the union adds nothing. Use the proper -`sentry_types.EventProcessor | None` instead (already imported under -TYPE_CHECKING in the same file). - -No behavior change. - -Closes LOW-1 and LOW-2 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/low-1-2-sentry-micro -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: tighten Sentry idiom and typing micro-issues" --body "$(cat <<'EOF' -## Summary -Two micro-fixes in \`sentry_instrument.py\`: - -- **LOW-1:** \`wrap_before_send_callbacks\` used \`if not callback:\` to skip \`None\` entries. Callables are always truthy unless they define \`__bool__\`, so the truthiness check is semantically wrong (happens to work, but obscures the intent). Use \`if callback is None:\` instead. -- **LOW-2:** \`SentryConfig.sentry_before_send\` was annotated \`Callable[[Any, Any], Any | None] | None\`. The inner \`Any | None\` collapses to \`Any\`, so the annotation reduces to \`Callable[..., Any] | None\` — the union adds nothing. Use the proper \`sentry_types.EventProcessor | None\` (already imported under \`TYPE_CHECKING\` in the same file). - -No behavior change. No new tests — existing Sentry tests cover both paths. - -Closes LOW-1 and LOW-2 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_sentry_instrument.py -v\` — pass. -- [x] \`just test\` — full suite passes. -- [x] \`just lint\` — clean. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR8 section) and audit (LOW-1, LOW-2): - -| Spec item | Task | -|-----------|------| -| LOW-1: `if not callback:` → `if callback is None:` | Task 2, Step 2 | -| LOW-2: `sentry_before_send` typing → `sentry_types.EventProcessor \| None` | Task 2, Step 1 | -| Branch name `fix/low-1-2-sentry-micro` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 3-5 | -| Single-file diff | Task 2, Step 6 | - -All spec items covered. No placeholders. Smallest PR in the deferred-refactors sequence; both edits are pre-existing patterns already used elsewhere in the same file (string-quoted forward reference for the type; explicit `is None` checks in other modules). - - ---- - -# PR9: OTel Instrument Touch-Ups (REF-1 + LOW-3 + LOW-5) - -> **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:** Three small OTel-area cleanups: -- **REF-1**: Hoist the identical `_build_excluded_urls()` method from `FastAPIOpenTelemetryInstrument` and `LitestarOpenTelemetryInstrument` (character-for-character duplicates) to the base `OpenTelemetryInstrument`. Use `getattr` with safe defaults so the method works even when the base instrument runs against a config without the framework-specific fields. -- **LOW-3**: Change `_format_span`'s line terminator from `os.linesep` to literal `"\n"` (OTel SDK convention; `os.linesep` produces `\r\n` on Windows). -- **LOW-5**: Add a one-line comment on `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps` explaining the `id(next_app)` cache assumption. - -**Architecture:** Three independent micro-changes, all in OTel-area files. No new tests; existing framework integration tests verify the hoist's behavior preservation. - -**Tech Stack:** Python 3.10+, OpenTelemetry SDK. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR9 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-1, LOW-3, LOW-5). - ---- - -## File Structure - -Three files modified. - -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — add `_build_excluded_urls` to the base `OpenTelemetryInstrument`; change `os.linesep` → `"\n"`. -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — delete the now-redundant `_build_excluded_urls` override. -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — delete the now-redundant `_build_excluded_urls` override; add a one-line comment on `_otel_apps`. - ---- - -## Locked decisions (from sequencing spec) - -- **REF-1 resolution:** `_build_excluded_urls` becomes a method on the base `OpenTelemetryInstrument` and reads via `getattr` with safe defaults. Framework subclasses no longer override it. -- **No new tests:** The framework integration tests (`test_fastapi_bootstrap`, `test_litestar_bootstrap`) exercise `_build_excluded_urls` via the `OpenTelemetryInstrument.bootstrap()` → instrumentor middleware chain. Hoisting + getattr is a behavior-preserving move. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-1-otel-touch-ups -``` - -Expected: `Switched to a new branch 'refactor/ref-1-otel-touch-ups'`. - ---- - -## Task 2: Apply the three changes, verify, commit - -### Step 1: REF-1 — hoist `_build_excluded_urls` to the base - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Locate the `OpenTelemetryInstrument` class (around lines 77-145 after PR7 + PR3 + PR2 landed). Add the `_build_excluded_urls` method to the class body. The natural place is after `check_dependencies()` and before `bootstrap()`. Insert: - -```python - def _build_excluded_urls(self) -> set[str]: - excluded_urls: set[str] = set(getattr(self.bootstrap_config, "opentelemetry_excluded_urls", [])) - prometheus_path = getattr(self.bootstrap_config, "prometheus_metrics_path", None) - if prometheus_path: - excluded_urls.add(prometheus_path) - if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - health_path = getattr(self.bootstrap_config, "health_checks_path", None) - if health_path: - excluded_urls.add(health_path) - return excluded_urls -``` - -Notes on the `getattr` defaults: -- `opentelemetry_excluded_urls` lives on `FastAPIConfig` and `LitestarConfig` (framework-specific). Default `[]` so the set construction is a no-op when the field is absent. -- `prometheus_metrics_path` lives on `PrometheusConfig` (inherited by FastAPI/Litestar but not by `FreeBootstrapperConfig`). Default `None`; the `if prometheus_path:` guard skips the add when absent. -- `health_checks_path` lives on `HealthChecksConfig` (same inheritance pattern). Default `None`; same guard. -- `opentelemetry_generate_health_check_spans` is on the base `OpentelemetryConfig` (always present); no getattr needed. - -This matches the existing FastAPI/Litestar override behavior exactly when the framework fields are present, and produces a safe empty set when they're absent. - -### Step 2: Delete `_build_excluded_urls` override from `FastAPIOpenTelemetryInstrument` - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:120-126` - -Locate `FastAPIOpenTelemetryInstrument`. The class currently looks like: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: FastAPIConfig - - def _build_excluded_urls(self) -> set[str]: - excluded_urls = set(self.bootstrap_config.opentelemetry_excluded_urls) - excluded_urls.add(self.bootstrap_config.prometheus_metrics_path) - if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - excluded_urls.add(self.bootstrap_config.health_checks_path) - - return excluded_urls - - def bootstrap(self) -> None: - super().bootstrap() - FastAPIInstrumentor.instrument_app( - app=self.bootstrap_config.application, - tracer_provider=get_tracer_provider(), - excluded_urls=",".join(self._build_excluded_urls()), - ) - - def teardown(self) -> None: - FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application) - super().teardown() -``` - -Delete the `_build_excluded_urls` method body (lines 120-126 in the original layout — exact line numbers may have shifted after PR7). The class becomes: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: FastAPIConfig - - def bootstrap(self) -> None: - super().bootstrap() - FastAPIInstrumentor.instrument_app( - app=self.bootstrap_config.application, - tracer_provider=get_tracer_provider(), - excluded_urls=",".join(self._build_excluded_urls()), - ) - - def teardown(self) -> None: - FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application) - super().teardown() -``` - -`self._build_excluded_urls()` now resolves to the inherited base method. - -### Step 3: Delete `_build_excluded_urls` override from `LitestarOpenTelemetryInstrument` - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:181-187` - -Locate `LitestarOpenTelemetryInstrument`. The class currently has: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: LitestarConfig - - def _build_excluded_urls(self) -> set[str]: - excluded_urls = set(self.bootstrap_config.opentelemetry_excluded_urls) - excluded_urls.add(self.bootstrap_config.prometheus_metrics_path) - if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - excluded_urls.add(self.bootstrap_config.health_checks_path) - - return excluded_urls - - def bootstrap(self) -> None: - super().bootstrap() - self.bootstrap_config.application_config.middleware.append( - LitestarOpenTelemetryInstrumentationMiddleware( - tracer_provider=get_tracer_provider(), - excluded_urls=self._build_excluded_urls(), - ) - ) -``` - -Delete the `_build_excluded_urls` method body. The class becomes: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: LitestarConfig - - def bootstrap(self) -> None: - super().bootstrap() - self.bootstrap_config.application_config.middleware.append( - LitestarOpenTelemetryInstrumentationMiddleware( - tracer_provider=get_tracer_provider(), - excluded_urls=self._build_excluded_urls(), - ) - ) -``` - -### Step 4: LOW-3 — change `os.linesep` to `"\n"` - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:25-26` - -Current `_format_span`: - -```python -def _format_span(readable_span: "ReadableSpan") -> str: - return typing.cast("str", readable_span.to_json(indent=None)) + os.linesep -``` - -Replace `os.linesep` with `"\n"`: - -```python -def _format_span(readable_span: "ReadableSpan") -> str: - return typing.cast("str", readable_span.to_json(indent=None)) + "\n" -``` - -**Important:** Do NOT remove the `import os` at the top of the file. `os.environ.get("HOSTNAME")` is still used in the `opentelemetry_container_name` default_factory. - -### Step 5: LOW-5 — add comment on `_otel_apps` cache - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` - -Locate the `LitestarOpenTelemetryInstrumentationMiddleware.__init__` (around lines 76-79): - -```python -class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: - self._tracer_provider = tracer_provider - self._excluded_urls = ",".join(excluded_urls) - self._otel_apps: dict[int, ASGIApp] = {} -``` - -Add a comment on the `_otel_apps` line: - -```python -class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: - self._tracer_provider = tracer_provider - self._excluded_urls = ",".join(excluded_urls) - # Cache keyed by id(next_app); Litestar's ASGI app instances are stable for - # the middleware lifetime, so id-reuse-after-GC isn't a concern. - self._otel_apps: dict[int, ASGIApp] = {} -``` - -### Step 6: Verify with the OTel + framework test files - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py tests/test_fastapi_bootstrap.py tests/test_litestar_bootstrap.py -v -``` - -Expected: all pass. Particularly watch the FastAPI/Litestar bootstrap tests — they exercise the full bootstrap chain that calls `_build_excluded_urls`. - -### Step 7: Run the full test suite - -```bash -just test -``` - -Expected: 89/89 PASS. No behavior change. - -### Step 8: Run lint - -```bash -just lint -``` - -Expected: clean. Watch for: -- F401 unused import warnings if `os` somehow appears unused (it shouldn't — verify). -- Any ruff complaint about the new base-class method. - -### Step 9: Commit - -Stage exactly the three modified files: - -```bash -git add \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py -git commit -m "$(cat <<'EOF' -refactor: OTel instrument touch-ups (REF-1, LOW-3, LOW-5) - -REF-1: _build_excluded_urls() was character-for-character identical in -FastAPIOpenTelemetryInstrument and LitestarOpenTelemetryInstrument. -Hoist to the base OpenTelemetryInstrument and use getattr with safe -defaults so the method works when the base instrument runs against a -config without the framework-specific fields (opentelemetry_excluded_urls, -prometheus_metrics_path, health_checks_path). - -LOW-3: _format_span used os.linesep, which produces \r\n on Windows. -The OTel SDK convention is plain \n; change to a literal. - -LOW-5: Add a one-line comment on -LitestarOpenTelemetryInstrumentationMiddleware._otel_apps documenting -the id(next_app) cache assumption (ASGI app instances are stable for -the middleware lifetime). - -No behavior change. - -Closes REF-1, LOW-3, LOW-5 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-1-otel-touch-ups -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: OTel instrument touch-ups (REF-1, LOW-3, LOW-5)" --body "$(cat <<'EOF' -## Summary -Three small OTel-area cleanups: - -- **REF-1:** \`_build_excluded_urls()\` was character-for-character identical in \`FastAPIOpenTelemetryInstrument\` and \`LitestarOpenTelemetryInstrument\`. Hoist to the base \`OpenTelemetryInstrument\` and use \`getattr\` with safe defaults so the method works when the base instrument runs against a config without the framework-specific fields (\`opentelemetry_excluded_urls\`, \`prometheus_metrics_path\`, \`health_checks_path\`). -- **LOW-3:** \`_format_span\` used \`os.linesep\` (produces \`\\r\\n\` on Windows). OTel SDK convention is plain \`\\n\`; switched to a literal. -- **LOW-5:** Added a one-line comment on \`LitestarOpenTelemetryInstrumentationMiddleware._otel_apps\` documenting the \`id(next_app)\` cache assumption. - -No behavior change. No new tests — existing framework integration tests verify the hoist's behavior preservation. - -Closes REF-1, LOW-3, LOW-5 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_opentelemetry_instrument.py tests/test_fastapi_bootstrap.py tests/test_litestar_bootstrap.py -v\` — pass. -- [x] \`just test\` — 89/89. -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the \`getattr\` defaults in the hoisted \`_build_excluded_urls\` match the pre-hoist behavior for FastAPI/Litestar (\`opentelemetry_excluded_urls\` default \`[]\`, paths default \`None\` with truthy guard). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR9 section) and audit (REF-1, LOW-3, LOW-5): - -| Spec item | Task | -|-----------|------| -| Hoist `_build_excluded_urls` to base with getattr defaults | Task 2, Step 1 | -| Delete `_build_excluded_urls` override from FastAPI | Task 2, Step 2 | -| Delete `_build_excluded_urls` override from Litestar | Task 2, Step 3 | -| `_format_span` newline change | Task 2, Step 4 | -| `_otel_apps` id() cache comment | Task 2, Step 5 | -| Branch name `refactor/ref-1-otel-touch-ups` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 6-8 | - -All spec items covered. No placeholders. The hoisted `_build_excluded_urls`'s `getattr` defaults match the pre-hoist behavior: - -- `opentelemetry_excluded_urls` default `[]` → `set([])` → empty set (same as the original `set(...)` over the empty list when the field has a default). -- `prometheus_metrics_path` truthy check ensures `None` is not added; `set.add(None)` would be a real bug. -- `health_checks_path` same truthy check. - - ---- - -# PR10: Test Gap Fill (TEST-4 + TEST-7) - -> **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:** Add standalone test files for the four instruments that today are only covered transitively via bootstrapper integration tests (`CorsInstrument`, `HealthChecksInstrument`, `PrometheusInstrument`, `SwaggerInstrument`) and add negative tests for `helpers.path.is_valid_path`. Pure additions; zero production code changes. - -**Architecture:** 5 new test files, no production changes. - -**Tech Stack:** Python 3.10+, pytest, parametrized tests. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR10 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (TEST-4, TEST-7). - ---- - -## File Structure - -5 new test files. No existing files modified. - -- Create: `tests/instruments/test_cors_instrument.py` -- Create: `tests/instruments/test_healthchecks_instrument.py` -- Create: `tests/instruments/test_prometheus_instrument.py` -- Create: `tests/instruments/test_swagger_instrument.py` -- Create: `tests/test_path.py` - ---- - -## Locked decisions - -- **Pure additions:** No production code changes. The existing transitive coverage via bootstrapper integration tests is fine; standalone tests just localize regression diagnosis. -- **Test style:** Match the existing simple-function test style in `tests/instruments/test_pyroscope_instrument.py` and `test_opentelemetry_instrument.py` (no fixtures, no shared setup, one function per behavior). - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/test-4-7-gaps -``` - -Expected: `Switched to a new branch 'fix/test-4-7-gaps'`. - ---- - -## Task 2: Create the five test files, verify, commit - -### Step 1: Create `tests/instruments/test_cors_instrument.py` - -```python -from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument - - -def test_cors_instrument_not_ready_without_origins_or_regex() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig()) - assert not instrument.is_ready() - assert instrument.not_ready_message == "cors_allowed_origins or cors_allowed_origin_regex must be provided" - - -def test_cors_instrument_ready_with_origins() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig(cors_allowed_origins=["http://test"])) - assert instrument.is_ready() - - -def test_cors_instrument_ready_with_regex() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig(cors_allowed_origin_regex=r"https?://.*")) - assert instrument.is_ready() - - -def test_cors_instrument_ready_with_both() -> None: - instrument = CorsInstrument( - bootstrap_config=CorsConfig( - cors_allowed_origins=["http://test"], - cors_allowed_origin_regex=r"https?://.*", - ), - ) - assert instrument.is_ready() - - -def test_cors_instrument_config_defaults() -> None: - config = CorsConfig() - assert config.cors_allowed_origins == [] - assert config.cors_allowed_methods == [] - assert config.cors_allowed_headers == [] - assert config.cors_exposed_headers == [] - assert config.cors_allowed_credentials is False - assert config.cors_allowed_origin_regex is None - assert config.cors_max_age == 600 - - -def test_cors_check_dependencies() -> None: - assert CorsInstrument.check_dependencies() is True -``` - -### Step 2: Create `tests/instruments/test_healthchecks_instrument.py` - -```python -from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument - - -def test_healthchecks_instrument_ready_by_default() -> None: - instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig()) - assert instrument.is_ready() - - -def test_healthchecks_instrument_not_ready_when_disabled() -> None: - instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig(health_checks_enabled=False)) - assert not instrument.is_ready() - assert instrument.not_ready_message == "health_checks_enabled is False" - - -def test_healthchecks_render_data_default() -> None: - instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig()) - data = instrument.render_health_check_data() - assert data == { - "service_version": "1.0.0", - "service_name": "micro-service", - "health_status": True, - } - - -def test_healthchecks_render_data_custom() -> None: - instrument = HealthChecksInstrument( - bootstrap_config=HealthChecksConfig(service_name="my-svc", service_version="2.0.0"), - ) - data = instrument.render_health_check_data() - assert data == { - "service_version": "2.0.0", - "service_name": "my-svc", - "health_status": True, - } - - -def test_healthchecks_config_defaults() -> None: - config = HealthChecksConfig() - assert config.health_checks_enabled is True - assert config.health_checks_path == "/health/" - assert config.health_checks_include_in_schema is False - - -def test_healthchecks_check_dependencies() -> None: - assert HealthChecksInstrument.check_dependencies() is True -``` - -### Step 3: Create `tests/instruments/test_prometheus_instrument.py` - -```python -from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument - - -def test_prometheus_instrument_ready_with_default_path() -> None: - instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig()) - assert instrument.is_ready() - - -def test_prometheus_instrument_not_ready_with_empty_path() -> None: - instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig(prometheus_metrics_path="")) - assert not instrument.is_ready() - assert instrument.not_ready_message == "prometheus_metrics_path is empty or not valid" - - -def test_prometheus_instrument_not_ready_with_invalid_path() -> None: - # No leading slash → invalid per is_valid_path regex. - instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig(prometheus_metrics_path="metrics")) - assert not instrument.is_ready() - - -def test_prometheus_instrument_ready_with_custom_valid_path() -> None: - instrument = PrometheusInstrument( - bootstrap_config=PrometheusConfig(prometheus_metrics_path="/custom-metrics/"), - ) - assert instrument.is_ready() - - -def test_prometheus_config_defaults() -> None: - config = PrometheusConfig() - assert config.prometheus_metrics_path == "/metrics" - assert config.prometheus_metrics_include_in_schema is False - - -def test_prometheus_check_dependencies() -> None: - assert PrometheusInstrument.check_dependencies() is True -``` - -### Step 4: Create `tests/instruments/test_swagger_instrument.py` - -```python -from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument - - -def test_swagger_instrument_ready_by_default() -> None: - instrument = SwaggerInstrument(bootstrap_config=SwaggerConfig()) - assert instrument.is_ready() - - -def test_swagger_config_defaults() -> None: - config = SwaggerConfig() - assert config.swagger_static_path == "/static" - assert config.swagger_path == "/docs" - assert config.swagger_offline_docs is False - - -def test_swagger_check_dependencies() -> None: - assert SwaggerInstrument.check_dependencies() is True -``` - -### Step 5: Create `tests/test_path.py` - -```python -import pytest - -from lite_bootstrap.helpers.path import is_valid_path - - -@pytest.mark.parametrize( - "path", - [ - "/metrics", - "/health/", - "/api/v1/users", - "/foo.bar", - "/foo_bar", - "/foo-bar", - "/a", - "/a/", - ], -) -def test_is_valid_path_accepts_valid(path: str) -> None: - assert is_valid_path(path) is True - - -@pytest.mark.parametrize( - "path", - [ - "", - "foo", - "foo/", - "/foo bar", - "/foo?bar", - "/foo#bar", - "/", - "//foo", - "/foo//bar", - ], -) -def test_is_valid_path_rejects_invalid(path: str) -> None: - assert is_valid_path(path) is False -``` - -Notes on the rejected cases: -- `""` — empty string fails the `^(/...)+/?$` pattern. -- `"foo"`, `"foo/"` — no leading `/`. -- `"/foo bar"` — space is not in `[a-zA-Z0-9._-]`. -- `"/foo?bar"`, `"/foo#bar"` — `?` and `#` are not in the charset. -- `"/"` — no segment after the slash; the regex requires `[a-zA-Z0-9._-]+`. -- `"//foo"`, `"/foo//bar"` — empty segments not allowed by the `+` quantifier. - -The regex DOES accept `/..` and `/../foo` because `.` is in the charset; the `is_valid_path` function does not block path traversal. That's a pre-existing design decision (out of scope here). - -### Step 6: Run the new test files - -```bash -just test -- tests/instruments/test_cors_instrument.py tests/instruments/test_healthchecks_instrument.py tests/instruments/test_prometheus_instrument.py tests/instruments/test_swagger_instrument.py tests/test_path.py -v -``` - -Expected: all new tests PASS on first run. They're pure additions verifying current behavior. - -If any test fails, investigate before continuing. The most likely cause is a wrong assertion (e.g., default value), not a real bug. - -### Step 7: Run the full test suite - -```bash -just test -``` - -Expected: total count goes from 89 to roughly 89 + (6+6+6+3+17) = ~127. All pass. - -Approximate breakdown of new tests: -- `test_cors_instrument.py`: 6 tests -- `test_healthchecks_instrument.py`: 6 tests -- `test_prometheus_instrument.py`: 6 tests -- `test_swagger_instrument.py`: 3 tests -- `test_path.py`: 17 parametrized cases across 2 functions - -### Step 8: Run lint - -```bash -just lint -``` - -Expected: clean. No production changes mean no lint-rule complications. - -### Step 9: Commit - -Stage exactly the five new files: - -```bash -git add \ - tests/instruments/test_cors_instrument.py \ - tests/instruments/test_healthchecks_instrument.py \ - tests/instruments/test_prometheus_instrument.py \ - tests/instruments/test_swagger_instrument.py \ - tests/test_path.py -git commit -m "$(cat <<'EOF' -test: add standalone instrument tests and is_valid_path negative tests - -TEST-4: Add tests/instruments/test_{cors,healthchecks,prometheus,swagger}_instrument.py -covering is_ready() across valid/invalid configurations, -not_ready_message content, render_health_check_data output shape, -config defaults, and check_dependencies. These instruments were -previously only covered transitively via bootstrapper integration -tests, which made regressions noisier to diagnose. - -TEST-7: Add tests/test_path.py with parametrized cases for -helpers.path.is_valid_path — both valid forms (default paths used by -the prometheus/swagger/healthchecks instruments) and invalid forms -(empty, no leading slash, spaces, special chars, empty segments). - -Closes TEST-4 and TEST-7 from the audit. Pure additions; no production -code changed. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/test-4-7-gaps -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "test: add standalone instrument tests and is_valid_path negative tests" --body "$(cat <<'EOF' -## Summary -Pure test additions; no production changes. - -- **TEST-4:** Standalone test files for the four instruments previously only covered transitively via bootstrapper integration tests: - - \`tests/instruments/test_cors_instrument.py\` — \`is_ready\` matrix (origins-only, regex-only, both, neither), \`not_ready_message\`, config defaults, \`check_dependencies\`. - - \`tests/instruments/test_healthchecks_instrument.py\` — enabled/disabled, \`render_health_check_data\` output shape, defaults. - - \`tests/instruments/test_prometheus_instrument.py\` — valid/invalid/empty paths, defaults. - - \`tests/instruments/test_swagger_instrument.py\` — instantiation, defaults. -- **TEST-7:** \`tests/test_path.py\` — parametrized cases for \`is_valid_path\` covering valid forms (\`/metrics\`, \`/health/\`, multi-segment, special chars in the allowed set) and invalid forms (empty, no leading slash, spaces, \`?\`/\`#\`, empty segments). - -Closes TEST-4 and TEST-7 from an internal audit. - -## Test plan -- [x] \`just test\` — full suite passes (89 prior + ~37 new ≈ 126). -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the rejected-path list in \`test_path.py\` matches the intended contract. (The regex DOES accept \`/..\` because \`.\` is in the allowed charset — pre-existing design decision; not tested as a "valid" or "invalid" case.) - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR10 section) and audit (TEST-4, TEST-7): - -| Spec item | Task | -|-----------|------| -| `tests/instruments/test_cors_instrument.py` | Task 2, Step 1 | -| `tests/instruments/test_healthchecks_instrument.py` | Task 2, Step 2 | -| `tests/instruments/test_prometheus_instrument.py` | Task 2, Step 3 | -| `tests/instruments/test_swagger_instrument.py` | Task 2, Step 4 | -| `tests/test_path.py` with negative cases | Task 2, Step 5 | -| Branch name `fix/test-4-7-gaps` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 6-8 | - -All spec items covered. No placeholders. Test code matches the existing simple-function style in the codebase. The Prometheus test uses both the default `/metrics` path (valid) and `"metrics"` (invalid — no leading slash) to exercise both branches of `is_valid_path`'s logic without depending on `tests/test_path.py`. - - ---- - -# PR11: Logging Cleanup + Lifecycle Test (REF-4 + REF-2 + LOW-6 + LOW-8 + LOW-9 + TEST-8) - -> **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:** The biggest PR of the deferred-refactors sequence. Bundles six logging-area items: -- **REF-4**: Split `logging_instrument.py` (212 lines) into a new `logging_factory.py` (factory + serializer + protocols) plus a slimmer `logging_instrument.py` (config + instrument + tracer injection). -- **REF-2**: Delete the dead `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` defensive check in `LitestarLoggingInstrument.bootstrap()`. -- **LOW-6**: Wrap `MemoryLoggerFactory`'s 4 logging-config kwargs into an internal `_MemoryLoggerFactoryConfig` dataclass. -- **LOW-8**: Add a docstring to `LoggingInstrument._unset_handlers` documenting that the mutation is permanent. -- **LOW-9**: Add a docstring to `LoggingInstrument.teardown()` documenting that root logger level is unconditionally reset to WARNING. -- **TEST-8**: Add a bootstrap→teardown→bootstrap→teardown lifecycle replay test. - -**Architecture:** One new module file, one production refactor (the split), one cross-file dead-code removal, two docstring additions, one test update, one new test. Backward compatibility is preserved by re-exporting moved symbols from `logging_instrument.py`. - -**Tech Stack:** Python 3.10+ dataclasses, structlog, pytest. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR11 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8). - ---- - -## File Structure - -5 files touched. - -- Create: `lite_bootstrap/instruments/logging_factory.py` — `MemoryLoggerFactory`, `_MemoryLoggerFactoryConfig`, `_serialize_log_with_orjson_to_string`, `AddressProtocol`, `RequestProtocol`, `ScopeType`. -- Modify: `lite_bootstrap/instruments/logging_instrument.py` — slim to `LoggingConfig`, `LoggingInstrument`, `tracer_injection`; re-import the moved symbols for backward compat; add LOW-8 / LOW-9 docstrings. -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — REF-2: delete the dead defensive check, unindent the body. -- Modify: `tests/instruments/test_logging_instrument.py` — update `MemoryLoggerFactory` instantiations to use `_MemoryLoggerFactoryConfig`; add `test_logging_instrument_lifecycle_replay`. - ---- - -## Locked decisions (from sequencing spec) - -- **Internal config dataclass for `MemoryLoggerFactory`:** Prefix `_MemoryLoggerFactoryConfig` (underscore = internal). Not exported from `__init__.py`. Tests import it via the underscore-prefixed name. -- **Backward compat for moved symbols:** `MemoryLoggerFactory`, `AddressProtocol`, `RequestProtocol`, `ScopeType` are re-imported into `logging_instrument.py` so existing `from lite_bootstrap.instruments.logging_instrument import MemoryLoggerFactory` imports continue to work. -- **No PLR2004 noqa for magic-value test assertions.** If ruff complains about a numeric literal in a test assertion (e.g., `assert config.x == 10`), extract the value to a named local variable. See PR10's `expected_max_age = 600` pattern. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-4-logging-cleanup -``` - -Expected: `Switched to a new branch 'refactor/ref-4-logging-cleanup'`. - ---- - -## Task 2: Apply all six changes, verify, commit - -The order of steps below is important — start with the file split (steps 1-3), then dead code removal (step 4), then docstrings (step 5), then test updates (steps 6-7), then verify. - -### Step 1: Create `lite_bootstrap/instruments/logging_factory.py` - -New file with full contents: - -```python -import dataclasses -import logging -import logging.handlers -import sys -import typing - -import orjson - -from lite_bootstrap import import_checker - - -ScopeType = typing.MutableMapping[str, typing.Any] - - -class AddressProtocol(typing.Protocol): - host: str - port: int - - -class RequestProtocol(typing.Protocol): - client: AddressProtocol - scope: ScopeType - method: str - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class _MemoryLoggerFactoryConfig: - logging_buffer_capacity: int - logging_flush_level: int - logging_log_level: int - log_stream: typing.Any = sys.stdout # noqa: ANN401 - - -def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 - return orjson.dumps(value, **kwargs).decode() - - -if import_checker.is_structlog_installed: - import structlog - - class MemoryLoggerFactory(structlog.stdlib.LoggerFactory): - def __init__( - self, - *args: typing.Any, # noqa: ANN401 - config: "_MemoryLoggerFactoryConfig", - **kwargs: typing.Any, # noqa: ANN401 - ) -> None: - super().__init__(*args, **kwargs) - self.config = config - self._created_handlers: list[tuple[logging.Logger, logging.handlers.MemoryHandler]] = [] - - def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 - logger: typing.Final = super().__call__(*args) - stream_handler: typing.Final = logging.StreamHandler(stream=self.config.log_stream) - handler: typing.Final = logging.handlers.MemoryHandler( - capacity=self.config.logging_buffer_capacity, - flushLevel=self.config.logging_flush_level, - target=stream_handler, - ) - logger.addHandler(handler) - logger.setLevel(self.config.logging_log_level) - logger.propagate = False - self._created_handlers.append((logger, handler)) - return logger - - def close_handlers(self) -> None: - for created_logger, handler in self._created_handlers: - created_logger.removeHandler(handler) - created_logger.propagate = True - target = handler.target - handler.close() - if target is not None: - target.close() - self._created_handlers.clear() -``` - -Notes on the structlog gate: -- `MemoryLoggerFactory` MUST be inside the gate because it inherits from `structlog.stdlib.LoggerFactory`. -- `_MemoryLoggerFactoryConfig` and `_serialize_log_with_orjson_to_string` are OUTSIDE the gate — they have no structlog dependency (just stdlib + orjson, both unconditional). This matters because `logging_instrument.py` imports them at module top; if they were gated, the import would fail when structlog isn't installed. -- `ScopeType`, `AddressProtocol`, `RequestProtocol` are unconditional (no structlog dependency). - -### Step 2: Rewrite `lite_bootstrap/instruments/logging_instrument.py` - -Replace the full file contents with: - -```python -import dataclasses -import logging -import logging.handlers -import sys -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument -from lite_bootstrap.instruments.logging_factory import ( - AddressProtocol, - RequestProtocol, - ScopeType, - _MemoryLoggerFactoryConfig, - _serialize_log_with_orjson_to_string, -) - - -if typing.TYPE_CHECKING: - from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory - from structlog.typing import EventDict, WrappedLogger - - -if import_checker.is_structlog_installed: - import structlog - - from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory - - -if import_checker.is_opentelemetry_installed: - from opentelemetry import trace - - def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": - current_span = trace.get_current_span() - if not current_span.is_recording(): - event_dict["tracing"] = {} - return event_dict - - current_span_context = current_span.get_span_context() - event_dict["tracing"] = { - "span_id": trace.format_span_id(current_span_context.span_id), - "trace_id": trace.format_trace_id(current_span_context.trace_id), - } - return event_dict - -else: # pragma: no cover - - def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": - return event_dict - - -__all__ = [ - "AddressProtocol", - "LoggingConfig", - "LoggingInstrument", - "MemoryLoggerFactory", - "RequestProtocol", - "ScopeType", - "tracer_injection", -] - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class LoggingConfig(BaseConfig): - logging_log_level: int = logging.INFO - logging_flush_level: int = logging.ERROR - logging_buffer_capacity: int = 10 - logging_extra_processors: list[typing.Any] = dataclasses.field(default_factory=list) - logging_unset_handlers: list[str] = dataclasses.field( - default_factory=list, - ) - logging_time_stamper: "structlog.processors.TimeStamper | None" = None - logging_enabled: bool = True - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class LoggingInstrument(BaseInstrument[LoggingConfig]): - not_ready_message = "logging_enabled is False" - missing_dependency_message = "structlog is not installed" - _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - - @property - def structlog_pre_chain_processors(self) -> list[typing.Any]: - return [ - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - tracer_injection, - structlog.stdlib.PositionalArgumentsFormatter(), - self.bootstrap_config.logging_time_stamper or structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.UnicodeDecoder(), - ] - - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled - - @staticmethod - def check_dependencies() -> bool: - return import_checker.is_structlog_installed - - def _unset_handlers(self) -> None: - """Clear handlers on the named loggers. Mutation is permanent; teardown() does not restore.""" - for unset_handlers_logger in self.bootstrap_config.logging_unset_handlers: - logging.getLogger(unset_handlers_logger).handlers = [] - - @property - def structlog_processors(self) -> list[typing.Any]: - return [ - structlog.stdlib.filter_by_level, - *self.structlog_pre_chain_processors, - *self.bootstrap_config.logging_extra_processors, - structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), - ] - - @property - def memory_logger_factory(self) -> "MemoryLoggerFactory": - cached: MemoryLoggerFactory | None = self._logger_factory - if cached is None: - cached = MemoryLoggerFactory( - config=_MemoryLoggerFactoryConfig( - logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, - logging_flush_level=self.bootstrap_config.logging_flush_level, - logging_log_level=self.bootstrap_config.logging_log_level, - ), - ) - object.__setattr__(self, "_logger_factory", cached) - return cached - - def _configure_structlog_loggers(self) -> None: - structlog.configure( - processors=self.structlog_processors, - context_class=dict, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - ) - - def _configure_foreign_loggers(self) -> None: - root_logger: typing.Final = logging.getLogger() - stream_handler: typing.Final = logging.StreamHandler(sys.stdout) - stream_handler.setFormatter( - structlog.stdlib.ProcessorFormatter( - foreign_pre_chain=self.structlog_pre_chain_processors, - processors=[ - structlog.stdlib.ProcessorFormatter.remove_processors_meta, - *self.bootstrap_config.logging_extra_processors, - structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), - ], - logger=root_logger, - ) - ) - root_logger.addHandler(stream_handler) - root_logger.setLevel(self.bootstrap_config.logging_log_level) - - def bootstrap(self) -> None: - self._unset_handlers() - self._configure_structlog_loggers() - self._configure_foreign_loggers() - - def teardown(self) -> None: - """Reset structlog and root logger. Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten.""" - structlog.reset_defaults() - root_logger = logging.getLogger() - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - h.close() - root_logger.setLevel(logging.WARNING) - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - object.__setattr__(self, "_logger_factory", None) -``` - -Key differences from the original: -- Module-top imports from `logging_factory`: `AddressProtocol`, `RequestProtocol`, `ScopeType`, `_MemoryLoggerFactoryConfig`, `_serialize_log_with_orjson_to_string` (all unconditional, no structlog dependency). -- `MemoryLoggerFactory` import is gated — at module top in `TYPE_CHECKING` block (for annotations) and inside `if import_checker.is_structlog_installed:` (for runtime use). This mirrors the original module's lazy-resolution pattern: `MemoryLoggerFactory` is only referenced at runtime when structlog is installed. -- `__all__` explicitly re-exports the moved public symbols (`AddressProtocol`, `MemoryLoggerFactory`, `RequestProtocol`, `ScopeType`) for backward compatibility. Consumers who try to access `MemoryLoggerFactory` without structlog will get `AttributeError` — same behavior as the original module. -- `_unset_handlers` and `teardown` now have one-line docstrings (LOW-8, LOW-9). -- `memory_logger_factory` property constructs `MemoryLoggerFactory` with a `_MemoryLoggerFactoryConfig` (LOW-6). -- All `MemoryLoggerFactory`/factory-internal code is gone — sourced from the new module. - -### Step 3: Verify the split with a quick smoke test - -```bash -just test -- tests/instruments/test_logging_instrument.py -v -``` - -Expected: existing tests may fail because they construct `MemoryLoggerFactory(logging_buffer_capacity=..., ...)` with the old signature. We'll fix those in Step 6 below. For now, just verify that imports resolve cleanly (no `ImportError`). - -If you see `ImportError`, the file split is broken — investigate before proceeding. - -### Step 4: REF-2 — delete dead defensive check in `LitestarLoggingInstrument.bootstrap` - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` - -Locate `LitestarLoggingInstrument.bootstrap`. Current: - -```python - def bootstrap(self) -> None: - self._unset_handlers() - if import_checker.is_structlog_installed and import_checker.is_litestar_installed: - self.bootstrap_config.application_config.plugins.append( - StructlogPlugin( - config=StructlogConfig( - structlog_logging_config=StructLoggingConfig( - processors=self.structlog_processors, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - pretty_print_tty=False, - standard_lib_logging_config=None, - ), - ), - ) - ) - self._configure_foreign_loggers() -``` - -Replace with (delete the `if` line and the matching dedent — the body unindents one level): - -```python - def bootstrap(self) -> None: - self._unset_handlers() - self.bootstrap_config.application_config.plugins.append( - StructlogPlugin( - config=StructlogConfig( - structlog_logging_config=StructLoggingConfig( - processors=self.structlog_processors, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - pretty_print_tty=False, - standard_lib_logging_config=None, - ), - ), - ) - ) - self._configure_foreign_loggers() -``` - -The `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` check is dead — the instrument couldn't have been registered without both packages installed. - -### Step 5: Update tests to use `_MemoryLoggerFactoryConfig` - -**File:** `tests/instruments/test_logging_instrument.py` - -There are two tests (`test_memory_logger_factory_info`, `test_memory_logger_factory_error`) that instantiate `MemoryLoggerFactory` directly. Both need to be updated to use the new config-based API. - -Locate the top-of-file imports. The current imports look like: - -```python -import logging -from io import StringIO - -import structlog -from opentelemetry.trace import get_tracer - -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -from tests.conftest import LoggingMock -``` - -(After PR3 the file also has `from unittest.mock import patch` and `import pytest`; preserve those.) - -Add `_MemoryLoggerFactoryConfig` to the imports. Either import from `logging_factory` directly (more explicit) or from `logging_instrument` (which already re-imports it). Use the direct path for clarity: - -```python -from lite_bootstrap.instruments.logging_factory import _MemoryLoggerFactoryConfig -``` - -Place this after the `from lite_bootstrap.instruments.logging_instrument import ...` line. - -Locate `test_memory_logger_factory_info`. Current: - -```python -def test_memory_logger_factory_info() -> None: - test_capacity = 10 - test_flush_level = logging.ERROR - test_stream = StringIO() - - logger_factory = MemoryLoggerFactory( - logging_buffer_capacity=test_capacity, - logging_flush_level=test_flush_level, - logging_log_level=logging.INFO, - log_stream=test_stream, - ) - ... -``` - -Replace the `MemoryLoggerFactory(...)` call with: - -```python - logger_factory = MemoryLoggerFactory( - config=_MemoryLoggerFactoryConfig( - logging_buffer_capacity=test_capacity, - logging_flush_level=test_flush_level, - logging_log_level=logging.INFO, - log_stream=test_stream, - ), - ) -``` - -Do the same in `test_memory_logger_factory_error`. - -### Step 6: Add lifecycle replay test (TEST-8) - -In the same file, append a new test: - -```python -def test_logging_instrument_lifecycle_replay(logging_mock: LoggingMock) -> None: - instrument = LoggingInstrument( - bootstrap_config=LoggingConfig( - logging_buffer_capacity=0, - logging_extra_processors=[logging_mock], - ), - ) - try: - instrument.bootstrap() - instrument.teardown() - instrument.bootstrap() - logger = structlog.getLogger(__name__) - logger.info("after replay") - assert any(e.get("event") == "after replay" for e in logging_mock.entries) - finally: - instrument.teardown() -``` - -Contract: bootstrap → teardown → bootstrap must succeed without raising. After the second bootstrap, the instrument is functional (new logger entries flow through `logging_mock`). The final `teardown()` in `finally` ensures global state is cleaned up regardless of test outcome. - -### Step 7: Run the full logging test file - -```bash -just test -- tests/instruments/test_logging_instrument.py -v -``` - -Expected: all tests PASS, including the two updated factory tests and the new lifecycle replay test. - -If any test fails, investigate. The most likely cause is a typo in the `_MemoryLoggerFactoryConfig` construction. - -### Step 8: Run the full test suite - -```bash -just test -``` - -Expected: 127/127 PASS (after PR10 brought the total to 127, this PR adds 1 test → 128). Watch for failures in the framework integration tests — particularly `test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap` — which exercise the full logging instrument lifecycle. - -### Step 9: Run lint - -```bash -just lint -``` - -Expected: clean. The file split and dataclass-config refactor shouldn't introduce lint issues. - -**Important — PLR2004 policy:** If ruff flags any numeric literal in test assertions with PLR2004 (magic value), DO NOT add `# noqa: PLR2004`. Extract the value to a named local variable. Example: `expected_capacity = 10; assert factory.config.logging_buffer_capacity == expected_capacity`. - -### Step 10: Commit - -Stage all five touched files: - -```bash -git add \ - lite_bootstrap/instruments/logging_factory.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - tests/instruments/test_logging_instrument.py -git commit -m "$(cat <<'EOF' -refactor: split logging module + cleanup + lifecycle test - -REF-4: Split lite_bootstrap/instruments/logging_instrument.py (212 -lines, four concerns) into: -- logging_factory.py: MemoryLoggerFactory, _MemoryLoggerFactoryConfig, - _serialize_log_with_orjson_to_string, AddressProtocol, - RequestProtocol, ScopeType. Single structlog-conditional gate at - module top. -- logging_instrument.py: LoggingConfig, LoggingInstrument, - tracer_injection. Re-imports the moved public symbols - (MemoryLoggerFactory, AddressProtocol, RequestProtocol, ScopeType) - for backward compatibility — existing imports from - lite_bootstrap.instruments.logging_instrument continue to work. - -LOW-6: MemoryLoggerFactory.__init__ now takes a single -_MemoryLoggerFactoryConfig dataclass instead of four logging-config -kwargs. The dataclass is underscore-prefixed (internal); tests import -it explicitly when constructing the factory directly. - -REF-2: Delete the dead `if import_checker.is_structlog_installed and -import_checker.is_litestar_installed:` defensive check in -LitestarLoggingInstrument.bootstrap. The check is unreachable — -the instrument couldn't have been registered without both packages -installed. - -LOW-8: Add a docstring to LoggingInstrument._unset_handlers documenting -that the mutation is permanent (teardown does not restore handlers). - -LOW-9: Add a docstring to LoggingInstrument.teardown documenting that -root logger level is unconditionally reset to WARNING. - -TEST-8: Add test_logging_instrument_lifecycle_replay exercising -bootstrap → teardown → bootstrap → teardown and verifying the -instrument is functional after the second bootstrap. - -No external behavior change. Updated factory tests to use the new -config-based constructor. - -Closes REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-4-logging-cleanup -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: split logging module + cleanup + lifecycle test" --body "$(cat <<'EOF' -## Summary -The biggest PR of the deferred-refactors sequence — six logging-area items bundled: - -- **REF-4:** Split `logging_instrument.py` (212 lines, four concerns) into a new `logging_factory.py` (MemoryLoggerFactory + serializer + protocols + the new internal `_MemoryLoggerFactoryConfig`) and a slimmer `logging_instrument.py` (config + instrument + tracer injection). Single structlog-conditional gate at the new module's top, vs three separate gates in the old layout. Backward compatibility: the moved public symbols are re-imported into `logging_instrument.py`, so existing `from lite_bootstrap.instruments.logging_instrument import MemoryLoggerFactory` continues to work. -- **REF-2:** Deleted the dead `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` check in `LitestarLoggingInstrument.bootstrap()`. Unreachable code (the instrument couldn't have been registered without both packages installed). -- **LOW-6:** `MemoryLoggerFactory.__init__` now takes a single `_MemoryLoggerFactoryConfig` dataclass instead of four logging-config kwargs. Underscore-prefixed because it's internal; tests import it directly. -- **LOW-8:** Added a docstring to `LoggingInstrument._unset_handlers` documenting that the mutation is permanent — teardown does NOT restore handlers. -- **LOW-9:** Added a docstring to `LoggingInstrument.teardown()` documenting that root logger level is unconditionally reset to `WARNING`. -- **TEST-8:** New `test_logging_instrument_lifecycle_replay` exercises bootstrap → teardown → bootstrap → teardown and verifies the instrument is functional after the second bootstrap. - -No external behavior change. Two existing factory tests updated to use the new config-based constructor. - -Closes REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8 from an internal audit. - -## Test plan -- [x] `just test -- tests/instruments/test_logging_instrument.py -v` — pass (including updated factory tests + new lifecycle replay). -- [x] `just test` — 128/128 (127 prior + 1 new). -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the backward-compat re-imports in `logging_instrument.py` cover all the public symbols (MemoryLoggerFactory, AddressProtocol, RequestProtocol, ScopeType). -- [ ] Reviewer: confirm the test update for the new config-based `MemoryLoggerFactory` constructor is correct. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR11 section) and audit: - -| Spec item | Task | -|-----------|------| -| REF-4: Split `logging_instrument.py` into `logging_factory.py` + slimmer `logging_instrument.py` | Task 2, Steps 1-2 | -| REF-2: Delete dead defensive check in `LitestarLoggingInstrument.bootstrap` | Task 2, Step 4 | -| LOW-6: `MemoryLoggerFactory` takes `_MemoryLoggerFactoryConfig` | Task 2, Steps 1, 2, 5 | -| LOW-8: docstring on `_unset_handlers` | Task 2, Step 2 | -| LOW-9: docstring on `teardown` | Task 2, Step 2 | -| TEST-8: lifecycle replay test | Task 2, Step 6 | -| Branch name `refactor/ref-4-logging-cleanup` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 8-9 | -| PLR2004 noqa NOT used; extract constants instead | Task 2, Step 9 (callout) | - -All spec items covered. No placeholders. Backward compatibility for moved public symbols is explicitly handled via re-imports in `logging_instrument.py` with `__all__` listing them. - -**Risk notes:** -- The file split is the highest-risk change. If imports break anywhere in the codebase or tests, this PR's full suite run catches it. -- The MemoryLoggerFactory constructor change is API-breaking for direct users of `MemoryLoggerFactory(logging_buffer_capacity=..., ...)`. Since `MemoryLoggerFactory` is publicly exported but its internals are framework-level (users rarely construct it directly outside tests), the impact is small. Documented in the commit message. - - ---- - -# PR12: Base Layer Cleanup (REF-3 + REF-5) - -> **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:** -- **REF-3**: Drop `abc.ABC` from `BaseInstrument`. After PR7 made the class generic and removed the `# noqa: B027` suppressions, the `abc.ABC` parent serves no purpose — all four methods (`bootstrap`, `teardown`, `is_ready`, `check_dependencies`) are concrete no-ops with sensible defaults. Removing it clarifies the class's role as a regular generic base. -- **REF-5**: Add a one-line module docstring to `swagger_instrument.py` and `prometheus_instrument.py` explaining that these files are config holders; framework-specific behavior lives in the bootstrapper subclasses. Per the locked decision in the sequencing spec, KEEP these files as separate modules (don't collapse). - -**Architecture:** Three files, three small edits. No behavior change. - -**Tech Stack:** Python 3.10+ dataclasses, generics. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR12 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-3, REF-5). - ---- - -## File Structure - -Three files modified. - -- Modify: `lite_bootstrap/instruments/base.py` — drop `abc.ABC` from `BaseInstrument`; remove the `import abc`. -- Modify: `lite_bootstrap/instruments/swagger_instrument.py` — add module docstring. -- Modify: `lite_bootstrap/instruments/prometheus_instrument.py` — add module docstring. - ---- - -## Locked decisions (from sequencing spec) - -- **REF-3 scope:** Only `BaseInstrument` loses `abc.ABC`. `BaseBootstrapper` (in `lite_bootstrap/bootstrappers/base.py`) keeps `abc.ABC` because it HAS abstract methods (`not_ready_message`, `_prepare_application`, `is_ready`). -- **REF-5 scope:** Keep `swagger_instrument.py` and `prometheus_instrument.py` as separate files (don't collapse). Add module docstrings explaining the split. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-3-5-base-layer -``` - -Expected: `Switched to a new branch 'refactor/ref-3-5-base-layer'`. - ---- - -## Task 2: Apply both changes, verify, commit - -### Step 1: REF-3 — drop `abc.ABC` from `BaseInstrument` - -**File:** `lite_bootstrap/instruments/base.py` - -Current file: - -```python -import abc -import dataclasses -import typing - -import typing_extensions - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseConfig: - ... - - -ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... - - def teardown(self) -> None: ... - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Two changes: - -1. Remove `import abc` from the top (it's no longer used in this file). -2. Change `class BaseInstrument(abc.ABC, typing.Generic[ConfigT]):` to `class BaseInstrument(typing.Generic[ConfigT]):`. - -After: - -```python -import dataclasses -import typing - -import typing_extensions - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseConfig: - ... - - -ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... - - def teardown(self) -> None: ... - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -`BaseConfig` is preserved unchanged (it never used `abc.ABC`). - -### Step 2: REF-5 — add docstring to `swagger_instrument.py` - -**File:** `lite_bootstrap/instruments/swagger_instrument.py` - -Current file: - -```python -import dataclasses - -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class SwaggerConfig(BaseConfig): - swagger_static_path: str = "/static" - swagger_path: str = "/docs" - swagger_offline_docs: bool = False - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument[SwaggerConfig]): - pass -``` - -Add a module docstring at the top: - -```python -"""Swagger config and minimal base instrument; framework-specific behavior lives in the bootstrapper subclasses.""" - -import dataclasses - -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class SwaggerConfig(BaseConfig): - swagger_static_path: str = "/static" - swagger_path: str = "/docs" - swagger_offline_docs: bool = False - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument[SwaggerConfig]): - pass -``` - -Single addition: the module docstring on line 1. - -### Step 3: REF-5 — add docstring to `prometheus_instrument.py` - -**File:** `lite_bootstrap/instruments/prometheus_instrument.py` - -Current file: - -```python -import dataclasses - -from lite_bootstrap.helpers.path import is_valid_path -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class PrometheusConfig(BaseConfig): - prometheus_metrics_path: str = "/metrics" - prometheus_metrics_include_in_schema: bool = False - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PrometheusInstrument(BaseInstrument[PrometheusConfig]): - not_ready_message = "prometheus_metrics_path is empty or not valid" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -Add a module docstring: - -```python -"""Prometheus config and readiness check; framework-specific bootstrap lives in the bootstrapper subclasses.""" - -import dataclasses - -from lite_bootstrap.helpers.path import is_valid_path -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class PrometheusConfig(BaseConfig): - ... -``` - -(Rest of the file unchanged.) - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: 128/128 PASS. REF-3 is a metaclass/MRO change but `abc.ABC` was unused (no abstract methods); dropping it should be invisible at runtime. REF-5 is pure docstring additions. - -Watch for surprises in: -- `tests/test_free_bootstrap.py` — exercises the base instrument lifecycle directly. -- Framework integration tests — instantiate instruments via the bootstrapper chain. - -If anything fails, the most likely cause is some `isinstance(..., abc.ABC)` check somewhere, or a place that relies on the `abc.ABC` metaclass. Search the codebase: `grep -rn "abc\.ABC\|isinstance.*ABC" lite_bootstrap/ tests/`. If only `bootstrappers/base.py` matches (BaseBootstrapper still uses ABC), all good. - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: clean. Watch for `F401` warning on the removed `import abc` — should not fire if the import was actually removed. - -### Step 6: Commit - -Stage exactly three files: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/swagger_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py -git commit -m "$(cat <<'EOF' -refactor: drop unused abc.ABC from BaseInstrument; document config holders - -REF-3: BaseInstrument inherited from abc.ABC but defined no abstract -methods. After PR7 made the class generic and removed the # noqa: B027 -suppressions, abc.ABC serves no purpose — all four methods -(bootstrap, teardown, is_ready, check_dependencies) are concrete -no-ops with sensible defaults. Drop the abc.ABC parent and the now- -unused `import abc`. - -BaseBootstrapper still uses abc.ABC (it has real abstract methods: -not_ready_message, _prepare_application, is_ready) and is unchanged. - -REF-5: Add one-line module docstrings to swagger_instrument.py and -prometheus_instrument.py explaining that these files hold config and -minimal base logic; framework-specific bootstrap behavior lives in -the bootstrapper subclasses (FastAPISwaggerInstrument, etc.). These -files were left as separate modules per the locked decision in the -deferred-refactors sequencing spec. - -No behavior change. - -Closes REF-3 and REF-5 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-3-5-base-layer -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: drop unused abc.ABC from BaseInstrument; document config holders" --body "$(cat <<'EOF' -## Summary -Two small base-layer cleanups: - -- **REF-3:** `BaseInstrument` inherited from `abc.ABC` but defined no abstract methods. After PR7 made the class generic and removed the `# noqa: B027` suppressions, `abc.ABC` serves no purpose — all four methods (`bootstrap`, `teardown`, `is_ready`, `check_dependencies`) are concrete no-ops with sensible defaults. Drop the `abc.ABC` parent and the now-unused `import abc`. `BaseBootstrapper` still uses `abc.ABC` (it has real abstract methods) and is unchanged. -- **REF-5:** Added one-line module docstrings to `swagger_instrument.py` and `prometheus_instrument.py` explaining that these files hold config and minimal base logic; framework-specific bootstrap behavior lives in the bootstrapper subclasses. Kept as separate modules per the locked decision in the deferred-refactors sequencing spec. - -No behavior change. - -Closes REF-3 and REF-5 from an internal audit. - -## Test plan -- [x] `just test` — 128/128. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm no `isinstance(..., abc.ABC)` check anywhere in the codebase relies on `BaseInstrument`'s ABC parent. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR12 section) and audit (REF-3, REF-5): - -| Spec item | Task | -|-----------|------| -| REF-3: drop `abc.ABC` from `BaseInstrument`; drop unused `import abc` | Task 2, Step 1 | -| REF-3: keep `abc.ABC` on `BaseBootstrapper` (not touched) | Task 2, Step 1 (out of scope confirmation) | -| REF-5: docstring on `swagger_instrument.py` | Task 2, Step 2 | -| REF-5: docstring on `prometheus_instrument.py` | Task 2, Step 3 | -| REF-5: keep both files separate (don't collapse) | Locked decisions section | -| Branch name `refactor/ref-3-5-base-layer` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | - -All spec items covered. No placeholders. - -**Risk:** Low. Both REF-3 and REF-5 are essentially metadata/documentation changes. The only theoretical risk is a downstream consumer relying on `isinstance(inst, abc.ABC)` checks on `BaseInstrument` instances — vanishingly unlikely in practice. The test suite catches it if anything's broken. - - ---- - -# PR13: Drop `frozen=True` From Instruments + FastAPIConfig Default Cleanup (REF-6 + LOW-4) - -> **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:** Two related-but-distinct cleanups. - -- **REF-6**: Drop `frozen=True` from the instrument hierarchy. Python's dataclass rules force a cascade: dropping `frozen=True` from `LoggingInstrument` requires dropping it from `BaseInstrument`, which requires dropping it from every other instrument subclass. 23 dataclass declarations across 12 files lose `frozen=True`. In `LoggingInstrument` and `OpenTelemetryInstrument`, the four `object.__setattr__(self, "_x", value)` workarounds for cached runtime state become plain `self._x = value` assignments. **Configs stay frozen** — only instruments lose `frozen=True`. - -- **LOW-4**: `FastAPIConfig.application` currently uses `default=None` + `# ty: ignore[invalid-assignment]` because the field is typed `fastapi.FastAPI` (non-Optional). Replace with a proper sentinel-type pattern: introduce `UnsetType` + `UNSET` in `lite_bootstrap/types.py` (a sentinel class with a singleton instance), type the field as `fastapi.FastAPI | UnsetType`, default to `UNSET`, and replace the truthiness check in `__post_init__` with `isinstance(self.application, UnsetType)`. Add a `_narrow_app(config)` helper at module scope that asserts the type and returns the narrowed value; FastAPI framework instruments call `_narrow_app(self.bootstrap_config)` instead of `self.bootstrap_config.application` directly. Drops the `# ty: ignore`. FastAPIConfig stays frozen — `object.__setattr__(self, "application", ...)` in `__post_init__` remains because the freeze bypass is the only way to mutate a frozen field after construction; a code comment documents the rationale. - -**Architecture:** Largest mechanical refactor in the deferred-refactors sequence. The cascade is purely mechanical — every change is `frozen=True` → (delete). The setattr replacements and LOW-4 sentinel are the only meaningful diffs. - -**Tech Stack:** Python 3.10+ dataclasses, frozen-inheritance rules. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR13 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-6, LOW-4). - ---- - -## Important context: Why the cascade - -Python's `@dataclasses.dataclass` enforces that **a non-frozen dataclass cannot inherit from a frozen one** (and vice versa). The check fires at class definition time as `TypeError`. - -Today's instrument hierarchy: -- `BaseInstrument(frozen=True)` -- 8 base instrument subclasses, all `frozen=True` -- 15 framework instrument subclasses inheriting from the base instruments, all `frozen=True` - -To drop `frozen=True` from `LoggingInstrument` (REF-6's stated target), `BaseInstrument` must also drop it, which forces every other subclass to drop it too. There's no surgical option. - -The sequencing spec's PR13 section said "drop `frozen=True` from `LoggingInstrument` and `OpenTelemetryInstrument`" — that was incorrect; the cascade is required. This plan implements the cascade. - ---- - -## File Structure - -12 files modified. - -**Instrument modules (9 files):** -- `lite_bootstrap/instruments/base.py` — `BaseInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/cors_instrument.py` — `CorsInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/healthchecks_instrument.py` — `HealthChecksInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/logging_instrument.py` — `LoggingInstrument` loses `frozen=True`; 2 `object.__setattr__` calls become direct assignment. -- `lite_bootstrap/instruments/opentelemetry_instrument.py` — `OpenTelemetryInstrument` loses `frozen=True`; 2 `object.__setattr__` calls become direct assignment. -- `lite_bootstrap/instruments/prometheus_instrument.py` — `PrometheusInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/pyroscope_instrument.py` — `PyroscopeInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/sentry_instrument.py` — `SentryInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument` loses `frozen=True`. - -**Bootstrapper modules (3 files):** -- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments lose `frozen=True` + LOW-4 sentinel-type pattern on `FastAPIConfig.application` + `_narrow_app` helper + every FastAPI instrument bootstrap calls `_narrow_app(self.bootstrap_config)` for the app reference. -- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments lose `frozen=True`. -- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments lose `frozen=True`. - -**Shared types (1 file):** -- `lite_bootstrap/types.py` — add `UnsetType` class + `UNSET: typing.Final[UnsetType]` singleton. Reusable sentinel for fields that distinguish "not passed" from "explicitly None". - ---- - -## Locked decisions - -- **Cascade scope:** Drop `frozen=True` from `BaseInstrument` and ALL 22 instrument subclasses. Configs stay frozen. Confirmed by the user after the constraint surfaced. -- **LOW-4 pattern:** Proper `UnsetType` sentinel class in `lite_bootstrap/types.py`, used via `isinstance(value, UnsetType)`. Honest to the type checker (no `typing.cast` lie). Adds a `_narrow_app` helper that wraps the assert/return narrowing for callers. Revised from the original plan's `typing.cast("fastapi.FastAPI", object())` pattern — the spec was updated retroactively to match what was built. -- **`FastAPIConfig` stays frozen:** Confirmed by user. The `object.__setattr__(self, "application", ...)` in `__post_init__` remains; a one-line code comment documents the rationale (frozen for user-facing immutability; bypass needed because `application` is constructed using other config fields). -- **No new tests.** The full existing test suite verifies behavior preservation; pure-refactor changes should not affect runtime semantics other than enabling future direct mutation (which we don't exercise). - ---- - -## Cascade list (23 dataclass declarations) - -For traceability, every dataclass decorator changing from `@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)` to `@dataclasses.dataclass(kw_only=True, slots=True)` (or `@dataclasses.dataclass(kw_only=True, frozen=True)` to `@dataclasses.dataclass(kw_only=True)`): - -| # | Class | File | -|---|-------|------| -| 1 | `BaseInstrument` | `instruments/base.py` | -| 2 | `CorsInstrument` | `instruments/cors_instrument.py` | -| 3 | `HealthChecksInstrument` | `instruments/healthchecks_instrument.py` | -| 4 | `LoggingInstrument` | `instruments/logging_instrument.py` | -| 5 | `OpenTelemetryInstrument` | `instruments/opentelemetry_instrument.py` | -| 6 | `PrometheusInstrument` | `instruments/prometheus_instrument.py` | -| 7 | `PyroscopeInstrument` | `instruments/pyroscope_instrument.py` | -| 8 | `SentryInstrument` | `instruments/sentry_instrument.py` | -| 9 | `SwaggerInstrument` | `instruments/swagger_instrument.py` | -| 10 | `FastAPICorsInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 11 | `FastAPIHealthChecksInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 12 | `FastAPIOpenTelemetryInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 13 | `FastAPIPrometheusInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 14 | `FastAPISwaggerInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 15 | `LitestarCorsInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 16 | `LitestarHealthChecksInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 17 | `LitestarLoggingInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 18 | `LitestarOpenTelemetryInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 19 | `LitestarPrometheusInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 20 | `LitestarSwaggerInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 21 | `FastStreamHealthChecksInstrument` | `bootstrappers/faststream_bootstrapper.py` | -| 22 | `FastStreamLoggingInstrument` | `bootstrappers/faststream_bootstrapper.py` | -| 23 | `FastStreamOpenTelemetryInstrument` | `bootstrappers/faststream_bootstrapper.py` | -| 24 | `FastStreamPrometheusInstrument` | `bootstrappers/faststream_bootstrapper.py` | - -(Yes, that's 24 — `LitestarSwaggerInstrument` is on the list because it inherits from `SwaggerInstrument`. All 24 actually need updating to keep the cascade consistent.) - -**Configs are NOT in this list.** `BaseConfig`, `LoggingConfig`, `SentryConfig`, `OpentelemetryConfig`, `PyroscopeConfig`, `CorsConfig`, `HealthChecksConfig`, `PrometheusConfig`, `SwaggerConfig`, `OpenTelemetryServiceFieldsConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`, `FreeBootstrapperConfig` — all stay frozen. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-6-frozen-setattr -``` - -Expected: `Switched to a new branch 'refactor/ref-6-frozen-setattr'`. - ---- - -## Task 2: Drop `frozen=True` from the cascade - -For each file below, the change pattern is identical: locate each instrument's `@dataclasses.dataclass(...)` decorator and remove `, frozen=True` (or `frozen=True,` if it's not last). Configs are untouched. - -### Step 1: `lite_bootstrap/instruments/base.py` - -Locate `BaseInstrument`. Change: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(typing.Generic[ConfigT]): -``` - -to: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class BaseInstrument(typing.Generic[ConfigT]): -``` - -`BaseConfig` (above it) stays untouched — keep `frozen=True`. - -### Step 2: `lite_bootstrap/instruments/cors_instrument.py` - -`CorsInstrument` decorator: drop `frozen=True`. - -### Step 3: `lite_bootstrap/instruments/healthchecks_instrument.py` - -`HealthChecksInstrument` decorator: drop `frozen=True`. - -### Step 4: `lite_bootstrap/instruments/logging_instrument.py` - -`LoggingInstrument` decorator: drop `frozen=True`. - -Then replace 2 `object.__setattr__` calls with direct assignment: - -In `memory_logger_factory` property (around line 109): -```python -# Before: -object.__setattr__(self, "_logger_factory", cached) -# After: -self._logger_factory = cached -``` - -In `teardown` method: -```python -# Before: -try: - self._logger_factory.close_handlers() -finally: - object.__setattr__(self, "_logger_factory", None) - -# After: -try: - self._logger_factory.close_handlers() -finally: - self._logger_factory = None -``` - -### Step 5: `lite_bootstrap/instruments/opentelemetry_instrument.py` - -`OpenTelemetryInstrument` decorator: drop `frozen=True`. - -Then replace 2 `object.__setattr__` calls: - -In `bootstrap()`: -```python -# Before: -object.__setattr__(self, "_tracer_provider", tracer_provider) -# After: -self._tracer_provider = tracer_provider -``` - -In `teardown()`: -```python -# Before: -try: - self._tracer_provider.shutdown() -finally: - object.__setattr__(self, "_tracer_provider", None) - -# After: -try: - self._tracer_provider.shutdown() -finally: - self._tracer_provider = None -``` - -### Step 6: `lite_bootstrap/instruments/prometheus_instrument.py` - -`PrometheusInstrument` decorator: drop `frozen=True`. - -### Step 7: `lite_bootstrap/instruments/pyroscope_instrument.py` - -`PyroscopeInstrument` decorator: drop `frozen=True`. - -### Step 8: `lite_bootstrap/instruments/sentry_instrument.py` - -`SentryInstrument` decorator: drop `frozen=True`. - -### Step 9: `lite_bootstrap/instruments/swagger_instrument.py` - -`SwaggerInstrument` decorator: drop `frozen=True`. - -### Step 10: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments - -Drop `frozen=True` from the decorators of: -- `FastAPICorsInstrument` -- `FastAPIHealthChecksInstrument` -- `FastAPIOpenTelemetryInstrument` -- `FastAPIPrometheusInstrument` -- `FastAPISwaggerInstrument` - -Each uses `@dataclasses.dataclass(kw_only=True, frozen=True)` (no `slots=True`). After: `@dataclasses.dataclass(kw_only=True)`. - -`FastAPIConfig` stays untouched here (frozen). The LOW-4 sentinel change comes in Task 3. - -### Step 11: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments - -Drop `frozen=True` from: -- `LitestarCorsInstrument` -- `LitestarHealthChecksInstrument` -- `LitestarLoggingInstrument` -- `LitestarOpenTelemetryInstrument` -- `LitestarPrometheusInstrument` -- `LitestarSwaggerInstrument` - -`LitestarConfig` stays frozen. - -### Step 12: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments - -Drop `frozen=True` from: -- `FastStreamHealthChecksInstrument` -- `FastStreamLoggingInstrument` -- `FastStreamOpenTelemetryInstrument` -- `FastStreamPrometheusInstrument` - -`FastStreamConfig` stays frozen. - -### Step 13: Quick verification - -```bash -just test -``` - -Expected: 128/128 PASS. If anything fails, the most likely cause is a `frozen=True` left in one of the 24 classes (Python's TypeError surfaces immediately on import). - -Run a sanity grep to confirm no instrument-class declaration still has `frozen=True`: - -```bash -grep -rn "frozen=True" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/ | grep -v "Config" -``` - -Expected: zero matches. The `grep -v "Config"` filters out config classes (which should still have `frozen=True`). - ---- - -## Task 3: LOW-4 — FastAPIConfig sentinel pattern - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` - -### Step 1: Add module-level sentinel and update `FastAPIConfig` - -Locate the top of the file (after the conditional imports for fastapi). Add this constant right after the `if import_checker.is_fastapi_installed:` block: - -```python -if import_checker.is_fastapi_installed: - import fastapi - from fastapi.middleware.cors import CORSMiddleware - from fastapi.routing import _merge_lifespan_context - -# ... other conditional imports stay as they are ... - -_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object()) -``` - -`typing.cast(...)` is a runtime no-op (returns the second argument). The type checker sees `_UNSET_FASTAPI_APP` as `fastapi.FastAPI`; at runtime it's a unique `object()` sentinel. `typing.Final` prevents accidental reassignment. - -The string-quoted `"fastapi.FastAPI"` in the cast lets the line evaluate even when `fastapi` isn't installed (cast doesn't look up the type at runtime). - -### Step 2: Update `FastAPIConfig.application` field declaration and `__post_init__` - -Locate `FastAPIConfig`. Current: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastAPIConfig( - CorsConfig, - ... -): - application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment] - application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - ... - - def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if not self.application: - object.__setattr__( - self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - ) - elif self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) - - self.application.title = self.service_name - self.application.debug = self.service_debug - self.application.version = self.service_version -``` - -Change two things: - -1. Replace `application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment]` with `application: "fastapi.FastAPI" = _UNSET_FASTAPI_APP`. Drop the `# ty: ignore`. - -2. In `__post_init__`, replace `if not self.application:` with `if self.application is _UNSET_FASTAPI_APP:`. Identity check instead of truthiness — clearer intent. - -After: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastAPIConfig( - CorsConfig, - ... -): - application: "fastapi.FastAPI" = _UNSET_FASTAPI_APP - application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - ... - - def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if self.application is _UNSET_FASTAPI_APP: - object.__setattr__( - self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - ) - elif self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) - - self.application.title = self.service_name - self.application.debug = self.service_debug - self.application.version = self.service_version -``` - -The `object.__setattr__` for `self.application` stays — `FastAPIConfig` is still frozen. - -The `self.application.title = ...` lines below also stay — they mutate the `fastapi.FastAPI` instance (which isn't frozen), not the `FastAPIConfig` dataclass. - ---- - -## Task 4: Verify and commit - -### Step 1: Run the full test suite - -```bash -just test -``` - -Expected: 128/128 PASS. The cascade is mechanical and should not affect runtime behavior. Watch for surprises in: - -- All framework integration tests (FastAPI, Litestar, FastStream, Free) — they exercise the full instrument lifecycle. -- `test_logging_instrument_lifecycle_replay` (from PR11) — confirms the `_logger_factory` direct-assignment doesn't break the replay cycle. -- `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider` (from PR2) — confirms the `_tracer_provider` direct-assignment doesn't break shutdown. - -If any test fails because something now mutates an instrument unexpectedly, that's a real bug surfaced by the refactor (frozen was masking it). Stop and investigate. - -### Step 2: Run lint - -```bash -just lint -``` - -Expected: clean. The `# ty: ignore[invalid-assignment]` is gone from `FastAPIConfig.application`. No new lint warnings should appear. - -### Step 3: Verify the cascade - -```bash -grep -n "frozen=True" lite_bootstrap/instruments/*.py lite_bootstrap/bootstrappers/*.py -``` - -Expected matches: ONLY on config classes (`BaseConfig`, `LoggingConfig`, `SentryConfig`, `OpentelemetryConfig`, `PyroscopeConfig`, `CorsConfig`, `HealthChecksConfig`, `PrometheusConfig`, `SwaggerConfig`, `OpenTelemetryServiceFieldsConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`, `FreeBootstrapperConfig`). - -No instrument class should match. If one does, that's a missed cascade entry — fix it before committing. - -### Step 4: Commit - -Stage exactly the 12 modified files: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/cors_instrument.py \ - lite_bootstrap/instruments/healthchecks_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/instruments/swagger_instrument.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py -git commit -m "$(cat <<'EOF' -refactor: drop frozen=True from instruments; sentinel for FastAPIConfig.application - -REF-6: LoggingInstrument and OpenTelemetryInstrument cached mutable -runtime state (_logger_factory, _tracer_provider) via -object.__setattr__ workarounds because the instruments were declared -frozen=True. The frozen claim was partly false — those two fields -mutated freely under the hood. - -Python's dataclass rules forbid surgically dropping frozen=True from -a subclass while the parent remains frozen (TypeError at class -definition). The fix cascades through BaseInstrument and all 22 -instrument subclasses: drop frozen=True from each. Configs stay -frozen — only instruments lose immutability. The 4 object.__setattr__ -call sites in LoggingInstrument and OpenTelemetryInstrument -(bootstrap-cache + teardown-reset for each) become plain self._x = ... -assignments. - -LOW-4: FastAPIConfig.application declared default=None with a -# ty: ignore[invalid-assignment] because the field is typed -fastapi.FastAPI (non-Optional). Replace with a typed sentinel: -_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object()) -The cast suppresses the type lie; the sentinel makes __post_init__'s -identity check (is _UNSET_FASTAPI_APP) clearer than the prior -truthiness check (not self.application). FastAPIConfig stays frozen, -so the object.__setattr__(self, "application", ...) in __post_init__ -remains — only the default and the check change. - -No behavior change. 128/128 tests pass. - -Closes REF-6 and LOW-4 from the audit. -EOF -)" -``` - ---- - -## Task 5: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-6-frozen-setattr -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: drop frozen=True from instruments; sentinel for FastAPIConfig.application" --body "$(cat <<'EOF' -## Summary -Two related cleanups in one PR: - -- **REF-6 cascade:** `LoggingInstrument` and `OpenTelemetryInstrument` cached mutable runtime state via `object.__setattr__` workarounds because they were declared `frozen=True`. Python's dataclass rules forbid surgically dropping `frozen=True` from one subclass while the parent stays frozen — the cascade is required. `BaseInstrument` and all 22 instrument subclasses lose `frozen=True`. The 4 `object.__setattr__` call sites in `LoggingInstrument` and `OpenTelemetryInstrument` become plain `self._x = ...` assignments. -- **LOW-4:** `FastAPIConfig.application` used `default=None` with a `# ty: ignore`. Replaced with a typed sentinel `_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object())`. The `__post_init__` check changes from `if not self.application:` to `if self.application is _UNSET_FASTAPI_APP:`. `FastAPIConfig` stays frozen — the existing `object.__setattr__(self, "application", ...)` in `__post_init__` remains. - -**Configs are unchanged.** All `*Config` classes keep `frozen=True`. Only instrument classes lose immutability. - -12 files modified; 24 dataclass declarations lose `frozen=True`; 4 `object.__setattr__` call sites simplified; 1 `# ty: ignore` removed. - -No behavior change. 128/128 tests pass. - -Closes REF-6 and LOW-4 from an internal audit. - -## Test plan -- [x] `just test` — 128/128. -- [x] `just lint` — clean (no `# ty: ignore` left in FastAPIConfig). -- [x] `grep -n "frozen=True" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/` — only config classes match. -- [ ] Reviewer: confirm `LoggingInstrument`'s and `OpenTelemetryInstrument`'s direct assignments preserve the `try/finally` exception safety from PR3. - -## Why the cascade -Python's `@dataclasses.dataclass` enforces that a non-frozen dataclass cannot inherit from a frozen one (and vice versa) — `TypeError` at class definition. To drop `frozen=True` from `LoggingInstrument` (REF-6's stated target), `BaseInstrument` must also drop it, which propagates to every other instrument subclass. The sequencing spec's PR13 section called for a surgical 2-class change; the actual cascade is 24 classes. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR13 section) and audit (REF-6, LOW-4): - -| Spec item | Task | -|-----------|------| -| Drop `frozen=True` from instrument hierarchy (cascade) | Task 2, Steps 1-12 | -| Replace `object.__setattr__` in `LoggingInstrument` with direct assignment | Task 2, Step 4 | -| Replace `object.__setattr__` in `OpenTelemetryInstrument` with direct assignment | Task 2, Step 5 | -| Configs stay frozen | Locked decisions + Task 4 Step 3 verification | -| LOW-4: replace `default=None` + `# ty: ignore` with sentinel | Task 3 | -| Branch name `refactor/ref-6-frozen-setattr` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 4, Steps 1-2 | - -All spec items covered. No placeholders. - -**Risk:** Medium. The cascade is mechanical but touches many classes. The chief risk is a missed entry — the verification grep at Task 4 Step 3 catches that. - -The behavioral risk is essentially zero: nothing in the codebase currently mutates an instrument after construction except the 4 `object.__setattr__` calls being replaced. Tests verify the cycles still work. - -**Deviation from sequencing spec:** Locked decision said "drop frozen=True from LoggingInstrument and OpenTelemetryInstrument" — implementation required the full 24-class cascade. Documented in the commit message and PR body. The sequencing spec should be updated retroactively (out of scope for this PR; can be a follow-up doc commit). - - ---- - -# PR14: Configurable FastStream Broker Health-Check Timeout (REF-7) - -> **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:** `FastStreamHealthChecksInstrument._define_health_status` calls `broker.ping(timeout=5)` with a hardcoded 5-second timeout. For users with slow brokers (large Redis clusters, message queues with cold connections), this is a footgun. Add a `faststream_health_check_broker_timeout: float = 5.0` field on `FastStreamConfig` and wire it through. - -**Architecture:** Pure additive config change. New field with backward-compatible default; existing callers see no behavior difference. One new regression test. - -**Tech Stack:** Python 3.10+ dataclasses, faststream, `unittest.mock.AsyncMock`. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR14 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-7). - ---- - -## File Structure - -Two files modified. - -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — add `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig`; update `FastStreamHealthChecksInstrument._define_health_status` to use it. -- Modify: `tests/test_faststream_bootstrap.py` — add `test_faststream_health_check_uses_configured_broker_timeout` exercising a non-default timeout. - ---- - -## Locked decisions (from sequencing spec) - -- **Field placement:** `FastStreamConfig`, NOT shared `HealthChecksConfig`. The timeout is FastStream-shaped (it's specifically about a message broker ping), so it belongs on the FastStream-specific config alongside other FastStream-only fields (`faststream_log_level`, `opentelemetry_middleware_cls`, etc.). Putting it on `HealthChecksConfig` would pollute FastAPI/Litestar configs with an unused field. -- **Default `5.0`:** Preserves existing behavior. Users who don't set the field see no change. -- **Field name:** `faststream_health_check_broker_timeout`. Prefixed `faststream_` for consistency with the other FastStream-specific fields on this config. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/ref-7-faststream-timeout -``` - -Expected: `Switched to a new branch 'fix/ref-7-faststream-timeout'`. - ---- - -## Task 2: Add the failing regression test - -**File:** `tests/test_faststream_bootstrap.py` - -The existing file uses `RedisBroker` fixtures and `TestClient` from starlette to exercise the health check. We'll spy on `broker.ping` to capture the timeout argument. - -### Step 1: Update imports - -Current top of file: - -```python -import logging -import typing - -import faststream.asgi -import pytest -import structlog -from faststream._internal.broker import BrokerUsecase -from faststream._internal.logger.params_storage import ManualLoggerStorage -from faststream.redis import RedisBroker, TestRedisBroker -... -``` - -Add `dataclasses` (stdlib) and `from unittest.mock import AsyncMock, patch` (stdlib). After: - -```python -import dataclasses -import logging -import typing -from unittest.mock import AsyncMock, patch - -import faststream.asgi -import pytest -import structlog -from faststream._internal.broker import BrokerUsecase -from faststream._internal.logger.params_storage import ManualLoggerStorage -from faststream.redis import RedisBroker, TestRedisBroker -... -``` - -(Preserve any existing imports between these — only adding the three new lines in the right import groups.) - -### Step 2: Append the new test - -At the end of the file, add: - -```python -async def test_faststream_health_check_uses_configured_broker_timeout(broker: RedisBroker) -> None: - expected_timeout = 12.5 - config = dataclasses.replace( - build_faststream_config(broker=broker), - faststream_health_check_broker_timeout=expected_timeout, - ) - bootstrapper = FastStreamBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with ( - patch.object(broker, "ping", new=AsyncMock(return_value=True)) as mock_ping, - TestClient(app=application) as test_client, - ): - response = test_client.get(config.health_checks_path) - assert response.status_code == status.HTTP_200_OK - mock_ping.assert_called_once_with(timeout=expected_timeout) - finally: - bootstrapper.teardown() -``` - -Contract: -- `dataclasses.replace` on a `FastStreamConfig` (still `frozen=True` post-PR13) creates a new instance overriding only the timeout. -- `patch.object(broker, "ping", new=AsyncMock(return_value=True))` replaces `broker.ping` with an async mock that returns `True` (healthy). -- `TestClient(app=application).get(config.health_checks_path)` triggers the health check, which calls `await broker.ping(timeout=...)`. -- `mock_ping.assert_called_once_with(timeout=expected_timeout)` asserts the configured value reached the broker. - -Note: `expected_timeout = 12.5` extracts the magic value into a named local — per the no-`PLR2004`-noqa policy established in PR10. - -### Step 3: Run the test and verify it FAILS - -```bash -just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v -``` - -Expected: **FAIL** in one of two ways: -- `AttributeError: 'FastStreamConfig' object has no attribute 'faststream_health_check_broker_timeout'` (the field doesn't exist yet on the config). -- Or: `AssertionError: expected call: ping(timeout=12.5)\nactual call: ping(timeout=5)` (if the field is somehow on the config but the instrument still hardcodes `5`). - -Either way, the test should NOT pass before the fix. If it does, stop and investigate. - ---- - -## Task 3: Implement the fix - -**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` - -### Step 1: Add field to `FastStreamConfig` - -Locate `FastStreamConfig`. Current: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - faststream_log_level: int = logging.WARNING -``` - -Add the new field at the end of the body: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -Single additive change. - -### Step 2: Use the field in `FastStreamHealthChecksInstrument._define_health_status` - -Locate `_define_health_status`. Current: - -```python - async def _define_health_status(self) -> bool: - if not self.bootstrap_config.application or not self.bootstrap_config.application.broker: - return False - - return await self.bootstrap_config.application.broker.ping(timeout=5) -``` - -Replace the hardcoded `timeout=5` with `timeout=self.bootstrap_config.faststream_health_check_broker_timeout`: - -```python - async def _define_health_status(self) -> bool: - if not self.bootstrap_config.application or not self.bootstrap_config.application.broker: - return False - - return await self.bootstrap_config.application.broker.ping( - timeout=self.bootstrap_config.faststream_health_check_broker_timeout, - ) -``` - -The expression is long enough that ruff will likely format it as multi-line (as shown). If ruff formats differently, accept its choice. - -### Step 3: Run the new test, verify PASS - -```bash -just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v -``` - -Expected: PASS. - -### Step 4: Run the full FastStream test file - -```bash -just test -- tests/test_faststream_bootstrap.py -v -``` - -Expected: all tests PASS. Watch the existing `test_faststream_bootstrap` (which exercises the health check via a real broker connection) — the default `5.0` is identical to the prior hardcoded `5`, so behavior should be unchanged. - -### Step 5: Run the full test suite - -```bash -just test -``` - -Expected: 129/129 (128 prior + 1 new). - -### Step 6: Run lint - -```bash -just lint -``` - -Expected: clean. The new field's `: float = 5.0` annotation should not trigger any ruff complaints; the test's `expected_timeout = 12.5` named-local pattern avoids PLR2004. - -### Step 7: Commit - -Stage the two modified files explicitly: - -```bash -git add \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ - tests/test_faststream_bootstrap.py -git commit -m "$(cat <<'EOF' -feat: configurable broker ping timeout for FastStream health check - -FastStreamHealthChecksInstrument._define_health_status called -broker.ping(timeout=5) with a hardcoded 5-second timeout. For users -with slow brokers (large Redis clusters under load, message queues -with cold connections), this is a footgun. - -Add faststream_health_check_broker_timeout: float = 5.0 to -FastStreamConfig. Default preserves the existing behavior; users can -now override. - -The field lives on FastStreamConfig (not the shared HealthChecksConfig) -because the timeout is FastStream-shaped — it's specifically about a -message broker ping, not a generic concern that FastAPI/Litestar -health checks would share. - -Regression test patches broker.ping to assert the configured timeout -value reaches it. - -Closes REF-7 from the audit. -EOF -)" -``` - ---- - -## Task 4: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/ref-7-faststream-timeout -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "feat: configurable broker ping timeout for FastStream health check" --body "$(cat <<'EOF' -## Summary -- Added `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig`. -- `FastStreamHealthChecksInstrument._define_health_status` now reads from the config instead of the previously hardcoded `timeout=5`. -- New regression test patches `broker.ping` and asserts the configured value reaches it. - -Default preserves existing behavior — pure-additive config option. Users with slow brokers can now bump the timeout without forking the library. - -Closes REF-7 from an internal audit. - -## Test plan -- [x] `just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v` — pass. -- [x] `just test` — 129/129. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the field lives on `FastStreamConfig` (not `HealthChecksConfig`) — this was the locked decision in the sequencing spec because the timeout is FastStream-shaped. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR14 section) and audit (REF-7): - -| Spec item | Task | -|-----------|------| -| Add `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig` | Task 3, Step 1 | -| Update `_define_health_status` to use the field instead of hardcoded `5` | Task 3, Step 2 | -| Add a regression test asserting the configured timeout reaches `broker.ping` | Task 2, Step 2 | -| Field on `FastStreamConfig`, NOT `HealthChecksConfig` (locked decision Q5) | Task 3, Step 1 | -| Branch name `fix/ref-7-faststream-timeout` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 3, Steps 5-6 | -| `expected_timeout = 12.5` named local (no PLR2004 noqa) | Task 2, Step 2 | - -All spec items covered. No placeholders. Risk: low — additive change with a backward-compatible default. - - ---- - -# PR15: Naming Pass — `Opentelemetry`→`OpenTelemetry` + `FreeBootstrapperConfig`→`FreeConfig` - -> **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:** The final PR of the deferred-refactors sequence. Two API-surface renames with silent backward-compatibility aliases. - -- **Bonus (Otel capitalization)**: `OpentelemetryConfig` → `OpenTelemetryConfig` to match `OpenTelemetryServiceFieldsConfig` (introduced in PR6) and conventional OpenTelemetry capitalization. -- **LOW-7**: `FreeBootstrapperConfig` → `FreeConfig` for consistency with sibling configs (`FastAPIConfig`, `LitestarConfig`, `FastStreamConfig` — none carry the `Bootstrapper` infix). - -Backward compat: silent aliases (`OpentelemetryConfig = OpenTelemetryConfig`, `FreeBootstrapperConfig = FreeConfig`) at module level plus a `FreeBootstrapperConfig` re-export in `lite_bootstrap/__init__.py`. Existing user code that imports the old names continues to work unchanged. - -**Architecture:** Mechanical renames across 11 files (7 production + 4 test). The aliases are simple assignments — same class object, so `isinstance(x, OldName)` and `isinstance(x, NewName)` are interchangeable. - -**Tech Stack:** Python 3.10+ dataclasses, module-level aliases. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR15 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (LOW-7). - ---- - -## File Structure - -11 files modified, no new files. - -**Production (7 files):** -- `lite_bootstrap/instruments/opentelemetry_instrument.py` — rename `OpentelemetryConfig` → `OpenTelemetryConfig`; add silent alias. -- `lite_bootstrap/bootstrappers/free_bootstrapper.py` — rename `FreeBootstrapperConfig` → `FreeConfig`; add silent alias; update internal references. -- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — update import and inheritance to `OpenTelemetryConfig`. -- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — same. -- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — same. -- `lite_bootstrap/__init__.py` — export both `FreeConfig` (canonical) and `FreeBootstrapperConfig` (alias) in `__all__`. - -**Tests (4 files):** -- `tests/test_free_bootstrap.py` — update internal usages to `FreeConfig`. -- `tests/instruments/test_opentelemetry_instrument.py` — update usages to `OpenTelemetryConfig`. -- `tests/instruments/test_logging_instrument.py` — update usages to `OpenTelemetryConfig`. -- `tests/instruments/test_pyroscope_instrument.py` — update usages to `FreeConfig`. - ---- - -## Locked decisions (from sequencing spec, Q2 + Q7) - -- **Silent aliases** (no warn-on-access). Library is small; warn-on-access is overkill for two renames. -- **Both names in `__init__.py`** for `FreeBootstrapperConfig`/`FreeConfig`. `OpentelemetryConfig` is not in `__init__.py` today, so the module-level alias suffices. -- **Update internal references and tests to the new names.** Aliases serve external users, not internal code. Aliases also exercise the new public API via tests. -- **Alias is a class assignment, not a subclass:** `OpentelemetryConfig = OpenTelemetryConfig` — same class object. `isinstance(x, OpentelemetryConfig) is isinstance(x, OpenTelemetryConfig)`. No `__init_subclass__` surprises, no MRO churn. - ---- - -## Cross-cutting concerns - -1. **Pickling.** Class identity is preserved by the alias (same object). New pickles use `OpenTelemetryConfig.__qualname__`. Old pickles (made before the rename, containing `OpentelemetryConfig` in their serialized form) still unpickle because the alias keeps the name resolvable in the module namespace. No data migration needed. - -2. **Import ordering.** The alias line MUST come AFTER the class definition. Standard Python — but easy to get wrong if reordering imports. - -3. **No PLR2004 noqa.** No new magic-value assertions in this PR. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/low-7-naming -``` - -Expected: `Switched to a new branch 'refactor/low-7-naming'`. - ---- - -## Task 2: Rename `OpentelemetryConfig` → `OpenTelemetryConfig` - -### Step 1: Rename in `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Locate the class definition (after `OpenTelemetryServiceFieldsConfig` from PR6): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpentelemetryConfig(OpenTelemetryServiceFieldsConfig): - ... -``` - -Change `class OpentelemetryConfig` → `class OpenTelemetryConfig`. - -Locate `OpenTelemetryInstrument`'s generic parameter: - -```python -class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): -``` - -Change to `BaseInstrument[OpenTelemetryConfig]`. - -Locate any other reference to `OpentelemetryConfig` in the file (e.g., field type annotations, function signatures) — there shouldn't be many; the symbol mostly appears in the class definition and the instrument generic. - -**Add the alias at the very end of the file**, after all class declarations: - -```python -# Backward-compatible alias preserved for users importing the old (lowercase t) spelling. -OpentelemetryConfig = OpenTelemetryConfig -``` - -### Step 2: Update inheritance + imports in three framework bootstrappers - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` - -Locate the import line: - -```python -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -``` - -Change `OpentelemetryConfig` → `OpenTelemetryConfig`. - -Locate `FastAPIConfig`'s inheritance: - -```python -class FastAPIConfig( - CorsConfig, - HealthChecksConfig, - LoggingConfig, - OpentelemetryConfig, # ← change to OpenTelemetryConfig - ... -``` - -Change `OpentelemetryConfig` → `OpenTelemetryConfig`. - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — same two changes. - -**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — same two changes. - -### Step 3: Update tests - -**Files:** `tests/instruments/test_opentelemetry_instrument.py`, `tests/instruments/test_logging_instrument.py` - -In each test file, change every `OpentelemetryConfig` reference (in imports and constructor calls) to `OpenTelemetryConfig`. Use Edit's `replace_all=true` for safety: - -```python -# Before: -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, ... - -# After: -from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, ... -``` - -And similarly for constructor calls like `OpentelemetryConfig(...)` → `OpenTelemetryConfig(...)`. - -### Step 4: Smoke test after the Otel rename - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_logging_instrument.py -v -``` - -Expected: all PASS. If anything fails with `NameError`, check that all references were updated. - ---- - -## Task 3: Rename `FreeBootstrapperConfig` → `FreeConfig` - -### Step 1: Rename in `lite_bootstrap/bootstrappers/free_bootstrapper.py` - -Current class: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FreeBootstrapperConfig(LoggingConfig, OpentelemetryConfig, PyroscopeConfig, SentryConfig): ... -``` - -Two changes here: -1. Rename to `class FreeConfig(LoggingConfig, OpenTelemetryConfig, PyroscopeConfig, SentryConfig): ...` (also picks up the Otel rename from Task 2). -2. Update the bootstrapper: - -```python -class FreeBootstrapper(BaseBootstrapper[None]): - ... - instruments_types: typing.ClassVar = [...] - bootstrap_config: FreeBootstrapperConfig # ← change to FreeConfig - not_ready_message = "" - ... - def __init__(self, bootstrap_config: FreeBootstrapperConfig) -> None: # ← change to FreeConfig - super().__init__(bootstrap_config) -``` - -Replace both `FreeBootstrapperConfig` occurrences with `FreeConfig`. - -**Add the alias at the end of the file:** - -```python -# Backward-compatible alias preserved for users importing the old name. -FreeBootstrapperConfig = FreeConfig -``` - -### Step 2: Update `lite_bootstrap/__init__.py` - -Current: - -```python -from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig -... -__all__ = [ - ... - "FreeBootstrapper", - "FreeBootstrapperConfig", - ... -] -``` - -Change to: - -```python -from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig, FreeConfig -... -__all__ = [ - ... - "FreeBootstrapper", - "FreeBootstrapperConfig", - "FreeConfig", - ... -] -``` - -Both names exported. Alphabetical order in `__all__` keeps `FreeBootstrapperConfig` before `FreeConfig`. Add `FreeConfig` after `FreeBootstrapperConfig`. - -### Step 3: Update tests - -**File:** `tests/test_free_bootstrap.py` - -Change every `FreeBootstrapperConfig` → `FreeConfig` (in imports, fixture annotations, constructor calls). Use Edit's `replace_all=true`. - -**File:** `tests/instruments/test_pyroscope_instrument.py` - -The pyroscope tests use `FreeBootstrapperConfig` to exercise the inheritance-through-Free path. Change references to `FreeConfig`. - -### Step 4: Smoke test after the Free rename - -```bash -just test -- tests/test_free_bootstrap.py tests/instruments/test_pyroscope_instrument.py -v -``` - -Expected: all PASS. - ---- - -## Task 4: Verify everything, commit - -### Step 1: Run the full test suite - -```bash -just test -``` - -Expected: 129/129 PASS. No behavior change; just symbol renames. - -### Step 2: Verify the aliases work for external imports - -```bash -uv run python -c "from lite_bootstrap import FreeBootstrapperConfig, FreeConfig; assert FreeBootstrapperConfig is FreeConfig; print('FreeConfig alias OK')" -uv run python -c "from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryConfig; assert OpentelemetryConfig is OpenTelemetryConfig; print('OpenTelemetryConfig alias OK')" -``` - -Both should print `... OK`. If either fails, the alias is broken. - -### Step 3: Run lint - -```bash -just lint -``` - -Expected: clean. Watch for: -- `ruff format` may reorder imports — accept its formatting. -- `ty` should be happy with both names since they're the same class. - -### Step 4: Sanity grep — verify no leftover old-name references in internal code - -```bash -grep -rn "OpentelemetryConfig\|FreeBootstrapperConfig" lite_bootstrap/ tests/ --include="*.py" | grep -v "alias\|backward" -``` - -Expected: ONLY the two alias-definition lines (`OpentelemetryConfig = OpenTelemetryConfig` and `FreeBootstrapperConfig = FreeConfig`) PLUS the `__init__.py` import/export entries (3 matches total for `FreeBootstrapperConfig`: import line, `__all__` entry, alias line; 1 match total for `OpentelemetryConfig`: the alias line). - -If any other `*.py` file has a reference to the old names outside these alias contexts, that's a missed update — fix it. - -### Step 5: Commit - -Stage all 11 files explicitly: - -```bash -git add \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/bootstrappers/free_bootstrapper.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ - lite_bootstrap/__init__.py \ - tests/test_free_bootstrap.py \ - tests/instruments/test_opentelemetry_instrument.py \ - tests/instruments/test_logging_instrument.py \ - tests/instruments/test_pyroscope_instrument.py -git commit -m "$(cat <<'EOF' -refactor: rename OpentelemetryConfig → OpenTelemetryConfig; FreeBootstrapperConfig → FreeConfig - -Two API-surface renames with silent backward-compatibility aliases. - -OpentelemetryConfig → OpenTelemetryConfig: matches the conventional -OpenTelemetry capitalization and the OpenTelemetryServiceFieldsConfig -mixin introduced in PR6. Module-level alias `OpentelemetryConfig = -OpenTelemetryConfig` preserves existing imports. Not exported from -__init__.py (wasn't before either). - -FreeBootstrapperConfig → FreeConfig: matches the sibling configs -(FastAPIConfig, LitestarConfig, FastStreamConfig — none carry the -"Bootstrapper" infix). Module-level alias plus `FreeBootstrapperConfig` -re-export in __init__.py preserves existing public imports. - -Internal references and tests updated to the new canonical names. -Aliases are simple class assignments — same class object, so -isinstance(x, OldName) and isinstance(x, NewName) are interchangeable. -Old pickles continue to unpickle via the alias. - -No behavior change. 129/129 tests pass. - -Closes LOW-7 from the audit. Also closes the bonus Otel capitalization -item surfaced during PR6's code review. -EOF -)" -``` - ---- - -## Task 5: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/low-7-naming -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: rename OpentelemetryConfig → OpenTelemetryConfig; FreeBootstrapperConfig → FreeConfig" --body "$(cat <<'EOF' -## Summary -The final PR of the deferred-refactors sequence. Two API-surface renames with silent backward-compatibility aliases: - -- **`OpentelemetryConfig` → `OpenTelemetryConfig`** — matches the conventional OpenTelemetry capitalization and the `OpenTelemetryServiceFieldsConfig` mixin from PR6. Module-level alias preserves existing imports. Not exported from `__init__.py` (wasn't before). -- **`FreeBootstrapperConfig` → `FreeConfig`** — matches the sibling configs (`FastAPIConfig`, `LitestarConfig`, `FastStreamConfig` — none carry the `Bootstrapper` infix). Module-level alias plus `FreeBootstrapperConfig` re-export in `__init__.py` preserves existing public imports. - -Internal references and tests updated to the new canonical names. Aliases are simple class assignments — same class object, so `isinstance(x, OldName)` and `isinstance(x, NewName)` are interchangeable. Old pickles continue to unpickle via the alias. - -No behavior change. 129/129 tests pass. - -Closes LOW-7 from an internal audit. Also closes the bonus Otel capitalization item surfaced during PR6's code review. - -## Test plan -- [x] `just test` — 129/129. -- [x] `just lint` — clean. -- [x] Aliases verified working (`isinstance(x, OldName) is isinstance(x, NewName)` for both renames). -- [ ] Reviewer: confirm the aliases are simple class assignments (not subclasses), so isinstance behavior is fully preserved. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR15 section) and audit (LOW-7): - -| Spec item | Task | -|-----------|------| -| Rename `OpentelemetryConfig` → `OpenTelemetryConfig` | Task 2, Step 1 | -| Add silent alias `OpentelemetryConfig = OpenTelemetryConfig` | Task 2, Step 1 | -| Rename `FreeBootstrapperConfig` → `FreeConfig` | Task 3, Step 1 | -| Add silent alias `FreeBootstrapperConfig = FreeConfig` | Task 3, Step 1 | -| Export both names from `__init__.py` | Task 3, Step 2 | -| Update internal references in framework bootstrappers | Task 2, Step 2 | -| Update tests to use new canonical names | Tasks 2 Step 3 + 3 Step 3 | -| Verify aliases work (`is` identity) | Task 4, Step 2 | -| Sanity grep for missed references | Task 4, Step 4 | -| Branch name `refactor/low-7-naming` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 4, Steps 1, 3 | - -All spec items covered. No placeholders. - -**Risk:** Low. Aliases preserve every existing import. The mechanical rename is well-scoped (11 files, predictable changes). The sanity grep at Task 4 Step 4 catches any miss. - -**Why this is the last PR:** With this merged, all 8 deferred-refactor PRs (PR8-15) close every audit finding except those explicitly marked out-of-scope in the sequencing spec. The audit becomes fully resolved. - - ---- - -# PR16: Post-Retro Hygiene (uv_build upper bound + Pyroscope endpoint assert) - -**Goal:** Two small hygiene items surfaced during retrospective action-item work. - -**Files:** -- `pyproject.toml` — add upper bound to `uv_build` to silence the every-`just lint` warning -- `lite_bootstrap/instruments/pyroscope_instrument.py` — add a runtime assert on `pyroscope_endpoint` to document the `is_ready()`-enforced invariant - -**Parent docs:** Surfaced in the [audit retrospective](../../retros/2026-06-01-audit-implementation-retro.md). Neither is an audit finding; both noticed during the retro action-item work (`just lint` warning persistence + Pyright's `reportArgumentType` on pyroscope's `server_address`). - -This is the first PR using the [lightweight plan template](../templates/lightweight-plan-template.md). Eat your own dog food. - ---- - -## Diff - -### `pyproject.toml` - -```python -# Before: -[build-system] -requires = ["uv_build"] -build-backend = "uv_build" - -# After: -[build-system] -requires = ["uv_build<0.12"] -build-backend = "uv_build" -``` - -The upper bound aligns with the warning's own suggestion (`Without bounding the uv_build version, the source distribution will break when a future, breaking version of uv_build is released. ...such as <0.12`). Pinning to <0.12 matches the major version we're on; the next breaking change is the next major. - -### `lite_bootstrap/instruments/pyroscope_instrument.py` - -In `PyroscopeInstrument.bootstrap()`, add an assert at the top documenting the precondition that `is_ready()` enforces: - -```python -# Before: -def bootstrap(self) -> None: - namespace = self.bootstrap_config.opentelemetry_namespace - tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags - pyroscope.configure( - application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, - server_address=self.bootstrap_config.pyroscope_endpoint, - sample_rate=self.bootstrap_config.pyroscope_sample_rate, - tags=tags, - **self.bootstrap_config.pyroscope_additional_params, - ) - -# After: -def bootstrap(self) -> None: - # is_ready() guarantees pyroscope_endpoint is set; assert documents the precondition - # for type narrowing and for direct callers that bypass the bootstrapper. - assert self.bootstrap_config.pyroscope_endpoint is not None - namespace = self.bootstrap_config.opentelemetry_namespace - tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags - pyroscope.configure( - application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, - server_address=self.bootstrap_config.pyroscope_endpoint, - sample_rate=self.bootstrap_config.pyroscope_sample_rate, - tags=tags, - **self.bootstrap_config.pyroscope_additional_params, - ) -``` - -Why an assert (not a cast): -- The invariant is real: `is_ready()` returns `bool(self.bootstrap_config.pyroscope_endpoint)`, and `BaseBootstrapper._register_or_skip` doesn't call `bootstrap()` if `is_ready()` returned False. -- `assert` runs at runtime and catches direct-bypass callers (e.g., `PyroscopeInstrument(config).bootstrap()` without going through a bootstrapper) with a clear `AssertionError` instead of a confusing pyroscope-side TypeError. -- The project allows `assert` (S101 is in ruff ignores). -- `ty` and Pyright both narrow `str | None` → `str` after the assert. - -No new test. The existing `test_pyroscope_instrument_bootstrap_and_teardown` covers the bootstrap path. - ---- - -## Verification - -1. `grep -n "uv_build" pyproject.toml` — confirm exactly one match, with `<0.12`. -2. `just lint` — the "missing upper bound on uv_build" warning should be gone. Lint stays clean otherwise. -3. `just test -- tests/instruments/test_pyroscope_instrument.py -v` — all pyroscope tests pass (the existing bootstrap test exercises the new assert path). -4. `just test` — full suite 129/129. - -### Pre-flight grep (template requirement) - -```bash -grep -rn "uv_build" pyproject.toml -grep -rn "self\.bootstrap_config\.pyroscope_endpoint" lite_bootstrap/instruments/pyroscope_instrument.py -``` - -Expected: -- `pyproject.toml` shows 2 matches (the `requires` line and `build-backend` line) — only the first changes. -- `pyroscope_instrument.py` shows 2 matches (the `is_ready` check at line ~28 and the `server_address` reference at line ~39); the assert addition is between them. - -## Commit - -```bash -git add pyproject.toml lite_bootstrap/instruments/pyroscope_instrument.py -git commit -m "$(cat <<'EOF' -chore: pin uv_build upper bound; assert pyroscope_endpoint precondition - -uv_build: silence the `just lint` warning about missing upper bound by -pinning to <0.12 (matches the warning's own suggestion). - -Pyroscope: add `assert self.bootstrap_config.pyroscope_endpoint is not None` -at the top of bootstrap(). This documents the precondition that is_ready() -already enforces and narrows the type for both ty and Pyright (was the -only remaining real Pyright complaint after the post-retro suppressions -landed). Direct callers that bypass the bootstrapper now get a clear -AssertionError instead of a TypeError from pyroscope. - -Both items surfaced during retro action-item work. First PR using the -lightweight plan template. -EOF -)" -``` - -## PR - -Branch: `chore/post-retro-hygiene`. Push, open via `gh pr create`. No reviewer asks beyond "diff looks right." diff --git a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/design.md b/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary.md similarity index 100% rename from planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/design.md rename to planning/changes/2026-06-02.01-stdlib-logging-and-build-summary.md diff --git a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md b/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md deleted file mode 100644 index 95c79f0..0000000 --- a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md +++ /dev/null @@ -1,495 +0,0 @@ -# Stdlib Logging + `build_summary()` 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:** Replace the `_get_logger()` structlog indirection in `lite_bootstrap/bootstrappers/base.py` with a module-level stdlib `logging` logger, and add a public `build_summary()` method on `BaseBootstrapper` that returns a multi-line human-readable description of configured + skipped instruments. The same string the INFO summary log emits is available for post-construction debugging. - -**Architecture:** One prod-side change (`bootstrappers/base.py`): delete the `try: import structlog / _get_logger / except` block, add `logger = logging.getLogger(__name__)` at module scope, add `build_summary()` method, and route both the `__init__` summary log and the teardown error log through the stdlib logger. Two test sites migrate from `structlog.testing.capture_logs()` to pytest's `caplog`. Two new tests added: a `build_summary()` format test, and a silent-skip regression test that locks in the contract that config-driven skips emit no warnings. Docs follow. - -**Tech Stack:** Python 3.10+, stdlib `logging`, pytest (`caplog`), ruff, `ty`. - -**Parent spec:** `./design.md` - ---- - -## File Structure - -| File | Change | -|------|--------| -| `lite_bootstrap/bootstrappers/base.py` | Delete `_get_logger()` block. Add `logger = logging.getLogger(__name__)` at module scope. Add `build_summary()` method on `BaseBootstrapper`. Replace `_get_logger().info(...)` and `_get_logger().warning(...)` calls. | -| `tests/test_free_bootstrap.py` | Migrate `test_teardown_error_isolation` and `test_free_bootstrap_emits_summary_log` from `capture_logs` to `caplog`. Add `test_config_skip_emits_no_warning`. Add `test_build_summary_format`. Drop `capture_logs` import; add `logging` and `warnings` imports. | -| `CLAUDE.md` | Replace the `_get_logger()` sentence with the `build_summary()` description. | -| `docs/introduction/configuration.md` | Append one paragraph documenting `build_summary()`. | -| `docs/superpowers/plans/2026-06-01-instrument-skip-rework.md` | One-line supersession note at the top. | - -No new files. No instrument or exception class touched. - ---- - -## Pre-flight grep verification - -Before starting, confirm the scope matches reality: - -```bash -grep -rn "_get_logger\|capture_logs" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: -- `lite_bootstrap/bootstrappers/base.py`: 4 matches (definition in try/except + 2 call sites: `__init__` summary log, teardown warning). -- `tests/test_free_bootstrap.py`: 3 matches (import + 2 `with capture_logs()` blocks). -- No matches anywhere else. - -```bash -grep -n "logger = " lite_bootstrap/bootstrappers/base.py -``` - -Expected: 0 matches today (the file uses `_get_logger()`, not a module-level `logger`). - -```bash -grep -rn "build_summary" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: 0 matches today. - ---- - -## Task 1: Add `build_summary()` + module-level stdlib logger + migrate impacted tests - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/base.py` -- Modify: `tests/test_free_bootstrap.py` - -This task lands the prod change and the test migration in one commit because the two `capture_logs()` test sites break the moment the logging backend switches — they must move together. - -- [ ] **Step 1.1: Write the failing `build_summary()` format test** - -Open `tests/test_free_bootstrap.py`. Append at the end of the file: - -```python -def test_build_summary_format() -> None: - bootstrapper = FreeBootstrapper( - bootstrap_config=FreeConfig( - sentry_dsn="https://testdsn@localhost/1", - sentry_additional_params={"transport": SentryTestTransport()}, - logging_enabled=False, - logging_buffer_capacity=0, - ), - ) - summary = bootstrapper.build_summary() - assert summary.startswith("FreeBootstrapper:") - assert " configured:" in summary - assert " skipped:" in summary - assert " - SentryInstrument" in summary - assert " - LoggingInstrument: logging_enabled is False" in summary -``` - -- [ ] **Step 1.2: Run the new test to confirm it fails** - -Run: `uv run pytest tests/test_free_bootstrap.py::test_build_summary_format -v` - -Expected: `AttributeError: 'FreeBootstrapper' object has no attribute 'build_summary'`. - -- [ ] **Step 1.3: Replace `_get_logger()` block with module-level stdlib logger** - -Open `lite_bootstrap/bootstrappers/base.py`. Replace lines 15–32 (the entire `try: import structlog / def _get_logger(): ... / except ImportError: def _get_logger(): ...` block) with: - -```python -logger = logging.getLogger(__name__) -``` - -After this edit, the top of the file reads: - -```python -import abc -import logging -import typing -import warnings - -from lite_bootstrap.exceptions import ( - BootstrapperNotReadyError, - InstrumentDependencyMissingWarning, - TeardownError, -) -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument -from lite_bootstrap.types import ApplicationT - - -logger = logging.getLogger(__name__) - - -InstrumentT = typing.TypeVar("InstrumentT", bound=BaseInstrument) -``` - -- [ ] **Step 1.4: Add `build_summary()` method** - -In the same file, inside `class BaseBootstrapper`, add `build_summary()` immediately after the `bootstrap_config: BaseConfig` field declaration and before `def __init__`. Result: - -```python - skipped_instruments: list[tuple[type[BaseInstrument], str]] - bootstrap_config: BaseConfig - - def build_summary(self) -> str: - """Return a multi-line human-readable summary of configured + skipped instruments. - - Useful for INFO-level diagnostic logging (called once by ``__init__``) and for - post-construction debugging (e.g. from a REPL or a health endpoint). - """ - lines = [f"{type(self).__name__}:", " configured:"] - if self.instruments: - lines.extend(f" - {type(i).__name__}" for i in self.instruments) - else: - lines.append(" (none)") - lines.append(" skipped:") - if self.skipped_instruments: - lines.extend(f" - {cls.__name__}: {reason}" for cls, reason in self.skipped_instruments) - else: - lines.append(" (none)") - return "\n".join(lines) - - def __init__(self, bootstrap_config: BaseConfig) -> None: -``` - -- [ ] **Step 1.5: Replace the `__init__` summary log call** - -In the same file, find the `__init__` summary log at the end of the loop (currently lines 70–74): - -```python - _get_logger().info( - f"{type(self).__name__}: " - f"configured={[type(i).__name__ for i in self.instruments]}, " - f"skipped={[(cls.__name__, reason) for cls, reason in self.skipped_instruments]}" - ) -``` - -Replace with: - -```python - logger.info(self.build_summary()) -``` - -- [ ] **Step 1.6: Replace the teardown error log call** - -In the same file, find the teardown error log (currently line 104): - -```python - _get_logger().warning(f"Error tearing down {name}: {e}") -``` - -Replace with: - -```python - logger.warning("Error tearing down %s: %s", name, e) -``` - -- [ ] **Step 1.7: Run the format test to confirm it passes** - -Run: `uv run pytest tests/test_free_bootstrap.py::test_build_summary_format -v` - -Expected: PASS. - -- [ ] **Step 1.8: Migrate `test_teardown_error_isolation` from `capture_logs` to `caplog`** - -Open `tests/test_free_bootstrap.py`. Replace the existing `test_teardown_error_isolation` function (currently lines 56–73) with: - -```python -def test_teardown_error_isolation( - free_bootstrapper_config: FreeConfig, caplog: pytest.LogCaptureFixture -) -> None: - bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) - bootstrapper.bootstrap() - - # Replace instruments with mocks: first raises, second succeeds. - bad = MagicMock() - bad.teardown.side_effect = RuntimeError("boom") - good = MagicMock() - bootstrapper.instruments = [bad, good] - - with ( - caplog.at_level(logging.WARNING, logger="lite_bootstrap.bootstrappers.base"), - pytest.raises(TeardownError, match="boom") as excinfo, - ): - bootstrapper.teardown() - - # Both instruments attempted teardown despite the error (LIFO: good first, bad second). - good.teardown.assert_called_once() - bad.teardown.assert_called_once() - assert any("boom" in r.message for r in caplog.records) - assert excinfo.value.errors == [("MagicMock", excinfo.value.__cause__)] -``` - -- [ ] **Step 1.9: Migrate `test_free_bootstrap_emits_summary_log` from `capture_logs` to `caplog`** - -Replace the existing `test_free_bootstrap_emits_summary_log` (currently lines 144–156) with: - -```python -def test_free_bootstrap_emits_summary_log(caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.INFO, logger="lite_bootstrap.bootstrappers.base"): - FreeBootstrapper( - bootstrap_config=FreeConfig( - sentry_dsn="https://testdsn@localhost/1", - sentry_additional_params={"transport": SentryTestTransport()}, - ), - ) - summary_records = [r for r in caplog.records if "FreeBootstrapper" in r.message] - assert summary_records, "expected a summary log entry mentioning FreeBootstrapper" - summary = summary_records[-1].message - assert "configured:" in summary - assert "skipped:" in summary -``` - -- [ ] **Step 1.10: Update test-file imports** - -In `tests/test_free_bootstrap.py`, change the top-of-file imports. Currently: - -```python -from unittest.mock import MagicMock - -import pytest -import structlog -from structlog.testing import capture_logs - -from lite_bootstrap import ( - FreeBootstrapper, - FreeConfig, - TeardownError, -) -``` - -Change to: - -```python -import logging -from unittest.mock import MagicMock - -import pytest -import structlog - -from lite_bootstrap import ( - FreeBootstrapper, - FreeConfig, - TeardownError, -) -``` - -(`capture_logs` import dropped; `logging` added. The module-level `import structlog` and `logger = structlog.getLogger(__name__)` on the line that follows the import block stay — they're used by `test_free_bootstrap` to emit an application-level log line through the configured `LoggingInstrument`.) - -- [ ] **Step 1.11: Run the full `test_free_bootstrap.py` suite** - -Run: `uv run pytest tests/test_free_bootstrap.py -v` - -Expected: all tests pass, including the migrated `test_teardown_error_isolation`, the migrated `test_free_bootstrap_emits_summary_log`, and the new `test_build_summary_format`. - -- [ ] **Step 1.12: Run lint** - -Run: `just lint` - -Expected: clean (ruff format, ruff check, ty check). - -If lint complains about an unused `typing` import on `bootstrappers/base.py`, leave the import — `typing.ClassVar`, `typing.Generic`, `typing.TypeVar` are still used in the file body. - -- [ ] **Step 1.13: Commit** - -```bash -git add lite_bootstrap/bootstrappers/base.py tests/test_free_bootstrap.py -git commit -m "$(cat <<'EOF' -refactor: use stdlib logging in BaseBootstrapper; add build_summary() - -Replace the _get_logger() structlog indirection with a module-level -logging.getLogger(__name__). The two log sites (INFO summary in __init__, -WARNING teardown error) describe bootstrapper lifecycle, not application -events, so stdlib logging is appropriate. Removes a global-structlog-state -landmine introduced by the previous fresh-per-call workaround. - -Add BaseBootstrapper.build_summary() returning a multi-line human-readable -description of configured + skipped instruments. __init__ uses it for the -INFO summary; users can call it post-construction for debugging. - -Migrate the two test sites that used structlog.testing.capture_logs() to -pytest's caplog fixture. -EOF -)" -``` - ---- - -## Task 2: Add silent-skip regression test - -**Files:** -- Modify: `tests/test_free_bootstrap.py` - -Locks in the contract this PR was designed to deliver: config-driven skip emits NO warning. Without this test, a future regression that re-adds `warnings.warn` to the `is_configured` False branch would pass CI. - -- [ ] **Step 2.1: Add the silent-skip test** - -Append to `tests/test_free_bootstrap.py` (after `test_build_summary_format`): - -```python -def test_config_skip_emits_no_warning() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") # any UserWarning becomes a test failure - bootstrapper = FreeBootstrapper( - bootstrap_config=FreeConfig( - logging_enabled=False, - logging_buffer_capacity=0, - ), - ) - assert LoggingInstrument in {cls for cls, _ in bootstrapper.skipped_instruments} -``` - -- [ ] **Step 2.2: Add the `warnings` import** - -In `tests/test_free_bootstrap.py`, add `import warnings` to the stdlib import block at the top of the file: - -```python -import logging -import warnings -from unittest.mock import MagicMock -``` - -- [ ] **Step 2.3: Run the new test** - -Run: `uv run pytest tests/test_free_bootstrap.py::test_config_skip_emits_no_warning -v` - -Expected: PASS. (Config-driven skip is already silent under the current `is_configured` flow; this test locks it in.) - -- [ ] **Step 2.4: Run lint** - -Run: `just lint` - -Expected: clean. - -- [ ] **Step 2.5: Commit** - -```bash -git add tests/test_free_bootstrap.py -git commit -m "$(cat <<'EOF' -test: lock in silent-skip contract for config-driven instrument skip - -Asserts that constructing a bootstrapper with instruments whose config -indicates they should not run emits zero UserWarnings. Without this -regression test, a future change that re-introduces warnings.warn to the -is_configured=False branch would pass CI. -EOF -)" -``` - ---- - -## Task 3: Documentation updates - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `docs/introduction/configuration.md` -- Modify: `docs/superpowers/plans/2026-06-01-instrument-skip-rework.md` - -- [ ] **Step 3.1: Update `CLAUDE.md`** - -In `CLAUDE.md`, find the "Instrument skip ordering" bullet. It currently ends with: - -> The bootstrappers/base.py logger is obtained fresh per call (`_get_logger()`) instead of cached at module level, so `structlog.testing.capture_logs()` can override processors at test time even after `LoggingInstrument.bootstrap()` sets `cache_logger_on_first_use=True`. - -Replace that sentence (keep the surrounding bullet content untouched) with: - -> One `logger.info` summary line at the end lists configured + skipped instruments via `BaseBootstrapper.build_summary()`; that method is also publicly callable for post-construction debugging. Uses stdlib `logging` so it composes cleanly with the user's logging setup and with pytest's `caplog`. - -- [ ] **Step 3.2: Append `build_summary()` paragraph to `configuration.md`** - -In `docs/introduction/configuration.md`, find the "Skipped instruments" section that ends with the `for cls, reason in bootstrapper.skipped_instruments` example. After that code block, append: - -```markdown -To get a human-readable view of the same information at any later point (e.g. for debugging from a REPL or a health endpoint), call `bootstrapper.build_summary()`. It returns the multi-line string that the INFO summary log emits — useful when log levels are filtered or when you want to render the bootstrapper state inline. -``` - -- [ ] **Step 3.3: Add supersession note to the previous plan** - -Open `docs/superpowers/plans/2026-06-01-instrument-skip-rework.md`. Immediately after the H1 title line (`# Instrument Skip Rework Implementation Plan`), insert a blockquote: - -```markdown -> **Note (2026-06-02):** the `_get_logger()` fresh-per-call decision documented below was revised by `docs/superpowers/specs/2026-06-02-stdlib-logging-and-build-summary-design.md`. The summary-log goal is unchanged; the implementation switched to stdlib `logging` with a public `build_summary()` method. -``` - -- [ ] **Step 3.4: Commit** - -```bash -git add CLAUDE.md docs/introduction/configuration.md docs/superpowers/plans/2026-06-01-instrument-skip-rework.md -git commit -m "$(cat <<'EOF' -docs: document build_summary() and remove _get_logger() rationale - -CLAUDE.md and configuration.md now describe the build_summary() method and -the stdlib logging backend. The previous instrument-skip-rework plan gets a -supersession note pointing at the new design. -EOF -)" -``` - ---- - -## Task 4: Full verification - -**Files:** (no edits; verification only) - -- [ ] **Step 4.1: Run full test suite** - -Run: `just test` - -Expected: 152 tests pass (the existing 150 from PR #107 plus the two new tests added in Tasks 1 and 2). - -- [ ] **Step 4.2: Run lint in CI mode** - -Run: `just lint-ci` - -Expected: clean. - -- [ ] **Step 4.3: Confirm grep success criteria** - -```bash -grep -rn "_get_logger" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: 0 matches. - -```bash -grep -rn "capture_logs" tests/test_free_bootstrap.py -``` - -Expected: 0 matches. - -```bash -grep -rn "build_summary" lite_bootstrap/ tests/ --include="*.py" -``` - -Expected: 4 matches — (1) method `def build_summary` in `lite_bootstrap/bootstrappers/base.py`, (2) call site `logger.info(self.build_summary())` in the same file, (3) test function name `def test_build_summary_format` in `tests/test_free_bootstrap.py`, (4) call `bootstrapper.build_summary()` inside that test. - -If counts diverge, investigate before proceeding. - -- [ ] **Step 4.4: No commit needed** - -Task 4 is verification only. If something failed, address it as a fix-up commit, not a force-push. - ---- - -## Self-Review - -**Spec coverage check** (against `docs/superpowers/specs/2026-06-02-stdlib-logging-and-build-summary-design.md`): - -| Spec item | Plan task | -|-----------|-----------| -| Delete `_get_logger()` block | Task 1, Step 1.3 | -| Module-level `logger = logging.getLogger(__name__)` | Task 1, Step 1.3 | -| `build_summary()` method | Task 1, Step 1.4 (test 1.1) | -| `__init__` calls `logger.info(self.build_summary())` | Task 1, Step 1.5 | -| Teardown uses `logger.warning("...", name, e)` (lazy `%`-format) | Task 1, Step 1.6 | -| Migrate `test_teardown_error_isolation` to `caplog` | Task 1, Step 1.8 | -| Migrate `test_free_bootstrap_emits_summary_log` to `caplog` | Task 1, Step 1.9 | -| Drop `capture_logs` import; add `logging`, `warnings` | Task 1 Step 1.10 + Task 2 Step 2.2 | -| New `test_build_summary_format` | Task 1, Step 1.1 | -| New `test_config_skip_emits_no_warning` | Task 2, Step 2.1 | -| `CLAUDE.md` update | Task 3, Step 3.1 | -| `configuration.md` paragraph | Task 3, Step 3.2 | -| Supersession note on prior plan | Task 3, Step 3.3 | - -All spec items covered. - -**Placeholder scan:** None of the disallowed phrases (TBD, TODO, "implement later", "similar to Task N") appear; every code step shows the exact code. Verified. - -**Type / name consistency:** `build_summary` used as method name throughout. `logger` (lowercase) used at module scope in every call site. Test fixtures use `caplog: pytest.LogCaptureFixture` consistently. Logger name string `"lite_bootstrap.bootstrappers.base"` used identically in both migrated tests. diff --git a/planning/changes/2026-06-05.01-bug-audit-v2/design.md b/planning/changes/2026-06-05.01-bug-audit-v2.md similarity index 100% rename from planning/changes/2026-06-05.01-bug-audit-v2/design.md rename to planning/changes/2026-06-05.01-bug-audit-v2.md diff --git a/planning/changes/2026-06-05.01-bug-audit-v2/plan.md b/planning/changes/2026-06-05.01-bug-audit-v2/plan.md deleted file mode 100644 index 8fb4a3b..0000000 --- a/planning/changes/2026-06-05.01-bug-audit-v2/plan.md +++ /dev/null @@ -1,2520 +0,0 @@ -# 05.01-bug-audit-v2 — implementation plan - -> Multi-PR plan: this change shipped as a sequence of PRs. Each section below was an independent per-PR plan; they are preserved verbatim here as the bundle's single `plan.md` (the spec is [`design.md`](./design.md)). - - ---- - -# PR1 — Lifecycle & Teardown Correctness 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:** Land all 10 lifecycle/teardown fixes from the 2026-06-05 audit (LOG-1..9 + SEC-4) into one cohesive PR. - -**Architecture:** Each fix is a small TDD cycle (failing test → implement → verify → commit). The sub-fixes are independent code-wise but share the "bootstrap promised, teardown didn't deliver" theme. Tasks are ordered to land the simplest invariants first (assert → raise), then the OTel teardown completeness, then the broader teardown-robustness fixes, then the app-reuse safety guards. - -**Tech Stack:** Python 3.10+, `uv` workspace, `pytest`, `pytest-asyncio`, `ty` type checker, `ruff` formatter, `structlog`, `opentelemetry-sdk`, `sentry-sdk`, `fastmcp`, `litestar`, `faststream`, `fastapi`. - -**Branch:** `fix/bug-audit-v2-pr1-lifecycle` - -**Important deviation from sequencing doc:** During plan drafting, the OTel SDK's `set_tracer_provider` was found to be enforced as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` (see `opentelemetry/trace/__init__.py:548-556` in the pinned version). A second `set_tracer_provider(NoOpTracerProvider())` would be logged-and-ignored, not applied. **LOG-1 therefore becomes a docstring-only change** that documents the OTel set-once constraint — the existing `shutdown()` call (added in CRIT-2) is the only practical teardown action. The audit's TEST-NEW-2 splits accordingly: the "shutdown called" half is already covered by `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider`; the "global is reset" half is dropped because OTel doesn't support it. - ---- - -## File Structure - -Modifications to existing files only — no new files. - -| File | What changes | -|------|-------------| -| `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` | `_narrow_app` assert → raise (Task 1); LOG-8 re-wrap guard (Task 10) | -| `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` | LOG-7 re-attach guard (Task 9) | -| `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` | LOG-4 broker logger restore (Task 6) | -| `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` | LOG-6 WeakKeyDictionary cache (Task 8) | -| `lite_bootstrap/instruments/logging_instrument.py` | LOG-3 teardown try/finally (Task 5) | -| `lite_bootstrap/instruments/opentelemetry_instrument.py` | LOG-1 docstring (Task 3); LOG-2 logger restore (Task 4) | -| `lite_bootstrap/instruments/pyroscope_instrument.py` | Pyroscope precondition assert → raise (Task 2) | -| `lite_bootstrap/instruments/sentry_instrument.py` | LOG-9 teardown override (Task 7) | -| `tests/test_fastapi_bootstrap.py` | Tests for Tasks 1, 10 | -| `tests/test_fastmcp_bootstrap.py` | Test for Task 9 | -| `tests/test_faststream_bootstrap.py` | Test for Task 6 | -| `tests/test_litestar_bootstrap.py` | Test for Task 8 | -| `tests/instruments/test_logging_instrument.py` | Test for Task 5 | -| `tests/instruments/test_opentelemetry_instrument.py` | Tests for Tasks 3, 4 | -| `tests/instruments/test_pyroscope_instrument.py` | Test for Task 2 | -| `tests/instruments/test_sentry_instrument.py` | Test for Task 7; remove `finally: sentry_sdk.init()` workarounds | - ---- - -## Task 1: LOG-5 part A — `_narrow_app` assert → explicit raise - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:78-80` -- Test: `tests/test_fastapi_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_bootstrap.py`: - -```python -import dataclasses - -import pytest - -from lite_bootstrap.bootstrappers.fastapi_bootstrapper import _narrow_app -from lite_bootstrap.types import UNSET - - -def test_narrow_app_raises_when_application_unset() -> None: - # Build a config and forcibly reset application to UNSET to simulate the - # invariant violation `_narrow_app` was guarding with an assert. - config = FastAPIConfig() - object.__setattr__(config, "application", UNSET) - with pytest.raises(RuntimeError, match="application is UNSET"): - _narrow_app(config) -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_narrow_app_raises_when_application_unset -``` - -Expected: FAIL — current code uses `assert` which raises `AssertionError`, not `RuntimeError`. The `pytest.raises(RuntimeError, match=...)` will not match. - -- [ ] **Step 3: Replace the assert** - -In `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py`, replace lines 78-80: - -```python -def _narrow_app(config: "FastAPIConfig") -> "fastapi.FastAPI": - if isinstance(config.application, UnsetType): - msg = "FastAPIConfig.application is UNSET; __post_init__ did not run" - raise RuntimeError(msg) - return config.application -``` - -- [ ] **Step 4: Run the test to verify it passes** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_narrow_app_raises_when_application_unset -``` - -Expected: PASS. - -- [ ] **Step 5: Verify nothing else broke** - -```bash -just test -- tests/test_fastapi_bootstrap.py tests/test_fastapi_offline_docs.py -``` - -Expected: all green. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastapi_bootstrapper.py tests/test_fastapi_bootstrap.py -git commit -m "fix: replace _narrow_app assert with RuntimeError (LOG-5/SEC-4 part A) - -Bandit B101 flagged the assert as stripped under \`python -O\`. Replace with -explicit raise so the invariant holds under all Python optimization levels." -``` - ---- - -## Task 2: LOG-5 part B — Pyroscope precondition assert → explicit raise - -**Files:** -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py:34-37` -- Test: `tests/instruments/test_pyroscope_instrument.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_pyroscope_instrument.py`: - -```python -def test_pyroscope_bootstrap_raises_when_endpoint_unset() -> None: - # Build a config that passes is_configured (endpoint set), then forcibly - # clear the endpoint to simulate a caller bypassing the bootstrapper's - # is_configured gate. - config = _make_config() - object.__setattr__(config, "pyroscope_endpoint", None) - instrument = PyroscopeInstrument(bootstrap_config=config) - with pytest.raises(RuntimeError, match="pyroscope_endpoint is unset"): - instrument.bootstrap() -``` - -Verify `pytest` is already imported in the file (it is — see line 4). - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_pyroscope_instrument.py::test_pyroscope_bootstrap_raises_when_endpoint_unset -``` - -Expected: FAIL — the current `assert` raises `AssertionError`, not `RuntimeError`. - -- [ ] **Step 3: Replace the assert** - -In `lite_bootstrap/instruments/pyroscope_instrument.py`, replace lines 34-37: - -```python -def bootstrap(self) -> None: - # is_configured() guarantees pyroscope_endpoint is set when called via the - # bootstrapper. Direct callers bypassing is_configured see an explicit raise. - if self.bootstrap_config.pyroscope_endpoint is None: - msg = "pyroscope_endpoint is unset; PyroscopeInstrument.is_configured() should have returned False" - raise RuntimeError(msg) - namespace = self.bootstrap_config.opentelemetry_namespace - tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags - pyroscope.configure( - application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, - server_address=self.bootstrap_config.pyroscope_endpoint, - sample_rate=self.bootstrap_config.pyroscope_sample_rate, - tags=tags, - **self.bootstrap_config.pyroscope_additional_params, - ) -``` - -- [ ] **Step 4: Run the test to verify it passes** - -```bash -just test -- tests/instruments/test_pyroscope_instrument.py::test_pyroscope_bootstrap_raises_when_endpoint_unset -``` - -Expected: PASS. - -- [ ] **Step 5: Verify nothing else broke** - -```bash -just test -- tests/instruments/test_pyroscope_instrument.py -``` - -Expected: all green. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/instruments/pyroscope_instrument.py tests/instruments/test_pyroscope_instrument.py -git commit -m "fix: replace pyroscope precondition assert with RuntimeError (LOG-5/SEC-4 part B) - -Bandit B101 flagged the assert as stripped under \`python -O\`. Replace with -explicit raise. Resolves the second of the two bandit B101 findings in PR1." -``` - ---- - -## Task 3: LOG-1 — Document OTel `set_tracer_provider` constraint - -**Files:** -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` (class docstring on `OpenTelemetryInstrument`) - -**Context:** `opentelemetry.trace.set_tracer_provider` is enforced as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` (verified against the pinned OTel SDK). A teardown that resets the global to a no-op would require touching private SDK internals (`_TRACER_PROVIDER` and `_TRACER_PROVIDER_SET_ONCE`). That hack is out of scope for this PR — we instead document the constraint so users understand the lifecycle. The audit's LOG-1 finding is downgraded to "documentation only" once the OTel API behavior is verified. - -- [ ] **Step 1: Add docstring to `OpenTelemetryInstrument`** - -In `lite_bootstrap/instruments/opentelemetry_instrument.py:80-86`, add a class docstring: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]): - """OpenTelemetry tracing instrument. - - Lifecycle note: ``bootstrap()`` calls ``opentelemetry.trace.set_tracer_provider``, - which the OTel SDK enforces as **set-once per process** (subsequent calls log - "Overriding of current TracerProvider is not allowed" and have no effect). - ``teardown()`` calls ``shutdown()`` on the provider, which flushes batched - spans and closes exporters, but it cannot reset the process-global pointer — - callers of ``opentelemetry.trace.get_tracer_provider()`` after teardown will - still receive the shut-down provider. The supported lifecycle is one - ``OpenTelemetryInstrument`` per process; do not bootstrap a second instance. - """ - - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) -``` - -- [ ] **Step 2: Verify lint passes** - -```bash -just lint-ci -``` - -Expected: green. Docstring change only. - -- [ ] **Step 3: Commit** - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py -git commit -m "docs: document OTel set_tracer_provider set-once constraint (LOG-1) - -The OTel SDK enforces set_tracer_provider as set-once per process. teardown() -calls shutdown() (added in CRIT-2) but cannot reset the process-global pointer. -Document the supported lifecycle: one OpenTelemetryInstrument per process." -``` - ---- - -## Task 4: LOG-2 — Restore disabled OTel loggers on teardown - -**Files:** -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` -- Test: `tests/instruments/test_opentelemetry_instrument.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_opentelemetry_instrument.py`: - -```python -import logging - - -def test_opentelemetry_teardown_restores_disabled_loggers() -> None: - instrumentor_logger = logging.getLogger("opentelemetry.instrumentation.instrumentor") - trace_logger = logging.getLogger("opentelemetry.trace") - # Capture pre-bootstrap state so the assertion is independent of test order. - prior_instrumentor_disabled = instrumentor_logger.disabled - prior_trace_disabled = trace_logger.disabled - - instrument = OpenTelemetryInstrument( - bootstrap_config=OpenTelemetryConfig(opentelemetry_log_traces=True), - ) - instrument.bootstrap() - assert instrumentor_logger.disabled is True - assert trace_logger.disabled is True - - instrument.teardown() - - assert instrumentor_logger.disabled is prior_instrumentor_disabled - assert trace_logger.disabled is prior_trace_disabled -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_teardown_restores_disabled_loggers -``` - -Expected: FAIL — current `teardown()` does not restore the `disabled` flags. - -- [ ] **Step 3: Add capture-and-restore logic** - -In `lite_bootstrap/instruments/opentelemetry_instrument.py`, modify the `OpenTelemetryInstrument` dataclass (around line 80-86 — keep the docstring from Task 3) to add a new init=False field: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]): - """OpenTelemetry tracing instrument. - - Lifecycle note: ``bootstrap()`` calls ``opentelemetry.trace.set_tracer_provider``, - which the OTel SDK enforces as **set-once per process** (subsequent calls log - "Overriding of current TracerProvider is not allowed" and have no effect). - ``teardown()`` calls ``shutdown()`` on the provider, which flushes batched - spans and closes exporters, but it cannot reset the process-global pointer — - callers of ``opentelemetry.trace.get_tracer_provider()`` after teardown will - still receive the shut-down provider. The supported lifecycle is one - ``OpenTelemetryInstrument`` per process; do not bootstrap a second instance. - """ - - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - _prior_logger_disabled: dict[str, bool] = dataclasses.field( - default_factory=dict, init=False, repr=False, compare=False - ) -``` - -In the same file, modify `bootstrap()` (lines 107-109 currently set `.disabled = True` on two loggers). Replace those two lines with capture-then-set: - -```python -def bootstrap(self) -> None: - for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"): - otel_logger = logging.getLogger(logger_name) - self._prior_logger_disabled[logger_name] = otel_logger.disabled - otel_logger.disabled = True - attributes = { - resources.SERVICE_NAME: self.bootstrap_config.opentelemetry_service_name - or self.bootstrap_config.service_name, - resources.TELEMETRY_SDK_LANGUAGE: "python", - resources.SERVICE_NAMESPACE: self.bootstrap_config.opentelemetry_namespace, - resources.SERVICE_VERSION: self.bootstrap_config.service_version, - resources.CONTAINER_NAME: self.bootstrap_config.opentelemetry_container_name, - } - # ... (rest of bootstrap body unchanged from current code) -``` - -In `teardown()` (lines 146-156), add restore logic. Place it after the uninstrument loop, before the `_tracer_provider.shutdown()` block so restoration happens even if shutdown raises: - -```python -def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() - for logger_name, prior in self._prior_logger_disabled.items(): - logging.getLogger(logger_name).disabled = prior - self._prior_logger_disabled.clear() - if self._tracer_provider is not None: - try: - self._tracer_provider.shutdown() - finally: - self._tracer_provider = None -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_teardown_restores_disabled_loggers -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the OTel test file** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py -``` - -Expected: all green. In particular `test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises` should still pass because the new `_prior_logger_disabled` restore runs **before** the shutdown raise. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py tests/instruments/test_opentelemetry_instrument.py -git commit -m "fix: restore OTel loggers' disabled state on teardown (LOG-2) - -bootstrap() previously set opentelemetry.instrumentation.instrumentor and -opentelemetry.trace loggers to disabled=True unconditionally, with no symmetric -restoration. Capture the pre-bootstrap state and restore on teardown so unrelated -code in the same process retains its expected logger configuration." -``` - ---- - -## Task 5: LOG-3 — Protect `LoggingInstrument.teardown()` root-handler loop - -**Files:** -- Modify: `lite_bootstrap/instruments/logging_instrument.py:163-178` -- Test: `tests/instruments/test_logging_instrument.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_logging_instrument.py`: - -```python -def test_logging_instrument_teardown_aggregates_handler_close_errors() -> None: - instrument = LoggingInstrument( - bootstrap_config=LoggingConfig(logging_buffer_capacity=0), - ) - instrument.bootstrap() - root_logger = logging.getLogger() - - # Find the StreamHandler added by _configure_foreign_loggers and patch its close. - bootstrap_added = [h for h in root_logger.handlers if isinstance(h, logging.StreamHandler)] - assert bootstrap_added, "bootstrap should have added at least one StreamHandler to root" - target_handler = bootstrap_added[0] - - with patch.object(target_handler, "close", side_effect=RuntimeError("boom")): - with pytest.raises(RuntimeError, match="boom"): - instrument.teardown() - - # After teardown despite the raise, post-loop cleanup must have completed: - assert instrument._logger_factory is None # noqa: SLF001 - assert root_logger.level == logging.WARNING - # And the broken handler must have been removed from root despite raising. - assert target_handler not in root_logger.handlers -``` - -`patch` is already imported in the file (see line 3); `pytest` and `logging` are already in scope. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_aggregates_handler_close_errors -``` - -Expected: FAIL — current `teardown` would let `h.close()` raise mid-loop; `_logger_factory.close_handlers()` never runs and `_logger_factory` stays non-None. The level reset also doesn't fire. - -- [ ] **Step 3: Rewrite teardown with try/finally** - -In `lite_bootstrap/instruments/logging_instrument.py`, replace `teardown()` (lines 163-178): - -```python -def teardown(self) -> None: - """Reset structlog and root logger. - - Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten. - - Best-effort cleanup: errors from individual ``handler.close()`` calls are collected and - re-raised after the rest of teardown (level reset, factory close) completes, so a single - misbehaving handler can't prevent the instrument from releasing the rest of its resources. - """ - structlog.reset_defaults() - root_logger = logging.getLogger() - close_errors: list[BaseException] = [] - try: - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - try: - h.close() - except Exception as e: # noqa: BLE001, PERF203 - close_errors.append(e) - root_logger.setLevel(logging.WARNING) - finally: - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - self._logger_factory = None - if close_errors: - raise close_errors[0] -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_aggregates_handler_close_errors -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the logging tests** - -```bash -just test -- tests/instruments/test_logging_instrument.py -``` - -Expected: all green. In particular `test_logging_instrument_teardown_resets_factory_when_close_handlers_raises` (existing) still asserts that `_logger_factory` is nulled on close_handlers raise — the new try/finally preserves that behavior. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/instruments/logging_instrument.py tests/instruments/test_logging_instrument.py -git commit -m "fix: protect LoggingInstrument.teardown root-handler loop (LOG-3) - -A raise from handler.close() mid-loop previously left remaining handlers -attached, skipped the root-level reset, and never called close_handlers on the -factory. Wrap in try/finally and aggregate close errors so the rest of teardown -completes before the first error is re-raised." -``` - ---- - -## Task 6: LOG-4 — Restore broker logger storage on FastStream teardown - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:110-120` -- Test: `tests/test_faststream_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_faststream_bootstrap.py`: - -```python -def test_faststream_teardown_restores_broker_params_storage(broker: RedisBroker) -> None: - config = build_faststream_config(broker=broker) - original_storage = broker.config.logger.params_storage - bootstrapper = FastStreamBootstrapper(bootstrap_config=config) - bootstrapper.bootstrap() - assert isinstance(broker.config.logger.params_storage, ManualLoggerStorage) - assert broker.config.logger.params_storage is not original_storage - - bootstrapper.teardown() - - assert broker.config.logger.params_storage is original_storage -``` - -`ManualLoggerStorage` and `RedisBroker` are already imported in the file (lines 11-12); `build_faststream_config` is defined locally (line 34). - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_teardown_restores_broker_params_storage -``` - -Expected: FAIL — `FastStreamLoggingInstrument` has no `teardown()` override, so `params_storage` stays as the `ManualLoggerStorage` set by bootstrap. - -- [ ] **Step 3: Add snapshot field and teardown override** - -In `lite_bootstrap/bootstrappers/faststream_bootstrapper.py`, modify `FastStreamLoggingInstrument` (lines 110-120) to add a snapshot field and a teardown override: - -```python -@dataclasses.dataclass(kw_only=True) -class FastStreamLoggingInstrument(LoggingInstrument): - bootstrap_config: FastStreamConfig - _prior_broker_params_storage: typing.Any = dataclasses.field( - default=None, init=False, repr=False, compare=False - ) - _broker_logger_replaced: bool = dataclasses.field( - default=False, init=False, repr=False, compare=False - ) - - 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) - self._prior_broker_params_storage = broker.config.logger.params_storage - broker.config.logger.params_storage = ManualLoggerStorage(logger) - self._broker_logger_replaced = True - - def teardown(self) -> None: - if self._broker_logger_replaced: - broker = self.bootstrap_config.application.broker - if broker is not None: - broker.config.logger.params_storage = self._prior_broker_params_storage - self._broker_logger_replaced = False - self._prior_broker_params_storage = None - super().teardown() -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_teardown_restores_broker_params_storage -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the FastStream tests** - -```bash -just test -- tests/test_faststream_bootstrap.py -``` - -Expected: all green. In particular `test_faststream_logging_instrument_injects_structlog_logger` should still pass — it inspects state mid-bootstrap, before teardown. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/faststream_bootstrapper.py tests/test_faststream_bootstrap.py -git commit -m "fix: restore broker logger storage on FastStreamLoggingInstrument teardown (LOG-4) - -bootstrap() mutates broker.config.logger.params_storage to inject a structlog -logger; teardown() now captures and restores the original value. Symmetric with -LoggingInstrument's parent teardown, which still runs via super()." -``` - ---- - -## Task 7: LOG-9 — Add `SentryInstrument.teardown()` - -**Files:** -- Modify: `lite_bootstrap/instruments/sentry_instrument.py:94-125` -- Modify: `tests/instruments/test_sentry_instrument.py` (add test + drop workarounds) - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_sentry_instrument.py`: - -```python -def test_sentry_teardown_disables_sdk(minimal_sentry_config: SentryConfig) -> None: - instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) - instrument.bootstrap() - assert sentry_sdk.Hub.current.client is not None - - instrument.teardown() - - # sentry_sdk.init() with no DSN disables the SDK; the Hub's client either becomes - # None or has dsn=None depending on the SDK version. Both indicate "disabled". - client = sentry_sdk.Hub.current.client - assert client is None or client.dsn is None -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_sentry_instrument.py::test_sentry_teardown_disables_sdk -``` - -Expected: FAIL — `SentryInstrument` currently has no `teardown()` override, so the SDK stays initialized with the test DSN. - -- [ ] **Step 3: Add the teardown override** - -In `lite_bootstrap/instruments/sentry_instrument.py`, extend `SentryInstrument` (after `bootstrap()`, around line 125): - -```python -def teardown(self) -> None: - """Flush pending events and reset the SDK to a no-op state. - - Calling ``sentry_sdk.init()`` with no DSN disables further event capture. This - cleans up after a bootstrap so the same process can be torn down and re-tested - without leaking the previous DSN/transport into subsequent code. - """ - sentry_sdk.flush(timeout=2) - sentry_sdk.init() -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/instruments/test_sentry_instrument.py::test_sentry_teardown_disables_sdk -``` - -Expected: PASS. - -- [ ] **Step 5: Drop the redundant `finally: sentry_sdk.init()` workarounds** - -The existing tests at `tests/instruments/test_sentry_instrument.py:43` and `:65` use -`finally: sentry_sdk.init()` to reset the SDK between test cases. With the new -`teardown()` in place, callers that go through the bootstrapper / instrument will get the -reset automatically. Update the two tests so the cleanup happens via `instrument.teardown()`: - -Replace `test_sentry_instrument_with_raise` (lines 36-43): - -```python -def test_sentry_instrument_with_raise(minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport) -> None: - instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) - instrument.bootstrap() - - try: - std_logger.error("some error") - assert len(sentry_mock.mock_envelopes) == 1 - finally: - instrument.teardown() -``` - -Replace `test_sentry_instrument_with_structlog_error` (lines 46-66): - -```python -def test_sentry_instrument_with_structlog_error( - minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, logging_mock: LoggingMock -) -> None: - sentry_instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) - sentry_instrument.bootstrap() - logging_instrument = LoggingInstrument( - bootstrap_config=LoggingConfig( - logging_unset_handlers=["uvicorn"], - logging_buffer_capacity=0, - service_debug=False, - logging_extra_processors=[logging_mock], - ) - ) - logging_instrument.bootstrap() - - try: - logger.error("some error") - logger.error("some error, skipping sentry", skip_sentry=True) - assert len(sentry_mock.mock_envelopes) == 1 - finally: - logging_instrument.teardown() - sentry_instrument.teardown() -``` - -- [ ] **Step 6: Run the full sentry test file** - -```bash -just test -- tests/instruments/test_sentry_instrument.py -``` - -Expected: all green. - -- [ ] **Step 7: Commit** - -```bash -git add lite_bootstrap/instruments/sentry_instrument.py tests/instruments/test_sentry_instrument.py -git commit -m "fix: add SentryInstrument.teardown that disables the SDK (LOG-9) - -bootstrap() called sentry_sdk.init() but no teardown reset the global state. -Process-local tests had to call sentry_sdk.init() in finally blocks as a -workaround; those are now replaced by instrument.teardown(). flush() drains -in-flight events before the reset." -``` - ---- - -## Task 8: LOG-6 — `WeakKeyDictionary` for Litestar OTel cache - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:75-99` -- Test: `tests/test_litestar_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_litestar_bootstrap.py`: - -```python -import gc -import weakref - - -def test_litestar_otel_apps_cache_evicts_dead_refs() -> None: - from lite_bootstrap.bootstrappers.litestar_bootstrapper import ( - LitestarOpenTelemetryInstrumentationMiddleware, - ) - from opentelemetry.sdk.trace import TracerProvider - - tracer_provider = TracerProvider() - middleware = LitestarOpenTelemetryInstrumentationMiddleware( - tracer_provider=tracer_provider, - excluded_urls=set(), - ) - - async def transient_app(scope: dict, receive: object, send: object) -> None: # noqa: ARG001 - return None - - weak_app = weakref.ref(transient_app) - middleware._otel_apps[transient_app] = "marker" # noqa: SLF001 — direct cache poke - assert weak_app() is not None - assert len(middleware._otel_apps) == 1 # noqa: SLF001 - - del transient_app - gc.collect() - - assert weak_app() is None - assert len(middleware._otel_apps) == 0 # noqa: SLF001 -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_litestar_bootstrap.py::test_litestar_otel_apps_cache_evicts_dead_refs -``` - -Expected: FAIL — `_otel_apps` is a `dict[int, ASGIApp]`; the integer key doesn't evict when the `transient_app` is GC'd. Also fails to even accept `transient_app` as a key (current code uses `id(next_app)`). - -The test sets `middleware._otel_apps[transient_app] = "marker"` to verify the dict-like API expects the app object as the key. This will need to be a real WeakKeyDictionary for the assertion to pass. - -- [ ] **Step 3: Replace cache with WeakKeyDictionary** - -In `lite_bootstrap/bootstrappers/litestar_bootstrapper.py`, add `import weakref` near the top of the file (alongside the existing imports) and replace lines 75-99: - -```python -if import_checker.is_litestar_opentelemetry_installed: - - class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: - self._tracer_provider = tracer_provider - self._excluded_urls = ",".join(excluded_urls) - # WeakKeyDictionary so wrapper apps are evicted when Litestar drops the - # next_app reference (hot reload, plugin add/remove, AppConfig rebuild). - # Falls back gracefully — apps that don't support weak refs are simply - # not cached. - self._otel_apps: "weakref.WeakKeyDictionary[ASGIApp, ASGIApp]" = weakref.WeakKeyDictionary() - - async def handle( - self, - scope: "Scope", - receive: "Receive", - send: "Send", - next_app: "ASGIApp", - ) -> None: - otel_app = self._otel_apps.get(next_app) - if otel_app is None: - otel_app = OpenTelemetryMiddleware( - app=next_app, - default_span_details=build_litestar_route_details_from_scope, - excluded_urls=self._excluded_urls, - tracer_provider=self._tracer_provider, - ) - try: - self._otel_apps[next_app] = otel_app # ty: ignore[invalid-assignment] - except TypeError: - # next_app doesn't support weak references; skip caching for it. - pass - await otel_app(scope, receive, send) # ty: ignore[invalid-argument-type] -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_litestar_bootstrap.py::test_litestar_otel_apps_cache_evicts_dead_refs -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the Litestar tests** - -```bash -just test -- tests/test_litestar_bootstrap.py -``` - -Expected: all green. In particular `test_litestar_otel_span_naming` exercises the cache hit path on real requests. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/litestar_bootstrapper.py tests/test_litestar_bootstrap.py -git commit -m "fix: use WeakKeyDictionary for Litestar OTel app cache (LOG-6) - -The previous dict[int, ASGIApp] keyed by id() never evicted, holding wrapper -OpenTelemetryMiddleware instances alive after Litestar dropped the next_app -reference. Switch to weakref.WeakKeyDictionary so wrappers GC with their apps. -Skip caching gracefully when an app doesn't support weak references." -``` - ---- - -## Task 9: LOG-7 — Guard against double `_TeardownProvider` attachment on FastMCP - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py:152-154` -- Test: `tests/test_fastmcp_bootstrap.py` - -**Context:** FastMCP's `add_provider` appends to `self.providers` (verified at -`fastmcp/server/providers/aggregate.py:116`). The list is inherited from `AggregateProvider` -(`fastmcp/server/providers/aggregate.py:88`). We can iterate `application.providers` to -check for existing `_TeardownProvider` instances. - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastmcp_bootstrap.py`: - -```python -import warnings - - -def test_second_fastmcp_bootstrapper_on_same_app_warns_not_stacks() -> None: - application = FastMCP() - config_a = FastMcpConfig(application=application, service_name="a") - bootstrapper_a = FastMcpBootstrapper(bootstrap_config=config_a) - providers_after_first = list(application.providers) - - config_b = FastMcpConfig(application=application, service_name="b") - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - FastMcpBootstrapper(bootstrap_config=config_b) - - matching = [w for w in caught if "_TeardownProvider" in str(w.message)] - assert matching, "expected warning about existing _TeardownProvider" - assert list(application.providers) == providers_after_first, ( - "second bootstrapper must not stack another _TeardownProvider" - ) - - bootstrapper_a.teardown() -``` - -`FastMCP` and `FastMcpConfig` are already imported in the file. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastmcp_bootstrap.py::test_second_fastmcp_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: FAIL — current code unconditionally calls `add_provider`, so `application.providers` would grow by one and no warning is emitted. - -- [ ] **Step 3: Add the re-attach guard** - -In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, add `import warnings` at the top -of the file (alongside the existing imports) and modify `__init__` (lines 152-154): - -```python -def __init__(self, bootstrap_config: FastMcpConfig) -> None: - super().__init__(bootstrap_config) - if any(isinstance(p, _TeardownProvider) for p in self.bootstrap_config.application.providers): - warnings.warn( - "FastMCP application already has a _TeardownProvider attached; " - "skipping re-attachment. Construct one FastMcpBootstrapper per application.", - stacklevel=2, - ) - return - self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown)) -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_fastmcp_bootstrap.py::test_second_fastmcp_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the FastMCP tests** - -```bash -just test -- tests/test_fastmcp_bootstrap.py -``` - -Expected: all green. The existing `test_fastmcp_teardown_runs_via_asgi_lifespan` confirms a single attachment still fires teardown on lifespan shutdown. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py -git commit -m "fix: guard against double _TeardownProvider attachment on FastMCP (LOG-7) - -Constructing two FastMcpBootstrappers around the same application previously -stacked two _TeardownProvider instances, doubling the teardown call on shutdown. -Detect an existing _TeardownProvider in application.providers and skip re-attach -with a warning." -``` - ---- - -## Task 10: LOG-8 — Guard against double lifespan wrapping on FastAPI - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:197-205` -- Test: `tests/test_fastapi_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_bootstrap.py`: - -```python -def test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks(fastapi_config: FastAPIConfig) -> None: - application = fastapi.FastAPI() - config_a = dataclasses.replace(fastapi_config, application=application) - FastAPIBootstrapper(bootstrap_config=config_a) - lifespan_after_first = application.router.lifespan_context - - config_b = dataclasses.replace(fastapi_config, application=application) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - FastAPIBootstrapper(bootstrap_config=config_b) - - matching = [w for w in caught if "lifespan" in str(w.message).lower()] - assert matching, "expected warning about existing lite-bootstrap lifespan" - assert application.router.lifespan_context is lifespan_after_first, ( - "second bootstrapper must not re-wrap the lifespan" - ) -``` - -`warnings` will need to be imported at the top of the test file (it is — line 4 in current state via no import; verify via Read before committing). If missing, add `import warnings`. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: FAIL — current `__init__` unconditionally wraps `lifespan_context`, so the second bootstrapper stacks another layer. - -- [ ] **Step 3: Add the re-wrap guard** - -In `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py`, modify the `FastAPIBootstrapper.__init__` (lines 197-205): - -```python -def __init__(self, bootstrap_config: FastAPIConfig) -> None: - super().__init__(bootstrap_config) - - application = _narrow_app(self.bootstrap_config) - if getattr(application.state, "lite_bootstrap_lifespan_attached", False): - warnings.warn( - "FastAPI application already has a lite-bootstrap lifespan wrapper attached; " - "skipping re-wrap. Construct one FastAPIBootstrapper per application.", - stacklevel=2, - ) - return - application.state.lite_bootstrap_lifespan_attached = True - old_lifespan_manager = application.router.lifespan_context - application.router.lifespan_context = _merge_lifespan_context( - old_lifespan_manager, - self.lifespan_manager, - ) -``` - -`warnings` is already imported in this file (line 4). `application.state` is the standard Starlette state container — assignment to arbitrary attributes is supported. - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the FastAPI tests** - -```bash -just test -- tests/test_fastapi_bootstrap.py -``` - -Expected: all green. The existing `test_fastapi_bootstrap` exercises the single-attachment lifespan path on a real ASGI client. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastapi_bootstrapper.py tests/test_fastapi_bootstrap.py -git commit -m "fix: guard against double lifespan wrapping on FastAPI (LOG-8) - -Constructing two FastAPIBootstrappers around the same application previously -stacked two lifespan wrappers, calling teardown twice on shutdown. Detect the -sentinel on application.state and skip re-wrap with a warning. Idempotent -teardown (CRIT-3) means the prior behavior was harmless, but this removes the -log noise and confusing stack depth." -``` - ---- - -## Final verification - -- [ ] **Step 1: Full test suite** - -```bash -just test -``` - -Expected: all green. Coverage stays at 100% (`--cov-fail-under=100` from `pyproject.toml`). - -- [ ] **Step 2: Lint (includes ty type check)** - -```bash -just lint-ci -``` - -Expected: all green. - -- [ ] **Step 3: Bandit clean (manual verification of LOG-5/SEC-4 closure)** - -```bash -bandit -r lite_bootstrap 2>&1 | grep -c "B101" -``` - -Expected: `0` (zero remaining B101 findings after Tasks 1 and 2). - -- [ ] **Step 4: Confirm the commit log shape** - -```bash -git log --oneline origin/main..HEAD -``` - -Expected: 10 commits, each titled `fix:` (or `docs:` for Task 3), each scoped to a single finding. Optional: rebase to squash trivially-related commits if reviewer prefers — recommend keeping them separate so the PR review can match commits to audit IDs. - ---- - -## Self-review checklist (run after the plan finishes) - -1. **Coverage of PR1 findings:** LOG-1 (Task 3) · LOG-2 (Task 4) · LOG-3 (Task 5) · LOG-4 (Task 6) · LOG-5 (Tasks 1+2) · LOG-6 (Task 8) · LOG-7 (Task 9) · LOG-8 (Task 10) · LOG-9 (Task 7) · SEC-4 (Tasks 1+2). All 10. -2. **TEST-NEW coverage:** TEST-NEW-2 (Task 4 — logger restore; LOG-1's "shutdown called" already exists in the suite per the audit) · TEST-NEW-3 (Task 5) · TEST-NEW-4 (Task 6) · TEST-NEW-5 (Tasks 8, 9, 10). All four mapped. -3. **No placeholders:** every step has the exact file path, line numbers where relevant, complete code blocks, exact `just test -- ` command, and the expected outcome. -4. **Type consistency:** `OpenTelemetryInstrument._prior_logger_disabled: dict[str, bool]` (Task 4), `FastStreamLoggingInstrument._prior_broker_params_storage / _broker_logger_replaced` (Task 6), `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps: weakref.WeakKeyDictionary[ASGIApp, ASGIApp]` (Task 8). All consistent between definition and usage. -5. **Commit isolation:** each task ends with a single commit scoped to its finding ID, so PR review can land/revert at the task level if needed. - - ---- - -# PR2 — Config UX & Security Validation 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:** Land six config-layer fixes from the 2026-06-05 audit (UX-1, UX-2, UX-3, SEC-1, SEC-2, SEC-3 + folded TEST-NEW-1 and TEST-NEW-6) in one PR. - -**Architecture:** Each fix is a small TDD cycle. The work all happens at config-construction time or at the user-facing helper layer — no instrument lifecycle changes. Tasks are ordered to land additive UX improvements first (UX-1, UX-2, UX-3), then the security validators (SEC-1, SEC-2, SEC-3) which are slightly more cross-cutting (SEC-2 introduces an `OpenTelemetryConfig.__post_init__` that interacts with the FastAPIConfig override touched in Task 1). - -**Tech Stack:** Python 3.10+, `uv` workspace, `pytest` (with `pytest-asyncio`), `ty` type checker, `ruff` formatter, `structlog`, `prometheus_client`, `opentelemetry-sdk`, `fastapi`, `litestar`, `faststream`, `fastmcp`. - -**Branch:** `fix/bug-audit-v2-pr2-config-security` (branch off `main` after PR1 merges, or off PR1's branch if parallel work is desired). - -**Sequencing prerequisite:** PR1 should merge first. PR2 doesn't structurally depend on PR1, but Task 5 introduces `OpenTelemetryConfig.__post_init__` which interacts with the `_prior_logger_disabled` field added in PR1 Task 4 (both touch `OpenTelemetryConfig`/`OpenTelemetryInstrument`). Rebasing PR2 onto a merged PR1 keeps the conflict resolution trivial. - ---- - -## File Structure - -Modifications to existing files only — no new files. - -| File | What changes | -|------|-------------| -| `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` | Move user-app field overrides into UnsetType branch (Task 1); add `super().__post_init__()` for Task 5 cascade | -| `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` | Add `prometheus_collector_registry` field + thread to instrument (Task 2); add `opentelemetry_excluded_urls` field (Task 3) | -| `lite_bootstrap/helpers/fastapi_helpers.py` | Validate `root_path` via `is_valid_path` + fall back on warning (Task 4) | -| `lite_bootstrap/instruments/cors_instrument.py` | Add `__post_init__` to `CorsConfig` rejecting wildcard + credentials combo (Task 6) | -| `lite_bootstrap/instruments/opentelemetry_instrument.py` | Add `__post_init__` to `OpenTelemetryConfig` warning on insecure non-local endpoint (Task 5) | -| `tests/test_fastapi_bootstrap.py` | Test for Task 1 | -| `tests/test_fastapi_offline_docs.py` | Test for Task 4 | -| `tests/test_faststream_bootstrap.py` | Tests for Tasks 2, 3 | -| `tests/instruments/test_cors_instrument.py` | Test for Task 6 | -| `tests/instruments/test_opentelemetry_instrument.py` | Test for Task 5 | - ---- - -## Task 1: UX-1 — `FastAPIConfig` respects user app's title/debug/version - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:58-75` -- Test: `tests/test_fastapi_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_bootstrap.py` (at the bottom, after existing tests): - -```python -def test_user_supplied_app_keeps_title_version_debug() -> None: - user_app = fastapi.FastAPI(title="user-title", version="9.9.9", debug=False) - config = FastAPIConfig( - application=user_app, - service_name="lite-name", - service_version="1.0.0", - service_debug=True, - ) - assert config.application is user_app - assert user_app.title == "user-title" - assert user_app.version == "9.9.9" - assert user_app.debug is False -``` - -`fastapi`, `FastAPIConfig` are already imported in this file. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_user_supplied_app_keeps_title_version_debug -``` - -Expected: FAIL — current `__post_init__` overwrites `application.title/.debug/.version` with the config defaults even when the user supplies a pre-configured app. - -- [ ] **Step 3: Move the field overrides into the UnsetType branch** - -In `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py`, the current `__post_init__` (lines 58-75) reads: - -```python -def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if isinstance(self.application, UnsetType): - application = fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - # FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs - # to set application after construction, so we bypass the freeze here. - object.__setattr__(self, "application", application) - else: - application = self.application - if self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) - - application.title = self.service_name - application.debug = self.service_debug - application.version = self.service_version -``` - -Replace with: - -```python -def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if isinstance(self.application, UnsetType): - application = fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - # FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs - # to set application after construction, so we bypass the freeze here. - object.__setattr__(self, "application", application) - application.title = self.service_name - application.debug = self.service_debug - application.version = self.service_version - elif self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) -``` - -Key changes: -- Three `application.X = ...` lines moved inside the `if isinstance(...)` branch -- The `else: application = self.application` line is gone (no longer needed since we don't reference `application` after the branch) -- `else: if self.application_kwargs:` collapsed to `elif self.application_kwargs:` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_user_supplied_app_keeps_title_version_debug -``` - -Expected: PASS. - -- [ ] **Step 5: Verify nothing else broke** - -```bash -just test -- tests/test_fastapi_bootstrap.py tests/test_fastapi_offline_docs.py -``` - -Expected: all green. In particular `test_fastapi_bootstrapper_apps_and_kwargs_warning` still triggers when both `application` and `application_kwargs` are passed. - -- [ ] **Step 6: Run full suite + lint** - -```bash -just test -just lint-ci -``` - -Expected: 165 passed (164 + 1 new), 100% coverage, lint clean. - -- [ ] **Step 7: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastapi_bootstrapper.py tests/test_fastapi_bootstrap.py -git commit -m "$(cat <<'EOF' -fix: FastAPIConfig respects user-supplied app title/version/debug (UX-1) - -Move the application.title/.debug/.version assignments inside the UnsetType -branch so they only apply when lite-bootstrap built the FastAPI() instance. -Pre-configured user apps now keep their construction-time values. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: UX-2 — Injectable `prometheus_collector_registry` on FastStream - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:63-72` (add config field), `:143-167` (thread to instrument) -- Test: `tests/test_faststream_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_faststream_bootstrap.py`: - -```python -import uuid - - -async def test_faststream_prometheus_uses_injected_registry(broker: RedisBroker) -> None: - custom_registry = prometheus_client.CollectorRegistry() - counter_name = f"injected_counter_{uuid.uuid4().hex}_total" - counter = prometheus_client.Counter(counter_name, "Injected registry counter", registry=custom_registry) - counter.inc() - - bootstrap_config = dataclasses.replace( - build_faststream_config(broker=broker), - prometheus_collector_registry=custom_registry, - ) - bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) - application = bootstrapper.bootstrap() - try: - with TestClient(app=application) as test_client, TestRedisBroker(broker): - response = test_client.get(bootstrap_config.prometheus_metrics_path) - assert response.status_code == status.HTTP_200_OK - assert counter_name.encode() in response.content - finally: - bootstrapper.teardown() -``` - -Imports — verify `prometheus_client` is imported at module level (it isn't yet in this test file). Add: - -```python -import prometheus_client -``` - -Near the existing `import` block at the top of `tests/test_faststream_bootstrap.py`. `dataclasses`, `uuid`, `status`, `TestClient`, `TestRedisBroker`, `RedisBroker` are already imported or will be from new imports. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_prometheus_uses_injected_registry -``` - -Expected: FAIL — `FastStreamConfig` doesn't have `prometheus_collector_registry` field; `dataclasses.replace(...)` raises `TypeError: __init__() got an unexpected keyword argument 'prometheus_collector_registry'`. - -- [ ] **Step 3: Add the config field** - -In `lite_bootstrap/bootstrappers/faststream_bootstrapper.py`, the current `FastStreamConfig` (lines 63-72) reads: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -Add a `prometheus_collector_registry` field after `prometheus_middleware_cls`: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -The string-annotation form `"prometheus_client.CollectorRegistry | None"` works because `prometheus_client` is imported conditionally at module scope (`if import_checker.is_prometheus_client_installed: import prometheus_client`). Dataclass field type annotations are not resolved at runtime by default, so the conditional import is fine. - -- [ ] **Step 4: Thread the field into `FastStreamPrometheusInstrument`** - -Currently (lines 143-167): - -```python -@dataclasses.dataclass(kw_only=True) -class FastStreamPrometheusInstrument(PrometheusInstrument): - bootstrap_config: FastStreamConfig - collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field( - default_factory=_make_collector_registry, init=False - ) - not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_middleware_cls is missing" - missing_dependency_message = "prometheus_client is not installed" - ... -``` - -Replace the `collector_registry` field declaration with an `init=False` field set in `__post_init__`: - -```python -@dataclasses.dataclass(kw_only=True) -class FastStreamPrometheusInstrument(PrometheusInstrument): - bootstrap_config: FastStreamConfig - collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field(init=False) - not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_middleware_cls is missing" - missing_dependency_message = "prometheus_client is not installed" - - def __post_init__(self) -> None: - injected = self.bootstrap_config.prometheus_collector_registry - self.collector_registry = injected if injected is not None else _make_collector_registry() - - @classmethod - def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: # ty: ignore[invalid-method-override] - return super().is_configured(bootstrap_config) and bool(bootstrap_config.prometheus_middleware_cls) - ... -``` - -`__post_init__` runs after dataclass `__init__`; assignment via `self.collector_registry = ...` works because the instrument is non-frozen. - -Keep the rest of the class unchanged (the `is_configured`, `check_dependencies`, `bootstrap` methods). - -- [ ] **Step 5: Run the new test to verify it passes** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_prometheus_uses_injected_registry -``` - -Expected: PASS. - -- [ ] **Step 6: Verify default-path still works** - -```bash -just test -- tests/test_faststream_bootstrap.py -``` - -Expected: all green. Existing `test_faststream_bootstrap` (which doesn't supply `prometheus_collector_registry`) continues to use the per-instance default. - -- [ ] **Step 7: Full suite + lint** - -```bash -just test -just lint-ci -``` - -Expected: 166 passed (165 + 1 new), 100% coverage, lint clean. - -- [ ] **Step 8: Commit** - -```bash -git add lite_bootstrap/bootstrappers/faststream_bootstrapper.py tests/test_faststream_bootstrap.py -git commit -m "$(cat <<'EOF' -feat: support injectable Prometheus CollectorRegistry on FastStream (UX-2) - -Add prometheus_collector_registry: CollectorRegistry | None config field on -FastStreamConfig. When non-None, FastStreamPrometheusInstrument uses the -injected registry; otherwise the existing per-instance default is preserved. -Lets users expose metrics that were registered on a shared registry. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: UX-3 — Add `opentelemetry_excluded_urls` to FastStreamConfig - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:63-72` -- Test: `tests/test_faststream_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_faststream_bootstrap.py`: - -```python -def test_faststream_opentelemetry_excluded_urls_in_built_set(broker: RedisBroker) -> None: - from lite_bootstrap.bootstrappers.faststream_bootstrapper import ( - FastStreamOpenTelemetryInstrument, - ) - - bootstrap_config = dataclasses.replace( - build_faststream_config(broker=broker), - opentelemetry_excluded_urls=["/foo", "/bar"], - ) - instrument = FastStreamOpenTelemetryInstrument(bootstrap_config=bootstrap_config) - excluded = instrument._build_excluded_urls() # noqa: SLF001 - assert "/foo" in excluded - assert "/bar" in excluded -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_opentelemetry_excluded_urls_in_built_set -``` - -Expected: FAIL — `dataclasses.replace(...)` raises because `FastStreamConfig` has no `opentelemetry_excluded_urls` field. - -- [ ] **Step 3: Add the field** - -In `lite_bootstrap/bootstrappers/faststream_bootstrapper.py`, the current `FastStreamConfig` (after Task 2's edit) reads approximately: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -Add `opentelemetry_excluded_urls` after `opentelemetry_middleware_cls`: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list) - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -The behavior of `_build_excluded_urls` (in `opentelemetry_instrument.py`) is unchanged — it already reads via `getattr(self.bootstrap_config, "opentelemetry_excluded_urls", [])`. The new field is just for discoverability (IDE help, tab completion). - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_opentelemetry_excluded_urls_in_built_set -``` - -Expected: PASS. - -- [ ] **Step 5: Full suite + lint** - -```bash -just test -just lint-ci -``` - -Expected: 167 passed (166 + 1 new), 100% coverage, lint clean. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/faststream_bootstrapper.py tests/test_faststream_bootstrap.py -git commit -m "$(cat <<'EOF' -feat: add opentelemetry_excluded_urls to FastStreamConfig (UX-3) - -FastAPIConfig and LitestarConfig already expose opentelemetry_excluded_urls; -FastStream relied on getattr fallback with no discoverable field. Add the field -to FastStreamConfig matching the FastAPI/Litestar pattern. _build_excluded_urls -behavior is unchanged — the getattr access still works the same way. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: SEC-1 — Validate `root_path` in offline-docs HTML - -**Files:** -- Modify: `lite_bootstrap/helpers/fastapi_helpers.py` -- Test: `tests/test_fastapi_offline_docs.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_offline_docs.py`: - -```python -def test_offline_docs_rejects_unsafe_root_path() -> None: - malicious_root = "/foo" - app = FastAPI(title="Tests", root_path=malicious_root, docs_url="/custom_docs") - enable_offline_docs(app, static_path="/static") - - with TestClient(app, root_path=malicious_root) as client, pytest.warns(UserWarning, match="root_path"): - response = client.get("/custom_docs") - assert response.status_code == HTTPStatus.OK - assert "" not in response.text - assert "/static/swagger-ui.css" in response.text # falls back to empty root_path -``` - -`FastAPI`, `TestClient`, `HTTPStatus`, `pytest`, `enable_offline_docs` are already imported in this file. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_offline_docs.py::test_offline_docs_rejects_unsafe_root_path -``` - -Expected: FAIL — current handler reflects `root_path` verbatim into the swagger HTML, so the `