Skip to content

Commit f7d87b9

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 2e4c1cc commit f7d87b9

File tree

3 files changed

+119
-15
lines changed

3 files changed

+119
-15
lines changed

tools/semgrep.yml

+20-7
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,26 @@ rules:
169169
message: 'A batched migration should not be atomic. Add "atomic = False" to the Migration class'
170170
languages: [python]
171171
severity: ERROR
172-
173-
- id: dont-biscuit-annotated-with-optionals
172+
173+
- id: dont-nest-annotated-types-with-param-config
174174
patterns:
175-
- pattern: |
176-
$T: Optional[Annotated[Optional[...], ...]]
177-
message: 'The outer Optional is unnecessary, use $T: Annotated[Optional[...], ...] instead'
175+
- pattern-not: |
176+
def $F(..., invalid_param: typing.Optional[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>], ...) -> ...:
177+
...
178+
- pattern-not: |
179+
def $F(..., $A: typing_extensions.Annotated[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>], ...) -> ...:
180+
...
181+
- pattern-not: |
182+
def $F(..., $A: typing_extensions.Annotated[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>] = ..., ...) -> ...:
183+
...
184+
- pattern-either:
185+
- pattern: |
186+
def $F(..., $A: $B[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>], ...) -> ...:
187+
...
188+
- pattern: |
189+
def $F(..., $A: $B[<... zerver.lib.typed_endpoint.ApiParamConfig(...) ...>] = ..., ...) -> ...:
190+
...
191+
message: |
192+
Annotated types containing zerver.lib.typed_endpoint.ApiParamConfig should not be nested inside Optional. Use Annotated[Optional[...], zerver.lib.typed_endpoint.ApiParamConfig(...)] instead.
178193
languages: [python]
179194
severity: ERROR
180-
181-

zerver/lib/typed_endpoint.py

+60-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
@@ -194,6 +195,41 @@ def is_annotated(type_annotation: Type[object]) -> bool:
194195
return origin is Annotated
195196

196197

