Skip to content

Commit

Permalink
introduce allow_mutation Field constraint (#2196)
Browse files Browse the repository at this point in the history
* introduce read_only Field constraint

* add changes markdown for read_only constraint

* add readOnly property to json schema generation

* Revert "add readOnly property to json schema generation"

This reverts commit dad3d3e.

* change read_only field constraint to allow_mutation

* Update change notes for allow_mutation

Co-authored-by: Samuel Colvin <samcolvin@gmail.com>

* allow field constraints defaults to be not None

* remove unnecessary test after constraint refactor

* push used constraints check back to schema functions

* use tuple item name descriptions instead of indexes

* move get_constraints function to method on FieldInfo

* address code review comments for minor changes

* Apply suggestions from code review

Co-authored-by: Samuel Colvin <samcolvin@gmail.com>

* fix merge conflict

Co-authored-by: Samuel Colvin <samcolvin@gmail.com>
  • Loading branch information
sblack-usu and samuelcolvin committed Feb 13, 2021
1 parent add3a67 commit 13928e5
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 29 deletions.
1 change: 1 addition & 0 deletions changes/2195-sblack-usu.md
@@ -0,0 +1 @@
add `allow_mutation` constraint to `Field`
2 changes: 2 additions & 0 deletions docs/usage/schema.md
Expand Up @@ -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

Expand Down
34 changes: 33 additions & 1 deletion pydantic/fields.py
Expand Up @@ -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)
Expand All @@ -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')
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions pydantic/main.py
Expand Up @@ -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_:
Expand Down
63 changes: 35 additions & 28 deletions pydantic/schema.py
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_main.py
Expand Up @@ -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"""

Expand Down
1 change: 1 addition & 0 deletions tests/test_schema.py
Expand Up @@ -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),
Expand Down

0 comments on commit 13928e5

Please sign in to comment.