24

I would like to define metaclass that will enable me to create properties (i.e. setter, getter) in new class on the base of the class's attributes.

For example, I would like to define the class:

class Person(metaclass=MetaReadOnly):
    name = "Ketty"
    age = 22

    def __str__(self):
        return ("Name: " + str(self.name) + "; age: "
                + str(self.age))

But I would like to get something like this:

class Person():
    __name = "Ketty"
    __age = 22

    @property
    def name(self):
        return self.__name;
    @name.setter
    def name(self, value):
        raise RuntimeError("Read only")

    @property
    def age(self):
        return self.__age
    @age.setter
    def age(self, value):
        raise RuntimeError("Read only")

    def __str__(self):
        return ("Name: " + str(self.name) + "; age: "
                + str(self.age))

Here is the metaclass I have written:

class MetaReadOnly(type):
    def __new__(cls, clsname, bases, dct):

        result_dct = {}

        for key, value in dct.items():
            if not key.startswith("__"):
                result_dct["__" + key] = value

                fget = lambda self: getattr(self, "__%s" % key)
                fset = lambda self, value: setattr(self, "__"
                                                   + key, value)

                result_dct[key] = property(fget, fset)

            else:
                result_dct[key] = value

        inst = super(MetaReadOnly, cls).__new__(cls, clsname,
                                                bases, result_dct)

        return inst

    def raiseerror(self, attribute):
        raise RuntimeError("%s is read only." % attribute)

However it dosen't work properly.

client = Person()
print(client)

Sometimes I get:

Name: Ketty; age: Ketty

sometimes:

Name: 22; age: 22

or even an error:

Traceback (most recent call last):
  File "F:\Projects\TestP\src\main.py", line 38, in <module>
    print(client)
  File "F:\Projects\TestP\src\main.py", line 34, in __str__
    return ("Name: " + str(self.name) + "; age: " + str(self.age))
  File "F:\Projects\TestP\src\main.py", line 13, in <lambda>
    fget = lambda self: getattr(self, "__%s" % key)
AttributeError: 'Person' object has no attribute '____qualname__'

I have found example how it can be done other way Python classes: Dynamic properties, but I would like to do it with metaclass. Do you have any idea how this can be done or is it possible at all ?

martineau
  • 119,623
  • 25
  • 170
  • 301
hakubaa
  • 305
  • 2
  • 7

2 Answers2

27

Bind the current value of key to the parameter in your fget and fset definitions:

fget = lambda self, k=key: getattr(self, "__%s" % k)
fset = lambda self, value, k=key: setattr(self, "__" + k, value)

This is a classic Python pitfall. When you define

fget = lambda self: getattr(self, "__%s" % key)

the value of key is determined when fget is called, not when fget is defined. Since it is a nonlocal variable, its value is found in the enclosing scope of the __new__ function. By the time fget gets called, the for-loop has ended, so the last value of key is the value found. Python3's dict.item method returns items in a unpredictable order, so sometimes the last key is, say, __qualname__, which is why a suprising error is sometimes raised, and sometimes the same wrong value is returned for all attributes with no error.


When you define a function with a parameter with a default value, the default value is bound to the parameter at the time the function is defined. Thus, the current values of key get corrected bound to fget and fset when you bind default values to k.

Unlike before, k is now a local variable. The default value is stored in fget.__defaults__ and fset.__defaults__.


Another option is to use a closure. You can define this outside of the metaclass:

def make_fget(key):
    def fget(self):
        return getattr(self, "__%s" % key)
    return fget
def make_fset(key):
    def fset(self, value):
        setattr(self, "__" + key, value)
    return fset

and use it inside the metaclass like this:

result_dct[key] = property(make_fget(key), make_fset(key))

Now when fget or fset is called, the proper value of key is found in the enclosing scope of make_fget or make_fset.

unutbu
  • 842,883
  • 184
  • 1,785
  • 1,677
0

@unutbu's answer is perfectly fine and addressed the main caveats very well.

However, there is one more thing to note (I cannot comment yet so here I am posing an entire answer), note that classproperty's and instanceproperty's have different keys.

What you are doing here is entirely based on class properties. If one wishes to make a setter for an "instance" property the key must be f"_{clsname}__{key}". I lack the technical understanding of Python data model to explain why or if what I'm saying is entirely correct or has other caveats. Just posting this here as a side note.

This is especially important if you wish to allow the user to override the methods with custom ones like the example I provide here (I'm not sure even if it has no syntax error but still I post it here):

class MyMeta(type):
    def __new__(cls, clsname, bases, info, **kwargs):
        property_key = "__my_private"  # should have been f"_{clsname}__my_private"
        info[property_key] = "default_value"
        if "my_getter" not in info:
            info["my_getter"] = lambda self, key=property_key: getattr(self, key)
        return super().__new__(cls, clsname, bases, info, **kwargs)

class MyClass(metaclass=MyMeta):
    def my_getter(self):
        return f"will fail to get {self.__my_private}"

MyClass().my_getter() # => Raises: AttributeError "_MyClass__my_private" not found.
# This would work so fine if no `self` keyword was being used (treated as classproperty)
Davoodeh
  • 69
  • 1
  • 5