Repository: samuelcolvin/pydantic (python)

Issue:
Introduce a allow_mutation Field constraint
### Checks

* [x] I added a descriptive title to this issue
* [x] I have searched (google, github) for similar issues and couldn't find anything
* [x] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this feature/change is needed
* [x] After submitting this, I commit to one of:
    * Look through open issues and helped at least one other person
    * Hit the "watch" button on this repo to receive notifications and I commit to help at least 2 people that ask questions in the future
    * Implement a Pull Request for a confirmed bug

<!-- Sorry to sound so draconian, but every second saved replying to issues is time spend improving pydantic :-) -->

# Feature Request
I found mentions of a readOnly parameter on the deprecated Schema class but it was not carried over to Field.  Others are using validators to enforce readonly on assignment, but it's a common enough feature that pydantic could provide it natively.  I am not able to use validators to create a readonly Field because I have validators that need to be run on initialization.

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.7.2
            pydantic compiled: False
                 install path: /Users/scootna/projects/pydantic/pydantic
               python version: 3.8.2 (default, Sep 24 2020, 19:37:08)  [Clang 12.0.0 (clang-1200.0.32.21)]
                     platform: macOS-10.15.7-x86_64-i386-64bit
     optional deps. installed: ['typing-extensions', 'email-validator', 'devtools']
```
<!-- or if you're using pydantic prior to v1.3, manually include: OS, python version and pydantic version -->

<!-- Please read the [docs](https://pydantic-docs.helpmanual.io/) and search through issues to
confirm your feature hasn't been asked for before, or already implemented. -->

<!-- Where possible please include a self-contained code snippet describing your feature request: -->

```py
from pydantic import BaseModel, Field

class Entry(BaseModel):
    id: float = Field(allow_mutation=False)
    val: float

    class Config:
        validate_assignment = True

r = Entry(id=1, val=100)
r.id = 2 # raise error when assigning to read_only field

