From 035c79dc68d2776ba31324b605f5333c1cd70a6f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 11:05:03 +0300 Subject: [PATCH 1/2] docs(release): add 2.20.0 notes The 2.20.0 tag was cut without planning/releases/2.20.0.md, so release.yml fell back to GitHub's auto-generated PR list instead of curated notes. Add the missing notes file (Group.get_named_providers()) to match every release since 2.15.0 and restore the GitHub Release body to the curated form. Co-Authored-By: Claude Opus 4.8 (1M context) --- planning/releases/2.20.0.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 planning/releases/2.20.0.md diff --git a/planning/releases/2.20.0.md b/planning/releases/2.20.0.md new file mode 100644 index 0000000..25f07b6 --- /dev/null +++ b/planning/releases/2.20.0.md @@ -0,0 +1,27 @@ +# modern-di 2.20.0 — `Group.get_named_providers()` + +Purely additive. One new public method; the contract of existing code is unchanged. + +## Feature + +- **`Group.get_named_providers() -> dict[str, AbstractProvider]`** — an MRO-walking accessor that maps each declared attribute name to its provider. `Group.get_providers()` is now `list(cls.get_named_providers().values())`, so the traversal and dedup/masking logic lives in one place. + +The new method preserves the exact semantics of the old `get_providers()` traversal: + +- MRO order (most-derived first) +- first-seen name wins (diamond inheritance returns each provider once) +- a non-provider override masks the parent provider of the same name + +`get_providers()`'s contract (return type, order, dedup, masking) is unchanged. + +## Why + +`get_providers()` discarded the attribute name each provider was declared under. Downstream integrations that need names (notably `modern-di-litestar`'s autowiring) reconstructed them with a fragile `id()`-keyed reverse lookup over `group.__dict__`. That lookup only sees the subclass `__dict__` while `get_providers()` walks the full MRO, so autowiring a `Group` that **inherits** a provider raised `KeyError`. Exposing names at the source — where `Group` owns provider declaration and traversal — fixes the bug for every consumer. + +## Downstream + +Unblocks the `modern-di-litestar` autowiring fix, which consumes `get_named_providers()` and bumps its floor to `modern-di>=2.20.0`. The FastAPI, FastStream, Typer, and `modern-di-pytest` integrations do **not** need to bump. + +## Internals + +- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. From db3c4dc1f60ba31e612985ef3c25dcde97213759 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 11:12:29 +0300 Subject: [PATCH 2/2] ci(release): require curated notes for stable tags Add a guard step before `just publish` that fails the release when a stable tag has no planning/releases/.md. PyPI is irreversible and runs first, so the check gates it: a missing notes file now aborts before anything ships, instead of silently falling back to GitHub's auto-generated notes (which is how 2.20.0 went out). Pre-release tags (e.g. 2.0.0rc1) stay exempt and keep the auto-generated fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef3c93e..14e19d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,25 @@ 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). @@ -31,9 +50,10 @@ jobs: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} # 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". + # auto-changelog appended); otherwise GitHub's generated notes. The guard + # above makes the file mandatory for stable tags, so the generated-notes + # fallback only ever fires for pre-releases. 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: |