Skip to content

Commit

Permalink
Merge pull request #18 from matthewwardrop/add_deprecated_alias
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewwardrop authored Jan 4, 2024
2 parents 66ece75 + 2e3de36 commit 3c02d2f
Show file tree
Hide file tree
Showing 5 changed files with 320 additions and 92 deletions.
4 changes: 3 additions & 1 deletion spec_classes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from ._version import __version__, __version_tuple__
from .errors import FrozenInstanceError
from .spec_class import spec_class
from .types import Attr, AttrProxy, MISSING, spec_property
from .types import Alias, DeprecatedAlias, Attr, AttrProxy, MISSING, spec_property

__author__ = "Matthew Wardrop"
__author_email__ = "mpwardrop@gmail.com"
Expand All @@ -14,6 +14,8 @@
"spec_class",
"FrozenInstanceError",
"MISSING",
"Alias",
"DeprecatedAlias",
"Attr",
"AttrProxy",
"spec_property",
Expand Down
3 changes: 3 additions & 0 deletions spec_classes/types/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .alias import Alias, DeprecatedAlias
from .attr import Attr
from .attr_proxy import AttrProxy
from .keyed import KeyedList, KeyedSet
Expand All @@ -6,6 +7,8 @@
from .validated import ValidatedType, bounded, validated

