Skip to content

Commit 9f9e840

Browse files
patrick91ampagent
andauthored
Support Annotated field configuration everywhere (#4594)
Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent 0230373 commit 9f9e840

10 files changed

Lines changed: 639 additions & 84 deletions

File tree

RELEASE.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
release type: patch
3+
social_messages:
4+
x: >-
5+
{project_name} {version} is out! Fields defined with typing.Annotated now
6+
support the full strawberry.field API across object, input, and interface
7+
types.
8+
https://strawberry.rocks/release/{version}
9+
linkedin: >-
10+
{project_name} {version} is out. You can now configure fields with
11+
strawberry.field inside typing.Annotated consistently across object, input,
12+
and interface types, including defaults and combined metadata.
13+
---
14+
15+
This release fixes fields configured with `strawberry.field()` inside
16+
`typing.Annotated`.
17+
18+
You can now use this syntax consistently on object types, input types, and
19+
interfaces, including in projects that use `from __future__ import annotations`:
20+
21+
```python
22+
from typing import Annotated
23+
24+
import strawberry
25+
26+
Name = Annotated[
27+
str,
28+
strawberry.field(name="displayName", default="Anonymous"),
29+
]
30+
31+
32+
@strawberry.type
33+
class User:
34+
name: Name
35+
```
36+
37+
All `strawberry.field()` options are supported. Fields with `default` or
38+
`default_factory` can be omitted when creating an instance, and field
39+
configuration can be combined with other Strawberry metadata such as named
40+
unions.

docs/types/object-types.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,74 @@ type Book {
9090

9191
</CodeGrid>
9292

93+
## Customizing fields with `Annotated`
94+
95+
You can configure fields by adding `strawberry.field()` to
96+
[`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated).
97+
This syntax works on object types, input types, and interfaces, including when
98+
using `from __future__ import annotations`:
99+
100+
```python
101+
from typing import Annotated
102+
103+
import strawberry
104+
105+
106+
@strawberry.type
107+
class User:
108+
name: Annotated[
109+
str,
110+
strawberry.field(name="displayName", description="The displayed name"),
111+
]
112+
tags: Annotated[list[str], strawberry.field(default_factory=list)]
113+
```
114+
115+
All `strawberry.field()` options are supported. In particular, `default` and
116+
`default_factory` also configure the generated dataclass constructor, so
117+
`User(name="Patrick")` in the example above gets a new empty `tags` list.
118+
119+
On Python 3.10 through 3.13, use `strawberry.lazy()` when the field type is only
120+
imported under `TYPE_CHECKING` or otherwise unavailable at runtime. This form
121+
works together with field metadata:
122+
123+
```python
124+
from typing import TYPE_CHECKING, Annotated
125+
126+
import strawberry
127+
128+
if TYPE_CHECKING:
129+
from .users import User
130+
131+
132+
@strawberry.type
133+
class Post:
134+
author: Annotated[
135+
"User",
136+
strawberry.lazy(".users"),
137+
strawberry.field(description="The post author"),
138+
]
139+
```
140+
141+
Python 3.14 and newer can also preserve the field metadata on a direct
142+
unresolved reference without `strawberry.lazy()`.
143+
144+
The field configuration can be combined with other Strawberry metadata. The
145+
order of the metadata does not matter:
146+
147+
```python
148+
@strawberry.type
149+
class Query:
150+
result: Annotated[
151+
Success | Failure,
152+
strawberry.union("Result"),
153+
strawberry.field(description="The operation result"),
154+
]
155+
```
156+
157+
Use only one `strawberry.field()` for each field. You can alternatively use the
158+
equivalent assignment syntax, such as
159+
`name: str = strawberry.field(description="The displayed name")`.
160+
93161
## API
94162

95163
`@strawberry.type(name: str = None, description: str = None)`

strawberry/types/field.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,18 +174,17 @@ def __copy__(self) -> Self:
174174
is_subscription=self.is_subscription,
175175
description=self.description,
176176
base_resolver=self.base_resolver,
177-
permission_classes=(
178-
self.permission_classes[:]
179-
if self.permission_classes is not None
180-
else []
181-
),
182-
default=self.default_value,
177+
permission_classes=[],
178+
default=self.default,
183179
default_factory=self.default_factory,
184180
metadata=self.metadata.copy() if self.metadata is not None else None,
185181
deprecation_reason=self.deprecation_reason,
186182
directives=self.directives[:] if self.directives is not None else [],
187183
extensions=self.extensions[:] if self.extensions is not None else [],
188184
)
185+
new_field.permission_classes = (
186+
self.permission_classes[:] if self.permission_classes is not None else []
187+
)
189188
new_field._arguments = (
190189
self._arguments[:] if self._arguments is not None else None
191190
)

strawberry/types/object_type.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,32 @@
22
import copy
33
import dataclasses
44
import inspect
5+
import sys
56
import types
67
from collections.abc import Callable, Sequence
78
from typing import (
9+
Annotated,
810
Any,
11+
ForwardRef,
912
TypeVar,
1013
cast,
14+
get_args,
15+
get_origin,
1116
overload,
1217
)
13-
from typing_extensions import dataclass_transform, get_annotations
18+
from typing_extensions import (
19+
Format,
20+
dataclass_transform,
21+
evaluate_forward_ref,
22+
get_annotations,
23+
)
1424

25+
from strawberry.annotation import StrawberryAnnotation
1526
from strawberry.exceptions import (
1627
InvalidSuperclassInterfaceError,
1728
MissingFieldAnnotationError,
1829
MissingReturnAnnotationError,
30+
MultipleStrawberryFieldsError,
1931
ObjectIsNotClassError,
2032
)
2133
from strawberry.types.base import get_object_definition
@@ -30,6 +42,103 @@
3042
T = TypeVar("T", bound=builtins.type)
3143

3244

45+
def _process_annotated_fields(cls: T) -> dict[str, StrawberryAnnotation]:
46+
"""Make StrawberryFields in Annotated available to dataclasses."""
47+
module_namespace = sys.modules[cls.__module__].__dict__
48+
type_annotations: dict[str, StrawberryAnnotation] = {}
49+
50+
annotations = get_annotations(cls, format=Format.FORWARDREF)
51+
52+
for field_name, raw_annotation in annotations.items():
53+
annotation: object
54+
if isinstance(raw_annotation, str):
55+
try:
56+
annotation = evaluate_forward_ref(
57+
ForwardRef(raw_annotation),
58+
owner=cls,
59+
globals=module_namespace,
60+
locals=dict(vars(cls)),
61+
format=Format.FORWARDREF,
62+
)
63+
if isinstance(annotation, ForwardRef):
64+
annotation = StrawberryAnnotation(
65+
raw_annotation,
66+
namespace=module_namespace,
67+
).evaluate()
68+
except (NameError, TypeError):
69+
continue
70+
else:
71+
annotation = raw_annotation
72+
73+
if get_origin(annotation) is not Annotated:
74+
continue
75+
76+
first, *rest = get_args(annotation)
77+
strawberry_fields = [arg for arg in rest if isinstance(arg, StrawberryField)]
78+
79+
if len(strawberry_fields) > 1 or (
80+
strawberry_fields
81+
and isinstance(cls.__dict__.get(field_name), StrawberryField)
82+
):
83+
raise MultipleStrawberryFieldsError(field_name=field_name, cls=cls)
84+
85+
if not strawberry_fields:
86+
continue
87+
88+
source_field = strawberry_fields[0]
89+
field = copy.copy(source_field)
90+
91+
default = cls.__dict__.get(field_name, dataclasses.MISSING)
92+
if (
93+
field.default is dataclasses.MISSING
94+
and field.default_factory is dataclasses.MISSING
95+
and default is not dataclasses.MISSING
96+
):
97+
if isinstance(default, dataclasses.Field):
98+
field.default = default.default
99+
field.default_factory = default.default_factory
100+
if default.default is not dataclasses.MISSING:
101+
field.default_value = default.default
102+
elif callable(default.default_factory):
103+
field.default_value = default.default_factory()
104+
else:
105+
field.default = default
106+
field.default_value = default
107+
108+
remaining_metadata = [
109+
arg for arg in rest if not isinstance(arg, StrawberryField)
110+
]
111+
field_type = (
112+
field.type_annotation.raw_annotation
113+
if field.type_annotation is not None
114+
else first
115+
)
116+
field_type = (
117+
Annotated[(field_type, *remaining_metadata)]
118+
if remaining_metadata
119+
else field_type
120+
)
121+
type_annotation = (
122+
field.type_annotation
123+
if field.type_annotation is not None and not remaining_metadata
124+
else StrawberryAnnotation(
125+
field_type,
126+
namespace=(
127+
field.type_annotation.namespace
128+
if field.type_annotation is not None
129+
and field.type_annotation.namespace is not None
130+
else module_namespace
131+
),
132+
)
133+
)
134+
field.type_annotation = type_annotation
135+
136+
setattr(cls, field_name, field)
137+
type_annotations[field_name] = type_annotation
138+
139+
return type_annotations
140+
141+
33142
def _get_interfaces(cls: builtins.type[Any]) -> list[StrawberryObjectDefinition]:
34143
interfaces: list[StrawberryObjectDefinition] = []
35144
for base in cls.__mro__[1:]: # Exclude current class
@@ -108,9 +217,19 @@ def _check_field_annotations(cls: builtins.type[Any]) -> None:
108217

109218
def _wrap_dataclass(cls: T) -> T:
110219
"""Wrap a strawberry.type class with a dataclass and check for any issues before doing so."""
220+
annotated_field_types = _process_annotated_fields(cls)
221+
111222
# Ensure all Fields have been properly type-annotated
112223
_check_field_annotations(cls)
113-
return cast("T", dataclasses.dataclass(kw_only=True)(cls))
224+
wrapped = cast("T", dataclasses.dataclass(kw_only=True)(cls))
225+
226+
# dataclasses replaces each StrawberryField's type with the class annotation.
227+
# Restore the type with only the StrawberryField metadata removed, keeping any
228+
# other Annotated metadata and explicit graphql_type override intact.
229+
for field_name, type_annotation in annotated_field_types.items():
230+
wrapped.__dataclass_fields__[field_name].type_annotation = type_annotation # type: ignore[attr-defined]
231+
232+
return wrapped
114233

115234

116235
def _inject_default_for_maybe_annotations(cls: T, annotations: dict[str, Any]) -> None:

0 commit comments

Comments
 (0)