Commits (9)
[bumpversion]
current_version = 0.1.1
current_version = 0.2.0
commit = True
tag = True
......
......@@ -13,11 +13,11 @@ frequently needs.
### Quickly Add Properties to Classes
```python
from class_tools.decorators import has_property
from class_tools.decorators import wrapper_property
@has_property("name", default=lambda: "Bob", doc="The dog's name")
@has_property("toy", default=lambda: "ball")
@has_property("sharp_teeth", default=lambda: 4, type=int)
@wrapper_property("name", default=lambda: "Bob", doc="The dog's name")
@wrapper_property("toy", default=lambda: "ball")
@wrapper_property("sharp_teeth", default=lambda: 4, type=int)
class Dog:
pass
......@@ -40,8 +40,8 @@ from class_tools.decorators import *
@with_init_from_properties()
@with_repr_like_init_from_properties()
@has_property("name")
@has_property("furcolor")
@wrapper_property("name")
@wrapper_property("furcolor")
class Cat:
pass
......@@ -59,8 +59,8 @@ cat
from class_tools.decorators import *
@with_eq_comparing_properties()
@has_property("name")
@has_property("furcolor")
@wrapper_property("name")
@wrapper_property("furcolor")
class Cat:
pass
......@@ -82,11 +82,11 @@ lucy == lucy_clone
```python
from class_tools.propertyobject import PropertyObject
from class_tools.decorators import has_property
from class_tools.decorators import wrapper_property
@has_property("name")
@has_property("height", type=float)
@has_property("friends", type=list, default=list)
@wrapper_property("name")
@wrapper_property("height", type=float)
@wrapper_property("friends", type=list, default=list)
class Giraffe(PropertyObject):
pass
......
......@@ -2,7 +2,11 @@
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 *
......@@ -10,6 +14,52 @@ from class_tools.utils import *
# external modules
class NotSet:
"""
Class to indicate that an argument has not been specified.
"""
pass
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
def classdecorator(decorated_fun):
"""
Decorator for other decorator functions that are supposed to only be
......@@ -33,100 +83,265 @@ def classdecorator(decorated_fun):
return wrapper
def has_property(
name,
type=None,
type_doc=None,
default=lambda: None,
set_default=True,
prefix="_",
doc=None,
):
def add_property(name, *args, abstract=False, **kwargs):
"""
Create a :any:`classdecorator` that adds a property with getter, setter and
deleter to a class, wrapping around an attribute.
Create a :any:`classdecorator` that adds a :any:`property` or
:any:`abc.abstractproperty` to the decorated class.
Args:
name (str): the name of the property
type (callable, optional): method to transform given values in the
setter with. The default is to just pass the given value.
type_doc (str, optional): the documentation of the property's type
default (callable, optional): Callable returning the default value
for the property. The default just returns ``None``.
set_default (bool, optional): whether to set the underlying attribute
default to the default if necessary. The default is ``True`` which
means to do so.
prefix (str, optional): the prefix for the underlying attribute
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:
callable : :any:`classdecorator`
:any:`classdecorator` : decorator for classes
"""
@classdecorator
def decorator(decorated_cls):
attr = "".join((prefix, name))
prop = (abstractproperty if abstract else property)(*args, **kwargs)
setattr(decorated_cls, name, prop)
return decorated_cls
def getter(self):
if set_default:
return decorator
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)
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
)
@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, default())
setattr(self, attr, static_default)
return getattr(self, attr)
else:
return getattr(self, attr, default())
proptype = (lambda x: x) if type is None else type
else:
def setter(self, new):
setattr(self, attr, proptype(new))
def deleter(self):
try:
delattr(self, attr)
except AttributeError: # pragma: no cover
pass
type_doc_str = (
(":any:`object`" if type is None else type.__name__)
if type_doc is None
else type_doc
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__)
)
getter.__doc__ = "\n\n".join(
else: # no default
def getter(self):
return getattr(self, attr)
doc_getter = doc_getter or (
"Return the value of the ``{attr}`` attribute. "
+ (
(
"{doc}",
":type: {type_doc}",
":getter: {getter}",
":setter: {setter}" if type is not None else "",
"If it hasn't been set yet, "
"it will be set to the default: {default}"
)
).format(
doc=("{name} property").format(name=name) if doc is None else doc,
type_doc=type_doc_str,
getter=(
"If no value was set yet, "
"set it to ``{default}`` and return it."
).format(default=repr(default()))
if set_default
else (
"If a value was set yet, return it. "
" Otherwise return the default value ``{default}``."
).format(default=repr(default())),
setter=("Convert the given value with ``{type_doc}``").format(
type_doc=type_doc_str
),
if (doc_getter_default and set_default)
else ""
)
prop = property(getter, setter, deleter)
setattr(decorated_cls, name, prop)
return decorated_cls
).format(attr=attr, default=doc_getter_default)
return decorator
# 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)
def with_init_from_properties():
"""
Create a :any:`classdecorator` that **overwrites** the ``__init__``-method
so that it accepts arguments according to its properties.
so that it accepts arguments according to its read- and settable
properties.
Returns:
callable : :any:`classdecorator`
......@@ -136,7 +351,9 @@ def with_init_from_properties():
def decorator(decorated_cls):
def __init__(self, **properties):
cls = type(self)
for name, prop in get_properties(cls).items():
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():
......@@ -187,7 +404,7 @@ def with_repr_like_init_from_properties(indent=" " * 4, full_path=False):
else type(self).__name__
)
properties = get_properties(cls)
properties = get_properties(cls, getter=True, setter=True)
# create "prop = {prop}" string tuple for reprformat
props_kv = tuple(
......@@ -230,7 +447,7 @@ def with_repr_like_init_from_properties(indent=" " * 4, full_path=False):
def with_eq_comparing_properties():
"""
Create a :any:`classdecorator` that **overwrites** the ``__eq__``-method so
that it compares all properties for equality.
that it compares all properties with a getter for equality.
Returns:
callable : :any:`classdecorator`
......@@ -239,8 +456,8 @@ def with_eq_comparing_properties():
@classdecorator
def decorator(decorated_cls):
def __eq__(self, other):
other_properties = get_properties(other)
for name, prop in get_properties(self).items():
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(
(
......
......@@ -6,13 +6,15 @@ import inspect
# external modules
def get_properties(obj):
def get_properties(obj, getter=None, setter=None, deleter=None):
"""
Get a mapping of property names to properties from a given object's class
or a class itself.
Args:
obj (object): either an object or a class
getter, setter, deleter (bool, optional): also check whether a
getter/setter/deleter is defined. The default is no check.
Returns:
dict : mapping of property names to the properties
......@@ -20,7 +22,18 @@ def get_properties(obj):
return dict(
inspect.getmembers(
obj if isinstance(obj, type) else type(obj),
lambda x: isinstance(x, property),
lambda x: (
isinstance(x, property)
and all(
bool(v) == bool(getattr(x, p))
for p, v in {
"fget": getter,
"fset": setter,
"fdel": deleter,
}.items()
if v is not None
)
),
)
)
......
__version__ = "0.1.1"
__version__ = "0.2.0"
logo.png

89.5 KB

......@@ -34,44 +34,117 @@ class ClassDecoratorTest(unittest.TestCase):
self.assertIs(TestClass.attr, True)
class HasPropertyDecoratorTest(unittest.TestCase):
class ConflictingArgumentsDecoratorTest(unittest.TestCase):
def test_decorator(self):
@conflicting_arguments("a", "b")
def fun(a, *args, b="b", c="c", **kwargs):
return "".join((a, b, c))
self.assertEqual(fun("A"), "Abc")
self.assertEqual(fun("A", c="C", something=1), "AbC")
with self.assertRaises(ValueError):
fun("A", "B")
with self.assertRaises(ValueError):
fun("A", b="B")
with self.assertRaises(ValueError):
fun(a="A", b="B")
class AddPropertyDecoratorTest(unittest.TestCase):
def test_decorator(self):
@add_property(
"prop",
lambda s: s._prop,
lambda s, n: setattr(s, "_prop", n),
lambda s: delattr(s, "_prop"),
)
class TestClass:
pass
t = TestClass()
self.assertFalse(hasattr(t, "_prop"))
t.prop = 1
self.assertTrue(hasattr(t, "_prop"))
self.assertEqual(t.prop, 1)
del t.prop
self.assertFalse(hasattr(t, "_prop"))
class ReadOnlyPropertyDecoratorTest(unittest.TestCase):
def test_decorator(self):
@readonly_property("prop", getter=lambda x: [])
class TestClass:
pass
t1 = TestClass()
t2 = TestClass()
self.assertIsNot(t1.prop, t2.prop)
with self.assertRaises(AttributeError):
t1.prop = 1
with self.assertRaises(AttributeError):
del t1.prop
class ConstantDecoratorTest(unittest.TestCase):
def test_decorator(self):
@constant("prop", [])
class TestClass:
pass
t1 = TestClass()
t2 = TestClass()
self.assertIs(t1.prop, t2.prop)
with self.assertRaises(AttributeError):
t1.prop = 1
with self.assertRaises(AttributeError):
del t1.prop
class WrapperPropertyDecoratorTest(unittest.TestCase):
def test_decorator(self):
@wrapper_property("prop", attr="_prop")
class TestClass:
pass
t = TestClass()
self.assertFalse(hasattr(t, "_prop"))
t.prop = 1
self.assertTrue(hasattr(t, "_prop"))
self.assertEqual(t.prop, 1)
del t.prop
self.assertFalse(hasattr(t, "_prop"))
def test_creates_property(self):
for name in ("prop", "other", "__prop"):
with self.subTest(name=name):
@has_property(name)
@wrapper_property(name)
class TestClass:
pass
self.assertTrue(hasattr(TestClass, name))
self.assertIsInstance(getattr(TestClass, name), property)
obj = TestClass()
self.assertTrue(hasattr(obj, name))
def test_set_default(self):
for name, prefix, default, set_default in itertools.product(
("prop", "_otherprop"),
("_", "__", "asdf"),
(lambda: 5, lambda: "asdf", lambda: None, str, int, list),
(True, False),
def test_static_default(self):
for name, static_default, set_default in itertools.product(
("prop", "otherprop"), (5, "asdf", None, str), (True, False)
):
with self.subTest(
name=name,
prefix=prefix,
default=default,
static_default=static_default,
set_default=set_default,
):
@has_property(
@wrapper_property(
name,
default=default,
static_default=static_default,
set_default=set_default,
prefix=prefix,
)
class TestClass:
pass
attr = "".join((prefix, name))
attr = "".join(("_", name))
obj = TestClass()
self.assertFalse(
hasattr(obj, attr),
......@@ -79,14 +152,63 @@ class HasPropertyDecoratorTest(unittest.TestCase):
"without the getter being called",
)
v = getattr(obj, name)
self.assertEqual(
self.assertIs(
v,
default(),
static_default,
"Instead of the default value {}, "
"the property returns {}".format(repr(default()), repr(v)),
"the property returns {}".format(
repr(static_default), repr(v)
),
)
if default is list:
self.assertIsNot(v, default())
(self.assertTrue if set_default else self.assertFalse)(
hasattr(obj, attr),
"despite set_default={}, "
"the underlying attribute {} set after "
"accessing the property".format(
set_default, "is not" if set_default else "is"
),
)
def test_dynamic_default(self):
for name, dynamic_default, set_default in itertools.product(
("prop", "otherprop"),
(lambda x: list(), lambda x: x),
(True, False),
):
with self.subTest(
name=name,
dynamic_default=dynamic_default,
set_default=set_default,
):
@wrapper_property(
name,
dynamic_default=dynamic_default,
set_default=set_default,
)
class TestClass:
pass
attr = "".join(("_", name))
obj = TestClass()
self.assertFalse(
hasattr(obj, attr),
"the underlying attribute is already set "
"without the getter being called",
)
v = getattr(obj, name)
if isinstance(v, list):
self.assertIsNot(
v,
dynamic_default(obj),
"dynamic_default list is not per-object",
),
else:
self.assertIs(
v,
dynamic_default(obj),
"dynamic_default does not work",
)
(self.assertTrue if set_default else self.assertFalse)(
hasattr(obj, attr),
"despite set_default={}, "
......@@ -97,23 +219,50 @@ class HasPropertyDecoratorTest(unittest.TestCase):
)
def test_setter_deleter(self):
for name, prefix, proptype, value in itertools.product(
for (
name,
static_type,
dynamic_type,
value,
static,
) in itertools.product(
("prop", "_otherprop"),
("_", "__", "asdf"),
(None, str, float),
(NotSet, str, float),
(NotSet, lambda x, v: str(v), lambda x, v: float(v)),
("1", "1.0", 3, 3.14),
(True, False),
):
with self.subTest(
name=name, prefix=prefix, type=proptype, value=value
name=name,
static_type=static_type,
dynamic_type=dynamic_type,
value=value,
):
@has_property(name, prefix=prefix, type=proptype)
class TestClass:
pass
if static:
@wrapper_property(name, static_type=static_type)
class TestClass:
pass
else:
@wrapper_property(name, dynamic_type=dynamic_type)
class TestClass:
pass
attr = "".join((prefix, name))
attr = "".join(("_", name))
obj = TestClass()
conv = (proptype or (lambda x: x))(value)
if static:
conv = (
(lambda x: x) if static_type is NotSet else static_type
)(value)
else:
conv = (
(lambda x, v: v)
if dynamic_type is NotSet
else dynamic_type
)(obj, value)
setattr(obj, name, value)
v = getattr(obj, name)
self.assertEqual(
......@@ -151,7 +300,7 @@ class InitFromPropertiesDecoratorTest(unittest.TestCase):
pass
for name in names:
TestClass = has_property(name)(TestClass)
TestClass = wrapper_property(name)(TestClass)
props = dict(map(reversed, enumerate(names)))
obj = TestClass(**props)
for name, val in props.items():
......@@ -179,7 +328,7 @@ class InitFromPropertiesDecoratorTest(unittest.TestCase):
pass
for name in names:
TestClass = has_property(name)(TestClass)
TestClass = wrapper_property(name)(TestClass)
props = dict(map(reversed, enumerate(names)))
props["".join(props)] = "asdf"
with self.assertWarnsRegex(
......@@ -205,7 +354,7 @@ class EqComparingPropertiesDecoratorTest(unittest.TestCase):
pass
for name in names:
TestClass = has_property(name)(TestClass)
TestClass = wrapper_property(name)(TestClass)
props = dict(map(reversed, enumerate(names)))
obj1 = TestClass()
obj2 = TestClass()
......@@ -235,7 +384,7 @@ class EqComparingPropertiesDecoratorTest(unittest.TestCase):
pass
for name in names:
TestClass = has_property(name)(TestClass)
TestClass = wrapper_property(name)(TestClass)
class TestClass2:
pass
......
......@@ -15,10 +15,14 @@ class PropertyObjectTest(unittest.TestCase):
self.assertEqual(eval(repr(obj)), obj)
def test_repr_consistency(self):
@has_property("prop1")
@has_property("prop2")
@wrapper_property("prop1", static_default=1)
@wrapper_property("prop2", static_default=2)
@wrapper_property("no_setter", static_default=2, fset=None)
@constant("constant", 1234)
class TestClass(PropertyObject):
pass
obj = TestClass()
self.assertNotIn("constant", repr(obj))
self.assertNotIn("no_deleter", repr(obj))
self.assertEqual(eval(repr(obj)), obj)
# system modules
import unittest
import itertools
# internal modules
from class_tools.utils import *
......@@ -17,25 +18,50 @@ class UtilsTest(unittest.TestCase):
self.assertEqual(full_object_path(obj), path)
def test_get_properties(self):
class TestClass(object):
@property
def prop1(self):
return 1
class TestClass:
prop = property
@property
def prop2(self):
return 2
kw = {
"fget": lambda x: 1,
"fset": lambda x, n: None,
"fdel": lambda x: None,
}
# fill class with all kinds of properties
for comb in itertools.chain.from_iterable(
itertools.combinations(kw, i) for i in range(1, len(kw) + 1)
):
setattr(
TestClass,
"prop" + ("".join(comb)),
property(**{k: v for k, v in kw.items() if k in comb}),
)
obj = TestClass()
with self.subTest(input_type="class"):
properties = get_properties(TestClass)
self.assertEqual(len(properties), 2)
for name, prop in properties.items():
self.assertIn(name, ("prop1", "prop2"))
self.assertIs(prop, getattr(TestClass, name))
with self.subTest(input_type="object"):
properties = get_properties(obj)
self.assertEqual(len(properties), 2)
for name, prop in properties.items():
self.assertIn(name, ("prop1", "prop2"))
self.assertIs(prop, getattr(TestClass, name))
trans = {"fget": "getter", "fset": "setter", "fdel": "deleter"}
# tests
for b in (True, False):
for comb in itertools.chain(
(tuple(),),
itertools.chain.from_iterable(
itertools.combinations(kw, i)
for i in range(1, len(kw) + 1)
),
):
props = get_properties(obj, **{trans[k]: b for k in comb})
for name, prop in props.items():
for attr in comb:
if b:
self.assertTrue(
getattr(prop, attr),
"{} property has no {}".format(
name, trans[attr]
),
)
else:
self.assertFalse(
getattr(prop, attr),
"{} property unexpectedly has a {}".format(
name, trans[attr]
),
)