Wör Du Schnaffzig
Wör Du Schnaffzig

Reputation: 1051

Python Metaclass defining __slots__ makes __slots__ readonly

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

Answers (1)

jsbueno
jsbueno

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

Related Questions