Skip to content

Commit a77a05e

Browse files
committed
Improve APIException input and detail types
APIException and its subclasses are more liberal in what they accept as input, but their `detail` attribute is already converted to a stricter type: translation strings are resolved, strings are conveted to ErrorDetail, etc This uses the recursive types feature from mypy and thus bumps minimum mypy version to 0.991: https://mypy-lang.blogspot.com/2022/11/mypy-0990-released.html
1 parent 8a98af4 commit a77a05e

File tree

4 files changed

+66
-17
lines changed

4 files changed

+66
-17
lines changed

Diff for: rest_framework-stubs/exceptions.pyi

+32-16
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,74 @@
1-
from collections.abc import Sequence
1+
from collections.abc import Mapping, Sequence
22
from typing import Any
33

44
from django.http import HttpRequest, JsonResponse
55
from django_stubs_ext import StrOrPromise
66
from rest_framework.renderers import BaseRenderer
77
from rest_framework.request import Request
8-
from typing_extensions import TypeAlias
9-
10-
def _get_error_details(data: Any, default_code: str | None = ...) -> Any: ...
11-
def _get_codes(detail: Any) -> Any: ...
12-
def _get_full_details(detail: Any) -> Any: ...
8+
from typing_extensions import TypeAlias, TypedDict
139

1410
class ErrorDetail(str):
1511
code: str | None
1612
def __new__(cls, string: str, code: str | None = ...): ...
1713

18-
_Detail: TypeAlias = StrOrPromise | list[Any] | dict[str, Any]
14+
_Detail: TypeAlias = ErrorDetail | list[ErrorDetail] | dict[str, ErrorDetail]
15+
# NB! _APIExceptionInput doesn't technically handle Sequence/Mapping, but only list/tuple/dict.
16+
# But since list/tuple are non-covariant types, we run into issues with union type compatibility for input params.
17+
# So use the more relaxed Sequence/Mapping for now.
18+
_APIExceptionInput: TypeAlias = (
19+
_Detail | StrOrPromise | Sequence[_APIExceptionInput] | Mapping[str, _APIExceptionInput] | None
20+
)
21+
_ErrorCodes: TypeAlias = str | None | list[_ErrorCodes] | dict[str, _ErrorCodes]
22+
23+
class _FullDetailDict(TypedDict):
24+
message: ErrorDetail
25+
code: str | None
26+
27+
_ErrorFullDetails: TypeAlias = _FullDetailDict | list[_FullDetailDict] | dict[str, _FullDetailDict]
28+
29+
def _get_error_details(data: _APIExceptionInput, default_code: str | None = ...) -> _Detail: ...
30+
def _get_codes(detail: _Detail) -> _ErrorCodes: ...
31+
def _get_full_details(detail: _Detail) -> _ErrorFullDetails: ...
1932

2033
class APIException(Exception):
2134
status_code: int
22-
default_detail: _Detail
35+
default_detail: _APIExceptionInput
2336
default_code: str
2437

2538
detail: _Detail
26-
def __init__(self, detail: _Detail | None = ..., code: str | None = ...) -> None: ...
27-
def get_codes(self) -> Any: ...
28-
def get_full_details(self) -> Any: ...
39+
def __init__(self, detail: _APIExceptionInput = ..., code: str | None = ...) -> None: ...
40+
def get_codes(self) -> _ErrorCodes: ...
41+
def get_full_details(self) -> _ErrorFullDetails: ...
42+
43+
class ValidationError(APIException):
44+
# ValidationError always wraps `detail` in a list.
45+
detail: list[ErrorDetail] | dict[str, ErrorDetail]
2946

30-
class ValidationError(APIException): ...
3147
class ParseError(APIException): ...
3248
class AuthenticationFailed(APIException): ...
3349
class NotAuthenticated(APIException): ...
3450
class PermissionDenied(APIException): ...
3551
class NotFound(APIException): ...
3652

3753
class MethodNotAllowed(APIException):
38-
def __init__(self, method: str, detail: _Detail | None = ..., code: str | None = ...) -> None: ...
54+
def __init__(self, method: str, detail: _APIExceptionInput = ..., code: str | None = ...) -> None: ...
3955

4056
class NotAcceptable(APIException):
4157
available_renderers: Sequence[BaseRenderer] | None
4258
def __init__(
4359
self,
44-
detail: _Detail | None = ...,
60+
detail: _APIExceptionInput = ...,
4561
code: str | None = ...,
4662
available_renderers: Sequence[BaseRenderer] | None = ...,
4763
) -> None: ...
4864

