Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d5538a2
pyproject(mypy) Add mypy override for frozen_dataclass method-assign
tony Feb 28, 2025
3cd3db0
pyproject(ruff) Ignore B010 set-attr-with-constant rule for frozen_da…
tony Feb 28, 2025
2a06a81
pyproject(mypy) Add mypy override for `frozen_dataclass_sealable` `me…
tony Feb 28, 2025
909c7c9
pyproject.toml(chore[mypy]): Exclude frozen_dataclass_sealable test f…
tony Mar 1, 2025
e28a6c8
pyproject.toml(chore[lint,types]): Exclude frozen_dataclass_sealable …
tony Mar 1, 2025
c4f6db7
frozen_dataclass(feat): Add `frozen_dataclass`
tony Feb 28, 2025
da481df
frozen_dataclass_sealable(feat): Add `frozen_dataclass_sealable`
tony Feb 28, 2025
8ef1de0
docs(frozen_dataclass) Add to `internals`
tony Feb 28, 2025
7318b59
docs(frozen_dataclass_sealable) Add to `internals`
tony Feb 28, 2025
22d3837
WIP: Snapshot
tony Feb 28, 2025
8337b08
test(Snapshot): Replace MagicMock with pytest fixtures
tony Mar 1, 2025
6694e73
docs(ServerSnapshot): Fix doctest examples in snapshot.py
tony Mar 1, 2025
5cb998d
docs(Pane): Fix send_keys method doctest example
tony Mar 1, 2025
a8441d4
src/libtmux/snapshot.py uv run ruff check --select ALL src/libtmux/sn…
tony Mar 1, 2025
92fbf00
test/test_snapshot.py: uv run ruff check --select ALL src/libtmux/sna…
tony Mar 1, 2025
3d3d3a0
chore[mypy]: Add snapshot module override
tony Mar 1, 2025
ef35c70
refactor(snapshot): Add explicit type ignores for seal methods
tony Mar 1, 2025
0143c98
test(snapshot): Add type annotation to mock_filter function
tony Mar 1, 2025
61ebd82
Revert "chore[mypy]: Add snapshot module override"
tony Mar 1, 2025
8f5ef63
snapshot(refactor[Snapshot]): Fix dataclass field order and enhance s…
tony Mar 1, 2025
6588fb7
mypy(config[snapshot]): Add override for property/field conflicts
tony Mar 1, 2025
d3351c7
test(fix[PaneSnapshot]): Specify capture_content flag in tests
tony Mar 1, 2025
047f01d
snapshot.py(style[exceptions]): Fix linting issues identified by ruff
tony Mar 1, 2025
81a6637
snapshot.py(refactor[performance]): Extract helper function for sessi…
tony Mar 1, 2025
8ce9ab5
notes(2025-03-02) Add architecture notes
tony Mar 2, 2025
5b7026e
frozen_dataclass_sealable fix imports from `typing`
tony Mar 2, 2025
cd53b1d
chore(mypy): drop now-unused type:ignore comments
tony May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -47,6 +47,8 @@ api/libtmux._internal.dataclasses
api/libtmux._internal.query_list
api/libtmux._internal.constants
api/libtmux._internal.sparse_array
frozen_dataclass
frozen_dataclass_sealable
```

## Environmental variables
Expand Down
149 changes: 149 additions & 0 deletions notes/2025-03-02-architecture-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<think>Let me analyze the `snapshot.py` file for its architecture, typing, and implementation details, as well as identify any flaws or opportunities for improvement in its API.

First, let's understand what this module is doing:

1. This module implements a snapshot mechanism for tmux objects (Server, Session, Window, Pane)
2. It uses frozen_dataclass_sealable to create immutable snapshots of these objects
3. It provides utilities to filter snapshots, convert them to dictionaries, etc.

Key classes in this module:
- PaneSnapshot, WindowSnapshot, SessionSnapshot, ServerSnapshot - these create immutable snapshots of their respective tmux objects
- Each snapshot class inherits from both the respective tmux class (Pane, Window, etc.) and Sealable
- Each has a from_X factory method to create a snapshot from a live object

Let's identify any architectural or typing issues:

1. Type annotations: The module uses Python's typing system extensively, with TypeVars, Union types, etc.
2. Immutability pattern: It uses frozen_dataclass_sealable to implement immutability
3. Circular references: The snapshots can have circular references (e.g., a WindowSnapshot has SessionSnapshot which has the same WindowSnapshot)

Now, let's look for flaws or areas for improvement:

1. **Complex Inheritance**: Multiple inheritance from both tmux objects and Sealable might cause confusion
2. **Code Duplication**: The from_X factory methods have a lot of duplicated logic
3. **Excessive Type Annotations**: Some of the type annotations might be overly complex
4. **Error Handling**: There's a mix of error suppression and re-raising
5. **Memory Usage**: Snapshots might use a lot of memory, especially with capture_content=True

