Skip to content

Commit 2ae9071

Browse files
patrick91bellini666pre-commit-ci[bot]
authored
Add support for defining fields using Annotated (#4059)
Co-authored-by: Thiago Bellini Ribeiro <thiago@bellini.dev> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent df9f3fc commit 2ae9071

File tree

6 files changed

+477
-11
lines changed

6 files changed

+477
-11
lines changed

RELEASE.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Release type: minor
2+
3+
This release adds support for defining fields using the `Annotated` syntax. This provides an
4+
alternative way to specify field metadata alongside the type annotation.
5+
6+
Example usage:
7+
8+
```python
9+
from typing import Annotated
10+
11+
import strawberry
12+
13+
14+
@strawberry.type
15+
class Query:
16+
name: Annotated[str, strawberry.field(description="The name")]
17+
age: Annotated[int, strawberry.field(deprecation_reason="Use birthDate instead")]
18+
19+
20+
@strawberry.input
21+
class CreateUserInput:
22+
name: Annotated[str, strawberry.field(description="User's name")]
23+
email: Annotated[str, strawberry.field(description="User's email")]
24+
```
25+
26+
This syntax works alongside the existing assignment syntax:
27+
28+
```python
29+
@strawberry.type
30+
class Query:
31+
# Both styles work
32+
field1: Annotated[str, strawberry.field(description="Using Annotated")]
33+
field2: str = strawberry.field(description="Using assignment")
34+
```
35+
36+
All `strawberry.field()` options are supported including `description`, `name`,
37+
`deprecation_reason`, `directives`, `metadata`, and `permission_classes`.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: Multiple Strawberry Fields Error
3+
---
4+
5+
# Multiple Strawberry Fields Error
6+
7+
## Description
8+
9+
This error is raised when using multiple `strawberry.field()` annotations inside
10+
an `Annotated` type. For example, the following code will raise this error:
11+
12+
```python
13+
from typing import Annotated
14+
15+
import strawberry
16+
17+
18+
@strawberry.type
19+
class Query:
20+
name: Annotated[
21+
str,
22+
strawberry.field(description="First"),
23+
strawberry.field(description="Second"),
24+
]
25+
26+
27+
schema = strawberry.Schema(query=Query)
28+
```
29+
30+
This happens because Strawberry only allows one `strawberry.field()` per field
31+
when using the `Annotated` syntax. Having multiple would create ambiguity about
32+
which field configuration to use.
33+
34+
## How to fix this error
35+
36+
You can fix this error by using only one `strawberry.field()` in your
37+
`Annotated` type annotation. Combine all the options you need into a single
38+
`strawberry.field()` call:
39+
40+
```python
41+
from typing import Annotated
42+
43+
import strawberry
44+
45+
46+
@strawberry.type
47+
class Query:
48+
name: Annotated[
49+
str,
50+
strawberry.field(
51+
description="The name",
52+
deprecation_reason="Use fullName instead",
53+
),
54+
]
55+
56+
57+
schema = strawberry.Schema(query=Query)
58+
```

strawberry/exceptions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .missing_dependencies import MissingOptionalDependenciesError
1717
from .missing_field_annotation import MissingFieldAnnotationError
1818
from .missing_return_annotation import MissingReturnAnnotationError
19+
from .multiple_strawberry_fields import MultipleStrawberryFieldsError
1920
from .object_is_not_a_class import ObjectIsNotClassError
2021
from .object_is_not_an_enum import ObjectIsNotAnEnumError
2122
from .private_strawberry_field import PrivateStrawberryFieldError
@@ -185,6 +186,7 @@ def __init__(self, payload: dict[str, object] | None = None) -> None:
185186
"MissingReturnAnnotationError",
186187
"MissingTypesForGenericError",
187188
"MultipleStrawberryArgumentsError",
189+
"MultipleStrawberryFieldsError",
188190
"ObjectIsNotAnEnumError",
189191
"ObjectIsNotClassError",
190192
"PrivateStrawberryFieldError",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
from functools import cached_property
4+
from typing import TYPE_CHECKING
5+
6+
from .exception import StrawberryException
7+
from .utils.source_finder import SourceFinder
8+
9+
if TYPE_CHECKING:
10+
from .exception_source import ExceptionSource
11+
12+
13+
class MultipleStrawberryFieldsError(StrawberryException):
14+
def __init__(self, field_name: str, cls: type) -> None:
15+
self.cls = cls
16+
self.field_name = field_name
17+
18+
self.message = (
19+
f"Annotation for field `{field_name}` on type `{cls.__name__}` "
20+
"cannot have multiple `strawberry.field`s"
21+
)
22+
self.rich_message = (
23+
f"Field `[underline]{self.field_name}[/]` on type "
24+
f"`[underline]{self.cls.__name__}[/]` cannot have multiple "
25+
"`strawberry.field`s"
26+
)
27+
self.annotation_message = "field with multiple strawberry.field annotations"
28+
self.suggestion = (
29+
"To fix this error you should use only one `strawberry.field()` "
30+
"in the `Annotated` type annotation."
31+
)
32+
33+
super().__init__(self.message)
34+
35+
@cached_property
36+
def exception_source(self) -> ExceptionSource | None:
37+
if self.cls is None:
38+
return None # pragma: no cover
39+
40+
source_finder = SourceFinder()
41+
42+
return source_finder.find_class_attribute_from_object(self.cls, self.field_name)

strawberry/types/type_resolver.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from __future__ import annotations
22

3+
import copy
34
import dataclasses
45
import sys
5-
from typing import Any
6+
from typing import Annotated, Any, get_args, get_origin
67

78
from strawberry.annotation import StrawberryAnnotation
89
from strawberry.exceptions import (
910
FieldWithResolverAndDefaultFactoryError,
1011
FieldWithResolverAndDefaultValueError,
12+
MultipleStrawberryFieldsError,
1113
PrivateStrawberryFieldError,
1214
)
1315
from strawberry.types.base import has_object_definition
@@ -16,6 +18,49 @@
1618
from strawberry.types.unset import UNSET
1719

1820

21+
def _get_field_from_annotated(
22+
field: dataclasses.Field,
23+
origin: type,
24+
module_namespace: dict[str, Any],
25+
cls: type,
26+
) -> StrawberryField | None:
27+
"""Extract a StrawberryField from an Annotated type annotation.
28+
29+
Returns a configured StrawberryField if the annotation contains one,
30+
or None if no StrawberryField is found in the Annotated args.
31+
Raises MultipleStrawberryFieldsError if more than one is found.
32+
"""
33+
field_type = field.type
34+
35+
if get_origin(field_type) is not Annotated:
36+
return None
37+
38+
first, *rest = get_args(field_type)
39+
40+
strawberry_fields = [arg for arg in rest if isinstance(arg, StrawberryField)]
41+
42+
if len(strawberry_fields) > 1:
43+
raise MultipleStrawberryFieldsError(field_name=field.name, cls=cls)
44+
45+
if not strawberry_fields:
46+
return None
47+
48+
result = copy.copy(strawberry_fields[0])
49+
result.python_name = field.name
50+
result.type_annotation = StrawberryAnnotation(
51+
annotation=first,
52+
namespace=module_namespace,
53+
)
54+
result.origin = origin
55+
56+
# Transfer default from dataclass field if not set in strawberry.field()
57+
if result.default is dataclasses.MISSING:
58+
result.default = field.default
59+
result.default_value = field.default
60+
61+
return result
62+
63+
1964
def _get_fields(
2065
cls: type[Any], original_type_annotations: dict[str, type[Any]]
2166
) -> list[StrawberryField]:
@@ -82,6 +127,19 @@ class if one is not set by either using an explicit strawberry.field(name=...) o
82127
# then we can proceed with finding the fields for the current class
83128
for field in dataclasses.fields(cls): # type: ignore
84129
if isinstance(field, StrawberryField):
130+
# Check for conflict: strawberry.field in both Annotated and assignment
131+
annotation = (
132+
field.type_annotation.annotation
133+
if isinstance(field.type_annotation, StrawberryAnnotation)
134+
else field.type
135+
)
136+
if get_origin(annotation) is Annotated:
137+
annotated_args = get_args(annotation)
138+
if any(isinstance(arg, StrawberryField) for arg in annotated_args[1:]):
139+
raise MultipleStrawberryFieldsError(
140+
field_name=field.python_name or field.name, cls=cls
141+
)
142+
85143
# Check that the field type is not Private
86144
if is_private(field.type):
87145
raise PrivateStrawberryFieldError(field.python_name, cls)
@@ -140,18 +198,24 @@ class if one is not set by either using an explicit strawberry.field(name=...) o
140198
origin = origins.get(field.name, cls)
141199
module = sys.modules[origin.__module__]
142200

143-
# Create a StrawberryField, for fields of Types #1 and #2a
144-
field = StrawberryField( # noqa: PLW2901
145-
python_name=field.name,
146-
graphql_name=None,
147-
type_annotation=StrawberryAnnotation(
148-
annotation=field.type,
149-
namespace=module.__dict__,
150-
),
151-
origin=origin,
152-
default=getattr(cls, field.name, dataclasses.MISSING),
201+
annotated_field = _get_field_from_annotated(
202+
field, origin, module.__dict__, cls
153203
)
154204

205+
if annotated_field is not None:
206+
field = annotated_field # noqa: PLW2901
207+
else:
208+
field = StrawberryField( # noqa: PLW2901
209+
python_name=field.name,
210+
graphql_name=None,
211+
type_annotation=StrawberryAnnotation(
212+
annotation=field.type,
213+
namespace=module.__dict__,
214+
),
215+
origin=origin,
216+
default=getattr(cls, field.name, dataclasses.MISSING),
217+
)
218+
155219
field_name = field.python_name
156220

157221
assert_message = "Field must have a name by the time the schema is generated"

0 commit comments

Comments
 (0)