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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
38 changes: 6 additions & 32 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,47 +25,22 @@ 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).
# Auth via PyPI Trusted Publishing (OIDC); no PYPI_TOKEN. Needs a Trusted
# Publisher on the modern-di-taskiq PyPI project (env: pypi, workflow: release.yml).
- run: just publish

# Description source: planning/releases/<tag>.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 <tag> --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
Expand All @@ -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
80 changes: 68 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/<capability>.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.
31 changes: 31 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 0 additions & 21 deletions architecture/README.md

This file was deleted.

62 changes: 0 additions & 62 deletions architecture/dependency-injection.md

This file was deleted.

29 changes: 29 additions & 0 deletions docs/adr/0001-per-task-scope-rides-taskiq-dependencies.md
Original file line number Diff line number Diff line change
@@ -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).
29 changes: 29 additions & 0 deletions docs/adr/0002-only-worker-lifecycle-is-wired.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 0 additions & 16 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 0 additions & 1 deletion planning/.convention-version

This file was deleted.

Loading
Loading