Skip to content

Commit 701ccde

Browse files
committed
Fix list constraint json schema application (#9818)
1 parent 2a066a2 commit 701ccde

3 files changed

Lines changed: 124 additions & 109 deletions

File tree

pydantic/_internal/_known_annotated_metadata.py

Lines changed: 99 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections import defaultdict
44
from copy import copy
5-
from functools import partial
5+
from functools import lru_cache, partial
66
from typing import TYPE_CHECKING, Any, Callable, Iterable
77

88
from pydantic_core import CoreSchema, PydanticCustomError, to_jsonable_python
@@ -13,33 +13,34 @@
1313
if TYPE_CHECKING:
1414
from ..annotated_handlers import GetJsonSchemaHandler
1515

16-
1716
STRICT = {'strict'}
1817
FAIL_FAST = {'fail_fast'}
19-
SEQUENCE_CONSTRAINTS = {'min_length', 'max_length', *FAIL_FAST}
18+
LENGTH_CONSTRAINTS = {'min_length', 'max_length'}
2019
INEQUALITY = {'le', 'ge', 'lt', 'gt'}
21-
NUMERIC_CONSTRAINTS = {'multiple_of', 'allow_inf_nan', *INEQUALITY}
20+
NUMERIC_CONSTRAINTS = {'multiple_of', *INEQUALITY}
21+
ALLOW_INF_NAN = {'allow_inf_nan'}
2222

2323
STR_CONSTRAINTS = {
24-
*SEQUENCE_CONSTRAINTS,
24+
*LENGTH_CONSTRAINTS,
2525
*STRICT,
2626
'strip_whitespace',
2727
'to_lower',
2828
'to_upper',
2929
'pattern',
3030
'coerce_numbers_to_str',
3131
}
32-
BYTES_CONSTRAINTS = {*SEQUENCE_CONSTRAINTS, *STRICT}
32+
BYTES_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT}
3333

34-
LIST_CONSTRAINTS = {*SEQUENCE_CONSTRAINTS, *STRICT, *FAIL_FAST}
35-
TUPLE_CONSTRAINTS = {*SEQUENCE_CONSTRAINTS, *STRICT, *FAIL_FAST}
36-
SET_CONSTRAINTS = {*SEQUENCE_CONSTRAINTS, *STRICT, *FAIL_FAST}
37-
DICT_CONSTRAINTS = {*SEQUENCE_CONSTRAINTS, *STRICT}
38-
GENERATOR_CONSTRAINTS = {*SEQUENCE_CONSTRAINTS, *STRICT}
34+
LIST_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST}
35+
TUPLE_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST}
36+
SET_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST}
37+
DICT_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT}
38+
GENERATOR_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT}
39+
SEQUENCE_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *FAIL_FAST}
3940

40-
FLOAT_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *STRICT}
41+
FLOAT_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *ALLOW_INF_NAN, *STRICT}
4142
DECIMAL_CONSTRAINTS = {'max_digits', 'decimal_places', *FLOAT_CONSTRAINTS}
42-
INT_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *STRICT}
43+
INT_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *ALLOW_INF_NAN, *STRICT}
4344
BOOL_CONSTRAINTS = STRICT
4445
UUID_CONSTRAINTS = STRICT
4546

@@ -64,46 +65,34 @@
6465
NUMERIC_SCHEMA_TYPES = ('float', 'int', 'date', 'time', 'timedelta', 'datetime')
6566

