Home > Blockchain >  Dynamically setting properties with metaclass
Dynamically setting properties with metaclass

Time:11-24

I'm facing a bug when creating a metaclass that dynamically creates classes with properties given a config file.

In more detail, I expect to dynamically create classes with some properties (regular setter and a getter that runs the result of a custom function on that attribute). So I created a Factory class to register classes at runtime and a metaclass to set the properties given the config dict.

from copy import deepcopy
from typing import Any, Callable, Dict, Tuple


class Meta(type):
    def __new__(
        cls,
        clsname: str,
        bases: Tuple,
        attrs: Dict[str, Any],
        fields: Dict[str, Callable] = None,
    ) -> object:
        if fields is not None:
            for field, field_fn in fields.items():
                attrs[f"_{field}"] = None
                attrs[f"{field}"] = property(
                    fget=lambda self: deepcopy(field_fn)(getattr(self, f"_{field}")),
                    fset=lambda self, value: setattr(self, f"_{field}", value),
                )
        return super().__new__(cls, clsname, bases, attrs)


class Factory:
    registry = {}

    @classmethod
    def register(
        cls,
        name: str,
        cfg: Dict[str, Callable],
    ) -> None:
        class ConfigurableClass(metaclass=Meta, fields=cfg):
            pass

        Factory.registry[name] = ConfigurableClass

    @classmethod
    def make(cls, name: str):
        return cls.registry[name]()


if __name__ == "__main__":
    Factory.register("foo", {"a": lambda x: x   1, "b": lambda x: x - 1})
    obj = Factory.make("foo")
    obj.a = 5
    obj.b = 5
    print(obj.a, obj.b)
    # Expected 6 and 4 but get 4 and 4 instead

However, for some reason, the function of the last dict key gets registered for all properties. I even tried to add a deepcopy in there.

Any help is appreciated, thanks.

CodePudding user response:

the problem is that you have a lambda function for the getter, so the value for field_fn is lazy-evaluated when the getter for a property is first called; therefore, all properties will return the value for the local var field_fn that is set in the last loop iteration, which explains the weird behavior you were noticing.

You can solve this by creating an outer function that wraps the property getter in a function as shown below, and pass arguments to the function, so that the local var field_fn for each property is explicitly set. This way, each property will use the local field_fn value that is explicitly bound to it.

from typing import Any, Callable, Dict, Tuple


class Meta(type):
    def __new__(
        cls,
        clsname: str,
        bases: Tuple,
        attrs: Dict[str, Any],
        fields: Dict[str, Callable] = None,
    ) -> object:
        if fields is not None:
            for field, field_fn in fields.items():

                # the problem is that you have a lambda function, so the `field_fn`
                # is only evaluated when the `getter` is first called. Since the
                # `field_fn` is lazy evaluated in this way, the value of `field_fn`
                # from the last loop iteration is used instead. You can solve this
                # by creating a function that binds the local `field_fn` for each
                # iteration explicitly.
                def getter_for_field(field_fn: Callable):
                    # lambda function here will use `field_fn` in function
                    # locals, *not* the one which is set in each loop iteration.
                    return lambda self: field_fn(getattr(self, under_f))

                under_f = f'_{field}'

                attrs[under_f] = None
                attrs[field] = property(
                        ## Modified
                        fget=getter_for_field(field_fn),
                        ## End
                        fset=lambda self, value: setattr(self, under_f, value),
                    )

        return super().__new__(cls, clsname, bases, attrs)


class Factory:
    registry = {}

    @classmethod
    def register(
        cls,
        name: str,
        cfg: Dict[str, Callable],
    ) -> None:
        class ConfigurableClass(metaclass=Meta, fields=cfg):
            pass

        Factory.registry[name] = ConfigurableClass

    @classmethod
    def make(cls, name: str):
        return cls.registry[name]()


if __name__ == "__main__":
    Factory.register("foo", {"a": lambda x: x   1, "b": lambda x: x - 1})
    obj = Factory.make("foo")
    obj.a = 5
    obj.b = 5
    print(obj.a, obj.b)
    # Expected 6 and 4 but get 4 and 4 instead

Output:

6 4
  • Related