# system modules
import textwrap
import warnings
import functools
import types
import itertools
import inspect
import re
from abc import abstractproperty
# internal modules
from class_tools.utils import *
# external modules
[docs]class NotSet:
"""
Class to indicate that an argument has not been specified.
"""
pass
[docs]def conflicting_arguments(*conflicts):
"""
Create a decorator which raises a :any:`ValueError` when the decorated
function was called with conflicting arguments. Works for both positional
and keyword arguments.
Args:
conflicts (sequence of str): the arguments which should not be
specified together.
Returns:
callable: a decorator for callables
"""
def decorator(decorated_fun):
@functools.wraps(decorated_fun)
def wrapper(*args, **kwargs):
spec = inspect.getfullargspec(decorated_fun)
posargs = dict(
zip(itertools.chain(spec.args, spec.kwonlyargs), args)
)
arguments = set(kwargs).union(set(posargs))
if arguments and all(map(arguments.__contains__, conflicts)):
raise ValueError(
"function {fun} called with "
"conflicting arguments: {args}".format(
fun=repr(decorated_fun.__name__),
args=", ".join(map(repr, conflicts)),
)
)
return decorated_fun(*args, **kwargs)
return wrapper
return decorator
[docs]def classdecorator(decorated_fun):
"""
Decorator for other decorator functions that are supposed to only be
applied to classes.
Raises:
ValueError : if the resulting decorator is applied to non-classes
"""
@functools.wraps(decorated_fun)
def wrapper(decorated_cls):
if not isinstance(decorated_cls, type):
raise TypeError(
(
"{cls} object is not a type and "
"cannot be decorated with this decorator"
).format(cls=repr(type(decorated_cls).__name__))
)
return decorated_fun(decorated_cls)
return wrapper
[docs]def add_property(name, *args, abstract=False, **kwargs):
"""
Create a :any:`classdecorator` that adds a :any:`property` or
:any:`abc.abstractproperty` to the decorated class.
Args:
name (str): the name for the property
abstract (bool, optional): whether to create an :any:`abstractproperty`
instead of a :any:`property`
args, kwargs: arguments passed to :any:`property` (or
:any:`abstractproperty` of `abstract=True`)
Returns:
:any:`classdecorator` : decorator for classes
"""
@classdecorator
def decorator(decorated_cls):
prop = (abstractproperty if abstract else property)(*args, **kwargs)
setattr(decorated_cls, name, prop)
return decorated_cls
return decorator
[docs]def readonly_property(name, getter, *args, **kwargs):
"""
Create a :any:`classdecorator` that adds a read-only :any:`property` (i.e.
without setter and deleter).
Args:
name (str): the name for the constant
getter (callable): the :any:`property.getter` to use
args, kwargs: arguments passed to :any:`add_property`
"""
return functools.partial(add_property, name, fget=getter)(*args, **kwargs)
[docs]def constant(name, value, *args, **kwargs):
"""
Create a :any:`classdecorator` that adds a :any:`readonly_property`
returning a static value to the decorated class.
Args:
name (str): the name for the constant
value (object): the value of the constant
args, kwargs: arguments passed to :any:`readonly_property`
"""
return functools.partial(readonly_property, name, getter=lambda s: value)(
*args, **kwargs
)
[docs]@conflicting_arguments("static_default", "dynamic_default")
@conflicting_arguments("static_type", "dynamic_type")
def wrapper_property(
name,
*args,
attr=NotSet,
static_default=NotSet,
dynamic_default=NotSet,
set_default=False,
static_type=NotSet,
dynamic_type=NotSet,
doc_default=None,
doc_type=None,
doc_getter=None,
doc_setter=None,
doc_property=None,
**kwargs
):
"""
Create a :any:`classdecorator` that adds a :any:`property` with getter,
setter and deleter, wrapping an attribute.
Args:
name (str): the name for the constant
attr (str, optional): the name for the wrapped attribute. If unset,
use ``name`` with an underscore (``_``) prepended.
static_default (object, optional): value to use in the getter if the
``attr`` is not yet set
dynamic_default (object, optional): the return value of this function
(called with the object as argument) is used in the getter if the
``attr`` is not yet set.
set_default (bool, optional): whether to set the ``attr`` to the
``static_default`` or ``dynamic_default`` (if specified) in the
getter when it was not yet set. Default is ``False``.
doc_default, doc_type, doc_getter, doc_setter (str, optional):
documentation strings.
static_type (callable, optional): function to convert the value in the
setter.
dynamic_type (callable, optional): function to convert the value in the
setter. Differing from ``static_type``, this function is also
handed the object reference as first argument.
args, kwargs: arguments passed to :any:`add_property`
"""
# determine the attribute
if attr is NotSet:
attr = "".join(("_", name))
# determine the getter
doc_getter_default = None
if static_default is not NotSet:
if set_default:
def getter(self):
try:
return getattr(self, attr)
except AttributeError:
setattr(self, attr, static_default)
return getattr(self, attr)
else:
def getter(self):
return getattr(self, attr, static_default)
doc_getter_default = "``{}``".format(repr(static_default))
elif dynamic_default is not NotSet:
try:
s = inspect.getfullargspec(dynamic_default)
assert len(s.args) == 1 and not any(
getattr(s, a)
for a in (
"varargs",
"varkw",
"defaults",
"kwonlyargs",
"kwonlydefaults",
)
), "needs to take exactly one positional argument"
except BaseException as e: # pragma: no cover
raise ValueError(
"dynamic_default needs to be "
"usable as a method: {}".format(e)
)
if set_default:
def getter(self):
try:
return getattr(self, attr)
except AttributeError:
setattr(self, attr, dynamic_default(self))
return getattr(self, attr)
else:
def getter(self):
try:
return getattr(self, attr)
except AttributeError:
return dynamic_default(self)
doc_getter_default = "the return value of {}".format(
"a user-specified function"
if (isinstance(dynamic_default, types.LambdaType))
else "``{}``".format(dynamic_default.__name__)
)
else: # no default
def getter(self):
return getattr(self, attr)
doc_getter = doc_getter or (
"Return the value of the ``{attr}`` attribute. "
+ (
(
"If it hasn't been set yet, "
"it will be set to the default: {default}"
)
if (doc_getter_default and set_default)
else ""
)
).format(attr=attr, default=doc_getter_default)
# determine the setter
doc_setter_type = None
if static_type is not NotSet:
def setter(self, new):
setattr(self, attr, static_type(new))
doc_setter_type = "new values are modified with {}".format(
"a user-specified function"
if (isinstance(static_type, types.LambdaType))
else "``{}``".format(static_type.__name__)
)
elif dynamic_type is not NotSet:
try:
s = inspect.getfullargspec(dynamic_type)
assert len(s.args) == 2 and not any(
getattr(s, a)
for a in (
"varargs",
"varkw",
"defaults",
"kwonlyargs",
"kwonlydefaults",
)
), "needs to take exactly two positional argument"
except BaseException as e: # pragma: no cover
raise ValueError(
"dynamic_type needs to be " "usable as a method: {}".format(e)
)
def setter(self, new):
setattr(self, attr, dynamic_type(self, new))
doc_setter_type = "new values are modified with {}".format(
"a user-specified function"
if (isinstance(dynamic_type, types.LambdaType))
else "``{}``".format(dynamic_type.__name__)
)
else: # no type
def setter(self, new):
setattr(self, attr, new)
doc_setter = doc_setter or ("Set the ``{attr}`` attribute").format(
attr=attr
)
doc_type = doc_type or doc_setter_type
# create the docstring
docstring = "\n\n".join(
filter(
bool,
(
"{doc_property}",
":type: {doc_type}" if doc_type else "",
":getter: {doc_getter}" if doc_getter else "",
":setter: {doc_setter}" if doc_setter else "",
),
)
).format(
doc_property=doc_property or ("{} property").format(name),
doc_type=doc_type or doc_setter_type or ":any:`object`",
doc_getter=doc_getter or "return the property's value",
doc_setter=doc_setter or "set the property's value",
)
return functools.partial(
add_property,
name,
fget=getter,
fset=setter,
fdel=lambda s: (delattr(s, attr) if hasattr(s, attr) else None),
doc=docstring,
)(*args, **kwargs)
[docs]def with_init_from_properties():
"""
Create a :any:`classdecorator` that **overwrites** the ``__init__``-method
so that it accepts arguments according to its read- and settable
properties.
Returns:
callable : :any:`classdecorator`
"""
@classdecorator
def decorator(decorated_cls):
def __init__(self, **properties):
cls = type(self)
for name, prop in get_properties(
cls, getter=True, setter=True
).items():
if name in properties:
setattr(self, name, properties.pop(name))
for arg, val in properties.items():
warnings.warn(
(
"{cls}.__init__() got an "
"unexpected keyword argument {arg}"
).format(cls=cls.__name__, arg=repr(arg))
)
setattr(decorated_cls, "__init__", __init__)
return decorated_cls
return decorator
[docs]def with_repr_like_init_from_properties(indent=" " * 4, full_path=False):
"""
Create a :any:`classdecorator` that **overwrites** the ``__repr__``-method
so that it returns a representation according to the decorated class'
properties.
.. note::
The created ``__repr__``-method assumes that the decorated class'
``__init__``-method accepts keyword arguments similar to its
properties. The :any:`with_init_from_properties` :any:`classdecorator`
creates such an initializer.
Args:
indent(str, optional): the indentation string. The default is four
spaces.
full_path (bool, optional): whether to use the :any:`full_object_path`
instead of just the object names. Defaults to ``False``.
Returns:
callable : :any:`classdecorator`
"""
@classdecorator
def decorator(decorated_cls):
def __repr__(self):
cls = type(self)
clspath = (
full_object_path(type(self))
if full_path
else type(self).__name__
)
properties = get_properties(cls, getter=True, setter=True)
# create "prop = {prop}" string tuple for reprformat
props_kv = tuple(
map(
functools.partial(textwrap.indent, prefix=indent),
map(
lambda pv: "{p}={v}".format(
p=pv[0],
v=re.sub(
"\n", indent + "\n", repr(pv[1].fget(self))
),
),
sorted(properties.items()),
),
)
)
# create the format string
reprformatstr = "{____cls}({args})".format(
____cls=clspath,
args=("\n{}\n" if props_kv else "{}").format(
",\n".join(props_kv)
),
)
return reprformatstr.format(
**{
name: repr(prop.fget(self))
for name, prop in properties.items()
}
)
setattr(decorated_cls, "__repr__", __repr__)
return decorated_cls
return decorator
[docs]def with_eq_comparing_properties():
"""
Create a :any:`classdecorator` that **overwrites** the ``__eq__``-method so
that it compares all properties with a getter for equality.
Returns:
callable : :any:`classdecorator`
"""
@classdecorator
def decorator(decorated_cls):
def __eq__(self, other):
other_properties = get_properties(other, getter=True)
for name, prop in get_properties(self, getter=True).items():
if name not in other_properties:
raise TypeError(
(
"{other_cls} object has no property "
"{property} and thus cannot be compared to "
"{our_cls} object"
).format(
other_cls=repr(type(other).__name__),
property=repr(name),
our_cls=repr(type(self).__name__),
)
)
if prop.fget(self) != other_properties.get(name).fget(other):
return False
return True
__eq__.__doc__ = (
"Checks whether all properties of "
"this object match the corresponding "
"properties of the given object to compare"
)
setattr(decorated_cls, "__eq__", __eq__)
return decorated_cls
return decorator