Repository: pydantic/pydantic

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

...
```



Comments:

Patch:
diff --git a/pydantic/fields.py b/pydantic/fields.py
--- 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 @@ def __init__(self, default: Any = Undefined, **kwargs: Any) -> None:
         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 @@ def infer(
             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
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -425,6 +425,8 @@ def __setattr__(self, name, value):  # noqa: C901 (ignore complexity)
                 # - 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
--- a/pydantic/schema.py
+++ b/pydantic/schema.py
@@ -876,32 +876,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 +990,7 @@ def constraint_func(**kwargs: Any) -> Type[Any]:
 
     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:


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1487,6 +1487,25 @@ class M(BaseModel):
     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
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1431,6 +1431,7 @@ class Foo(BaseModel):
         ({'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),