Skip to content

Commit b96c9ce

Browse files
committed
WIP: Custom frozen dataclasses
1 parent e34253f commit b96c9ce

File tree

2 files changed

+244
-0
lines changed

2 files changed

+244
-0
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
# Add type ignore directives to silence mypy "Cannot assign to a method" errors
73+
cls.__init__ = __init__ # type: ignore[method-assign]
74+
cls.__setattr__ = __setattr__ # type: ignore[method-assign]
75+
cls.__delattr__ = __delattr__ # type: ignore[method-assign]
76+
77+
return cls
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
# Silence specific mypy errors with a global disable
29+
# mypy: disable-error-code="misc"
30+
31+
32+
# 2. Subclass the mutable BasePane, but freeze it with our custom decorator
33+
@frozen_dataclass
34+
class PaneSnapshot(BasePane):
35+
"""Test snapshot class with additional fields."""
36+
37+
# Add snapshot-specific fields
38+
captured_content: list[str] = dataclasses.field(default_factory=list)
39+
created_at: datetime = dataclasses.field(default_factory=datetime.now)
40+
parent_window: WindowSnapshot | None = None
41+
42+
def resize(self, width: int, height: int) -> None:
43+
"""Override to prevent resizing."""
44+
error_msg = "Snapshot is immutable. resize() not allowed."
45+
raise NotImplementedError(error_msg)
46+
47+
48+
# Another test class for nested reference handling
49+
@frozen_dataclass
50+
class WindowSnapshot:
51+
"""Test window snapshot class."""
52+
53+
window_id: str
54+
name: str
55+
panes: list[PaneSnapshot] = dataclasses.field(default_factory=list)
56+
57+
58+
def test_basic_functionality() -> None:
59+
"""Test that the base class is mutable but the snapshot is not."""
60+
# Create a regular mutable pane
61+
pane = BasePane(pane_id="pane123", width=80, height=24)
62+
63+
# Should be mutable
64+
pane.width = 100
65+
assert pane.width == 100
66+
pane.resize(120, 30)
67+
assert pane.width == 120
68+
assert pane.height == 30
69+
70+
# Create a frozen snapshot with our decorator
71+
snapshot = PaneSnapshot(
72+
pane_id=pane.pane_id,
73+
width=pane.width,
74+
height=pane.height,
75+
captured_content=["Line1", "Line2"],
76+
)
77+
78+
# Test type checking
79+
assert isinstance(snapshot, PaneSnapshot)
80+
81+
# Should maintain the inheritance relationship
82+
assert isinstance(snapshot, BasePane)
83+
84+
# Values should be correctly assigned
85+
assert snapshot.pane_id == pane.pane_id
86+
assert snapshot.width == pane.width
87+
assert snapshot.height == pane.height
88+
assert snapshot.captured_content == ["Line1", "Line2"]
89+
assert isinstance(snapshot.created_at, datetime)
90+
91+
92+
def test_immutability() -> None:
93+
"""Test that the snapshot is immutable."""
94+
snapshot = PaneSnapshot(
95+
pane_id="pane123", width=80, height=24, captured_content=["Line1"]
96+
)
97+
98+
# Attempting to modify a field should raise AttributeError
99+
with pytest.raises(AttributeError) as excinfo:
100+
snapshot.width = 200 # type: ignore
101+
assert "immutable" in str(excinfo.value)
102+
103+
# Attempting to add a new field should raise AttributeError
104+
with pytest.raises(AttributeError) as excinfo:
105+
snapshot.new_field = "value" # type: ignore
106+
assert "immutable" in str(excinfo.value)
107+
108+
# Attempting to delete a field should raise AttributeError
109+
with pytest.raises(AttributeError) as excinfo:
110+
del snapshot.width
111+
assert "immutable" in str(excinfo.value)
112+
113+
# Calling a method that tries to modify state should fail
114+
# Use separate variable for the NotImplementedError exception info
115+
with pytest.raises(NotImplementedError) as resize_excinfo:
116+
snapshot.resize(200, 50)
117+
assert "immutable" in str(resize_excinfo.value)
118+
119+
120+
def test_nested_references() -> None:
121+
"""Test that nested structures work properly."""
122+
# Create temporary panes (will be re-created with the window)
123+
temp_panes: list[PaneSnapshot] = []
124+
125+
# We need to create objects in a specific order to handle bi-directional references
126+
# First, create a window with an empty panes list
127+
window = WindowSnapshot(window_id="win1", name="Test Window", panes=temp_panes)
128+
129+
# Now create panes with references to the window
130+
pane1 = PaneSnapshot(pane_id="pane1", width=80, height=24, parent_window=window)
131+
pane2 = PaneSnapshot(pane_id="pane2", width=80, height=24, parent_window=window)
132+
133+
# Update the panes list before it gets frozen
134+
# This is a bit of a hack, but that's how you'd need to handle bi-directional
135+
# references with immutable objects in real code
136+
temp_panes.append(pane1)
137+
temp_panes.append(pane2)
138+
139+
# Test relationships
140+
assert pane1.parent_window is window
141+
assert pane2.parent_window is window
142+
assert pane1 in window.panes
143+
assert pane2 in window.panes
144+
145+
# Can't test by trying to reassign since we'll hit a type error first
146+
# But we can still modify the contents of lists (limitation of Python)
147+
# Let's verify this limitation exists:
148+
pane3 = PaneSnapshot(pane_id="pane3", width=100, height=30)
149+
window.panes.append(pane3)
150+
assert len(window.panes) == 3 # Successfully modified
151+
152+
# This is a "leaky abstraction" in Python's immutability model
153+
# In real code, consider using immutable collections (tuple, frozenset)
154+
# or deep freezing containers
155+
156+
157+
def test_internal_attributes() -> None:
158+
"""Test that internal attributes (starting with _) can be modified."""
159+
snapshot = PaneSnapshot(
160+
pane_id="pane123",
161+
width=80,
162+
height=24,
163+
)
164+
165+
# Should be able to set internal attributes
166+
snapshot._internal_cache = {"test": "value"} # type: ignore
167+
assert snapshot._internal_cache == {"test": "value"} # type: ignore

0 commit comments

Comments
 (0)