diff --git a/HISTORY.rst b/HISTORY.rst index 8e4306b..3f5da54 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -12,6 +12,7 @@ v0.31 (unreleased) * add advanced exclude support for ``dict``, ``json`` and ``copy``, #648 by @MrMrRobat * fix bug in ``GenericModel`` for models with concrete parameterized fields, #672 by @dmontagu * add documentation for Literal type, #651 by @dmontagu +* add ``Config.keep_untouched`` for custom descriptors support, #679 by @MrMrRobat * use ``inspect.cleandoc`` internally to get model description, #657 by @tiangolo * add Color to schema generation, by @euri10 diff --git a/docs/index.rst b/docs/index.rst index a7112ee..47389fc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -762,6 +762,8 @@ Options: details. :orm_mode: allows usage of :ref:`ORM mode ` :alias_generator: callable that takes field name and returns alias for it +:keep_untouched: tuple of types (e. g. descriptors) that won't change during model creation and won't be + included in the model schemas. .. warning:: diff --git a/pydantic/main.py b/pydantic/main.py index ff63b91..624ba2a 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -97,6 +97,7 @@ class BaseConfig: json_encoders: Dict[AnyType, AnyCallable] = {} orm_mode: bool = False alias_generator: Optional[Callable[[str], str]] = None + keep_untouched: Tuple[type, ...] = () @classmethod def get_field_schema(cls, name: str) -> Dict[str, str]: @@ -169,7 +170,7 @@ def validate_custom_root_type(fields: Dict[str, Field]) -> None: raise TypeError('custom root type cannot allow mapping') -TYPE_BLACKLIST = FunctionType, property, type, classmethod, staticmethod +UNTOUCHED_TYPES = FunctionType, property, type, classmethod, staticmethod class MetaModel(ABCMeta): @@ -217,10 +218,11 @@ class MetaModel(ABCMeta): config=config, ) + untouched_types = UNTOUCHED_TYPES + config.keep_untouched for var_name, value in namespace.items(): if ( is_valid_field(var_name) - and (annotations.get(var_name) == PyObject or not isinstance(value, TYPE_BLACKLIST)) + and (annotations.get(var_name) == PyObject or not isinstance(value, untouched_types)) and var_name not in class_vars ): validate_field_name(bases, var_name) diff --git a/pydantic/validators.py b/pydantic/validators.py index 7ecb9de..a5a5136 100644 --- a/pydantic/validators.py +++ b/pydantic/validators.py @@ -475,7 +475,9 @@ def find_validators( # noqa: C901 (ignore complexity) if config.arbitrary_types_allowed: yield make_arbitrary_type_validator(type_) else: - raise RuntimeError(f'no validator found for {type_}') + raise RuntimeError( + f'no validator found for {type_} see `keep_untouched` or `arbitrary_types_allowed` in Config' + ) def _find_supertype(type_: AnyType) -> Optional[AnyType]: diff --git a/tests/test_main.py b/tests/test_main.py index 5bf39d2..0ea0073 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -743,3 +743,66 @@ def test_parse_root_as_mapping(): class MyModel(BaseModel): __root__: Mapping[str, str] + + +def test_untouched_types(): + from pydantic import BaseModel + + class _ClassPropertyDescriptor: + def __init__(self, getter): + self.getter = getter + + def __get__(self, instance, owner): + return self.getter(owner) + + classproperty = _ClassPropertyDescriptor + + class Model(BaseModel): + class Config: + keep_untouched = (classproperty,) + + @classproperty + def class_name(cls) -> str: + return cls.__name__ + + assert Model.class_name == 'Model' + assert Model().class_name == 'Model' + + +def test_custom_types_fail_without_keep_untouched(): + from pydantic import BaseModel + + class _ClassPropertyDescriptor: + def __init__(self, getter): + self.getter = getter + + def __get__(self, instance, owner): + return self.getter(owner) + + classproperty = _ClassPropertyDescriptor + + with pytest.raises(RuntimeError) as e: + + class Model(BaseModel): + @classproperty + def class_name(cls) -> str: + return cls.__name__ + + Model.class_name + + assert str(e.value) == ( + "no validator found for ." + "_ClassPropertyDescriptor'> see `keep_untouched` or `arbitrary_types_allowed` in Config" + ) + + class Model(BaseModel): + class Config: + arbitrary_types_allowed = True + + @classproperty + def class_name(cls) -> str: + return cls.__name__ + + with pytest.raises(AttributeError) as e: + Model.class_name + assert str(e.value) == "type object 'Model' has no attribute 'class_name'"