diff --git a/changes/1466-MrMrRobat.md b/changes/1466-MrMrRobat.md new file mode 100644 index 0000000000..1e1cb3b1cc --- /dev/null +++ b/changes/1466-MrMrRobat.md @@ -0,0 +1 @@ +Make `BaseModel.__signature__` class-only, so getting `__signature__` from model instance will raise `AttributeError` \ No newline at end of file diff --git a/pydantic/main.py b/pydantic/main.py index e1161d7fcd..bb01d2e3cc 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -35,6 +35,7 @@ from .types import PyObject, StrBytes from .typing import AnyCallable, AnyType, ForwardRef, is_classvar, resolve_annotations, update_field_forward_refs from .utils import ( + ClassAttribute, GetterDict, Representation, ValueItems, @@ -300,7 +301,8 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901 } cls = super().__new__(mcs, name, bases, new_namespace, **kwargs) - cls.__signature__ = generate_model_signature(cls.__init__, fields, config) + # set __signature__ attr only for model class, but not for its instances + cls.__signature__ = ClassAttribute('__signature__', generate_model_signature(cls.__init__, fields, config)) return cls diff --git a/pydantic/utils.py b/pydantic/utils.py index 1a31192a5e..fdd4f59a07 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -45,6 +45,7 @@ 'GetterDict', 'ValueItems', 'version_info', # required here to match behaviour in v1.3 + 'ClassAttribute', ) @@ -439,3 +440,23 @@ def _normalize_indexes( def __repr_args__(self) -> 'ReprArgs': return [(None, self._items)] + + +class ClassAttribute: + """ + Hide class attribute from its instances + """ + + __slots__ = ( + 'name', + 'value', + ) + + def __init__(self, name: str, value: Any) -> None: + self.name = name + self.value = value + + def __get__(self, instance: Any, owner: Type[Any]) -> None: + if instance is None: + return self.value + raise AttributeError(f'{self.name!r} attribute of {owner.__name__!r} is class-only') diff --git a/tests/test_model_signature.py b/tests/test_model_signature.py index 6b384a7b15..789372b9b3 100644 --- a/tests/test_model_signature.py +++ b/tests/test_model_signature.py @@ -126,3 +126,15 @@ class Config: extra = Extra.allow assert _equals(str(signature(Model)), '(extra_data: int = 1, **foobar: Any) -> None') + + +def test_signature_is_class_only(): + class Model(BaseModel): + foo: int = 123 + + def __call__(self, a: int) -> bool: + pass + + assert _equals(str(signature(Model)), '(*, foo: int = 123) -> None') + assert _equals(str(signature(Model())), '(a: int) -> bool') + assert not hasattr(Model(), '__signature__') diff --git a/tests/test_utils.py b/tests/test_utils.py index a58e14f688..feb373ce60 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,7 +12,15 @@ from pydantic.dataclasses import dataclass from pydantic.fields import Undefined from pydantic.typing import display_as_type, is_new_type, new_type_supertype -from pydantic.utils import ValueItems, deep_update, get_model, import_string, lenient_issubclass, truncate +from pydantic.utils import ( + ClassAttribute, + ValueItems, + deep_update, + get_model, + import_string, + lenient_issubclass, + truncate, +) from pydantic.version import version_info try: @@ -298,3 +306,17 @@ def test_version_info(): def test_version_strict(): assert str(StrictVersion(VERSION)) == VERSION + + +def test_class_attribute(): + class Foo: + attr = ClassAttribute('attr', 'foo') + + assert Foo.attr == 'foo' + + with pytest.raises(AttributeError, match="'attr' attribute of 'Foo' is class-only"): + Foo().attr + + f = Foo() + f.attr = 'not foo' + assert f.attr == 'not foo'