6667
CONSTRAINTS_TO_ALLOWED_SCHEMAS: dict[str, set[str]] = defaultdict(set)
67-
for constraint in STR_CONSTRAINTS:
68-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(TEXT_SCHEMA_TYPES)
69-
for constraint in BYTES_CONSTRAINTS:
70-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('bytes',))
71-
for constraint in LIST_CONSTRAINTS:
72-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('list',))
73-
for constraint in TUPLE_CONSTRAINTS:
74-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('tuple',))
75-
for constraint in SET_CONSTRAINTS:
76-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('set', 'frozenset'))
77-
for constraint in DICT_CONSTRAINTS:
78-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('dict',))
79-
for constraint in GENERATOR_CONSTRAINTS:
80-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('generator',))
81-
for constraint in FLOAT_CONSTRAINTS:
82-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('float',))
83-
for constraint in INT_CONSTRAINTS:
84-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('int',))
85-
for constraint in DATE_TIME_CONSTRAINTS:
86-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('date', 'time', 'datetime'))
87-
for constraint in TIMEDELTA_CONSTRAINTS:
88-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('timedelta',))
89-
for constraint in TIME_CONSTRAINTS:
90-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('time',))
91-
for schema_type in (*TEXT_SCHEMA_TYPES, *SEQUENCE_SCHEMA_TYPES, *NUMERIC_SCHEMA_TYPES, 'typed-dict', 'model'):
92-
CONSTRAINTS_TO_ALLOWED_SCHEMAS['strict'].add(schema_type)
93-
for constraint in UNION_CONSTRAINTS:
94-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('union',))
95-
for constraint in URL_CONSTRAINTS:
96-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('url', 'multi-host-url'))
97-
for constraint in BOOL_CONSTRAINTS:
98-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('bool',))
99-
for constraint in UUID_CONSTRAINTS:
100-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('uuid',))
101-
for constraint in LAX_OR_STRICT_CONSTRAINTS:
102-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('lax-or-strict',))
103-
for constraint in ENUM_CONSTRAINTS:
104-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('enum',))
105-
for constraint in DECIMAL_CONSTRAINTS:
106-
CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint].update(('decimal',))
68+
69+
constraint_schema_pairings: list[tuple[set[str], tuple[str, ...]]] = [
70+
(STR_CONSTRAINTS, TEXT_SCHEMA_TYPES),
71+
(BYTES_CONSTRAINTS, ('bytes',)),
72+
(LIST_CONSTRAINTS, ('list',)),
73+
(TUPLE_CONSTRAINTS, ('tuple',)),
74+
(SET_CONSTRAINTS, ('set', 'frozenset')),
75+
(DICT_CONSTRAINTS, ('dict',)),
76+
(GENERATOR_CONSTRAINTS, ('generator',)),
77+
(FLOAT_CONSTRAINTS, ('float',)),
78+
(INT_CONSTRAINTS, ('int',)),
79+
(DATE_TIME_CONSTRAINTS, ('date', 'time', 'datetime')),
80+
(TIMEDELTA_CONSTRAINTS, ('timedelta',)),
81+
(TIME_CONSTRAINTS, ('time',)),
82+
# TODO: this is a bit redundant, we could probably avoid some of these
83+
(STRICT, (*TEXT_SCHEMA_TYPES, *SEQUENCE_SCHEMA_TYPES, *NUMERIC_SCHEMA_TYPES, 'typed-dict', 'model')),
84+
(UNION_CONSTRAINTS, ('union',)),
85+
(URL_CONSTRAINTS, ('url', 'multi-host-url')),
86+
(BOOL_CONSTRAINTS, ('bool',)),
87+
(UUID_CONSTRAINTS, ('uuid',)),
88+
(LAX_OR_STRICT_CONSTRAINTS, ('lax-or-strict',)),
89+
(ENUM_CONSTRAINTS, ('enum',)),
90+
(DECIMAL_CONSTRAINTS, ('decimal',)),
91+
]
92+
93+
for constraints, schemas in constraint_schema_pairings:
94+
for c in constraints:
95+
CONSTRAINTS_TO_ALLOWED_SCHEMAS[c].update(schemas)
10796

10897

10998
def add_js_update_schema(s: cs.CoreSchema, f: Callable[[], dict[str, Any]]) -> None:
@@ -168,6 +157,28 @@ def expand_grouped_metadata(annotations: Iterable[Any]) -> Iterable[Any]:
168157
yield annotation
169158

170159

160+
@lru_cache
161+
def _get_at_to_constraint_map() -> dict[type, str]:
162+
"""Return a mapping of annotated types to constraints.
163+
164+
Normally, we would define a mapping like this in the module scope, but we can't do that
165+
because we don't permit module level imports of `annotated_types`, in an attempt to speed up
166+
the import time of `pydantic`. We still only want to have this dictionary defined in one place,
167+
so we use this function to cache the result.
168+
"""
169+
import annotated_types as at
170+
171+
return {
172+
at.Gt: 'gt',
173+
at.Ge: 'ge',
174+
at.Lt: 'lt',
175+
at.Le: 'le',
176+
at.MultipleOf: 'multiple_of',
177+
at.MinLen: 'min_length',
178+
at.MaxLen: 'max_length',
179+
}
180+
181+
171182
def apply_known_metadata(annotation: Any, schema: CoreSchema) -> CoreSchema | None: # noqa: C901
172183
"""Apply `annotation` to `schema` if it is an annotation we know about (Gt, Le, etc.).
173184
Otherwise return `None`.
@@ -189,32 +200,19 @@ def apply_known_metadata(annotation: Any, schema: CoreSchema) -> CoreSchema | No
189200
"""
190201
import annotated_types as at
191202

