Identifier for each subclass

Viewed 47

I would like to create a metaclass/descriptor in order to have a identifier attribute which counts through each instances. Furthermore I would like to have that the identifier attribute cannot be changed. This is what I have done so far:

class Identifier(object):
  """Iterator for Transformation Identification
  """

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

  def __iter__(self):
      return self

  def __next__(self):
      self.current += 1
      return self.current

  def __get__(self, instance, owner):
      return self

  def __set__(self, instance, value):
    raise Exception('')


class InheritDecoratorsMixin:

  _iter = Identifier(0)

  def __init_subclass__(cls, *args, **kwargs):
    setattr(cls, 'id', next(cls._iter))
    super().__init_subclass__(*args, **kwargs)

Here we have an example:

class Test1(InheritDecoratorsMixin):
    pass


class Test2(Test1):
    pass

class Test3(Test1):
    pass


class Test4(Test3):
    pass


a = Test2()
print(a.id)
b = Test3()
print(b.id)
c = Test4()
print(c.id)
print(c.id)
c.id = 5
print(c.id)

The output is

2
3
4
4
5

This is fine except for the fact that the attribute can be changed. When I changed the code to:

class Identifier(object):
  """Iterator for Transformation Identification
  """

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

  def __iter__(self):
      return self

  def __next__(self):
      self.current += 1
      return self

  def __get__(self, instance, owner):
      return self

  def __set__(self, instance, value):
    raise Exception('')

where the next returns 'self' which is basically the iterator then the value cannot be set to '5' but the attribute 'id' is not an integer anymore but an instance of the iterator class. Is there a way to combine both versions, where we both have an integer as id attribute as well as the restriction that the attribute cannot be changed?

Note: in the output there are different values for each subclass but it remains the same for each instance.

Thanks in advance for any help.

2 Answers

You implement a descriptor protocol but do so on the class you are using an infinite counter essentially. That __set__ you write to block setting id will only work if you try to set _iter.

from typing import Any, NoReturn

from typing_extensions import Self

class Identifier:
  """Descriptor for Transformation Identification
  """

  def __init__(self, starting_id: int) -> None:
      self.current: int = starting_id

  def __set_name__(self, owner: type, name: str) -> None:
      self.name = name
      self.id_map: dict[owner, int] = {}

  def __get__(self, instance: Any, owner: type) -> Self|int:
      if instance is None:
           return self
      else:
           instance_id = self.id_map.get(instance, None)
           if instance_id is None:
               self.current += 1
               instance_id = self.current
               self.id_map[instance] = instance_id
           return instance_id

  def __set__(self, instance: Any, value: Any) -> NoReturn:
      raise Exception(f'Cannot set {self.name}')


class InheritDecoratorsMixin:

    id = Identifier(0)


class Test1(InheritDecoratorsMixin):
    pass

class Test2(Test1):
    pass

class Test3(Test1):
    pass

class Test4(Test3):
    pass

a = Test2()
print(a.id)
b = Test3()
print(b.id)
b2 = Test3()
print(b2.id)
c = Test4()
print(c.id)
print(c.id)
c.id = 5

And the output in ipython

1
2
3
4
4
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Input In [4], in <module>
     59 print(c.id)
     60 print(c.id)
---> 61 c.id = 5

Input In [4], in Identifier.__set__(self, instance, value)
     27 def __set__(self, instance: Any, value: Any) -> NoReturn:
---> 28     raise Exception(f"Cannot set {self.name}")

Exception: Cannot set id

You don't actually need a __init_subclass__ method there: the descriptor itself can work to keep track of the instances for each class it is set on. And then you can assign the descriptor to .id itself:

class Identifier:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        inner_attr = "_" + self.name
        id = instance.__dict__.get(inner_attr)
        if id is not None:
            return id
        counter = owner.__dict__.get(inner_attr, 0)

        counter += 1
        setattr(instance, inner_attr, counter)
        setattr(owner, inner_attr, counter)
        return counter

    def __set__(self, instance, value):
        raise AttributeError("read only attribute")

class A:
    id = Identifier()


And testing it on an interactive session:


In [35]: a = A()

In [36]: a.id
Out[36]: 1

In [37]: a.id = 3
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [37], in <cell line: 1>()
----> 1 a.id = 3

Input In [34], in Identifier.__set__(self, instance, value)
     19 def __set__(self, instance, value):
---> 20     raise AttributeError("read only attribute")

AttributeError: read only attribute

In [38]: class B(A): pass

In [39]: B().id
Out[39]: 1

In [40]: B().id
Out[40]: 2

In [41]: B().id
Out[41]: 3

In [42]: A().id
Out[42]: 2


So, this won't need metaclasses or __init_subclass__ at all. As for the unerscored attribute _id: it will remain writable, and it is important that you understand that it is impossible, in Python to prevent the inner state of being modifiable. The "public" attribute, ".id" is not changeable, and that is what maytters. It is possible to "hide away" the _id attribute - as an attribute of the descriptor instead of in the class and instances dictionary, for example, but ultimately, if someone 'wants' to change it, there will always be a way to do so. The documentation contract that it is a readonly, public attribute named "id" and whatever else exists is private have to be enough.

Related