Skip to content

Commit 5e5e3d3

Browse files
committed
api: Avoid programming errors due to nested Annotated types.
We want to reject ambiguous type annotations that set ApiParamConfig inside a Union. If a parameter is Optional and has a default of None, we prefer Annotated[Optional[T], ...] over Optional[Annotated[T, ...]]. This implements a check that detects Optional[Annotated[T, ...]] and raise an assertion error if ApiParamConfig is in the annotation. It also checks if the type annotation contains any ApiParamConfig objects that are ignored, which can happen if the Annotated type is nested inside another type like List, Union, etc. Note that because param: Annotated[Optional[T], ...] = None and param: Optional[Annotated[Optional[T], ...]] = None are equivalent in runtime prior to Python 3.11, there is no way for us to distinguish the two. So we cannot detect that in runtime. See also: python/cpython#90353
1 parent 5f15703 commit 5e5e3d3

File tree

3 files changed

+140
-9
lines changed

3 files changed

+140
-9
lines changed

tools/semgrep.yml

+23
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,26 @@ rules:
186186
or use @typed_endpoint_without_parameters instead.
187187
languages: [python]
188188
severity: ERROR
189+
190+
- id: dont-nest-annotated-types-with-param-config
191+
patterns:
192+
- pattern-not: |
193+
def $F(..., invalid_param: typing.Optional[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>], ...) -> ...:
194+
...
195+
- pattern-not: |
196+
def $F(..., $A: typing_extensions.Annotated[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>], ...) -> ...:
197+
...
198+
- pattern-not: |
199+
def $F(..., $A: typing_extensions.Annotated[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>] = ..., ...) -> ...:
200+
...
201+
- pattern-either:
202+
- pattern: |
203+
def $F(..., $A: $B[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>], ...) -> ...:
204+
...
205+
- pattern: |
206+
def $F(..., $A: $B[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>] = ..., ...) -> ...:
207+
...
208+
message: |
209+
Annotated types containing zerver.lib.typed_endpoint.ApiParamConfig should not be nested inside Optional. Use Annotated[Optional[...], zerver.lib.typed_endpoint.ApiParamConfig(...)] instead.
210+
languages: [python]
211+
severity: ERROR

zerver/lib/typed_endpoint.py

+62-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import inspect
22
import json
3+
import sys
34
from dataclasses import dataclass
45
from enum import Enum, auto
56
from functools import wraps
@@ -157,6 +158,41 @@ def is_annotated(type_annotation: Type[object]) -> bool:
157158
return origin is Annotated
158159

159160

