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
15 changes: 14 additions & 1 deletion src/sentry/api/endpoints/organization_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ def _filter_releases_by_query(
return queryset


class _ProjectsErrorResponse(TypedDict):
projects: list[str]


class _RefsErrorResponse(TypedDict):
refs: list[str]


class ReleaseSerializerWithProjects(ReleaseWithVersionSerializer):
projects = ListField(
child=CharField(),
Expand Down Expand Up @@ -804,7 +812,12 @@ def qs_load_func(
)
def post(
self, request: Request, organization: Organization
) -> Response[ReleaseSerializerResponse] | Response[ValidationErrorResponse]:
) -> (
Response[ReleaseSerializerResponse]
| Response[_ProjectsErrorResponse]
| Response[_RefsErrorResponse]
| Response[ValidationErrorResponse]
):
"""
Create a new release for the given organization. Releases are used by Sentry to
improve error reporting by correlating first-seen events with the release that may
Expand Down
14 changes: 14 additions & 0 deletions src/sentry/apidocs/response_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ class DetailResponse(TypedDict):
detail: str


class NonFieldErrorsResponse(TypedDict):
"""DRF's error-body shape for errors not tied to one field:
`{"non_field_errors": ["..."]}`.

Use for inline `Response({"non_field_errors": [...]}, status=4xx)` returns.
"""

non_field_errors: list[str]


ValidationErrorResponse: TypeAlias = dict[str, Any]
"""DRF's validation-error body shape: `{field_name: <errors>, ...}`.

Expand All @@ -56,6 +66,10 @@ class DetailResponse(TypedDict):
def post(...) -> Response[FooResponse] | Response[ValidationErrorResponse]:

and produce the body via `as_validation_errors(serializer)` below.

When the union also has TypedDict arms, the Sentry mypy plugin rejects a dict
literal body that matches none of them, even though this arm would accept it.
Declare a TypedDict for the shape of an inline literal instead.
"""


Expand Down
7 changes: 6 additions & 1 deletion src/sentry/core/endpoints/organization_member_details.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Literal
from typing import Literal, TypedDict

from django.db import router, transaction
from django.db.models import Q
Expand Down Expand Up @@ -122,6 +122,10 @@ class UpdateOrgMemberRolesSerializer(OrganizationMemberRequestSerializer):
)


class _RoleErrorResponse(TypedDict):
role: str


@extend_schema(tags=["Organizations"])
@cell_silo_endpoint
class OrganizationMemberDetailsEndpoint(OrganizationMemberEndpoint):
Expand Down Expand Up @@ -207,6 +211,7 @@ def put(
Response[OrganizationMemberResponse]
| Response[None]
| Response[DetailResponse]
| Response[_RoleErrorResponse]
| Response[ValidationErrorResponse]
):
"""
Expand Down
12 changes: 11 additions & 1 deletion src/sentry/core/endpoints/organization_member_index.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, TypedDict

from django.conf import settings
from django.db import router, transaction
Expand Down Expand Up @@ -173,6 +173,14 @@ def validate_teamRoles(self, teamRoles: list[dict[str, Any]]) -> list[tuple[Team
return valid_team_roles


class _OrganizationErrorResponse(TypedDict):
organization: str


class _EmailErrorResponse(TypedDict):
email: str


@cell_silo_endpoint
@extend_schema(tags=["Organizations"])
class OrganizationMemberIndexEndpoint(OrganizationEndpoint):
Expand Down Expand Up @@ -349,6 +357,8 @@ def post(
) -> (
Response[OrganizationMemberResponse]
| Response[DetailResponse]
| Response[_OrganizationErrorResponse]
| Response[_EmailErrorResponse]
| Response[ValidationErrorResponse]
):
"""
Expand Down
13 changes: 12 additions & 1 deletion src/sentry/core/endpoints/organization_teams.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import TypedDict

from django.db import IntegrityError, router, transaction
from django.db.models import Q
from drf_spectacular.utils import OpenApiResponse, extend_schema
Expand Down Expand Up @@ -39,6 +41,11 @@
CONFLICTING_SLUG_ERROR = "A team with this slug already exists."


class _ConflictingSlugResponse(TypedDict):
non_field_errors: list[str]
detail: str


# OrganizationPermission + team:write
class OrganizationTeamsPermission(OrganizationPermission):
scope_map = {
Expand Down Expand Up @@ -198,7 +205,11 @@ def should_add_creator_to_team(self, request: Request) -> bool:
)
def post(
self, request: Request, organization: Organization, **kwargs: object
) -> Response[TeamSerializerResponse] | Response[ValidationErrorResponse]:
) -> (
Response[TeamSerializerResponse]
| Response[_ConflictingSlugResponse]
| Response[ValidationErrorResponse]
):
"""
Create a new team bound to an organization. Requires at least one of the `name`
or `slug` body params to be set.
Expand Down
9 changes: 6 additions & 3 deletions src/sentry/core/endpoints/project_team_details.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import cast
from typing import TypedDict, cast

from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
Expand All @@ -18,11 +18,14 @@
from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
from sentry.apidocs.examples.project_examples import ProjectExamples
from sentry.apidocs.parameters import GlobalParams
from sentry.apidocs.response_types import ValidationErrorResponse
from sentry.models.project import Project
from sentry.models.team import Team


class _DetailListResponse(TypedDict):
detail: list[str]


class ProjectTeamsPermission(ProjectPermission):
scope_map = {
"POST": ["project:write", "project:admin"],
Expand Down Expand Up @@ -123,7 +126,7 @@ def post(
)
def delete(
self, request: Request, project: Project, team: Team
) -> Response[ProjectWithTeamResponseDict] | Response[ValidationErrorResponse]:
) -> Response[ProjectWithTeamResponseDict] | Response[_DetailListResponse]:
"""
Revoke a team's access to a project.

Expand Down
13 changes: 12 additions & 1 deletion src/sentry/issues/endpoints/group_integration_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
from sentry.apidocs.descriptions import ApiDescription
from sentry.apidocs.examples.integration_examples import IntegrationExamples
from sentry.apidocs.parameters import GlobalParams, IssueParams
from sentry.apidocs.response_types import DetailResponse, ValidationErrorResponse
from sentry.apidocs.response_types import (
DetailResponse,
NonFieldErrorsResponse,
ValidationErrorResponse,
)
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.auth.principal import acting_user_id
from sentry.constants import CELL_API_DEPRECATION_DATE
Expand Down Expand Up @@ -122,6 +126,10 @@ class ExternalIssueLinkResponse(TypedDict):
]


class _ExternalIssueErrorResponse(TypedDict):
externalIssue: list[str]


class IntegrationIssueConfigSerializer(Serializer[IntegrationIssueConfigResponse]):
def __init__(self, action: str, config: list[IntegrationIssueConfigField]) -> None:
self.action = action
Expand Down Expand Up @@ -291,6 +299,7 @@ def post(
) -> (
Response[ExternalIssueLinkResponse]
| Response[DetailResponse]
| Response[NonFieldErrorsResponse]
| Response[ValidationErrorResponse]
):
"""
Expand Down Expand Up @@ -463,6 +472,8 @@ def put(
) -> (
Response[ExternalIssueLinkResponse]
| Response[DetailResponse]
| Response[NonFieldErrorsResponse]
| Response[_ExternalIssueErrorResponse]
| Response[ValidationErrorResponse]
):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ class _ReleaseDateQueryAndOrder(TypedDict):
order_by: list[str]


class _RefsErrorResponse(TypedDict):
refs: list[str]


def add_status_filter_to_queryset(
queryset: QuerySet[Release], status_filter: str
) -> QuerySet[Release]:
Expand Down Expand Up @@ -521,7 +525,11 @@ def get(
)
def put(
self, request: Request, organization: Organization, version: str
) -> Response[ReleaseSerializerResponse] | Response[ValidationErrorResponse]:
) -> (
Response[ReleaseSerializerResponse]
| Response[_RefsErrorResponse]
| Response[ValidationErrorResponse]
):
"""

Update a release. This can change some metadata associated with
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/sentry_apps/api/endpoints/sentry_app_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from sentry.apidocs.parameters import SentryAppParams
from sentry.apidocs.response_types import (
DetailResponse,
NonFieldErrorsResponse,
ValidationErrorResponse,
as_validation_errors,
)
Expand Down Expand Up @@ -125,6 +126,7 @@ def put(
) -> (
Response[SentryAppSerializerResponse]
| Response[DetailResponse]
| Response[NonFieldErrorsResponse]
| Response[ValidationErrorResponse]
):
"""
Expand Down
117 changes: 117 additions & 0 deletions tests/tools/mypy_helpers/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,123 @@ def view() -> Response[FooResponse] | Response[StatsPeriodErrorResponse]:
assert ret == 0, out


_LOOSE_ARM_SRC = """\
from typing import Any, Final, Literal, NotRequired, TypedDict
from rest_framework.response import Response

RUN_IN_FLIGHT: Final = "run_in_flight"

class ConflictResponse(TypedDict):
detail: str
code: NotRequired[Literal["run_in_flight", "repos_not_linked"]]

class DetailResponse(TypedDict):
detail: str

def view() -> (
Response[ConflictResponse] | Response[DetailResponse] | Response[dict[str, Any]]
):
return Response({BODY}, status=409)
"""


def test_response_union_loose_arm_rejects_wrong_literal_value() -> None:
"""A `dict[str, Any]` arm (e.g. `ValidationErrorResponse`) accepts any
dict literal, so a body with a `code` outside its declared `Literal` used
to type-check through it. It must fail against the TypedDict arms."""
src = _LOOSE_ARM_SRC.replace("{BODY}", '{"detail": "x", "code": "bogus"}')
ret, out = call_mypy(src)
assert ret, out
assert "[response-body]" in out
assert '"ConflictResponse", "DetailResponse"' in out


def test_response_union_loose_arm_rejects_typo_key() -> None:
src = _LOOSE_ARM_SRC.replace("{BODY}", '{"detial": "x"}')
ret, out = call_mypy(src)
assert ret, out
assert "[response-body]" in out


def test_response_union_loose_arm_accepts_matching_literals() -> None:
for body in (
'{"detail": "x", "code": "run_in_flight"}',
'{"detail": "x", "code": RUN_IN_FLIGHT}',
'{"detail": "x"}',
):
ret, out = call_mypy(_LOOSE_ARM_SRC.replace("{BODY}", body))
assert ret == 0, (body, out)


def test_response_union_loose_arm_accepts_annotated_variable() -> None:
"""Bodies that are deliberately loose opt out by not being a literal."""
src = """\
from typing import Any, TypedDict
from rest_framework.response import Response

class DetailResponse(TypedDict):
detail: str

def view() -> Response[DetailResponse] | Response[dict[str, Any]]:
errors: dict[str, Any] = {"field": ["This field is required."]}
return Response(errors, status=400)
"""
ret, out = call_mypy(src)
assert ret == 0, out


def test_response_union_nested_dict_literal_matches_nested_typeddict() -> None:
"""Nested dict literals are inferred against the nested TypedDict field,
so a correct nested body matches its arm instead of being reported."""
src = """\
from typing import Any, TypedDict
from rest_framework.response import Response

class CodedErrorDetail(TypedDict):
message: str
code: str

class CodedErrorResponse(TypedDict):
detail: CodedErrorDetail

class DetailResponse(TypedDict):
detail: str

def view(flag: bool) -> (
Response[CodedErrorResponse] | Response[DetailResponse] | Response[dict[str, Any]]
):
if flag:
return Response({"detail": {"message": "m", "code": "c"}}, status=400)
return Response({"detail": {"message": "m", "cod": "c"}}, status=400)
"""
ret, out = call_mypy(src)
assert ret, out
assert out.count("[response-body]") == 1, out
assert ":19:" in out, out


def test_response_union_no_loose_arm_reports_once() -> None:
"""Without a loose arm mypy already reports the mismatch, so the plugin
adds no second error."""
src = """\
from typing import TypedDict
from rest_framework.response import Response

class FooResponse(TypedDict):
x: int

class DetailResponse(TypedDict):
detail: str

def view() -> Response[FooResponse] | Response[DetailResponse]:
return Response({"wrong": "shape"}, status=400)
"""
ret, out = call_mypy(src)
assert ret, out
assert "Incompatible return value type" in out
assert "[response-body]" not in out


# -- Serializer auto-derive ----------------------------------------------------


Expand Down
Loading
Loading