Skip to content

feat(api): Add Serializer[T] generic; pilot on environments#116538

Draft
azulus wants to merge 1 commit into
masterfrom
jeremy/type-serializer-generic-pilot
Draft

feat(api): Add Serializer[T] generic; pilot on environments#116538
azulus wants to merge 1 commit into
masterfrom
jeremy/type-serializer-generic-pilot

Conversation

@azulus
Copy link
Copy Markdown
Member

@azulus azulus commented May 29, 2026

Make sentry.api.serializers.Serializer statically generic over its
serialized output type, so Response(serialize(obj, user, FooSerializer()))
type-checks against Response[T] end-to-end. Builds on the Response[T]
work in #116335 / #116433 / #116496 — closes the gap where
serialize()'s -> Any return defeated the new mypy plugin's body check.

Opt-in per serializer, no churn for unmigrated ones

T = TypeVar("T", default=Any) means class FooSerializer(Serializer):
keeps returning Any from the helper's perspective — every existing
call site is unaffected, including those that mutate the result
(data["key"] = ...). The new check fires only for serializers that
explicitly opt in by declaring Serializer[FooResponse].

Pilot: EnvironmentSerializer + OrganizationEnvironmentsEndpoint

# src/sentry/api/serializers/models/environment.py
class EnvironmentSerializer(Serializer[EnvironmentSerializerResponse]):
    ...

# src/sentry/core/endpoints/organization_environments.py
def get(
    self, request: Request, organization: Organization
) -> Response[list[EnvironmentSerializerResponse]]:
    ...
    return Response(serialize(list(queryset), request.user, EnvironmentSerializer()))

The 400 inline-validation Response({"detail": ...}, status=400)
converts to raise ParseError(...) so the return type is single-armed
— same pattern as the earlier Response[T] cluster work. DRF's
exception handler produces the same {"detail": "..."} body at
runtime; existing test assertions pass unchanged.

Drift caught at both ends

Verified synthetically. With the pilot serializer declared
Serializer[EnvironmentSerializerResponse]:

  • Returning a wrong-shape body from the endpoint produces:
    Missing keys ("id", "name") for TypedDict +
    Extra key "wrong" for TypedDict.
  • Declaring the serializer with a mismatched base produces both
    Return type ... incompatible with return type ... in supertype (at
    the serializer) and Argument 3 to "serialize" has incompatible type ... expected "Serializer[EnvironmentSerializerResponse]" (at the
    endpoint).

No stub file — typing lives in base.py

Two .pyi placements were tried during development:
fixtures/stubs-for-mypy/sentry/api/serializers/base.pyi and sibling
src/sentry/api/serializers/base.pyi. Both rejected: mypy ignores
stubs for internal modules when CI's files = ["."] puts the .py in
the check set. The runtime file is the single source of truth.
@overload decorators on serialize() provide the typed shape; the
no-serializer overload comes first to preserve Any for the
registry-lookup path. Three # type: ignore[return-value] on the base
Serializer method bodies cover the placeholder return None /
return {} cases — concrete subclasses override with real bodies that
satisfy T.

Deferred

  • Extending the structural linter (feat(apidocs): Support union Response[T] annotations in structural linter #116496) to resolve
    responses={200: FooSerializer} to T via Serializer[T] bases.
    Useful once a handful of serializers are migrated; the bare class
    references continue to be skipped silently for now.
  • Cluster migrations of additional serializers + endpoints.
  • Kwargs-divergent serializers (e.g. IssueEventSerializer returning
    different shapes for different call args) stay un-generic.

Make sentry's serializer base class statically generic over its
serialized output type, mirroring the Response[T] pattern shipped in
PRs #116335 / #116433 / #116496 one layer up the call chain.

The motivation: even with Response[T] in place, `Response(serialize(obj,
user))` still evaluates to `Response[Any]` because the top-level
serialize() helper returns `Any`. The new mypy plugin's `Any`-guard
fires whenever a typed endpoint's body comes from `serialize()` — but it
can only error with "body is Any," never with the actual shape mismatch.
Typed serializers close this gap.

Changes:

- `Serializer` becomes `Generic[T]` with `TypeVar("T", default=Any)`,
  preserving historical caller behavior for unmigrated serializers
  (`class FooSerializer(Serializer):` → `serialize(obj, user, ser())`
  still returns `Any`, callers can still mutate the result).
- `serialize()` helper gains 5 `@overload` decorators. The no-serializer
  overload comes first so the registry-lookup path keeps returning `Any`
  unconditionally. Typed overloads only fire when callers explicitly
  pass a `Serializer[T]` instance.
- Pilot: `EnvironmentSerializer` migrates to
  `Serializer[EnvironmentSerializerResponse]` (1-line); the corresponding
  endpoint tightens its return to `Response[list[T]]` and passes the
  serializer explicitly to `serialize()`. The 400 validation path
  converts to `raise ParseError(...)` so the return type is
  single-armed (same pattern as the Response[T] migration).

Verification:

- mypy clean across the 134 source files in
  `src/sentry/api/serializers/` + `src/sentry/core/endpoints/`.
- Synthetic drift caught by mypy at both ends: wrong body shape against
  the endpoint annotation, and wrong base TypedDict on the serializer.
- `make build-api-docs` output byte-identical to master.
- Structural linter (#116496) regression-free; bare class references in
  `@extend_schema(responses={200: FooSerializer})` continue to be
  skipped silently (Phase 3 follow-up will resolve them via
  `Serializer[T]` bases).
- Existing endpoint tests pass; `ParseError` produces the same 400 +
  `{"detail": ...}` shape as the prior inline-`Response`.

The original design called for a `.pyi` stub. Both `fixtures/stubs-for-mypy/`
and sibling-`.pyi` placements were verified during apply and rejected
because mypy ignores stubs for internal modules when CI's
`files = ["."]` includes the `.py` in its check set. Runtime typing is
the single-source-of-truth path. See OpenSpec
`type-serializer-generic-pilot/design.md` Decision 1 for the
verification record.

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 May 29, 2026
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