198+
def is_optional(type_annotation: Type[object]) -> bool:
199+
origin = get_origin(type_annotation)
200+
type_args = get_args(type_annotation)
201+
return origin is Union and type(None) in type_args and len(type_args) == 2
202+
203+
204+
API_PARAM_CONFIG_USAGE_HINT = f"""
205+
Detected incorrect usage of Annotated types for parameter {{param_name}}!
206+
Check the placement of the {ApiParamConfig.__name__} object in the type annotation:
207+
208+
{{param_name}}: {{param_type}}
209+
210+
The Annotated[T, ...] type annotation containing the
211+
{ApiParamConfig.__name__} object should not be nested inside another type.
212+
213+
Correct examples:
214+
215+
# Using Optional inside Annotated
216+
param: Annotated[Optional[int], ApiParamConfig(...)]
217+
param: Annotated[Optional[int], ApiParamConfig(...)]] = None
218+
219+
# Not using Optional when the default is not None
220+
param: Annotated[int, ApiParamConfig(...)]
221+
222+
Incorrect examples:
223+
224+
# Nesting Annotated inside Optional
225+
param: Optional[Annotated[int, ApiParamConfig(...)]]
226+
param: Optional[Annotated[int, ApiParamConfig(...)]] = None
227+
228+
# Nesting the Annotated type carrying ApiParamConfig inside other types like Union
229+
param: Union[str, Annotated[int, ApiParamConfig(...)]]
230+
"""
231+
232+
197233
def parse_single_parameter(
198234
param_name: str, param_type: Type[T], parameter: inspect.Parameter
199235
) -> FuncParam[T]:
@@ -208,13 +244,22 @@ def parse_single_parameter(
208244
# otherwise causes undesired behaviors that the annotated metadata gets
209245
# lost. This is fixed in Python 3.11:
210246
# https://github.com/python/cpython/issues/90353
211-
if param_default is None:
212-
origin = get_origin(param_type)
247+
if sys.version_info < (3, 11) and param_default is None:
213248
type_args = get_args(param_type)
214-
if origin is Union and type(None) in type_args and len(type_args) == 2:
215-
inner_type = type_args[0] if type_args[1] is type(None) else type_args[1]
216-
if is_annotated(inner_type):
217-
param_type = inner_type
249+
assert is_optional(param_type)
250+
inner_type = type_args[0] if type_args[1] is type(None) else type_args[1]
251+
if is_annotated(inner_type):
252+
annotated_type, *annotations = get_args(inner_type)
253+
has_api_param_config = any(
254+
isinstance(annotation, ApiParamConfig) for annotation in annotations
255+
)
256+
# This prohibits the use of `Optional[Annotated[T, ApiParamConfig(...)]] = None`
257+
# and encourage `Annotated[Optional[T], ApiParamConfig(...)] = None`
258+
# to avoid confusion when the parameter metadata is unintentionally nested.
259+
assert not has_api_param_config or is_optional(
260+
annotated_type
261+
), API_PARAM_CONFIG_USAGE_HINT.format(param_name=param_name, param_type=param_type)
262+
param_type = inner_type
218263

219264
param_config: Optional[ApiParamConfig] = None
220265
json_wrapper = False
@@ -223,14 +268,22 @@ def parse_single_parameter(
223268
# metadata attached to Annotated. Note that we do not transform
224269
# param_type to its underlying type because the Annotated metadata might
225270
# still be needed by other parties like Pydantic.
226-
_, *annotations = get_args(param_type)
271+
ignored_type, *annotations = get_args(param_type)
227272
for annotation in annotations:
228273
if type(annotation) is Json:
229274
json_wrapper = True
230275
if not isinstance(annotation, ApiParamConfig):
231276
continue
232277
assert param_config is None, "ApiParamConfig can only be defined once per parameter"
233278
param_config = annotation
279+
else:
280+
# When no parameter configuration is found, assert that there is none
281+
# nested somewhere in a Union type to avoid silently ignoring it. If it
282+
# does present in the stringified parameter type, it is very likely a
283+
# programming error.
284+
assert ApiParamConfig.__name__ not in str(param_type), API_PARAM_CONFIG_USAGE_HINT.format(
285+
param_name=param_name, param_type=param_type
286+
)
234287
# Set param_config to a default early to avoid additional None-checks.
235288
if param_config is None:
236289
param_config = ApiParamConfig()

zerver/tests/test_typed_endpoint.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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
6+
from pydantic import BaseModel, ConfigDict, Json, StringConstraints
77
from pydantic.dataclasses import dataclass
88
from typing_extensions import Annotated
99

@@ -399,6 +399,44 @@ def view3(
399399
)
400400
self.assertFalse(result)
401401

402+
# Not nesting the Annotated type with the ApiParamConfig inside Optional is fine
403+
@typed_endpoint
404+
def no_nesting(
405+
request: HttpRequest,
406+
bar: Annotated[
407+
Optional[str],
408+
StringConstraints(strip_whitespace=True, max_length=3),
409+
ApiParamConfig("test"),
410+
] = None,
411+
) -> None:
412+
raise AssertionError
413+
414+
with self.assertRaisesMessage(ApiParamValidationError, "test is too long"):
415+
call_endpoint(no_nesting, HostRequestMock({"test": "long"}))
416+
417+
# Nesting Annotated with ApiParamConfig inside Optional is not fine
418+
def nesting_with_config(
419+
request: HttpRequest,
420+
invalid_param: Optional[Annotated[str, ApiParamConfig("test")]] = None,
421+
) -> None:
422+
raise AssertionError
423+
424+
with self.assertRaisesRegex(
425+
AssertionError, "Detected incorrect usage of Annotated types for parameter bar!"
426+
):
427+
typed_endpoint(nesting_with_config)
428+
429+
# Nesting Annotated inside Optional, when ApiParamConfig is not also nested is fine
430+
@typed_endpoint
431+
def nesting_without_config(
432+
request: HttpRequest,
433+
bar: Optional[Annotated[str, StringConstraints(max_length=3)]] = None,
434+
) -> None:
435+
raise AssertionError
436+
437+
with self.assertRaisesMessage(ApiParamValidationError, "bar is too long"):
438+
call_endpoint(nesting_without_config, HostRequestMock({"bar": "long"}))
439+
402440
def test_aliases(self) -> None:
403441
@typed_endpoint
404442
def foo(

0 commit comments

Comments
 (0)