diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index ee26d59..054eb07 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -38,3 +38,17 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - run: just test-ci + + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # --offline blocks network requests and excludes every external URL, so this gate + # is deterministic: it fails only on a relative link or file path a diff broke. + - name: Check local links + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline + --no-progress + '**/*.md' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6b8a28..64fffdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,25 +25,6 @@ jobs: - uses: extractions/setup-just@v4 - uses: astral-sh/setup-uv@v7 - # Curated release notes are MANDATORY for a stable tag. This runs BEFORE - # `just publish` (which is irreversible) so a missing notes file aborts - # the release before anything reaches PyPI — rather than silently shipping - # with GitHub's auto-generated notes. Pre-release tags (a letter in the - # name, e.g. 2.0.0rc1) are exempt and keep the auto-generated fallback. - - name: Require curated release notes (stable tags) - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then - echo "Pre-release ${GITHUB_REF_NAME}: curated notes not required." - exit 0 - fi - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ ! -f "$notes" ]; then - echo "::error::Stable tag ${GITHUB_REF_NAME} has no curated release notes at ${notes}. Write the notes, commit to main, and re-tag." >&2 - exit 1 - fi - echo "Found curated release notes: ${notes}" - # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no # GitHub Release is created advertising a version that never reached PyPI. # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). @@ -51,21 +32,15 @@ jobs: # Publisher on the modern-di-taskiq PyPI project (env: pypi, workflow: release.yml). - run: just publish - # Description source: planning/releases/.md if present (verbatim, no - # auto-changelog appended); otherwise GitHub's generated notes. A tag with - # a letter (2.0.0rc1) is a pre-release -> flagged so GitHub won't mark it - # "Latest". + # The Release body is GitHub's generated notes, rendered from the squashed + # PR titles since the previous tag — so a conventional-commit title is what + # a reader gets. A release wanting prose is edited after the fact with + # `gh release edit --notes-file`. A tag with a letter (2.0.0rc1) is a + # pre-release -> flagged so GitHub won't mark it "Latest". - name: Resolve release metadata id: meta run: | set -euo pipefail - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ -f "$notes" ]; then - echo "body_path=$notes" >> "$GITHUB_OUTPUT" - echo "generate_notes=false" >> "$GITHUB_OUTPUT" - else - echo "generate_notes=true" >> "$GITHUB_OUTPUT" - fi if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" else @@ -75,7 +50,6 @@ jobs: - name: Publish GitHub Release uses: softprops/action-gh-release@v3 with: - body_path: ${{ steps.meta.outputs.body_path }} - generate_release_notes: ${{ steps.meta.outputs.generate_notes }} + generate_release_notes: true prerelease: ${{ steps.meta.outputs.prerelease }} draft: false diff --git a/AGENTS.md b/AGENTS.md index 2588543..4409e1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,14 +1,70 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`modern-di-taskiq` is a taskiq integration for +[`modern-di`](https://github.com/modern-python/modern-di); [`CONTEXT.md`](CONTEXT.md) opens with +what it does and owns the vocabulary — read it before naming a concept in code, a test name, or an +issue title. It is one of that project's integrations, each of which lives in a separate repository +and ships as a separate PyPI package. + +## Commands + +`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth — +`just --list`, or read it. + +## Architecture + +All implementation is `modern_di_taskiq/main.py`, short enough to read whole. Read it. What reading +it will not tell you is why two of its shapes are load-bearing rather than incidental: the per-task +child rides a generator `TaskiqDepends` instead of a middleware +([ADR-0001](docs/adr/0001-per-task-scope-rides-taskiq-dependencies.md)), and only the `WORKER_*` +lifecycle pair is wired +([ADR-0002](docs/adr/0002-only-worker-lifecycle-is-wired.md)). + ## Workflow -Planning uses a portable convention — `architecture/` (repo root) is the living -**truth home** and promotion target; `planning/changes/` holds the per-change -files. Start at the -[Quick path](planning/README.md#quick-path-start-here) in `planning/README.md` -(the authoritative spec) to pick a lane — **Full** (design template), -**Lightweight** (change template), or **Tiny** (just a commit) — and ship. -`just check-planning` validates changes; `just index` prints the change + -decision listing; `planning/_templates/` are copy-and-fill starting points. - -**When a change alters a capability's behavior, update the matching -`architecture/.md` in the same PR** — that promotion is what keeps -`architecture/` true. +**The spec for a change is its PR body**, not a committed file: why, design, non-goals, +verification, reviewed with the diff. There is no change file and no lane to choose. A trivial PR +(typo, dep bump, formatter, CI tweak) ships a conventional-commit title with no body ceremony. + +Two things outlive the PR, and there are exactly two places to put them: an alternative **rejected** +with reasoning becomes an ADR in [`docs/adr/`](docs/adr/) (`NNNN-slug.md`, sequential, with a +revisit trigger), and real work **not scheduled** becomes a GitHub issue. There is no third state, +and no separate truth-home directory — a behaviour change is reviewed with the diff, not promoted +to a page. + +### Where a fact goes + +Four homes, one owner each: + +| Home | Holds | +|---|---| +| `modern_di_taskiq/` | anything readable from the module — the default | +| a named test | an **invariant**: must stay true, and a change could silently break it | +| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated | +| `README.md` | anything a user needs | + +Before writing a line anywhere: + +> Can an agent get this by reading `modern_di_taskiq/`? → **don't write it.** +> Would a wrong change here fail a test? → it belongs **in the test**, not in prose. +> Does a user need it? → **`README.md`**. +> Otherwise it does not get written. + +**Prose about mechanism has no home. There is no file to add a paragraph to.** This file included: +it is always loaded, so a line that restates a docstring, a justfile comment, or `pyproject.toml` +costs every turn and rots in two places at once. A package this small tempts a full restatement of +its own source; that is the failure mode to watch for here. + +An invariant is a test whose name is the claim, with a docstring opening `INVARIANT:` and a second +paragraph naming **what breaks it** — design rationale, not a report of what this one test catches. +Nothing enforces that docstring shape; it is read at review time. A relative link to an ADR *is* +checked — CI runs lychee `--offline` over every `.md`. Both ADRs and `INVARIANT:` docstrings +ratchet: nothing prunes a record once its call is settled. Keeping them lean is a standing habit. + +Much of what this package does is taskiq's behaviour, not ours — how a generator dependency is +cached and finalized, when the lifecycle events fire. A claim about taskiq belongs in a test that +would go red if taskiq changed it, or nowhere. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..4ff6f69 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,31 @@ +# modern-di-taskiq + +A [`modern-di`](https://github.com/modern-python/modern-di) integration for +[taskiq](https://taskiq-python.github.io): it attaches a container to a taskiq broker, ties that +container's lifecycle to the worker, and resolves task parameters from a child container built for +each task. + +## Language + +A term is listed only when there is a synonym to reject, or a meaning subtle enough that code and +docs must agree on it. General programming vocabulary does not belong here, however heavily this +package uses it. + +The domain terms are `modern-di`'s — `Container`, `Provider`, `Group`, `Scope`, `Resolution`, +`Override` — and taskiq's — broker, worker, task, message, middleware, result backend. Those +projects are the authority for all of them; nothing here redefines one. The two below are this +package's own. + +**Root container**: +The `Container` that `setup_di` attaches to `broker.state`: the one a caller constructs and hands +in, the one `fetch_di_container` returns, and the one the worker's startup and shutdown events open +and close. +_Avoid_: APP-scope container — that names a scope where what matters is the position at the top of +the tree, and `Scope.APP` is `modern-di`'s word for a band in the hierarchy, not for this object. + +**Per-task child**: +The child container built for one task execution, carrying that task's `TaskiqMessage` as context. +Exactly one exists per task: every `FromDI` parameter in a task shares it, no two tasks share one, +and it is closed when the task ends — including when the task raises. +_Avoid_: request child — `REQUEST` is the scope it sits at, but nothing here is a request; the unit +of work is a task. diff --git a/README.md b/README.md index 9f72a14..d27311a 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ The `WORKER_STARTUP`/`WORKER_SHUTDOWN` events fire when the broker's worker proc | Symbol | Description | |---|---| -| `setup_di(broker, container)` | Stores the APP-scope container on `broker.state`, opens/closes it on worker startup/shutdown, and builds a `Scope.REQUEST` child container per task. Returns the container | +| `setup_di(broker, container)` | Stores the root container on `broker.state`, opens/closes it on worker startup/shutdown, and builds a `Scope.REQUEST` child container per task. Returns the container | | `FromDI(dependency)` | Inert marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a type | -| `fetch_di_container(broker)` | Returns the APP-scope container registered with the taskiq broker | +| `fetch_di_container(broker)` | Returns the root container attached to the taskiq broker | | `taskiq_message_provider` | `ContextProvider` for the current `taskiq.TaskiqMessage` (`REQUEST` scope) | ## 📦 [PyPI](https://pypi.org/project/modern-di-taskiq) diff --git a/architecture/README.md b/architecture/README.md deleted file mode 100644 index 40ab382..0000000 --- a/architecture/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Architecture - -The living truth about what `modern-di-taskiq` does **now** — one file per -capability, updated by hand whenever a change ships. The *why* and *how it got -here* live in [`../planning/changes/`](../planning/changes/), and decisions -deliberately taken (including options rejected) in -[`../planning/decisions/`](../planning/decisions/); this directory is the present. - -These files carry **no frontmatter** — they are prose, dated by git. - -## Capabilities - -- [`dependency-injection.md`](dependency-injection.md) — wiring a `modern-di` - container into a taskiq broker: `setup_di`, the per-task container seam, and - `FromDI` resolution. - -## Promotion rule - -Shipping a change hand-edits the affected capability file(s) here to match the -new reality, in the same PR as the code. The change file stays in place under -[`../planning/changes/`](../planning/changes/) — no folder move. diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md deleted file mode 100644 index 6f4cce0..0000000 --- a/architecture/dependency-injection.md +++ /dev/null @@ -1,62 +0,0 @@ -# Dependency injection - -The capability this package exists for: wiring a `modern-di` `Container` into a -taskiq broker so task parameters resolve from it, scoped per task. Everything -lives in `modern_di_taskiq/main.py`; the public surface is `setup_di`, -`FromDI`, `fetch_di_container`, and `taskiq_message_provider`. - -## Setup - -`setup_di(broker, container)` is the single entry point. It: - -1. Stores the container on `broker.state` under `_ROOT_CONTAINER_ATTR` - (`"modern_di_container"`), a named constant — writer and reader stay in - provable agreement instead of relying on a bare string literal. -2. Registers `taskiq_message_provider` (a `ContextProvider` binding - `taskiq.TaskiqMessage` at `Scope.REQUEST`) so the current message is - resolvable inside DI. -3. Wires `container.open` to `WORKER_STARTUP` and `container.close_async` to - `WORKER_SHUTDOWN`, so the root container's lifecycle tracks the worker. - -## Lifecycle - -The worker owns resolution, so only the **worker** events are wired. Reopening -on `WORKER_STARTUP` is a no-op when the container is already open (a fresh -`Container` is open on construction) and lets a second worker cycle — a restart, -a test re-entry — reopen a container closed on the previous `WORKER_SHUTDOWN` -instead of raising `ContainerClosedError`. - -## Per-task scope - -taskiq resolves a generator `TaskiqDepends` **once per task** and shares the -yielded value across every dependent. `build_di_container` exploits this: it -derives the child's scope and context via -`modern_di.integrations.bind(taskiq_message_provider, context.message)` — -`bind(provider, connection)` returns `ConnectionMatch(scope=provider.scope, -context={provider.context_type: connection})`, so this always produces -`scope=Scope.REQUEST, context={TaskiqMessage: context.message}`, the same -values the code used to hand-write. taskiq has a single connection provider, -so there is nothing for `classify_connection` (which dispatches across -several providers) to dispatch across here. It builds one child container per -task via `build_child_container(scope=match.scope, context=match.context)`, -opened through `Container`'s own `async with` — entering an already-open -container is a no-op; exiting closes it, run when taskiq finalizes the -generator. Every `FromDI` parameter in a task therefore shares one child, and -the child is closed after the task completes, including when the task raises -(taskiq throws the task exception into the generator at the `yield`, which -propagates out of the `async with` and triggers the close). - -## Resolution - -`FromDI(dependency, *, use_cache=True)` returns a taskiq `TaskiqDepends` wrapping -a frozen `Dependency` holding a `modern_di.integrations.Marker(dependency)`. At -resolution time `Dependency.__call__` receives the per-task child (via -`TaskiqDepends(build_di_container)`) and calls `self.marker.resolve(request_container)`, -which is `container.resolve_dependency(self.dependency)` under the hood — -dispatching on the argument kind: - -- an `AbstractProvider` → `resolve_provider(...)`, -- a bare `type` → `resolve(...)`. - -`Dependency` is the deep part of the seam — the container lookup and the `Marker` -delegation sit behind a single `__call__`. `FromDI` is just its constructor. diff --git a/docs/adr/0001-per-task-scope-rides-taskiq-dependencies.md b/docs/adr/0001-per-task-scope-rides-taskiq-dependencies.md new file mode 100644 index 0000000..3e17f2b --- /dev/null +++ b/docs/adr/0001-per-task-scope-rides-taskiq-dependencies.md @@ -0,0 +1,29 @@ +# Per-task scope rides a taskiq generator dependency, not a middleware + +**Decision:** the per-task child is built by a generator `TaskiqDepends` (`build_di_container`); we +will not ship a `TaskiqMiddleware` that opens and closes a container around every task. + +A middleware is the obvious place to hang a per-unit-of-work scope, and it is what Dishka's taskiq +integration does, so the option comes back on its own. It was rejected because taskiq already +provides the exact contract: a generator dependency is resolved **once per task** under the default +`use_cache=True`, its yielded value is shared by every dependent in that task, and it is finalized +after the task completes — including when the task raises, because taskiq throws the task's +exception into the generator at the `yield`. That is child-container-per-unit-of-work, already +built. + +Taking it as a dependency rather than a middleware buys three things a middleware cannot. It is +**lazy**: a task with no `FromDI` parameter resolves no dependency and therefore builds no child, so +a broker with one wired task pays nothing on the others. It needs **no registry**: the child reaches +the parameters through taskiq's own dependency cache, so there is nothing keyed by task id to +populate, look up, and clean up, and nothing to leak if a task dies between the two halves of a +middleware. And it requires **no installation step beyond `setup_di`** — a middleware would have to +be registered on the broker as well, giving a second way to get the wiring half-done. + +The middleware also fails the deletion test in reverse: adding one would not remove the generator +dependency, because `FromDI` parameters still need a container handed to them at resolve time. It +would be a second mechanism layered over the one that already works. + +**Revisit trigger:** taskiq changes the caching or finalization semantics of generator dependencies +— a yielded value no longer shared across a task's parameters, or a finalizer no longer run on the +error path — or a required feature genuinely needs to act before the first `FromDI` parameter is +resolved (per-task container setup that must happen even for tasks that inject nothing). diff --git a/docs/adr/0002-only-worker-lifecycle-is-wired.md b/docs/adr/0002-only-worker-lifecycle-is-wired.md new file mode 100644 index 0000000..3770e6a --- /dev/null +++ b/docs/adr/0002-only-worker-lifecycle-is-wired.md @@ -0,0 +1,29 @@ +# `setup_di` wires only the worker lifecycle events + +**Decision:** `setup_di` registers handlers for `WORKER_STARTUP` and `WORKER_SHUTDOWN` only; the +`CLIENT_STARTUP` / `CLIENT_SHUTDOWN` pair is deliberately left unwired. + +taskiq fires two independent lifecycle pairs, and wiring both looks like the safe default. It was +rejected because the worker is the only side that resolves. A kicker process constructs the broker +and calls `.kiq()`; it never runs a task, so a container opened on `CLIENT_STARTUP` would hold app +scoped resources — connections, pools, whatever the providers create at open — for a process that +resolves nothing from them, and would have to close them again on a shutdown event that a +short-lived client script frequently never fires. Wiring the pair that matches where resolution +happens keeps the container's lifetime equal to the span in which it is used. + +The cost is a real one, so it is stated rather than hidden: a process that both kicks and executes +in-process, which is what `InMemoryBroker` does in a test or a script, gets no lifecycle from +`setup_di` unless the worker events actually fire. `InMemoryBroker.startup()` fires both pairs, so +the in-process case works; a caller driving tasks by other means opens and closes the root container +itself. This is documented in `README.md`, because it is the one place the choice is visible to a +user. + +`container.open()` on `WORKER_STARTUP` is unconditional for the same reason. A fresh `Container` is +already open, so the first call is a no-op; the call earns its keep on the **second** worker cycle — +a restart, or a test that starts and stops the same broker twice — where the container was closed by +the previous `WORKER_SHUTDOWN` and resolving without reopening would raise `ContainerClosedError`. + +**Revisit trigger:** a client-side capability appears that resolves from the container before any +task runs — for instance a kicker-side provider used to build task arguments, or middleware on the +client path that needs DI. At that point the client is a resolving context and needs its own +lifecycle. diff --git a/justfile b/justfile index 6d5767d..f048347 100644 --- a/justfile +++ b/justfile @@ -15,22 +15,6 @@ lint-ci: uv run ruff format --check uv run ruff check --no-fix uv run ty check - uv run python planning/index.py --check - uv run python planning/links.py - -# Print the planning change index (flat, newest-first) to stdout. -index: - uv run python planning/index.py - -# Validate planning changes + decisions; CI runs this. -check-planning: - uv run python planning/index.py --check - -# Check every relative Markdown link and heading anchor. Nothing else validates them: -# every .md here is read on GitHub, where a rotted link stays invisible until someone -# clicks it. -check-links: - uv run python planning/links.py test *args: uv run --no-sync pytest {{ args }} diff --git a/planning/.convention-version b/planning/.convention-version deleted file mode 100644 index ccbccc3..0000000 --- a/planning/.convention-version +++ /dev/null @@ -1 +0,0 @@ -2.2.0 diff --git a/planning/README.md b/planning/README.md deleted file mode 100644 index 837e313..0000000 --- a/planning/README.md +++ /dev/null @@ -1,164 +0,0 @@ -# Planning - -Specs, plans, and change history for `modern-di-taskiq`. The living truth -about *what the system does now* lives in [`architecture/`](../architecture/) -at the repo root; this directory records *how it got there*. - -## Quick path (start here) - -> The fast lane for making a change. The full reference is in -> [Conventions](#conventions) below — read it only when this isn't enough. - -**1. Choose a lane — first matching rule wins:** - -1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** (design template) -2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no change file, conventional - commit) -3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** (change template) - -Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. - -**2. Create the change file** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded -intra-day counter — copied from the matching template (design or change) in -[`_templates/`](_templates/). - -**3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the change file's `summary:` to the -realized result, and run `just check-planning` before pushing. - -## Conventions - -> This is the portable convention, sourced from the canonical repo -> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) -> (applied version in `.convention-version`, beside this file). To update -> it, run that repo's `APPLY.md` flow. The generated change index (`just index`) -> and the `## Other` pointers below are repo-local. `just check-links` validates every -> relative Markdown link and heading anchor in the repo, including the trees a site -> builder never sees. - -### Two axes, never mixed - -- **`architecture/` (repo root) — the present.** One file per capability, plus - a single `glossary.md` (the ubiquitous language); living prose, updated in the - same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One file per change, - kept in place after ship. - -A change **promotes** its conclusions into the affected -`architecture/.md` by hand **in the implementing PR, alongside the -code** — the edit rides in the same diff and is reviewed with it, never applied -as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the change file stays in `changes/` as the *why*. - -### Glossary - -`architecture/glossary.md` is the project's **ubiquitous language** — one page -defining the domain terms that code, specs, and capability pages all share. Like -the capability files beside it, it is living prose with **no frontmatter**, dated -by git, and authored lazily: it appears when the first term is worth pinning down. - -Each entry is a term, a one-or-two-sentence definition of what it *is* (not what -it does), and an optional `_Avoid_:` line naming the synonyms to reject: - -```md -**Timer**: -A scheduled future delivery, identified by a timer id. -_Avoid_: job, task, alarm -``` - -Keep it a glossary, not a spec — no implementation detail. A change that -introduces or sharpens a term updates `glossary.md` in the same PR, the same way -a behavior change promotes into a capability file. - -### Change files - -A change is a file `changes/YYYY-MM-DD.NN-.md`: - -- `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter - (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. -- `` — kebab-case description, not a story ID. - -`summary` is written when the change is created (the intent one-liner) and -**finalized at ship** to state the realized result — set in the implementing -PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no file move. `date` and `slug` are never written — they are -read from the file name. - -### Three lanes - -| Lane | Artifacts | Use when | -|------|-----------|----------| -| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | -| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | - -Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. - -### Plans are ephemeral - -The executable plan — task checklists, embedded code, commit sequences, -whatever the executor needs — is a working artifact, not history. Keep it out -of `changes/` and out of version control (git-ignored scratch, e.g. -`.superpowers/`). Once the change ships, the diff and the PR are the record -of execution; a committed plan duplicates them. `check-planning` rejects -anything in `changes/` that is not a flat change file. - -### Lean specs - -The change file is the single home of a change's rationale: - -- The PR body summarizes and links to the change file — it never restates it. -- Rejected alternatives live in `decisions/` and are referenced, not retold. -- Show a sketch when the design needs code; never the full diff-to-be. -- Delete template sections that don't apply — an empty section is ceremony. -- Most designs fit well under ~700 words; length must buy information. - -### Artifacts at a glance - -- **design template** — the spec: the *thinking* (why, design, trade-offs, - scope); the change file it produces is the single home of rationale (see - [Lean specs](#lean-specs)). -- **change template** — the condensed spec for the lightweight lane. -- **`releases/.md`** — per-release user-facing notes. -- **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; - spawns fix changes. -- **`retros/-.md`** — what we learned after a body of work. -- **`deferred.md`** — real-but-unscheduled items, each with a revisit trigger. -- **`decisions/-.md`** — one file per design decision taken - (especially options *rejected*), each with a revisit trigger; listed by - `just index`. - -Templates live in [`_templates/`](_templates/). - -### Frontmatter - -`date` and `slug` are **derived from the file name** — never -repeated in frontmatter. So: - -- `changes/*.md`: `summary` (single line) only. -- `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional - `supersedes` / `superseded_by`. -- Files in `architecture/` carry **no** frontmatter — living prose, dated by git. - -**`summary`** is one line: written at creation as the intent, then **finalized -at ship** to state the realized result — what shipped and its effect. It is the -only field the index renders. - -## Index - -The listing is **generated**, not maintained — run `just index` to print it: a -flat, newest-first list of changes, then decisions newest-first. The frontmatter -in each change / decision file is the single source of truth; there is no -committed copy to drift. - -## Other - -- **[`architecture/`](../architecture/)** at the repo root — the living - capability truth. This is the promotion target on every ship. -- **[decisions/](decisions/)** — design decisions taken (and alternatives - rejected), each with a revisit trigger, so reviews don't re-litigate them; - indexed by `just index`. diff --git a/planning/_templates/change.md b/planning/_templates/change.md deleted file mode 100644 index 5aa7e81..0000000 --- a/planning/_templates/change.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. ---- - -# Change: One-line capitalized title - -**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, rewrite it from -the design template. - -## Goal - -One or two sentences: what changes and why. - -## Approach - -The shape of the change in brief — enough that a reviewer sees the design -without a full spec. Link the truth home (`architecture/.md`) if a -capability contract moves. - -## Files - -- `path/to/file.py` — what changes -- `tests/test_x.py` — test added / updated - -## Verification - -- [ ] Failing test first — command + expected error. -- [ ] Apply the change. -- [ ] Test passes — command. -- [ ] `just test` — full suite green. -- [ ] `just lint` — clean. diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md deleted file mode 100644 index 45ccaf0..0000000 --- a/planning/_templates/decision.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: accepted # accepted | superseded -summary: One line — shown in `just index`. -supersedes: null -superseded_by: null ---- - -# One-line capitalized title - -**Decision:** What was decided, in a sentence. - -## Context - -Why this came up; the options that were on the table. - -## Decision & rationale - -The call and why — including why the alternatives were rejected. Enough that a -future explorer doesn't re-litigate it. - -## Revisit trigger - -The concrete signal that should reopen this decision. diff --git a/planning/_templates/design.md b/planning/_templates/design.md deleted file mode 100644 index 17dbee1..0000000 --- a/planning/_templates/design.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. ---- - -# Design: One-line capitalized title - - - -## Summary - -One paragraph. What changes, at the level a reader needs to decide if this -spec is worth reading in full. - -## Motivation - -Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. - -## Design - -What changes, in enough detail that a reader who has not seen the codebase -can follow. Sketches and interface fragments welcome; never the full -diff-to-be. Reference rejected alternatives in `decisions/` instead of -retelling them. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. One line each. - -## Testing - -How we know it landed correctly. Be specific: the command and the expected -signal. - -## Risk - -What could go wrong, ranked by likelihood × impact. Mitigations. diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md deleted file mode 100644 index 82385c3..0000000 --- a/planning/_templates/glossary.md +++ /dev/null @@ -1,15 +0,0 @@ -# Glossary - -The project's ubiquitous language — the domain terms that code, specs, and -capability pages share. Living prose, no frontmatter, dated by git. Each entry is -a term, what it *is* (not what it does), and the synonyms to avoid. No -implementation detail; this is a glossary, not a spec. - -**Term**: -A one-or-two-sentence definition of what it is. -_Avoid_: rejected-synonym, another-one - -**Another term**: -Define what it is, tightly. Group related terms under `##` subheadings when -natural clusters emerge; a flat list is fine when they don't. -_Avoid_: … diff --git a/planning/_templates/release.md b/planning/_templates/release.md deleted file mode 100644 index 5081187..0000000 --- a/planning/_templates/release.md +++ /dev/null @@ -1,38 +0,0 @@ -# - - - - - -## Feature - -- **.** What it adds and how to use it. - -## Fix - -- **.** What was broken, now fixed (reference the issue/regression). - -## Internal refactors - -- **.** What changed under the hood, stated as no behavior change. - -## Packaging - -- Metadata / build / dependency changes visible to installers. - -## Why - -Context a reader needs for the headline change. Omit for small releases. - -## Downstream - -What dependents must do — e.g. bump their version floor — or "No action -needed" when there is no API change. Omit if the project has no downstreams. - -## Internals - -- Coverage / tooling notes. diff --git a/planning/changes/2026-07-09.01-taskiq-di-integration.md b/planning/changes/2026-07-09.01-taskiq-di-integration.md deleted file mode 100644 index 589b955..0000000 --- a/planning/changes/2026-07-09.01-taskiq-di-integration.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -summary: New modern-di integration for taskiq — a per-task REQUEST child container delivered through taskiq's own generator-dependency DI (FromDI → TaskiqDepends), with the root container's lifecycle wired to the broker's WORKER_STARTUP/SHUTDOWN events. ---- - -# Design: modern-di integration for taskiq - -**Status:** design, pending maintainer approval -**Target:** new repo `modern-python/modern-di-taskiq`, package `modern_di_taskiq` - -## Summary - -Ship the official [modern-di](https://modern-di.modern-python.org) integration -for [taskiq](https://taskiq-python.github.io), following the core repo's -[Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/) -contract along the **native-DI generator-dependency path** (the FastAPI shape), -since taskiq has its own `TaskiqDepends` injection seam. One module -(`modern_di_taskiq/main.py`) exposes `setup_di`, `fetch_di_container`, `FromDI`, -and `taskiq_message_provider`. A per-task `Scope.REQUEST` child container is -built by a generator dependency and closed after the task; the root container's -lifecycle rides the broker's `WORKER_STARTUP`/`WORKER_SHUTDOWN` events. - -## Motivation - -taskiq is the roadmap's top integration target ("one wiring, every entrypoint"): -it lets an existing container cover an async task-queue worker alongside the -already-shipped FastAPI/Litestar/FastStream/aiohttp/Starlette/Typer entrypoints. -Dishka has a taskiq integration; modern-di has none. taskiq is a native-DI -framework, so the integration is the FastAPI pattern with taskiq's -`TaskiqDepends`/`Context` in place of `Depends`/`Request`. - -## Verified taskiq facts (0.12.4, spiked end-to-end) - -Every design decision below was proven against installed taskiq, not inferred: - -- **Generator `TaskiqDepends` gives per-task setup/teardown with caching.** A - generator dependency that `yield`s a value is resolved **once per task** - (`use_cache=True` default) and shared by every dependent, and its `finally` - runs after the task — including when the task **raises** (taskiq `athrow`s the - task exception into the generator at the `yield`). This is exactly the - child-container-per-unit-of-work contract, for free. -- **`Context` carries `.message` (`TaskiqMessage`) and `.broker` (`AsyncBroker`)** - and is injectable via `TaskiqDepends()`. `Context.__init__(message, broker)`. -- **`broker.state` is a live `TaskiqState`** instance attribute (present at - construction) that accepts arbitrary attributes — the root-container store. -- **Lifecycle events** are `TaskiqEvents.{WORKER,CLIENT}_{STARTUP,SHUTDOWN}`, - registered via `broker.add_event_handler(event, handler)` (handler receives - `TaskiqState`). `InMemoryBroker.startup()`/`shutdown()` fire **both** the - CLIENT and WORKER pair, so worker events run in-process under tests. -- **`InMemoryBroker`** runs tasks in-process: `await task.kiq()` then - `await result.wait_result()` — the test-broker analog of `TestNatsBroker`. - -## Design - -### 1. Connection provider (kind → scope) - -One connection kind (a consumed message → `REQUEST`): - -```python -taskiq_message_provider = providers.ContextProvider( - taskiq.TaskiqMessage, - scope=Scope.REQUEST, -) -_CONNECTION_PROVIDERS = (taskiq_message_provider,) -``` - -### 2. `setup_di(broker, container) -> Container` - -```python -def setup_di(broker: AsyncBroker, container: Container) -> Container: - setattr(broker.state, _ROOT_CONTAINER_ATTR, container) # attach - container.add_providers(*_CONNECTION_PROVIDERS) # register - broker.add_event_handler(TaskiqEvents.WORKER_STARTUP, lambda _state: container.open()) - broker.add_event_handler(TaskiqEvents.WORKER_SHUTDOWN, lambda _state: container.close_async()) - return container -``` - -The worker owns resolution, so its lifecycle is wired to the **WORKER** events. -`container.open()` on startup is the guide's reopen-on-restart rule (a no-op if -already open; a fresh `Container` is created open). `_ROOT_CONTAINER_ATTR` is a -named constant, not a bare literal, keeping writer/reader in provable agreement. - -### 3. `fetch_di_container(broker) -> Container` - -```python -def fetch_di_container(broker: AsyncBroker) -> Container: - return typing.cast(Container, getattr(broker.state, _ROOT_CONTAINER_ATTR)) -``` - -### 4. Per-task child builder (generator dependency) - -```python -async def build_di_container(context: Context = TaskiqDepends()) -> typing.AsyncIterator[Container]: - container = fetch_di_container(context.broker).build_child_container( - scope=Scope.REQUEST, - context={taskiq.TaskiqMessage: context.message}, - ) - try: - yield container - finally: - await container.close_async() -``` - -Reaches the root off `context.broker` (verified), seeds the message as context, -and closes in `finally`. taskiq's per-task caching means all `FromDI` params in -one task share this single child. - -### 5. `FromDI` marker + `Dependency` resolver (native-DI path) - -```python -@dataclasses.dataclass(slots=True, frozen=True) -class Dependency(typing.Generic[T_co]): - dependency: providers.AbstractProvider[T_co] | type[T_co] - - async def __call__( - self, - request_container: typing.Annotated[Container, TaskiqDepends(build_di_container)], - ) -> T_co: - return request_container.resolve_dependency(self.dependency) - - -def FromDI(dependency: providers.AbstractProvider[T_co] | type[T_co], *, use_cache: bool = True) -> T_co: # noqa: N802 - return typing.cast(T_co, TaskiqDepends(Dependency(dependency), use_cache=use_cache)) -``` - -Usage: `svc: Annotated[Svc, FromDI(Group.svc)]` or `FromDI(Svc)` on a task -parameter. `resolve_dependency` dispatches provider-vs-type, inheriting -overrides/caching/suggestions. - -### Public API (`__all__`) - -`setup_di`, `fetch_di_container`, `FromDI`, `taskiq_message_provider`. - -## Non-goals - -- **Async resolution** — resolution stays sync (`resolve_dependency`); only the - container builder and finalizers are async, matching every other integration. -- **A `TaskiqMiddleware`** — the generator dependency covers the per-task scope - natively; no middleware/registry (Dishka uses one; we don't need it). Lazy: a - task with no `FromDI` builds no child. -- **Client-side (kicker) lifecycle** — a pure client that never starts a worker - doesn't resolve; only WORKER events are wired. -- Any runtime dependency beyond `taskiq` and `modern-di`; a separate docs site. - -## Testing - -`InMemoryBroker`, 100%-coverage gate (`just test-ci`), mirroring -`modern-di-faststream`'s suite: - -- `dependencies.py` — a `Group` with app- and request-scoped `Factory` - providers plus one reading the `TaskiqMessage` (e.g. `task_name`), proving - context injection. -- `test_taskiq_di.py` — a task with two `FromDI` params resolves app- and - request-scoped values; the two share one child (request-scoped instance - identity); the message provider resolves. Signal: `result.return_value` - asserts, `result.is_err is False`. -- `test_lifespan.py` — `setup_di` opens the root on `startup()` and closes it on - `shutdown()`; a second `startup()` after shutdown re-opens without raising - `ContainerClosedError` (restart). -- `fetch_di_container(broker)` returns the same `Container` instance. - -## Risk - -- **R1 — taskiq minor-version churn (pre-1.0).** taskiq is `0.x`; the dep-graph - and `Context` API could shift. *Mitigation:* pin `taskiq>=0.11,<0.13` (ruled - by maintainer). The implementation step installs the `0.11.0` floor and runs - the suite green to confirm the `TaskiqDepends`/`Context`/`broker.state` shape - the integration relies on holds at the lower bound; the integration test is - the ongoing regression gate. -- **R2 — teardown-on-error noise.** taskiq logs "Exception found on dependency - teardown" when it throws the task error into the generator; our `finally` - still closes the child (spiked). Cosmetic only — `is_err` and the closed - container are correct. No mitigation needed; note in docs if it confuses. -- **R3 — `broker.state` attribute collision.** Storing under a namespaced - constant (`modern_di_container`) avoids clobbering user state. Low. - -## Scaffolding (implementation phase) - -Mirror `modern-di-faststream`: `pyproject.toml` (`taskiq>=0.11,<0.13` + `modern-di>=2.25,<3`, -standard classifiers/urls, `version = "0"`), `Justfile`, `CLAUDE.md`, -`architecture/` (README + `dependency-injection.md`), `py.typed`, the -planning-convention infra, and a `docs/integrations/taskiq.md` usage page + -`mkdocs.yml` nav entry **in the core `modern-di` repo**. diff --git a/planning/changes/2026-07-13.01-adopt-integration-kit.md b/planning/changes/2026-07-13.01-adopt-integration-kit.md deleted file mode 100644 index 9308fd5..0000000 --- a/planning/changes/2026-07-13.01-adopt-integration-kit.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -summary: modern_di_taskiq/main.py now composes modern_di.integrations (bind for the TaskiqMessage scope/context derivation in build_di_container, Container's async-with, Marker for the Dependency/FromDI resolution seam) instead of hand-rolling them; no public-API or test change. ---- - -# Design: Adopt the modern-di integration kit - -## Summary - -`modern-di` 2.28.0 shipped `modern_di.integrations` — the framework-agnostic -primitives that formalize what this package's `build_di_container` and -`Dependency`/`FromDI` already hand-roll. This change swaps the hand-rolled -internals for kit calls. Public API and runtime behavior are unchanged; -every existing test asserts on public behavior only. - -## Motivation - -Eleventh of the 13 adapter conversions surveyed by the kit's own design — -see -[modern-di's decision record](https://github.com/modern-python/modern-di/blob/main/planning/decisions/2026-07-13-integration-kit-shape.md) -and its source design doc -([`changes/2026-07-13.02-integration-kit.md`](https://github.com/modern-python/modern-di/blob/main/planning/changes/2026-07-13.02-integration-kit.md)), -which names this repo among the 3 adapters (fastapi, litestar, taskiq) -whose `build_di_container` child-generator is copied "line-for-line but for -the connection type," and among the 4 **native-DI** adapters whose -`Dependency.__call__` seam is the extraction target for Layer 2's `Marker`. - -taskiq is a **native-DI adapter**: it uses taskiq's own `TaskiqDepends`, so -`FromDI` builds a framework object (`TaskiqDepends`) directly — there is no -hand-rolled `@inject` decorator, no annotation-scanning, so -`parse_markers`/`resolve_markers`/`is_injected`/`mark_injected` do **not** -apply here. It keeps its own `FromDI` (it must wrap `TaskiqDepends`), same as -`modern-di-fastapi`/`modern-di-litestar`/`modern-di-faststream`. - -Unlike fastapi/litestar (which have two connection providers — request + -websocket — and dispatch with `classify_connection`), taskiq has exactly -**one** connection provider (`taskiq_message_provider`, binding -`taskiq.TaskiqMessage`). So — like grpc — this conversion uses `bind()` -directly for scope/context derivation, with **no `classify_connection`** -call (there being only one provider, nothing to dispatch across) and no -`if match else None` fallback (`bind()` never returns `None`). - -## Design - -In `modern_di_taskiq/main.py`: - -- Import `integrations` from `modern_di`: - `from modern_di import Container, Scope, integrations, providers`. -- `build_di_container`: replace the hand-written - `build_child_container(scope=Scope.REQUEST, context={taskiq.TaskiqMessage: - context.message})` + manual `try`/`finally: await container.close_async()` - with `match = integrations.bind(taskiq_message_provider, context.message)` - then `async with fetch_di_container(context.broker).build_child_container( - scope=match.scope, context=match.context) as container: yield container`. - `Container`'s own `async with` closes the child when taskiq finalizes the - generator — normal completion or the task-error path (taskiq throws the - exception into the generator at the `yield`), matching the old `finally`'s - ordering exactly. -- `Dependency`'s field changes from `dependency: - providers.AbstractProvider[T_co] | type[T_co]` to `marker: - integrations.Marker[T_co]`; its `__call__` body becomes `return - self.marker.resolve(request_container)` (it stays `async def`, still - receiving the per-task child via `TaskiqDepends(build_di_container)`). -- `FromDI` constructs `Dependency(integrations.Marker(dependency))` instead - of `Dependency(dependency)` — its own signature (`dependency, *, - use_cache`) is unchanged; it still wraps the result in - `TaskiqDepends(..., use_cache=use_cache)`. - -No dead imports result — this is a native-DI adapter (like fastapi/litestar): -`dataclasses` still decorates `Dependency`, `providers` still types `FromDI`'s -signature and the module-level `taskiq_message_provider` declaration, `Scope` -still declares that provider's scope, and `T_co` still parametrizes -`Dependency`/`FromDI`. - -`__init__.py`'s re-exports are untouched — every public name keeps its exact -signature and behavior. No test constructs `Dependency` directly (verified -by reading all test files), so no test-file edit is expected — including -`test_resolves_app_request_and_context`, which asserts that a task resolving -`FromDI(Dependencies.task_name)` reads `message.task_name` off the bound -`TaskiqMessage`, exercising exactly the `bind()`-derived context this change -produces. - -## Non-goals - -- `classify_connection` — taskiq has exactly one connection provider; a - dispatch-across-providers function has nothing to dispatch across here - (confirmed by the single-element `_CONNECTION_PROVIDERS` tuple). -- `parse_markers`/`resolve_markers`/`is_injected`/`mark_injected` — taskiq's - own `TaskiqDepends` already scans/resolves per parameter; there is no - hand-rolled decorator or double-wrap guard to replace. -- Any change to `setup_di`, `fetch_di_container`, or the - `WORKER_STARTUP`/`WORKER_SHUTDOWN` root-lifecycle wiring — none of that is - part of the kit. -- Any test rewrite. The existing suite asserts on public behavior only - (`FromDI`, `setup_di`, `fetch_di_container`, `taskiq_message_provider` — - none of it references `Dependency` or the scope-derivation internals being - replaced). - -## Testing - -- `just test-ci` — 100% line coverage, all existing tests green with zero - test-file changes (the suite uses `taskiq.InMemoryBroker`; no external - broker/Redis). -- `just lint-ci` — ruff (`select=ALL`), `ty check`, `check-planning`. - -## Risk - -- **Version-floor bump breaks on an environment still resolving `<2.28`** - (low likelihood — semver-compatible; low impact — `pyproject.toml` pins - the floor explicitly, in this repo's existing `>=2.25,<3` style without a - patch component, kept consistent as `>=2.28,<3`). -- **`bind()` derives a different context dict than the hand-written - `{TaskiqMessage: context.message}`** (low likelihood — `bind(provider, - connection)` returns `context={provider.context_type: connection}`, and - `taskiq_message_provider.context_type` is `taskiq.TaskiqMessage` — the - exact same key the hand-written dict already used — verified against the - provider declaration before writing this spec; `test_resolves_app_request_ - and_context` exercises the `TaskiqMessage`-derived `task_name` and must - still pass unmodified). -- **Wrapping the async generator's `yield` in `async with` changes - close-timing versus the old `try`/`finally`** (low likelihood — the same - pattern already shipped in `modern-di-fastapi`/`modern-di-litestar`'s - `build_di_container`; `Container.__aexit__` runs `close_async()` on both - the normal and thrown-in paths, exactly as the `finally` did — - `test_request_child_closed_on_task_error` exercises the error path and - must still pass unmodified). diff --git a/planning/changes/2026-07-25.01-canonical-example-onramp.md b/planning/changes/2026-07-25.01-canonical-example-onramp.md deleted file mode 100644 index 3fae7c8..0000000 --- a/planning/changes/2026-07-25.01-canonical-example-onramp.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -summary: Added examples/ minimal-DI runnable app + in-memory smoke test (which covers it to 100%, no coverage exclusion) + README canonical-example line, flipping the blessed-ready audit's D2/D6 to 2 — rollout of the reusable canonical-example pattern locked by the faststream pilot. ---- - -# Design: Canonical minimal-DI example (taskiq rollout) - -## Summary - -Ship a canonical, runnable example in `modern-di-taskiq`: a minimal -`examples/` app wiring one `Settings` (APP scope) and one `Greeter` (REQUEST -scope) into a task via the real `FromDI` idiom, an in-memory smoke test that -proves it runs, and the README `Usage example:` line the audit found missing. -This flips the two coupled gaps the blessed-ready audit scored on this repo — -**D2 (canonical example) 1 -> 2** and **D6 (README consistency) 1 -> 2** — -following the shape locked by the `modern-di-faststream` pilot -(`2026-07-24.01-canonical-example-onramp.md`). - -## Motivation - -The blessed-ready on-ramp audit found most integrations at **D2=1 / D6=1**: a -complete inline README example but no *dedicated, linked* canonical example, -so neither the `Usage example:` line (D6) nor a D2=2 score is reachable. The -faststream pilot locked the reusable shape; this change rolls it to -`modern-di-taskiq`, the third `FromDI`-based integration to adopt it. - -## Design - -**The example** — `examples/app.py` (+ `examples/__init__.py`): the smallest -runnable taskiq app, matching the README snippet's shape (same `Settings` / -`Greeter` / `AppGroup` names) but as a complete program: - -- `Settings` — `providers.Factory(Settings, scope=Scope.APP, cache=True)`. -- `Greeter` — `providers.Factory(Greeter, scope=Scope.REQUEST)`, depends on - `Settings` by type (auto-injected). -- one `@broker.task` handler taking the greeter via - `typing.Annotated[Greeter, FromDI(Greeter)]` (taskiq's decorator-free - idiom — no `@inject`), wired by `setup_di(broker, container)` on an - `InMemoryBroker`. A `# run: ...` header comment states the real-run - invocation. - -**Smoke test** — `tests/test_example.py`: imports the example's `broker`/ -`greet` task, and using the repo's existing in-memory idiom (explicit -`broker.startup()`/`broker.shutdown()` around `.kiq()` /`.wait_result()`, -already used in `tests/test_taskiq_di.py` — `InMemoryBroker` predates -async-context-manager support on this repo's taskiq floor) calls the task and -asserts the injected `Greeter`'s real output (`"Hello, world!"`), not a mock. - -**Coverage** — none excluded. The smoke test executes `examples/app.py` end -to end (import wires the broker + task; the call runs the handler), so it -covers the example to **100%** under the repo's existing -`--cov-fail-under=100` gate. No `omit` is needed. - -**README** — insert `Usage example: [examples/](./examples)` directly under -the `Full guide:` line (and its blank line), before `## Installation`, -matching the faststream pilot's placement. - -No `modern_di_taskiq/` source changes, so no `architecture/` promotion — this -adds documentation-grade example + test, not a capability change. - -## Non-goals - -- The remaining rollout integrations and the core `writing-integrations.md` - codification — separate, one line each. -- A realistic/DB starter — minimal DI demo only, matching the locked pattern. -- New runtime dependencies — the example uses only `taskiq` + `modern-di`, - already present. -- Editing the inline README snippet or any integration behavior. - -## Testing - -- `just test-ci` green: the smoke test covers `examples/app.py` to 100% (no - exclusion) and calls a real task asserting resolution (real behavior, not a - mock). -- `just lint-ci` green (ruff/ty/eof/format over the new files). -- `just check-planning` green (this bundle validates). -- `uvx ruff@0.16.0 check --no-fix .` and `uvx ruff@0.16.0 format --check .` - clean on the new/changed files (verified against CI's floated ruff, newer - than the repo's pinned version). - -## Risk - -- **Example rot** (low x low) — mitigated structurally: the example is - measured (no coverage exclusion) and the smoke test executes it every CI - run, so a broken or uncovered example fails the build. -- **Example drifts from the README snippet** (low x low) — they share one - shape (`Settings`/`Greeter`/`AppGroup`); keep the example the runnable - superset of the snippet. -- **Pattern doesn't transfer from faststream's `= FromDI(...)` default idiom** - (low, already resolved) — taskiq's idiom is - `typing.Annotated[T, FromDI(...)]`, not a parameter default; the example and - test use the repo's own idiom throughout, not faststream's. diff --git a/planning/decisions/.gitkeep b/planning/decisions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/planning/index.py b/planning/index.py deleted file mode 100644 index 2d70ac3..0000000 --- a/planning/index.py +++ /dev/null @@ -1,183 +0,0 @@ -# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) -"""Generate the planning index from frontmatter. - -Run via ``just index``. Globs ``planning/changes/*.md`` and -``planning/decisions/*.md``, reads their frontmatter, and prints a Markdown -listing to stdout — changes then decisions, newest-first. Never writes a file: -the listing is a query over the files, not a committed artifact. - -``date`` and ``slug`` are derived from the file name, not -frontmatter — the name is the single source of truth for both. -""" - -import pathlib -import re -import sys - - -ROOT = pathlib.Path(__file__).parent -VALID_DECISION_STATUS = {"accepted", "superseded"} -CHANGE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") -DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") -SPEC_REQUIRED = ("summary",) -DECISION_REQUIRED = ("status", "summary") - - -def parse_frontmatter(text: str) -> dict[str, str]: - """Parse a single-line-scalar YAML frontmatter block into a dict.""" - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return {} - fields: dict[str, str] = {} - for line in lines[1:]: - if line.strip() == "---": - break - if line[:1] in (" ", "\t"): - continue - key, sep, value = line.partition(": ") - if not sep: - continue - cleaned = value.strip().strip('"').strip("'") - fields[key.strip()] = "" if cleaned == "null" else cleaned - return fields - - -def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: - """Inject ``date``/``slug`` derived from a file name into ``fields``.""" - match = pattern.match(name) - if match: - fields["date"] = match.group("date") - fields["slug"] = match.group("slug") - return fields - - -def load_changes(root: pathlib.Path) -> list[dict[str, str]]: - """Read each change file's summary; derive date/slug from the file name.""" - changes_dir = root / "changes" - changes: list[dict[str, str]] = [] - if not changes_dir.is_dir(): - return changes - for path in sorted(changes_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith(("_", ".")): - continue - fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, CHANGE_RE) - fields["path"] = f"changes/{path.name}" - fields["name"] = path.stem - changes.append(fields) - return changes - - -def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: - """Read each decision's frontmatter; derive date/slug from the file name.""" - decisions_dir = root / "decisions" - decisions: list[dict[str, str]] = [] - if not decisions_dir.is_dir(): - return decisions - for path in sorted(decisions_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith("_"): - continue - fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) - fields["path"] = f"decisions/{path.name}" - fields["name"] = path.stem - decisions.append(fields) - return decisions - - -def format_row(row: dict[str, str]) -> str: - """Render one change or decision as a Markdown list item.""" - slug = row.get("slug", "?") - path = row.get("path", "") - date = row.get("date", "") - summary = row.get("summary") or "(no summary)" - line = f"- **[{slug}]({path})** ({date}) — {summary}" - if row.get("supersedes"): - line += f" _(supersedes {row['supersedes']})_" - if row.get("superseded_by"): - line += f" _(superseded by {row['superseded_by']})_" - return line - - -def render(changes: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: - """Render the full Markdown listing: changes then decisions, newest-first.""" - out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] - change_rows = sorted(changes, key=lambda b: b.get("name", ""), reverse=True) - out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] - out += ["", "## Decisions", ""] - decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) - out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"] - out.append("") - return "\n".join(out).rstrip() + "\n" - - -def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations: list[str]) -> None: - """Append a violation for each required key that is absent or empty.""" - violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) - - -def _check_change(path: pathlib.Path, violations: list[str]) -> None: - """Validate one change file (requires `summary`).""" - rel = f"changes/{path.name}" - if CHANGE_RE.match(path.stem) is None: - violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'") - fields = parse_frontmatter(path.read_text(encoding="utf-8")) - _require(fields, SPEC_REQUIRED, rel, violations) - - -def _check_decision(path: pathlib.Path, violations: list[str]) -> None: - """Validate one decision file (requires `status` + `summary`).""" - rel = f"decisions/{path.name}" - if DECISION_RE.match(path.stem) is None: - violations.append(f"{rel}: file name is not 'YYYY-MM-DD-slug.md'") - fields = parse_frontmatter(path.read_text(encoding="utf-8")) - _require(fields, DECISION_REQUIRED, rel, violations) - status = fields.get("status", "") - if status and status not in VALID_DECISION_STATUS: - violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") - - -def check(root: pathlib.Path) -> list[str]: - """Validate every change file and decision; return the list of violation strings.""" - violations: list[str] = [] - changes_dir = root / "changes" - decisions_dir = root / "decisions" - if changes_dir.is_dir(): - for path in sorted(changes_dir.iterdir()): - if path.is_dir(): - violations.append( - f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files " - f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)" - ) - continue - if path.name == "README.md" or path.name.startswith(("_", ".")): - continue - if path.suffix != ".md": - violations.append(f"changes/{path.name}: unexpected non-md file in changes/") - else: - _check_change(path, violations) - if decisions_dir.is_dir(): - for path in sorted(decisions_dir.glob("*.md")): - if path.name == "README.md" or path.name.startswith("_"): - continue - _check_decision(path, violations) - return violations - - -def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Print the listing to stdout, or validate change files and decisions with --check.""" - argv = sys.argv[1:] if argv is None else argv - root = ROOT if root is None else root - if "--check" in argv: - violations = check(root) - if violations: - sys.stderr.write(f"planning: {len(violations)} violation(s)\n") - for violation in violations: - sys.stderr.write(f" - {violation}\n") - return 1 - sys.stdout.write("planning: OK\n") - return 0 - sys.stdout.write(render(load_changes(root), load_decisions(root))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/planning/links.py b/planning/links.py deleted file mode 100644 index c11f861..0000000 --- a/planning/links.py +++ /dev/null @@ -1,148 +0,0 @@ -# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) -"""Check every relative Markdown link and heading anchor in the repository. - -Run via ``just check-links``. Exists because a site builder only validates the -directory it publishes: a repo's ``architecture/`` and ``planning/`` trees usually -sit outside it, are read on GitHub, and rot silently. In the repo this convention -came from, anchors in ``architecture/`` broke three times in one week, each caught -only by a human re-deriving slugs by hand. - -Slugs follow **GitHub's** algorithm, because that is where these files are read — -including the ones a site builder also publishes. Where the two disagree, the fix -is to change the heading rather than to teach this checker both dialects: a heading -containing an em dash yields ``a--b`` on GitHub (the dash is dropped, both spaces -become hyphens) and ``a-b`` under python-markdown (the whitespace run collapses). - -External links are not fetched; this checks the repository's internal consistency. -A relative link that resolves outside the repository is reported rather than followed: -it is a 404 on GitHub, and whether it resolves on disk depends on what the author -happens to have cloned next to the repo — a verdict a lint gate must never depend on. -""" - -import argparse -import collections -import pathlib -import re -import sys - - -SKIP_DIRS = frozenset({".git", ".venv", ".tox", "site", "node_modules", "__pycache__", ".ruff_cache", ".superpowers"}) -FENCE = re.compile(r"^\s*(```|~~~)") -INLINE_CODE = re.compile(r"(`+).+?\1") # any run of backticks delimits a span: `x`, ``a`b`` -HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") -LINK = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)") -EXTERNAL = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.IGNORECASE) - - -def repo_root(start: pathlib.Path) -> pathlib.Path: - """Nearest ancestor holding ``.git``, else ``start``. - - Found rather than computed because this file has two homes: the canonical repo's - root, and a consumer's ``planning/`` — a fixed relative depth is wrong in one of them. - """ - for candidate in [start, *start.parents]: - if (candidate / ".git").exists(): - return candidate - return start - - -def strip_fences(text: str) -> str: - """Blank out fenced blocks, keeping line count, so code is never read as a heading.""" - out, fenced = [], False - for line in text.splitlines(): - if FENCE.match(line): - fenced = not fenced - out.append("") - continue - out.append("" if fenced else line) - return "\n".join(out) - - -def link_lines(text: str) -> list[str]: - """Lines with fenced blocks and inline spans removed — what to scan for real links. - - Only link scanning strips inline spans. A page documenting the markup an author should - copy is not linking anywhere, while a heading's backticked content is part of its slug. - """ - return [INLINE_CODE.sub("", line) for line in strip_fences(text).splitlines()] - - -def slugify(heading: str) -> str: - """GitHub's heading slug: drop formatting and punctuation, lowercase, spaces to hyphens.""" - text = re.sub(r"`([^`]*)`", r"\1", heading) - text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) - # `*` and `~` are emphasis; `_` is kept because GitHub keeps it and headings name - # identifiers (`bound_type`) far more often than they use underscore-italics. - text = re.sub(r"[*~]", "", text) - text = "".join(ch for ch in text.lower() if ch.isalnum() or ch in " -_") - return text.strip().replace(" ", "-") - - -def anchors(text: str) -> set[str]: - """Every anchor a reader can target, including GitHub's ``-1``/``-2`` duplicate suffixes.""" - seen: collections.Counter[str] = collections.Counter() - found: set[str] = set() - for line in strip_fences(text).splitlines(): - match = HEADING.match(line) - if not match: - continue - base = slugify(match.group(2)) - found.add(base if not seen[base] else f"{base}-{seen[base]}") - seen[base] += 1 - return found - - -def check(root: pathlib.Path) -> list[str]: - """Return one message per broken link; empty means every internal link resolves.""" - root = root.resolve() - files = sorted(p for p in root.rglob("*.md") if not SKIP_DIRS & set(p.relative_to(root).parts)) - cache: dict[pathlib.Path, set[str]] = {} - violations: list[str] = [] - for path in files: - text = path.read_text(encoding="utf-8") - for line_no, line in enumerate(link_lines(text), 1): - for target in LINK.findall(line): - if EXTERNAL.match(target): - continue - rel, _, fragment = target.partition("#") - # A bare `#frag` targets this same file — the anchor is still checkable, - # and a same-page link rots exactly like a cross-page one. - dest = (path.parent / rel).resolve() if rel else path - where = f"{path.relative_to(root)}:{line_no}" - if dest != root and root not in dest.parents: - # Judged before existence: a sibling repo cloned alongside this one makes - # ../../../other-repo/… resolve on one machine and nowhere else, and it is - # a 404 on GitHub either way. The verdict must not depend on the checkout layout. - violations.append(f"{where}: leaves the repository -> {rel}") - continue - if not dest.exists(): - violations.append(f"{where}: no such file -> {rel}") - continue - if not fragment or dest.suffix != ".md": - continue - if dest not in cache: - cache[dest] = anchors(dest.read_text(encoding="utf-8")) - if fragment.lower() not in cache[dest]: - violations.append(f"{where}: no such anchor -> {target}") - return violations - - -def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Report every broken link; return 1 if any, else 0.""" - parser = argparse.ArgumentParser(description="Check Markdown links and heading anchors.") - parser.add_argument("--root", type=pathlib.Path, default=None) - args = parser.parse_args(sys.argv[1:] if argv is None else argv) - - target = args.root or root or repo_root(pathlib.Path(__file__).resolve().parent) - violations = check(target) - if violations: - sys.stderr.write(f"links: {len(violations)} broken\n") - for violation in violations: - sys.stderr.write(f" - {violation}\n") - return 1 - sys.stdout.write("links: OK\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/planning/releases/2.0.0.md b/planning/releases/2.0.0.md deleted file mode 100644 index 2f36be9..0000000 --- a/planning/releases/2.0.0.md +++ /dev/null @@ -1,52 +0,0 @@ -# modern-di-taskiq 2.0.0 — first release - -First release of the taskiq integration for modern-di. It wires a -[modern-di](https://modern-di.modern-python.org) container into a -[taskiq](https://taskiq-python.github.io) broker and resolves dependencies into -task handlers, scoped per task. The version starts at `2.0.0` to track the -modern-di 2.x ecosystem alongside the other official integrations. - -taskiq has its own dependency-injection system (`TaskiqDepends`), so this -integration follows modern-di's **native-DI generator-dependency path**: -`FromDI` returns a `TaskiqDepends` marker, and a generator dependency owns the -per-task container lifecycle. - -## Feature - -- **`setup_di(broker, container)`.** Stashes the root container on the broker - (`broker.state`, under a named constant, read back with - `fetch_di_container(broker)`), registers the connection provider, and wires - the container's lifecycle to the broker's worker events — reopening it on - `WORKER_STARTUP` (so a worker restart works) and closing it on - `WORKER_SHUTDOWN`. Returns the container. -- **Per-task child containers.** A generator dependency opens one - `Scope.REQUEST` child container per task, seeded with the current - `taskiq.TaskiqMessage` as context, and closes it when the task finishes — - including when the task raises. taskiq resolves the generator once per task, - so every `FromDI` parameter in a task shares the same child. -- **`FromDI`.** Mark a task parameter with - `Annotated[T, FromDI(provider_or_type)]`; it resolves from the task's child - container. `FromDI` accepts a provider reference or a bare type, dispatching - through `Container.resolve_dependency` (so overrides, caching, and - did-you-mean suggestions are inherited). -- **`taskiq_message_provider`.** A `ContextProvider` binding - `taskiq.TaskiqMessage` at `Scope.REQUEST`, so the current message is - resolvable inside DI. - -## Packaging - -- Requires `taskiq>=0.11,<0.13` and `modern-di>=2.25,<3`; Python 3.10–3.14. -- Ships `py.typed`; zero runtime dependencies beyond taskiq and modern-di. -- Resolution stays synchronous by design; only the per-task container builder - and finalizers are async. - -## Downstream - -No action needed — this is a new package. - -## Internals - -- 100% line coverage; `ruff`, `ty`, and `eof-fixer` clean across Python - 3.10–3.14. -- Uses the portable planning convention (`planning/`) with an `architecture/` - truth home; releases are tag-driven. diff --git a/planning/releases/2.1.0.md b/planning/releases/2.1.0.md deleted file mode 100644 index e94a57a..0000000 --- a/planning/releases/2.1.0.md +++ /dev/null @@ -1,67 +0,0 @@ -# modern-di-taskiq 2.1.0 — adopt the modern-di integration kit - -Maintenance release. **No public API change** — `FromDI`, `setup_di`, -`fetch_di_container`, and `taskiq_message_provider` keep their signatures -and behavior. Swaps this package's hand-rolled connection-context derivation -and marker resolution for the shared primitives in -[`modern_di.integrations`](https://github.com/modern-python/modern-di/blob/main/architecture/integration-kit.md), -shipped in -[modern-di 2.28.0](https://github.com/modern-python/modern-di/releases/tag/2.28.0). -Eleventh of 13 planned adapter conversions across the `modern-di` ecosystem -(after -[`modern-di-starlette` 2.2.0](https://github.com/modern-python/modern-di-starlette/releases/tag/2.2.0), -[`modern-di-fastapi` 2.10.0](https://github.com/modern-python/modern-di-fastapi/releases/tag/2.10.0), -[`modern-di-litestar` 2.13.0](https://github.com/modern-python/modern-di-litestar/releases/tag/2.13.0), -[`modern-di-aiohttp` 2.2.0](https://github.com/modern-python/modern-di-aiohttp/releases/tag/2.2.0), -[`modern-di-flask` 2.1.0](https://github.com/modern-python/modern-di-flask/releases/tag/2.1.0), -[`modern-di-faststream` 2.10.0](https://github.com/modern-python/modern-di-faststream/releases/tag/2.10.0), -[`modern-di-typer` 2.3.0](https://github.com/modern-python/modern-di-typer/releases/tag/2.3.0), -[`modern-di-grpc` 2.1.0](https://github.com/modern-python/modern-di-grpc/releases/tag/2.1.0), -[`modern-di-celery` 2.1.0](https://github.com/modern-python/modern-di-celery/releases/tag/2.1.0), -and -[`modern-di-arq` 2.1.0](https://github.com/modern-python/modern-di-arq/releases/tag/2.1.0)). -taskiq is a **native-DI adapter** (uses taskiq's own `TaskiqDepends`) with a -single connection provider, so — like grpc — it uses `bind()` directly with -no `classify_connection`. - -## Internal refactors - -- **`build_di_container` now derives scope/context via - `integrations.bind(taskiq_message_provider, context.message)`**, and opens - the per-task child via `async with ... as container: yield container` — - replacing the manual `try`/`finally: await container.close_async()`. taskiq - has a single connection provider, so there's no `classify_connection` - dispatch (nothing to dispatch across). -- **`Dependency`'s field is renamed from a raw `dependency` to - `marker: integrations.Marker[T_co]`; `__call__` delegates to - `self.marker.resolve(request_container)`.** `FromDI` now constructs - `Dependency(integrations.Marker(dependency))` — its own signature - (`dependency, *, use_cache`) is unchanged, and it still wraps taskiq's own - `TaskiqDepends`. -- Like `modern-di-fastapi`/`modern-di-litestar`/`modern-di-faststream`, this - package is a **native-DI** adapter: no hand-rolled `@inject` decorator, no - `parse_markers`/`resolve_markers` usage, and no imports become dead. -- `setup_di`, `fetch_di_container`, and the - `WORKER_STARTUP`/`WORKER_SHUTDOWN` root-lifecycle wiring are untouched. -- `architecture/dependency-injection.md`'s "Per-task scope" and "Resolution" - sections promoted to describe the new internals. - -## Packaging - -- Bumps the `modern-di` floor to `>=2.28,<3`. - -## Downstream - -No action needed — the public API (`FromDI`, `setup_di`, -`fetch_di_container`, `taskiq_message_provider`) is unchanged. Upgrading -only requires `modern-di>=2.28.0`. - -## Internals - -- 100% line coverage; `ruff`, `ty` clean across Python 3.10–3.14. Tests use - `taskiq.InMemoryBroker` (no external broker). -- Built via `subagent-driven-development`: 5 planned tasks, each with an - independent spec+quality review; a whole-branch review before merge that - traced the async-generator-inside-`async with` structure's close-timing - against the original `try`/`finally` on both the normal and task-error - paths. diff --git a/planning/releases/3.0.0.md b/planning/releases/3.0.0.md deleted file mode 100644 index 1b364f5..0000000 --- a/planning/releases/3.0.0.md +++ /dev/null @@ -1,26 +0,0 @@ -# modern-di-taskiq 3.0.0 — modern-di 3.x - -Requires **modern-di >= 3, < 4**. **No public API change** — `FromDI`, -`fetch_di_container`, `setup_di`, and `taskiq_message_provider` keep their -signatures and behavior. The adapter already drove the explicit open/close -container lifecycle (`async with` around the per-task child container, and -`container.open()` / `await container.close_async()` on the root container -via the `WORKER_STARTUP` / `WORKER_SHUTDOWN` event handlers), so no adapter -code changed for -[modern-di 3.0's mandatory-open lifecycle](https://modern-di.modern-python.org/migration/to-3.x/). - -## Packaging - -- **Bumped the `modern-di` dependency to `>=3,<4`.** Requires modern-di 3.x; - drops support for modern-di 2.x. - -## Downstream - -No action needed beyond raising your own `modern-di` floor to `>=3`. If your -code builds child containers, open them with `with` / `async with` (or -`open()`) before resolving — see the -[modern-di 3.x migration guide](https://modern-di.modern-python.org/migration/to-3.x/). - -## Internals - -- 100% line coverage; `ruff`, `ty` clean. diff --git a/planning/releases/3.0.1.md b/planning/releases/3.0.1.md deleted file mode 100644 index c6e538e..0000000 --- a/planning/releases/3.0.1.md +++ /dev/null @@ -1,40 +0,0 @@ -# modern-di-taskiq 3.0.1 — documentation catches up with modern-di 3.1 - -**No code changes.** The installed package is byte-identical to 3.0.0; nothing -in `modern_di_taskiq/` moved. This release exists to publish the corrected -documentation, because the README a user reads on PyPI is rendered from the -released distribution — so the fix below was invisible until now. - -## Fix - -- **The documented setup no longer passes `validate=True`.** modern-di 3.1 made - `Container(validate=...)` a deprecated no-op that emits - `ValidateArgumentWarning`. The README example therefore emitted a - `DeprecationWarning` on every construction, and — more quietly — silently - stopped validating anything, because that argument *was* the explicit opt-in - to graph validation. This package pins `modern-di>=3`, so users reached that - state on a routine upgrade. - - The docs now build the container plain and call `container.validate()` - explicitly, after `setup_di`. That ordering matters and is unchanged: - `setup_di` registers this integration's providers, so validating first fails - any provider with a non-optional dependency on them. - -## Features - -- **A runnable canonical example** under `examples/`, linked from the README, so - the integration can be adopted in one sitting. - -## Downstream - -Nothing is required of you. If you copied the old README snippet, drop -`validate=True` — it does nothing on modern-di 3.1 and warns: - -```python -container = Container(groups=[Dependencies]) -setup_di(...) -container.validate() # optional fail-fast, after setup_di registers its providers -``` - -Keeping it works, but a suite configured with `error::DeprecationWarning` will -fail on it. The argument is removed in modern-di 4.0. diff --git a/tests/dependencies.py b/tests/dependencies.py index 3cecb5a..dca933b 100644 --- a/tests/dependencies.py +++ b/tests/dependencies.py @@ -29,3 +29,9 @@ class Dependencies(Group): bound_type=None, cache=True, ) + request_singleton_holder = providers.Factory( + scope=Scope.REQUEST, + creator=DependentCreator, + kwargs={"dep1": request_singleton}, + bound_type=None, + ) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 7aef0dd..90d7308 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -25,6 +25,14 @@ async def test_startup_opens_and_shutdown_closes(broker: InMemoryBroker) -> None async def test_restart_reopens_without_error(broker: InMemoryBroker) -> None: + """INVARIANT: a second worker cycle reopens the root container instead of raising. + + Broken by making the ``WORKER_STARTUP`` handler conditional, or by dropping it on the grounds + that a fresh ``Container`` is already open -- which it is, so the first cycle passes either way + and the regression only shows on the second. Worker processes restart: on redeploy, after a + crash, and between two ``startup()``/``shutdown()`` pairs in one test session. Without the + reopen the container closed by the previous shutdown stays closed and every task fails. + """ container = fetch_di_container(broker) await broker.startup() await broker.shutdown() diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py new file mode 100644 index 0000000..382ac4b --- /dev/null +++ b/tests/test_public_surface.py @@ -0,0 +1,22 @@ +import types + +import modern_di_taskiq + + +def test_public_surface_is_exactly_the_four_documented_symbols() -> None: + """INVARIANT: the package exports exactly the four symbols the README documents. + + Broken by promoting a helper to a public name, in ``__all__`` or as an unprefixed binding in + ``__init__`` -- the latter is public whether or not it was meant to be. ``build_di_container`` + and ``Dependency`` are the standing temptation: they are the seam taskiq resolves through, so + they read like API, but exporting either would turn the shape of the per-task wiring into a + semver promise instead of an implementation detail this package can re-cut against a new taskiq. + """ + public = sorted( + name + for name, value in vars(modern_di_taskiq).items() + if not name.startswith("_") and not isinstance(value, types.ModuleType) + ) + + assert public == ["FromDI", "fetch_di_container", "setup_di", "taskiq_message_provider"] + assert modern_di_taskiq.__all__ == public diff --git a/tests/test_taskiq_di.py b/tests/test_taskiq_di.py index 7b617e8..8463940 100644 --- a/tests/test_taskiq_di.py +++ b/tests/test_taskiq_di.py @@ -42,13 +42,27 @@ async def my_task( assert data["task_name"] == "my_task" -async def test_request_child_shared_within_task_isolated_across_tasks(broker: InMemoryBroker) -> None: +async def test_per_task_child_shared_within_task_isolated_across_tasks(broker: InMemoryBroker) -> None: + """INVARIANT: one per-task child per task execution, shared by its parameters, never across tasks. + + Broken by anything that stops the container builder being resolved exactly once per task: + taking the child with ``use_cache=False``, hoisting it out to broker or worker lifetime to save + an allocation, or moving it under a middleware that keys children by anything coarser than a + single execution. Sharing within the task is what makes a cached REQUEST-scoped provider mean + one instance per task; isolation across tasks is what stops one task's message context, cached + values and finalizers leaking into the next task the worker picks up. + + The two parameters name *different* providers deliberately. Two parameters naming the same one + collapse into a single taskiq dependency node, resolved once whatever the child count, so such a + test would stay green while every parameter got its own child. + """ + @broker.task(task_name="shared") async def collect( - a: typing.Annotated[SimpleCreator, FromDI(Dependencies.request_singleton)], - b: typing.Annotated[SimpleCreator, FromDI(Dependencies.request_singleton)], + direct: typing.Annotated[SimpleCreator, FromDI(Dependencies.request_singleton)], + holder: typing.Annotated[DependentCreator, FromDI(Dependencies.request_singleton_holder)], ) -> tuple[bool, SimpleCreator]: - return (a is b, a) + return (direct is holder.dep1, direct) await broker.startup() try: @@ -61,12 +75,20 @@ async def collect( assert r2.is_err is False shared1, inst1 = r1.return_value shared2, inst2 = r2.return_value - assert shared1 is True # two FromDI params in one task share ONE request child + assert shared1 is True # two FromDI params in one task share ONE per-task child assert shared2 is True assert inst1 is not inst2 # each task gets its own child (cross-task isolation) -async def test_request_child_closed_on_task_error() -> None: +async def test_per_task_child_closed_on_task_error() -> None: + """INVARIANT: the per-task child is closed when the task raises, not only when it returns. + + Broken by closing the child anywhere but the exit of the ``async with`` in the generator + dependency -- after the ``yield`` without a try, or from a caller that only runs on the success + path. taskiq throws the task's exception into the generator at the ``yield``, so the error path + is the one that silently regresses: a worker survives failing tasks, so a finalizer that stops + running on errors leaks a connection per failure until the process dies rather than at once. + """ teardowns: list[str] = [] class Boom(Group):