feat(api): Add Serializer[T] generic; pilot on environments#116538
Draft
azulus wants to merge 1 commit into
Draft
feat(api): Add Serializer[T] generic; pilot on environments#116538azulus wants to merge 1 commit into
azulus wants to merge 1 commit into
Conversation
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>
gricha
approved these changes
May 30, 2026
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.
Make
sentry.api.serializers.Serializerstatically generic over itsserialized output type, so
Response(serialize(obj, user, FooSerializer()))type-checks against
Response[T]end-to-end. Builds on theResponse[T]work in #116335 / #116433 / #116496 — closes the gap where
serialize()'s-> Anyreturn defeated the new mypy plugin's body check.Opt-in per serializer, no churn for unmigrated ones
T = TypeVar("T", default=Any)meansclass FooSerializer(Serializer):keeps returning
Anyfrom the helper's perspective — every existingcall site is unaffected, including those that mutate the result
(
data["key"] = ...). The new check fires only for serializers thatexplicitly opt in by declaring
Serializer[FooResponse].Pilot:
EnvironmentSerializer+OrganizationEnvironmentsEndpointThe 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'sexception handler produces the same
{"detail": "..."}body atruntime; existing test assertions pass unchanged.
Drift caught at both ends
Verified synthetically. With the pilot serializer declared
Serializer[EnvironmentSerializerResponse]:Missing keys ("id", "name") for TypedDict+Extra key "wrong" for TypedDict.Return type ... incompatible with return type ... in supertype(atthe serializer) and
Argument 3 to "serialize" has incompatible type ... expected "Serializer[EnvironmentSerializerResponse]"(at theendpoint).
No stub file — typing lives in
base.pyTwo
.pyiplacements were tried during development:fixtures/stubs-for-mypy/sentry/api/serializers/base.pyiand siblingsrc/sentry/api/serializers/base.pyi. Both rejected: mypy ignoresstubs for internal modules when CI's
files = ["."]puts the.pyinthe check set. The runtime file is the single source of truth.
@overloaddecorators onserialize()provide the typed shape; theno-serializer overload comes first to preserve
Anyfor theregistry-lookup path. Three
# type: ignore[return-value]on the baseSerializermethod bodies cover the placeholderreturn None/return {}cases — concrete subclasses override with real bodies thatsatisfy
T.Deferred
responses={200: FooSerializer}toTviaSerializer[T]bases.Useful once a handful of serializers are migrated; the bare class
references continue to be skipped silently for now.
IssueEventSerializerreturningdifferent shapes for different call args) stay un-generic.