161+
def is_optional(type_annotation: Type[object]) -> bool:
162+
origin = get_origin(type_annotation)
163+
type_args = get_args(type_annotation)
164+
return origin is Union and type(None) in type_args and len(type_args) == 2
165+
166+
167+
API_PARAM_CONFIG_USAGE_HINT = f"""
168+
Detected incorrect usage of Annotated types for parameter {{param_name}}!
169+
Check the placement of the {ApiParamConfig.__name__} object in the type annotation:
170+
171+
{{param_name}}: {{param_type}}
172+
173+
The Annotated[T, ...] type annotation containing the
174+
{ApiParamConfig.__name__} object should not be nested inside another type.
175+
176+
Correct examples:
177+
178+
# Using Optional inside Annotated
179+
param: Annotated[Optional[int], ApiParamConfig(...)]
180+
param: Annotated[Optional[int], ApiParamConfig(...)]] = None
181+
182+
# Not using Optional when the default is not None
183+
param: Annotated[int, ApiParamConfig(...)]
184+
185+
Incorrect examples:
186+
187+
# Nesting Annotated inside Optional
188+
param: Optional[Annotated[int, ApiParamConfig(...)]]
189+
param: Optional[Annotated[int, ApiParamConfig(...)]] = None
190+
191+
# Nesting the Annotated type carrying ApiParamConfig inside other types like Union
192+
param: Union[str, Annotated[int, ApiParamConfig(...)]]
193+
"""
194+
195+
160196
def parse_single_parameter(
161197
param_name: str, param_type: Type[T], parameter: inspect.Parameter
162198
) -> FuncParam[T]:
@@ -171,26 +207,45 @@ def parse_single_parameter(
171207
# otherwise causes undesired behaviors that the annotated metadata gets
172208
# lost. This is fixed in Python 3.11:
173209
# https://github.com/python/cpython/issues/90353
174-
if param_default is None:
175-
origin = get_origin(param_type)
210+
if (
211+
sys.version_info < (3, 11) and param_default is None
212+
): # nocoverage # We lose coverage of this with Python 3.11+ only
176213
type_args = get_args(param_type)
177-
if origin is Union and type(None) in type_args and len(type_args) == 2:
178-
inner_type = type_args[0] if type_args[1] is type(None) else type_args[1]
179-
if is_annotated(inner_type):
180-
param_type = inner_type
214+
assert is_optional(param_type)
215+
inner_type = type_args[0] if type_args[1] is type(None) else type_args[1]
216+
if is_annotated(inner_type):
217+
annotated_type, *annotations = get_args(inner_type)
218+
has_api_param_config = any(
219+
isinstance(annotation, ApiParamConfig) for annotation in annotations
220+
)
221+
# This prohibits the use of `Optional[Annotated[T, ApiParamConfig(...)]] = None`
222+
# and encourage `Annotated[Optional[T], ApiParamConfig(...)] = None`
223+
# to avoid confusion when the parameter metadata is unintentionally nested.
224+
assert not has_api_param_config or is_optional(
225+
annotated_type
226+
), API_PARAM_CONFIG_USAGE_HINT.format(param_name=param_name, param_type=param_type)
227+
param_type = inner_type
181228

182229
param_config: Optional[ApiParamConfig] = None
183230
if is_annotated(param_type):
184231
# The first type is the underlying type of the parameter, the rest are
185232
# metadata attached to Annotated. Note that we do not transform
186233
# param_type to its underlying type because the Annotated metadata might
187234
# still be needed by other parties like Pydantic.
188-
_, *annotations = get_args(param_type)
235+
ignored_type, *annotations = get_args(param_type)
189236
for annotation in annotations:
190237
if not isinstance(annotation, ApiParamConfig):
191238
continue
192239
assert param_config is None, "ApiParamConfig can only be defined once per parameter"
193240
param_config = annotation
241+
else:
242+
# When no parameter configuration is found, assert that there is none
243+
# nested somewhere in a Union type to avoid silently ignoring it. If it
244+
# does present in the stringified parameter type, it is very likely a
245+
# programming error.
246+
assert ApiParamConfig.__name__ not in str(param_type), API_PARAM_CONFIG_USAGE_HINT.format(
247+
param_name=param_name, param_type=param_type
248+
)
194249
# Set param_config to a default early to avoid additional None-checks.
195250
if param_config is None:
196251
param_config = ApiParamConfig()

zerver/tests/test_typed_endpoint.py

+55-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from typing import Any, Callable, Dict, List, Literal, Optional, TypeVar, Union
1+
from typing import Any, Callable, Dict, List, Literal, Optional, Type, TypeVar, Union, cast
22

33
import orjson
44
from django.core.exceptions import ValidationError as DjangoValidationError
55
from django.http import HttpRequest, HttpResponse
6-
from pydantic import BaseModel, ConfigDict, Json, ValidationInfo, WrapValidator
6+
from pydantic import BaseModel, ConfigDict, Json, StringConstraints, ValidationInfo, WrapValidator
77
from pydantic.dataclasses import dataclass
88
from pydantic.functional_validators import ModelWrapValidatorHandler
99
from typing_extensions import Annotated
@@ -20,6 +20,7 @@
2020
RequiredStringConstraint,
2121
WebhookPayload,
2222
get_func_param,
23+
is_optional,
2324
typed_endpoint,
2425
typed_endpoint_without_parameters,
2526
)
@@ -38,6 +39,16 @@ def call_endpoint(
3839

3940

4041
class TestEndpoint(ZulipTestCase):
42+
def test_is_optional(self) -> None:
43+
"""This test is only needed because we don't
44+
have coverage of is_optional in Python 3.11.
45+
"""
46+
type = cast(Type[Optional[str]], Optional[str])
47+
self.assertTrue(is_optional(type))
48+
49+
type = str
50+
self.assertFalse(is_optional(str))
51+
4152
def test_coerce(self) -> None:
4253
@typed_endpoint
4354
def view(request: HttpRequest, *, strict_int: int) -> None:
@@ -416,6 +427,48 @@ def view3(
416427
)
417428
self.assertFalse(result)
418429

430+
# Not nesting the Annotated type with the ApiParamConfig inside Optional is fine
431+
@typed_endpoint
432+
def no_nesting(
433+
request: HttpRequest,
434+
*,
435+
bar: Annotated[
436+
Optional[str],
437+
StringConstraints(strip_whitespace=True, max_length=3),
438+
ApiParamConfig("test"),
439+
] = None,
440+
) -> None:
441+
raise AssertionError
442+
443+
with self.assertRaisesMessage(ApiParamValidationError, "test is too long"):
444+
call_endpoint(no_nesting, HostRequestMock({"test": "long"}))
445+
446+
# Nesting Annotated with ApiParamConfig inside Optional is not fine
447+
def nesting_with_config(
448+
request: HttpRequest,
449+
*,
450+
invalid_param: Optional[Annotated[str, ApiParamConfig("test")]] = None,
451+
) -> None:
452+
raise AssertionError
453+
454+
with self.assertRaisesRegex(
455+
AssertionError,
456+
"Detected incorrect usage of Annotated types for parameter invalid_param!",
457+
):
458+
typed_endpoint(nesting_with_config)
459+
460+
# Nesting Annotated inside Optional, when ApiParamConfig is not also nested is fine
461+
@typed_endpoint
462+
def nesting_without_config(
463+
request: HttpRequest,
464+
*,
465+
bar: Optional[Annotated[str, StringConstraints(max_length=3)]] = None,
466+
) -> None:
467+
raise AssertionError
468+
469+
with self.assertRaisesMessage(ApiParamValidationError, "bar is too long"):
470+
call_endpoint(nesting_without_config, HostRequestMock({"bar": "long"}))
471+
419472
def test_aliases(self) -> None:
420473
@typed_endpoint
421474
def foo(

0 commit comments

Comments
 (0)