Skip to content

Commit

Permalink
ConstrainedList improvements, fix pydantic#913 (pydantic#917)
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin authored and andreshndz committed Jan 17, 2020
1 parent 147c0ba commit 3b9a1f7
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 14 deletions.
1 change: 1 addition & 0 deletions changes/917-samuelcolvin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix `ConstrainedList`, update schema generation to reflect `min_items` and `max_items` `Field()` arguments
41 changes: 27 additions & 14 deletions pydantic/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
condecimal,
confloat,
conint,
conlist,
constr,
)
from .typing import (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pydantic/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
decimal_validator,
float_validator,
int_validator,
list_validator,
number_multiple_validator,
number_size_validator,
path_exists_validator,
Expand Down Expand Up @@ -115,6 +116,7 @@ class ConstrainedList(list): # type: ignore

@classmethod
def __get_validators__(cls) -> 'CallableGenerator':
yield list_validator
yield cls.list_length_validator

@classmethod
Expand Down
33 changes: 33 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}]
4 changes: 4 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit 3b9a1f7

Please sign in to comment.