class inheritance in python as fields - side effects / incorrect handling

64 Views Asked by At

Take the following simplified class structure which is used to clean and structure data from a legacy system.

  • ProductModels contain fields
  • Fields are their own classes and types on which a bunch of cleaning is done.

Overall the approach works like a charm in our use-case. But there is an issue where newly set products hold values from a previously generated product is the field is unused.

The minimal reproducible structure for Field / ProductModel looks like:

class BaseManager:
    database_settings = {}

    def __init__(self, model_class):
        self.model_class = model_class

    def build_query(self):
        pass

    def connection(self):
        pass

    def run_query(self):
        pass

    def select(self):
        pass

class MetaModel(type):
    manager_class = BaseManager

    def __new__(mcs, name, bases, attrs):
        field_list = []
        for k, v in attrs.items():
            if isinstance(v, Field):
                v.field_name = k
                v.table_name = attrs.get('table_name')
                field_list.append(k)


        cls = type.__new__(mcs, name, bases, attrs)
        cls.__field_list__ = field_list

        return cls

    def _get_manager(cls):
        return cls.manager_class(model_class=cls)

    @property
    def objects(cls):
        return cls._get_manager()


class Field:
    def __init__(self, field_name, value=None):
        self.field_name = field_name
        self.value = value

    def set_value(self, value):
        self.value = value


class ProductModel(metaclass=MetaModel):
    sku = Field('sku')
    name = Field('name')

    table_name = 'my_table'

    def __init__(self, **field_data):
        for field_name, value in field_data.items():
            getattr(self, field_name).set_value(value)

    def __str__(self):
        return f"{self.sku.value=}, {self.name.value=}"

Now look at the first example:

   ...: prod = ProductModel(sku='124', name='Name')
   ...: print(prod)
self.sku.value='124', self.name.value='Name'

The value for the sku = 124, which is correct. The value for the name = "Name", which is also correct.

But now, the second example:

   ...: prod_two = ProductModel(sku='789')
   ...: print(prod_two)
self.sku.value='789', self.name.value='Name'

The value for sku has changed to 789, correct. BUT the value for name, has remained "Name" instead of being None

It seem that when I create a new product, the field values are somehow kept from the initial product instead of being re-initialised.

One way of handling it, would be to reset all the field values upon a new ProductModel.init(). But this feels like a poor solution. Instead I would rather understand better how to initialise the classes correctly.

Can you show me the right way?

2

There are 2 best solutions below

10
matszwecja On
class ProductModel:
    sku = Field('sku')
    name = Field('name')

This defines something known as class attribute. These are supposed to be shared between different instances of the class. In fact, would you check back on your prod after creating prod_two, you will see that it had changed value to 789. That shows us that both prod.sku and prod_two.sku are the same object.

In order to define instance attributes, you need to assign them on instance, for example in the __init__.

class ProductModel:
    def __init__(self, **field_data):
        self.sku = Field('sku')
        self.name = Field('name')
        for field_name, value in field_data.items():
            getattr(self, field_name).set_value(value)
0
S.D. On

Following the suggestions of @matszwecja and @juanpa.arrivillaga, I created copies of the fields in the ProductModel.init() and set those to None.

class ProductModel(metaclass=MetaModel):
    sku = Field('sku')
    name = Field('name')

    table_name = 'my_table'

    def __init__(self, **field_data):
        for field_name in self.__field_list__:
            field = getattr(self, field_name)
            field_copy = deepcopy(field)
            field_copy.value = None
            setattr(self, field_name, field_copy)

        for field_name, value in field_data.items():
            getattr(self, field_name).set_value(value)

    def __str__(self):
        return f"{self.sku.value=}, {self.name.value=}"

These ensured that the examples are now showing the expected behaviour:

   ...: prod = ProductModel(sku='124', name='Name')
   ...: print(prod)
self.sku.value='124', self.name.value='Name'
   ...: prod_two = ProductModel(sku='789')
   ...: print(prod_two)
self.sku.value='789', self.name.value=None

If I wouldn't copy the fields, and just set them None, it would correct the current ProductModel instance, but would also affect the historic ones. By using deepcopy() we ensure every instance has their own, unshared fields.