diff --git a/changes/2195-sblack-usu.md b/changes/2195-sblack-usu.md new file mode 100644 index 0000000000..273da98020 --- /dev/null +++ b/changes/2195-sblack-usu.md @@ -0,0 +1 @@ +add `allow_mutation` constraint to `Field` diff --git a/docs/usage/schema.md b/docs/usage/schema.md index 7ec256c901..a58e122903 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 f75e16f1f2..127f9cb1a8 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 @@ 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 index c2f8cb37f5..0f833f16fa 100644 --- 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 index 6801c5fe4a..2b68b89e54 100644 --- a/pydantic/schema.py +++ b/pydantic/schema.py @@ -887,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]: @@ -986,15 +1001,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: diff --git a/tests/test_main.py b/tests/test_main.py index 2890ec439b..7046a391d4 100644 --- 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 index 1235af4f40..f7d5d661d1 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1445,6 +1445,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),