Skip to content

Commit c3f1aae

Browse files
committed
neo(feat[scope-aware]): Make get_output_format scope+version aware
why: PR #672's CI matrix on tmux 3.2a crashed when the -F template included tokens that don't apply to the calling list-* subcommand or don't exist in the running tmux's format_table. The empirical crashers were 11 client_* tokens queried during list-windows (no client context) and several post-3.2a tokens that contributed cumulative risk. what: - src/libtmux/neo.py: - Add SCOPES_BY_LIST_CMD dict mapping each list-* to the set of token scopes its format engine can resolve (e.g. list-windows reaches universal + session + window; list-clients reaches universal + session + client). - Add FIELD_VERSION dict (initially empty) mapping field name → min tmux version; fields absent from the dict default to the project's floor (3.2a). - Add _SCOPE_PREFIXES table and _token_scope() helper that derive a token's scope from its name prefix (pane_*, window_*, session_*, client_*, buffer_*, etc.). Runtime-only tokens (mouse_*, cursor_*, selection_*, copy_cursor_*, popup_*) resolve to "event" and are excluded from all list-* templates. - Add _UNIVERSAL_TOKENS frozenset for cross-scope tokens without a scope prefix (pid, version, host, host_short, socket_path, etc.). - Add _normalize_tmux_version() helper that treats tmux master as a sentinel "newer than any tagged release" for comparison. - Rewrite get_output_format() to take (list_cmd, tmux_version) and filter the field set accordingly. Cached via @functools.cache on the small number of (list_cmd, version) combinations a process sees. - Rewrite parse_output() to take the same args so it reads the same filtered field order. - Thread the live tmux version through fetch_objs() via get_version(server.tmux_bin) before calling get_output_format(), pass through to parse_output() per line. - Doctests on the helpers and on get_output_format / parse_output demonstrate the new contracts. No Obj field changes in this commit. The 27 fields rolled back during the prior CI bisect remain absent — they re-enter in follow-up commits that exercise the new scope/version gating.
1 parent 0d1c385 commit c3f1aae

1 file changed

Lines changed: 213 additions & 21 deletions

File tree

src/libtmux/neo.py

Lines changed: 213 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from collections.abc import Iterable
1111

1212
from libtmux import exc
13-
from libtmux.common import raise_if_stderr, tmux_cmd
13+
from libtmux._compat import LooseVersion
14+
from libtmux.common import get_version, raise_if_stderr, tmux_cmd
1415
from libtmux.formats import FORMAT_SEPARATOR
1516

1617
if t.TYPE_CHECKING:
@@ -26,6 +27,134 @@
2627
OutputsRaw = list[OutputRaw]
2728

2829