...
```


Pull request:
introduce allow_mutation Field constraint
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
<!-- See https://pydantic-docs.helpmanual.io/contributing/ for help on Contributing -->

## Change Summary
Introduces an allow_mutation constraint on Field.  When set to False, a TypeError is raised if the field is assigned.  I also added a check for unused constraints when validation_assignment is set to True. 

<!-- Please give a short summary of the changes. -->

## Related issue number
fix #2195
<!-- Are there any issues opened that will be resolved by merging this change? -->

## Checklist

* [x] Unit tests for the changes exist
* [x] Tests pass on CI and coverage remains at 100%
* [x] Documentation reflects the changes where applicable
* [x] `changes/<pull request or issue id>-<github username>.md` file added describing change
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)


Patch:
diff --git a/changes/2195-sblack-usu.md b/changes/2195-sblack-usu.md
new file mode 100644
index 000000000..273da9802
--- /dev/null
+++ b/changes/2195-sblack-usu.md
@@ -0,0 +1,1 @@
+add `allow_mutation` constraint to `Field`
diff --git a/changes/2262-maximberg.md b/changes/2262-maximberg.md
new file mode 100644
index 000000000..0911c71d0
--- /dev/null
+++ b/changes/2262-maximberg.md
@@ -0,0 +1,1 @@
+Support generating schema for Generic fields. 
\ No newline at end of file
diff --git a/changes/2306-hukkinj1.md b/changes/2306-hukkinj1.md
new file mode 100644
index 000000000..f2300718f
--- /dev/null
+++ b/changes/2306-hukkinj1.md
@@ -0,0 +1,1 @@
+fix: `python_requires` metadata to require >=3.6.1
\ No newline at end of file
diff --git a/changes/2320-PrettyWood.md b/changes/2320-PrettyWood.md
new file mode 100644
index 000000000..b3bd818ae
--- /dev/null
+++ b/changes/2320-PrettyWood.md
@@ -0,0 +1,1 @@
+fix: allow `None` for type `Optional[conset / conlist]`
diff --git a/docs/usage/schema.md b/docs/usage/schema.md
index 7ec256c90..a58e12290 100644
--- a/docs/usage/schema.md
+++ b/docs/usage/schema.md
@@ -71,6 +71,8 @@ It has the following arguments:
   JSON Schema
 * `max_length`: for string values, this adds a corresponding validation and an annotation of `maxLength` to the
   JSON Schema
+* `allow_mutation`: a boolean which defaults to `True`. When False, the field raises a `TypeError` if the field is 
+  assigned on an instance.  The model config must set `validate_assignment` to `True` for this check to be performed.
 * `regex`: for string values, this adds a Regular Expression validation generated from the passed string and an
   annotation of `pattern` to the JSON Schema
 
diff --git a/pydantic/fields.py b/pydantic/fields.py
index f75e16f1f..127f9cb1a 100644
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -97,10 +97,25 @@ class FieldInfo(Representation):
         'max_items',
         'min_length',
         'max_length',
+        'allow_mutation',
         'regex',
         'extra',
     )
 
+    __field_constraints__ = {  # field constraints with the default value
+        'min_length': None,
+        'max_length': None,
+        'regex': None,
+        'gt': None,
+        'lt': None,
+        'ge': None,
+        'le': None,
+        'multiple_of': None,
+        'min_items': None,
+        'max_items': None,
+        'allow_mutation': True,
+    }
+
     def __init__(self, default: Any = Undefined, **kwargs: Any) -> None:
         self.default = default
         self.default_factory = kwargs.pop('default_factory', None)
@@ -118,9 +133,22 @@ class FieldInfo(Representation):
         self.max_items = kwargs.pop('max_items', None)
         self.min_length = kwargs.pop('min_length', None)
         self.max_length = kwargs.pop('max_length', None)
+        self.allow_mutation = kwargs.pop('allow_mutation', True)
         self.regex = kwargs.pop('regex', None)
         self.extra = kwargs
 
+    def __repr_args__(self) -> 'ReprArgs':
+        attrs = ((s, getattr(self, s)) for s in self.__slots__)
+        return [(a, v) for a, v in attrs if v != self.__field_constraints__.get(a, None)]
+
+    def get_constraints(self) -> Set[str]:
+        """
+        Gets the constraints set on the field by comparing the constraint value with its default value
+
+        :return: the constraints set on field_info
+        """
+        return {attr for attr, default in self.__field_constraints__.items() if getattr(self, attr) != default}
+
     def _validate(self) -> None:
         if self.default not in (Undefined, Ellipsis) and self.default_factory is not None:
             raise ValueError('cannot specify both default and default_factory')
@@ -143,6 +171,7 @@ def Field(
     max_items: int = None,
     min_length: int = None,
     max_length: int = None,
+    allow_mutation: bool = True,
     regex: str = None,
     **extra: Any,
 ) -> Any:
@@ -172,6 +201,8 @@ def Field(
       schema will have a ``maximum`` validation keyword
     :param max_length: only applies to strings, requires the field to have a maximum length. The
       schema will have a ``maxLength`` validation keyword
+    :param allow_mutation: a boolean which defaults to True. When False, the field raises a TypeError if the field is
+      assigned on an instance.  The BaseModel Config must set validate_assignment to True
     :param regex: only applies to strings, requires the field match agains a regular expression
       pattern string. The schema will have a ``pattern`` validation keyword
     :param **extra: any additional keyword arguments will be added as is to the schema
@@ -192,6 +223,7 @@ def Field(
         max_items=max_items,
         min_length=min_length,
         max_length=max_length,
+        allow_mutation=allow_mutation,
         regex=regex,
         **extra,
     )
@@ -351,7 +383,7 @@ class ModelField(Representation):
             value = None
         elif value is not Undefined:
             required = False
-        annotation = get_annotation_from_field_info(annotation, field_info, name)
+        annotation = get_annotation_from_field_info(annotation, field_info, name, config.validate_assignment)
         return cls(
             name=name,
             type_=annotation,
diff --git a/pydantic/main.py b/pydantic/main.py
index c2f8cb37f..0f833f16f 100644
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -425,6 +425,8 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
                 # - make sure validators are called without the current value for this field inside `values`
                 # - keep other values (e.g. submodels) untouched (using `BaseModel.dict()` will change them into dicts)
                 # - keep the order of the fields
+                if not known_field.field_info.allow_mutation:
+                    raise TypeError(f'"{known_field.name}" has allow_mutation set to False and cannot be assigned')
                 dict_without_original_value = {k: v for k, v in self.__dict__.items() if k != name}
                 value, error_ = known_field.validate(value, dict_without_original_value, loc=name, cls=self.__class__)
                 if error_:
diff --git a/pydantic/schema.py b/pydantic/schema.py
index e1e14912b..2b68b89e5 100644
--- a/pydantic/schema.py
+++ b/pydantic/schema.py
@@ -11,6 +11,7 @@ from typing import (
     Callable,
     Dict,
     FrozenSet,
+    Generic,
     Iterable,
     List,
     Optional,
@@ -27,6 +28,7 @@ from uuid import UUID
 
 from .fields import (
     SHAPE_FROZENSET,
+    SHAPE_GENERIC,
     SHAPE_ITERABLE,
     SHAPE_LIST,
     SHAPE_MAPPING,
@@ -488,7 +490,7 @@ def field_type_schema(
             sub_schema = sub_schema[0]  # type: ignore
         f_schema = {'type': 'array', 'items': sub_schema}
     else:
-        assert field.shape == SHAPE_SINGLETON, field.shape
+        assert field.shape in {SHAPE_SINGLETON, SHAPE_GENERIC}, field.shape
         f_schema, f_definitions, f_nested_models = field_singleton_schema(
             field,
             by_alias=by_alias,
@@ -503,7 +505,11 @@ def field_type_schema(
 
     # check field type to avoid repeated calls to the same __modify_schema__ method
     if field.type_ != field.outer_type_:
-        modify_schema = getattr(field.outer_type_, '__modify_schema__', None)
+        if field.shape == SHAPE_GENERIC:
+            field_type = field.type_
+        else:
+            field_type = field.outer_type_
+        modify_schema = getattr(field_type, '__modify_schema__', None)
         if modify_schema:
             modify_schema(f_schema)
     return f_schema, definitions, nested_models
@@ -845,6 +851,11 @@ def field_singleton_schema(  # noqa: C901 (ignore complexity)
         schema_ref = get_schema_ref(model_name, ref_prefix, ref_template, schema_overrides)
         return schema_ref, definitions, nested_models
 
+    # For generics with no args
+    args = get_args(field_type)
+    if args is not None and not args and Generic in field_type.__bases__:
+        return f_schema, definitions, nested_models
+
     raise ValueError(f'Value not declarable with JSON Schema, field: {field}')
 
 
@@ -876,32 +887,47 @@ 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
+
+
+def get_annotation_from_field_info(
+    annotation: Any, field_info: FieldInfo, field_name: str, validate_assignment: bool = False
+) -> Type[Any]:
     """
     Get an annotation with validation implemented for numbers and strings based on the field_info.
-
     :param annotation: an annotation from a field specification, as ``str``, ``ConstrainedStr``
     :param field_info: an instance of FieldInfo, possibly with declarations for validations and JSON Schema
     :param field_name: name of the field for use in error messages
+    :param validate_assignment: default False, flag for BaseModel Config value of validate_assignment
     :return: the same ``annotation`` if unmodified or a new annotation with validation in place
     """
-    constraints = {f for f in _field_constraints if getattr(field_info, f) is not None}
-    if not constraints:
-        return annotation
+    constraints = field_info.get_constraints()
+
+    used_constraints: Set[str] = set()
+    if constraints:
+        annotation, used_constraints = get_annotation_with_constraints(annotation, field_info)
+
+    if validate_assignment:
+        used_constraints.add('allow_mutation')
+
+    unused_constraints = constraints - used_constraints
+    if unused_constraints:
+        raise ValueError(
+            f'On field "{field_name}" the following field constraints are set but not enforced: '
+            f'{", ".join(unused_constraints)}. '
+            f'\nFor more details see https://pydantic-docs.helpmanual.io/usage/schema/#unenforced-field-constraints'
+        )
+
+    return annotation
+
+
+def get_annotation_with_constraints(annotation: Any, field_info: FieldInfo) -> Tuple[Type[Any], Set[str]]:  # noqa: C901
+    """
+    Get an annotation with used constraints implemented for numbers and strings based on the field_info.
+
+    :param annotation: an annotation from a field specification, as ``str``, ``ConstrainedStr``
+    :param field_info: an instance of FieldInfo, possibly with declarations for validations and JSON Schema
+    :return: the same ``annotation`` if unmodified or a new annotation along with the used constraints.
+    """
     used_constraints: Set[str] = set()
 
     def go(type_: Any) -> Type[Any]:
@@ -975,15 +1001,7 @@ def get_annotation_from_field_info(annotation: Any, field_info: FieldInfo, field
 
     ans = go(annotation)
 
-    unused_constraints = constraints - used_constraints
-    if unused_constraints:
-        raise ValueError(
-            f'On field "{field_name}" the following field constraints are set but not enforced: '
-            f'{", ".join(unused_constraints)}. '
-            f'\nFor more details see https://pydantic-docs.helpmanual.io/usage/schema/#unenforced-field-constraints'
-        )
-
-    return ans
+    return ans, used_constraints
 
 
 def normalize_name(name: str) -> str:
diff --git a/pydantic/types.py b/pydantic/types.py
index 846e9ee31..1c2b2da83 100644
--- a/pydantic/types.py
+++ b/pydantic/types.py
@@ -106,7 +106,6 @@ StrIntFloat = Union[str, int, float]
 
 if TYPE_CHECKING:
     from .dataclasses import Dataclass  # noqa: F401
-    from .fields import ModelField
     from .main import BaseConfig, BaseModel  # noqa: F401
     from .typing import CallableGenerator
 
@@ -187,8 +186,8 @@ class ConstrainedList(list):  # type: ignore
         update_not_none(field_schema, minItems=cls.min_items, maxItems=cls.max_items)
 
     @classmethod
-    def list_length_validator(cls, v: 'Optional[List[T]]', field: 'ModelField') -> 'Optional[List[T]]':
-        if v is None and not field.required:
+    def list_length_validator(cls, v: 'Optional[List[T]]') -> 'Optional[List[T]]':
+        if v is None:
             return None
 
         v = list_validator(v)
@@ -229,7 +228,10 @@ class ConstrainedSet(set):  # type: ignore
         update_not_none(field_schema, minItems=cls.min_items, maxItems=cls.max_items)
 
     @classmethod
-    def set_length_validator(cls, v: 'Optional[Set[T]]', field: 'ModelField') -> 'Optional[Set[T]]':
+    def set_length_validator(cls, v: 'Optional[Set[T]]') -> 'Optional[Set[T]]':
+        if v is None:
+            return None
+
         v = set_validator(v)
         v_len = len(v)
 
diff --git a/pydantic/typing.py b/pydantic/typing.py
index cf4f25e58..314faef43 100644
--- a/pydantic/typing.py
+++ b/pydantic/typing.py
@@ -124,7 +124,7 @@ if sys.version_info < (3, 8):  # noqa: C901
     if sys.version_info < (3, 7):
 
         def get_args(t: Type[Any]) -> Tuple[Any, ...]:
-            """Simplest get_args compatability layer possible.
+            """Simplest get_args compatibility layer possible.
 
             The Python 3.6 typing module does not have `_GenericAlias` so
             this won't work for everything. In particular this will not
@@ -140,7 +140,7 @@ if sys.version_info < (3, 8):  # noqa: C901
         from typing import _GenericAlias
 
         def get_args(t: Type[Any]) -> Tuple[Any, ...]:
-            """Compatability version of get_args for python 3.7.
+            """Compatibility version of get_args for python 3.7.
 
             Mostly compatible with the python 3.8 `typing` module version
             and able to handle almost all use cases.
diff --git a/setup.py b/setup.py
index e6b72c446..96c735324 100644
--- a/setup.py
+++ b/setup.py
@@ -124,7 +124,7 @@ setup(
     license='MIT',
     packages=['pydantic'],
     package_data={'pydantic': ['py.typed']},
-    python_requires='>=3.6',
+    python_requires='>=3.6.1',
     zip_safe=False,  # https://mypy.readthedocs.io/en/latest/installed_packages.html
     install_requires=[
         'dataclasses>=0.6;python_version<"3.7"'


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 2890ec439..7046a391d 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1487,6 +1487,25 @@ def test_base_config_type_hinting():
     get_type_hints(M.__config__)
 
 
+def test_allow_mutation_field():
+    """assigning a allow_mutation=False field should raise a TypeError"""
+
+    class Entry(BaseModel):
+        id: float = Field(allow_mutation=False)
+        val: float
+
+        class Config:
+            validate_assignment = True
+
+    r = Entry(id=1, val=100)
+    assert r.val == 100
+    r.val = 101
+    assert r.val == 101
+    assert r.id == 1
+    with pytest.raises(TypeError, match='"id" has allow_mutation set to False and cannot be assigned'):
+        r.id = 2
+
+
 def test_inherited_model_field_copy():
     """It should copy models used as fields by default"""
 
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 661354e5b..f7d5d661d 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -7,7 +7,21 @@ from decimal import Decimal
 from enum import Enum, IntEnum
 from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
 from pathlib import Path
-from typing import Any, Callable, Dict, FrozenSet, Iterable, List, NewType, Optional, Set, Tuple, Union
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    FrozenSet,
+    Generic,
+    Iterable,
+    List,
+    NewType,
+    Optional,
+    Set,
+    Tuple,
+    TypeVar,
+    Union,
+)
 from uuid import UUID
 
 import pytest
@@ -1431,6 +1445,7 @@ def test_constraints_schema(kwargs, type_, expected_extra):
         ({'max_length': 5}, int),
         ({'min_length': 2}, float),
         ({'max_length': 5}, Decimal),
+        ({'allow_mutation': False}, bool),
         ({'regex': '^foo$'}, int),
         ({'gt': 2}, str),
         ({'lt': 5}, bytes),
@@ -2195,3 +2210,58 @@ class MyModel(BaseModel):
         f'{module_2.__name__}__MyEnum',
         f'{module_2.__name__}__MyModel',
     }
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 7), reason='schema generation for generic fields is not available in python < 3.7'
+)
+def test_schema_for_generic_field():
+    T = TypeVar('T')
+
+    class GenModel(Generic[T]):
+        def __init__(self, data: Any):
+            self.data = data
+
+        @classmethod
+        def __get_validators__(cls):
+            yield cls.validate
+
+        @classmethod
+        def validate(cls, v: Any):
+            return v
+
+    class Model(BaseModel):
+        data: GenModel[str]
+        data1: GenModel
+
+    assert Model.schema() == {
+        'title': 'Model',
+        'type': 'object',
+        'properties': {
+            'data': {'title': 'Data', 'type': 'string'},
+            'data1': {
+                'title': 'Data1',
+            },
+        },
+        'required': ['data', 'data1'],
+    }
+
+    class GenModelModified(GenModel, Generic[T]):
+        @classmethod
+        def __modify_schema__(cls, field_schema):
+            field_schema.pop('type', None)
+            field_schema.update(anyOf=[{'type': 'string'}, {'type': 'array', 'items': {'type': 'string'}}])
+
+    class ModelModified(BaseModel):
+        data: GenModelModified[str]
+        data1: GenModelModified
+
+    assert ModelModified.schema() == {
+        'title': 'ModelModified',
+        'type': 'object',
+        'properties': {
+            'data': {'title': 'Data', 'anyOf': [{'type': 'string'}, {'type': 'array', 'items': {'type': 'string'}}]},
+            'data1': {'title': 'Data1', 'anyOf': [{'type': 'string'}, {'type': 'array', 'items': {'type': 'string'}}]},
+        },
+        'required': ['data', 'data1'],
+    }
diff --git a/tests/test_types.py b/tests/test_types.py
index f019a7f5a..66bb6b862 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -172,6 +172,34 @@ def test_constrained_list_too_short():
     ]
 
 
+def test_constrained_list_optional():
+    class Model(BaseModel):
+        req: Optional[conlist(str, min_items=1)] = ...
+        opt: Optional[conlist(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=[], opt=[])
+    assert exc_info.value.errors() == [
+        {
+            'loc': ('req',),
+            'msg': 'ensure this value has at least 1 items',
+            'type': 'value_error.list.min_items',
+            'ctx': {'limit_value': 1},
+        },
+        {
+            'loc': ('opt',),
+            'msg': 'ensure this value has at least 1 items',
+            'type': 'value_error.list.min_items',
+            'ctx': {'limit_value': 1},
+        },
+    ]
+
+    assert Model(req=['a'], opt=['a']).dict() == {'req': ['a'], 'opt': ['a']}
+
+
 def test_constrained_list_constraints():
     class ConListModelBoth(BaseModel):
         v: conlist(int, min_items=7, max_items=11)
@@ -323,6 +351,34 @@ def test_constrained_set_too_short():
     ]
 
 
+def test_constrained_set_optional():
+    class Model(BaseModel):
+        req: Optional[conset(str, min_items=1)] = ...
+        opt: Optional[conset(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=set(), opt=set())
+    assert exc_info.value.errors() == [
+        {
+            'loc': ('req',),
+            'msg': 'ensure this value has at least 1 items',
+            'type': 'value_error.set.min_items',
+            'ctx': {'limit_value': 1},
+        },
+        {
+            'loc': ('opt',),
+            'msg': 'ensure this value has at least 1 items',
+            'type': 'value_error.set.min_items',
+            'ctx': {'limit_value': 1},
+        },
+    ]
+
+    assert Model(req={'a'}, opt={'a'}).dict() == {'req': {'a'}, 'opt': {'a'}}
+
+
 def test_constrained_set_constraints():
     class ConSetModelBoth(BaseModel):
         v: conset(int, min_items=7, max_items=11)