Immutable Data - Python

Object oriented programming is built around the encapsulation of state transitions to occur with in the data objects. For example in the below Tracker class I want to hold a variable that describes the current version of the object.

@dataclasses.dataclass
class Tracker:
  version: int = 0

  def update_version(self) -> int:
    self.version += 1
    return self.version

State is managed by manipulating the internal reference which I would do like so

current_item = Tracker()
... need to change the version now ...
new_version = current_item.update_version()

This is a simple example, but what always happens is that the interaction between objects eventually falls apart as far as boundaries go and you will have compound objects manipulating internal state of other objects. That means in the above example someone will do this

current_item = Tracker(4)
...
current_item.version = -5
... later someone uses the normal expected update method

new_version = current_item.update_version() # this yields a negative number which is unexpected to say the least

In this case it is best to separate data from logic and that means state transitions are always managed away from the containing objects so it is clear what is happening and the preferred way I find to do this is with immutable data, so I would create a class like this

@dataclasses.dataclass(frozen=True, kw_only=True)
class Tracker:
    version: int = field(default=0)

    def __post_init__(self) -> None:
        if self.version < 0:
            raise ValueError(f'version {self.version} is not valid')

Key points

Here are that the version field is initialized to 0 as expected and there is a check with the __post_init__ method to validate that the version variable is initialized always as expected. Second point is that by freezing the data class and using the kw_only parameter the following is not allowed

current_item = Tracker()
current_item.version = -1 # not allowed

current_item.version = 3 # not allowed

current_item.verions += 1 # not allowed

What you should do is this

current_item = Tracker()
current_item = dataclass.replace(current_item, version=current_item.version + 1)

Test method: Dataclasses

def immutable_dataclass_test() -> None:
    current = Tracker(version=1)
    current = dataclasses.replace(current, version=current.version + 1)
    current = dataclasses.replace(current, version=0)
    ic(current)

I prefer to use dataclasses, but to do the same in Pydantic is supported and valid. However Pydantic update method assumes data validation has happened on the input as it is updated as trusted data and is why I prefer using dataclasses instead of Pydantic models.

Example with a little bit more detail.

@dataclasses.dataclass(frozen=True, kw_only=True)
class HelpLink:
    url: str


@dataclasses.dataclass(frozen=True, kw_only=True)
class Complex:
    name: str
    help_link: HelpLink


class TrackerPydantic(pydantic.BaseModel):
    version: int
    inner_data: Complex

    model_config = ConfigDict(revalidate_instances='always', frozen=True)

Test method: Pydantic

def immutable_pydantic_test() -> None:
    current = TrackerPydantic(version=1, inner_data=Complex(name='test', help_link=HelpLink(url='https://example.com')))
    # locked = TrackerPydantic(version=1)
    # locked.version = 5
    current1 = current.model_copy(update={'version': current.version + 1}, deep=True)
    ic(current1)
    current2 = current.model_copy(update={'inner_data': Complex(name=current.inner_data.name, help_link=HelpLink(url="https://asfas.com"))}, deep=True)
    ic(current2)
    current3 = current.model_copy(update={'inner_data.help_link.url': 'https://afsdf.com'}, deep=True)
    ic(current3)