192-
from . import _validators
193-
194-
COMPARISON_VALIDATORS = {
195-
'gt': _validators.greater_than_validator,
196-
'ge': _validators.greater_than_or_equal_validator,
197-
'lt': _validators.less_than_validator,
198-
'le': _validators.less_than_or_equal_validator,
199-
'multiple_of': _validators.multiple_of_validator,
200-
'min_length': _validators.min_length_validator,
201-
'max_length': _validators.max_length_validator,
202-
}
203-
204-
CONSTRAINT_STR_FROM_ANNOTATED_TYPE = {
205-
at.Gt: 'gt',
206-
at.Ge: 'ge',
207-
at.Lt: 'lt',
208-
at.Le: 'le',
209-
at.MultipleOf: 'multiple_of',
210-
at.MinLen: 'min_length',
211-
at.MaxLen: 'max_length',
212-
}
203+
from ._validators import forbid_inf_nan_check, get_constraint_validator
213204

214205
schema = schema.copy()
215206
schema_update, other_metadata = collect_known_metadata([annotation])
216207
schema_type = schema['type']
217208

209+
chain_schema_constraints: set[str] = {
210+
'pattern',
211+
'strip_whitespace',
212+
'to_lower',
213+
'to_upper',
214+
'coerce_numbers_to_str',
215+
}
218216
chain_schema_steps: list[CoreSchema] = []
219217

220218
for constraint, value in schema_update.items():
@@ -224,6 +222,8 @@ def apply_known_metadata(annotation: Any, schema: CoreSchema) -> CoreSchema | No
224222

225223
# if it becomes necessary to handle more than one constraint
226224
# in this recursive case with function-after or function-wrap, we should refactor
225+
# this is a bit challenging because we sometimes want to apply constraints to the inner schema,
226+
# whereas other times we want to wrap the existing schema with a new one that enforces a new constraint.
227227
if schema_type in {'function-before', 'function-wrap', 'function-after'} and constraint == 'strict':
228228
schema['schema'] = apply_known_metadata(annotation, schema['schema']) # type: ignore # schema is function-after schema
229229
return schema
@@ -235,40 +235,42 @@ def apply_known_metadata(annotation: Any, schema: CoreSchema) -> CoreSchema | No
235235
schema[constraint] = value
236236
continue
237237

238-
if constraint in {'pattern', 'strip_whitespace', 'to_lower', 'to_upper', 'coerce_numbers_to_str'}:
238+
if constraint in chain_schema_constraints:
239239
chain_schema_steps.append(cs.str_schema(**{constraint: value}))
240-
elif constraint in {'gt', 'ge', 'lt', 'le', 'multiple_of', 'min_length', 'max_length'}:
241-
if constraint == 'multiple_of':
242-
json_schema_constraint = 'multiple_of'
243-
elif constraint in {'min_length', 'max_length'}:
244-
if schema['type'] == 'list' or (
245-
schema['type'] == 'json-or-python' and schema['json_schema']['type'] == 'list'
240+
elif constraint in {*NUMERIC_CONSTRAINTS, *LENGTH_CONSTRAINTS}:
241+
if constraint in NUMERIC_CONSTRAINTS:
242+
json_schema_constraint = constraint
243+
elif constraint in LENGTH_CONSTRAINTS:
244+
inner_schema = schema
245+
while inner_schema['type'] in {'function-before', 'function-wrap', 'function-after'}:
246+
inner_schema = inner_schema['schema'] # type: ignore
247+
inner_schema_type = inner_schema['type']
248+
if inner_schema_type == 'list' or (
249+
inner_schema_type == 'json-or-python' and inner_schema['json_schema']['type'] == 'list' # type: ignore
246250
):
247251
json_schema_constraint = 'minItems' if constraint == 'min_length' else 'maxItems'
248252
else:
249253
json_schema_constraint = 'minLength' if constraint == 'min_length' else 'maxLength'
250-
else:
251-
json_schema_constraint = constraint
252254

253255
schema = cs.no_info_after_validator_function(
254-
partial(COMPARISON_VALIDATORS[constraint], **{constraint: value}), schema
256+
partial(get_constraint_validator(constraint), **{constraint: value}), schema
255257
)
256-
257258
add_js_update_schema(schema, lambda: {json_schema_constraint: as_jsonable_value(value)})
258259
elif constraint == 'allow_inf_nan' and value is False:
259260
schema = cs.no_info_after_validator_function(
260-
_validators.forbid_inf_nan_check,
261+
forbid_inf_nan_check,
261262
schema,
262263
)
263264
else:
264265
raise RuntimeError(f'Unable to apply constraint {constraint} to schema {schema_type}')
265266

266267
for annotation in other_metadata:
267-
if isinstance(annotation, (at.Gt, at.Ge, at.Lt, at.Le, at.MultipleOf, at.MinLen, at.MaxLen)):
268-
constraint = CONSTRAINT_STR_FROM_ANNOTATED_TYPE[type(annotation)]
268+
if (annotation_type := type(annotation)) in (at_to_constraint_map := _get_at_to_constraint_map()):
269+
constraint = at_to_constraint_map[annotation_type]
269270
schema = cs.no_info_after_validator_function(
270-
partial(COMPARISON_VALIDATORS[constraint], {constraint: getattr(annotation, constraint)}), schema
271+
partial(get_constraint_validator(constraint), {constraint: getattr(annotation, constraint)}), schema
271272
)
273+
continue
272274
elif isinstance(annotation, at.Predicate):
273275
predicate_name = f'{annotation.func.__qualname__} ' if hasattr(annotation.func, '__qualname__') else ''
274276

@@ -281,9 +283,10 @@ def val_func(v: Any) -> Any:
281283
)
282284
return v
283285