API simplification opportunities:

1. **Factory Method Consistency**: Consider simplifying the factory methods
2. **Context Manager**: Consider adding a context manager for creating and using snapshots
3. **Fluent Interface**: Consider making the filter and conversion methods chainable
4. **Simplify Type Annotations**: Make type annotations more consistent and simpler where possible
5. **Reduce Circular References**: Consider alternative designs to reduce circular references

Let's discuss each of these in detail in the response.</think># Analysis of Snapshot Architecture

After examining the `snapshot.py` module, I can provide an analysis of its architecture, typing approach, and potential areas for improvement.

## Overall Architecture

The module implements a hierarchical snapshot system for tmux objects with these key components:

1. Four snapshot classes that mirror the tmux object hierarchy:
- `ServerSnapshot`
- `SessionSnapshot`
- `WindowSnapshot`
- `PaneSnapshot`

2. Each class inherits from both:
- The corresponding tmux class (Server, Session, etc.)
- A `Sealable` base class to provide immutability

3. Utility functions for:
- Filtering snapshots (`filter_snapshot`)
- Converting to dictionaries (`snapshot_to_dict`)
- Creating active-only views (`snapshot_active_only`)

## Typing Approach

The module makes excellent use of Python's modern typing features:

- Type variables with covariance (`PaneT = t.TypeVar("PaneT", bound=Pane, covariant=True)`)
- Proper return type annotations with Union types
- Type checking guards (`if t.TYPE_CHECKING:`)
- Type casts for better type safety (`t.cast("ServerSnapshot", filtered)`)

## Strengths

1. **Immutability Pattern**: Using `frozen_dataclass_sealable` provides a robust way to create immutable snapshots
2. **Complete Object Graph**: Snapshots maintain the full hierarchy of relationships
3. **Type Safety**: Strong typing throughout the codebase

## Potential Flaws

1. **Complex Factory Methods**: The `from_X` methods contain complex logic for finding server references, with multiple fallback strategies:
```python
if source_server is None and window_snapshot is not None:
source_server = window_snapshot.server
# ...more fallbacks...
```

2. **Circular References**: The bi-directional references (window_snapshot -> session_snapshot -> window_snapshot) could create complexity for serialization and garbage collection.

3. **Error Handling Inconsistencies**: There's a mix of:
```python
with contextlib.suppress(Exception):
# silent failure
```
and explicit error raising:
```python
if source_server is None:
error_msg = "Cannot create snapshot..."
raise ValueError(error_msg)
```

4. **Memory Overhead**: Snapshots duplicate a lot of data, especially with `capture_content=True`.

5. **Attribute Access Pattern**: The code uses direct attribute access in some places but property methods in others.

## API Simplification Opportunities

1. **Unified Factory Method**: Consider a single entry point for snapshot creation:
```python
def create_snapshot(tmux_object, capture_content=False, depth=None):
"""Create a snapshot of a tmux object."""
# Dispatch to appropriate snapshot class based on type
```

2. **Context Manager Support**:
```python
@contextlib.contextmanager
def tmux_snapshot(server, capture_content=False):
"""Create a snapshot and yield it as a context manager."""
snapshot = ServerSnapshot.from_server(server, capture_content)
try:
yield snapshot
finally:
# Cleanup if needed
```

3. **Fluent Interface** for chaining operations:
```python
snapshot = (
ServerSnapshot.from_server(server)
.filter(lambda obj: obj.name.startswith("dev"))
.active_only()
.to_dict()
)
```

4. **Focused Snapshot Classes**: The snapshots try to maintain compatibility with their tmux counterparts but override behaviors. Consider a cleaner separation with composition instead of inheritance.

5. **More Targeted Snapshot Creation**: Allow for creating more targeted snapshots:
```python
# Only capturing active session/window/pane hierarchy
snapshot = create_snapshot(server, include='active')

# Capturing only specified sessions
snapshot = create_snapshot(server, include_sessions=['dev', 'prod'])
```

## Type Safety Improvements

1. The use of `type: ignore[override]` comments suggests there might be a better way to handle property overrides in the typing system. Consider using Protocol classes or structural typing.

2. The `filter_snapshot` function's return type is quite complex - it might be worth creating a generic type for this.

Overall, the module is well-designed but could benefit from some API simplifications to make it more intuitive for users while maintaining its strong typing and immutability guarantees.
23 changes: 23 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ 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 = "libtmux.snapshot"
disable_error_code = ["override"]

[[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
Expand Down Expand Up @@ -227,6 +246,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
Loading