Repository: pydantic/pydantic

Issue:
ConstrainedList fixes
as found while working on #909:
* `Field` arguments `min_items` and `max_items` are not reflected by `schema()`
* I think `ConstrainedList` might not be working correctly in all situations



Discussion:

Resolving patch:
diff --git a/pydantic/schema.py b/pydantic/schema.py
--- 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
--- 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


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- 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
--- 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):