Reputation: 1051
In the following example I try to create a python metaclass which init's my class with __slots__
and default values.
class Meta(type):
def __new__(cls, name, bases, dictionary, defaults):
dictionary['__slots__'] = list(defaults.keys())
obj = super().__new__(cls, name, bases, dictionary)
return obj
def __init__(self, name, bases, dictionary, defaults):
for s in defaults:
setattr(self, s, defaults[s])
class A(metaclass = Meta, defaults = {'a':123, 'b':987}):
pass
Instantiating class A
, I get the following results:
a = A()
>>> dir (a)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', 'a', 'b']
-> OK
>>> a.c = 500
Traceback (most recent call last):
File "<pyshell#87>", line 1, in <module>
a.c = 500
AttributeError: 'A' object has no attribute 'c'
-> OK
>>> a.b = 40
Traceback (most recent call last):
File "<pyshell#88>", line 1, in <module>
a.b = 40
AttributeError: 'A' object attribute 'b' is read-only
-> Not OK, expected a.b
to be read- and writeable
A you can see the metaclass Meta
correctly creates the __slots__
and correctly set's the default values, but unfortunately the slotted attributes were made readonly for some reason i don't understand.
Is it possible to obtain slotted read/write attributes from the metaclass Meta
?
Upvotes: 4
Views: 751
Reputation: 110311
The thing is that the code that sets the attribute in Meta.__init__
change it in the class itself. The problem is that the default variables in the class (the "a" and "b" defaults in this case), are special descriptors, meant to handle slot value assignment in the instances of the created class (the object "a" in your example). The descriptors are overwritten and can no longer work.
(It is indeed a peculiar side effect that they become "read-only class attributes" - I will investigate if this is documented or on purpose or is just an undefined behavior)
Nonetheless, what you need is a way to set make the values available in your slot-variables once the objects are instantiated.
The one obvious way to do that is to transfer the logic in your Meta.__init__
to a baseclass __init__
method, and set the values there (attach the defaults
dict to the class itself). Then any subclasses that call super().__init__()
will have it.
If you don't want, or can't do that, you'd have put code in the metaclass to inject an __init__
in each class, wrapping the original __init__
if any (and taking care of all possible cases, like: no __init__
, already have an wrapped __init__
in a parent class, etc...) - this can done, and if you opt for this, I could supply some example code.
(update: on a second thought, instead of all this malabarism, the code could be set on the metaclass __call__
method, and override the default type.__call__
completely, so the default value assignment takes place before the class' __init__
is called)
class Meta(type):
def __new__(mcls, name, bases, dictionary, defaults):
dictionary['__slots__'] = list(defaults)
dictionary["_defaults"] = defaults
return super().__new__(mcls, name, bases, dictionary)
def __call__(cls, *args, **kw):
"""Replaces completly the mechanism that makes `__new__` and
`__init__` being called, adding a new step between the two calls
"""
instance = cls.__new__(cls, *args, **kw)
for k, v in instance._defaults.items():
setattr(instance, k, v)
instance.__init__(*args, **kw)
return instance
class A(metaclass = Meta, defaults = {'a':123, 'b':987}):
def __init__(self):
print (f"I can see the default values of a and b: {(self.a, self.b)}")
And it working:
In [51]: A()
I can see the default values of a and b: (123, 987)
Out[51]: <__main__.A at 0x7f093cfeb820>
In [52]: a = A()
I can see the default values of a and b: (123, 987)
In [53]: a.c = 500
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-53-ce3d946a718e> in <module>
----> 1 a.c = 500
AttributeError: 'A' object has no attribute 'c'
In [54]: a.b
Out[54]: 987
In [55]: a.b = 1000
In [56]: a.b
Out[56]: 1000
Another way is to create special descriptors that will know the default value. Change the slotted variable names adding a prefix (such as "_"), and use those descriptors to access them. This is somewhat straightforward, and although it is more complex than writting the metaclass __call__
, ou have the advantage of being able to place additional guard code on the descriptors themselves (like: refuse assignment of values that differ in type of the default value)
PREFIX = "_"
class DefaultDescriptor:
def __init__(self, name, default):
self.name = name
self.default = default
def __get__(self, instance, owner):
if instance is None:
return self
# or, if you want the default value to be visible as a class attribute:
# return self.default
return getattr(instance, PREFIX + self.name, self.default)
def __set__(self, instance, value):
setattr(instance, PREFIX + self.name, value)
class Meta(type):
def __new__(mcls, name, bases, dictionary, defaults):
dictionary['__slots__'] = [PREFIX + key for key in defaults]
cls = super().__new__(mcls, name, bases, dictionary)
for key, value in defaults.items():
setattr(cls, key, DefaultDescriptor(key, value))
return cls
class A(metaclass = Meta, defaults = {'a':123, 'b':987}):
pass
And on the REPL:
In [37]: a = A()
In [38]: a.c = 500
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-38-ce3d946a718e> in <module>
----> 1 a.c = 500
AttributeError: 'A' object has no attribute 'c'
In [39]: a.b
Out[39]: 987
In [40]: a.b = 1000
In [41]: a.b
Out[41]: 1000
Upvotes: 4