1

Requirement:

I have a class with many fields initialized in __init__ method. Some of these fields should be possible to reset to initial values via a reset() method. I would like to provide typing info for these attributes and make Flake, MyPy, PyCharm (and me) happy with the solution.

Possible solutions:

  1. Duplicate initial values

    In this solution all tools (MyPy, Flake, PyCharm) are happy but not me. I have initial values in two places (__init__ and reset) and I need to keep them in sync. There is a possibility that if in the future one initial value needs to be modified, then I will not change it in both places.

    class Test:
    
      def __init__(self) -> None:
        self.persistentCounter: int = 0
    
        self.resetableCounter: int = 1  # the same value is in reset method. Keep them in sync!!!
        # seven more attributes of different types and initial values
    
      def reset(self) -> None:
        self.resetableCounter = 1  # the same value is in __init__ method. Keep them in sync!!!
        # reset remaining seven attributes to the same values as in __init__()
    
  2. Keep initial values in one method only

    The fix seems to be easy: keep initial values in reset method only and call reset from __init__.

    class Test:
    
      def __init__(self) -> None:
        self.persistentCounter: int = 0
    
        self.reset()
    
      def reset(self) -> None:
        self.resetableCounter: int = 1
        # seven more attributes of different types and initial values
    

    I'm happy (and Flake8 and MyPy too) with such solution but PyCharm complains that

    Instance attribute resetableCounter defined outside __init__

    This warning can be switched off, but sometimes it is useful - when I forgot to define some attribute in neither __init__ nor reset method.

  3. Define attributes as None

    So we can improve the second solution - define attribute inside __init__ but set it as None and then call the reset method.

    from typing import Optional
    
    
    class Test:
    
      def __init__(self) -> None:
        self.persistentCounter: int = 0
    
        self.resetableCounter: Optional[int] = None
        # seven more attributes of different types - all defined as Optional
    
        self.reset()
    
      def reset(self) -> None:
        self.resetableCounter = 1
        # seven more attributes with different initial values
    
      def foo(self) -> bool:
        return self.resetableCounter > 10
    

    The downside of this solution is that the attribute is defined as Optional[int], not int and when I use it for example in the foo method, then mypy complains

    error: Unsupported operand types for < ("int" and "None")
    note: Left operand is of type "Optional[int]"
    

    This can be fixed when I put an additional assert inside foo method:

      def foo(self) -> bool:
        assert self.resetableCounter is not None
        return self.resetableCounter > 10
    

    It makes all tools happy but not me - I do not want to fill the source code with many "unnecessary" assertions.

Question:

How to fulfill the requirement specified above and mitigate downsides of presented solutions?

Instance attribute attribute_name defined outside __init__ describes a similar issue but no answer fits my requirement (see above).

