feat(api): Union response annotations with plugin narrowing + relaxed linter#116659
Merged
Conversation
… linter
Three pieces of infrastructure that together enable typing endpoints with
mixed return shapes (success TypedDict + inline error bodies) without any
business-logic changes:
1. Plugin narrowing across union arms
─────────────────────────────────────
The existing mypy plugin gains `_narrow_response_literal_in_union`, a
sibling hook to the body-Any check. When `Response(<literal>, ...)` is
constructed inside a function returning a union of `Response[...]` arms,
the plugin tries narrowing the literal to one of those arms structurally:
- Non-empty dict literal → TypedDict-coercion check against each arm's T
- Empty dict literal `{}` → matches `dict[K,V]` arms or no-required-keys TypedDicts
- Empty list literal `[]` → matches `list[T]` arms
When exactly one arm accepts, the plugin returns `Response[that_T]` and
mypy's union check passes. Zero matches (real drift) or multiple matches
(ambiguous) fall back to default. The plugin is name-agnostic — no
hardcoded TypedDict names.
This restores in-union the bidirectional inference mypy already does for
single-target `Response[T]` annotations. mypy intentionally refuses to do
TypedDict-from-literal narrowing across multiple union targets; this is
the codified-as-a-plugin equivalent for Response's specific case.
2. Linter relaxation: set-subset, not set-equality
──────────────────────────────────────────────────
`sentry.apidocs._check_response_annotation_matches_schema` now requires the
decorator's typed-T set to be a subset of the annotation's typed-T set,
not equal. The annotation may declare arms that the decorator does not
(e.g. local error TypedDicts not exposed in OpenAPI via opaque
`RESPONSE_BAD_REQUEST` constants). This preserves API-as-today: endpoints
can enrich their internal annotations without forcing decorator
migrations that would change the published OpenAPI document. Drift in
the other direction (decorator declares T that annotation omits) is
still caught.
3. `src/sentry/apidocs/response_types.py` as a shared-shapes module
───────────────────────────────────────────────────────────────────
A small module that holds TypedDict shapes recurring across endpoints.
Initial contents: `DetailResponse` (DRF's standard `{"detail": str}`
shape). The module is *not* authoritative — endpoints whose error
shapes don't match anything here are expected to declare local
TypedDicts in their own files. The linter is name-agnostic, treating
shared and local TypedDicts identically.
Migration coverage in this PR
─────────────────────────────
Nine of 25 candidate endpoints from the original harness migrate cleanly
under the new infrastructure:
- src/sentry/api/endpoints/debug_files.py
- src/sentry/issues/endpoints/organization_eventid.py
- src/sentry/issues/endpoints/project_event_details.py
- src/sentry/integrations/api/endpoints/organization_config_integrations.py
- src/sentry/integrations/api/endpoints/organization_integrations_index.py
- src/sentry/preprod/api/endpoints/public/organization_preprod_size_analysis.py
- src/sentry/preprod/api/endpoints/public/organization_preprod_artifact_install_details.py
- src/sentry/issues/endpoints/group_events.py (plugin G1 — empty list)
- src/sentry/replays/endpoints/organization_replay_count.py (plugin G1 — empty dict)
The other 16 candidates have compound issues — `Response(serializer.errors,
...)` (DRF Any-typed `ReturnDict`), `resp.dict()` pydantic returns, nested
custom error shapes — that each need per-file work (raise conversions,
custom TypedDicts, source-side typing). Deferred to per-cluster follow-up
PRs. The infrastructure they'll need is in place.
No runtime changes anywhere. No `cast()` calls. No `# type: ignore`.
Plugin and linter operate purely at type-check time.
Verification
────────────
- Full-tree mypy (8038 files): clean in src/ and tests/
- prek: clean
- Structural linter: clean
- `make build-api-docs`: byte-identical to master
- Plugin tests (`tests/tools/mypy_helpers/test_plugin.py`): 39/39 pass
(29 existing + 7 union narrowing + 3 empty literal/name-agnostic)
- Linter tests: 19/19 pass (16 existing + 3 subset-semantic scenarios)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gricha
approved these changes
Jun 2, 2026
3 tasks
3 tasks
azulus
added a commit
that referenced
this pull request
Jun 2, 2026
Continues the Response[T] rollout (#116335 / #116433 / #116496 / #116538 / #116659). Tightens 6 endpoints where the `T` already named in the `@extend_schema(responses=...)` decorator can flow into the return annotation with zero `cast()` calls — either the body already matches structurally, or a one-line source-typing fix unblocks it: - organization_events_timeseries.py: tighten to `Response[StatsResponse]`. EMPTY_STATS_RESPONSE in `timeseries.py` retyped from `dict[str, Any]` to `StatsResponse` (its value `{"timeSeries": []}` is already a valid StatsResponse since `meta` is NotRequired). - event_attachments.py: tighten to `Response[list[EventAttachmentSerializerResponse]]`. - group_hashes.py: tighten to `Response[list[GroupHashesResult]]`. - project_releases.py: tighten to `Response[list[ReleaseSerializerResponse]]`. - discover_saved_queries.py: tighten to `Response[list[DiscoverSavedQueryResponse]]`, pass the (already-generic) `DiscoverSavedQueryModelSerializer()` instance explicitly so the call site is typed end-to-end. - source_map_debug.py: tighten to `Response[SourceMapDebugResponse]`, type the locally-built `processed_exceptions: list[SourceMapDebugException]` and `processed_frames: list[SourceMapDebugFrame]` lists. The remaining 23 endpoints from the original Surface A audit hit drift that requires real source-typing work (Serializer[T] migrations for EventSerializer / RuleSerializer / ReleaseSerializer / etc., typed return annotations on `_handle_results` / `massage_outcomes_result`, richer union arms for validation-error paths). Those land in follow-up PRs that do the source typing properly — no cast() escape valves. The linter flip to make bare-Response + typed-decorator a violation also waits until all 29 endpoints are tightened. Verification: - mypy: 0 errors across the 7 touched files - prek: clean - `sentry django spectacular`: byte-identical OpenAPI output vs master Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
azulus
added a commit
that referenced
this pull request
Jun 2, 2026
#116717) ## Summary Tightens 6 endpoint return annotations from `-> Response` to `-> Response[T]`, where `T` is the type already named in the same method's `inline_sentry_response_serializer(...)` decorator. Continues the Response[T] rollout from #116335 / #116433 / #116496 / #116538 / #116659. ## Changes - **`organization_events_timeseries.py`** — `-> Response[StatsResponse]`. `EMPTY_STATS_RESPONSE` in `timeseries.py` retyped from `dict[str, Any]` to `StatsResponse` (its value `{"timeSeries": []}` is already a valid `StatsResponse` since `meta` is `NotRequired`). - **`event_attachments.py`** — `-> Response[list[EventAttachmentSerializerResponse]]`. - **`group_hashes.py`** — `-> Response[list[GroupHashesResult]]`. - **`project_releases.py`** — `-> Response[list[ReleaseSerializerResponse]]`. - **`discover_saved_queries.py`** — `-> Response[list[DiscoverSavedQueryResponse]]`. Pass the (already-generic) `DiscoverSavedQueryModelSerializer()` instance explicitly so the call site is typed end-to-end. - **`source_map_debug.py`** — `-> Response[SourceMapDebugResponse]`. Type the locally-built `processed_exceptions: list[SourceMapDebugException]` and `processed_frames: list[SourceMapDebugFrame]` lists. ## Verification - mypy: 0 errors across the 7 touched files - prek: clean - `sentry django spectacular`: byte-identical OpenAPI output vs master ## Test plan - [ ] CI typing job passes (mypy) - [ ] CI prek hooks pass - [ ] Existing endpoint tests pass unchanged (no runtime behavior change) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three pieces of infrastructure that together enable typing endpoints with
mixed return shapes (success TypedDict + inline error bodies) without
business-logic changes.
Plugin narrowing across union arms
The mypy plugin gains a sibling hook to the body-Any check. When
Response(<literal>, ...)is constructed inside a function returning aunion of
Response[...]arms, the plugin narrows the literal to onearm structurally:
Exactly one match → returns
Response[that_T]. Zero (drift) or multiple(ambiguous) → falls back to default. The plugin is name-agnostic —
no hardcoded TypedDict names. It does for unions what mypy already does
in single-target
Response[T]contexts, which mypy intentionallyrefuses to extend to unions (bidirectional TypedDict-from-literal
inference is single-target only by design).
Linter relaxation: set-subset, not set-equality
The structural linter (#116496) now requires the decorator's typed-T
set to be a subset of the annotation's typed-T set, not equal.
This lets endpoints enrich their internal annotations (e.g. local error
TypedDicts) without forcing decorator migrations that would change the
OpenAPI document. Drift in the other direction (decorator declares T that
annotation omits) is still caught.
src/sentry/apidocs/response_types.pyas a shared-shapes moduleA small module that holds TypedDict shapes recurring across endpoints.
Initial contents:
DetailResponse(DRF's standard{"detail": str}shape). The module is not authoritative — endpoints whose error
shapes don't match anything here are expected to declare local
TypedDicts in their own files. The linter is name-agnostic, treating
shared and local TypedDicts identically.
Migration coverage in this PR: 9 of 25 candidate endpoints
Migrated under the new infrastructure:
Each migrated endpoint's diff is exactly two things: add a
DetailResponseimport, and change the return annotation from
Responseto a union.The other 16 candidates stay unmigrated
They have body sources that are typed
Anyat the runtime API surface(
serializer.errorsreturns DRFReturnDict[Any, Any], pydanticresp.dict()returnsdict[str, Any], untyped helper functions), orinline bodies with custom nested shapes that don't match shared
TypedDicts. Migrating them requires improving the source — typing DRF
stubs better, typing pydantic dict() returns, etc. — not changing how
the endpoints work. They stay bare-
-> Responseuntil the sourcetyping catches up.
No runtime changes anywhere. No
cast()calls. No# type: ignorecomments. No
return → raiseconversions. Plugin and linter operatepurely at type-check time.
Verification
make build-api-docs: byte-identical to master