Skip to content

Commit fa15860

Browse files
authored
refactor(feat[logging]) structured logging with lifecycle events (#637)
Add structured lifecycle logging across Server, Session, Window, and Pane. All lifecycle operations (create, kill, rename, split) now emit INFO-level log records with structured `extra` context using scalar keys (`tmux_subcommand`, `tmux_session`, `tmux_window`, `tmux_pane`, `tmux_target`) for filtering in log aggregators and test assertions via `caplog.records`. - **NullHandler**: add to library `__init__.py` per Python logging best practices - **DEBUG logging**: structured `tmux_cmd` execution logs with `isEnabledFor` guards and heavy keys (`tmux_stdout`, `tmux_stderr`, `tmux_stdout_len`, `tmux_stderr_len`) - **Lazy formatting**: replace f-string log formatting with `%s` throughout; replace `traceback.print_stack()` with `logger.debug(exc_info=True)` - **Options warnings**: replace `logger.exception()` with `logger.warning()` and `tmux_option_key` structured context for recoverable parse failures - **Cleanup**: remove unused logger definitions from modules that don't log Bug fixes: - **Window.rename_window()**: propagate errors instead of silently swallowing exceptions - **Server.kill()**: check stderr, raise on unexpected errors, handle "no server running" gracefully - **Server.new_session()**: raise `LibTmuxException` on `kill-session` stderr when `kill_session=True` - **Session.kill_window()**: fix `self.window_name` → `self.session_name` for integer target formatting; widen type signature to `str | int | None`
2 parents aaabdd7 + 21e54c5 commit fa15860

20 files changed

Lines changed: 577 additions & 77 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,16 @@ Pass structured data on every log call where useful for filtering, searching, or
307307
| `tmux_session` | `str` | session name |
308308
| `tmux_window` | `str` | window name or index |
309309
| `tmux_pane` | `str` | pane identifier |
310+
| `tmux_option_key` | `str` | tmux option name |
310311

311312
**Heavy/optional keys** (DEBUG only, potentially large):
312313

313314
| Key | Type | Context |
314315
|-----|------|---------|
315316
| `tmux_stdout` | `list[str]` | tmux stdout lines (truncate or cap; `%(tmux_stdout)s` produces repr) |
316317
| `tmux_stderr` | `list[str]` | tmux stderr lines (same caveats) |
318+
| `tmux_stdout_len` | `int` | number of stdout lines |
319+
| `tmux_stderr_len` | `int` | number of stderr lines |
317320

318321
Treat established keys as compatibility-sensitive — downstream users may build dashboards and alerts on them. Change deliberately.
319322

CHANGES

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,42 @@ $ uvx --from 'libtmux' --prerelease allow python
3636
_Notes on the upcoming release will go here._
3737
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3838

39+
### What's new
40+
41+
#### Structured lifecycle logging across Server, Session, Window, and Pane (#637)
42+
43+
All lifecycle operations (create, kill, rename, split) now emit INFO-level log
44+
records with structured `extra` context. Every log call includes scalar keys
45+
such as `tmux_subcommand`, `tmux_session`, `tmux_window`, `tmux_pane`, and
46+
`tmux_target` for filtering in log aggregators and test assertions via
47+
`caplog.records`.
48+
49+
- Add `NullHandler` to library `__init__.py` per Python logging best practices
50+
- Add DEBUG-level structured logs for `tmux_cmd` execution with `isEnabledFor` guards
51+
- Replace f-string log formatting with lazy `%s` formatting throughout
52+
- Replace `traceback.print_stack()` calls with proper `logger.debug(exc_info=True)`
53+
- Replace `logger.exception()` in options parsing with `logger.warning()` and `tmux_option_key` context
54+
- Remove unused logger definitions from modules that don't log
55+
56+
### Bug fixes
57+
58+
#### Window.rename_window() now raises on failure instead of silently swallowing (#637)
59+
60+
Previously `rename_window()` caught all exceptions and logged them, masking
61+
tmux errors. It now propagates the error, consistent with all other command
62+
methods.
63+
64+
#### Server.kill() captures stderr and handles "no server running" gracefully (#637)
65+
66+
`Server.kill()` previously discarded the tmux return value. It now checks
67+
stderr, raises on unexpected errors, and silently returns for expected
68+
conditions ("no server running", "error connecting to").
69+
70+
#### Server.new_session() checks kill-session stderr (#637)
71+
72+
When `kill_session=True` and the existing session kill fails, `new_session()`
73+
now raises `LibTmuxException` with the stderr instead of proceeding silently.
74+
3975
## libtmux 0.53.1 (2026-02-18)
4076

4177
### Bug fixes

src/libtmux/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import logging
6+
57
from .__about__ import (
68
__author__,
79
__copyright__,
@@ -17,6 +19,8 @@
1719
from .session import Session
1820
from .window import Window
1921

22+
logging.getLogger(__name__).addHandler(logging.NullHandler())
23+
2024
__all__ = (
2125
"Pane",
2226
"Server",

src/libtmux/_internal/constants.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import io
6-
import logging
76
import typing as t
87
from dataclasses import dataclass, field
98

@@ -19,8 +18,6 @@
1918
TerminalFeatures = dict[str, list[str]]
2019
HookArray: TypeAlias = "dict[str, SparseArray[str]]"
2120

22-
logger = logging.getLogger(__name__)
23-
2421

2522
@dataclass(repr=False)
2623
class ServerOptions(

src/libtmux/_internal/query_list.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
import logging
1111
import re
12-
import traceback
1312
import typing as t
1413
from collections.abc import Callable, Iterable, Mapping, Sequence
1514

@@ -105,9 +104,12 @@ def keygetter(
105104
elif hasattr(dct, sub_field):
106105
dct = getattr(dct, sub_field)
107106

108-
except Exception as e:
109-
traceback.print_stack()
110-
logger.debug("The above error was %s", e)
107+
except Exception:
108+
logger.debug(
109+
"key lookup failed for path: %s",
110+
path,
111+
exc_info=True,
112+
)
111113
return None
112114

113115
return dct
@@ -146,9 +148,12 @@ def parse_lookup(
146148
field_name = path.split(lookup, maxsplit=1)[0]
147149
if field_name is not None:
148150
return keygetter(obj, field_name)
149-
except Exception as e:
150-
traceback.print_stack()
151-
logger.debug("The above error was %s", e)
151+
except Exception:
152+
logger.debug(
153+
"lookup parsing failed for path: %s",
154+
path,
155+
exc_info=True,
156+
)
152157
return None
153158

154159

src/libtmux/common.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,12 @@ def __init__(self, *args: t.Any) -> None:
270270
stdout, stderr = self.process.communicate()
271271
returncode = self.process.returncode
272272
except Exception:
273-
logger.exception(f"Exception for {subprocess.list2cmdline(cmd)}")
273+
logger.error( # noqa: TRY400
274+
"tmux subprocess failed",
275+
extra={
276+
"tmux_cmd": subprocess.list2cmdline(cmd),
277+
},
278+
)
274279
raise
275280

276281
self.returncode = returncode
@@ -288,12 +293,18 @@ def __init__(self, *args: t.Any) -> None:
288293
else:
289294
self.stdout = stdout_split
290295

291-
logger.debug(
292-
"self.stdout for {cmd}: {stdout}".format(
293-
cmd=" ".join(cmd),
294-
stdout=self.stdout,
295-
),
296-
)
296+
if logger.isEnabledFor(logging.DEBUG):
297+
logger.debug(
298+
"tmux command completed",
299+
extra={
300+
"tmux_cmd": subprocess.list2cmdline(cmd),
301+
"tmux_exit_code": self.returncode,
302+
"tmux_stdout": self.stdout[:100],
303+
"tmux_stderr": self.stderr[:100],
304+
"tmux_stdout_len": len(self.stdout),
305+
"tmux_stderr_len": len(self.stderr),
306+
},
307+
)
297308

298309

299310
def get_version() -> LooseVersion:

src/libtmux/hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ def show_hooks(
305305
elif len(parts) == 1:
306306
key, val = parts[0], None
307307
else:
308-
logger.warning(f"Error extracting hook: {item}")
308+
logger.warning("failed to extract hook: %s", item)
309309
continue
310310

311311
if isinstance(val, str) and val.isdigit():

src/libtmux/neo.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import dataclasses
66
import functools
77
import logging
8+
import shlex
89
import typing as t
910
from collections.abc import Iterable
1011

@@ -305,12 +306,38 @@ def fetch_objs(
305306

306307
tmux_cmds.append(f"-F{format_string}")
307308

309+
cmd_str: str | None = None
310+
311+
if logger.isEnabledFor(logging.DEBUG):
312+
cmd_str = shlex.join([str(x) for x in tmux_cmds])
313+
logger.debug(
314+
"tmux list queried",
315+
extra={
316+
"tmux_subcommand": list_cmd,
317+
"tmux_cmd": cmd_str,
318+
},
319+
)
320+
308321
proc = tmux_cmd(*tmux_cmds) # output
309322

310323
if proc.stderr:
311324
raise exc.LibTmuxException(proc.stderr)
312325

313-
return [parse_output(line) for line in proc.stdout]
326+
outputs = [parse_output(line) for line in proc.stdout]
327+
328+
if logger.isEnabledFor(logging.DEBUG):
329+
if cmd_str is None:
330+
cmd_str = shlex.join([str(x) for x in tmux_cmds])
331+
logger.debug(
332+
"tmux list parsed",
333+
extra={
334+
"tmux_subcommand": list_cmd,
335+
"tmux_cmd": cmd_str,
336+
"tmux_stdout_len": len(proc.stdout),
337+
},
338+
)
339+
340+
return outputs
314341

315342

316343
def fetch_obj(

src/libtmux/options.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,10 @@ def explode_arrays(
429429
options[key][0] = val
430430
else:
431431
options[key] = val
432-
logger.exception("Error parsing options")
432+
logger.warning(
433+
"tmux options parse failed",
434+
extra={"tmux_option_key": key},
435+
)
433436
return options
434437

435438

@@ -513,7 +516,10 @@ def explode_complex(
513516
term, features = item.split(":", maxsplit=1)
514517
new_val[term] = features.split(":")
515518
except Exception: # NOQA: PERF203
516-
logger.exception("Error parsing options")
519+
logger.warning(
520+
"tmux options parse failed",
521+
extra={"tmux_option_key": key},
522+
)
517523
options[key] = new_val
518524
continue
519525
if isinstance(val, SparseArray) and key == "terminal-overrides":
@@ -537,7 +543,10 @@ def explode_complex(
537543
elif feature:
538544
new_overrides[term][feature] = None
539545
except Exception: # NOQA: PERF203
540-
logger.exception("Error parsing options")
546+
logger.warning(
547+
"tmux options parse failed",
548+
extra={"tmux_option_key": key},
549+
)
541550
options[key] = new_overrides
542551
continue
543552
if isinstance(val, SparseArray) and key == "command-alias":
@@ -553,15 +562,21 @@ def explode_complex(
553562
options[key] = {}
554563
new_aliases[alias] = command
555564
except Exception: # NOQA: PERF203
556-
logger.exception("Error parsing options")
565+
logger.warning(
566+
"tmux options parse failed",
567+
extra={"tmux_option_key": key},
568+
)
557569
options[key] = new_aliases
558570
continue
559571
options[key] = val
560572
continue
561573

562574
except Exception:
563575
options[key] = val
564-
logger.exception("Error parsing options")
576+
logger.warning(
577+
"tmux options parse failed",
578+
extra={"tmux_option_key": key},
579+
)
565580
return options
566581

567582

src/libtmux/pane.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,15 @@ def kill(
567567
if proc.stderr:
568568
raise exc.LibTmuxException(proc.stderr)
569569

570+
extra: dict[str, str] = {
571+
"tmux_subcommand": "kill-pane",
572+
}
573+
if self.pane_id is not None:
574+
extra["tmux_pane"] = str(self.pane_id)
575+
extra["tmux_target"] = str(self.pane_id)
576+
msg = "other panes killed" if all_except else "pane killed"
577+
logger.info(msg, extra=extra)
578+
570579
"""
571580
Commands ("climber"-helpers)
572581
@@ -769,7 +778,22 @@ def split(
769778
zip(["pane_id"], pane_output.split(FORMAT_SEPARATOR), strict=False),
770779
)
771780

772-
return self.from_pane_id(server=self.server, pane_id=pane_formatters["pane_id"])
781+
pane = self.from_pane_id(server=self.server, pane_id=pane_formatters["pane_id"])
782+
783+
extra: dict[str, str] = {
784+
"tmux_subcommand": "split-window",
785+
"tmux_pane": str(pane.pane_id),
786+
}
787+
if self.session.session_name is not None:
788+
extra["tmux_session"] = str(self.session.session_name)
789+
if self.window.window_name is not None:
790+
extra["tmux_window"] = str(self.window.window_name)
791+
if target is not None:
792+
extra["tmux_target"] = str(target)
793+
794+
logger.info("pane created", extra=extra)
795+
796+
return pane
773797

774798
"""
775799
Commands (helpers)

0 commit comments

Comments
 (0)