eNca
  • 1,043
  • 11
  • 21
  • 1
    Note this would probably be better dealt with by [bountying](https://stackoverflow.com/help/privileges/set-bounties) the canonical Q&A you already found, so new answers end up there too. – jonrsharpe Jan 26 '22 at 17:49
  • "but no answer fits my requirements" Why is that? – MisterMiyagi Jan 26 '22 at 17:50
  • How many arguments are "persistent"? Is there one for each resettable attribute or more/less? Do the attributes come in pairs by any chance? – MisterMiyagi Jan 26 '22 at 17:52
  • @MisterMiyagi resetable and persistent attributes are not connected in any way. I have mentioned presistent attributes just to prevent an answer that I can create a new instance of the object instead of reseting the existing one. That's not a solution for my case - I need to keep the reset method. – eNca Jan 26 '22 at 18:06
  • @MisterMiyagi - the comment about requirements. Unfortunately jonrsharpe removed all headers from my original question so it was not clear what is my requirement. I returned headers (as bold text - to be less obtrusive) back to the question. I hope it make more sense now. – eNca Jan 26 '22 at 18:59
  • If you use many linters, you will probably end up with some linters not agreeing if your code is sound or not. Maybe the solution would be to only use one linter, that "agrees" with solutions that you are happy with, and stick with those solutions. In this case, this would mean sticking with the second solution (which also seems the best without linting, exactly for the reasons you give) and reject linters that do not accept it (linting should prevent bugs: if a linter refuses a pattern that is also made to prevent bugs, it is not a good linter). – jthulhu Jan 26 '22 at 19:02

2 Answers2

2

You could write a custom descriptor that stores the default value and handles the resetting.

from typing import Type, Any, TypeVar, Generic, Optional

T = TypeVar('T')


class ResettableAttribute(Generic[T]):
    def __init__(self, default_value: T) -> None:
        # the attribute stores the default value
        self.default_value = default_value
        # and its name (will be filled later)
        self.name = ''

    def __set_name__(self, owner: Type[Any], name: str) -> None:
        # this method requires python 3.6+
        # it is called when we instantiate a descriptor inside a class

        # prepend an underscore to prevent name collisions
        self.name = f'_{name}'

    def __get__(self, instance: Optional[Any], owner: Type[Any]) -> T:
        # this is called when we access the descriptor from a class (or instance)
        assert instance is not None  # you could handle the case ClassName.attribute
        # return the stored value or the default if it was never set
        return getattr(instance, self.name, self.default_value)

    def __set__(self, instance: Any, value: T) -> None:
        # this is called when we assign to the descriptor from an instance
        setattr(instance, self.name, value)

    def __delete__(self, instance: Any) -> None:
        # this is called when you write del instance.attribute
        # we can use this to reset the value to the default
        setattr(instance, self.name, self.default_value)

Note: this only works correctly with immutable default values, because the default_values are identical for all instances.

You can use the attribute like this:

class Test:
    x = ResettableAttribute(1)
    y = ResettableAttribute("default")

    def __init__(self) -> None:
        self.persistent = 0

    def reset(self) -> None:
        del self.x
        del self.y


a = Test()
b = Test()

a.x = 123
print(a.x, b.x)  # 123 1
b.x = 234
print(a.x, b.x)  # 123 234
a.reset()
print(a.x, b.x)  # 1 234

Now the default values are only mentioned once. Flake, Mypy and Pycharm don't complain.

If you still don't want to repeat all resettable attributes in your classes you could do this:

def reset_all_resettable_attributes(instance: Any) -> None:
    for attribute in vars(type(instance)).values():
        if isinstance(attribute, ResettableAttribute):
            attribute.__delete__(instance)

Then the reset() method becomes this:

def reset(self) -> None:
    reset_all_resettable_attributes(self)

If you want to support mutable default values do this: let ResettableAttribute accept factory functions that construct the default value and call that factory in the constructor.

Wombatz
  • 4,958
  • 1
  • 26
  • 35
  • That's really interesting idea. But it seems to me that the `ResettableAttribute` class is not needed. Be inspired by your very valuable answer I ended with much simpler solution - see my own answer in this thread. Many thanks for your input. – eNca Jan 31 '22 at 17:19
0

Be inspired by @Wombatz response I ended with this solution:

class Test:

  resetableCounter: int = 1
  # seven more attributes of different types

  def __init__(self) -> None:
    self.persistentCounter: int = 0
    self.reset()

  def reset(self) -> None:
    if 'resetableCounter' in self.__dict__:
      del self.resetableCounter
    # del also rest seven attributes

All tools are happy and the code is clean, brief and readable.

Many thanks for all contributions.

Update

I had to add the if 'resetableCounter' in self.__dict__: line to the code above. Otherwise the reset method failed when it was called from __init__ or when it was called two times in the row without variable modification between first and second call.

The code is not so nice now (especially the need to write manually the variable name as string) but I think it is still the best (known to me) solution.

eNca
  • 1,043
  • 11
  • 21