Repository: pydantic/pydantic

Issue:
min_items not enforcable for FrozenSet
# Bug

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.6.1
            pydantic compiled: True
                 install path: /home/snazzy/code/test-project/venv/lib/python3.8/site-packages/pydantic
               python version: 3.8.2 (default, Jul 16 2020, 14:00:26)  [GCC 9.3.0]
                     platform: Linux-5.4.0-42-generic-x86_64-with-glibc2.29
     optional deps. installed: ['typing-extensions']
```

## Code Example

This works fine:

```py
from pydantic import BaseModel, Field
from typing import Set

class ModelOne(BaseModel):
    my_set: Set = Field(..., min_items=1)

this_should_fail = ModelOne(my_set=set())
```

Using `FrozenSet` instead of `Set` doesn't work:

```py
from pydantic import BaseModel, Field
from typing import FrozenSet

class ModelTwo(BaseModel):
    my_frozen_set: FrozenSet = Field(..., min_items=1)
```
This causes an error at definition time:
```
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "pydantic/main.py", line 252, in pydantic.main.ModelMetaclass.__new__
  File "pydantic/fields.py", line 308, in pydantic.fields.ModelField.infer
  File "pydantic/schema.py", line 864, in pydantic.schema.get_annotation_from_field_info
ValueError: On field "my_frozen_set" the following field constraints are set but not enforced: min_items. 
For more details see https://pydantic-docs.helpmanual.io/usage/schema/#unenforced-field-constraints
```

Since `FrozenSet` is simply the immutable version of `Set`, it should be possible to use `min_items` to validate the size of the set.


---
Edit: 

The reason I expected `FrozenSet` to work the same way as mutable `Set` is because [the docs](https://pydantic-docs.helpmanual.io/usage/types/#typing-iterables) describe them in the exact same manner. Even though frozen sets are not mentioned in the [constrained types](https://pydantic-docs.helpmanual.io/usage/types/#constrained-types) section, it's a little natural to (wrongly) assume parallel functionalities are available for it.


Comments:
Hello @snazzyfox 
Unfortunately we currently have some [custom logic](https://github.com/samuelcolvin/pydantic/blob/master/pydantic/schema.py#L820), which doesn't support `Frozenset` as the origin is `frozenset`.
We could probably just add a `if issubclass(origin, frozenset) ...` and add the immutability in `conset` or duplicate some logic to have a `confrozenset` 
Thanks for taking a look! This totally makes sense. 

I think whether we create `confrozenset` or `conset(frozen=True)` depends on if we want to also duplicate the `ConstrainedSet` class. The code for frozen sets will most probably be almost identical, just with a different base class. If there's a way we can update/reuse the existing class, adding a immutable option to `conset` would make more sense.

Patch:
diff --git a/pydantic/__init__.py b/pydantic/__init__.py
--- a/pydantic/__init__.py
+++ b/pydantic/__init__.py
@@ -78,6 +78,8 @@
     'conlist',
     'ConstrainedSet',
     'conset',
+    'ConstrainedFrozenSet',
+    'confrozenset',
     'ConstrainedStr',
     'constr',
     'PyObject',
diff --git a/pydantic/errors.py b/pydantic/errors.py
--- a/pydantic/errors.py
+++ b/pydantic/errors.py
@@ -36,7 +36,6 @@
     'IntegerError',
     'FloatError',
     'PathError',
-    '_PathValueError',
     'PathNotExistsError',
     'PathNotAFileError',
     'PathNotADirectoryError',
@@ -49,11 +48,14 @@
     'TupleLengthError',
     'ListMinLengthError',
     'ListMaxLengthError',
+    'SetMinLengthError',
+    'SetMaxLengthError',
+    'FrozenSetMinLengthError',
+    'FrozenSetMaxLengthError',
     'AnyStrMinLengthError',
     'AnyStrMaxLengthError',
     'StrError',
     'StrRegexError',
-    '_NumberBoundError',
     'NumberNotGtError',
     'NumberNotGeError',
     'NumberNotLtError',
@@ -338,6 +340,22 @@ def __init__(self, *, limit_value: int) -> None:
         super().__init__(limit_value=limit_value)
 
 
+class FrozenSetMinLengthError(PydanticValueError):
+    code = 'frozenset.min_items'
+    msg_template = 'ensure this value has at least {limit_value} items'
+
+    def __init__(self, *, limit_value: int) -> None:
+        super().__init__(limit_value=limit_value)
+
+
+class FrozenSetMaxLengthError(PydanticValueError):
+    code = 'frozenset.max_items'
+    msg_template = 'ensure this value has at most {limit_value} items'
+
+    def __init__(self, *, limit_value: int) -> None:
+        super().__init__(limit_value=limit_value)
+
+
 class AnyStrMinLengthError(PydanticValueError):
     code = 'any_str.min_length'
     msg_template = 'ensure this value has at least {limit_value} characters'
diff --git a/pydantic/fields.py b/pydantic/fields.py
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -618,6 +618,13 @@ def _type_analysis(self) -> None:  # noqa: C901 (ignore complexity)
             self.type_ = get_args(self.type_)[0]
             self.shape = SHAPE_SET
         elif issubclass(origin, FrozenSet):
+            # Create self validators
+            get_validators = getattr(self.type_, '__get_validators__', None)
+            if get_validators:
+                self.class_validators.update(
+                    {f'frozenset_{i}': Validator(validator, pre=True) for i, validator in enumerate(get_validators())}
+                )
+
             self.type_ = get_args(self.type_)[0]
             self.shape = SHAPE_FROZENSET
         elif issubclass(origin, Deque):
diff --git a/pydantic/networks.py b/pydantic/networks.py
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -12,8 +12,8 @@
 from typing import (
     TYPE_CHECKING,
     Any,
+    Collection,
     Dict,
-    FrozenSet,
     Generator,
     Optional,
     Pattern,
@@ -123,7 +123,7 @@ class AnyUrl(str):
     strip_whitespace = True
     min_length = 1
     max_length = 2 ** 16
-    allowed_schemes: Optional[Set[str]] = None
+    allowed_schemes: Optional[Collection[str]] = None
     tld_required: bool = False
     user_required: bool = False
     host_required: bool = True
@@ -249,7 +249,7 @@ def validate_parts(cls, parts: 'Parts') -> 'Parts':
             raise errors.UrlSchemeError()
 
         if cls.allowed_schemes and scheme.lower() not in cls.allowed_schemes:
-            raise errors.UrlSchemePermittedError(cls.allowed_schemes)
+            raise errors.UrlSchemePermittedError(set(cls.allowed_schemes))
 
         port = parts['port']
         if port is not None and int(port) > 65_535:
@@ -383,7 +383,7 @@ def stricturl(
     max_length: int = 2 ** 16,
     tld_required: bool = True,
     host_required: bool = True,
-    allowed_schemes: Optional[Union[FrozenSet[str], Set[str]]] = None,
+    allowed_schemes: Optional[Collection[str]] = None,
 ) -> Type[AnyUrl]:
     # use kwargs then define conf in a dict to aid with IDE type hinting
     namespace = dict(
diff --git a/pydantic/schema.py b/pydantic/schema.py
--- a/pydantic/schema.py
+++ b/pydantic/schema.py
@@ -48,6 +48,7 @@
 from .types import (
     ConstrainedDecimal,
     ConstrainedFloat,
+    ConstrainedFrozenSet,
     ConstrainedInt,
     ConstrainedList,
     ConstrainedSet,
@@ -57,6 +58,7 @@
     conbytes,
     condecimal,
     confloat,
+    confrozenset,
     conint,
     conlist,
     conset,
@@ -962,7 +964,7 @@ def go(type_: Any) -> Type[Any]:
         if (
             is_literal_type(annotation)
             or isinstance(type_, ForwardRef)
-            or lenient_issubclass(type_, (ConstrainedList, ConstrainedSet))
+            or lenient_issubclass(type_, (ConstrainedList, ConstrainedSet, ConstrainedFrozenSet))
         ):
             return type_
         origin = get_origin(type_)
@@ -985,6 +987,10 @@ def go(type_: Any) -> Type[Any]:
                 used_constraints.update({'min_items', 'max_items'})
                 return conset(go(args[0]), min_items=field_info.min_items, max_items=field_info.max_items)
 
+            if issubclass(origin, FrozenSet) and (field_info.min_items is not None or field_info.max_items is not None):
+                used_constraints.update({'min_items', 'max_items'})
+                return confrozenset(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
                     return t[tuple(go(a) for a in args)]  # type: ignore
@@ -1008,7 +1014,16 @@ def constraint_func(**kwargs: Any) -> Type[Any]:
                 attrs = ('max_length', 'min_length', 'regex')
                 constraint_func = conbytes
             elif issubclass(type_, numeric_types) and not issubclass(
-                type_, (ConstrainedInt, ConstrainedFloat, ConstrainedDecimal, ConstrainedList, ConstrainedSet, bool)
+                type_,
+                (
+                    ConstrainedInt,
+                    ConstrainedFloat,
+                    ConstrainedDecimal,
+                    ConstrainedList,
+                    ConstrainedSet,
+                    ConstrainedFrozenSet,
+                    bool,
+                ),
             ):
                 # Is numeric type
                 attrs = ('gt', 'lt', 'ge', 'le', 'multiple_of')
diff --git a/pydantic/types.py b/pydantic/types.py
--- a/pydantic/types.py
+++ b/pydantic/types.py
@@ -12,6 +12,7 @@
     Callable,
     ClassVar,
     Dict,
+    FrozenSet,
     List,
     Optional,
     Pattern,
@@ -36,6 +37,7 @@
     constr_strip_whitespace,
     decimal_validator,
     float_validator,
+    frozenset_validator,
     int_validator,
     list_validator,
     number_multiple_validator,
@@ -62,6 +64,8 @@
     'conlist',
     'ConstrainedSet',
     'conset',
+    'ConstrainedFrozenSet',
+    'confrozenset',
     'ConstrainedStr',
     'constr',
     'PyObject',
@@ -484,6 +488,48 @@ def conset(item_type: Type[T], *, min_items: int = None, max_items: int = None)
     return new_class('ConstrainedSetValue', (ConstrainedSet,), {}, lambda ns: ns.update(namespace))
 
 
+# This types superclass should be FrozenSet[T], but cython chokes on that...
+class ConstrainedFrozenSet(frozenset):  # type: ignore
+    # Needed for pydantic to detect that this is a set
+    __origin__ = frozenset
+    __args__: FrozenSet[Type[T]]  # type: ignore
+
+    min_items: Optional[int] = None
+    max_items: Optional[int] = None
+    item_type: Type[T]  # type: ignore
+
+    @classmethod
+    def __get_validators__(cls) -> 'CallableGenerator':
+        yield cls.frozenset_length_validator
+
+    @classmethod
+    def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None:
+        update_not_none(field_schema, minItems=cls.min_items, maxItems=cls.max_items)
+
+    @classmethod
+    def frozenset_length_validator(cls, v: 'Optional[FrozenSet[T]]') -> 'Optional[FrozenSet[T]]':
+        if v is None:
+            return None
+
+        v = frozenset_validator(v)
+        v_len = len(v)
+
+        if cls.min_items is not None and v_len < cls.min_items:
+            raise errors.FrozenSetMinLengthError(limit_value=cls.min_items)
+
+        if cls.max_items is not None and v_len > cls.max_items:
+            raise errors.FrozenSetMaxLengthError(limit_value=cls.max_items)
+
+        return v
+
+
+def confrozenset(item_type: Type[T], *, min_items: int = None, max_items: int = None) -> Type[FrozenSet[T]]:
+    # __args__ is needed to conform to typing generics api
+    namespace = {'min_items': min_items, 'max_items': max_items, 'item_type': item_type, '__args__': [item_type]}
+    # We use new_class to be able to deal with Generic types
+    return new_class('ConstrainedFrozenSetValue', (ConstrainedFrozenSet,), {}, lambda ns: ns.update(namespace))
+
+
 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ LIST TYPES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 # This types superclass should be List[T], but cython chokes on that...


Test patch:
diff --git a/tests/mypy/modules/success.py b/tests/mypy/modules/success.py
--- a/tests/mypy/modules/success.py
+++ b/tests/mypy/modules/success.py
@@ -36,6 +36,7 @@
     StrictInt,
     StrictStr,
     root_validator,
+    stricturl,
     validate_arguments,
     validator,
 )
@@ -242,6 +243,10 @@ class Config:
 validated.my_dir_path.absolute()
 validated.my_dir_path_str.absolute()
 
+stricturl(allowed_schemes={'http'})
+stricturl(allowed_schemes=frozenset({'http'}))
+stricturl(allowed_schemes=('s3', 's3n', 's3a'))
+
 
 class Config(BaseConfig):
     title = 'Record'
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -28,7 +28,7 @@
 import pytest
 from typing_extensions import Literal
 
-from pydantic import BaseModel, Extra, Field, ValidationError, conlist, conset, validator
+from pydantic import BaseModel, Extra, Field, ValidationError, confrozenset, conlist, conset, validator
 from pydantic.color import Color
 from pydantic.dataclasses import dataclass
 from pydantic.generics import GenericModel
@@ -1460,6 +1460,7 @@ class Foo(BaseModel):
         ({'gt': 0}, Callable[[int], int]),
         ({'gt': 0}, conlist(int, min_items=4)),
         ({'gt': 0}, conset(int, min_items=4)),
+        ({'gt': 0}, confrozenset(int, min_items=4)),
     ],
 )
 def test_unenforced_constraints_schema(kwargs, type_):
diff --git a/tests/test_types.py b/tests/test_types.py
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -64,6 +64,7 @@
     conbytes,
     condecimal,
     confloat,
+    confrozenset,
     conint,
     conlist,
     conset,
@@ -529,6 +530,97 @@ class Model(BaseModel):
     assert Model().foo is None
 
 
+def test_confrozenset():
+    class Model(BaseModel):
+        foo: FrozenSet[int] = Field(..., min_items=2, max_items=4)
+        bar: confrozenset(str, min_items=1, max_items=4) = None
+
+    m = Model(foo=[1, 2], bar=['spoon'])
+    assert m.dict() == {'foo': {1, 2}, 'bar': {'spoon'}}
+    assert isinstance(m.foo, frozenset)
+    assert isinstance(m.bar, frozenset)
+
+    assert Model(foo=[1, 1, 1, 2, 2], bar=['spoon']).dict() == {'foo': {1, 2}, 'bar': {'spoon'}}
+
+    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'},
+                'uniqueItems': True,
+                'minItems': 2,
+                'maxItems': 4,
+            },
+            'bar': {
+                'title': 'Bar',
+                'type': 'array',
+                'items': {'type': 'string'},
+                'uniqueItems': True,
+                'minItems': 1,
+                'maxItems': 4,
+            },
+        },
+        'required': ['foo'],
+    }
+
+    with pytest.raises(ValidationError) as exc_info:
+        Model(foo=[1, 'x', 'y'])
+    errors = exc_info.value.errors()
+    assert len(errors) == 2
+    assert all(error['msg'] == 'value is not a valid integer' for error in errors)
+
+    with pytest.raises(ValidationError) as exc_info:
+        Model(foo=1)
+    assert exc_info.value.errors() == [
+        {'loc': ('foo',), 'msg': 'value is not a valid frozenset', 'type': 'type_error.frozenset'}
+    ]
+
+
+def test_confrozenset_not_required():
+    class Model(BaseModel):
+        foo: Optional[FrozenSet[int]] = None
+
+    assert Model(foo=None).foo is None
+    assert Model().foo is None
+
+
+def test_constrained_frozenset_optional():
+    class Model(BaseModel):
+        req: Optional[confrozenset(str, min_items=1)] = ...
+        opt: Optional[confrozenset(str, min_items=1)]
+
+    assert Model(req=None).dict() == {'req': None, 'opt': None}
+    assert Model(req=None, opt=None).dict() == {'req': None, 'opt': None}
+
+    with pytest.raises(ValidationError) as exc_info:
+        Model(req=frozenset(), opt=frozenset())
+    assert exc_info.value.errors() == [
+        {
+            'loc': ('req',),
+            'msg': 'ensure this value has at least 1 items',
+            'type': 'value_error.frozenset.min_items',
+            'ctx': {'limit_value': 1},
+        },
+        {
+            'loc': ('opt',),
+            'msg': 'ensure this value has at least 1 items',
+            'type': 'value_error.frozenset.min_items',
+            'ctx': {'limit_value': 1},
+        },
+    ]
+
+    assert Model(req={'a'}, opt={'a'}).dict() == {'req': {'a'}, 'opt': {'a'}}
+
+
 class ConStringModel(BaseModel):
     v: constr(max_length=10) = 'foobar'