"""
Abstract base class for descriptors backed by protected/private attributes.
"""
# Part of typed-descriptors
# Copyright (C) 2023 Hashberg Ltd
from __future__ import annotations
from abc import abstractmethod
import sys
from typing import (
Any,
Literal,
Optional,
Protocol,
Type,
TypeVar,
Union,
cast,
final,
overload,
runtime_checkable,
)
from typing_extensions import Self
from typing_validation import can_validate, validate
[docs]
def is_dict_available(owner: type) -> bool:
"""
Checks whether instances of a descriptor owner class have ``__dict__``.
Returns :obj:`True` if the MRO root ``owner.__mro__[-1]`` is not
:obj:`object`, or if the following is true for any class ``cls`` in
``owner.__mro__[:-1]`` (i.e. excluding the MRO root):
1. ``cls`` does not define ``__slots__``, or
2. ``__dict__`` appears in the ``__slots__`` for ``cls``
"""
mro = owner.__mro__
assert mro[-1] == object, "All classes should inherit from object."
for cls in mro[:-1]:
if not hasattr(cls, "__slots__"):
return True
if "__slots__" not in cls.__dict__:
return True
if "__dict__" in cls.__slots__:
return True
return False
[docs]
def class_slots(cls: type) -> tuple[str, ...] | None:
"""
Returns a tuple consisting of all slots for the given class and all
non-private slots for all classes in its MRO.
Returns :obj:`None` if slots are not defined for the class.
"""
if not hasattr(cls, "__slots__"):
return None
slots: list[str] = list(cls.__slots__)
for cls in cls.__mro__[1:-1]:
for slot in getattr(cls, "__slots__", ()):
assert isinstance(slot, str)
if slot.startswith("__") and not slot.endswith("__"):
continue
slots.append(slot)
return tuple(slots)
[docs]
def name_mangle(owner: type, attr_name: str) -> str:
"""
If the given attribute name is private and not dunder,
return its name-mangled version for the given owner class.
"""
if not attr_name.startswith("__"):
return attr_name
if attr_name.endswith("__"):
return attr_name
return f"_{owner.__name__}{attr_name}"
[docs]
def name_unmangle(owner: type, attr_name: str) -> str:
"""
If the given attribute name is name-mangled for the given owner class,
removes the name-mangling prefix.
"""
name_mangling_prefix = f"_{owner.__name__}"
if attr_name.startswith(name_mangling_prefix + "__"):
return attr_name[len(name_mangling_prefix) :]
return attr_name
T = TypeVar("T")
""" Invariant type variable for generic values. """
T_co = TypeVar("T_co", covariant=True)
""" Invariant type variable for generic values. """
[docs]
@runtime_checkable
class TypedDescriptor(Protocol[T_co]):
"""
Structural type for typed descriptors.
"""
__descriptor_type__: Any # Will be TypeForm[T_co]
"""
Indicates the type (or type annotation, if a string) for the descriptor.
:meta public:
"""
[docs]
def __set_name__(self, owner: Type[Any], name: str) -> None:
"""
Hook called when the descriptor is assigned to a class attribute,
usually responsible for setting the owner and name of the descriptor.
:meta public:
"""
@overload
def __get__(self, instance: None, _: Type[Any]) -> Self: ...
@overload
def __get__(self, instance: Any, _: Type[Any]) -> T_co: ...
[docs]
def __get__(self, instance: Any, _: Type[Any]) -> T_co | Self:
"""
If the descriptor is accessed on an instance, returns the value of
the descriptor on the given instance.
If the descriptor is accessed on the owner class, i.e. if
``instance`` is :obj:`None`, returns the descriptor object itself.
:meta public:
"""
[docs]
class DescriptorBase(TypedDescriptor[T]):
"""
Base class for descriptors backed by an attribute whose name and access mode
is determined by the following logic.
Logic for using ``__dict__`` vs "attr" functions for access to the
backing attribute:
1. If the ``use_dict`` argument is set to :obj:`True` in the descriptor
constructor, then ``__dict__`` will be used. If the library is certain
that ``__dict__`` is not available on instances of the descriptor owner
class (cf. :func:`is_dict_available`), then a :obj:`TypeError` is raised
at the time when ``__set_name__`` is called.
2. If the ``use_slots`` argument is set to :obj:`True` in the descriptor
constructor, then the "attr" functions :func:`getattr`, :func:`setattr`,
:func:`delattr` and :func:`hasattr` will be used. If the library is
certain that ``__dict__`` is not available on instances of the descriptor
owner class (cf. :func:`is_dict_available`) and the backing attribute
name is not present in the class slots (cf. :func:`class_slots`), then a
:obj:`TypeError` is raised at the time when ``__set_name__`` is called.
3. If neither ``use_dict`` nor ``use_slots__`` is set to :obj:`True` in the
descriptor constructor (the default case), then :func:`is_dict_available`
is called and the result is used to determine whether to use ``__dict__``
or slots for the backing attribute. Further validation is then performed,
as described in points 1 and 2 above.
Naming logic for the backing attribute:
1. If the ``backed_by`` argument is specified in the descriptor constructor,
the string passed to it is used as name for the backing attribute.
2. Else, if using ``__dict__`` for access to the backing attribute, then the
backing attribute name coincides with the descriptor name.
3. Else, the backing attribute name is obtained by prepending one or
two underscores to the descriptor name (one if the descriptor name starts
with underscore, two if it doesn't).
If the backing attribute name starts with two underscores but does not end
with two underscores, name-mangling is automatically performed.
"""
# Attributes set by constructor:
__type: Union[Type[T], Any]
# Attributes set by __set_name__:
__name: str
__owner: Type[Any]
__backed_by: str
__use_dict: bool
# Attribute set by constructor and deleted by __set_name__:
__temp_use_dict: Optional[bool]
__temp_backed_by: Optional[str]
__slots__ = (
"__type",
"__name",
"__owner",
"__backed_by",
"__use_dict",
"__temp_use_dict",
"__temp_backed_by",
"__descriptor_type__",
)
@overload
def __init__(
self,
type: Type[T],
/,
*,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
) -> None:
# pylint: disable = redefined-builtin
...
@overload
def __init__(
self,
type: Any,
/,
*,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
) -> None:
# pylint: disable = redefined-builtin
...
[docs]
def __init__(
self,
type: Type[T] | Any, # will be TypeForm[T] one day
/,
*,
backed_by: Optional[str] = None,
use_dict: Optional[Literal[True]] = None,
use_slots: Optional[Literal[True]] = None,
) -> None:
"""
Creates a new descriptor with the given type and optional validator.
:param type: the type of the descriptor.
:param backed_by: name for the backing attribute (optional, default name
used if not specified).
:param use_dict: if set to :obj:`True`, ``__dict__`` will be used to
store the the backing attribute.
:param use_dict: if set to :obj:`True`, ``__slots__`` will be used to
store the the backing attribute.
:raises TypeError: if the type cannot be validated by the
:mod:`typing_validation` library.
:meta public:
"""
# pylint: disable = redefined-builtin
if not can_validate(type):
raise TypeError(f"Cannot validate type {type!r}.")
validate(backed_by, Optional[str])
if use_dict and use_slots:
raise ValueError(
"Cannot set both use_dict=True and use_slots=True."
)
self.__type = type
self.__temp_backed_by = backed_by
self.__temp_use_dict = (
True if use_dict else False if use_slots else None
)
self.__descriptor_type__ = type
@final
@property
def name(self) -> str:
"""
The name of the attribute.
"""
return self.__name
@final
@property
def type(self) -> Union[Type[T], Any]: # will be TypeForm[T] one day
"""
The type of the attribute.
"""
return self.__type
@final
@property
def owner(self) -> Type[Any]:
"""
The class that owns the attribute.
"""
return self.__owner
@final
def _get_on(self, instance: Any) -> T:
"""
Gets the value of the backing attribute on the given instance.
"""
if self.__use_dict:
try:
return cast(T, instance.__dict__[self.__backed_by])
except KeyError:
raise AttributeError(
f"{self.__owner.__name__!r} object has no "
f"attribute {self.__backed_by!r}."
) from None
return cast(T, getattr(instance, self.__backed_by))
@final
def _set_on(self, instance: Any, value: T) -> None:
"""
Sets the value of the backing attribute on the given instance.
"""
if self.__use_dict:
instance.__dict__[self.__backed_by] = value
else:
setattr(instance, self.__backed_by, value)
@final
def _del_on(self, instance: Any) -> None:
"""
Deletes the value of the backing attribute on the given instance.
"""
if self.__use_dict:
try:
del instance.__dict__[self.__backed_by]
except KeyError:
raise AttributeError(
f"{self.__owner.__name__!r} object has no "
f"attribute {self.__backed_by!r}."
) from None
else:
delattr(instance, self.__backed_by)
@final
def _is_set_on(self, instance: Any) -> bool:
"""
Checkes whether the value of the backing attribute is set on the
given instance.
"""
if self.__use_dict:
return self.__backed_by in instance.__dict__
return hasattr(instance, self.__backed_by)
@final
@property
def is_assigned(self) -> bool:
"""
Whether the descriptor has been assigned its owner and name.
"""
return hasattr(self, "_DescriptorBase__owner")
[docs]
@final
def __set_name__(self, owner: Type[Any], name: str) -> None:
"""
Hook called when the descriptor is assigned to a class attribute.
Responsible for:
- Setting the owner and name of the descriptor
- Setting the name of the backing attribute (incl. name-mangling)
- Determining how to access the backing attribute.
See :class:`DescriptorBase` class documentation for the logic behind
the backing attribute's name and access mode.
:raises TypeError: if the descriptor is assigned more than once
:raises TypeError: if the owner class has ``__slots__`` and the
descriptor ``name`` appears in ``__slots__``
:raises TypeError: if the owner class has ``__slots__``, ``__dict__`` is
not in ``__slots__``, and the backing attribute name
is not in ``__slots__``.
:meta public:
"""
# 1. Ensure descriptor is not assigned twice:
if self.is_assigned:
raise TypeError(
"Cannot set owner/name for the same descriptor twice."
)
# 2. If descriptor name is mangled, unmangle it:
name = name_unmangle(owner, name)
# 3. Compute backing attribute name and whether to use __dict__:
temp_backed_by = self.__temp_backed_by
temp_use_dict = self.__temp_use_dict
dict_available = is_dict_available(owner)
owner_slots = class_slots(owner)
use_dict = dict_available if temp_use_dict is None else temp_use_dict
if use_dict:
if not dict_available:
raise TypeError(
"Cannot set use_dict=True in descriptor constructor: "
"__dict__ not available on instances of owner class."
)
if temp_backed_by is None:
temp_backed_by = name
else:
if owner_slots is None:
raise TypeError(
"Slots are not available on descriptor owner class: "
"please set use_dict=True in the descriptor constructor."
)
if temp_backed_by is None:
if name.startswith("_"):
temp_backed_by = f"_{name}"
else:
temp_backed_by = f"__{name}"
if temp_backed_by not in owner_slots:
raise TypeError(
f"Backing attribute {temp_backed_by!r} does not appear in "
"the slots of the descriptor owner class. You can either: "
"(i) add the backing attribute name to the __slots__, or "
"(ii) set use_dict=True in the descriptor constructor, "
"as long as __dict__ is available on owner class instances."
)
backed_by = name_mangle(owner, temp_backed_by)
# 4. Set owner, name (not name-mangled) and backed_by (name-mangled):
self.__owner = owner
self.__name = name
self.__backed_by = backed_by
self.__use_dict = use_dict
del self.__temp_backed_by
@abstractmethod
@overload
def __get__(self, instance: None, _: Type[Any]) -> Self: ...
@abstractmethod
@overload
def __get__(self, instance: Any, _: Type[Any]) -> T: ...
[docs]
@abstractmethod
def __get__(self, instance: Any, _: Type[Any]) -> T | Self:
"""
If the descriptor is accessed on an instance, returns the value of
the descriptor on the given instance.
If the descriptor is accessed on the owner class, i.e. if
``instance`` is :obj:`None`, returns the descriptor object.
:meta public:
"""