1010from collections .abc import Iterable
1111
1212from 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
1415from libtmux .formats import FORMAT_SEPARATOR
1516
1617if t .TYPE_CHECKING :
2627OutputsRaw = 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 ()
30159class 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