30+
SCOPES_BY_LIST_CMD: dict[str, frozenset[str]] = {
31+
"list-sessions": frozenset({"universal", "session"}),
32+
"list-windows": frozenset({"universal", "session", "window"}),
33+
"list-panes": frozenset({"universal", "session", "window", "pane"}),
34+
"list-clients": frozenset({"universal", "session", "client"}),
35+
}
36+
"""Format-token scopes a given tmux ``list-*`` subcommand can resolve.
37+
38+
A token whose scope is in the set is safe to include in that subcommand's
39+
``-F`` template. A token whose scope is *outside* the set may be unknown to
40+
the format engine in that context, or in older tmux releases trigger a
41+
server-side fault — exclude it from the format string.
42+
"""
43+
44+
45+
FIELD_VERSION: dict[str, str] = {}
46+
"""Minimum tmux version that registers each format token.
47+
48+
Field names absent from this dict default to ``"3.2a"`` (always-safe within
49+
the supported tmux range). Entries here represent tokens added after 3.2a
50+
that need explicit gating to keep the ``-F`` template compatible with older
51+
tmux versions.
52+
"""
53+
54+
55+
# Field-name prefixes that map to a single format-token scope. Resolved by
56+
# :func:`_token_scope`. Order matters: longer prefixes win (e.g.
57+
# ``copy_cursor_`` is a runtime token, not a generic ``copy_`` one).
58+
_SCOPE_PREFIXES: tuple[tuple[str, str], ...] = (
59+
("copy_cursor_", "event"),
60+
("pane_", "pane"),
61+
("window_", "window"),
62+
("session_", "session"),
63+
("client_", "client"),
64+
("buffer_", "buffer"),
65+
("mouse_", "event"),
66+
("cursor_", "event"),
67+
("selection_", "event"),
68+
("scroll_", "event"),
69+
("popup_", "event"),
70+
)
71+
72+
# Standalone tokens not captured by the prefix table.
73+
_UNIVERSAL_TOKENS: frozenset[str] = frozenset(
74+
{
75+
"active_window_index",
76+
"alternate_on",
77+
"alternate_saved_x",
78+
"alternate_saved_y",
79+
"command_list_alias",
80+
"command_list_name",
81+
"command_list_usage",
82+
"config_files",
83+
"current_file",
84+
"history_bytes",
85+
"history_limit",
86+
"history_size",
87+
"host",
88+
"host_short",
89+
"insert_flag",
90+
"keypad_cursor_flag",
91+
"keypad_flag",
92+
"last_window_index",
93+
"line",
94+
"next_session_id",
95+
"origin_flag",
96+
"pid",
97+
"search_match",
98+
"socket_path",
99+
"start_time",
100+
"uid",
101+
"user",
102+
"version",
103+
"wrap_flag",
104+
}
105+
)
106+
107+
108+
def _token_scope(field_name: str) -> str:
109+
"""Resolve a format token's scope from its name.
110+
111+
Returns ``"universal"`` for cross-scope tokens (e.g. ``version``,
112+
``socket_path``, ``host``). Returns ``"event"`` for runtime-only tokens
113+
that never appear in a ``list-*`` output (mouse, cursor, selection,
114+
popup). Returns ``"pane"`` / ``"window"`` / ``"session"`` / ``"client"``
115+
/ ``"buffer"`` for scope-prefixed tokens.
116+
117+
Examples
118+
--------
119+
>>> from libtmux.neo import _token_scope
120+
>>> _token_scope("pane_id")
121+
'pane'
122+
>>> _token_scope("window_zoomed_flag")
123+
'window'
124+
>>> _token_scope("client_name")
125+
'client'
126+
>>> _token_scope("version")
127+
'universal'
128+
>>> _token_scope("mouse_x")
129+
'event'
130+
"""
131+
for prefix, scope in _SCOPE_PREFIXES:
132+
if field_name.startswith(prefix):
133+
return scope
134+
if field_name in _UNIVERSAL_TOKENS:
135+
return "universal"
136+
return "universal"
137+
138+
139+
def _normalize_tmux_version(version: str) -> LooseVersion:
140+
"""Convert a tmux version string into a comparable :class:`LooseVersion`.
141+
142+
tmux master is reported as ``"master"`` (or e.g. ``"3.6a-master"``);
143+
treat it as larger than any tagged release.
144+
145+
Examples
146+
--------
147+
>>> from libtmux.neo import _normalize_tmux_version
148+
>>> _normalize_tmux_version("3.6a") < _normalize_tmux_version("master")
149+
True
150+
>>> _normalize_tmux_version("3.2a") < _normalize_tmux_version("3.6a")
151+
True
152+
"""
153+
if "master" in version.lower():
154+
return LooseVersion("99.0")
155+
return LooseVersion(version)
156+
157+
29158
@dataclasses.dataclass()
30159
class Obj:
31160
"""Dataclass of generic tmux object."""
@@ -211,40 +340,98 @@ def _refresh(
211340

212341

213342
@functools.cache
214-
def get_output_format() -> tuple[tuple[str, ...], str]:
215-
"""Return field names and tmux format string for all Obj fields.
343+
def get_output_format(
344+
list_cmd: str = "list-panes",
345+
tmux_version: str = "3.2a",
346+
) -> tuple[tuple[str, ...], str]:
347+
"""Return field names and tmux format string filtered by scope and version.
348+
349+
Only emits tokens whose scope is reachable from *list_cmd* (per
350+
:data:`SCOPES_BY_LIST_CMD`) and whose minimum tmux version (per
351+
:data:`FIELD_VERSION`) is at or below *tmux_version*. Runtime-only
352+
tokens (``mouse_*``, ``cursor_*``, popups) are excluded from every
353+
``list-*`` template — they only resolve in event-time format contexts.
216354
217-
Excludes the ``server`` field, which is a Python object reference
218-
rather than a tmux format variable.
355+
Parameters
356+
----------
357+
list_cmd : str
358+
The tmux list subcommand the format string is being built for.
359+
Determines which token scopes are reachable.
360+
tmux_version : str
361+
The live tmux version. Used to gate post-3.2a tokens. Defaults to
362+
``"3.2a"`` (the project's minimum) for safe fallback when the
363+
caller can't yet detect the version.
219364
220365
Returns
221366
-------
222367
tuple[tuple[str, ...], str]
223-
A tuple of (field_names, tmux_format_string).
368+
A tuple of (field_names, tmux_format_string) restricted to tokens
369+
the given *list_cmd* and *tmux_version* can resolve.
224370
225371
Examples
226372
--------
227373
>>> from libtmux.neo import get_output_format
228-
>>> fields, fmt = get_output_format()
374+
>>> fields, fmt = get_output_format("list-sessions", "3.6a")
229375
>>> 'session_id' in fields
230376
True
377+
>>> 'pane_id' in fields
378+
False
231379
>>> 'server' in fields
232380
False
233-
"""
234-
# Exclude 'server' - it's a Python object, not a tmux format variable
235-
formats = tuple(f for f in Obj.__dataclass_fields__ if f != "server")
236-
tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats]
237-
return formats, "".join(tmux_formats)
238381
382+
Pane scope picks up window and session tokens too:
383+
384+
>>> fields, _ = get_output_format("list-panes", "3.6a")
385+
>>> all(t in fields for t in ('pane_id', 'window_id', 'session_id'))
386+
True
387+
388+
Client scope is isolated from pane/window tokens:
389+
390+
>>> fields, _ = get_output_format("list-clients", "3.6a")
391+
>>> 'pane_id' in fields
392+
False
393+
"""
394+
allowed_scopes = SCOPES_BY_LIST_CMD.get(
395+
list_cmd,
396+
frozenset({"universal", "session", "window", "pane"}),
397+
)
398+
live_ver = _normalize_tmux_version(tmux_version)
399+
400+
formats: list[str] = []
401+
for f in Obj.__dataclass_fields__:
402+
if f == "server":
403+
continue
404+
if _token_scope(f) not in allowed_scopes:
405+
continue
406+
min_v = FIELD_VERSION.get(f)
407+
if min_v is not None and _normalize_tmux_version(min_v) > live_ver:
408+
continue
409+
formats.append(f)
410+
411+
tmux_format = "".join(f"#{{{n}}}{FORMAT_SEPARATOR}" for n in formats)
412+
return tuple(formats), tmux_format
413+
414+
415+
def parse_output(
416+
output: str,
417+
list_cmd: str = "list-panes",
418+
tmux_version: str = "3.2a",
419+
) -> OutputRaw:
420+
"""Parse a tmux ``-F`` line into a dict keyed by Obj field name.
239421
240-
def parse_output(output: str) -> OutputRaw:
241-
"""Parse tmux output formatted with get_output_format() into a dict.
422+
The (*list_cmd*, *tmux_version*) pair must match what was passed to
423+
:func:`get_output_format` when the ``-F`` template was built —
424+
otherwise the field order won't line up with the split values.
242425
243426
Parameters
244427
----------
245428
output : str
246-
Raw tmux output produced with the format string from
429+
Raw tmux output line produced with a template from
247430
:func:`get_output_format`.
431+
list_cmd : str
432+
Same value passed to :func:`get_output_format`.
433+
tmux_version : str
434+
Same value passed to :func:`get_output_format`.
248435
249436
Returns
250437
-------
@@ -255,16 +442,20 @@ def parse_output(output: str) -> OutputRaw:
255442
--------
256443
>>> from libtmux.neo import get_output_format, parse_output
257444
>>> from libtmux.formats import FORMAT_SEPARATOR
258-
>>> fields, fmt = get_output_format()
445+
>>> fields, fmt = get_output_format("list-sessions", "3.6a")
259446
>>> values = [''] * len(fields)
260447
>>> values[fields.index('session_id')] = '$1'
261-
>>> result = parse_output(FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR)
448+
>>> result = parse_output(
449+
... FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR,
450+
... list_cmd="list-sessions",
451+
... tmux_version="3.6a",
452+
... )
262453
>>> result['session_id']
263454
'$1'
264-
>>> 'buffer_sample' in result
455+
>>> 'pane_id' in result
265456
False
266457
"""
267-
formats, _ = get_output_format()
458+
formats, _ = get_output_format(list_cmd, tmux_version)
268459
values = output.split(FORMAT_SEPARATOR)
269460

270461
# Remove the trailing empty string from the split
@@ -326,7 +517,8 @@ def fetch_objs(
326517
>>> 'session_id' in objs[0]
327518
True
328519
"""
329-
_fields, format_string = get_output_format()
520+
tmux_version = str(get_version(tmux_bin=server.tmux_bin))
521+
_fields, format_string = get_output_format(list_cmd, tmux_version)
330522

331523
cmd_args: list[str | int] = []
332524

@@ -367,7 +559,7 @@ def fetch_objs(
367559

368560
raise_if_stderr(proc, list_cmd)
369561

370-
outputs = [parse_output(line) for line in proc.stdout]
562+
outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout]
371563

372564
if logger.isEnabledFor(logging.DEBUG):
373565
if cmd_str is None:

0 commit comments

Comments
 (0)