"""
Descriptor class for attributes.
"""
# Part of typed-descriptors
# Copyright (C) 2023 Hashberg Ltd
from __future__ import annotations
from inspect import signature
from typing import (
Any,
Literal,
Optional,
Protocol,
Type,
TypeVar,
Union,
final,
get_type_hints,
overload,
)
from typing_extensions import Self
from typing_validation import validate
from .base import DescriptorBase, T
_T = TypeVar("_T")
""" Invariant type variable for generic values, privately used. """
T_contra = TypeVar("T_contra", contravariant=True)
""" Contravariant type variable for generic values. """
[docs]
class SupportsBool(Protocol):
"""
Structural types for things which can be converted to :obj:`bool`.
"""
def __bool__(self) -> bool: ...
[docs]
class ValidatorFunction(Protocol[T_contra]):
"""
Structural type for the validator function of a :class:`Attr`.
"""
def __call__(
self, instance: Any, value: T_contra, /
) -> Union[SupportsBool, None]:
"""
Validates the given value for assignment to a :class:`Attr`,
in the context of the given instance.
Called passing the current ``instance`` and the ``value`` that is
to be assigned to the descriptor.
At the time when the validator function for a descriptor is invoked,
the given ``value`` has already passed its runtime typecheck.
Validator functions can use ``instance`` to perform validation
involving other descriptors for the same class.
There are two ways in which a validator function can trigger an error
as part of the validation logic for an :class:`Attr`:
- By returning :obj:`False`: a :obj:`ValueError` will be raised.
- By raising any exception as part of its body: the exception is
caught as part of a `try...except` block, and a :obj:`ValueError`
is raised from it (preserving the original error information).
In the second case, the validator should return :obj:`True` at the
end, to signal that validation was successful.
"""
...
[docs]
def validate_validator_fun(validator_fun: ValidatorFunction[T], /) -> None:
"""
Runtime validation for validator functions.
:raises TypeError: if the argument is not a validator function
"""
if not callable(validator_fun):
raise TypeError("Validator function must be callable.")
if len(signature(validator_fun).parameters) != 2:
raise TypeError("Validator function must take exactly two arguments.")
[docs]
def validator_fun_value_type(validator_fun: ValidatorFunction[T], /) -> Any:
"""
Returns the type annotation for the ``value`` argument of a validator
function. Used by :meth:`Attr.validator` to infer the :class:`Attr`
type from the validator function type hints.
:raises TypeError: if the argument is not a validator function
:raises ValueError: if the function doesn't have an explicit annotation
for its ``value`` argument type or its return type.
"""
validate_validator_fun(validator_fun)
validator_fun_types = get_type_hints(validator_fun)
validator_fun_sig = signature(validator_fun)
validator_fun_params = tuple(validator_fun_sig.parameters.keys())
value_argname = validator_fun_params[1]
if value_argname not in validator_fun_types:
raise ValueError(
"Validator function must explicitly annotate the type for its "
f"second argument {value_argname!r}."
)
return validator_fun_types[value_argname]
[docs]
class ValidatedAttrFactory(Protocol):
"""
Structural type for functions which create :class:`Attr` instances
from validator functions.
"""
def __call__(self, validator_fun: ValidatorFunction[T], /) -> Attr[T]:
"""
Returns a validated :class:`Attr` from a validator function.
"""
...
[docs]
class Attr(DescriptorBase[T]):
"""
A descriptor class for attributes, supporting:
- static type checking for the attribute value
- runtime type checking of values assigned to the attribute
- optional runtime validation of values assigned to the attribute
- optional read-only restrictions on the attribute (set once)
See :class:`DescriptorBase` for details on how the attribute value is
stored in each instance.
"""
@staticmethod
@overload
def validator(
validator_fun: ValidatorFunction[T],
/,
*,
readonly: bool = False,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
typecheck: bool = True,
) -> Attr[T]: ...
@staticmethod
@overload
def validator(
validator_fun: None = None,
/,
*,
readonly: bool = False,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
typecheck: bool = True,
) -> ValidatedAttrFactory: ...
[docs]
@staticmethod
def validator(
validator_fun: Optional[ValidatorFunction[T]] = None,
/,
*,
readonly: bool = False,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
typecheck: bool = True,
) -> ValidatedAttrFactory | Attr[T]:
"""
Decorator used to create an :class:`Attr` from a validator function,
optionally specifying a readonly modifier and a backing attribute.
It can be used directly, for mutable attributes with default backing
attribute name:
.. code-block ::
class MyClass:
@Attr.validator
def x(self, value: Sequence[str]) -> bool:
''' Validator function for attribute 'C.x'. '''
return 1 <= len(value) <= 3
It can be used by supplying ``readonly`` and ``backed_by`` values,
for more general attributes:
.. code-block ::
class MyClass:
@Attr.validator(readonly=True)
def x(self, value: int|str) -> bool:
''' Validator function for readonly attribute 'C.x'. '''
if isinstance(value, int):
return value > 0
return len(value) > 0
@Attr.validator(backed_by='_w')
def w(self, value: Sequence[int]) -> bool:
''' Validator function for mutable attribute 'C.w'. '''
return len(value) in range(3)
__slots__ = ("__x", "_w")
# default ^^^^^ ^^^^ custom
# backing attributes
"""
if validator_fun is not None:
ty = validator_fun_value_type(validator_fun)
return Attr(
ty,
validator=validator_fun,
readonly=readonly,
backed_by=backed_by,
use_dict=use_dict,
use_slots=use_slots,
typecheck=typecheck,
)
def _validated_attr(validator_fun: ValidatorFunction[_T]) -> Attr[_T]:
return Attr.validator(
validator_fun,
readonly=readonly,
backed_by=backed_by,
use_dict=use_dict,
use_slots=use_slots,
typecheck=typecheck,
)
return _validated_attr
__readonly: bool
__typecheck: bool
__validator_fun: Optional[ValidatorFunction[T]]
@overload
def __init__(
self,
type: Type[T],
/,
validator: Optional[ValidatorFunction[T]] = None,
*,
readonly: bool = False,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
typecheck: bool = True,
) -> None:
# pylint: disable = redefined-builtin
...
@overload
def __init__(
self,
type: Any,
/,
validator: Optional[ValidatorFunction[T]] = None,
*,
readonly: bool = False,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
typecheck: bool = True,
) -> None:
# pylint: disable = redefined-builtin
...
[docs]
def __init__(
self,
type: Type[T] | Any,
/,
validator: Optional[ValidatorFunction[T]] = None,
*,
readonly: bool = False,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
typecheck: bool = True,
) -> None:
"""
Creates a new attribute with the given type and optional validator.
:param ty: the type of the attribute
:param validator: an optional validator function for the attribute
:param readonly: whether the attribute is read-only
:param typecheck: whether to perform dynamic typechecks (default: True)
:raises TypeError: if the type is not a valid type
:raises TypeError: if the validator is not callable
:meta public:
"""
# pylint: disable = redefined-builtin
super().__init__(
type,
backed_by=backed_by,
use_dict=use_dict,
use_slots=use_slots,
)
if validator is not None:
validate_validator_fun(validator)
self.__doc__ = validator.__doc__
self.__validator_fun = validator
self.__readonly = bool(readonly)
self.__typecheck = bool(typecheck)
@final
@property
def readonly(self) -> bool:
"""
Whether the attribute is readonly.
"""
return self.__readonly
@final
@property
def validator_fun(self) -> Optional[ValidatorFunction[T]]:
"""
The custom validator function for the attribute,
or :obj:`None` if no validator was specified.
There are two ways in which the validator can trigger an error:
- By returning :obj:`False`: a :obj:`ValueError` is raised
- By raising any exception as part of its body: the exception is
caught as part of a `try...except` block, and a :obj:`ValueError`
is raised from it (preserving the original error information).
In the second case, the validator should return :obj:`True` at the
end, to signal that validation was successful.
"""
return self.__validator_fun
[docs]
@final
def is_defined_on(self, instance: Any) -> bool:
"""
Wether the attribute is defined on the given instance.
"""
validate(instance, self.owner)
return self._is_set_on(instance)
@overload
def __get__(self, instance: None, _: Type[Any]) -> Self: ...
@overload
def __get__(self, instance: Any, _: Type[Any]) -> T: ...
[docs]
@final
def __get__(self, instance: Any, _: Type[Any]) -> T | Self:
"""
If the descriptor is accessed on an instance, returns the value of
the attribute on the given instance.
If the descriptor is accessed on the owner class, i.e. if
``instance`` is :obj:`None`, returns the :class:`Attr` object.
:raises AttributeError: if the attribute is not defined,
see :meth:`is_defined_on`.
:meta public:
"""
if instance is None:
return self
try:
return self._get_on(instance)
# return cast(T, getattr(instance, self.attr_name))
except AttributeError:
raise AttributeError(f"Attribute {self} is not set.") from None
[docs]
@final
def __set__(self, instance: Any, value: T) -> None:
"""
Sets the value of the descriptor on the given instance.
:raises AttributeError: if the attribute is readonly and it already
has a value assigned to it
:raises TypeError: if the value has the wrong type
:raises ValueError: if a custom validator is specified and the
value is invalid
:meta public:
"""
if self.readonly:
if self._is_set_on(instance):
raise AttributeError(
f"Attribute {self} is readonly: it can only be set once."
)
if self.__typecheck:
validate(value, self.type)
validator = self.__validator_fun
if validator is not None:
try:
res = validator(instance, value)
if res is not None and not res:
raise ValueError(
f"Invalid value for attribute {self}: {value!r}"
)
except ValueError as e:
raise ValueError(
f"Invalid value for attribute {self}: {value!r}"
) from e
self._set_on(instance, value)
@final
def __delete__(self, instance: Any) -> None:
"""
Deletes the value of the descriptor on the given instance.
:raises AttributeError: if the attribute is readonly
:raises AttributeError: if the attribute is not defined,
see :meth:`is_defined_on`.
:meta public:
"""
if self.readonly:
raise AttributeError(
f"Attribute {self.name!r} is readonly: it cannot be deleted."
)
if not self._is_set_on(instance):
raise AttributeError(f"Attribute {self} is not set.")
self._del_on(instance)
def __str__(self) -> str:
"""
Representation of this attribute, inclusive of the following info:
- the :attr:`owner` name
- the attribute :attr:`name`
An example:
.. code-block ::
Color.hue
"""
owner_name = self.owner.__name__
name = self.name
return f"{owner_name}.{name}"
def __repr__(self) -> str:
"""
Representation of this attribute, inclusive of the following info:
- the :attr:`owner` name
- the attribute :attr:`name`
- the attribute :attr:`type`
- optional ``readonly`` qualifier
Two examples:
.. code-block ::
<Attr Color.hue: str>
<readonly Attr Color.saturation: int>
"""
owner = self.owner.__name__
name = self.name
ty = (
self.type.__name__
if isinstance(self.type, type)
else str(self.type)
)
qualifier = "readonly " if self.readonly else ""
return f"<{qualifier}Attr {owner}.{name}: {ty}>"