__all__ = (
"Alias",
"DeprecatedAlias",
"Attr",
"AttrProxy",
"KeyedList",
Expand Down
170 changes: 170 additions & 0 deletions spec_classes/types/alias.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import warnings
from typing import Any, Callable, Optional, Type

from .missing import MISSING


class Alias:
"""
Allows one attribute to act as a alias/proxy for another.
This is especially useful if you have changed the name of an attribute and
need to provide backwards compatibility for an indefinite period; or if one
attribute is supposed to mirror another attribute unless overwritten (e.g.
the label of a class might be the "key" unless overwritten).
When a class attribute is assigned a value of `Alias('<attribute name>')`,
that attribute will proxy/mirror the value of '<attribute name>' instead. If
some transform is required to satisfy (e.g.) types, then you can also
optionally specify a unary transform using `Alias('<attribute name>',
transform=<function>)`. By default, aliased attributes are locally mutable;
that is, they store local overrides when assigned new values. If you want
mutations to be passed through to the aliased attribute, then you need to
specify `passthrough=True`.
A `fallback` can also be specified for whenever the host attribute has not
been specified or results in an AttributeError when `Alias` attempts to
retrieve it.
Attributes:
attr: The name of the attribute to be aliased.
passthrough: Whether to pass through mutations of the `Alias`
attribute through to the aliased attribute. (default: False)
transform: An optional unary transform to apply to the value of the
aliased attribute before returning it as the value of this
attribute.
fallback: An optional default value to return if the attribute being
aliased does not yet have a value (otherwise any errors retrieving
the underlying aliased attribute value are passed through).
"""

def __init__(
self,
attr: str,
*,
passthrough: bool = False,
transform: Optional[Callable[[Any], Any]] = None,
fallback: Any = MISSING,
):
self.attr = attr
self.transform = transform
self.passthrough = passthrough
self.fallback = fallback

self._owner = None
self._owner_attr = None

@property
def override_attr(self):
if not self._owner_attr and not self.passthrough:
return None
return (
self.attr
if self.passthrough
else f"__spec_classes_Alias_{self._owner_attr}_override"
)

def __get__(self, instance: Any, owner=None):
if instance is None:
return self
if self._owner_attr and hasattr(instance, self.override_attr):
return getattr(instance, self.override_attr)
try:
return (self.transform or (lambda x: x))(getattr(instance, self.attr))
except AttributeError:
if self.fallback is not MISSING:
return self.fallback
raise
except RecursionError as e:
raise ValueError(
f"{self.__class__.__name__} for `{instance.__class__.__name__}.{self.attr}` "
"appears to be self-referential. Please change the `attr` argument to point "
"to a different attribute."
) from e

def __set__(self, instance, value):
if not self.override_attr:
raise RuntimeError(
f"Attempting to set the value of an `{self.__class__.__name__}` "
"instance that is not properly associated with a class."
)
setattr(instance, self.override_attr, value)

def __delete__(self, instance):
delattr(instance, self.override_attr)

def __set_name__(self, owner, name):
self._owner = owner
self._owner_attr = name


class DeprecatedAlias(Alias):
"""
A subclass of `Alias` that emits deprecation warnings upon interactions.
Note that `passthrough` is enabled by default for these aliases, since most
of the time these will be used for backwards compatibility and need to be
kept in sync with the new attribute values.
"""

def __init__(
self,
attr: str,
*,
passthrough: bool = True,
transform: Optional[Callable[[Any], Any]] = None,
fallback: Any = MISSING,
as_of: Optional[str] = None,
until: Optional[str] = None,
warning_cls: Type[Warning] = DeprecationWarning,
):
"""
Args:
attr: The attribute to proxy.
passthrough: Whether to pass through mutations of the `Alias`
attribute through to the aliased attribute. (default: True)
transform: An optional unary transform to apply to the value of the
aliased attribute before returning it as the value of this
attribute.
fallback: An optional default value to return if the attribute being
aliased does not yet have a value (otherwise any errors retrieving
the underlying aliased attribute value are passed through).
as_of: The version as of which the attribute was deprecated.
until: The version after which this alias will be removed.
warning_cls: The warning class to use when emitting warnings.
"""
super().__init__(
attr, passthrough=passthrough, transform=transform, fallback=fallback
)
self.as_of = as_of
self.until = until
self.warning_cls = warning_cls

def __warn(self):
msg = []
if self.as_of:
msg.append(
f"`{self._owner.__name__}.{self._owner_attr}` was deprecated in version {self.as_of}."
)
else:
msg.append(
f"`{self._owner.__name__}.{self._owner_attr}` has been deprecated."
)
msg.append(f"Please use `{self._owner.__name__}.{self.attr}` instead.")
if self.until:
msg.append(
f"This deprecated alias will be removed in version {self.until}."
)
warnings.warn(" ".join(msg), self.warning_cls, stacklevel=3)

def __get__(self, instance, owner=None):
self.__warn()
return super().__get__(instance, owner)

def __set__(self, instance, value):
self.__warn()
super().__set__(instance, value)

def __delete__(self, instance):
self.__warn()
super().__delete__(instance)
101 changes: 10 additions & 91 deletions spec_classes/types/attr_proxy.py
Original file line number Diff line number Diff line change
@@ -1,98 +1,17 @@
from typing import Any, Callable, Optional
import warnings

from .missing import MISSING
from .alias import Alias


class AttrProxy:
class AttrProxy(Alias):
"""
Allows one attribute to act as a proxy for another.
This is especially useful if you have changed the name of an attribute and
need to provide backwards compatibility for an indefinite period; or if one
attribute is supposed to mirror another attribute unless overwritten (e.g.
the label of a class might be the "key" unless overwritten). This
functionality could obviously be implemented directly using property, which
may be advisable if readability is more important than concision.
When a class attribute is assigned a value of `AttrProxy('<attribute
name>')`, that attribute will proxy/mirror the value of '<attribute name>'
instead. If some transform is required to satisfy (e.g.) types, then you can
also optionally specify a unary transform using `AttrProxy('<attribute
name>', transform=<function>)`. By default, proxied attributes are locally
mutable; that is, they store local overrides when assigned new values. If
you want mutations to be passed through to the proxied attribute, then you
need to specify `passthrough=True`.
A `fallback` can also be specified for whenever the host attribute has not
been specified or results in an AttributeError when `AttrProxy` attempts to
retrieve it.
Attributes:
attr: The name of the attribute to be proxied.
passthrough: Whether to pass through mutations of the `AttrProxy`
attribute through to the proxied attribute. (default: False)
transform: An optional unary transform to apply to the value of the
proxied attribute before returning it as the value of this
attribute.
fallback: An optional default value to return if the attribute being
proxied does not yet have a value (otherwise any errors retrieving
the underlying proxied attribute value are passed through).
host_attr (private): The name of the attribute to which this `AttrProxy`
instance has been assigned.
A deprecated alias of `Alias`.
"""

def __init__(
self,
attr: str,
*,
passthrough=False,
transform: Optional[Callable[[Any], Any]] = None,
fallback=MISSING,
):
self.attr = attr
self.transform = transform
self.passthrough = passthrough
self.fallback = fallback

self.host_attr = None

@property
def override_attr(self):
if not self.host_attr and not self.passthrough:
return None
return (
self.attr
if self.passthrough
else f"__spec_classes_attrproxy_{self.host_attr}_override"
def __init__(self, attr, **kwargs):
warnings.warn(
"`AttrProxy` is deprecated. Please use `Alias` instead. `AttrProxy` will be removed in spec-classes 2.0.0.",
DeprecationWarning,
stacklevel=2,
)

def __get__(self, instance: Any, owner=None):
if instance is None:
return self
if self.host_attr and hasattr(instance, self.override_attr):
return getattr(instance, self.override_attr)
try:
return (self.transform or (lambda x: x))(getattr(instance, self.attr))
except AttributeError:
if self.fallback is not MISSING:
return self.fallback
raise
except RecursionError as e:
raise ValueError(
f"AttrProxy for `{instance.__class__.__name__}.{self.attr}` appears "
"to be self-referential. Please change the `attr` argument to point "
"to a different attribute."
) from e

def __set__(self, instance, value):
if not self.override_attr:
raise RuntimeError(
"Attempting to set the value of an `AttrProxy` instance that is not properly associated with a class."
)
setattr(instance, self.override_attr, value)

def __delete__(self, instance):
delattr(instance, self.override_attr)

def __set_name__(self, owner, name):
self.host_attr = name
super().__init__(attr, **kwargs)
Loading

0 comments on commit 3c02d2f

Please sign in to comment.