diff --git a/changes/917-samuelcolvin.md b/changes/917-samuelcolvin.md new file mode 100644 index 00000000000..1b3e3116117 --- /dev/null +++ b/changes/917-samuelcolvin.md @@ -0,0 +1 @@ +Fix `ConstrainedList`, update schema generation to reflect `min_items` and `max_items` `Field()` arguments diff --git a/pydantic/schema.py b/pydantic/schema.py index 257b27badd7..a2793db05d6 100644 --- a/pydantic/schema.py +++ b/pydantic/schema.py @@ -60,6 +60,7 @@ condecimal, confloat, conint, + conlist, constr, ) from .typing import ( @@ -386,20 +387,21 @@ def field_type_schema( definitions = {} nested_models: Set[str] = set() ref_prefix = ref_prefix or default_prefix - if field.shape in {SHAPE_LIST, SHAPE_TUPLE_ELLIPSIS, SHAPE_SEQUENCE}: + if field.shape in {SHAPE_LIST, SHAPE_TUPLE_ELLIPSIS, SHAPE_SEQUENCE, SHAPE_SET, SHAPE_FROZENSET}: f_schema, f_definitions, f_nested_models = field_singleton_schema( field, by_alias=by_alias, model_name_map=model_name_map, ref_prefix=ref_prefix, known_models=known_models ) definitions.update(f_definitions) nested_models.update(f_nested_models) - return {'type': 'array', 'items': f_schema}, definitions, nested_models - elif field.shape in {SHAPE_SET, SHAPE_FROZENSET}: - f_schema, f_definitions, f_nested_models = field_singleton_schema( - field, by_alias=by_alias, model_name_map=model_name_map, ref_prefix=ref_prefix, known_models=known_models - ) - definitions.update(f_definitions) - nested_models.update(f_nested_models) - return {'type': 'array', 'uniqueItems': True, 'items': f_schema}, definitions, nested_models + s: Dict[str, Any] = {'type': 'array', 'items': f_schema} + if field.shape in {SHAPE_SET, SHAPE_FROZENSET}: + s['uniqueItems'] = True + field_info = cast(FieldInfo, field.field_info) + if field_info.min_items is not None: + s['minItems'] = field_info.min_items + if field_info.max_items is not None: + s['maxItems'] = field_info.max_items + return s, definitions, nested_models elif field.shape == SHAPE_MAPPING: dict_schema: Dict[str, Any] = {'type': 'object'} key_field = cast(ModelField, field.key_field) @@ -755,6 +757,18 @@ def encode_default(dft: Any) -> Any: _map_types_constraint: Dict[Any, Callable[..., type]] = {int: conint, float: confloat, Decimal: condecimal} +_field_constraints = { + 'min_length', + 'max_length', + 'regex', + 'gt', + 'lt', + 'ge', + 'le', + 'multiple_of', + 'min_items', + 'max_items', +} def get_annotation_from_field_info(annotation: Any, field_info: FieldInfo, field_name: str) -> Type[Any]: # noqa: C901 @@ -766,7 +780,7 @@ def get_annotation_from_field_info(annotation: Any, field_info: FieldInfo, field :param field_name: name of the field for use in error messages :return: the same ``annotation`` if unmodified or a new annotation with validation in place """ - constraints = {f for f in validation_attribute_to_schema_keyword if getattr(field_info, f) is not None} + constraints = {f for f in _field_constraints if getattr(field_info, f) is not None} if not constraints: return annotation used_constraints: Set[str] = set() @@ -784,10 +798,9 @@ def go(type_: Any) -> Type[Any]: if origin is Union: return Union[tuple(go(a) for a in args)] - # conlist isn't working properly with schema #913 - # if issubclass(origin, List): - # used_constraints.update({'min_items', 'max_items'}) - # return conlist(go(args[0]), min_items=field_info.min_items, max_items=field_info.max_items) + if issubclass(origin, List) and (field_info.min_items is not None or field_info.max_items is not None): + used_constraints.update({'min_items', 'max_items'}) + return conlist(go(args[0]), min_items=field_info.min_items, max_items=field_info.max_items) for t in (Tuple, List, Set, FrozenSet, Sequence): if issubclass(origin, t): # type: ignore diff --git a/pydantic/types.py b/pydantic/types.py index 60c9159ce91..f0423d5d1c7 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -17,6 +17,7 @@ decimal_validator, float_validator, int_validator, + list_validator, number_multiple_validator, number_size_validator, path_exists_validator, @@ -115,6 +116,7 @@ class ConstrainedList(list): # type: ignore @classmethod def __get_validators__(cls) -> 'CallableGenerator': + yield list_validator yield cls.list_length_validator @classmethod diff --git a/tests/test_schema.py b/tests/test_schema.py index 6c1b850d87f..42264efcb2c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1594,3 +1594,36 @@ class Config: 'required': ['foo'], } ) + + +def test_conlist(): + class Model(BaseModel): + foo: List[int] = Field(..., min_items=2, max_items=4) + + assert Model(foo=[1, 2]).dict() == {'foo': [1, 2]} + + with pytest.raises(ValidationError, match='ensure this value has at least 2 items'): + Model(foo=[1]) + + with pytest.raises(ValidationError, match='ensure this value has at most 4 items'): + Model(foo=list(range(5))) + + assert Model.schema() == { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'foo': {'title': 'Foo', 'type': 'array', 'items': {'type': 'integer'}, 'minItems': 2, 'maxItems': 4} + }, + 'required': ['foo'], + } + + with pytest.raises(ValidationError) as exc_info: + Model(foo=[1, 'x', 'y']) + assert exc_info.value.errors() == [ + {'loc': ('foo', 1), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}, + {'loc': ('foo', 2), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}, + ] + + with pytest.raises(ValidationError) as exc_info: + Model(foo=1) + assert exc_info.value.errors() == [{'loc': ('foo',), 'msg': 'value is not a valid list', 'type': 'type_error.list'}] diff --git a/tests/test_types.py b/tests/test_types.py index eb9e0996e64..6a1b9fa7c41 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -167,6 +167,10 @@ class ConListModelBoth(BaseModel): } ] + with pytest.raises(ValidationError) as exc_info: + ConListModelBoth(v=1) + assert exc_info.value.errors() == [{'loc': ('v',), 'msg': 'value is not a valid list', 'type': 'type_error.list'}] + def test_constrained_list_item_type_fails(): class ConListModel(BaseModel):