Last active
October 12, 2020 23:01
-
-
Save Skinner927/413c0e9cc8433123f426832f9fe8d931 to your computer and use it in GitHub Desktop.
Properties for Python Classes. Supports get and set.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
To use simply copy ClassPropertyMeta and classproperty into your project | |
""" | |
class ClassPropertyMeta(type): | |
def __setattr__(self, key, value): | |
obj = self.__dict__.get(key, None) | |
if type(obj) is classproperty: | |
return obj.__set__(self, value) | |
return super().__setattr__(key, value) | |
class classproperty(object): | |
""" | |
Similar to @property but used on classes instead of instances. | |
The only caveat being that your class must use the | |
classproperty.meta metaclass. | |
Class properties will still work on class instances unless the | |
class instance has overidden the class default. This is no different | |
than how class instances normally work. | |
Derived from: https://stackoverflow.com/a/5191224/721519 | |
class Z(object, metaclass=classproperty.meta): | |
@classproperty | |
def foo(cls): | |
return 123 | |
_bar = None | |
@classproperty | |
def bar(cls): | |
return cls._bar | |
@bar.setter | |
def bar(cls, value): | |
return cls_bar = value | |
Z.foo # 123 | |
Z.bar # None | |
Z.bar = 222 | |
Z.bar # 222 | |
""" | |
meta = ClassPropertyMeta | |
def __init__(self, fget, fset=None): | |
self.fget = self._fix_function(fget) | |
self.fset = None if fset is None else self._fix_function(fset) | |
def __get__(self, instance, owner=None): | |
if not issubclass(type(owner), ClassPropertyMeta): | |
raise TypeError( | |
f"Class {owner} does not extend from the required " | |
f"ClassPropertyMeta metaclass" | |
) | |
return self.fget.__get__(None, owner)() | |
def __set__(self, owner, value): | |
if not self.fset: | |
raise AttributeError("can't set attribute") | |
if type(owner) is not ClassPropertyMeta: | |
owner = type(owner) | |
return self.fset.__get__(None, owner)(value) | |
def setter(self, fset): | |
self.fset = self._fix_function(fset) | |
return self | |
_fn_types = (type(__init__), classmethod, staticmethod) | |
@classmethod | |
def _fix_function(cls, fn): | |
if not isinstance(fn, cls._fn_types): | |
raise TypeError("Getter or setter must be a function") | |
# Always wrap in classmethod so we can call its __get__ and not | |
# have to deal with difference between raw functions. | |
if not isinstance(fn, (classmethod, staticmethod)): | |
return classmethod(fn) | |
return fn | |
# ---------------- TESTS ---------------- | |
import unittest | |
from unittest.mock import MagicMock, sentinel | |
class TestClassProperty(unittest.TestCase): | |
def test_get_set(self): | |
get_only_cls = MagicMock() | |
get_set_get_cls = MagicMock() | |
get_set_set_cls = MagicMock() | |
class Z(object, metaclass=classproperty.meta): | |
_get_set = sentinel.nothing | |
@classproperty | |
def get_only(cls): | |
get_only_cls(cls) | |
return sentinel.get_only | |
@classproperty | |
def get_set(cls): | |
get_set_get_cls(cls) | |
return cls._get_set | |
@get_set.setter | |
def get_set(cls, value): | |
get_set_set_cls(cls) | |
cls._get_set = value | |
for c, msg in [(Z, "class"), (Z(), "instance")]: | |
with self.subTest(msg=msg): | |
# Reset | |
Z._get_set = sentinel.nothing | |
# Test get_only | |
self.assertEqual(sentinel.get_only, c.get_only) | |
get_only_cls.assert_called_once_with(Z) | |
get_only_cls.reset_mock() | |
# Should return our initial "nothing" value | |
self.assertEqual(sentinel.nothing, c.get_set) | |
get_set_get_cls.assert_called_once_with(Z) | |
get_set_get_cls.reset_mock() | |
# Now test the set | |
c.get_set = sentinel.get_set_val | |
get_set_set_cls.assert_called_once_with(Z) | |
get_set_set_cls.reset_mock() | |
self.assertEqual(sentinel.get_set_val, c.get_set) | |
get_set_get_cls.assert_called_once_with(Z) | |
get_set_get_cls.reset_mock() | |
def test_read_only(self): | |
class Z(object, metaclass=classproperty.meta): | |
_get_set = sentinel.nothing | |
@classproperty | |
def get_only(cls): | |
return sentinel.get_only | |
self.assertEqual(sentinel.get_only, Z.get_only) | |
with self.assertRaises(AttributeError): | |
Z.get_only = 123 | |
def test_proper_metaclass(self): | |
class Z(object): | |
_get_set = sentinel.nothing | |
@classproperty | |
def get_only(cls): | |
return sentinel.get_only | |
with self.assertRaises(TypeError): | |
self.assertEqual("should not resolve", Z.get_only) | |
if __name__ == "__main__": | |
unittest.main() |
@devopsec I've been meaning to respond to you, I just haven't had a free second.
There is undoubtedly a reason we don't get native classproperties. I haven't had a chance to look into what you're reporting but I plan to very soon.
This classproperty is an evolution of what I used to do and that's to create static non-data descriptors. You could rig up a non-data descriptor to always return the value of a static function (similar to this example: https://gist.github.com/Skinner927/b717574a51713bc4208e88182b9735b7) and just overwrite that function as you update the value.
CLEARLY not as nice, but might work. Again, untested, but I didn't want to leave you hanging. I'll get back to this.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How would you allow sub classes to propagate share changes with super classes?
For instance here is an example:
`class A(object, metaclass=classproperty.meta):
_val = 0
@classproperty
def val(cls):
super_class = cls.mro()[1]
if not super_class.name == 'object':
return super_class.val
else:
return cls._val
@val.setter
def val(cls, value):
super_class = cls.mro()[1]
if not super_class.name == 'object':
super_class.val = value
else:
cls._val = value
class B(A):
pass`
The above would work as a getter and setter class method but when set using your technique, with the @classproperty, when settings a subclasses property the method is overwritten instead