Skip to content
Draft
8 changes: 8 additions & 0 deletions docs/internals/frozen_dataclass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Frozen Dataclass - `libtmux._internal.frozen_dataclass`

```{eval-rst}
.. automodule:: libtmux._internal.frozen_dataclass
:members:
:special-members:

```
6 changes: 6 additions & 0 deletions docs/internals/frozen_dataclass_sealable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Frozen Dataclass (Sealable) - `libtmux._internal.frozen_dataclass_sealable`

```{eval-rst}
.. automodule:: libtmux._internal.frozen_dataclass_sealable
:members:
:special-members:
2 changes: 2 additions & 0 deletions docs/internals/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ If you need an internal API stabilized please [file an issue](https://github.com

```{toctree}
dataclasses
frozen_dataclass
frozen_dataclass_sealable
query_list
```

Expand Down
20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ files = [
"tests",
]

[[tool.mypy.overrides]]
module = "libtmux._internal.frozen_dataclass"
disable_error_code = ["method-assign"]

[[tool.mypy.overrides]]
module = "libtmux._internal.frozen_dataclass_sealable"
disable_error_code = ["method-assign"]

[[tool.mypy.overrides]]
module = "tests._internal.test_frozen_dataclass_sealable"
ignore_errors = true

[[tool.mypy.overrides]]
module = "tests.examples._internal.frozen_dataclass_sealable.test_basic"
ignore_errors = true

[tool.coverage.run]
branch = true
parallel = true
Expand Down Expand Up @@ -208,6 +224,10 @@ convention = "numpy"

[tool.ruff.lint.per-file-ignores]
"*/__init__.py" = ["F401"]
"src/libtmux/_internal/frozen_dataclass.py" = [
"B010", # set-attr-with-constant
]
"tests/_internal/test_frozen_dataclass_sealable.py" = ["RUF009"]

[tool.pytest.ini_options]
addopts = [
Expand Down
156 changes: 156 additions & 0 deletions src/libtmux/_internal/frozen_dataclass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Custom frozen dataclass implementation that works with inheritance.

This module provides a `frozen_dataclass` decorator that allows creating
effectively immutable dataclasses that can inherit from mutable ones,
which is not possible with standard dataclasses.
"""

from __future__ import annotations

import dataclasses
import functools
import typing as t

from typing_extensions import dataclass_transform

_T = t.TypeVar("_T")


@dataclass_transform(frozen_default=True)
def frozen_dataclass(cls: type[_T]) -> type[_T]:
"""Create a dataclass that's effectively immutable but inherits from non-frozen.

This decorator:
1) Applies dataclasses.dataclass(frozen=False) to preserve normal dataclass
generation
2) Overrides __setattr__ and __delattr__ to block changes post-init
3) Tells type-checkers that the resulting class should be treated as frozen

Parameters
----------
cls : Type[_T]
The class to convert to a frozen-like dataclass

Returns
-------
Type[_T]
The processed class with immutability enforced at runtime

Examples
--------
Basic usage:

>>> @frozen_dataclass
... class User:
... id: int
... name: str
>>> user = User(id=1, name="Alice")
>>> user.name
'Alice'
>>> user.name = "Bob"
Traceback (most recent call last):
...
AttributeError: User is immutable: cannot modify field 'name'

Mutating internal attributes (_-prefixed):

>>> user._cache = {"logged_in": True}
>>> user._cache
{'logged_in': True}

Nested mutable fields limitation:

>>> @frozen_dataclass
... class Container:
... items: list[int]
>>> c = Container(items=[1, 2])
>>> c.items.append(3) # allowed; mutable field itself isn't protected
>>> c.items
[1, 2, 3]
>>> # For deep immutability, use immutable collections (tuple, frozenset)
>>> @frozen_dataclass
... class ImmutableContainer:
... items: tuple[int, ...] = (1, 2)
>>> ic = ImmutableContainer()
>>> ic.items
(1, 2)

Inheritance from mutable base classes:

>>> import dataclasses
>>> @dataclasses.dataclass
... class MutableBase:
... value: int
>>> @frozen_dataclass
... class ImmutableSub(MutableBase):
... pass
>>> obj = ImmutableSub(42)
>>> obj.value
42
>>> obj.value = 100
Traceback (most recent call last):
...
AttributeError: ImmutableSub is immutable: cannot modify field 'value'

Security consideration - modifying the _frozen flag:

>>> @frozen_dataclass
... class SecureData:
... secret: str
>>> data = SecureData(secret="password123")
>>> data.secret = "hacked"
Traceback (most recent call last):
...
AttributeError: SecureData is immutable: cannot modify field 'secret'
>>> # CAUTION: The _frozen attribute can be modified to bypass immutability
>>> # protection. This is a known limitation of this implementation
>>> data._frozen = False # intentionally bypassing immutability
>>> data.secret = "hacked" # now works because object is no longer frozen
>>> data.secret
'hacked'
"""
# A. Convert to a dataclass with frozen=False
cls = dataclasses.dataclass(cls)

# B. Explicitly annotate and initialize the `_frozen` attribute for static analysis
cls.__annotations__["_frozen"] = bool
setattr(cls, "_frozen", False)

# Save the original __init__ to use in our hooks
original_init = cls.__init__

# C. Create a new __init__ that will call the original and then set _frozen flag
@functools.wraps(original_init)
def __init__(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None:
# Call the original __init__
original_init(self, *args, **kwargs)
# Set the _frozen flag to make object immutable
object.__setattr__(self, "_frozen", True)

# D. Custom attribute assignment method
def __setattr__(self: t.Any, name: str, value: t.Any) -> None:
# If _frozen is set and we're trying to set a field, block it
if getattr(self, "_frozen", False) and not name.startswith("_"):
# Allow mutation of private (_-prefixed) attributes after initialization
error_msg = f"{cls.__name__} is immutable: cannot modify field '{name}'"
raise AttributeError(error_msg)

# Allow the assignment
object.__setattr__(self, name, value)

# E. Custom attribute deletion method
def __delattr__(self: t.Any, name: str) -> None:
# If we're frozen, block deletion
if getattr(self, "_frozen", False):
error_msg = f"{cls.__name__} is immutable: cannot delete field '{name}'"
raise AttributeError(error_msg)

# Allow the deletion
object.__delattr__(self, name)

# F. Inject methods into the class (using setattr to satisfy mypy)
setattr(cls, "__init__", __init__) # Sets _frozen flag post-initialization
setattr(cls, "__setattr__", __setattr__) # Blocks attribute modification post-init
setattr(cls, "__delattr__", __delattr__) # Blocks attribute deletion post-init

return cls
Loading