Skip to content

Commit 5f03ecd

Browse files
committed
WIP: Custom frozen dataclasses
1 parent db47652 commit 5f03ecd

File tree

2 files changed

+238
-0
lines changed

2 files changed

+238
-0
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Custom frozen dataclass implementation that works with inheritance.
2+
3+
This module provides a `frozen_dataclass` decorator that allows creating
4+
effectively immutable dataclasses that can inherit from mutable ones,
5+
which is not possible with standard dataclasses.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import dataclasses
11+
import typing as t
12+
13+
from typing_extensions import dataclass_transform
14+
15+
_T = t.TypeVar("_T")
16+
17+
18+
@dataclass_transform(frozen_default=True)
19+
def frozen_dataclass(cls: type[_T]) -> type[_T]:
20+
"""Create a dataclass that's effectively immutable but inherits from non-frozen.
21+
22+
This decorator:
23+
1) Applies dataclasses.dataclass(frozen=False) to preserve normal dataclass
24+
generation
25+
2) Overrides __setattr__ and __delattr__ to block changes post-init
26+
3) Tells type-checkers that the resulting class should be treated as frozen
27+
28+
Parameters
29+
----------
30+
cls : Type[_T]
31+
The class to convert to a frozen-like dataclass
32+
33+
Returns
34+
-------
35+
Type[_T]
36+
The processed class with immutability enforced at runtime
37+
"""
38+
# A. Convert to a dataclass with frozen=False
39+
cls = dataclasses.dataclass(cls)
40+
41+
# Save the original __init__ to use in our hooks
42+
original_init = cls.__init__
43+
44+
# C. Create a new __init__ that will call the original and then set _frozen flag
45+
def __init__(self: t.Any, *args: t.Any, **kwargs: t.Any) -> None:
46+
# Call the original __init__
47+
original_init(self, *args, **kwargs)
48+
# Set the _frozen flag to make object immutable
49+
object.__setattr__(self, "_frozen", True)
50+
51+
# D. Custom attribute assignment method
52+
def __setattr__(self: t.Any, name: str, value: t.Any) -> None:
53+
# If _frozen is set and we're trying to set a field, block it
54+
if getattr(self, "_frozen", False) and not name.startswith("_"):
55+
error_msg = f"{cls.__name__} is immutable: cannot modify field '{name}'"
56+
raise AttributeError(error_msg)
57+
58+
# Allow the assignment
59+
object.__setattr__(self, name, value)
60+
61+
# E. Custom attribute deletion method
62+
def __delattr__(self: t.Any, name: str) -> None:
63+
# If we're frozen, block deletion
64+
if getattr(self, "_frozen", False):
65+
error_msg = f"{cls.__name__} is immutable: cannot delete field '{name}'"
66+
raise AttributeError(error_msg)
67+
68+
# Allow the deletion
69+
object.__delattr__(self, name)
70+
71+
# F. Inject into the class
72+
cls.__init__ = __init__
73+
cls.__setattr__ = __setattr__
74+
cls.__delattr__ = __delattr__
75+
76+
return cls
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for the custom frozen_dataclass implementation."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
from datetime import datetime
7+
8+
import pytest
9+
10+
from libtmux._internal.frozen_dataclass import frozen_dataclass
11+
12+
13+
# 1. Create a base class that is a normal (mutable) dataclass
14+
@dataclasses.dataclass
15+
class BasePane:
16+
"""Test base class to simulate tmux Pane."""
17+
18+
pane_id: str
19+
width: int
20+
height: int
21+
22+
def resize(self, width: int, height: int) -> None:
23+
"""Resize the pane (mutable operation)."""
24+
self.width = width
25+
self.height = height
26+
27+
28+
# 2. Subclass the mutable BasePane, but freeze it with our custom decorator
29+
@frozen_dataclass
30+
class PaneSnapshot(BasePane):
31+
"""Test snapshot class with additional fields."""
32+
33+
# Add snapshot-specific fields
34+
captured_content: list[str] = dataclasses.field(default_factory=list)
35+
created_at: datetime = dataclasses.field(default_factory=datetime.now)
36+
parent_window: WindowSnapshot | None = None
37+
38+
def resize(self, width: int, height: int) -> None:
39+
"""Override to prevent resizing."""
40+
error_msg = "Snapshot is immutable. resize() not allowed."
41+
raise NotImplementedError(error_msg)
42+
43+
44+
# Another test class for nested reference handling
45+
@frozen_dataclass
46+
class WindowSnapshot:
47+
"""Test window snapshot class."""
48+
49+
window_id: str
50+
name: str
51+
panes: list[PaneSnapshot] = dataclasses.field(default_factory=list)
52+
53+
54+
def test_basic_functionality() -> None:
55+
"""Test that the base class is mutable but the snapshot is not."""
56+
# Create a regular mutable pane
57+
pane = BasePane(pane_id="pane123", width=80, height=24)
58+
59+
# Should be mutable
60+
pane.width = 100
61+
assert pane.width == 100
62+
pane.resize(120, 30)
63+
assert pane.width == 120
64+
assert pane.height == 30
65+
66+
# Create a frozen snapshot with our decorator
67+
snapshot = PaneSnapshot(
68+
pane_id=pane.pane_id,
69+
width=pane.width,
70+
height=pane.height,
71+
captured_content=["Line1", "Line2"],
72+
)
73+
74+
# Test type checking
75+
assert isinstance(snapshot, PaneSnapshot)
76+
77+
# Should maintain the inheritance relationship
78+
assert isinstance(snapshot, BasePane)
79+
80+
# Values should be correctly assigned
81+
assert snapshot.pane_id == pane.pane_id
82+
assert snapshot.width == pane.width
83+
assert snapshot.height == pane.height
84+
assert snapshot.captured_content == ["Line1", "Line2"]
85+
assert isinstance(snapshot.created_at, datetime)
86+
87+
88+
def test_immutability() -> None:
89+
"""Test that the snapshot is immutable."""
90+
snapshot = PaneSnapshot(
91+
pane_id="pane123", width=80, height=24, captured_content=["Line1"]
92+
)
93+
94+
# Attempting to modify a field should raise AttributeError
95+
with pytest.raises(AttributeError) as excinfo:
96+
snapshot.width = 200
97+
assert "immutable" in str(excinfo.value)
98+
99+
# Attempting to add a new field should raise AttributeError
100+
with pytest.raises(AttributeError) as excinfo:
101+
snapshot.new_field = "value" # type: ignore
102+
assert "immutable" in str(excinfo.value)
103+
104+
# Attempting to delete a field should raise AttributeError
105+
with pytest.raises(AttributeError) as excinfo:
106+
del snapshot.width
107+
assert "immutable" in str(excinfo.value)
108+
109+
# Calling a method that tries to modify state should fail
110+
with pytest.raises(NotImplementedError) as excinfo:
111+
snapshot.resize(200, 50)
112+
assert "immutable" in str(excinfo.value)
113+
114+
115+
def test_nested_references() -> None:
116+
"""Test that nested structures work properly."""
117+
# Create temporary panes (will be re-created with the window)
118+
temp_panes: list[PaneSnapshot] = []
119+
120+
# We need to create objects in a specific order to handle bi-directional references
121+
# First, create a window with an empty panes list
122+
window = WindowSnapshot(window_id="win1", name="Test Window", panes=temp_panes)
123+
124+
# Now create panes with references to the window
125+
pane1 = PaneSnapshot(pane_id="pane1", width=80, height=24, parent_window=window)
126+
pane2 = PaneSnapshot(pane_id="pane2", width=80, height=24, parent_window=window)
127+
128+
# Update the panes list before it gets frozen
129+
# This is a bit of a hack, but that's how you'd need to handle bi-directional
130+
# references with immutable objects in real code
131+
temp_panes.append(pane1)
132+
temp_panes.append(pane2)
133+
134+
# Test relationships
135+
assert pane1.parent_window is window
136+
assert pane2.parent_window is window
137+
assert pane1 in window.panes
138+
assert pane2 in window.panes
139+
140+
# Can't test by trying to reassign since we'll hit a type error first
141+
# But we can still modify the contents of lists (limitation of Python)
142+
# Let's verify this limitation exists:
143+
pane3 = PaneSnapshot(pane_id="pane3", width=100, height=30)
144+
window.panes.append(pane3)
145+
assert len(window.panes) == 3 # Successfully modified
146+
147+
# This is a "leaky abstraction" in Python's immutability model
148+
# In real code, consider using immutable collections (tuple, frozenset)
149+
# or deep freezing containers
150+
151+
152+
def test_internal_attributes() -> None:
153+
"""Test that internal attributes (starting with _) can be modified."""
154+
snapshot = PaneSnapshot(
155+
pane_id="pane123",
156+
width=80,
157+
height=24,
158+
)
159+
160+
# Should be able to set internal attributes
161+
snapshot._internal_cache = {"test": "value"} # type: ignore
162+
assert snapshot._internal_cache == {"test": "value"} # type: ignore

0 commit comments

Comments
 (0)