|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## Project Overview |
| 6 | + |
| 7 | +libtmux is a typed Python library providing an ORM-like interface for tmux (terminal multiplexer). It allows programmatic control of tmux servers, sessions, windows, and panes. |
| 8 | + |
| 9 | +## Development Commands |
| 10 | + |
| 11 | +### Testing |
| 12 | +- `make test` - Run full test suite |
| 13 | +- `make start` - Run tests then start pytest-watcher |
| 14 | +- `make watch_test` - Auto-run tests on file changes (requires entr) |
| 15 | +- `uv run pytest tests/path/to/specific_test.py` - Run a specific test file |
| 16 | +- `uv run pytest -k "test_name"` - Run tests matching pattern |
| 17 | + |
| 18 | +### Code Quality |
| 19 | +- `make ruff` - Run linter checks (must pass before committing) |
| 20 | +- `make ruff_format` - Auto-format code |
| 21 | +- `make mypy` - Run type checking (must pass before committing) |
| 22 | +- `make watch_ruff` - Auto-lint on file changes |
| 23 | +- `make watch_mypy` - Auto-typecheck on file changes |
| 24 | + |
| 25 | +### Documentation |
| 26 | +- `make build_docs` - Build documentation |
| 27 | +- `make serve_docs` - Serve docs locally at http://localhost:8009 |
| 28 | +- `make dev_docs` - Watch and rebuild docs on changes |
| 29 | + |
| 30 | +## Architecture |
| 31 | + |
| 32 | +### Core Objects Hierarchy |
| 33 | +``` |
| 34 | +Server (tmux server process) |
| 35 | +└── Session (tmux session) |
| 36 | + └── Window (tmux window) |
| 37 | + └── Pane (tmux pane) |
| 38 | +``` |
| 39 | + |
| 40 | +Each object provides methods to: |
| 41 | +- Query and manipulate tmux state |
| 42 | +- Execute tmux commands |
| 43 | +- Access child objects (e.g., server.sessions, session.windows) |
| 44 | + |
| 45 | +### Key Modules |
| 46 | +- `server.py` - Server class for managing tmux server |
| 47 | +- `session.py` - Session class for tmux sessions |
| 48 | +- `window.py` - Window class for tmux windows |
| 49 | +- `pane.py` - Pane class for tmux panes |
| 50 | +- `common.py` - Shared utilities and base classes |
| 51 | +- `formats.py` - tmux format string handling |
| 52 | +- `exc.py` - Custom exceptions |
| 53 | + |
| 54 | +### Internal Architecture |
| 55 | +- `_internal/dataclasses.py` - Type-safe data structures for tmux objects |
| 56 | +- `_internal/query_list.py` - QueryList implementation for filtering collections |
| 57 | +- All tmux commands go through `tmux_cmd()` method on objects |
| 58 | +- Uses subprocess to communicate with tmux via CLI |
| 59 | + |
| 60 | +### Command Execution Architecture |
| 61 | + |
| 62 | +Commands flow through a hierarchical delegation pattern: |
| 63 | + |
| 64 | +``` |
| 65 | +User Code → Object Method → .cmd() → Server.cmd() → tmux_cmd → subprocess → tmux binary |
| 66 | +``` |
| 67 | + |
| 68 | +**Key components**: |
| 69 | +- `tmux_cmd` class (`src/libtmux/common.py:193-268`) - Wraps tmux binary via subprocess |
| 70 | +- Each object (Server, Session, Window, Pane) has a `.cmd()` method |
| 71 | +- Commands built progressively as tuples with conditional flags |
| 72 | +- Auto-targeting: objects automatically include their ID (`-t` flag) |
| 73 | + |
| 74 | +**Example flow**: |
| 75 | +```python |
| 76 | +session.new_window(window_name='my_window', attach=False) |
| 77 | +# → Builds: ("-d", "-P", "-F#{window_id}", "-n", "my_window") |
| 78 | +# → Delegates: self.cmd("new-window", *args, target=self.session_id) |
| 79 | +# → Executes: tmux -Llibtmux_test3k9m7x2q new-window -d -P -F#{window_id} -n my_window -t$1 |
| 80 | +``` |
| 81 | + |
| 82 | +### Testing Architecture |
| 83 | + |
| 84 | +**CRITICAL: We NEVER mock tmux. All tests use real tmux processes.** |
| 85 | + |
| 86 | +#### Core Testing Principles |
| 87 | + |
| 88 | +1. **Real tmux processes** - Every test runs against actual tmux server via subprocess |
| 89 | +2. **Unique isolation** - Each test gets its own tmux server with guaranteed unique socket |
| 90 | +3. **No mocking** - All tmux commands execute through real tmux CLI |
| 91 | +4. **Parallel-safe** - Tests can run concurrently without conflicts |
| 92 | + |
| 93 | +#### Unique Server Isolation |
| 94 | + |
| 95 | +Each test gets a real tmux server with unique socket name: |
| 96 | + |
| 97 | +```python |
| 98 | +Server(socket_name=f"libtmux_test{next(namer)}") |
| 99 | +# Example: libtmux_test3k9m7x2q |
| 100 | +``` |
| 101 | + |
| 102 | +**Socket name generation** (`src/libtmux/test/random.py:28-56`): |
| 103 | +- Uses `RandomStrSequence` to generate 8-character random suffixes |
| 104 | +- Format: `libtmux_test` + 8 random chars (lowercase, digits, underscore) |
| 105 | +- Each socket creates independent tmux process in `/tmp/tmux-{uid}/` |
| 106 | +- Negligible collision probability |
| 107 | + |
| 108 | +#### Pytest Fixtures |
| 109 | + |
| 110 | +**Fixture hierarchy** (`src/libtmux/pytest_plugin.py`): |
| 111 | + |
| 112 | +``` |
| 113 | +Session-scoped (shared across test session): |
| 114 | +├── home_path (temporary /home/) |
| 115 | +├── user_path (temporary user directory) |
| 116 | +└── config_file (~/.tmux.conf with base-index=1) |
| 117 | +
|
| 118 | +Function-scoped (per test): |
| 119 | +├── set_home (auto-use: sets $HOME to isolated directory) |
| 120 | +├── clear_env (cleans unnecessary environment variables) |
| 121 | +├── server (unique tmux Server with auto-cleanup) |
| 122 | +├── session (Session on server with unique name) |
| 123 | +└── TestServer (factory for creating multiple servers per test) |
| 124 | +``` |
| 125 | + |
| 126 | +**Key fixtures**: |
| 127 | + |
| 128 | +- **`server`** - Creates tmux server with unique socket, auto-killed via finalizer |
| 129 | +- **`session`** - Creates session on server with unique name (`libtmux_` + random) |
| 130 | +- **`TestServer`** - Factory using `functools.partial` to create multiple independent servers |
| 131 | + ```python |
| 132 | + def test_multiple_servers(TestServer): |
| 133 | + server1 = TestServer() # libtmux_test3k9m7x2q |
| 134 | + server2 = TestServer() # libtmux_testz9w1b4a7 |
| 135 | + # Both are real, independent tmux processes |
| 136 | + ``` |
| 137 | + |
| 138 | +#### Isolation Mechanisms |
| 139 | + |
| 140 | +**Triple isolation ensures parallel test safety**: |
| 141 | + |
| 142 | +1. **Unique socket names** - 8-char random suffix prevents collisions |
| 143 | +2. **Independent processes** - Each server is separate tmux process with unique PID |
| 144 | +3. **Isolated $HOME** - Temporary home directory with standard `.tmux.conf` |
| 145 | + |
| 146 | +**Home directory setup**: |
| 147 | +- Each test session gets temporary home directory |
| 148 | +- Contains `.tmux.conf` with `base-index 1` for consistent window/pane indexing |
| 149 | +- `$HOME` environment variable monkeypatched to isolated directory |
| 150 | +- No interference from user's actual tmux configuration |
| 151 | + |
| 152 | +#### Test Utilities |
| 153 | + |
| 154 | +**Helper modules** in `src/libtmux/test/`: |
| 155 | + |
| 156 | +- **`temporary.py`** - Context managers for temporary objects: |
| 157 | + ```python |
| 158 | + with temp_session(server) as session: |
| 159 | + session.new_window() # Auto-cleaned up after block |
| 160 | + |
| 161 | + with temp_window(session) as window: |
| 162 | + window.split_window() # Auto-cleaned up after block |
| 163 | + ``` |
| 164 | + |
| 165 | +- **`random.py`** - Unique name generation: |
| 166 | + ```python |
| 167 | + get_test_session_name(server) # Returns: libtmux_3k9m7x2q (checks for uniqueness) |
| 168 | + get_test_window_name(session) # Returns: libtmux_z9w1b4a7 (checks for uniqueness) |
| 169 | + ``` |
| 170 | + |
| 171 | +- **`retry.py`** - Retry logic for tmux operations: |
| 172 | + ```python |
| 173 | + retry_until(lambda: pane.pane_current_path is not None) |
| 174 | + # Retries for up to 8 seconds (configurable via RETRY_TIMEOUT_SECONDS) |
| 175 | + # 50ms intervals (configurable via RETRY_INTERVAL_SECONDS) |
| 176 | + ``` |
| 177 | + |
| 178 | +- **`constants.py`** - Test configuration: |
| 179 | + - `TEST_SESSION_PREFIX = "libtmux_"` |
| 180 | + - `RETRY_TIMEOUT_SECONDS = 8` (configurable via env var) |
| 181 | + - `RETRY_INTERVAL_SECONDS = 0.05` (configurable via env var) |
| 182 | + |
| 183 | +#### Doctest Integration |
| 184 | + |
| 185 | +**All doctests use real tmux** (`conftest.py:31-49`): |
| 186 | + |
| 187 | +```python |
| 188 | +@pytest.fixture(autouse=True) |
| 189 | +def add_doctest_fixtures(doctest_namespace): |
| 190 | + # Injects Server, Session, Window, Pane classes |
| 191 | + # Injects server, session, window, pane instances |
| 192 | + # All are real tmux objects with unique sockets |
| 193 | +``` |
| 194 | + |
| 195 | +Docstrings can include runnable examples: |
| 196 | +```python |
| 197 | +>>> server.new_session('my_session') |
| 198 | +Session($1 my_session) |
| 199 | + |
| 200 | +>>> session.new_window(window_name='my_window') |
| 201 | +Window(@3 2:my_window, Session($1 ...)) |
| 202 | +``` |
| 203 | + |
| 204 | +These execute against real tmux during `pytest --doctest-modules`. |
| 205 | + |
| 206 | +#### Parallel Test Execution |
| 207 | + |
| 208 | +**Tests are safe for parallel execution** (`pytest -n auto`): |
| 209 | + |
| 210 | +- Each worker process generates unique socket names |
| 211 | +- No shared state between test workers |
| 212 | +- Independent home directories prevent race conditions |
| 213 | +- Automatic cleanup prevents resource leaks |
| 214 | + |
| 215 | +#### Testing Patterns |
| 216 | + |
| 217 | +**Standard test pattern**: |
| 218 | +```python |
| 219 | +def test_example(server: Server, session: Session) -> None: |
| 220 | + """Test description.""" |
| 221 | + # No setup needed - fixtures provide real tmux objects |
| 222 | + window = session.new_window(window_name='test') |
| 223 | + assert window.window_name == 'test' |
| 224 | + # No teardown needed - fixtures auto-cleanup |
| 225 | +``` |
| 226 | + |
| 227 | +**Multiple server pattern**: |
| 228 | +```python |
| 229 | +def test_multiple_servers(TestServer: t.Callable[..., Server]) -> None: |
| 230 | + """Test with multiple independent servers.""" |
| 231 | + server1 = TestServer() |
| 232 | + server2 = TestServer() |
| 233 | + # Both are real tmux processes with unique sockets |
| 234 | + assert server1.socket_name != server2.socket_name |
| 235 | +``` |
| 236 | + |
| 237 | +**Retry pattern for tmux operations**: |
| 238 | +```python |
| 239 | +def test_async_operation(session: Session) -> None: |
| 240 | + """Test operation that takes time to complete.""" |
| 241 | + pane = session.active_window.active_pane |
| 242 | + pane.send_keys('cd /tmp', enter=True) |
| 243 | + |
| 244 | + # Wait for tmux to update pane path |
| 245 | + retry_until(lambda: pane.pane_current_path == '/tmp') |
| 246 | +``` |
| 247 | + |
| 248 | +#### CI Testing Matrix |
| 249 | + |
| 250 | +Tests run against: |
| 251 | +- **tmux versions**: 2.6, 2.7, 2.8, 3.0, 3.1, 3.2, 3.3, 3.4, master |
| 252 | +- **Python versions**: 3.9, 3.10, 3.11, 3.12, 3.13 |
| 253 | +- All use real tmux processes (never mocked) |
| 254 | + |
| 255 | +## Important Patterns |
| 256 | + |
| 257 | +### Command Building |
| 258 | +- Commands built progressively as tuples with conditional flags: |
| 259 | + ```python |
| 260 | + tmux_args: tuple[str, ...] = () |
| 261 | + |
| 262 | + if not attach: |
| 263 | + tmux_args += ("-d",) |
| 264 | + |
| 265 | + tmux_args += ("-P", "-F#{window_id}") |
| 266 | + |
| 267 | + if window_name is not None: |
| 268 | + tmux_args += ("-n", window_name) |
| 269 | + |
| 270 | + cmd = self.cmd("new-window", *tmux_args, target=target) |
| 271 | + ``` |
| 272 | +- Auto-targeting: objects pass their ID automatically (override with `target=`) |
| 273 | +- Version-aware: use `has_gte_version()` / `has_lt_version()` for compatibility |
| 274 | +- Format strings: use `FORMAT_SEPARATOR` (default `␞`) for multi-field parsing |
| 275 | + |
| 276 | +### Type Safety |
| 277 | +- All public APIs are fully typed |
| 278 | +- Use `from __future__ import annotations` in all modules |
| 279 | +- Mypy runs in strict mode - new code must be type-safe |
| 280 | + |
| 281 | +### Error Handling |
| 282 | +- Custom exceptions in `exc.py` (e.g., `LibTmuxException`, `TmuxCommandNotFound`) |
| 283 | +- tmux command failures raise exceptions with command output |
| 284 | +- Check `cmd.stderr` after command execution |
| 285 | + |
| 286 | +### Vendor Dependencies |
| 287 | +- Some dependencies are vendored in `_vendor/` to avoid runtime dependencies |
| 288 | +- Do not modify vendored code directly |
| 289 | + |
| 290 | +### Writing Tests |
| 291 | + |
| 292 | +**When writing new tests**: |
| 293 | +- Use `server` and `session` fixtures - they provide real tmux instances |
| 294 | +- Never mock tmux - use `retry_until()` for async operations instead |
| 295 | +- Use `temp_session()` / `temp_window()` context managers for temporary objects |
| 296 | +- Use `get_test_session_name()` / `get_test_window_name()` for unique names |
| 297 | +- Tests must work across all tmux versions (2.6+) and Python versions (3.9-3.13) |
| 298 | +- Use version checks (`has_gte_version`, `has_lt_version`) for version-specific features |
| 299 | + |
| 300 | +**For multiple servers per test**: |
| 301 | +```python |
| 302 | +def test_example(TestServer): |
| 303 | + server1 = TestServer() |
| 304 | + server2 = TestServer() |
| 305 | + # Both are real, independent tmux processes |
| 306 | +``` |
0 commit comments