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
26 changes: 23 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -31,9 +50,10 @@ jobs:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}

# 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".
# 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: |
Expand Down
27 changes: 27 additions & 0 deletions planning/releases/2.20.0.md
Original file line number Diff line number Diff line change
@@ -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.