284-
return cs.no_info_after_validator_function(val_func, schema)
285-
# ignore any other unknown metadata
286-
return None
286+
schema = cs.no_info_after_validator_function(val_func, schema)
287+
else:
288+
# ignore any other unknown metadata
289+
return None
287290

288291
if chain_schema_steps:
289292
chain_schema_steps = [schema] + chain_schema_steps
@@ -311,31 +314,19 @@ def collect_known_metadata(annotations: Iterable[Any]) -> tuple[dict[str, Any],
311314
#> ({'gt': 1, 'min_length': 42}, [Ellipsis])
312315
```
313316
"""
314-
import annotated_types as at
315-
316317
annotations = expand_grouped_metadata(annotations)
317318

318319
res: dict[str, Any] = {}
319320
remaining: list[Any] = []
321+
320322
for annotation in annotations:
321323
# isinstance(annotation, PydanticMetadata) also covers ._fields:_PydanticGeneralMetadata
322324
if isinstance(annotation, PydanticMetadata):
323325
res.update(annotation.__dict__)
324326
# we don't use dataclasses.asdict because that recursively calls asdict on the field values
325-
elif isinstance(annotation, at.MinLen):
326-
res.update({'min_length': annotation.min_length})
327-
elif isinstance(annotation, at.MaxLen):
328-
res.update({'max_length': annotation.max_length})
329-
elif isinstance(annotation, at.Gt):
330-
res.update({'gt': annotation.gt})
331-
elif isinstance(annotation, at.Ge):
332-
res.update({'ge': annotation.ge})
333-
elif isinstance(annotation, at.Lt):
334-
res.update({'lt': annotation.lt})
335-
elif isinstance(annotation, at.Le):
336-
res.update({'le': annotation.le})
337-
elif isinstance(annotation, at.MultipleOf):
338-
res.update({'multiple_of': annotation.multiple_of})
327+
elif (annotation_type := type(annotation)) in (at_to_constraint_map := _get_at_to_constraint_map()):
328+
constraint = at_to_constraint_map[annotation_type]
329+
res[constraint] = getattr(annotation, constraint)
339330
elif isinstance(annotation, type) and issubclass(annotation, PydanticMetadata):
340331
# also support PydanticMetadata classes being used without initialisation,
341332
# e.g. `Annotated[int, Strict]` as well as `Annotated[int, Strict()]`

pydantic/_internal/_validators.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import re
1010
import typing
1111
from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
12-
from typing import Any
12+
from typing import Any, Callable
1313

1414
from pydantic_core import PydanticCustomError, core_schema
1515
from pydantic_core._pydantic_core import PydanticKnownError
@@ -287,3 +287,22 @@ def forbid_inf_nan_check(x: Any) -> Any:
287287
if not math.isfinite(x):
288288
raise PydanticKnownError('finite_number')
289289
return x
290+
291+
292+
_CONSTRAINT_TO_VALIDATOR_MAP: dict[str, Callable] = {
293+
'gt': greater_than_validator,
294+
'ge': greater_than_or_equal_validator,
295+
'lt': less_than_validator,
296+
'le': less_than_or_equal_validator,
297+
'multiple_of': multiple_of_validator,
298+
'min_length': min_length_validator,
299+
'max_length': max_length_validator,
300+
}
301+
302+
303+
def get_constraint_validator(constraint: str) -> Callable:
304+
"""Fetch the validator function for the given constraint."""
305+
try:
306+
return _CONSTRAINT_TO_VALIDATOR_MAP[constraint]
307+
except KeyError:
308+
raise TypeError(f'Unknown constraint {constraint}')

tests/test_json_schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6263,3 +6263,8 @@ class Model(BaseModel):
62636263
'title': 'Model',
62646264
'type': 'object',
62656265
}
6266+
6267+
6268+
def test_min_and_max_in_schema() -> None:
6269+
TSeq = TypeAdapter(Annotated[Sequence[int], Field(min_length=2, max_length=5)])
6270+
assert TSeq.json_schema() == {'items': {'type': 'integer'}, 'maxItems': 5, 'minItems': 2, 'type': 'array'}

0 commit comments

Comments
 (0)