Skip to content

Commit cf24754

Browse files
PIG208freshpex
authored andcommitted
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 5f2d094 commit cf24754

File tree

3 files changed

+137
-9
lines changed

3 files changed

+137
-9
lines changed

Diff for: 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

Diff for: zerver/lib/typed_endpoint.py

+58-7
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,41 @@ def is_annotated(type_annotation: Type[object]) -> bool:
160160
return origin is Annotated
161161

162162

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

185228
param_config: Optional[ApiParamConfig] = None
186229
if is_annotated(param_type):
187230
# The first type is the underlying type of the parameter, the rest are
188231
# metadata attached to Annotated. Note that we do not transform
189232
# param_type to its underlying type because the Annotated metadata might
190233
# still be needed by other parties like Pydantic.
191-
_, *annotations = get_args(param_type)
234+
ignored_type, *annotations = get_args(param_type)
192235
for annotation in annotations:
193236
if not isinstance(annotation, ApiParamConfig):
194237
continue
195238
assert param_config is None, "ApiParamConfig can only be defined once per parameter"
196239
param_config = annotation
240+
else:
241+
# When no parameter configuration is found, assert that there is none
242+
# nested somewhere in a Union type to avoid silently ignoring it. If it
243+
# does present in the stringified parameter type, it is very likely a
244+
# programming error.
245+
assert ApiParamConfig.__name__ not in str(param_type), API_PARAM_CONFIG_USAGE_HINT.format(
246+
param_name=param_name, param_type=param_type
247+
)
197248
# If param_config is still None at this point, we could not find an instance
198249
# of it in the type annotation of the function parameter. In this case, we
199250
# fallback to the defaults by constructing ApiParamConfig here.

Diff for: zerver/tests/test_typed_endpoint.py

+56-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
@@ -19,6 +19,7 @@
1919
PathOnly,
2020
RequiredStringConstraint,
2121
WebhookPayload,
22+
is_optional,
2223
typed_endpoint,
2324
typed_endpoint_without_parameters,
2425
)
@@ -37,6 +38,16 @@ def call_endpoint(
3738

3839

3940
class TestEndpoint(ZulipTestCase):
41+
def test_is_optional(self) -> None:
42+
"""This test is only needed because we don't
43+
have coverage of is_optional in Python 3.11.
44+
"""
45+
type = cast(Type[Optional[str]], Optional[str])
46+
self.assertTrue(is_optional(type))
47+
48+
type = str
49+
self.assertFalse(is_optional(str))
50+
4051
def test_coerce(self) -> None:
4152
@typed_endpoint
4253
def view(request: HttpRequest, *, strict_int: int) -> None:
@@ -387,6 +398,49 @@ def annotated_with_extra_unrelated_metadata(
387398
)
388399
self.assertFalse(result)
389400

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

0 commit comments

Comments
 (0)