4965
class UnsupportedMediaType(APIException):
50-
def __init__(self, media_type: str, detail: _Detail | None = ..., code: str | None = ...) -> None: ...
66+
def __init__(self, media_type: str, detail: _APIExceptionInput = ..., code: str | None = ...) -> None: ...
5167

5268
class Throttled(APIException):
5369
extra_detail_singular: str
5470
extra_detail_plural: str
55-
def __init__(self, wait: float | None = ..., detail: _Detail | None = ..., code: str | None = ...): ...
71+
def __init__(self, wait: float | None = ..., detail: _APIExceptionInput = ..., code: str | None = ...): ...
5672

5773
def server_error(request: HttpRequest | Request, *args: Any, **kwargs: Any) -> JsonResponse: ...
5874
def bad_request(request: HttpRequest | Request, exception: Exception, *args: Any, **kwargs: Any) -> JsonResponse: ...

Diff for: scripts/typecheck_tests.py

+9
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
'"_MonkeyPatchedWSGIResponse" has no attribute "data"',
5858
'" defined here',
5959
'" has no attribute "id"',
60+
'Invalid index type "int" for "Union[List[ErrorDetail], Dict[str, ErrorDetail]]"; expected type "str"',
61+
'Invalid index type "str" for "Union[ErrorDetail, List[ErrorDetail], Dict[str, ErrorDetail]]"; expected type "Union[SupportsIndex, slice]"', # noqa: E501
62+
'Invalid index type "int" for "Union[ErrorDetail, List[ErrorDetail], Dict[str, ErrorDetail]]"; expected type "str"', # noqa: E501
6063
],
6164
"authentication": [
6265
'Argument 1 to "post" of "APIClient" has incompatible type "None"; expected "str',
@@ -103,6 +106,12 @@
103106
'Argument 1 to "api_view" has incompatible type "Callable[[Any], Any]"; expected "Optional[Sequence[str]]"',
104107
],
105108
"test_encoders.py": ['Argument "serializer" to "ReturnList" has incompatible type "None'],
109+
"test_exceptions.py": [
110+
'error: No overload variant of "__getitem__" of "list" matches argument type "str"',
111+
"note: Possible overload variants:",
112+
"note: def __getitem__(self, SupportsIndex, /) -> ErrorDetail",
113+
"note: def __getitem__(self, slice, /) -> List[ErrorDetail]",
114+
],
106115
"test_fields.py": [
107116
'"ChoiceModel"',
108117
'Argument "validators" to "CharField" has incompatible type',

Diff for: setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def find_stub_files(name: str) -> List[str]:
2020
readme = f.read()
2121

2222
dependencies = [
23-
"mypy>=0.980",
23+
"mypy>=0.991",
2424
"django-stubs>=1.13.0",
2525
"typing-extensions>=3.10.0",
2626
"requests>=2.0.0",

Diff for: tests/typecheck/test_exceptions.yml

+24
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
status_code = 200
1616
default_detail = {"ok": "everything"}
1717
default_code = "ok"
18+
1819
- case: test_exception_declaration_lazystr
1920
main: |
2021
from django.utils.translation import gettext_lazy as _
@@ -24,3 +25,26 @@
2425
status_code = 200
2526
default_detail = _("Está tudo bem")
2627
default_code = "ok"
28+
29+
- case: test_exception_input
30+
main: |
31+
from django.utils.translation import gettext_lazy as _
32+
from rest_framework.exceptions import APIException, ErrorDetail
33+
34+
test_exception = APIException({
35+
'a': [
36+
'value',
37+
_('translated'),
38+
ErrorDetail('with code', code='brown'),
39+
{'b': 'test', 'c': _('translated')},
40+
('also', 'tuple', ErrorDetail('value')),
41+
]
42+
})
43+
APIException(detail=test_exception.detail, code='123')
44+
APIException(detail=[test_exception.detail], code='123')
45+
APIException('I am just a message', code='msg')
46+
APIException()
47+
APIException(None, None)
48+
APIException(...) # E: Argument 1 to "APIException" has incompatible type "ellipsis"; expected "_APIExceptionInput"
49+
APIException({'a': ...}) # E: Dict entry 0 has incompatible type "str": "ellipsis"; expected "str": "Union[Sequence[_APIExceptionInput], Mapping[str, _APIExceptionInput], None]"
50+
APIException({'a': ['test', ...]}) # E: List item 1 has incompatible type "ellipsis"; expected "Union[Sequence[_APIExceptionInput], Mapping[str, _APIExceptionInput], None]"

0 commit comments

Comments
 (0)