Skip to content

Commit 749c332

Browse files
authored
feat(typing): support typing.ReadOnly for TypedDict schemas (#4424)
1 parent d478219 commit 749c332

File tree

4 files changed

+69
-6
lines changed

4 files changed

+69
-6
lines changed

docs/release-notes/changelog.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,19 @@
261261
difference is expected. Some test cases may break though if they relied on
262262
the fact that the middleware wrapper created by ``ASGIMiddleware`` was
263263
always being called
264+
265+
.. change:: Support for ``typing.ReadOnly`` in typed dict schemas
266+
:type: feature
267+
:issue: 4423
268+
:pr: 4424
269+
270+
Support unwrapping ``ReadOnly`` type in schemas like:
271+
272+
.. code:: python
273+
274+
from typing import ReadOnly, TypedDict
275+
276+
class User(TypedDict):
277+
id: ReadOnly[int]
278+
279+
``typing_extensions.ReadOnly`` should be used for python versions <3.13.

litestar/utils/typing.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,16 @@
3636
cast,
3737
)
3838

39-
from typing_extensions import Annotated, NewType, NotRequired, Required, get_args, get_origin, get_type_hints
39+
from typing_extensions import (
40+
Annotated,
41+
NewType,
42+
NotRequired,
43+
ReadOnly,
44+
Required,
45+
get_args,
46+
get_origin,
47+
get_type_hints,
48+
)
4049

4150
from litestar.types.builtin_types import NoneType, UnionTypes
4251

@@ -122,7 +131,7 @@
122131
different args, and types.
123132
"""
124133

125-
wrapper_type_set = {Annotated, Required, NotRequired}
134+
wrapper_type_set = {Annotated, Required, NotRequired, ReadOnly}
126135
"""Types that always contain a wrapped type annotation as their first arg."""
127136

128137

@@ -145,7 +154,9 @@ def make_non_optional_union(annotation: UnionT | None) -> UnionT:
145154

146155

147156
def unwrap_annotation(annotation: Any) -> tuple[Any, tuple[Any, ...], set[Any]]:
148-
"""Remove "wrapper" annotation types, such as ``Annotated``, ``Required``, and ``NotRequired``.
157+
"""Remove "wrapper" annotation types.
158+
159+
Such as ``Annotated``, ``ReadOnly``, ``Required``, and ``NotRequired``.
149160
150161
Note:
151162
``annotation`` should have been retrieved from :func:`get_type_hints()` with ``include_extras=True``. This

tests/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import msgspec
77
from polyfactory.factories import DataclassFactory
8-
from typing_extensions import NotRequired, Required, TypedDict
8+
from typing_extensions import NotRequired, ReadOnly, Required, TypedDict
99

1010

1111
class Species(str, Enum):
@@ -35,7 +35,7 @@ class DataclassPerson:
3535
class TypedDictPerson(TypedDict):
3636
first_name: Required[str]
3737
last_name: Required[str]
38-
id: Required[str]
38+
id: Required[ReadOnly[str]]
3939
optional: NotRequired[Optional[str]]
4040
complex: Required[dict[str, list[dict[str, str]]]]
4141
pets: NotRequired[Optional[list[DataclassPet]]]

tests/unit/test_openapi/test_request_body.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from dataclasses import dataclass
2-
from typing import Annotated, Any, Callable
2+
from typing import Annotated, Any, Callable, TypedDict
33
from unittest.mock import ANY, MagicMock
44

55
import pytest
6+
from typing_extensions import ReadOnly
67

78
from litestar import Controller, Litestar, get, post
89
from litestar._openapi.datastructures import OpenAPIContext
@@ -181,3 +182,38 @@ async def handler(data: dict[str, Any]) -> None:
181182
mock_dto.create_openapi_schema.assert_called_once_with(
182183
field_definition=field_definition, handler_id=resolved_handler.handler_id, schema_creator=ANY
183184
)
185+
186+
187+
def test_unwrap_read_only() -> None:
188+
class SchemaDict(TypedDict):
189+
id: ReadOnly[int]
190+
email: str
191+
192+
@post("/")
193+
async def handler(
194+
data: SchemaDict,
195+
) -> SchemaDict:
196+
return {"id": data["id"], "email": "[email protected]"}
197+
198+
app = Litestar([handler])
199+
schema = app.openapi_schema.to_schema()
200+
201+
assert schema["paths"]["/"]["post"]["requestBody"]["content"]["application/json"] == {
202+
"schema": {"$ref": "#/components/schemas/test_unwrap_read_only.SchemaDict"}
203+
}
204+
assert schema["paths"]["/"]["post"]["responses"]["201"]["content"]["application/json"] == {
205+
"schema": {"$ref": "#/components/schemas/test_unwrap_read_only.SchemaDict"}
206+
}
207+
assert schema["components"] == {
208+
"schemas": {
209+
"test_unwrap_read_only.SchemaDict": {
210+
"properties": {
211+
"id": {"type": "integer"},
212+
"email": {"type": "string"},
213+
},
214+
"type": "object",
215+
"required": ["email", "id"],
216+
"title": "SchemaDict",
217+
}
218+
}
219+
}

0 commit comments

Comments
 (0)