Skip to content

feat(api): Union response annotations with plugin narrowing + relaxed linter#116659

Merged
azulus merged 1 commit into
masterfrom
jeremy/response-typing-union-batch
Jun 2, 2026
Merged

feat(api): Union response annotations with plugin narrowing + relaxed linter#116659
azulus merged 1 commit into
masterfrom
jeremy/response-typing-union-batch

Conversation

@azulus
Copy link
Copy Markdown
Member

@azulus azulus commented Jun 2, 2026

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 a
union of Response[...] arms, the plugin narrows the literal to one
arm structurally:

Non-empty dict literal       → TypedDict-coercion check per arm's T
Empty dict literal `{}`      → matches dict[K,V] or no-required-keys TypedDict
Empty list literal `[]`      → matches list[T] arms

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 intentionally
refuses 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.

Before (set-equality):
─────────────────────
Decorator: {200: inline_sentry_response_serializer("X", T)}
Annotation: Response[T] | Response[ErrorT]
              → mismatch (annotation declares more)

After (set-subset):
───────────────────
Same input → linter passes. {T} ⊆ {T, ErrorT}.

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.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: 9 of 25 candidate endpoints

Migrated 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)

Each migrated endpoint's diff is exactly two things: add a DetailResponse
import, and change the return annotation from Response to a union.

The other 16 candidates stay unmigrated

They have body sources that are typed Any at the runtime API surface
(serializer.errors returns DRF ReturnDict[Any, Any], pydantic
resp.dict() returns dict[str, Any], untyped helper functions), or
inline 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--> Response until the source
typing catches up.

No runtime changes anywhere. No cast() calls. No # type: ignore
comments. No return → raise conversions. 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: 39/39 pass (29 existing + 7 union-narrowing + 3 empty-literal/name-agnostic)
  • Linter tests: 19/19 pass (16 existing + 3 subset-semantic scenarios)

… 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>
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 2, 2026
@azulus azulus marked this pull request as ready for review June 2, 2026 16:24
@azulus azulus requested review from a team as code owners June 2, 2026 16:24
@azulus azulus merged commit b319ab1 into master Jun 2, 2026
68 checks passed
@azulus azulus deleted the jeremy/response-typing-union-batch branch June 2, 2026 16:55
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants