diff --git a/.gitignore b/.gitignore index 58474ac..4038db7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ htmlcov/ benchmarks/*.json docs/_build/ docs/.TMP_HISTORY.rst +.pytest_cache/ diff --git a/HISTORY.rst b/HISTORY.rst index 9645f63..689471c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,9 +3,10 @@ History ------- -v0.8.0 (2018-03-XX) +v0.8.0 (2018-03-25) ................... * fix type annotation for ``inherit_config`` #139 +* **breaking change**: vheck for invalid field names in validators #140 v0.7.1 (2018-02-07) ................... diff --git a/docs/index.rst b/docs/index.rst index c24a93a..42293ce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -160,7 +160,20 @@ to set a dynamic default value. (This script is complete, it should run "as is") You'll often want to use this together with ``pre`` since otherwise the with ``always=True`` -_pydantic_ would try to validate the default ``None`` which would cause an error. +*pydantic* would try to validate the default ``None`` which would cause an error. + + +Field Checks +~~~~~~~~~~~~ + +.. note:: + + New in version ``v0.8.0``. + +On class creation validators are checked to confirm that the fields they specify actually exist on the model. + +Occasionally however this is not wanted: when you define a validator to validate fields on inheriting models. +In this case you should set ``check_fields=False`` on the validator. Recursive Models diff --git a/pydantic/fields.py b/pydantic/fields.py index e44207e..5898ca0 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -27,6 +27,7 @@ class Validator(NamedTuple): pre: bool whole: bool always: bool + check_fields: bool class Field: diff --git a/pydantic/main.py b/pydantic/main.py index 5fcb28d..aa91913 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -1,6 +1,7 @@ import warnings from abc import ABCMeta from copy import deepcopy +from itertools import chain from pathlib import Path from types import FunctionType from typing import Any, Dict, Set, Type, Union @@ -39,6 +40,27 @@ def inherit_config(self_config, parent_config) -> Type[BaseConfig]: TYPE_BLACKLIST = FunctionType, property, type, classmethod, staticmethod +class ValidatorGroup: + def __init__(self, validators): + self.validators = validators + self.used_validators = {'*'} + + def get_validators(self, name): + self.used_validators.add(name) + specific_validators = self.validators.get(name) + wildcard_validators = self.validators.get('*') + if specific_validators or wildcard_validators: + return (specific_validators or []) + (wildcard_validators or []) + + def check_for_unused(self): + unused_validators = set(chain(*[(v.func.__name__ for v in self.validators[f] if v.check_fields) + for f in (self.validators.keys() - self.used_validators)])) + if unused_validators: + fn = ', '.join(unused_validators) + raise ConfigError(f"Validators defined with incorrect fields: {fn} " + f"(use check_fields=True if you're inheriting from the model and intended this)") + + def _extract_validators(namespace): validators = {} for var_name, value in namespace.items(): @@ -53,15 +75,7 @@ def _extract_validators(namespace): return validators -def _get_validators(validators, name): - specific_validators = validators.get(name) - wildcard_validators = validators.get('*') - if specific_validators or wildcard_validators: - return (specific_validators or []) + (wildcard_validators or []) - - class MetaModel(ABCMeta): - def __new__(mcs, name, bases, namespace): fields = {} config = BaseConfig @@ -71,7 +85,7 @@ class MetaModel(ABCMeta): config = inherit_config(base.__config__, config) config = inherit_config(namespace.get('Config'), config) - validators = _extract_validators(namespace) + vg = ValidatorGroup(_extract_validators(namespace)) for f in fields.values(): f.set_config(config) @@ -84,7 +98,7 @@ class MetaModel(ABCMeta): name=ann_name, value=..., annotation=ann_type, - class_validators=_get_validators(validators, ann_name), + class_validators=vg.get_validators(ann_name), config=config, ) @@ -94,14 +108,15 @@ class MetaModel(ABCMeta): name=var_name, value=value, annotation=annotations.get(var_name), - class_validators=_get_validators(validators, var_name), + class_validators=vg.get_validators(var_name), config=config, ) + vg.check_for_unused() new_namespace = { '__config__': config, '__fields__': fields, - '__validators__': validators, + '__validators__': vg.validators, **{n: v for n, v in namespace.items() if n not in fields} } return super().__new__(mcs, name, bases, new_namespace) @@ -335,6 +350,7 @@ def create_model( validators = {} config = __config__ or BaseConfig + vg = ValidatorGroup(validators) for f_name, f_def in field_definitions.items(): if isinstance(f_def, tuple): @@ -353,9 +369,10 @@ def create_model( name=f_name, value=f_value, annotation=f_annotation, - class_validators=_get_validators(validators, f_name), + class_validators=vg.get_validators(f_name), config=config, ) + namespace = { 'config': config, '__fields__': fields, @@ -366,13 +383,14 @@ def create_model( _FUNCS = set() -def validator(*fields, pre: bool=False, whole: bool=False, always: bool=False): +def validator(*fields, pre: bool=False, whole: bool=False, always: bool=False, check_fields: bool=True): """ Decorate methods on the class indicating that they should be used to validate fields :param fields: which field(s) the method should be called on :param pre: whether or not this validator should be called before the standard validators (else after) :param whole: for complex objects (sets, lists etc.) whether to validate individual elements or the whole object :param always: whether this method and other validators should be called even if the value is missing + :param check_fields: whether to check that the fields actually exist on the model """ if not fields: raise ConfigError('validator with no fields specified') @@ -386,6 +404,6 @@ def validator(*fields, pre: bool=False, whole: bool=False, always: bool=False): raise ConfigError(f'duplicate validator function "{ref}"') _FUNCS.add(ref) f_cls = classmethod(f) - f_cls.__validator_config = fields, Validator(f, pre, whole, always) + f_cls.__validator_config = fields, Validator(f, pre, whole, always, check_fields) return f_cls return dec diff --git a/tests/test_create_model.py b/tests/test_create_model.py index bf9e41b..c1cefc3 100644 --- a/tests/test_create_model.py +++ b/tests/test_create_model.py @@ -63,7 +63,7 @@ def test_custom_config(): def test_inheritance_validators(): class BarModel(BaseModel): - @validator('a') + @validator('a', check_fields=False) def check_a(cls, v): if 'foobar' not in v: raise ValueError('"foobar" not found in a') diff --git a/tests/test_validators.py b/tests/test_validators.py index e213285..d0db965 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -277,3 +277,15 @@ def test_wildcard_validator_error(): Model(a='snap') assert '"foobar" not found in a' in str(exc_info.value) assert len(exc_info.value.errors_dict) == 2 + + +def test_invalid_field(): + with pytest.raises(ConfigError) as exc_info: + class Model(BaseModel): + a: str + + @validator('b') + def check_b(cls, v): + return v + assert str(exc_info.value) == ("Validators defined with incorrect fields: check_b " + "(use check_fields=True if you're inheriting from the model and intended this)")