From 45b25e1433f335eea979b85edf462dc1ffaa4849 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:22:02 -0500 Subject: [PATCH 01/36] Core(fix): Check for tmux 3.3a before commands why: A newer client can connect to an older daemon, while normalized version comparisons lose the patch letter that distinguishes 3.3a. what: - Share a bounded daemon-first version check across ordinary raw dispatch - Fence the original native command batch on the checked release - Preserve standalone binary diagnostics without authorizing effects - Route liveness through the version check and make absent cleanup a no-op - Cover patch ordering, raw batches and replacement before mutation --- src/libtmux/_internal/support/versions.py | 133 ++++++++++++++++ src/libtmux/common.py | 31 ++-- src/libtmux/server.py | 34 ++-- tests/legacy_api/test_common.py | 19 +-- tests/test_common.py | 9 +- tests/test_versions.py | 181 ++++++++++++++++++++++ 6 files changed, 367 insertions(+), 40 deletions(-) create mode 100644 src/libtmux/_internal/support/versions.py create mode 100644 tests/test_versions.py diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py new file mode 100644 index 0000000000..3fc8d61aef --- /dev/null +++ b/src/libtmux/_internal/support/versions.py @@ -0,0 +1,133 @@ +"""Version checks for ordinary commands on a live or newly created tmux endpoint.""" + +from __future__ import annotations + +import getopt +import re +import subprocess +import uuid + +from libtmux import exc + +MINIMUM = "3.3a" +_RELEASE = re.compile(r"(\d+)\.(\d+)([a-z]?)(?:-(master|dev|rc\d+))?") + + +def require_version(version: str, minimum: str = MINIMUM) -> None: + """Reject unknown or older releases, preserving numeric and patch order.""" + # Development lanes are explicit candidates, not stable feature guarantees. + candidate = version.removeprefix("next-") + if candidate == "master": + return + parsed = _RELEASE.fullmatch(candidate) + floor = _RELEASE.fullmatch(minimum) + if parsed is not None and floor is not None: + running = (int(parsed[1]), int(parsed[2]), parsed[3]) + required = (int(floor[1]), int(floor[2]), floor[3]) + if running > required or ( + running == required + and not version.startswith("next-") + and not (parsed[4] or "").startswith("rc") + ): + return + msg = f"libtmux requires tmux {minimum} or newer; endpoint reports {version!r}" + raise exc.VersionTooLow(msg) + + +def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + argv, + capture_output=True, + text=True, + encoding="utf-8", + errors="backslashreplace", + timeout=5, + check=False, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + except subprocess.TimeoutExpired as error: + msg = "tmux version check timed out before command dispatch" + raise exc.LibTmuxException(msg) from error + + +def prepare(argv: list[str]) -> tuple[list[str], str | None]: + """Check the daemon's version, then fence dispatch against it.""" + try: + options, commands = getopt.getopt(argv[1:], "28c:CDdf:hlL:NqS:T:uUvV") + except getopt.GetoptError as error: + raise exc.LibTmuxException(str(error)) from error + if any(flag in {"-V", "-h"} for flag, _ in options): + return argv, None + if any(flag == "-c" for flag, _ in options): + msg = "tmux -c is outside command dispatch; use Server.run_shell instead" + raise exc.LibTmuxException(msg) + if not commands: + commands = ["new-session"] + globals_: list[str] = [] + selectors: list[str] = [] + for flag, value in options: + globals_.append(flag) + if flag in {"-f", "-L", "-S", "-T"}: + globals_.append(value) + if flag in {"-L", "-S"}: + selectors.extend((flag, value)) + version = endpoint_version(argv[0], selectors) + marker = "libtmux-version-" + uuid.uuid4().hex + condition = "#{==:#{version}," + version + "}" + return [ + argv[0], + *globals_, + "if-shell", + "-F", + condition, + "", + marker, + ";", + *commands, + ], marker + + +def check_result(stderr: str, marker: str | None) -> None: + """Reject a replaced daemon before returning an ordinary result.""" + if marker is not None and f"unknown command: {marker}" in stderr.splitlines(): + msg = "tmux daemon changed before dispatch; retry the command" + raise exc.VersionTooLow(msg) + + +def live_version(binary: str, selectors: list[str]) -> str | None: + """Check an existing daemon's version, returning None for an absent endpoint.""" + probe = _run([binary, *selectors, "-N", "display-message", "-p", "#{version}"]) + if probe.returncode == 0: + version = probe.stdout.strip() + require_version(version) + return version + if "no server running" in probe.stderr or ( + "error connecting to" in probe.stderr + and any( + reason in probe.stderr + for reason in ( + "No such file or directory", + "Connection refused", + ) + ) + ): + return None + msg = probe.stderr.strip() or "tmux did not report a daemon version" + raise exc.LibTmuxException(msg) + + +def endpoint_version(binary: str, selectors: list[str]) -> str: + """Read a fresh daemon version, or the executable for an absent endpoint.""" + version = live_version(binary, selectors) + if version is not None: + return version + result = _run([binary, "-V"]) + if result.returncode or not result.stdout.startswith("tmux "): + detail = result.stderr.strip() or result.stdout.strip() + msg = f"cannot establish tmux version before command dispatch: {detail}" + raise exc.VersionTooLow(msg) + version = result.stdout.removeprefix("tmux ").strip() + require_version(version) + return version diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..560e139103 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -18,6 +18,7 @@ from . import exc from ._compat import LooseVersion +from ._internal.support import versions if t.TYPE_CHECKING: from collections.abc import Callable @@ -26,7 +27,7 @@ #: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = "3.2a" +TMUX_MIN_VERSION = versions.MINIMUM #: Most recent version of tmux supported TMUX_MAX_VERSION = "3.7" @@ -319,6 +320,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: cmd = [str(c) for c in cmd] self.cmd = cmd + dispatched, marker = support.prepare(cmd) if logger.isEnabledFor(logging.DEBUG): cmd_str = shlex.join(cmd) @@ -329,7 +331,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: try: self.process = subprocess.Popen( - cmd, + dispatched, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -349,6 +351,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) raise + support.check_result(stderr, marker) self.returncode = returncode stdout_split = stdout.split("\n") @@ -428,7 +431,10 @@ def _query_version(tmux_bin: str | None = None) -> str: raise _TmuxVersionUnavailable raise exc.VersionTooLow(proc.stderr) - return proc.stdout[0].split("tmux ")[1] + if len(proc.stdout) != 1 or not proc.stdout[0].startswith("tmux "): + msg = "tmux -V did not report a single version" + raise exc.VersionTooLow(msg) + return proc.stdout[0].removeprefix("tmux ") @functools.cache @@ -606,7 +612,10 @@ def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool: def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool: - """Return True if tmux meets version requirement. Version >= 3.2a. + """Return True if the executable meets the complete release minimum. + + Commands check their connected daemon's version on their own. This helper preserves + patch letters and does not use the legacy normalized version cache. Parameters ---------- @@ -637,15 +646,13 @@ def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> boo Versions will now remove trailing letters per `Issue 55 `_. """ - current_version = get_version(tmux_bin=tmux_bin) - if current_version < LooseVersion(TMUX_MIN_VERSION): + try: + version = _query_version(tmux_bin=tmux_bin) + versions.require_version(version, minimum=TMUX_MIN_VERSION) + except (exc.VersionTooLow, _TmuxVersionUnavailable): if raises: - msg = ( - f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater. This " - f"system has {current_version} installed. Upgrade your " - "tmux to use libtmux, or use libtmux v0.48.x for older tmux versions." - ) - raise exc.VersionTooLow(msg) + msg = f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater" + raise exc.VersionTooLow(msg) from None return False return True diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..c344e8ff48 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -18,6 +18,7 @@ from libtmux import exc from libtmux._internal.env import socket_path_from_env from libtmux._internal.query_list import QueryList +from libtmux._internal.support import versions from libtmux.client import Client from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope @@ -295,6 +296,8 @@ def is_alive(self) -> bool: """ try: res = self.cmd("list-sessions") + except exc.VersionTooLow: + raise except Exception: return False return res.returncode == 0 @@ -317,22 +320,25 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ - resolved = self.tmux_bin or shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound + result = self.cmd("list-sessions") + if result.returncode: + raise subprocess.CalledProcessError( + result.returncode, + result.cmd, + output="\n".join(result.stdout), + stderr="\n".join(result.stderr), + ) - cmd_args: list[str] = ["list-sessions"] + def _version_endpoint(self) -> tuple[str, list[str]]: + binary = self.tmux_bin or shutil.which("tmux") + if binary is None: + raise exc.TmuxCommandNotFound + selectors: list[str] = [] if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") + selectors.extend(("-L", str(self.socket_name))) if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") - if self.config_file: - cmd_args.insert(0, f"-f{self.config_file}") - - try: - subprocess.check_call([resolved, *cmd_args]) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None + selectors = ["-S", str(self.socket_path), *selectors] + return binary, selectors # # Command @@ -472,6 +478,8 @@ def kill(self) -> None: >>> svr.is_alive() False """ + if support.live_version(*self._version_endpoint()) is None: + return proc = self.cmd("kill-server") if proc.stderr: stderr_text = " ".join(str(line) for line in proc.stderr) diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index a6631f7136..b4f3960ddb 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -86,7 +86,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) monkeypatch.setattr(sys, "platform", "openbsd 5.2") - assert has_minimum_version() + assert not has_minimum_version(raises=False) assert has_gte_version(TMUX_MIN_VERSION) assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" assert get_version() == f"{TMUX_MAX_VERSION}-openbsd", ( @@ -133,20 +133,11 @@ def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: def test_error_version_less_1_7(monkeypatch: pytest.MonkeyPatch) -> None: - """Test raises if tmux version less than 1.7.""" - - def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: - return LooseVersion("1.7") - - monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) - with pytest.raises(exc.LibTmuxException) as excinfo: + """Refuse an old executable without dropping its version report.""" + monkeypatch.setattr(libtmux.common, "_query_version", lambda **kwargs: "1.7") + with pytest.raises(exc.VersionTooLow, match="libtmux only supports"): has_minimum_version() - excinfo.match(r"libtmux only supports") - - with pytest.raises(exc.LibTmuxException) as excinfo: - has_minimum_version() - - excinfo.match(r"libtmux only supports") + assert not has_minimum_version(raises=False) def test_has_version() -> None: diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d573..2f4fc33be8 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -488,7 +488,10 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: exc_info.match(exc_msg_regex) else: assert get_version() == expected_version - assert has_minimum_version() + if test_id == "openbsd_version": + assert not has_minimum_version(raises=False) + else: + assert has_minimum_version() assert has_gte_version(TMUX_MIN_VERSION) assert has_gt_version(TMUX_MAX_VERSION) @@ -623,6 +626,10 @@ def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: return LooseVersion(mock_version) monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) + if check_type == "min_version": + monkeypatch.setattr( + libtmux.common, "_query_version", lambda **kwargs: mock_version + ) if check_type == "min_version": if raises: diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000000..cb57abda06 --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,181 @@ +"""tmux version checks before ordinary public dispatch.""" + +from __future__ import annotations + +import subprocess +import typing as t + +import pytest + +from libtmux import Server, exc +from libtmux._internal import support +from libtmux.common import tmux_cmd + + +@pytest.mark.parametrize( + "version", + ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8", "master"], +) +def test_admitted_release(version: str) -> None: + """Keep patch letters and numeric minor ordering at the floor.""" + support.require_version(version) + + +@pytest.mark.parametrize( + "version", + [ + "3.2a", + "3.3", + "3.3rc1", + "3.3a-rc1", + "next-3.3", + "next-3.3a", + "", + "unknown", + "3.3alpha", + "3.3a\n3.7", + ], +) +def test_refused_release(version: str) -> None: + """Unknown and pre-floor reports cannot inherit a passed check.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + support.require_version(version) + + +@pytest.mark.parametrize( + "route", + ["raw-read", "raw-write", "raw-batch", "server", "alive", "raise-if-dead", "kill"], +) +def test_older_daemon_refused_before_dispatch( + monkeypatch: pytest.MonkeyPatch, route: str +) -> None: + """Synthetic version reports let the test assert zero requested effects.""" + probes: list[list[str]] = [] + + def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: + probes.append(argv) + return subprocess.CompletedProcess(argv, 0, "3.3\n", "") + + def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("requested process started below the minimum") + + monkeypatch.setattr(versions, "_run", probe) + monkeypatch.setattr(subprocess, "Popen", dispatch) + server = Server(tmux_bin="/test/tmux", socket_path="/private/socket") + with pytest.raises(exc.VersionTooLow, match=r"3\.3"): + if route == "raw-read": + tmux_cmd("-S", "/private/socket", "list-sessions", tmux_bin="/test/tmux") + elif route == "raw-write": + tmux_cmd("set-option", "-g", "@probe", "changed", tmux_bin="/test/tmux") + elif route == "raw-batch": + tmux_cmd( + "set-option", + "-g", + "@probe", + "changed", + ";", + "list-sessions", + tmux_bin="/test/tmux", + ) + elif route == "alive": + server.is_alive() + elif route == "kill": + server.kill() + elif route == "raise-if-dead": + server.raise_if_dead() + else: + server.cmd("new-session", "-d") + assert len(probes) == 1 + assert probes[0][-3:] == ["display-message", "-p", "#{version}"] + + +def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep native argv and batch separators after the version fence.""" + monkeypatch.setattr( + support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") + ) + original = [ + "/tmux", + "-Lodd;socket", + "set-option", + "-g", + "@value", + "literal\\;", + ";", + "list-sessions", + ] + guarded, marker = versions.prepare(original) + assert guarded[:3] == ["/tmux", "-L", "odd;socket"] + assert guarded[-6:] == original[-6:] + assert guarded[3:7] == ["if-shell", "-F", "#{==:#{version},3.3a}", ""] + assert guarded[7] == marker + + +def test_changed_daemon_is_explicit() -> None: + """The native false branch identifies refusal before requested commands.""" + with pytest.raises(exc.VersionTooLow, match="changed before dispatch"): + versions.check_result("unknown command: private-marker\n", "private-marker") + + +def test_probe_error_is_not_a_cold_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + """Permission and protocol failures never authorize server creation.""" + calls: list[list[str]] = [] + + def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 1, "", "permission denied") + + monkeypatch.setattr(versions, "_run", probe) + with pytest.raises(exc.LibTmuxException, match="permission denied"): + versions.prepare(["/tmux", "new-session", "-d"]) + assert len(calls) == 1 + + +def test_native_prefix_refuses_a_changed_report( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A native mismatch aborts every requested command in the same batch.""" + server.new_session("versioned") + run = support._run + + def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: + result = run(argv) + if result.returncode == 0 and argv[-1] == "#{version}": + return subprocess.CompletedProcess(argv, 0, "3.99\n", "") + return result + + with monkeypatch.context() as scoped: + scoped.setattr(versions, "_run", changed) + with pytest.raises(exc.VersionTooLow, match="changed before dispatch"): + server.cmd( + "set-option", + "-g", + "@version-first", + "changed", + ";", + "set-option", + "-g", + "@version-second", + "changed", + ) + assert server.cmd("show-option", "-gqv", "@version-first").stdout == [] + assert server.cmd("show-option", "-gqv", "@version-second").stdout == [] + + +def test_absent_cleanup_never_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: + """An absent endpoint stays untouched even when its binary is too old.""" + calls: list[list[str]] = [] + + def absent(argv: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 1, "", "no server running") + + def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("absent cleanup dispatched a process") + + monkeypatch.setattr(versions, "_run", absent) + monkeypatch.setattr(subprocess, "Popen", dispatch) + Server(socket_name="absent", tmux_bin="/test/old-tmux").kill() + assert len(calls) == 1 + assert calls[0][-1] == "#{version}" From 61f0b9000a3223006166616a9e9e57ced9c152bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:24:23 -0500 Subject: [PATCH 02/36] Core(refactor): Use the daemon feature baseline why: A supported daemon can differ from its client executable. Numeric format introduction checks must also keep tmux patch letters out of Python package prerelease ordering. what: - Select format fields from fresh endpoint reports - Preserve native introduction metadata for offline format utilities - Remove obsolete popup, hook and prompt fallbacks below 3.3a - Restore the session dimensions test after retiring its 3.2 defect --- src/libtmux/hooks.py | 53 +++++++----------------------------------- src/libtmux/neo.py | 12 ++++++---- src/libtmux/pane.py | 50 ++++++--------------------------------- src/libtmux/server.py | 27 ++++----------------- tests/test_server.py | 1 - tests/test_versions.py | 11 +++++++++ 6 files changed, 38 insertions(+), 116 deletions(-) diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py index d9ad24d407..64760570bb 100644 --- a/src/libtmux/hooks.py +++ b/src/libtmux/hooks.py @@ -5,7 +5,7 @@ Hooks are array options (e.g., ``session-renamed[0]``, ``session-renamed[1]``) with sparse index support (can have gaps: ``[0]``, ``[5]``, ``[10]``). -All features available in libtmux's minimum supported version (tmux 3.2+): +Features available on the tmux 3.3a baseline: - Session, window, and pane-level hooks - Window hooks via ``-w`` flag, pane hooks via ``-p`` flag @@ -38,7 +38,7 @@ Hooks, ) from libtmux._internal.sparse_array import SparseArray -from libtmux.common import CmdMixin, has_lt_version +from libtmux.common import CmdMixin from libtmux.constants import ( DEFAULT_OPTION_SCOPE, HOOK_SCOPE_FLAG_MAP, @@ -59,7 +59,7 @@ class HooksMixin(CmdMixin): """Mixin for manager scoped hooks in tmux. - Requires tmux 3.1+. For older versions, use raw commands. + Commands share the tmux 3.3a core minimum. """ default_hook_scope: OptionScope | None @@ -70,13 +70,6 @@ def __init__(self, default_hook_scope: OptionScope | None) -> None: self.default_hook_scope = default_hook_scope self.hooks = Hooks() - @property - def _tmux_bin(self) -> str | None: - """Resolve tmux_bin from self (Server) or self.server (Session/Window/Pane).""" - return getattr(self, "tmux_bin", None) or getattr( - getattr(self, "server", None), "tmux_bin", None - ) - def run_hook( self, hook: str, @@ -96,13 +89,7 @@ def run_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -175,13 +162,7 @@ def set_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -228,13 +209,7 @@ def unset_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -293,13 +268,7 @@ def show_hooks( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd("show-hooks", *flags) output = cmd.stdout @@ -351,13 +320,7 @@ def _show_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) flags += (hook,) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..51ec827157 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -5,13 +5,14 @@ import dataclasses import functools import logging +import re import shlex import typing as t from collections.abc import Iterable from libtmux import exc from libtmux._compat import LooseVersion -from libtmux.common import get_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -277,7 +278,8 @@ def _normalize_tmux_version(version: str) -> LooseVersion: """ if "master" in version.lower(): return LooseVersion("99.0") - return LooseVersion(version) + release = re.match(r"(?:next-)?(\d+\.\d+)", version) + return LooseVersion(release[1] if release is not None else version) @dataclasses.dataclass() @@ -908,7 +910,7 @@ def _refresh( @functools.cache def get_output_format( list_cmd: str = "list-panes", - tmux_version: str = "3.2a", + tmux_version: str = "3.3a", ) -> tuple[tuple[str, ...], str]: """Return field names and tmux format string filtered by scope and version. @@ -985,7 +987,7 @@ def get_output_format( def parse_output( output: str, list_cmd: str = "list-panes", - tmux_version: str = "3.2a", + tmux_version: str = "3.3a", ) -> OutputRaw: """Parse a tmux ``-F`` line into a dict keyed by Obj field name. @@ -1095,7 +1097,7 @@ def fetch_objs( >>> 'session_id' in objs[0] True """ - tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + tmux_version = server._get_version() _fields, format_string = get_output_format(list_cmd, tmux_version) cmd_args: list[str | int] = [] diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f59619..eb8091f619 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1782,59 +1782,23 @@ def display_popup( tmux_args += ("-d", str(start_path)) if title is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-T", title) - else: - warnings.warn( - "title requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-T", title) if border_lines is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-b", border_lines) - else: - warnings.warn( - "border_lines requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-b", border_lines) if style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-s", style) - else: - warnings.warn( - "style requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-s", style) if border_style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-S", border_style) - else: - warnings.warn( - "border_style requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-S", border_style) if environment: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - for k, v in environment.items(): - tmux_args += (f"-e{k}={v}",) - else: - warnings.warn( - "environment requires tmux 3.3+, ignoring", - stacklevel=2, - ) + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) if no_border: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-B",) - else: - warnings.warn( - "no_border requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args += ("-B",) if close_on_any_key: if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c344e8ff48..7760b93153 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -20,7 +20,7 @@ from libtmux._internal.query_list import QueryList from libtmux._internal.support import versions from libtmux.client import Client -from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -340,6 +340,9 @@ def _version_endpoint(self) -> tuple[str, list[str]]: selectors = ["-S", str(self.socket_path), *selectors] return binary, selectors + def _get_version(self) -> str: + return support.endpoint_version(*self._version_endpoint()) + # # Command # @@ -890,10 +893,6 @@ def server_access( ... result = server.server_access(list_access=True) ... assert isinstance(result, list) """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "server_access requires tmux 3.3+" - raise exc.LibTmuxException(msg) - if read_only and write: msg = "read_only and write are mutually exclusive (tmux rejects -r -w)" raise ValueError(msg) @@ -1160,10 +1159,6 @@ def confirm_before( >>> result 'yes' """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "confirm_before requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = ("-b",) if prompt is not None: @@ -1276,10 +1271,6 @@ def command_prompt( >>> result 'hi' """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "command_prompt requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = ("-b",) if one_key: @@ -1766,10 +1757,6 @@ def show_prompt_history( >>> isinstance(result, list) True """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "show_prompt_history requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = () if prompt_type is not None: @@ -1800,10 +1787,6 @@ def clear_prompt_history( >>> if has_gte_version("3.3"): ... server.clear_prompt_history() """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): - msg = "clear_prompt_history requires tmux 3.3+" - raise exc.LibTmuxException(msg) - tmux_args: tuple[str, ...] = () if prompt_type is not None: @@ -2338,7 +2321,7 @@ def new_session( del os.environ["TMUX"] try: - tmux_version = str(get_version(tmux_bin=self.tmux_bin)) + tmux_version = self._get_version() _fields, format_string = get_output_format("list-sessions", tmux_version) tmux_args: tuple[str | int, ...] = ( diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..e64eeb896f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -227,7 +227,6 @@ def test_new_session_shell_env(server: Server) -> None: assert pane_start_command.replace('"', "") == cmd -@pytest.mark.skipif(True, reason="tmux 3.2 returns wrong width - test needs rework") def test_new_session_width_height(server: Server) -> None: """Verify ``Server.new_session`` creates valid session running w/ dimensions.""" cmd = "/usr/bin/env PS1='$ ' sh" diff --git a/tests/test_versions.py b/tests/test_versions.py index cb57abda06..17460b5c05 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -131,6 +131,17 @@ def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: assert len(calls) == 1 +def test_format_version_uses_tmux_release_order() -> None: + """Patch letters qualify the core but keep the release's format tokens.""" + from libtmux.neo import get_output_format + + fields, _ = get_output_format("list-panes", "3.7d") + assert "pane_floating_flag" in fields + fields, _ = get_output_format("list-panes", "3.3a") + assert "pane_dead_signal" in fields + assert "pane_floating_flag" not in fields + + def test_native_prefix_refuses_a_changed_report( server: Server, monkeypatch: pytest.MonkeyPatch, From 7837b147a61be5efbc6e6a5d84c17aabd1cc5d14 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:26:59 -0500 Subject: [PATCH 03/36] Docs(docs): Align support and CI on tmux 3.3a why: Installation guidance and tests must name the same baseline that ordinary commands enforce on their connected daemon. what: - Retire 3.2a from the ordinary CI matrix and state the 3.3a floor - Explain daemon authority, diagnostics and absent cleanup - Document development reports and unversioned OpenBSD refusal - Describe fresh format selection separately from cached diagnostics --- .github/workflows/tests.yml | 2 +- AGENTS.md | 2 +- README.md | 2 +- docs/project/compatibility.md | 32 ++++++++++++++++++++++++++++---- docs/quickstart.md | 2 +- docs/topics/format-tokens.md | 32 +++++++++++++------------------- src/libtmux/server.py | 2 ++ 7 files changed, 47 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 340cff9627..71562ac4ce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: python-version: ['3.15'] - tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', 'master'] + tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', 'master'] steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index 79c289585f..3c37b7db48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ be stated twice, the file listed above is the one that governs. - A passing gate is evidence only once it has been shown capable of failing. Pair a new test with a deliberate break that proves it bites. -tmux >= 3.2a is the compatibility floor (see `tests.yml`'s build +tmux >= 3.3a is the compatibility floor (see `tests.yml`'s build matrix). `Server.sessions`, `Server.clients`, and `Server.attached_sessions` return an empty `QueryList` rather than raising when the underlying tmux list command fails for any reason — diff --git a/README.md b/README.md index 920af194ff..61dc454b3f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli ## Requirements & support -- tmux: >= 3.2a +- tmux: >= 3.3a - Python: >= 3.10 (CPython and PyPy) Maintenance-only backports (no new fixes): diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 4dc5347e14..51fef480a1 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -8,10 +8,34 @@ ## tmux -- **Minimum**: tmux 3.2a -- **Tested**: latest stable tmux release -- libtmux uses tmux's format system and control mode -- older tmux versions - may lack required format variables +Core operations require tmux **3.3a or newer**, including the patch letter. +Tmux 3.3 is below the minimum. The CI matrix includes 3.3a and later releases; +`master` is a development lane. + +Commands check the connected daemon, which can differ from the selected +client executable. An absent endpoint uses the executable's fresh version +report before startup. Raw commands, batches and liveness checks share this +rule. An unsupported or unreadable version raises +{exc}`~libtmux.exc.VersionTooLow` before the requested command runs. +A replacement daemon is checked again before dispatch. +{meth}`~libtmux.Server.kill` returns without dispatching when its endpoint is +absent. + +Each command adds a read-only version query, bounded to five seconds. The check +does not cache daemon versions. Binary diagnostics through `tmux -V` and +`tmux -h` remain available. Raw `tmux -c` shell execution is refused; use +{meth}`~libtmux.Server.run_shell` for a tmux command instead. + +`master` and numbered development builds newer than the baseline are accepted +as development candidates. They do not inherit stable feature qualification. +Unknown reports are refused. On OpenBSD, an existing daemon must report a +qualified release; an executable without `-V` cannot start an absent endpoint. +The synthetic values returned by legacy version helpers are diagnostic values +and never authorize a command. + +Individual features can require a newer daemon. Their API documentation states +those requirements. Raising the core floor does not qualify control startup, +output synchronization or other optional behavior on every later release. ## Platforms diff --git a/docs/quickstart.md b/docs/quickstart.md index b8f9188493..a352bf95f5 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,7 +12,7 @@ from inside a live tmux session. ## Requirements -- [tmux] 3.2a or newer +- [tmux] 3.3a or newer - [pip] - for this handbook's examples [tmux]: https://tmux.github.io/ diff --git a/docs/topics/format-tokens.md b/docs/topics/format-tokens.md index 237c19feaf..670b523619 100644 --- a/docs/topics/format-tokens.md +++ b/docs/topics/format-tokens.md @@ -37,8 +37,7 @@ supported version. A typed field is `None` for one of three reasons: - **Not yet introduced.** Older tmux doesn't know the token at all. - {attr}`~libtmux.Pane.pane_dead_signal` is `None` on tmux 3.2a because - the token landed in 3.3. + {attr}`~libtmux.Pane.pane_flags` is `None` before tmux 3.7. - **Wrong scope for this object.** A {class}`~libtmux.Client` row can report client tokens plus the client's current session/window/pane. `buffer_*` tokens never apply to client rows. @@ -47,15 +46,13 @@ A typed field is `None` for one of three reasons: binding, copy-mode, popup) — never in a `list-*` snapshot. libtmux excludes them from every `-F` template. -The version map for post-3.2a tokens is small and stable. The -following are the tokens libtmux currently gates: +The following tokens arrived after the core 3.3a baseline: | Added in | Tokens | |----------|--------| -| 3.3 | {attr}`~libtmux.Pane.pane_dead_signal`, {attr}`~libtmux.Pane.pane_dead_time` | | 3.7 | {attr}`~libtmux.Pane.bracket_paste_flag`, {attr}`~libtmux.Pane.pane_flags`, {attr}`~libtmux.Pane.pane_floating_flag`, {attr}`~libtmux.Pane.pane_pb_progress`, {attr}`~libtmux.Pane.pane_pb_state`, {attr}`~libtmux.Pane.pane_pipe_pid`, {attr}`~libtmux.Pane.pane_x`, {attr}`~libtmux.Pane.pane_y`, {attr}`~libtmux.Pane.pane_z`, {attr}`~libtmux.Pane.pane_zoomed_flag`, {attr}`~libtmux.Pane.synchronized_output_flag` | -Everything not listed above is safe on every supported tmux (≥ 3.2a). +Everything not listed above is safe on every supported tmux (≥ 3.3a). Fields for newer tmux tokens will be added as each supported version is validated. @@ -118,19 +115,16 @@ The result is cached per `(list_cmd, tmux_version)` pair. ## tmux version detection -You never call this directly, but it's worth knowing how the version -gate gets its answer. libtmux detects the live tmux version via -{func}`libtmux.common.get_version` and passes it through to -`get_output_format` whenever it builds a `-F` template. That lookup is -memoized for the process lifetime, as is the raw-string -{func}`libtmux.common.get_version_str`; the two cache independently, so -if you're swapping the `tmux` binary mid-test, clear both with -`libtmux.common.get_version.cache_clear()` and -`get_version_str.cache_clear()`. - -The {ref}`project` page tracks the project's minimum tmux version -(currently 3.2a); see {doc}`/project/compatibility` for the full -matrix. +Format templates use a fresh version report from the selected daemon. Before +startup, they use the selected executable's report. This keeps field selection +correct when the executable and daemon have different versions. + +{func}`libtmux.common.get_version` and +{func}`libtmux.common.get_version_str` describe the executable and cache their +results independently. Their caches do not authorize daemon operations. + +See {doc}`/project/compatibility` for the tmux 3.3a core minimum and the +version-check rules. ## See also diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 7760b93153..01ec7e4eff 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -466,6 +466,8 @@ def has_session(self, target_session: str, exact: bool = True) -> bool: def kill(self) -> None: """Kill tmux server. + An absent endpoint returns without dispatching or starting a daemon. + >>> svr = Server(socket_name="testing") >>> svr Server(socket_name=testing) From 1c1d09166dd8e8209e82878c97fb8eeb66006dd9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:32:34 -0500 Subject: [PATCH 04/36] Core(fix): Reject undated version reports why: Bare `master` names no release. The legacy helpers map it to a synthetic maximum, which would let an undated development daemon pass the 3.3a check. what: - Require an identifiable release before dispatching to a daemon - Keep the legacy master normalization for diagnostics only - Test that a daemon reporting master is refused The master case fails on the previous check path; the focused version check suites pass (105 tests), as do ruff and mypy. --- docs/project/compatibility.md | 10 ++++++---- src/libtmux/_internal/support/versions.py | 2 -- tests/legacy_api/test_common.py | 6 ++++-- tests/test_common.py | 2 +- tests/test_versions.py | 3 ++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 51fef480a1..e07165a815 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -26,10 +26,12 @@ does not cache daemon versions. Binary diagnostics through `tmux -V` and `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use {meth}`~libtmux.Server.run_shell` for a tmux command instead. -`master` and numbered development builds newer than the baseline are accepted -as development candidates. They do not inherit stable feature qualification. -Unknown reports are refused. On OpenBSD, an existing daemon must report a -qualified release; an executable without `-V` cannot start an absent endpoint. +Numbered development reports are accepted as candidates when their release +base meets the baseline; a `next-` build precedes the release it names. These +candidates do not inherit stable feature qualification. Unknown reports, +including an undated `master`, are refused. On OpenBSD, an existing daemon +must report a qualified release; an executable without `-V` cannot start an +absent endpoint. The synthetic values returned by legacy version helpers are diagnostic values and never authorize a command. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 3fc8d61aef..8ab339bccd 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -17,8 +17,6 @@ def require_version(version: str, minimum: str = MINIMUM) -> None: """Reject unknown or older releases, preserving numeric and patch order.""" # Development lanes are explicit candidates, not stable feature guarantees. candidate = version.removeprefix("next-") - if candidate == "master": - return parsed = _RELEASE.fullmatch(candidate) floor = _RELEASE.fullmatch(minimum) if parsed is not None and floor is not None: diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index b4f3960ddb..77e35ecd08 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -36,7 +36,9 @@ version_regex = re.compile(r"([0-9]\.[0-9])|(master)") -def test_allows_master_version(monkeypatch: pytest.MonkeyPatch) -> None: +def test_master_diagnostic_does_not_qualify_minimum( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Assert get_version() works with builds from git trunk.""" class Hi: @@ -48,7 +50,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) - assert has_minimum_version() + assert not has_minimum_version(raises=False) assert has_gte_version(TMUX_MIN_VERSION) assert has_gt_version(TMUX_MAX_VERSION), "Greater than the max-supported version" assert get_version() == f"{TMUX_MAX_VERSION}-master", ( diff --git a/tests/test_common.py b/tests/test_common.py index 2f4fc33be8..14823bc6df 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -488,7 +488,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: exc_info.match(exc_msg_regex) else: assert get_version() == expected_version - if test_id == "openbsd_version": + if test_id in {"openbsd_version", "master_version"}: assert not has_minimum_version(raises=False) else: assert has_minimum_version() diff --git a/tests/test_versions.py b/tests/test_versions.py index 17460b5c05..a4e5f59253 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -14,7 +14,7 @@ @pytest.mark.parametrize( "version", - ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8", "master"], + ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8"], ) def test_admitted_release(version: str) -> None: """Keep patch letters and numeric minor ordering at the floor.""" @@ -32,6 +32,7 @@ def test_admitted_release(version: str) -> None: "next-3.3a", "", "unknown", + "master", "3.3alpha", "3.3a\n3.7", ], From 8bb49959d2be5a90b0a9a294c7f90395177c71ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:37:41 -0500 Subject: [PATCH 05/36] Core(fix): Prevent unchecked daemon restarts why: After a live daemon passes the version check, a command that finds it gone would let the configured executable start a new daemon, which may be a different release, before any check runs. what: - Pass -N when dispatch follows a successful live-daemon check - Keep cold startup validating the executable and creating natively - Test a daemon that disappears between the check and the command Without the fix the disappearance test restarts a daemon and fails. The focused suites pass (221 tests), as do ruff, mypy and the strict docs build. --- docs/project/compatibility.md | 4 ++++ src/libtmux/_internal/support/versions.py | 11 ++++++++- tests/test_versions.py | 27 +++++++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index e07165a815..f34e1a0160 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -35,6 +35,10 @@ absent endpoint. The synthetic values returned by legacy version helpers are diagnostic values and never authorize a command. +If a checked daemon disappears before dispatch, that command fails without +starting the configured executable. Retry against the absent endpoint to +validate the executable before creating a replacement. + Individual features can require a newer daemon. Their API documentation states those requirements. Raising the core floor does not qualify control startup, output synchronization or other optional behavior on every later release. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 8ab339bccd..b9b1902f69 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -71,7 +71,12 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: globals_.append(value) if flag in {"-L", "-S"}: selectors.extend((flag, value)) - version = endpoint_version(argv[0], selectors) + version = live_version(argv[0], selectors) + if version is None: + version = _binary_version(argv[0]) + elif "-N" not in globals_: + # A vanished daemon must not restart an older configured executable. + globals_.append("-N") marker = "libtmux-version-" + uuid.uuid4().hex condition = "#{==:#{version}," + version + "}" return [ @@ -121,6 +126,10 @@ def endpoint_version(binary: str, selectors: list[str]) -> str: version = live_version(binary, selectors) if version is not None: return version + return _binary_version(binary) + + +def _binary_version(binary: str) -> str: result = _run([binary, "-V"]) if result.returncode or not result.stdout.startswith("tmux "): detail = result.stderr.strip() or result.stdout.strip() diff --git a/tests/test_versions.py b/tests/test_versions.py index a4e5f59253..fb502cc4fa 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -108,8 +108,8 @@ def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> N guarded, marker = versions.prepare(original) assert guarded[:3] == ["/tmux", "-L", "odd;socket"] assert guarded[-6:] == original[-6:] - assert guarded[3:7] == ["if-shell", "-F", "#{==:#{version},3.3a}", ""] - assert guarded[7] == marker + assert guarded[3:8] == ["-N", "if-shell", "-F", "#{==:#{version},3.3a}", ""] + assert guarded[8] == marker def test_changed_daemon_is_explicit() -> None: @@ -191,3 +191,26 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: Server(socket_name="absent", tmux_bin="/test/old-tmux").kill() assert len(calls) == 1 assert calls[0][-1] == "#{version}" + + +def test_checked_daemon_disappearance_never_restarts( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """Losing a checked daemon cannot start the configured executable.""" + server.new_session("versioned", window_command="sleep 300") + run = support._run + + def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: + result = run(argv) + if result.returncode == 0 and argv[-1] == "#{version}": + assert run([*argv[:-4], "kill-server"]).returncode == 0 + return result + + with monkeypatch.context() as scoped: + scoped.setattr(versions, "_run", disappear) + result = server.cmd( + "new-session", "-d", "-s", "unexpected-restart", "sleep 300" + ) + assert result.returncode != 0, "dispatch restarted the checked daemon" + binary, selectors = server._version_endpoint() + assert support.live_version(binary, selectors) is None From 52eadfdb9d78536cb8115704c42beba263cff5c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:40:02 -0500 Subject: [PATCH 06/36] Core(fix): Tell -N apart from option values why: A socket name or config path given to -L, -S or -f can itself be the literal -N, which must not count as the no-start flag. what: - Read -N from the parsed global options, not from any argv word - Test -N as the value of -L, -S and -f The regression fails on the previous code and the three value cases pass with the fix; ruff format and checks pass. --- src/libtmux/_internal/support/versions.py | 2 +- tests/test_versions.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index b9b1902f69..0e92a94e3b 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -74,7 +74,7 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: version = live_version(argv[0], selectors) if version is None: version = _binary_version(argv[0]) - elif "-N" not in globals_: + elif not any(flag == "-N" for flag, _ in options): # A vanished daemon must not restart an older configured executable. globals_.append("-N") marker = "libtmux-version-" + uuid.uuid4().hex diff --git a/tests/test_versions.py b/tests/test_versions.py index fb502cc4fa..c964031592 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -118,6 +118,18 @@ def test_changed_daemon_is_explicit() -> None: versions.check_result("unknown command: private-marker\n", "private-marker") +@pytest.mark.parametrize("option", ["-L", "-S", "-f"]) +def test_no_start_flag_distinguishes_option_values( + monkeypatch: pytest.MonkeyPatch, option: str +) -> None: + """A literal option value cannot disable daemon restart protection.""" + monkeypatch.setattr( + support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") + ) + guarded, _ = support.prepare(["/tmux", option, "-N", "new-session", "-d"]) + assert guarded[:4] == ["/tmux", option, "-N", "-N"] + + def test_probe_error_is_not_a_cold_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: """Permission and protocol failures never authorize server creation.""" calls: list[list[str]] = [] From 49b2c33f62a3ab09ea4a0b32a1db4eea539c1894 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:46:50 -0500 Subject: [PATCH 07/36] Formats(fix): Bound numbered dev builds' fields why: A numbered development report such as 3.3a-master fell through to the bare-master offline convenience, which selects the newest format catalog, so the daemon was asked for fields later releases introduced. what: - Map a numbered development report to its numeric format catalog before the bare-master convenience applies - Test that a 3.3a-master daemon requests no later fields The 3.3a-master regression fails on the previous code. The version check, format and break-pane checks pass (66 tests), as do strict Sphinx, ruff and mypy. --- src/libtmux/neo.py | 13 +++++++++---- tests/test_versions.py | 7 +++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 51ec827157..19cd6d2354 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -265,8 +265,9 @@ def _token_scope(field_name: str) -> str: def _normalize_tmux_version(version: str) -> LooseVersion: """Convert a tmux version string into a comparable :class:`LooseVersion`. - tmux master is reported as ``"master"`` (or e.g. ``"3.6a-master"``); - treat it as larger than any tagged release. + Numbered reports select their release's format catalog. Bare ``"master"`` + selects the complete catalog for offline inspection; it never passes a + daemon's version check. Examples -------- @@ -275,11 +276,15 @@ def _normalize_tmux_version(version: str) -> LooseVersion: True >>> _normalize_tmux_version("3.2a") < _normalize_tmux_version("3.6a") True + >>> _normalize_tmux_version("3.3a-master") == _normalize_tmux_version("3.3") + True """ + release = re.match(r"(?:next-)?(\d+\.\d+)", version) + if release is not None: + return LooseVersion(release[1]) if "master" in version.lower(): return LooseVersion("99.0") - release = re.match(r"(?:next-)?(\d+\.\d+)", version) - return LooseVersion(release[1] if release is not None else version) + return LooseVersion(version) @dataclasses.dataclass() diff --git a/tests/test_versions.py b/tests/test_versions.py index c964031592..33e80da46e 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -144,13 +144,16 @@ def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: assert len(calls) == 1 -def test_format_version_uses_tmux_release_order() -> None: +@pytest.mark.parametrize( + "floor_report", ["3.3a", "3.3a-master", "3.3a-dev", "next-3.3b"] +) +def test_format_version_uses_tmux_release_order(floor_report: str) -> None: """Patch letters qualify the core but keep the release's format tokens.""" from libtmux.neo import get_output_format fields, _ = get_output_format("list-panes", "3.7d") assert "pane_floating_flag" in fields - fields, _ = get_output_format("list-panes", "3.3a") + fields, _ = get_output_format("list-panes", floor_report) assert "pane_dead_signal" in fields assert "pane_floating_flag" not in fields From 23a721ee349d101ae4400b4174f6010d1e98e3d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:46:50 -0500 Subject: [PATCH 08/36] Tests(fix): Wait for the break-pane naming command why: A POSIX shell can stay the pane's foreground process while it waits for a child, so neither the split reply nor a fixed delay shows that sleep is running, and the automatic-name assertion raced. what: - Run exec sleep and wait for its foreground name before asserting the window's automatic name Forcing the exact-3.7 placeholder on a newer daemon fails the intended name assertion; with the release guard restored the focused tests pass without reruns. --- tests/test_pane.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce62..11d410f6d9 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1358,7 +1358,13 @@ def test_break_pane_no_name_uses_natural_name(session: Session) -> None: pane = window.active_pane assert pane is not None - new_pane = pane.split(shell="sleep 123") + new_pane = pane.split(shell="exec sleep 123") + + def command_started() -> bool: + new_pane.refresh() + return new_pane.pane_current_command == "sleep" + + retry_until(command_started) broken = new_pane.break_pane() assert broken.window_name == "sleep" From 9ecc8403d0e819fb51116a0b152b156ea4fd4f9a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 20:52:50 -0500 Subject: [PATCH 09/36] Core(fix): Keep version refusals through lookups why: List accessors, winlink discovery and query lookup turned command errors into empty results, so an unsupported daemon looked like a server with no sessions instead of raising VersionTooLow. what: - Re-raise VersionTooLow from list-shaped accessors, winlink discovery and query lookup - Keep other command errors under the existing lenient list contract - Test each public traversal route against an unsupported daemon Every route fails on the previous code and raises the refusal with the fix. The full suite and the executed documentation examples pass (1499 tests, no reruns), as do strict Sphinx, ruff and mypy. --- AGENTS.md | 3 +++ src/libtmux/AGENTS.md | 3 +++ src/libtmux/_internal/query_list.py | 5 +++++ src/libtmux/neo.py | 2 ++ src/libtmux/server.py | 8 ++++++++ src/libtmux/window.py | 2 ++ tests/test_versions.py | 29 +++++++++++++++++++++++++++-- 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c37b7db48..070d97225c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,9 @@ list-shaped accessors are lenient by default; `Server.is_alive()` and `src/libtmux/AGENTS.md` for the full contract and this package's logging conventions. +An unsupported tmux version raises `VersionTooLow`, including from lenient +list accessors and query traversal. Version refusals are not empty results. + ## References - Documentation: https://libtmux.git-pull.com/ diff --git a/src/libtmux/AGENTS.md b/src/libtmux/AGENTS.md index 380707c714..76215aa4bc 100644 --- a/src/libtmux/AGENTS.md +++ b/src/libtmux/AGENTS.md @@ -34,6 +34,9 @@ manager) rather than changing the default contract of an existing accessor or hard-coding raise-on-tmux-error into a new one. Empty-on-tmux-error stays the default; raise is opt-in. +Unsupported-version refusals raise `VersionTooLow` before a listing is dispatched. +Do not convert those refusals into empty results or missing attributes. + ## Logging These rules guide future logging changes; existing code may not yet diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 20aeb407f6..03c1b34e75 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -12,6 +12,7 @@ import typing as t from collections.abc import Callable, Iterable, Mapping, Sequence +from libtmux import exc from libtmux.exc import ( MultipleObjectsReturned as MultipleObjectsReturned, ObjectDoesNotExist as ObjectDoesNotExist, @@ -101,6 +102,8 @@ def keygetter( elif hasattr(dct, sub_field): dct = getattr(dct, sub_field) + except exc.VersionTooLow: + raise except Exception: logger.debug( "key lookup failed for path: %s", @@ -145,6 +148,8 @@ def parse_lookup( field_name = path.split(lookup, maxsplit=1)[0] if field_name is not None: return keygetter(obj, field_name) + except exc.VersionTooLow: + raise except Exception: logger.debug( "lookup parsing failed for path: %s", diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 19cd6d2354..702a4c4238 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1354,6 +1354,8 @@ def fetch_obj( list_cmd=list_cmd, list_extra_args=list_extra_args, ) + except exc.VersionTooLow: + raise except exc.LibTmuxException as e: # A ``-t``-scoped listing pushes the "does it exist?" question down # into tmux, which answers on stderr rather than with an empty listing. diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 01ec7e4eff..20ca2484c3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -77,6 +77,8 @@ def _fetch_or_empty( """ try: return fetch_objs(server=server, list_cmd=list_cmd, **kwargs) # type: ignore[arg-type] + except exc.VersionTooLow: + raise except exc.LibTmuxException as e: if e.args and _is_daemon_not_up_error(str(e.args[0])): return [] @@ -2405,12 +2407,15 @@ def sessions(self) -> QueryList[Session]: missing socket, a permission error, or a subprocess failure. To distinguish "no sessions" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + Unsupported tmux reports raise :exc:`~libtmux.exc.VersionTooLow`. """ try: sessions: list[Session] = [ Session(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-sessions") ] + except exc.VersionTooLow: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(sessions) @@ -2466,6 +2471,7 @@ def clients(self) -> QueryList[Client]: missing socket, a permission error, or a subprocess failure. To distinguish "no clients attached" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + Unsupported tmux reports raise :exc:`~libtmux.exc.VersionTooLow`. Returns ------- @@ -2483,6 +2489,8 @@ def clients(self) -> QueryList[Client]: Client(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-clients") ] + except exc.VersionTooLow: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(clients) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db99692..6cbec4df7e 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -363,6 +363,8 @@ def linked_sessions(self) -> QueryList[Session]: server=self.server, list_cmd="list-sessions", ) + except exc.VersionTooLow: + raise except exc.LibTmuxException: return QueryList([]) diff --git a/tests/test_versions.py b/tests/test_versions.py index 33e80da46e..56144da757 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -7,8 +7,10 @@ import pytest -from libtmux import Server, exc +from libtmux import Server, Window, exc from libtmux._internal import support +from libtmux._internal.query_list import QueryList +from libtmux._internal.support import versions from libtmux.common import tmux_cmd @@ -45,7 +47,20 @@ def test_refused_release(version: str) -> None: @pytest.mark.parametrize( "route", - ["raw-read", "raw-write", "raw-batch", "server", "alive", "raise-if-dead", "kill"], + [ + "raw-read", + "raw-write", + "raw-batch", + "server", + "alive", + "raise-if-dead", + "kill", + "sessions", + "clients", + "attached-sessions", + "linked-sessions", + "query", + ], ) def test_older_daemon_refused_before_dispatch( monkeypatch: pytest.MonkeyPatch, route: str @@ -84,6 +99,16 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: server.kill() elif route == "raise-if-dead": server.raise_if_dead() + elif route == "sessions": + _ = server.sessions + elif route == "clients": + _ = server.clients + elif route == "attached-sessions": + _ = server.attached_sessions + elif route == "linked-sessions": + _ = Window(server=server, window_id="@1").linked_sessions + elif route == "query": + QueryList([server]).filter(sessions__noeq=[]) else: server.cmd("new-session", "-d") assert len(probes) == 1 From 0095c8da00e8d717e1a9dbe7ac0dd1b787ccc12b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 21:08:14 -0500 Subject: [PATCH 10/36] Docs(fix): Await real output in marker example why: The completion-marker example typed a nonexistent command and slept 200 ms before reading the pane. It failed in the 3.3a, 3.6 and 3.7b CI lanes and could also match the echo of the typed input. what: - Start a real command with the window and keep the pane alive - Wait with retry_until for the marker as a complete output line The doctest passes without retries on 3.3a and 3.7d; changing only the printed marker makes the unchanged predicate fail with WaitTimeout. Strict docs pass. --- docs/topics/automation_patterns.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index 8d4811c2a7..370e418369 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -514,21 +514,19 @@ True ### 1. Always use markers for completion detection -Timing is a guess; a marker is a fact. Instead of sleeping long enough and hoping a -command finished, have it print an explicit marker and poll for that. Your automation -then reacts to what actually happened rather than to a clock. +Have the command print a marker when it finishes, then wait until that marker appears +as a complete output line. Starting the command with the window avoids matching an +echo of typed input. The shell keeps the pane open so its output remains available. ```python ->>> bp_window = session.new_window(window_name='best-practice', attach=False) +>>> bp_window = session.new_window( +... window_name='best-practice', attach=False, +... window_shell="printf '%s\\n' 'work complete' '__DONE__'; exec /bin/sh", +... ) >>> bp_pane = bp_window.active_pane ->>> # Good: Use completion marker ->>> bp_pane.send_keys('long_command; echo "__DONE__"') - ->>> # Then poll for marker ->>> import time ->>> time.sleep(0.2) ->>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +>>> from libtmux.test.retry import retry_until +>>> retry_until(lambda: '__DONE__' in bp_pane.capture_pane(), seconds=3) True >>> bp_window.kill() From 379f18d1db8883e2f1f5b0579fb946b2292608c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 16 Oct 2026 22:11:55 -0500 Subject: [PATCH 11/36] Core(fix): Accept a renamed executable's banner why: tmux 3.3a prints its executable's basename in the -V banner, so a 3.3a build installed under another name was refused at the floor. what: - Match the configured executable's basename before the tmux prefix, including names with spaces and non-UTF-8 bytes - Keep full release validation and daemon framing unchanged The version check cases pass on 3.3a (52 tests); the previous parser fails four banner cases, three of them real renamed executables. Ruff, mypy and strict Sphinx pass. --- src/libtmux/_internal/support/versions.py | 14 ++++- tests/test_versions.py | 62 ++++++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 0e92a94e3b..6e462706d1 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -3,6 +3,8 @@ from __future__ import annotations import getopt +import os +import pathlib import re import subprocess import uuid @@ -131,10 +133,18 @@ def endpoint_version(binary: str, selectors: list[str]) -> str: def _binary_version(binary: str) -> str: result = _run([binary, "-V"]) - if result.returncode or not result.stdout.startswith("tmux "): + # tmux 3.3a prints its executable name, including non-UTF-8 bytes. + name = os.fsencode(pathlib.Path(binary).name).decode( + "utf-8", errors="backslashreplace" + ) + prefix = next( + (item for item in (name + " ", "tmux ") if result.stdout.startswith(item)), + None, + ) + if result.returncode or prefix is None: detail = result.stderr.strip() or result.stdout.strip() msg = f"cannot establish tmux version before command dispatch: {detail}" raise exc.VersionTooLow(msg) - version = result.stdout.removeprefix("tmux ").strip() + version = result.stdout[len(prefix) :].strip() require_version(version) return version diff --git a/tests/test_versions.py b/tests/test_versions.py index 56144da757..e8b4096ef0 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pathlib +import shutil import subprocess import typing as t @@ -253,4 +255,62 @@ def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: ) assert result.returncode != 0, "dispatch restarted the checked daemon" binary, selectors = server._version_endpoint() - assert support.live_version(binary, selectors) is None + assert versions.live_version(binary, selectors) is None + + +@pytest.mark.parametrize("name", ["renamed-tmux", "tmux with spaces", "tmux\udcff"]) +def test_named_33a_executable_can_create(tmp_path: pathlib.Path, name: str) -> None: + """The native 3.3a banner includes the configured executable basename.""" + binary = shutil.which("tmux") + assert binary is not None + report = subprocess.run( + [binary, "-V"], capture_output=True, check=True, timeout=5 + ).stdout + if report != b"tmux 3.3a\n": + pytest.skip("requires native tmux 3.3a") + renamed = tmp_path / name + renamed.symlink_to(binary) + socket = tmp_path / "s" + server = Server( + tmux_bin=str(renamed), socket_path=str(socket), config_file="/dev/null" + ) + try: + session = server.new_session( + "named-executable", window_command="exec sleep 123" + ) + assert session.session_id is not None + assert server._get_version() == "3.3a" + finally: + subprocess.run( + [binary, "-N", "-S", str(socket), "kill-server"], + capture_output=True, + check=False, + timeout=5, + ) + + +@pytest.mark.parametrize( + ("banner", "accepted"), + [ + ("tmux 3.3a\n", True), + ("custom tmux 3.3a\n", True), + ("unrelated 3.3a\n", False), + ("custom tmux-suffix 3.3a\n", False), + ("custom tmux 3.3a extra\n", False), + ("custom tmux 3.3\n", False), + ], +) +def test_executable_report_uses_its_exact_name( + monkeypatch: pytest.MonkeyPatch, banner: str, accepted: bool +) -> None: + """An executable's native name cannot relax the complete release token.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: subprocess.CompletedProcess(argv, 0, banner, ""), + ) + if accepted: + assert versions._binary_version("/test/custom tmux") == "3.3a" + else: + with pytest.raises(exc.VersionTooLow): + support._binary_version("/test/custom tmux") From 67c0f2d470ec673c79be7ca4e1ced1d8b11f8964 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:37:02 -0500 Subject: [PATCH 12/36] Core(fix): Probe the daemon version hook-free why: The version probe ran display-message -p '#{version}', which fires a user's after-display-message hook on every release before the check can refuse anything. what: - Probe with -N run-shell -C '#{version}'. run-shell has no after-hook, and with -C the expanded string is parsed in a timer callback after the command returned, so no command-error fires. tmux exits 1 with `unknown command: ` on stderr and the version is read from that line; any other failure stays an error - Answer the mocked probes in test_versions.py in run-shell format - Add a native test that arms after-display-message and, from 3.5 where set-hook accepts it, command-error, then runs the probe, a prepared command, a dispatched command, a control display-message and a failing command; each sentinel holding exactly one letter shows no probe fired a hook The native test passes on 3.3a, 3.4, 3.5, 3.6b, 3.7d, 3.8 and master (next-3.9). Restoring display-message as the probe fails it on 3.3a, 3.5, 3.8 and master. test_versions, test_server, test_common and test_version pass on 3.7d (236 tests); ruff and mypy are clean. --- src/libtmux/_internal/support/versions.py | 18 ++++-- tests/test_versions.py | 73 +++++++++++++++++++---- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 6e462706d1..531e34755a 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -103,11 +103,19 @@ def check_result(stderr: str, marker: str | None) -> None: def live_version(binary: str, selectors: list[str]) -> str | None: """Check an existing daemon's version, returning None for an absent endpoint.""" - probe = _run([binary, *selectors, "-N", "display-message", "-p", "#{version}"]) - if probe.returncode == 0: - version = probe.stdout.strip() - require_version(version) - return version + # run-shell has no after-hook and, with -C, fails while parsing its own + # output, so the probe fires neither after-* nor command-error hooks. + marker = "libtmux-version-" + uuid.uuid4().hex + "-" + probe = _run([binary, *selectors, "-N", "run-shell", "-C", marker + "#{version}"]) + prefix = f"unknown command: {marker}" + reports = [ + line[len(prefix) :] + for line in probe.stderr.splitlines() + if line.startswith(prefix) + ] + if probe.returncode == 1 and len(reports) == 1: + require_version(reports[0]) + return reports[0] if "no server running" in probe.stderr or ( "error connecting to" in probe.stderr and any( diff --git a/tests/test_versions.py b/tests/test_versions.py index e8b4096ef0..4c2e588dbc 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -16,6 +16,17 @@ from libtmux.common import tmux_cmd +def _is_probe(argv: list[str]) -> bool: + return argv[-3:-1] == ["run-shell", "-C"] + + +def _reply(argv: list[str], version: str) -> subprocess.CompletedProcess[str]: + """Answer a version probe the way a live daemon does.""" + return subprocess.CompletedProcess( + argv, 1, "", f"unknown command: {argv[-1].replace('#{version}', version)}\n" + ) + + @pytest.mark.parametrize( "version", ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8"], @@ -72,7 +83,7 @@ def test_older_daemon_refused_before_dispatch( def probe(argv: list[str]) -> subprocess.CompletedProcess[str]: probes.append(argv) - return subprocess.CompletedProcess(argv, 0, "3.3\n", "") + return _reply(argv, "3.3") def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: pytest.fail("requested process started below the minimum") @@ -114,14 +125,52 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: else: server.cmd("new-session", "-d") assert len(probes) == 1 - assert probes[0][-3:] == ["display-message", "-p", "#{version}"] + assert probes[0][-4:-1] == ["-N", "run-shell", "-C"] + assert probes[0][-1].endswith("#{version}") + + +def test_version_check_fires_no_user_hooks(server: Server) -> None: + """The version probe has no side effects a user's hooks can observe.""" + from libtmux.test.retry import retry_until + + server.new_session("versioned", window_command="sleep 300") + version = server._get_version() + has_command_error = True + try: + versions.require_version(version, "3.5") + except exc.VersionTooLow: + has_command_error = False + server.cmd( + "set-hook", "-g", "after-display-message", "set-option -ag @probe-after x" + ) + if has_command_error: + server.cmd("set-hook", "-g", "command-error", "set-option -ag @probe-error y") + + binary, selectors = server._version_endpoint() + assert versions.live_version(binary, selectors) == version + versions.prepare([binary, *selectors, "list-sessions"]) + server.cmd("list-sessions") + + # Hooks run in order, so a control firing after the probes proves that no + # earlier probe left a mark: each sentinel holds exactly one letter. + server.cmd("display-message", "-p", "control") + if has_command_error: + assert server.cmd("kill-session", "-t", "=missing").returncode != 0 + + def settled() -> bool: + after = server.cmd("show-option", "-gqv", "@probe-after").stdout + error = server.cmd("show-option", "-gqv", "@probe-error").stdout + return after == ["x"] and (error == ["y"] or not has_command_error) + + assert retry_until(settled, seconds=5, raises=False) + assert server.cmd("show-option", "-gqv", "@probe-after").stdout == ["x"] + if has_command_error: + assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: """Keep native argv and batch separators after the version fence.""" - monkeypatch.setattr( - support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") - ) + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) original = [ "/tmux", "-Lodd;socket", @@ -150,10 +199,8 @@ def test_no_start_flag_distinguishes_option_values( monkeypatch: pytest.MonkeyPatch, option: str ) -> None: """A literal option value cannot disable daemon restart protection.""" - monkeypatch.setattr( - support, "_run", lambda argv: subprocess.CompletedProcess(argv, 0, "3.3a\n", "") - ) - guarded, _ = support.prepare(["/tmux", option, "-N", "new-session", "-d"]) + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) + guarded, _ = versions.prepare(["/tmux", option, "-N", "new-session", "-d"]) assert guarded[:4] == ["/tmux", option, "-N", "-N"] @@ -195,8 +242,8 @@ def test_native_prefix_refuses_a_changed_report( def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) - if result.returncode == 0 and argv[-1] == "#{version}": - return subprocess.CompletedProcess(argv, 0, "3.99\n", "") + if _is_probe(argv) and result.returncode == 1: + return _reply(argv, "3.99") return result with monkeypatch.context() as scoped: @@ -232,7 +279,7 @@ def dispatch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: monkeypatch.setattr(subprocess, "Popen", dispatch) Server(socket_name="absent", tmux_bin="/test/old-tmux").kill() assert len(calls) == 1 - assert calls[0][-1] == "#{version}" + assert _is_probe(calls[0]) def test_checked_daemon_disappearance_never_restarts( @@ -244,7 +291,7 @@ def test_checked_daemon_disappearance_never_restarts( def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) - if result.returncode == 0 and argv[-1] == "#{version}": + if _is_probe(argv) and result.returncode == 1: assert run([*argv[:-4], "kill-server"]).returncode == 0 return result From e2d5e6a4e57242991df1fbcae84e2124712ca6b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:37:02 -0500 Subject: [PATCH 13/36] Support(docs): Describe the hook-free probe why: The compatibility page still called the version query read-only, which says nothing about the user's hooks. what: - Say the check queries with run-shell -C, which fires no after-* or command-error hook - Name the one case where command-error fires: a replaced daemon failing the fence on tmux 3.5 or newer --- docs/project/compatibility.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index f34e1a0160..6c27891d02 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -21,8 +21,12 @@ A replacement daemon is checked again before dispatch. {meth}`~libtmux.Server.kill` returns without dispatching when its endpoint is absent. -Each command adds a read-only version query, bounded to five seconds. The check -does not cache daemon versions. Binary diagnostics through `tmux -V` and +Each command adds a version query, bounded to five seconds. The check does not +cache daemon versions. The query is `run-shell -C` with a marker format, which +fires neither `after-*` hooks nor `command-error`, so a user's hooks never run +for a version check. It exits 1 with the version in its error text. If a replaced +daemon fails the fence on tmux 3.5 or newer, `command-error` fires once for +that refusal, which reports a real error. Binary diagnostics through `tmux -V` and `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use {meth}`~libtmux.Server.run_shell` for a tmux command instead. From f5f36775393afb9856e04b85bf96ab61e1e9de03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 06:57:16 -0500 Subject: [PATCH 14/36] Core(fix): Parse versions as tmux reports them why: The check accepted 3.3a-master and 3.3a-dev at the floor while it refused next-3.3a, rejected the bare 3.8-rc that tmux itself reports, and let 03.3a through. tmux never reports those suffixes; library wrappers made them up. what: - Parse only what tmux has reported: X.Y with an optional patch letter, X.Y-rc and X.Y-rcN, and next-X.Y, with ASCII digits and no leading zeros - Order them next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya, so a candidate or development build passes only above the floor - Replace the cases with one table of supported, below-floor and unparseable vectors, plus the ordering chains and the 3.7 feature floor Widening the pattern to accept -master/-dev and to require rc digits fails seven cases, including 3.8-rc and 3.3a-master. Ruff and mypy are clean. --- docs/project/compatibility.md | 16 ++-- src/libtmux/_internal/support/versions.py | 60 ++++++++++---- tests/test_versions.py | 95 ++++++++++++++++++----- 3 files changed, 129 insertions(+), 42 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 6c27891d02..796c922e49 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -30,12 +30,16 @@ that refusal, which reports a real error. Binary diagnostics through `tmux -V` a `tmux -h` remain available. Raw `tmux -c` shell execution is refused; use {meth}`~libtmux.Server.run_shell` for a tmux command instead. -Numbered development reports are accepted as candidates when their release -base meets the baseline; a `next-` build precedes the release it names. These -candidates do not inherit stable feature qualification. Unknown reports, -including an undated `master`, are refused. On OpenBSD, an existing daemon -must report a qualified release; an executable without `-V` cannot start an -absent endpoint. +The version check reads the forms tmux reports: a release with an optional patch +letter (`3.3a`), a candidate (`3.8-rc`, `3.8-rc2`) and a development build +(`next-3.9`). They order as `next-3.9` < `3.9-rc` < `3.9-rc2` < `3.9` < `3.9a`, +and minor numbers compare numerically, so a candidate or development build is +supported only when its release number is above the baseline. Such builds do not +inherit stable feature qualification. Everything else is refused: an undated +`master`, a development suffix on a release (`3.3a-dev`), a patch letter on a +development build (`next-3.3a`), leading zeros and OpenBSD's own numbering. On +OpenBSD, an existing daemon must report a qualified release; an executable +without `-V` cannot start an absent endpoint. The synthetic values returned by legacy version helpers are diagnostic values and never authorize a command. diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 531e34755a..42a2ef9cad 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -12,28 +12,58 @@ from libtmux import exc MINIMUM = "3.3a" -_RELEASE = re.compile(r"(\d+)\.(\d+)([a-z]?)(?:-(master|dev|rc\d+))?") + +_NUMBER = r"(?:0|[1-9][0-9]*)" +_REPORT = re.compile( + rf"(?Pnext-)?(?P{_NUMBER})\.(?P{_NUMBER})" + r"(?:(?P[a-z])|-rc(?P[0-9]*))?", + re.ASCII, +) + + +def _rank(report: str) -> tuple[int, int, int, int, str] | None: + """Order the forms tmux reports, or return None for any other string. + + ``next-X.Y`` precedes ``X.Y-rc``, then ``X.Y-rcN``, then ``X.Y`` and its + patch letters. A bare ``-rc`` ranks as the first candidate. + """ + parsed = _REPORT.fullmatch(report) + if parsed is None: + return None + dev = parsed["dev"] is not None + letter = parsed["letter"] + candidate = parsed["candidate"] + if dev and (letter or candidate is not None): + return None + stage = 0 if dev else 1 if candidate is not None else 2 + return ( + int(parsed["major"]), + int(parsed["minor"]), + stage, + int(candidate or 1) if stage == 1 else 0, + letter or "", + ) def require_version(version: str, minimum: str = MINIMUM) -> None: - """Reject unknown or older releases, preserving numeric and patch order.""" - # Development lanes are explicit candidates, not stable feature guarantees. - candidate = version.removeprefix("next-") - parsed = _RELEASE.fullmatch(candidate) - floor = _RELEASE.fullmatch(minimum) - if parsed is not None and floor is not None: - running = (int(parsed[1]), int(parsed[2]), parsed[3]) - required = (int(floor[1]), int(floor[2]), floor[3]) - if running > required or ( - running == required - and not version.startswith("next-") - and not (parsed[4] or "").startswith("rc") - ): - return + """Reject unknown or older reports, preserving numeric and patch order.""" + running = _rank(version) + required = _rank(minimum) + if running is not None and required is not None and running >= required: + return msg = f"libtmux requires tmux {minimum} or newer; endpoint reports {version!r}" raise exc.VersionTooLow(msg) +def is_at_least(version: str, minimum: str) -> bool: + """Compare a checked version report with a feature's complete release floor.""" + try: + require_version(version, minimum=minimum) + except exc.VersionTooLow: + return False + return True + + def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: try: return subprocess.run( diff --git a/tests/test_versions.py b/tests/test_versions.py index 4c2e588dbc..2bb4c75c57 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools import pathlib import shutil import subprocess @@ -27,35 +28,87 @@ def _reply(argv: list[str], version: str) -> subprocess.CompletedProcess[str]: ) +SUPPORTED = [ + "3.3a", + "3.3b", + "3.4", + "3.7d", + "3.8", + "3.10", + "4.0", + "3.4-rc", + "3.8-rc", + "3.8-rc2", + "next-3.4", + "next-3.9", +] +BELOW_FLOOR = ["3.3", "3.2a", "3.3-rc", "3.2-rc5", "next-3.3"] +UNPARSEABLE = [ + "master", + "openbsd-7.6", + "", + "3", + "garbage", + "3.3a garbage", + "3.3a\n3.7", + "3.3a-master", + "3.3a-dev", + "next-3.3a", + "next-3.9-master", + "next-3.9a", + "3.3a-rc", + "3.3a-rc1", + "03.3a", + "3.03a", + "3.3A", + "3.3aa", + "3.3a ", + "\u0663.\u0663a", +] + + +@pytest.mark.parametrize("version", SUPPORTED) +def test_checked_release(version: str) -> None: + """Every form tmux reports at or above the floor is supported.""" + versions.require_version(version) + + +@pytest.mark.parametrize("version", [*BELOW_FLOOR, *UNPARSEABLE]) +def test_refused_release(version: str) -> None: + """Older and unparseable reports are never supported.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + versions.require_version(version) + + @pytest.mark.parametrize( - "version", - ["3.3a", "3.3b", "3.4", "3.10", "4.0", "3.3a-master", "next-3.8"], + "ascending", + [ + ["next-3.7", "3.7-rc", "3.7-rc3", "3.7", "3.7a"], + ["next-3.4", "3.4-rc", "3.4"], + ["3.9", "3.10"], + ["3.3", "3.3a", "3.3b"], + ], ) -def test_admitted_release(version: str) -> None: - """Keep patch letters and numeric minor ordering at the floor.""" - support.require_version(version) +def test_release_order(ascending: list[str]) -> None: + """Development builds precede candidates, which precede the release.""" + for lower, higher in itertools.pairwise(ascending): + assert versions.is_at_least(higher, lower) + assert not versions.is_at_least(lower, higher) @pytest.mark.parametrize( - "version", + ("version", "allowed"), [ - "3.2a", - "3.3", - "3.3rc1", - "3.3a-rc1", - "next-3.3", - "next-3.3a", - "", - "unknown", - "master", - "3.3alpha", - "3.3a\n3.7", + ("3.8-rc2", True), + ("next-3.8", True), + ("next-3.7", False), + ("3.7-rc2", False), + ("3.7", True), ], ) -def test_refused_release(version: str) -> None: - """Unknown and pre-floor reports cannot inherit a passed check.""" - with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): - support.require_version(version) +def test_candidates_rank_against_a_feature_floor(version: str, allowed: bool) -> None: + """A build below a release does not inherit that release's features.""" + assert versions.is_at_least(version, "3.7") is allowed @pytest.mark.parametrize( From 1ba526553a722cd288187d47ce81b3281d842cb0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:04:37 -0500 Subject: [PATCH 15/36] Core(fix): Pick optional flags by daemon release why: Pane, Window, Session and Server chose optional flags such as -E, -k and -C from the client executable's -V, so a current client talking to a 3.3a daemon sent flags the daemon rejects with a native error instead of taking the warn-and-ignore path. what: - Add Server._has_gte_version, which compares the connected daemon's release, and use it at every optional-flag gate - Read the daemon's release for exact-3.7 break-pane naming too - Patch that decision in the warn-on-old-tmux tests - Test a client that reports the newest release against a 3.3a daemon Making _has_gte_version consult the client again fails the new test. The server, pane, capture, window and session modules pass on 3.7d. --- src/libtmux/pane.py | 38 ++++++++++++++++---------------- src/libtmux/server.py | 39 ++++++++++++++++++--------------- src/libtmux/session.py | 4 ++-- src/libtmux/window.py | 4 ++-- tests/test_pane_capture_pane.py | 8 +++---- tests/test_server.py | 15 ++++--------- tests/test_versions.py | 12 ++++++++++ 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index eb8091f619..d1559bb8bd 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -15,7 +15,7 @@ from libtmux import exc from libtmux._internal.env import pane_id_from_env -from libtmux.common import get_version_str, has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -641,7 +641,7 @@ def capture_pane( if preserve_trailing: cmd.append("-N") if trim_trailing: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): cmd.append("-T") else: warnings.warn( @@ -653,7 +653,7 @@ def capture_pane( if quiet: cmd.append("-q") if mode_screen: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): cmd.append("-M") else: warnings.warn( @@ -663,7 +663,7 @@ def capture_pane( if pending: cmd.append("-P") if hyperlinks: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): cmd.append("-H") else: warnings.warn( @@ -671,7 +671,7 @@ def capture_pane( stacklevel=2, ) if line_numbers: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): cmd.append("-L") else: warnings.warn( @@ -679,7 +679,7 @@ def capture_pane( stacklevel=2, ) if line_flags: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): cmd.append("-F") else: warnings.warn( @@ -805,7 +805,7 @@ def send_keys( tmux_args += ("-H",) if key_name: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-K",) else: warnings.warn( @@ -820,7 +820,7 @@ def send_keys( tmux_args += ("-N", str(repeat)) if target_client is not None: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-c", target_client) else: warnings.warn( @@ -971,7 +971,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-l",) else: warnings.warn( @@ -983,7 +983,7 @@ def display_message( tmux_args += ("-N",) if update_pane: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): tmux_args += ("-C",) else: warnings.warn( @@ -1365,7 +1365,7 @@ def split( tmux_args += (f"-e{k}={v}",) if empty: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): tmux_args += ("-E",) else: warnings.warn( @@ -1380,7 +1380,7 @@ def split( "-m": message, } if keep or any(v is not None for v in styling.values()): - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): for flag, value in styling.items(): if value is not None: tmux_args += (flag, value) @@ -1515,7 +1515,7 @@ def new_pane( >>> is_floating '1' """ - if not has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if not self.server._has_gte_version("3.7"): msg = "new_pane (floating panes) requires tmux 3.7+" raise exc.LibTmuxException(msg) @@ -1801,7 +1801,7 @@ def display_popup( tmux_args += ("-B",) if close_on_any_key: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): tmux_args += ("-k",) else: warnings.warn( @@ -1810,7 +1810,7 @@ def display_popup( ) if no_keys: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.6"): tmux_args += ("-N",) else: warnings.warn( @@ -1880,7 +1880,7 @@ def paste_buffer( tmux_args += ("-s", separator) if no_vis: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): tmux_args += ("-S",) else: warnings.warn( @@ -1986,7 +1986,7 @@ def copy_mode( tmux_args += ("-M",) if page_down: - if has_gte_version("3.5", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.5"): tmux_args += ("-d",) else: warnings.warn( @@ -2433,7 +2433,7 @@ def break_pane( # given (NULL-deref); 3.7a reverted it. When needed, pass a placeholder # -n then set the real name via rename-window below. Compare the raw # version string to gate the workaround on the literal 3.7 release only. - breaks_without_name = get_version_str(tmux_bin=self.server.tmux_bin) == "3.7" + breaks_without_name = self.server._get_version() == "3.7" tmux_args: tuple[str, ...] = ("-P", "-F#{window_id}") @@ -2554,7 +2554,7 @@ def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: tmux_args: tuple[str, ...] = () if reset_hyperlinks: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-H",) else: warnings.warn( diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 20ca2484c3..4612ddb3ad 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -20,7 +20,7 @@ from libtmux._internal.query_list import QueryList from libtmux._internal.support import versions from libtmux.client import Client -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -345,6 +345,9 @@ def _version_endpoint(self) -> tuple[str, list[str]]: def _get_version(self) -> str: return support.endpoint_version(*self._version_endpoint()) + def _has_gte_version(self, minimum: str) -> bool: + return versions.is_at_least(self._get_version(), minimum) + # # Command # @@ -596,7 +599,7 @@ def run_shell( tmux_args += ("-t", target_pane) if cwd is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-c", str(cwd)) else: warnings.warn( @@ -605,7 +608,7 @@ def run_shell( ) if show_stderr: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.6"): tmux_args += ("-E",) else: warnings.warn( @@ -616,7 +619,7 @@ def run_shell( tmux_args += (command,) if args: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += tuple(args) else: warnings.warn( @@ -799,7 +802,7 @@ def list_keys( tmux_args += ("-T", key_table) if format_ is not None: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-F", format_) else: warnings.warn( @@ -957,7 +960,7 @@ def refresh_client( tmux_args += ("-t", target_client) if request_clipboard: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-l",) else: warnings.warn( @@ -1169,7 +1172,7 @@ def confirm_before( tmux_args += ("-p", prompt) if confirm_key is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-c", confirm_key) else: warnings.warn( @@ -1178,7 +1181,7 @@ def confirm_before( ) if default_yes: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-y",) else: warnings.warn( @@ -1293,7 +1296,7 @@ def command_prompt( tmux_args += ("-F",) if literal: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.6"): tmux_args += ("-l",) else: warnings.warn( @@ -1302,7 +1305,7 @@ def command_prompt( ) if bspace_exit: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-e",) else: warnings.warn( @@ -1311,7 +1314,7 @@ def command_prompt( ) if no_freeze: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.7"): tmux_args += ("-C",) else: warnings.warn( @@ -1427,7 +1430,7 @@ def display_menu( tmux_args += ("-y", str(y)) if starting_choice is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-C", str(starting_choice)) else: warnings.warn( @@ -1436,7 +1439,7 @@ def display_menu( ) if border_lines is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-b", border_lines) else: warnings.warn( @@ -1445,7 +1448,7 @@ def display_menu( ) if style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-s", style) else: warnings.warn( @@ -1454,7 +1457,7 @@ def display_menu( ) if border_style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-S", border_style) else: warnings.warn( @@ -1463,7 +1466,7 @@ def display_menu( ) if selected_style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-H", selected_style) else: warnings.warn( @@ -1472,7 +1475,7 @@ def display_menu( ) if mouse: - if has_gte_version("3.5", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.5"): tmux_args += ("-M",) else: warnings.warn( @@ -1698,7 +1701,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._has_gte_version("3.4"): tmux_args += ("-l",) else: warnings.warn( diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a37..38f6a587e0 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -14,7 +14,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, OptionScope, WindowDirection from libtmux.formats import FORMAT_SEPARATOR from libtmux.hooks import HooksMixin @@ -723,7 +723,7 @@ def kill( flags += ("-C",) if group: # Kill all sessions in this session's group (tmux 3.7+) - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.7"): flags += ("-g",) else: warnings.warn( diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 6cbec4df7e..b173f6e5e7 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -15,7 +15,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, OptionScope, @@ -1324,7 +1324,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self.server._has_gte_version("3.4"): tmux_args += ("-l",) else: warnings.warn( diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5111a06706..5f9e85e0a0 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -450,12 +450,10 @@ def test_capture_pane_trim_trailing_warning( """Test that trim_trailing issues a warning on tmux < 3.4.""" import warnings - from libtmux import pane as pane_module + from libtmux import Server - # has_gte_version is imported into libtmux.pane at module import time; - # monkeypatching the libtmux.common attribute would not propagate, so - # patch the pane module's binding directly. - monkeypatch.setattr(pane_module, "has_gte_version", lambda v, **kw: v != "3.4") + # Feature flags follow the daemon's release, so patch that decision. + monkeypatch.setattr(Server, "_has_gte_version", lambda _self, v: v != "3.4") pane = session.active_window.split(shell="sh") diff --git a/tests/test_server.py b/tests/test_server.py index e64eeb896f..92df5f18af 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1177,13 +1177,9 @@ def test_run_shell_cwd_warns_on_old_tmux( ) -> None: """``cwd=`` emits a warning and skips ``-c`` on tmux <3.4. - Simulates older tmux by patching ``has_gte_version`` in the - :mod:`libtmux.server` module namespace (where it's bound at - import time). + Simulates an older daemon by patching the daemon release decision. """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + monkeypatch.setattr(Server, "_has_gte_version", lambda *_a: False) server.new_session(session_name="run_shell_cwd_warn_test") with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): server.run_shell("true", cwd=tmp_path) @@ -1195,12 +1191,9 @@ def test_run_shell_show_stderr_warns_on_old_tmux( ) -> None: """``show_stderr=True`` emits a warning and skips ``-E`` on tmux <3.6. - Simulates older tmux by patching ``has_gte_version`` in the - :mod:`libtmux.server` module namespace. + Simulates an older daemon by patching the daemon release decision. """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + monkeypatch.setattr(Server, "_has_gte_version", lambda *_a: False) server.new_session(session_name="run_shell_stderr_warn_test") with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): server.run_shell("true", show_stderr=True) diff --git a/tests/test_versions.py b/tests/test_versions.py index 2bb4c75c57..04f9a42e73 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -221,6 +221,18 @@ def settled() -> bool: assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] +def test_feature_flags_follow_the_daemon_not_the_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A newer client executable cannot select flags an older daemon rejects.""" + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) + monkeypatch.setattr("libtmux.common.has_gte_version", lambda *args, **kwargs: True) + server = Server(tmux_bin="/test/newer-client") + assert server._has_gte_version("3.3a") + assert not server._has_gte_version("3.4") + assert not server._has_gte_version("3.7") + + def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: """Keep native argv and batch separators after the version fence.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) From 69d58160a828a5ea57fe5a5bdd6e6b41d4c29d4e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:08:52 -0500 Subject: [PATCH 16/36] Core(perf): Reuse each endpoint's checked release why: Every command spawned a version probe plus the command, and each optional-flag decision spawned another, so a lookup took two to four tmux processes. The in-band if-shell -F fence already detects a replaced daemon, so the release only needs probing again after the fence fires or the daemon disappears. what: - Give each Server a VersionCache holding the release last checked for its endpoint (binary, selectors, TMUX_TMPDIR and TMUX) - Read it in prepare() and Server._get_version(), and share it with list accessors through fetch_objs - Clear it when the fence refuses. When no daemon answers, clear it and check once more, which validates the executable for an absent endpoint exactly as the first call does and cannot have had an effect - Read the endpoint directly on the first call, for an absent endpoint and in Server.kill(); never cache an absent endpoint - Test one probe across three commands, a list accessor and a flag decision; a stale cached release refused with no effect, forgotten and re-read; a daemon killed from outside detected and a new session started after a fresh check; kill() probing afresh - Clear the cache in the existing disappearance and changed-report tests so they still exercise the probe tmux spawns per call, before and after, on 3.7d with config /dev/null: Server.cmd("list-sessions") 2 and 1, Server.sessions 3 and 1, and Pane.capture_pane with two optional flags 4 and 1. Wall time, best of two runs on a shared WSL2 host (it varies by 30 percent between runs; spawn counts do not): 100 list-sessions 3.02s and 1.94s, 100 Server.sessions 4.11s and 1.72s, 50 capture_pane 0.83s and 0.88s. Breaking each guard fails its tests: not clearing on a fence refusal, skipping the gone-daemon retry, and never storing the release. The version check module passes on 3.7d (83 tests). --- src/libtmux/_internal/support/versions.py | 110 +++++++++++++++++++--- src/libtmux/common.py | 41 +++++--- src/libtmux/neo.py | 1 + src/libtmux/server.py | 20 +++- tests/test_versions.py | 83 +++++++++++++++- 5 files changed, 222 insertions(+), 33 deletions(-) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 42a2ef9cad..5428636d68 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -7,6 +7,7 @@ import pathlib import re import subprocess +import threading import uuid from libtmux import exc @@ -82,7 +83,71 @@ def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: raise exc.LibTmuxException(msg) from error -def prepare(argv: list[str]) -> tuple[list[str], str | None]: +class VersionCache: + """The daemon release last checked for one endpoint. + + The in-band version fence catches a replaced daemon, so a cached release + only needs a new probe after the fence fires or the daemon disappears. + An absent endpoint is never cached. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._entry: tuple[tuple[str, tuple[str, ...], str, str], str] | None = None + + @staticmethod + def _key( + binary: str, selectors: list[str] + ) -> tuple[str, tuple[str, ...], str, str]: + # The socket directory follows the environment when no path is named. + env = os.environ + return ( + binary, + tuple(selectors), + env.get("TMUX_TMPDIR", ""), + env.get("TMUX", ""), + ) + + def get(self, binary: str, selectors: list[str]) -> str | None: + """Return the checked release for this endpoint, if one is cached.""" + with self._lock: + entry = self._entry + if entry is not None and entry[0] == self._key(binary, selectors): + return entry[1] + return None + + def put(self, binary: str, selectors: list[str], version: str) -> None: + """Remember a checked release.""" + with self._lock: + self._entry = (self._key(binary, selectors), version) + + def clear(self) -> None: + """Forget the checked release.""" + with self._lock: + self._entry = None + + def __bool__(self) -> bool: + return self._entry is not None + + +def _absent_endpoint(stderr: str) -> bool: + return "no server running" in stderr or ( + "error connecting to" in stderr + and any( + reason in stderr + for reason in ("No such file or directory", "Connection refused") + ) + ) + + +def daemon_gone(stderr: str) -> bool: + """Report that a command found no daemon to talk to.""" + return _absent_endpoint(stderr) + + +def prepare( + argv: list[str], *, cache: VersionCache | None = None +) -> tuple[list[str], str | None]: """Check the daemon's version, then fence dispatch against it.""" try: options, commands = getopt.getopt(argv[1:], "28c:CDdf:hlL:NqS:T:uUvV") @@ -103,7 +168,14 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: globals_.append(value) if flag in {"-L", "-S"}: selectors.extend((flag, value)) - version = live_version(argv[0], selectors) + version = cache.get(argv[0], selectors) if cache is not None else None + if version is None: + version = live_version(argv[0], selectors) + if cache is not None: + if version is None: + cache.clear() + else: + cache.put(argv[0], selectors, version) if version is None: version = _binary_version(argv[0]) elif not any(flag == "-N" for flag, _ in options): @@ -124,9 +196,13 @@ def prepare(argv: list[str]) -> tuple[list[str], str | None]: ], marker -def check_result(stderr: str, marker: str | None) -> None: +def check_result( + stderr: str, marker: str | None, *, cache: VersionCache | None = None +) -> None: """Reject a replaced daemon before returning an ordinary result.""" if marker is not None and f"unknown command: {marker}" in stderr.splitlines(): + if cache is not None: + cache.clear() msg = "tmux daemon changed before dispatch; retry the command" raise exc.VersionTooLow(msg) @@ -146,26 +222,30 @@ def live_version(binary: str, selectors: list[str]) -> str | None: if probe.returncode == 1 and len(reports) == 1: require_version(reports[0]) return reports[0] - if "no server running" in probe.stderr or ( - "error connecting to" in probe.stderr - and any( - reason in probe.stderr - for reason in ( - "No such file or directory", - "Connection refused", - ) - ) - ): + if _absent_endpoint(probe.stderr): return None msg = probe.stderr.strip() or "tmux did not report a daemon version" raise exc.LibTmuxException(msg) -def endpoint_version(binary: str, selectors: list[str]) -> str: - """Read a fresh daemon version, or the executable for an absent endpoint.""" +def endpoint_version( + binary: str, selectors: list[str], *, cache: VersionCache | None = None +) -> str: + """Read the daemon version, or the executable's for an absent endpoint. + + With a cache, a release checked earlier is returned without a probe. + """ + if cache is not None: + cached = cache.get(binary, selectors) + if cached is not None: + return cached version = live_version(binary, selectors) if version is not None: + if cache is not None: + cache.put(binary, selectors, version) return version + if cache is not None: + cache.clear() return _binary_version(binary) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 560e139103..2d5961d2b3 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -310,7 +310,12 @@ class tmux_cmd: Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: + def __init__( + self, + *args: t.Any, + tmux_bin: str | None = None, + _version_cache: versions.VersionCache | None = None, + ) -> None: resolved = tmux_bin or shutil.which("tmux") if not resolved: raise exc.TmuxCommandNotFound @@ -320,7 +325,8 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: cmd = [str(c) for c in cmd] self.cmd = cmd - dispatched, marker = support.prepare(cmd) + cached = _version_cache is not None and bool(_version_cache) + dispatched, marker = versions.prepare(cmd, cache=_version_cache) if logger.isEnabledFor(logging.DEBUG): cmd_str = shlex.join(cmd) @@ -330,16 +336,13 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) try: - self.process = subprocess.Popen( - dispatched, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="backslashreplace", - ) - stdout, stderr = self.process.communicate() - returncode = self.process.returncode + stdout, stderr, returncode = self._dispatch(dispatched) + if cached and versions.daemon_gone(stderr): + # The cached daemon vanished; nothing ran, so check its version again. + assert _version_cache is not None + _version_cache.clear() + dispatched, marker = versions.prepare(cmd, cache=_version_cache) + stdout, stderr, returncode = self._dispatch(dispatched) except FileNotFoundError: raise exc.TmuxCommandNotFound from None except Exception: @@ -351,7 +354,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) raise - support.check_result(stderr, marker) + versions.check_result(stderr, marker, cache=_version_cache) self.returncode = returncode stdout_split = stdout.split("\n") @@ -380,6 +383,18 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: }, ) + def _dispatch(self, argv: list[str]) -> tuple[str, str, int]: + self.process = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="backslashreplace", + ) + stdout, stderr = self.process.communicate() + return stdout, stderr, self.process.returncode + class _TmuxVersionUnavailable(Exception): """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 702a4c4238..eceaec1bd4 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1140,6 +1140,7 @@ def fetch_objs( proc = tmux_cmd( *tmux_cmds, tmux_bin=server.tmux_bin, + _version_cache=server._version_cache, ) raise_if_stderr(proc, list_cmd) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 4612ddb3ad..c8060dee9d 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -183,6 +183,7 @@ def __init__( ) -> None: EnvironmentMixin.__init__(self, "-g") self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self._version_cache = versions.VersionCache() self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] @@ -343,7 +344,9 @@ def _version_endpoint(self) -> tuple[str, list[str]]: return binary, selectors def _get_version(self) -> str: - return support.endpoint_version(*self._version_endpoint()) + return versions.endpoint_version( + *self._version_endpoint(), cache=self._version_cache + ) def _has_gte_version(self, minimum: str) -> bool: return versions.is_at_least(self._get_version(), minimum) @@ -422,7 +425,12 @@ def cmd( cmd_args = ["-t", str(target), *args] if target is not None else [*args] - return tmux_cmd(*svr_args, *cmd_args, tmux_bin=self.tmux_bin) + return tmux_cmd( + *svr_args, + *cmd_args, + tmux_bin=self.tmux_bin, + _version_cache=self._version_cache, + ) @property def attached_sessions(self) -> list[Session]: @@ -488,9 +496,15 @@ def kill(self) -> None: >>> svr.is_alive() False """ - if support.live_version(*self._version_endpoint()) is None: + # Liveness is read afresh; the kill then reuses the version that read checked. + self._version_cache.clear() + binary, selectors = self._version_endpoint() + version = versions.live_version(binary, selectors) + if version is None: return + self._version_cache.put(binary, selectors, version) proc = self.cmd("kill-server") + self._version_cache.clear() if proc.stderr: stderr_text = " ".join(str(line) for line in proc.stderr) if _is_daemon_not_up_error(stderr_text): diff --git a/tests/test_versions.py b/tests/test_versions.py index 04f9a42e73..d10f807c5e 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -303,7 +303,8 @@ def test_native_prefix_refuses_a_changed_report( ) -> None: """A native mismatch aborts every requested command in the same batch.""" server.new_session("versioned") - run = support._run + server._version_cache.clear() + run = versions._run def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) @@ -329,6 +330,83 @@ def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: assert server.cmd("show-option", "-gqv", "@version-second").stdout == [] +def _count_probes(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + probes: list[list[str]] = [] + run = versions._run + + def counted(argv: list[str]) -> subprocess.CompletedProcess[str]: + if _is_probe(argv): + probes.append(argv) + return run(argv) + + monkeypatch.setattr(versions, "_run", counted) + return probes + + +def test_checked_release_is_probed_once_per_endpoint( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """Later commands and flag decisions reuse the release the fence guards.""" + server.new_session("versioned", window_command="sleep 300") + server._version_cache.clear() + probes = _count_probes(monkeypatch) + for _ in range(3): + assert server.cmd("list-sessions").returncode == 0 + _ = server.sessions + assert server._has_gte_version("3.3a") + assert len(probes) == 1 + + +def test_stale_cached_release_is_refused_and_forgotten( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """A daemon that no longer matches the cached release fails the fence.""" + server.new_session("versioned", window_command="sleep 300") + server.cmd("list-sessions") + binary, selectors = server._version_endpoint() + server._version_cache.put(binary, selectors, "3.99") + with pytest.raises(exc.VersionTooLow, match="changed before dispatch"): + server.cmd("set-option", "-g", "@stale-effect", "ran") + assert not server._version_cache + probes = _count_probes(monkeypatch) + assert server.cmd("show-option", "-gqv", "@stale-effect").stdout == [] + assert server.cmd("list-sessions").returncode == 0 + assert len(probes) == 1 + + +def test_cached_release_survives_daemon_loss_without_a_stale_refusal( + server: Server, +) -> None: + """A vanished daemon is detected, and the next command checks again.""" + server.new_session("versioned", window_command="sleep 300") + server.cmd("list-sessions") + assert server._version_cache + binary, selectors = server._version_endpoint() + stop = subprocess.run( + [binary, "-N", *selectors, "kill-server"], capture_output=True, check=False + ) + assert stop.returncode == 0 + assert not server.is_alive() + assert not server._version_cache + created = server.new_session("returned", window_command="sleep 300") + assert created.session_name == "returned" + assert server.is_alive() + + +def test_kill_reads_liveness_afresh( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """Server.kill() probes even with a cached release, then forgets it.""" + server.new_session("versioned", window_command="sleep 300") + server.cmd("list-sessions") + assert server._version_cache + probes = _count_probes(monkeypatch) + server.kill() + assert len(probes) == 1 + assert not server._version_cache + assert not server.is_alive() + + def test_absent_cleanup_never_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: """An absent endpoint stays untouched even when its binary is too old.""" calls: list[list[str]] = [] @@ -352,7 +430,8 @@ def test_checked_daemon_disappearance_never_restarts( ) -> None: """Losing a checked daemon cannot start the configured executable.""" server.new_session("versioned", window_command="sleep 300") - run = support._run + server._version_cache.clear() + run = versions._run def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: result = run(argv) From addb94f12acf166d23807831bec7c0f40d1c4b1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:08:52 -0500 Subject: [PATCH 17/36] Support(docs): Describe per-endpoint version reuse why: The compatibility page said the version check never caches, which stopped being true once each Server reused its checked release. what: - Say a Server probes its endpoint once and reuses the release - Say the fence refuses a replaced daemon with no effect - Say kill() and an absent endpoint read the endpoint directly --- docs/project/compatibility.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 796c922e49..0e14e7e3ad 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -21,8 +21,12 @@ A replacement daemon is checked again before dispatch. {meth}`~libtmux.Server.kill` returns without dispatching when its endpoint is absent. -Each command adds a version query, bounded to five seconds. The check does not -cache daemon versions. The query is `run-shell -C` with a marker format, which +Each {class}`~libtmux.Server` probes its endpoint once, bounded to five +seconds, and reuses the checked release for later commands and optional-flag +decisions. The version fence in every command catches a replaced daemon: it +refuses with no effect and the next command probes again. A vanished daemon is +detected and the next command checks the version again; {meth}`~libtmux.Server.kill` and +an absent endpoint always read the endpoint directly. The query is `run-shell -C` with a marker format, which fires neither `after-*` hooks nor `command-error`, so a user's hooks never run for a version check. It exits 1 with the version in its error text. If a replaced daemon fails the fence on tmux 3.5 or newer, `command-error` fires once for From 54f0658d6c12d22cac32672ef8bd26ebc64659e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:10:00 -0500 Subject: [PATCH 18/36] Core(feat): Add a small version-check surface why: libtmux-mcp and tmuxp reuse the daemon-first version check for their own tmux subprocesses, and had to read private Server methods to do it. what: - Add libtmux._internal.support with MINIMUM, VersionTooLow, require_version, is_at_least, server_version(server) and check_fence, wrapping the existing helpers with no behaviour change - Test the exported names, an older daemon refused through server_version, a checked release returned, and a fence marker turned into VersionTooLow while an unrelated line is ignored - Run the module doctest --- src/libtmux/_internal/support/__init__.py | 72 +++++++++++++++++++++++ tests/test_versions.py | 22 +++++++ 2 files changed, 94 insertions(+) create mode 100644 src/libtmux/_internal/support/__init__.py diff --git a/src/libtmux/_internal/support/__init__.py b/src/libtmux/_internal/support/__init__.py new file mode 100644 index 0000000000..f6ee5cd1be --- /dev/null +++ b/src/libtmux/_internal/support/__init__.py @@ -0,0 +1,72 @@ +"""tmux version and feature support inside libtmux. + +Here "support" means which tmux releases and features libtmux works with, not +helper code. libtmux checks the connected daemon's version, not the client +executable's, before every command. A program built on libtmux that dispatches +its own tmux subprocesses can reuse that check: + +- :func:`server_version` reads the release a :class:`~libtmux.Server` talks to. +- :func:`require_version` and :func:`is_at_least` compare reports with a floor. +- :func:`check_fence` turns the replaced-daemon marker of a fenced batch into + :exc:`~libtmux.exc.VersionTooLow`. + +Examples +-------- +>>> from libtmux._internal import support +>>> support.MINIMUM +'3.3a' +>>> support.is_at_least("3.4-rc", "3.3a") +True +>>> support.is_at_least("3.3", "3.3a") +False +>>> support.is_at_least(support.server_version(server), support.MINIMUM) +True +""" + +from __future__ import annotations + +import typing as t + +from libtmux._internal.support.versions import ( + MINIMUM, + check_result, + is_at_least, + require_version, +) +from libtmux.exc import VersionTooLow + +if t.TYPE_CHECKING: + from libtmux.server import Server + +__all__ = ( + "MINIMUM", + "VersionTooLow", + "check_fence", + "is_at_least", + "require_version", + "server_version", +) + + +def server_version(server: Server) -> str: + """Return the tmux release *server* talks to, refusing an unsupported one. + + A live daemon answers with its own release; an absent endpoint answers + with the configured executable's. Either must meet :data:`MINIMUM`. + + Raises + ------ + :exc:`~libtmux.exc.VersionTooLow` + When the release is below the minimum or cannot be established. + """ + return server._get_version() + + +def check_fence(stderr: str, marker: str | None) -> None: + """Raise :exc:`~libtmux.exc.VersionTooLow` if a fenced batch was refused. + + A batch prefixed with ``if-shell -F '#{==:#{version},}' '' ;`` + reports ``unknown command: `` on stderr when the daemon is not the + release the caller checked, and none of its commands ran. + """ + check_result(stderr, marker) diff --git a/tests/test_versions.py b/tests/test_versions.py index d10f807c5e..bed3e69f7e 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -233,6 +233,28 @@ def test_feature_flags_follow_the_daemon_not_the_client( assert not server._has_gte_version("3.7") +def test_support_surface(monkeypatch: pytest.MonkeyPatch) -> None: + """Consumers reach the decision through libtmux._internal.support alone.""" + from libtmux._internal import support + + assert set(support.__all__) == { + "MINIMUM", + "VersionTooLow", + "server_version", + "check_fence", + "is_at_least", + "require_version", + } + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3")) + with pytest.raises(support.VersionTooLow, match=r"3\.3a"): + support.server_version(Server(tmux_bin="/test/tmux")) + monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.8")) + assert support.server_version(Server(tmux_bin="/test/tmux")) == "3.8" + with pytest.raises(support.VersionTooLow, match="changed before dispatch"): + support.check_fence("unknown command: private-marker\n", "private-marker") + support.check_fence("unknown command: other\n", "private-marker") + + def test_prefix_preserves_native_arguments(monkeypatch: pytest.MonkeyPatch) -> None: """Keep native argv and batch separators after the version fence.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) From 6f271176e4dbbe7e9c144aec13ed3e10f7a7d4fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:10:00 -0500 Subject: [PATCH 19/36] Docs(feat): Document the internal support package why: libtmux-mcp and tmuxp import libtmux._internal.support, so its names need a reference page beside the other internals. what: - Add an internals API page for libtmux._internal.support - Add its card to the internals index --- docs/internals/api/libtmux._internal.support.md | 17 +++++++++++++++++ docs/internals/index.md | 7 +++++++ 2 files changed, 24 insertions(+) create mode 100644 docs/internals/api/libtmux._internal.support.md diff --git a/docs/internals/api/libtmux._internal.support.md b/docs/internals/api/libtmux._internal.support.md new file mode 100644 index 0000000000..6e23e5a319 --- /dev/null +++ b/docs/internals/api/libtmux._internal.support.md @@ -0,0 +1,17 @@ +# Internal Support + +The {mod}`libtmux._internal.support` package holds the tmux version check that +every command runs against the connected daemon. + +:::{warning} +These APIs are private and are **not** covered by version policies. They can +break or be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._internal.support + :members: + +.. automodule:: libtmux._internal.support.versions + :members: +``` diff --git a/docs/internals/index.md b/docs/internals/index.md index c3748026a5..286ffe2d01 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -37,6 +37,12 @@ Internal format strings and tmux constants. Sparse array data structure for tmux format parsing. ::: +:::{grid-item-card} Support +:link: api/libtmux._internal.support +:link-type: doc +The tmux version check every command runs against the connected daemon. +::: + :::: ```{toctree} @@ -47,6 +53,7 @@ api/libtmux._internal.dataclasses api/libtmux._internal.query_list api/libtmux._internal.constants api/libtmux._internal.sparse_array +api/libtmux._internal.support ``` ## Environmental variables From e1d41e17262cc0565423e94513084828e731737f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:11:03 -0500 Subject: [PATCH 20/36] Tests(feat): Refuse real 3.2a and 3.3 daemons why: Refusal below the floor was covered only with a monkeypatched probe. what: - Start a real 3.2a or 3.3 daemon and drive it with a 3.3a client: a write, a new session, a raw tmux_cmd kill-server and a list accessor all raise VersionTooLow, and a sentinel option and the session list stay unchanged - Check that a cold 3.2a or 3.3 executable cannot create its socket, and that a real 3.3a daemon passes as the control - Skip unless the three LIBTMUX_TEST_*_TMUX variables name native builds, the convention libtmux-mcp and tmuxp use The seven native cases pass against 3.2a, 3.3 and 3.3a builds. Making require_version accept everything fails the four refusal cases. --- tests/test_versions.py | 120 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/tests/test_versions.py b/tests/test_versions.py index bed3e69f7e..c1e0a70060 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -3,9 +3,11 @@ from __future__ import annotations import itertools +import os import pathlib import shutil import subprocess +import tempfile import typing as t import pytest @@ -526,4 +528,120 @@ def test_executable_report_uses_its_exact_name( assert versions._binary_version("/test/custom tmux") == "3.3a" else: with pytest.raises(exc.VersionTooLow): - support._binary_version("/test/custom tmux") + versions._binary_version("/test/custom tmux") + + +def _native_binary(variable: str, expected: str) -> str: + value = os.environ.get(variable) + if value is None: + pytest.skip(f"set {variable} to native tmux {expected}") + report = subprocess.run([value, "-V"], capture_output=True, timeout=5, check=True) + assert report.stdout == f"tmux {expected}\n".encode() + return value + + +def _native( + binary: str, socket: pathlib.Path, *argv: str +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [binary, "-N", "-S", str(socket), *argv], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + + +@pytest.fixture +def rejection_socket() -> t.Iterator[pathlib.Path]: + """Return a short private socket path for a real older daemon.""" + folder = tempfile.mkdtemp(prefix="lt-") + try: + yield pathlib.Path(folder) / "s" + finally: + shutil.rmtree(folder, ignore_errors=True) + + +REJECTED_RELEASES = [ + ("LIBTMUX_TEST_UNSUPPORTED_TMUX", "3.2a"), + ("LIBTMUX_TEST_UNPATCHED_TMUX", "3.3"), +] + + +@pytest.mark.parametrize(("variable", "release"), REJECTED_RELEASES) +def test_native_older_daemon_refuses_every_route_without_effects( + rejection_socket: pathlib.Path, variable: str, release: str +) -> None: + """A supported client refuses a real 3.2a or 3.3 daemon.""" + supported = _native_binary("LIBTMUX_TEST_MINIMUM_TMUX", "3.3a") + older = _native_binary(variable, release) + started = subprocess.run( + [ + older, + "-f", + "/dev/null", + "-S", + str(rejection_socket), + "new-session", + "-d", + "-s", + "kept", + "exec sleep 120", + ], + capture_output=True, + timeout=5, + check=False, + ) + assert started.returncode == 0, started.stderr + server = Server(tmux_bin=supported, socket_path=str(rejection_socket)) + try: + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + server.cmd("set-option", "-g", "@forbidden", "changed") + with pytest.raises(exc.VersionTooLow): + server.new_session("forbidden", window_command="exec sleep 120") + with pytest.raises(exc.VersionTooLow): + tmux_cmd( + "-S", + str(rejection_socket), + "kill-server", + tmux_bin=supported, + ) + with pytest.raises(exc.VersionTooLow): + _ = server.sessions + assert ( + _native( + older, rejection_socket, "show-options", "-gqv", "@forbidden" + ).stdout + == "" + ) + names = _native(older, rejection_socket, "list-sessions", "-F", "#S") + assert names.stdout.split() == ["kept"] + finally: + _native(older, rejection_socket, "kill-server") + + +@pytest.mark.parametrize(("variable", "release"), REJECTED_RELEASES) +def test_native_older_executable_does_not_start_an_endpoint( + rejection_socket: pathlib.Path, variable: str, release: str +) -> None: + """Cold creation validates the executable before new-session can run.""" + older = _native_binary(variable, release) + server = Server(tmux_bin=older, socket_path=str(rejection_socket)) + with pytest.raises(exc.VersionTooLow): + server.new_session("forbidden", window_command="exec sleep 120") + assert not rejection_socket.exists() + + +def test_native_supported_daemon_passes(rejection_socket: pathlib.Path) -> None: + """Control: the same client accepts and serves a real 3.3a daemon.""" + supported = _native_binary("LIBTMUX_TEST_MINIMUM_TMUX", "3.3a") + server = Server( + tmux_bin=supported, socket_path=str(rejection_socket), config_file="/dev/null" + ) + try: + session = server.new_session("supported", window_command="exec sleep 120") + assert server._get_version() == "3.3a" + assert [item.session_name for item in server.sessions] == ["supported"] + assert session.session_id is not None + finally: + server.kill() From 52aa7963d615428dd9e77b3190d1ce7285586a43 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:11:03 -0500 Subject: [PATCH 21/36] CI(test): Run the native rejection lanes why: The native refusal tests skip unless real 3.2a, 3.3 and 3.3a builds are available, so CI needs a job that provides them. what: - Add an unsupported-versions job that builds tmux 3.2a, 3.3 and 3.3a from source, exports the three native paths and runs the version check module - Leave the supported matrix starting at 3.3a --- .github/workflows/tests.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 71562ac4ce..843b65a6a3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -90,6 +90,44 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} + unsupported-versions: + # Real 3.2a and 3.3 daemons must be refused with no effects. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.2.0 + with: + enable-cache: true + + - name: Set up Python 3.14 + run: uv python install 3.14 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Build supported and rejected native endpoints + run: | + sudo apt-get update + sudo apt-get install -y libevent-dev libncurses-dev bison + mkdir -p "$RUNNER_TEMP/tmux-versions" + for version in 3.2a 3.3 3.3a; do + git clone --quiet --depth=1 --branch "$version" https://github.com/tmux/tmux.git "$RUNNER_TEMP/tmux-versions/$version" + cd "$RUNNER_TEMP/tmux-versions/$version" + sh autogen.sh + ./configure --prefix="$RUNNER_TEMP/tmux-versions/$version/install" + make -j2 + make install + done + echo "LIBTMUX_TEST_MINIMUM_TMUX=$RUNNER_TEMP/tmux-versions/3.3a/install/bin/tmux" >> "$GITHUB_ENV" + echo "LIBTMUX_TEST_UNSUPPORTED_TMUX=$RUNNER_TEMP/tmux-versions/3.2a/install/bin/tmux" >> "$GITHUB_ENV" + echo "LIBTMUX_TEST_UNPATCHED_TMUX=$RUNNER_TEMP/tmux-versions/3.3/install/bin/tmux" >> "$GITHUB_ENV" + + - name: Refuse older daemons + run: uv run pytest tests/test_versions.py --reruns=0 -q + release: runs-on: ubuntu-latest needs: build From db9bae6d710228094509d7dc234ff4f1809768a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 10:50:23 -0500 Subject: [PATCH 22/36] CI(test): Add the tmux 3.8 lane why: tmux 3.8 is a released tag, so the matrix should cover it as well as master. what: - Add 3.8 to the tmux matrix as a gating lane; only master stays continue-on-error Focused runs of the version check, control and optional-feature modules pass locally against a 3.8 build. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 843b65a6a3..1a1b271cdf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: python-version: ['3.15'] - tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', 'master'] + tmux-version: ['3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', '3.8', 'master'] steps: - uses: actions/checkout@v7 From 6fc4222014bcf76da46a67f88717a7a2cd77efb5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:42:32 -0500 Subject: [PATCH 23/36] Hooks(feat): Track the tmux 3.8 hook table why: tmux 3.8 adds after-swap-window, client-closed, client-created, marked-pane-changed, session-added-to-group and session-removed-from-group, and drops after-queue. Hooks.from_stdout raised TypeError on the first hook its dataclass lacked, so show-hooks on 3.8 broke parsing. what: - Add the six 3.8 hooks as Hooks fields - Skip hooks the table does not know instead of failing the parse - Cover the 3.8 hooks in the set/show/unset grid, gated on 3.8 - Gate the after-queue case to tmux up to 3.7 --- src/libtmux/_internal/constants.py | 41 +++++++++++++++++++++++++++--- tests/test_hooks.py | 26 ++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/libtmux/_internal/constants.py b/src/libtmux/_internal/constants.py index df4d9843f2..8b9540f8f6 100644 --- a/src/libtmux/_internal/constants.py +++ b/src/libtmux/_internal/constants.py @@ -4,7 +4,7 @@ import io import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from libtmux._internal.dataclasses import SkipDefaultFieldsReprMixin from libtmux._internal.sparse_array import SparseArray, is_sparse_array_list @@ -793,7 +793,7 @@ class Hooks( after_pipe_pane : SparseArray[str] Runs after 'pipe-pane' completes. after_queue : SparseArray[str] - Runs after 'queue' command is processed. + Runs after 'queue' command is processed (removed in tmux 3.8). after_refresh_client : SparseArray[str] Runs after 'refresh-client' completes. after_rename_session : SparseArray[str] @@ -834,6 +834,18 @@ class Hooks( Runs after 'unbind-key' completes. command_error : SparseArray[str] Runs when a command fails (tmux 3.5+). + after_swap_window : SparseArray[str] + Runs after 'swap-window' completes (tmux 3.8+). + client_closed : SparseArray[str] + Run when a client is closed (tmux 3.8+). + client_created : SparseArray[str] + Run when a client is created (tmux 3.8+). + marked_pane_changed : SparseArray[str] + Run when the marked pane changes (tmux 3.8+). + session_added_to_group : SparseArray[str] + Run when a session is added to a session group (tmux 3.8+). + session_removed_from_group : SparseArray[str] + Run when a session is removed from a session group (tmux 3.8+). Examples -------- @@ -1050,7 +1062,7 @@ class Hooks( after_paste_buffer: SparseArray[str] = field(default_factory=SparseArray) # Runs after 'pipe-pane' completes after_pipe_pane: SparseArray[str] = field(default_factory=SparseArray) - # Runs after 'queue' command is processed + # Runs after 'queue' command is processed (removed in tmux 3.8) after_queue: SparseArray[str] = field(default_factory=SparseArray) # Runs after 'refresh-client' completes after_refresh_client: SparseArray[str] = field(default_factory=SparseArray) @@ -1092,6 +1104,18 @@ class Hooks( after_unbind_key: SparseArray[str] = field(default_factory=SparseArray) # Runs when a command fails (tmux 3.5+) command_error: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'swap-window' completes (tmux 3.8+). + after_swap_window: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is closed (tmux 3.8+). + client_closed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is created (tmux 3.8+). + client_created: SparseArray[str] = field(default_factory=SparseArray) + # Run when the marked pane changes (tmux 3.8+). + marked_pane_changed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session is added to a session group (tmux 3.8+). + session_added_to_group: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session is removed from a session group (tmux 3.8+). + session_removed_from_group: SparseArray[str] = field(default_factory=SparseArray) @classmethod def from_stdout(cls, value: list[str]) -> Hooks: @@ -1135,6 +1159,12 @@ def from_stdout(cls, value: list[str]) -> Hooks: >>> sorted(hooks.after_select_window.keys()) [0, 10] + A hook this table does not know is ignored: + + >>> hooks_unknown = Hooks.from_stdout(["future-hook[0] refresh-client"]) + >>> hooks_unknown == Hooks() + True + Empty input returns empty SparseArrays: >>> hooks_empty = Hooks.from_stdout([]) @@ -1164,4 +1194,7 @@ def from_stdout(cls, value: list[str]) -> Hooks: k.lstrip("%").replace("-", "_"): v for k, v in output_exploded.items() } - return cls(**output_renamed) + # A newer tmux can list hooks this table predates; skip them rather + # than fail the whole parse. + known = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in output_renamed.items() if k in known}) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 4e03f0a26b..5bb062c393 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -8,7 +8,7 @@ from libtmux._internal.constants import Hooks from libtmux._internal.sparse_array import SparseArray -from libtmux.common import has_gte_version +from libtmux.common import has_gte_version, has_lte_version if t.TYPE_CHECKING: from libtmux.server import Server @@ -314,6 +314,7 @@ class HookTestCase(t.NamedTuple): test_id: str hook: str # tmux hook name (hyphenated) min_version: str = "3.0" # Minimum tmux version required + max_version: str | None = None # Last tmux version that has the hook xfail_reason: str | None = None # Mark as expected failure with reason @@ -381,7 +382,8 @@ class HookTestCase(t.NamedTuple): HookTestCase("after_new_window", "after-new-window"), HookTestCase("after_paste_buffer", "after-paste-buffer"), HookTestCase("after_pipe_pane", "after-pipe-pane"), - HookTestCase("after_queue", "after-queue"), + # tmux 3.8 dropped after-queue from its hook table. + HookTestCase("after_queue", "after-queue", max_version="3.7"), HookTestCase("after_refresh_client", "after-refresh-client"), HookTestCase("after_rename_session", "after-rename-session"), HookTestCase("after_rename_window", "after-rename-window"), @@ -400,9 +402,19 @@ class HookTestCase(t.NamedTuple): HookTestCase("after_show_messages", "after-show-messages"), HookTestCase("after_show_options", "after-show-options"), HookTestCase("after_split_window", "after-split-window"), + HookTestCase("after_swap_window", "after-swap-window", "3.8"), HookTestCase("after_unbind_key", "after-unbind-key"), ] +# --- Hooks added in tmux 3.8 --- +TMUX_38_HOOKS: list[HookTestCase] = [ + HookTestCase("client_closed", "client-closed", "3.8"), + HookTestCase("client_created", "client-created", "3.8"), + HookTestCase("marked_pane_changed", "marked-pane-changed", "3.8"), + HookTestCase("session_added_to_group", "session-added-to-group", "3.8"), + HookTestCase("session_removed_from_group", "session-removed-from-group", "3.8"), +] + # --- New Hooks (tmux 3.5+) --- NEW_HOOKS: list[HookTestCase] = [ HookTestCase( @@ -416,7 +428,13 @@ class HookTestCase(t.NamedTuple): # Combine all hook test cases ALL_HOOK_TEST_CASES: list[HookTestCase] = ( - ALERT_HOOKS + CLIENT_HOOKS + SESSION_HOOKS + WINDOW_HOOKS + PANE_HOOKS + AFTER_HOOKS + ALERT_HOOKS + + CLIENT_HOOKS + + SESSION_HOOKS + + WINDOW_HOOKS + + PANE_HOOKS + + AFTER_HOOKS + + TMUX_38_HOOKS ) @@ -442,6 +460,8 @@ def test_hook_set_show_unset_cycle(server: Server, test_case: HookTestCase) -> N """ if not has_gte_version(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") + if test_case.max_version and not has_lte_version(test_case.max_version): + pytest.skip(f"Removed after tmux {test_case.max_version}") session = server.new_session(session_name="test_hook_cycle") window = session.active_window From c747540c71e13a51b91175856a9471c8d7d97be3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:42:43 -0500 Subject: [PATCH 24/36] Pane(fix[new_pane]): Expect 3.8 border footprint why: From 3.8, new-pane -x/-y/-X/-Y give the on-screen footprint including the default border (regress/floating-pane-geometry.sh), so a pane asked for 80 columns reads pane_width 78. The test and the floating_panes doctest assumed content-box sizing and failed on 3.8. what: - Document the border offset on Pane.new_pane and Window.new_pane - Expect width/height minus 2 and x/y plus 1 on 3.8+, in the test and doctest --- docs/topics/floating_panes.md | 12 ++++++++++-- src/libtmux/pane.py | 14 ++++++++++---- src/libtmux/window.py | 11 +++++++---- tests/test_pane.py | 8 ++++++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 7aa96a6c3d..01f1c90871 100644 --- a/docs/topics/floating_panes.md +++ b/docs/topics/floating_panes.md @@ -59,10 +59,18 @@ window (tmux's `-X` / `-Y`). tmux reports the placement back through the ... position = (placed.pane_x, placed.pane_y) ... else: ... position = ("2", "1") ->>> position -('2', '1') +>>> border = 1 if has_gte_version("3.8") else 0 +>>> position == (str(2 + border), str(1 + border)) +True ``` +From tmux 3.8, `width`, `height`, `x` and `y` give the pane's footprint on screen +including its border, which is drawn by default. A pane created with +`width=20, height=5, x=2, y=1` then reports `pane_width` 18, `pane_height` 3, +and `pane_x` / `pane_y` of 3 and 2: the border sits on the requested column and +row, and the content one cell inside. Earlier versions size and place the +content directly. + ## Styling For the rarer cases where appearance matters, you can style a floating pane with diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d1559bb8bd..20e491833f 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1470,13 +1470,19 @@ def new_pane( environment : dict, optional Environment variables for the new pane (``-e`` flag). width : int, optional - Width of the floating pane in cells (``-x`` flag). + Width of the floating pane in cells (``-x`` flag). On tmux 3.8+ + a bordered pane (the default) counts its border, so ``pane_width`` + reads 2 less than requested. height : int, optional - Height of the floating pane in cells (``-y`` flag). + Height of the floating pane in cells (``-y`` flag). Counts the + border on tmux 3.8+, like *width*. x : int, optional - X position of the floating pane in cells (``-X`` flag). + X position of the floating pane in cells (``-X`` flag). On tmux + 3.8+ the border lands on this column, so ``pane_left`` reads 1 + more. y : int, optional - Y position of the floating pane in cells (``-Y`` flag). + Y position of the floating pane in cells (``-Y`` flag). On tmux + 3.8+ the border lands on this row, so ``pane_top`` reads 1 more. zoom : bool, optional Zoom the pane (``-Z`` flag). empty : bool, optional diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b173f6e5e7..d3c00bccba 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -656,13 +656,16 @@ def new_pane( environment : dict, optional Environment variables for the new pane (``-e``). width : int, optional - Width in cells (``-x``). + Width in cells (``-x``). On tmux 3.8+ a bordered pane (the + default) counts its border, so ``pane_width`` reads 2 less. height : int, optional - Height in cells (``-y``). + Height in cells (``-y``). Counts the border on tmux 3.8+. x : int, optional - X position in cells (``-X``). + X position in cells (``-X``). On tmux 3.8+ the border lands on + this column, so ``pane_left`` reads 1 more. y : int, optional - Y position in cells (``-Y``). + Y position in cells (``-Y``). On tmux 3.8+ the border lands on + this row, so ``pane_top`` reads 1 more. zoom : bool, optional Zoom the pane (``-Z``). empty : bool, optional diff --git a/tests/test_pane.py b/tests/test_pane.py index 11d410f6d9..484cbd1c81 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1818,8 +1818,12 @@ def test_new_pane_floating(session: Session) -> None: if has_gte_version("3.7"): floating = pane.new_pane(width=80, height=15, x=5, y=3, shell="sleep 30") assert floating.pane_floating_flag == "1" - assert floating.pane_width == "80" - assert floating.pane_height == "15" + # tmux 3.8 counts the border in -x/-y/-X/-Y, so the content is smaller. + border = 2 if has_gte_version("3.8") else 0 + assert floating.pane_width == str(80 - border) + assert floating.pane_height == str(15 - border) + assert floating.pane_x == str(5 + border // 2) + assert floating.pane_y == str(3 + border // 2) else: with pytest.raises(exc.LibTmuxException, match=r"new_pane .*requires tmux 3.7"): pane.new_pane(width=40, height=10) From f302319cd1e572ab3677e98f2ea2e0f88de4540f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:42:43 -0500 Subject: [PATCH 25/36] tests(fix[popup]): Attach popup clients on a pty why: tmux 3.8 display-popup returns early for control-mode clients (cmd-display-menu.c: CLIENT_CONTROL check), so the command never ran and the popup tests timed out waiting on their marker files. what: - Add a pty_client fixture that attaches a terminal-backed client - Use it in the display_popup flag, close_on_success and target_client tests - Note the 3.8 control-client behaviour on Pane.display_popup --- src/libtmux/pane.py | 7 +++-- tests/conftest.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 30 ++++++++++---------- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 20e491833f..0554081e67 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1690,9 +1690,10 @@ def display_popup( ) -> None: """Display a popup overlay via ``$ tmux display-popup``. - Requires tmux 3.2+ and an attached client. Use - :class:`~libtmux._internal.control_mode.ControlMode` in tests to provide - a client. + Requires tmux 3.2+ and an attached client. tmux 3.8+ ignores the + command for control-mode clients (such as + :class:`~libtmux._internal.control_mode.ControlMode`), so a test needs a + client attached on a terminal. Parameters ---------- diff --git a/tests/conftest.py b/tests/conftest.py index c0015e4e65..f250744c5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,23 @@ from __future__ import annotations +import contextlib +import fcntl +import os +import pty +import struct +import subprocess +import termios +import typing as t + import pytest from libtmux.common import get_version, get_version_str +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session @pytest.fixture(autouse=True) @@ -20,3 +34,58 @@ def _clear_get_version_cache() -> None: """ get_version.cache_clear() get_version_str.cache_clear() + + +@pytest.fixture +def pty_client( + server: Server, + session: Session, +) -> t.Iterator[t.Callable[[], str]]: + """Attach a client on a pseudo-terminal and return its name. + + ``display-popup`` draws on a client's terminal. tmux 3.8 turned it into a + no-op for control-mode clients, so popup tests need a terminal-backed one. + """ + opened: list[tuple[subprocess.Popen[bytes], int, int]] = [] + + def attach() -> str: + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + tmux_bin = server.tmux_bin or "tmux" + if server.socket_name is not None: + socket_args = ["-L", str(server.socket_name)] + elif server.socket_path is not None: + socket_args = ["-S", str(server.socket_path)] + else: + socket_args = [] + proc = subprocess.Popen( + [tmux_bin, *socket_args, "attach-session", "-t", str(session.session_id)], + stdin=slave, + stdout=slave, + stderr=slave, + env={**os.environ, "TERM": "xterm-256color"}, + ) + opened.append((proc, master, slave)) + found: list[str] = [] + + def registered() -> bool: + result = server.cmd("list-clients", "-F", "#{client_pid}\t#{client_name}") + for line in result.stdout: + pid, _, name = line.partition("\t") + if pid == str(proc.pid) and name: + found.append(name.strip()) + return True + return False + + retry_until(registered, 3, raises=True) + return found[0] + + yield attach + + for proc, master, slave in opened: + proc.kill() + proc.wait() + with contextlib.suppress(OSError): + os.close(master) + with contextlib.suppress(OSError): + os.close(slave) diff --git a/tests/test_pane.py b/tests/test_pane.py index 484cbd1c81..c1280317ff 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1091,7 +1091,7 @@ def test_display_popup_flags( test_id: str, kwargs: dict[str, t.Any], min_tmux_version: str | None, - control_mode: t.Callable[..., t.Any], + pty_client: t.Callable[[], str], session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1110,14 +1110,14 @@ def test_display_popup_flags( call_kwargs = {"command": f"touch {marker}", "close_on_exit": True, **kwargs} - with control_mode(): - pane.display_popup(**call_kwargs) + pty_client() + pane.display_popup(**call_kwargs) retry_until(lambda: marker.exists(), 3, raises=True) def test_display_popup_close_on_success( - control_mode: t.Callable[..., t.Any], + pty_client: t.Callable[[], str], session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1126,8 +1126,8 @@ def test_display_popup_close_on_success( pane = session.active_window.active_pane assert pane is not None - with control_mode(): - pane.display_popup(command=f"touch {marker}", close_on_success=True) + pty_client() + pane.display_popup(command=f"touch {marker}", close_on_success=True) retry_until(lambda: marker.exists(), 3, raises=True) @@ -1157,26 +1157,26 @@ def test_display_popup_close_existing( def test_display_popup_target_client( - control_mode: t.Callable[..., t.Any], + pty_client: t.Callable[[], str], session: Session, tmp_path: pathlib.Path, ) -> None: """Test Pane.display_popup(target_client=...) emits ``-c ``. ``-c`` has been on ``display-popup`` since tmux 3.2a, so no version - guard is needed. The popup itself is invisible without a TTY-backed - client; this is a smoke test for the flag-passing path. + guard is needed. The client is attached on a pseudo-terminal because + tmux 3.8 ignores ``display-popup`` for control-mode clients. """ pane = session.active_window.active_pane assert pane is not None marker = tmp_path / "popup_target_client.marker" - with control_mode() as ctl: - pane.display_popup( - command=f"touch {marker}", - close_on_exit=True, - target_client=ctl.client_name, - ) + client_name = pty_client() + pane.display_popup( + command=f"touch {marker}", + close_on_exit=True, + target_client=client_name, + ) retry_until(lambda: marker.exists(), 3, raises=True) From 2c14822feae0465bd28d880458ef2d810c1600e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:51:08 -0500 Subject: [PATCH 26/36] Server(fix): Pickle a Server without its cache why: The per-Server version cache holds a threading.Lock, so copy.deepcopy(Server(...)) and pickle.dumps(Server(...)) raised "cannot pickle '_thread.lock' object". Both work on master. what: - Give VersionCache __getstate__/__setstate__ that drop the cached release and create a fresh lock, so a copy checks its daemon again rather than trusting a release checked by another object - Test deepcopy and pickle round trips keep the endpoint and start without a checked release Removing __getstate__ makes the round-trip test fail with the original TypeError. --- src/libtmux/_internal/support/versions.py | 10 ++++++++++ tests/test_versions.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py index 5428636d68..a42f84c721 100644 --- a/src/libtmux/_internal/support/versions.py +++ b/src/libtmux/_internal/support/versions.py @@ -8,6 +8,7 @@ import re import subprocess import threading +import typing as t import uuid from libtmux import exc @@ -129,6 +130,15 @@ def clear(self) -> None: def __bool__(self) -> bool: return self._entry is not None + def __getstate__(self) -> dict[str, t.Any]: + # A pickled or copied Server must check its daemon again: the lock + # cannot be copied, and a release checked elsewhere proves nothing. + return {} + + def __setstate__(self, state: dict[str, t.Any]) -> None: + self._lock = threading.Lock() + self._entry = None + def _absent_endpoint(stderr: str) -> bool: return "no server running" in stderr or ( diff --git a/tests/test_versions.py b/tests/test_versions.py index c1e0a70060..24ec0ca5b0 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,9 +2,11 @@ from __future__ import annotations +import copy import itertools import os import pathlib +import pickle import shutil import subprocess import tempfile @@ -645,3 +647,16 @@ def test_native_supported_daemon_passes(rejection_socket: pathlib.Path) -> None: assert session.session_id is not None finally: server.kill() + + +def test_server_pickles_and_copies_without_its_version_cache() -> None: + """A copied or unpickled Server checks its daemon again.""" + server = Server(tmux_bin="/test/tmux", socket_name="copied") + server._version_cache.put(*server._version_endpoint(), "3.8") + clones: list[Server] = [copy.deepcopy(server), pickle.loads(pickle.dumps(server))] + for clone in clones: + assert clone.socket_name == "copied" + assert not clone._version_cache + clone._version_cache.put(*clone._version_endpoint(), "3.4") + assert clone._version_cache.get(*clone._version_endpoint()) == "3.4" + assert server._version_cache.get(*server._version_endpoint()) == "3.8" From 28a4e7781d66f484b0480d0041c24cfa145d56c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:51:08 -0500 Subject: [PATCH 27/36] Support(fix): Read the daemon in server_version why: server_version(server) returned the release the Server had cached, so after a 3.3a daemon was replaced by 3.2a on the same socket it still answered "3.3a". check_fence had no way to tell a Server that its cached version was stale, so a consumer dispatching its own fenced subprocess could not invalidate it either. what: - Clear the Server's cached version before server_version probes - Accept an optional server in check_fence and clear its cached version when the fence fires, before re-raising VersionTooLow - Test a real 3.3a daemon replaced by 3.2a: server_version refuses it and leaves nothing cached - Test that a fired fence clears the cache and a foreign marker does not - Import libtmux._internal.support at module level in the tests Each guard has a deliberate break that fails its test: server_version reading the cache, and check_fence keeping it. --- src/libtmux/_internal/support/__init__.py | 20 ++++++--- tests/test_versions.py | 54 ++++++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/libtmux/_internal/support/__init__.py b/src/libtmux/_internal/support/__init__.py index f6ee5cd1be..677b93d8e2 100644 --- a/src/libtmux/_internal/support/__init__.py +++ b/src/libtmux/_internal/support/__init__.py @@ -51,22 +51,32 @@ def server_version(server: Server) -> str: """Return the tmux release *server* talks to, refusing an unsupported one. - A live daemon answers with its own release; an absent endpoint answers - with the configured executable's. Either must meet :data:`MINIMUM`. + The release is read from the endpoint on every call, never from what + *server* checked earlier, so a daemon replaced since then is reported as + it is now. A live daemon answers with its own release; an absent endpoint + answers with the configured executable's. Either must meet + :data:`MINIMUM`. Raises ------ :exc:`~libtmux.exc.VersionTooLow` When the release is below the minimum or cannot be established. """ + server._version_cache.clear() return server._get_version() -def check_fence(stderr: str, marker: str | None) -> None: +def check_fence(stderr: str, marker: str | None, server: Server | None = None) -> None: """Raise :exc:`~libtmux.exc.VersionTooLow` if a fenced batch was refused. A batch prefixed with ``if-shell -F '#{==:#{version},}' '' ;`` reports ``unknown command: `` on stderr when the daemon is not the - release the caller checked, and none of its commands ran. + release the caller checked, and none of its commands ran. Pass *server* + so its next command reads the replacement daemon's release afresh. """ - check_result(stderr, marker) + try: + check_result(stderr, marker) + except VersionTooLow: + if server is not None: + server._version_cache.clear() + raise diff --git a/tests/test_versions.py b/tests/test_versions.py index 24ec0ca5b0..e8ef6d3e67 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -238,9 +238,7 @@ def test_feature_flags_follow_the_daemon_not_the_client( def test_support_surface(monkeypatch: pytest.MonkeyPatch) -> None: - """Consumers reach the decision through libtmux._internal.support alone.""" - from libtmux._internal import support - + """Consumers reach the version check through libtmux._internal.support.""" assert set(support.__all__) == { "MINIMUM", "VersionTooLow", @@ -657,6 +655,52 @@ def test_server_pickles_and_copies_without_its_version_cache() -> None: for clone in clones: assert clone.socket_name == "copied" assert not clone._version_cache - clone._version_cache.put(*clone._version_endpoint(), "3.4") - assert clone._version_cache.get(*clone._version_endpoint()) == "3.4" + binary, selectors = clone._version_endpoint() + clone._version_cache.put(binary, selectors, "3.4") + assert clone._version_cache.get(binary, selectors) == "3.4" assert server._version_cache.get(*server._version_endpoint()) == "3.8" + + +def test_native_server_version_reads_a_replaced_daemon( + rejection_socket: pathlib.Path, +) -> None: + """server_version reports the daemon now on the socket, not a cached one.""" + supported = _native_binary("LIBTMUX_TEST_MINIMUM_TMUX", "3.3a") + older = _native_binary("LIBTMUX_TEST_UNSUPPORTED_TMUX", "3.2a") + + def start(binary: str) -> None: + started = subprocess.run( + [ + binary, + *("-f", "/dev/null", "-S", str(rejection_socket)), + *("new-session", "-d", "-s", "kept", "exec sleep 120"), + ], + capture_output=True, + timeout=5, + check=False, + ) + assert started.returncode == 0, started.stderr + + start(supported) + server = Server(tmux_bin=supported, socket_path=str(rejection_socket)) + try: + assert support.server_version(server) == "3.3a" + _native(supported, rejection_socket, "kill-server") + start(older) + with pytest.raises(exc.VersionTooLow, match=r"3\.3a"): + support.server_version(server) + assert not server._version_cache + finally: + _native(older, rejection_socket, "kill-server") + _native(supported, rejection_socket, "kill-server") + + +def test_check_fence_forgets_the_checked_release() -> None: + """A fired fence makes the server read its daemon's release afresh.""" + server = Server(tmux_bin="/test/tmux", socket_name="fenced") + server._version_cache.put(*server._version_endpoint(), "3.8") + support.check_fence("unknown command: other\n", "marker", server) + assert server._version_cache + with pytest.raises(support.VersionTooLow): + support.check_fence("unknown command: marker\n", "marker", server) + assert not server._version_cache From 2a517cc1f7320a5b6d6951b0a72950dc329fcc22 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:24:17 -0500 Subject: [PATCH 28/36] Core(refactor[version]): Deprecate -V helpers why: get_version and the has_*_version helpers read the tmux client executable, which can be a different release from the daemon a Server talks to. libtmux checks the daemon through libtmux._internal.support, so the executable helpers are legacy and belong out of common. what: - Add libtmux/_internal/version.py holding get_version, get_version_str, has_version, has_gt/gte/lte/lt_version, has_minimum_version, their private helpers, TMUX_MIN_VERSION and TMUX_MAX_VERSION - Each public helper emits a DeprecationWarning (stacklevel 2) saying it reads the client executable and naming the libtmux._internal.support replacement, e.g. is_at_least(server_version(server), "3.4") - Keep cache_clear() on get_version and get_version_str, typed so mypy accepts it - Re-export every helper from libtmux.common, and serve the two constants through a module __getattr__ that warns, so old imports keep working - Resolve tmux_cmd through libtmux.common at call time so tests that replace it keep working - Point the tests that patch TMUX_MIN_VERSION and _query_version at the module that now owns them --- src/libtmux/_internal/version.py | 407 +++++++++++++++++++++++++++++++ src/libtmux/common.py | 332 ++++--------------------- tests/legacy_api/test_common.py | 7 +- tests/test_common.py | 5 +- 4 files changed, 463 insertions(+), 288 deletions(-) create mode 100644 src/libtmux/_internal/version.py diff --git a/src/libtmux/_internal/version.py b/src/libtmux/_internal/version.py new file mode 100644 index 0000000000..a9b8dd4227 --- /dev/null +++ b/src/libtmux/_internal/version.py @@ -0,0 +1,407 @@ +"""Legacy helpers that read the tmux client executable's version. + +``libtmux.common`` re-exports every public name here, so existing imports keep +working. The helpers report the executable found on ``PATH`` (or *tmux_bin*), +not the daemon a :class:`~libtmux.Server` is connected to, which can be a +different release. Each one emits a :exc:`DeprecationWarning`; libtmux itself +checks the daemon through :mod:`libtmux._internal.support`. +""" + +from __future__ import annotations + +import functools +import re +import sys +import typing as t +import warnings + +from libtmux import exc +from libtmux._compat import LooseVersion +from libtmux._internal.support import versions + +if t.TYPE_CHECKING: + from collections.abc import Callable + +#: Minimum version of tmux required to run libtmux +TMUX_MIN_VERSION = versions.MINIMUM + +#: Most recent version of tmux supported +TMUX_MAX_VERSION = "3.7" + +_COMPARE_HINT = 'is_at_least(server_version(server), "3.4")' + + +def _tmux_cmd(*args: str, tmux_bin: str | None = None) -> t.Any: + """Run ``tmux`` through ``libtmux.common.tmux_cmd``, looked up at call time.""" + from libtmux import common + + return common.tmux_cmd(*args, tmux_bin=tmux_bin) + + +def _deprecation(name: str, replacement: str) -> str: + """Return the warning text shared by the deprecated helpers.""" + return ( + f"libtmux.common.{name}() reads the tmux client executable, not the " + f"daemon a Server is connected to. Use {replacement} from " + "libtmux._internal.support." + ) + + +class _TmuxVersionUnavailable(Exception): + """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" + + +def _no_version_flag_fallback() -> str: + """Return a synthetic version string when tmux lacks ``-V``. + + OpenBSD ships a ``-V``-less base tmux, so assume the maximum supported + version; any other platform is genuinely too old. + """ + if sys.platform.startswith("openbsd"): # openbsd has no tmux -V + return f"{TMUX_MAX_VERSION}-openbsd" + msg = ( + f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. This system" + " does not meet the minimum tmux version requirement." + ) + raise exc.LibTmuxException(msg) + + +def _query_version(tmux_bin: str | None = None) -> str: + """Return the raw ``tmux -V`` version token, letter suffix intact. + + Not memoized -- :func:`_get_version` and :func:`_get_version_str` each + cache their own result on top of this query. + + Raises + ------ + _TmuxVersionUnavailable + tmux predates the ``-V`` flag; callers apply + :func:`_no_version_flag_fallback`. + :exc:`~libtmux.exc.VersionTooLow` + tmux reported another error on ``-V``. + """ + proc = _tmux_cmd("-V", tmux_bin=tmux_bin) + if proc.stderr: + if proc.stderr[0] == "tmux: unknown option -- V": + raise _TmuxVersionUnavailable + raise exc.VersionTooLow(proc.stderr) + + if len(proc.stdout) != 1 or not proc.stdout[0].startswith("tmux "): + msg = "tmux -V did not report a single version" + raise exc.VersionTooLow(msg) + version: str = proc.stdout[0].removeprefix("tmux ") + return version + + +@functools.cache +def _get_version_str(tmux_bin: str | None = None) -> str: + """Return the executable's version token verbatim, letter suffix intact.""" + try: + return _query_version(tmux_bin=tmux_bin) + except _TmuxVersionUnavailable: + return _no_version_flag_fallback() + + +@functools.cache +def _get_version(tmux_bin: str | None = None) -> LooseVersion: + """Return the executable's version with letter suffixes stripped.""" + try: + version = _query_version(tmux_bin=tmux_bin) + except _TmuxVersionUnavailable: + # OpenBSD base tmux lacks ``-V``; skip letter-stripping on the synthetic. + return LooseVersion(_no_version_flag_fallback()) + + # Allow latest tmux HEAD + if version == "master": + return LooseVersion(f"{TMUX_MAX_VERSION}-master") + + version = re.sub(r"[a-z-]", "", version) + + return LooseVersion(version) + + +_R = t.TypeVar("_R") +_R_co = t.TypeVar("_R_co", covariant=True) + + +class _CachedHelper(t.Protocol[_R_co]): + """A public helper that still offers the old ``cache_clear()`` hook.""" + + def __call__(self, tmux_bin: str | None = None) -> _R_co: ... + + def cache_clear(self) -> None: ... + + +def _clears( + cached: t.Any, +) -> Callable[[Callable[[str | None], _R]], _CachedHelper[_R]]: + """Expose *cached*'s ``cache_clear`` on the public helper it backs.""" + + def decorate(func: Callable[[str | None], _R]) -> _CachedHelper[_R]: + func.cache_clear = cached.cache_clear # type: ignore[attr-defined] + return t.cast("_CachedHelper[_R]", func) + + return decorate + + +@_clears(_get_version_str) +def get_version_str(tmux_bin: str | None = None) -> str: + """Return the tmux executable's version string, preserving letter suffixes. + + .. deprecated:: 0.63.0 + Reads the client executable. Use + ``libtmux._internal.support.server_version(server)``. + + Parameters + ---------- + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + str + Raw tmux version, e.g. ``"3.7a"``. Git builds return ``"master"``; + OpenBSD base tmux returns ``"-openbsd"``. + + Notes + ----- + Memoized per *tmux_bin*, independently of :func:`get_version`. Call + ``get_version_str.cache_clear()`` after swapping the tmux binary. + """ + warnings.warn( + _deprecation("get_version_str", "server_version(server)"), + DeprecationWarning, + stacklevel=2, + ) + return _get_version_str(tmux_bin) + + +@_clears(_get_version) +def get_version(tmux_bin: str | None = None) -> LooseVersion: + """Return the tmux executable's version. + + .. deprecated:: 0.63.0 + Reads the client executable. Use + ``libtmux._internal.support.server_version(server)``. + + If tmux is built from git master, the version returned will be the latest + version appended with -master, e.g. ``2.4-master``. + + If using OpenBSD's base system tmux, the version will have ``-openbsd`` + appended to the latest version, e.g. ``2.4-openbsd``. + + Parameters + ---------- + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux from + :func:`shutil.which`. + + Returns + ------- + :class:`distutils.version.LooseVersion` + tmux version according to *tmux_bin* if provided, otherwise the + system tmux from :func:`shutil.which` + + Notes + ----- + Memoized per *tmux_bin* (``None`` is a distinct key), independently of + :func:`get_version_str`. The cache is sticky across ``PATH`` changes and + on-disk binary swaps; call ``get_version.cache_clear()`` to invalidate. + """ + warnings.warn( + _deprecation("get_version", "server_version(server)"), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) + + +def has_version(version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is exactly *version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. + + Parameters + ---------- + version : str + version number, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version matches + """ + warnings.warn( + _deprecation("has_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) == LooseVersion(version) + + +def has_gt_version(min_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is newer than *min_version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. + + Parameters + ---------- + min_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version above min_version + """ + warnings.warn( + _deprecation("has_gt_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) > LooseVersion(min_version) + + +def has_gte_version(min_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is at least *min_version*. + + .. deprecated:: 0.63.0 + Reads the client executable. Use + ``is_at_least(server_version(server), min_version)`` from + ``libtmux._internal.support``. + + Parameters + ---------- + min_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version above or equal to min_version + """ + warnings.warn( + _deprecation("has_gte_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) >= LooseVersion(min_version) + + +def has_lte_version(max_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is at most *max_version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. + + Parameters + ---------- + max_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version below or equal to max_version + """ + warnings.warn( + _deprecation("has_lte_version", _COMPARE_HINT), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) <= LooseVersion(max_version) + + +def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool: + """Return True if the tmux executable is older than *max_version*. + + .. deprecated:: 0.63.0 + Reads the client executable, not the daemon. Use + ``not is_at_least(server_version(server), max_version)`` from + ``libtmux._internal.support``. + + Parameters + ---------- + max_version : str + tmux version, e.g. '3.2a' + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if version below max_version + """ + warnings.warn( + _deprecation("has_lt_version", f"not {_COMPARE_HINT}"), + DeprecationWarning, + stacklevel=2, + ) + return _get_version(tmux_bin) < LooseVersion(max_version) + + +def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool: + """Return True if the executable meets the complete release minimum. + + .. deprecated:: 0.63.0 + Reads the client executable. Commands check their connected daemon's + version on their own; use + ``require_version(server_version(server))`` from + ``libtmux._internal.support`` to check one explicitly. + + This helper preserves patch letters and does not use the legacy + normalized version cache. + + Parameters + ---------- + raises : bool + raise exception if below minimum version requirement + tmux_bin : str, optional + Path to tmux binary. If *None*, uses the system tmux. + + Returns + ------- + bool + True if tmux meets minimum required version. + + Raises + ------ + libtmux.exc.VersionTooLow + tmux version below minimum required for libtmux + + Notes + ----- + .. versionchanged:: 0.49.0 + Minimum version bumped to 3.2a. For older tmux, use libtmux v0.48.x. + + .. versionchanged:: 0.7.0 + No longer returns version, returns True or False + + .. versionchanged:: 0.1.7 + Versions will now remove trailing letters per + `Issue 55 `_. + """ + warnings.warn( + _deprecation("has_minimum_version", "require_version(server_version(server))"), + DeprecationWarning, + stacklevel=2, + ) + try: + version = _query_version(tmux_bin=tmux_bin) + versions.require_version(version, minimum=TMUX_MIN_VERSION) + except (exc.VersionTooLow, _TmuxVersionUnavailable): + if raises: + msg = f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater" + raise exc.VersionTooLow(msg) from None + return False + return True diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2d5961d2b3..3560246195 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,30 +7,72 @@ from __future__ import annotations -import functools import logging -import re import shlex import shutil import subprocess -import sys import typing as t +import warnings from . import exc from ._compat import LooseVersion +from ._internal import version as _version from ._internal.support import versions +from ._internal.version import ( + get_version, + get_version_str, + has_gt_version, + has_gte_version, + has_lt_version, + has_lte_version, + has_minimum_version, + has_version, +) if t.TYPE_CHECKING: from collections.abc import Callable +__all__ = ( + "CmdMixin", + "CmdProtocol", + "EnvironmentMixin", + "PaneDict", + "SessionDict", + "WindowDict", + "WindowOptionDict", + "get_libtmux_version", + "get_version", + "get_version_str", + "has_gt_version", + "has_gte_version", + "has_lt_version", + "has_lte_version", + "has_minimum_version", + "has_version", + "raise_if_stderr", + "session_check_name", + "tmux_cmd", +) + logger = logging.getLogger(__name__) +_DEPRECATED_CONSTANTS = frozenset({"TMUX_MIN_VERSION", "TMUX_MAX_VERSION"}) + -#: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = versions.MINIMUM +def __getattr__(name: str) -> t.Any: + """Serve the legacy tmux version constants, warning on each access.""" + if name in _DEPRECATED_CONSTANTS: + warnings.warn( + f"libtmux.common.{name} describes the tmux client executable's " + "supported range and is deprecated. Use " + "libtmux._internal.support.MINIMUM for the floor.", + DeprecationWarning, + stacklevel=2, + ) + return getattr(_version, name) + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) -#: Most recent version of tmux supported -TMUX_MAX_VERSION = "3.7" SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] @@ -396,282 +438,6 @@ def _dispatch(self, argv: list[str]) -> tuple[str, str, int]: return stdout, stderr, self.process.returncode -class _TmuxVersionUnavailable(Exception): - """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" - - -def _no_version_flag_fallback() -> str: - """Return a synthetic version string when tmux lacks ``-V``. - - OpenBSD ships a ``-V``-less base tmux, so assume the maximum supported - version; any other platform is genuinely too old. - """ - if sys.platform.startswith("openbsd"): # openbsd has no tmux -V - return f"{TMUX_MAX_VERSION}-openbsd" - msg = ( - f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. This system" - " does not meet the minimum tmux version requirement." - ) - raise exc.LibTmuxException(msg) - - -def _query_version(tmux_bin: str | None = None) -> str: - """Return the raw ``tmux -V`` version token, letter suffix intact. - - Runs ``tmux -V`` and extracts the version token (e.g. ``"3.7a"``, - ``"master"``, ``"next-3.8"``). Not memoized -- :func:`get_version` and - :func:`get_version_str` each cache their own result on top of this query. - - Parameters - ---------- - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - str - Raw version token from ``tmux -V``. - - Raises - ------ - _TmuxVersionUnavailable - tmux predates the ``-V`` flag; callers apply - :func:`_no_version_flag_fallback`. - :exc:`~libtmux.exc.VersionTooLow` - tmux reported another error on ``-V``. - """ - proc = tmux_cmd("-V", tmux_bin=tmux_bin) - if proc.stderr: - if proc.stderr[0] == "tmux: unknown option -- V": - raise _TmuxVersionUnavailable - raise exc.VersionTooLow(proc.stderr) - - if len(proc.stdout) != 1 or not proc.stdout[0].startswith("tmux "): - msg = "tmux -V did not report a single version" - raise exc.VersionTooLow(msg) - return proc.stdout[0].removeprefix("tmux ") - - -@functools.cache -def get_version_str(tmux_bin: str | None = None) -> str: - """Return the tmux version string verbatim, preserving letter suffixes. - - :func:`get_version` normalizes point releases for numeric comparison - (``"3.7a"`` becomes ``LooseVersion("3.7")``). This helper keeps the raw - suffix, so callers can distinguish patch releases whose behavior differs - -- for example the tmux 3.7 break-pane crash, reverted in 3.7a. - - Parameters - ---------- - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - str - Raw tmux version, e.g. ``"3.7a"``. Git builds return ``"master"``; - OpenBSD base tmux returns ``"-openbsd"``. - - Examples - -------- - >>> isinstance(get_version_str(), str) - True - - Notes - ----- - Memoized via :func:`functools.cache`, keyed on *tmux_bin*, independently of - :func:`get_version`. Call ``get_version_str.cache_clear()`` after swapping - the tmux binary. - """ - try: - return _query_version(tmux_bin=tmux_bin) - except _TmuxVersionUnavailable: - return _no_version_flag_fallback() - - -@functools.cache -def get_version(tmux_bin: str | None = None) -> LooseVersion: - """Return tmux version. - - If tmux is built from git master, the version returned will be the latest - version appended with -master, e.g. ``2.4-master``. - - If using OpenBSD's base system tmux, the version will have ``-openbsd`` - appended to the latest version, e.g. ``2.4-openbsd``. - - Parameters - ---------- - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux from - :func:`shutil.which`. - - Returns - ------- - :class:`distutils.version.LooseVersion` - tmux version according to *tmux_bin* if provided, otherwise the - system tmux from :func:`shutil.which` - - Notes - ----- - Memoized via :func:`functools.cache`, keyed on the *tmux_bin* argument - (``None`` is a distinct key from any explicit path), independently of - :func:`get_version_str`. The cache is sticky across ``PATH`` changes and - on-disk binary swaps when *tmux_bin* is ``None`` or the same path string -- - call ``get_version.cache_clear()`` to invalidate. Tests that monkey-patch - :class:`tmux_cmd` should call ``cache_clear()`` before asserting - parsed-version behavior. - """ - try: - version = _query_version(tmux_bin=tmux_bin) - except _TmuxVersionUnavailable: - # OpenBSD base tmux lacks ``-V``; skip letter-stripping on the synthetic. - return LooseVersion(_no_version_flag_fallback()) - - # Allow latest tmux HEAD - if version == "master": - return LooseVersion(f"{TMUX_MAX_VERSION}-master") - - version = re.sub(r"[a-z-]", "", version) - - return LooseVersion(version) - - -def has_version(version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version installed. - - Parameters - ---------- - version : str - version number, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version matches - """ - return get_version(tmux_bin=tmux_bin) == LooseVersion(version) - - -def has_gt_version(min_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version greater than minimum. - - Parameters - ---------- - min_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version above min_version - """ - return get_version(tmux_bin=tmux_bin) > LooseVersion(min_version) - - -def has_gte_version(min_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version greater or equal to minimum. - - Parameters - ---------- - min_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version above or equal to min_version - """ - return get_version(tmux_bin=tmux_bin) >= LooseVersion(min_version) - - -def has_lte_version(max_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version less or equal to minimum. - - Parameters - ---------- - max_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version below or equal to max_version - """ - return get_version(tmux_bin=tmux_bin) <= LooseVersion(max_version) - - -def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool: - """Return True if tmux version less than minimum. - - Parameters - ---------- - max_version : str - tmux version, e.g. '3.2a' - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if version below max_version - """ - return get_version(tmux_bin=tmux_bin) < LooseVersion(max_version) - - -def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool: - """Return True if the executable meets the complete release minimum. - - Commands check their connected daemon's version on their own. This helper preserves - patch letters and does not use the legacy normalized version cache. - - Parameters - ---------- - raises : bool - raise exception if below minimum version requirement - tmux_bin : str, optional - Path to tmux binary. If *None*, uses the system tmux. - - Returns - ------- - bool - True if tmux meets minimum required version. - - Raises - ------ - libtmux.exc.VersionTooLow - tmux version below minimum required for libtmux - - Notes - ----- - .. versionchanged:: 0.49.0 - Minimum version bumped to 3.2a. For older tmux, use libtmux v0.48.x. - - .. versionchanged:: 0.7.0 - No longer returns version, returns True or False - - .. versionchanged:: 0.1.7 - Versions will now remove trailing letters per - `Issue 55 `_. - """ - try: - version = _query_version(tmux_bin=tmux_bin) - versions.require_version(version, minimum=TMUX_MIN_VERSION) - except (exc.VersionTooLow, _TmuxVersionUnavailable): - if raises: - msg = f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater" - raise exc.VersionTooLow(msg) from None - return False - return True - - def session_check_name(session_name: str | None) -> None: """Raise exception session name invalid, modeled after tmux function. diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index 77e35ecd08..560e720554 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -15,6 +15,7 @@ import libtmux from libtmux import exc from libtmux._compat import LooseVersion +from libtmux._internal import version as legacy_version from libtmux.common import ( TMUX_MAX_VERSION, TMUX_MIN_VERSION, @@ -120,11 +121,11 @@ def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: allow letters. """ - monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", "1.9a") + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", "1.9a") result = has_minimum_version() assert result - monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", "1.8a") + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", "1.8a") result = has_minimum_version() assert result @@ -136,7 +137,7 @@ def test_ignores_letter_versions(monkeypatch: pytest.MonkeyPatch) -> None: def test_error_version_less_1_7(monkeypatch: pytest.MonkeyPatch) -> None: """Refuse an old executable without dropping its version report.""" - monkeypatch.setattr(libtmux.common, "_query_version", lambda **kwargs: "1.7") + monkeypatch.setattr(legacy_version, "_query_version", lambda **kwargs: "1.7") with pytest.raises(exc.VersionTooLow, match="libtmux only supports"): has_minimum_version() assert not has_minimum_version(raises=False) diff --git a/tests/test_common.py b/tests/test_common.py index 14823bc6df..3e394d6b8e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -13,6 +13,7 @@ import libtmux from libtmux import exc from libtmux._compat import LooseVersion +from libtmux._internal import version as legacy_version from libtmux.common import ( TMUX_MAX_VERSION, TMUX_MIN_VERSION, @@ -618,7 +619,7 @@ def test_version_validation( ) -> None: """Test version validation.""" if mock_min_version is not None: - monkeypatch.setattr(libtmux.common, "TMUX_MIN_VERSION", mock_min_version) + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", mock_min_version) if mock_version is not None: @@ -628,7 +629,7 @@ def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) if check_type == "min_version": monkeypatch.setattr( - libtmux.common, "_query_version", lambda **kwargs: mock_version + legacy_version, "_query_version", lambda **kwargs: mock_version ) if check_type == "min_version": From 0a514b68ff23ac0dd1b0150b2a981189caecbcd8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:24:52 -0500 Subject: [PATCH 29/36] tests(refactor[legacy_api]): Move -V helper tests why: The tests that exercise get_version and the has_*_version helpers test the legacy executable API, so they belong beside the other legacy tests and not among the suite that libtmux's own behaviour is built on. what: - Move the helper tests from tests/test_common.py to tests/legacy_api/test_version_helpers.py unchanged: memoization, cache keying, comparisons, version parsing and validation - Silence DeprecationWarning for that module only, since it deliberately calls the deprecated helpers - Leave tmux_cmd, session_check_name and the other common tests in place --- tests/legacy_api/test_version_helpers.py | 549 +++++++++++++++++++++++ tests/test_common.py | 527 ---------------------- 2 files changed, 549 insertions(+), 527 deletions(-) create mode 100644 tests/legacy_api/test_version_helpers.py diff --git a/tests/legacy_api/test_version_helpers.py b/tests/legacy_api/test_version_helpers.py new file mode 100644 index 0000000000..db42b4dd6c --- /dev/null +++ b/tests/legacy_api/test_version_helpers.py @@ -0,0 +1,549 @@ +"""Tests for the deprecated tmux executable version helpers. + +These helpers read the client executable and stay importable from +``libtmux.common`` for existing callers. They warn on use; the warning +itself is asserted in ``test_deprecation_warnings.py``. +""" + +from __future__ import annotations + +import sys +import typing as t + +import pytest + +import libtmux +from libtmux import exc +from libtmux._compat import LooseVersion +from libtmux._internal import version as legacy_version +from libtmux.common import ( + TMUX_MAX_VERSION, + TMUX_MIN_VERSION, + get_version, + has_gt_version, + has_gte_version, + has_lt_version, + has_lte_version, + has_minimum_version, + has_version, +) + +pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") + + +def test_has_version() -> None: + """Test has_version().""" + assert has_version(str(get_version())) + + +def test_get_version_is_memoized_for_same_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two calls with the same tmux_bin fork tmux -V once. + + Validates the @functools.cache contract: identical-arg calls hit the + cache after the first miss. + """ + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + v1 = get_version() + v2 = get_version() + + assert v1 == v2 + assert call_count["n"] == 1 + + +def test_get_version_cache_keyed_by_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different tmux_bin args cache independently; same arg revisits hit.""" + call_count = {"n": 0} + versions = {"/path/a/tmux": "tmux 3.4", "/path/b/tmux": "tmux 3.6a"} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc(versions[kwargs["tmux_bin"]]) + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + a1 = get_version(tmux_bin="/path/a/tmux") + b1 = get_version(tmux_bin="/path/b/tmux") + a2 = get_version(tmux_bin="/path/a/tmux") + + assert a1 != b1 + assert a1 == a2 + assert call_count["n"] == 2 # /a once, /b once, /a hits cache + + +def test_get_version_cache_clear_invalidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cache_clear() forces a fresh subprocess on the next call.""" + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + get_version() + get_version() + assert call_count["n"] == 1 + + get_version.cache_clear() + get_version() + assert call_count["n"] == 2 + + +def test_get_version_binary_swap_requires_explicit_cache_clear( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Documents the sticky-cache trap when tmux_bin=None and PATH changes. + + Simulates a user upgrading tmux mid-process: two consecutive + ``get_version()`` calls with ``tmux_bin=None`` see different + underlying binaries, but the cache pins the first answer. The + escape hatch is ``get_version.cache_clear()`` — this test asserts + the trap is real and the escape hatch works. + """ + versions = ["tmux 3.2a", "tmux 3.6a"] + call_count = {"n": 0} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + proc = _MockProc(versions[call_count["n"]]) + call_count["n"] += 1 + return proc + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + first = get_version() + assert str(first) == "3.2" + + # "Binary swap" — PATH changed, but cache is sticky. + second = get_version() + assert str(second) == "3.2" # Stale: still the cached 3.2a. + assert call_count["n"] == 1 # No fresh subprocess. + + # Escape hatch. + get_version.cache_clear() + third = get_version() + assert str(third) == "3.6" # Fresh lookup. + assert call_count["n"] == 2 + + +class VersionComparisonFixture(t.NamedTuple): + """Test fixture for version comparison functions.""" + + test_id: str + version: str + comparison_type: t.Literal["gt", "gte", "lt", "lte"] + expected: bool + + +VERSION_COMPARISON_FIXTURES: list[VersionComparisonFixture] = [ + # Greater than tests + VersionComparisonFixture( + test_id="gt_older_version", + version="1.6", + comparison_type="gt", + expected=True, + ), + VersionComparisonFixture( + test_id="gt_older_version_with_letter", + version="1.6b", + comparison_type="gt", + expected=True, + ), + VersionComparisonFixture( + test_id="gt_newer_version", + version="4.0", + comparison_type="gt", + expected=False, + ), + VersionComparisonFixture( + test_id="gt_newer_version_with_letter", + version="4.0b", + comparison_type="gt", + expected=False, + ), + # Greater than or equal tests + VersionComparisonFixture( + test_id="gte_older_version", + version="1.6", + comparison_type="gte", + expected=True, + ), + VersionComparisonFixture( + test_id="gte_older_version_with_letter", + version="1.6b", + comparison_type="gte", + expected=True, + ), + VersionComparisonFixture( + test_id="gte_current_version", + version=str(get_version()), + comparison_type="gte", + expected=True, + ), + VersionComparisonFixture( + test_id="gte_newer_version", + version="4.0", + comparison_type="gte", + expected=False, + ), + VersionComparisonFixture( + test_id="gte_newer_version_with_letter", + version="4.0b", + comparison_type="gte", + expected=False, + ), + # Less than tests + VersionComparisonFixture( + test_id="lt_newer_version_with_letter", + version="4.0a", + comparison_type="lt", + expected=True, + ), + VersionComparisonFixture( + test_id="lt_newer_version", + version="4.0", + comparison_type="lt", + expected=True, + ), + VersionComparisonFixture( + test_id="lt_older_version", + version="1.7", + comparison_type="lt", + expected=False, + ), + VersionComparisonFixture( + test_id="lt_current_version", + version=str(get_version()), + comparison_type="lt", + expected=False, + ), + # Less than or equal tests + VersionComparisonFixture( + test_id="lte_newer_version_with_letter", + version="4.0a", + comparison_type="lte", + expected=True, + ), + VersionComparisonFixture( + test_id="lte_newer_version", + version="4.0", + comparison_type="lte", + expected=True, + ), + VersionComparisonFixture( + test_id="lte_current_version", + version=str(get_version()), + comparison_type="lte", + expected=True, + ), + VersionComparisonFixture( + test_id="lte_older_version", + version="1.7", + comparison_type="lte", + expected=False, + ), + VersionComparisonFixture( + test_id="lte_older_version_with_letter", + version="1.7b", + comparison_type="lte", + expected=False, + ), +] + + +@pytest.mark.parametrize( + list(VersionComparisonFixture._fields), + VERSION_COMPARISON_FIXTURES, + ids=[test.test_id for test in VERSION_COMPARISON_FIXTURES], +) +def test_version_comparison( + test_id: str, + version: str, + comparison_type: t.Literal["gt", "gte", "lt", "lte"], + expected: bool, +) -> None: + """Test version comparison functions.""" + comparison_funcs = { + "gt": has_gt_version, + "gte": has_gte_version, + "lt": has_lt_version, + "lte": has_lte_version, + } + assert comparison_funcs[comparison_type](version) == expected + + +class VersionParsingFixture(t.NamedTuple): + """Test fixture for version parsing and validation.""" + + test_id: str + mock_stdout: list[str] | None + mock_stderr: list[str] | None + mock_platform: str | None + expected_version: str | None + raises: bool + exc_msg_regex: str | None + + +VERSION_PARSING_FIXTURES: list[VersionParsingFixture] = [ + VersionParsingFixture( + test_id="master_version", + mock_stdout=["tmux master"], + mock_stderr=None, + mock_platform=None, + expected_version=f"{TMUX_MAX_VERSION}-master", + raises=False, + exc_msg_regex=None, + ), + VersionParsingFixture( + test_id="next_version", + mock_stdout=["tmux next-3.8"], + mock_stderr=None, + mock_platform=None, + expected_version="3.8", + raises=False, + exc_msg_regex=None, + ), + VersionParsingFixture( + test_id="openbsd_version", + mock_stdout=None, + mock_stderr=["tmux: unknown option -- V"], + mock_platform="openbsd 5.2", + expected_version=f"{TMUX_MAX_VERSION}-openbsd", + raises=False, + exc_msg_regex=None, + ), + VersionParsingFixture( + test_id="too_low_version", + mock_stdout=None, + mock_stderr=["tmux: unknown option -- V"], + mock_platform=None, + expected_version=None, + raises=True, + exc_msg_regex="does not meet the minimum tmux version requirement", + ), +] + + +@pytest.mark.parametrize( + list(VersionParsingFixture._fields), + VERSION_PARSING_FIXTURES, + ids=[test.test_id for test in VERSION_PARSING_FIXTURES], +) +def test_version_parsing( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + mock_stdout: list[str] | None, + mock_stderr: list[str] | None, + mock_platform: str | None, + expected_version: str | None, + raises: bool, + exc_msg_regex: str | None, +) -> None: + """Test version parsing and validation.""" + + class MockTmuxOutput: + stdout = mock_stdout + stderr = mock_stderr + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: + return MockTmuxOutput() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + if mock_platform is not None: + monkeypatch.setattr(sys, "platform", mock_platform) + + if raises: + with pytest.raises(exc.LibTmuxException) as exc_info: + get_version() + if exc_msg_regex is not None: + exc_info.match(exc_msg_regex) + else: + assert get_version() == expected_version + if test_id in {"openbsd_version", "master_version"}: + assert not has_minimum_version(raises=False) + else: + assert has_minimum_version() + assert has_gte_version(TMUX_MIN_VERSION) + assert has_gt_version(TMUX_MAX_VERSION) + + +class VersionValidationFixture(t.NamedTuple): + """Test fixture for version validation tests.""" + + test_id: str + mock_min_version: str | None + mock_version: str | None + check_type: t.Literal["min_version", "has_version", "type_check"] + raises: bool + exc_msg_regex: str | None + + +VERSION_VALIDATION_FIXTURES: list[VersionValidationFixture] = [ + # Letter version tests + VersionValidationFixture( + test_id="accepts_letter_in_min_version_1_9a", + mock_min_version="1.9a", + mock_version=None, + check_type="min_version", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_letter_in_min_version_1_8a", + mock_min_version="1.8a", + mock_version=None, + check_type="min_version", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_version_1_8", + mock_min_version=None, + mock_version="1.8", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_version_1_8a", + mock_min_version=None, + mock_version="1.8a", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_version_1_9a", + mock_min_version=None, + mock_version="1.9a", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + # Version too low tests + VersionValidationFixture( + test_id="rejects_version_1_7", + mock_min_version=None, + mock_version="1.7", + check_type="min_version", + raises=True, + exc_msg_regex=r"libtmux only supports", + ), + # Additional test cases for version validation + VersionValidationFixture( + test_id="accepts_master_version", + mock_min_version=None, + mock_version="master", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_next_version", + mock_min_version=None, + mock_version="next-3.4", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_openbsd_version", + mock_min_version=None, + mock_version="3.3-openbsd", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_dev_version", + mock_min_version=None, + mock_version="3.3-dev", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), + VersionValidationFixture( + test_id="accepts_rc_version", + mock_min_version=None, + mock_version="3.3-rc2", + check_type="type_check", + raises=False, + exc_msg_regex=None, + ), +] + + +@pytest.mark.parametrize( + list(VersionValidationFixture._fields), + VERSION_VALIDATION_FIXTURES, + ids=[test.test_id for test in VERSION_VALIDATION_FIXTURES], +) +def test_version_validation( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + mock_min_version: str | None, + mock_version: str | None, + check_type: t.Literal["min_version", "has_version", "type_check"], + raises: bool, + exc_msg_regex: str | None, +) -> None: + """Test version validation.""" + if mock_min_version is not None: + monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", mock_min_version) + + if mock_version is not None: + + def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: + return LooseVersion(mock_version) + + monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) + if check_type == "min_version": + monkeypatch.setattr( + legacy_version, "_query_version", lambda **kwargs: mock_version + ) + + if check_type == "min_version": + if raises: + with pytest.raises(exc.LibTmuxException) as exc_info: + has_minimum_version() + if exc_msg_regex is not None: + exc_info.match(exc_msg_regex) + else: + assert has_minimum_version() + elif check_type == "type_check": + assert mock_version is not None # For type checker + assert isinstance(has_version(mock_version), bool) diff --git a/tests/test_common.py b/tests/test_common.py index 3e394d6b8e..8e688b3428 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -13,18 +13,9 @@ import libtmux from libtmux import exc from libtmux._compat import LooseVersion -from libtmux._internal import version as legacy_version from libtmux.common import ( - TMUX_MAX_VERSION, - TMUX_MIN_VERSION, get_libtmux_version, get_version, - has_gt_version, - has_gte_version, - has_lt_version, - has_lte_version, - has_minimum_version, - has_version, session_check_name, tmux_cmd, ) @@ -36,135 +27,6 @@ version_regex = re.compile(r"([0-9]\.[0-9])|(master)") -def test_has_version() -> None: - """Test has_version().""" - assert has_version(str(get_version())) - - -def test_get_version_is_memoized_for_same_tmux_bin( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two calls with the same tmux_bin fork tmux -V once. - - Validates the @functools.cache contract: identical-arg calls hit the - cache after the first miss. - """ - call_count = {"n": 0} - - class _MockProc: - stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] - stderr: t.ClassVar[list[str]] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - call_count["n"] += 1 - return _MockProc() - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - v1 = get_version() - v2 = get_version() - - assert v1 == v2 - assert call_count["n"] == 1 - - -def test_get_version_cache_keyed_by_tmux_bin( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Different tmux_bin args cache independently; same arg revisits hit.""" - call_count = {"n": 0} - versions = {"/path/a/tmux": "tmux 3.4", "/path/b/tmux": "tmux 3.6a"} - - class _MockProc: - def __init__(self, line: str) -> None: - self.stdout = [line] - self.stderr: list[str] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - call_count["n"] += 1 - return _MockProc(versions[kwargs["tmux_bin"]]) - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - a1 = get_version(tmux_bin="/path/a/tmux") - b1 = get_version(tmux_bin="/path/b/tmux") - a2 = get_version(tmux_bin="/path/a/tmux") - - assert a1 != b1 - assert a1 == a2 - assert call_count["n"] == 2 # /a once, /b once, /a hits cache - - -def test_get_version_cache_clear_invalidates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """cache_clear() forces a fresh subprocess on the next call.""" - call_count = {"n": 0} - - class _MockProc: - stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] - stderr: t.ClassVar[list[str]] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - call_count["n"] += 1 - return _MockProc() - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - get_version() - get_version() - assert call_count["n"] == 1 - - get_version.cache_clear() - get_version() - assert call_count["n"] == 2 - - -def test_get_version_binary_swap_requires_explicit_cache_clear( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Documents the sticky-cache trap when tmux_bin=None and PATH changes. - - Simulates a user upgrading tmux mid-process: two consecutive - ``get_version()`` calls with ``tmux_bin=None`` see different - underlying binaries, but the cache pins the first answer. The - escape hatch is ``get_version.cache_clear()`` — this test asserts - the trap is real and the escape hatch works. - """ - versions = ["tmux 3.2a", "tmux 3.6a"] - call_count = {"n": 0} - - class _MockProc: - def __init__(self, line: str) -> None: - self.stdout = [line] - self.stderr: list[str] = [] - - def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: - proc = _MockProc(versions[call_count["n"]]) - call_count["n"] += 1 - return proc - - monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) - get_version.cache_clear() - - first = get_version() - assert str(first) == "3.2" - - # "Binary swap" — PATH changed, but cache is sticky. - second = get_version() - assert str(second) == "3.2" # Stale: still the cached 3.2a. - assert call_count["n"] == 1 # No fresh subprocess. - - # Escape hatch. - get_version.cache_clear() - third = get_version() - assert str(third) == "3.6" # Fresh lookup. - assert call_count["n"] == 2 - - def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: """Verify raises if tmux command not found.""" monkeypatch.setenv("PATH", "") @@ -256,395 +118,6 @@ def test_get_libtmux_version() -> None: assert LooseVersion(__version__) == version -class VersionComparisonFixture(t.NamedTuple): - """Test fixture for version comparison functions.""" - - test_id: str - version: str - comparison_type: t.Literal["gt", "gte", "lt", "lte"] - expected: bool - - -VERSION_COMPARISON_FIXTURES: list[VersionComparisonFixture] = [ - # Greater than tests - VersionComparisonFixture( - test_id="gt_older_version", - version="1.6", - comparison_type="gt", - expected=True, - ), - VersionComparisonFixture( - test_id="gt_older_version_with_letter", - version="1.6b", - comparison_type="gt", - expected=True, - ), - VersionComparisonFixture( - test_id="gt_newer_version", - version="4.0", - comparison_type="gt", - expected=False, - ), - VersionComparisonFixture( - test_id="gt_newer_version_with_letter", - version="4.0b", - comparison_type="gt", - expected=False, - ), - # Greater than or equal tests - VersionComparisonFixture( - test_id="gte_older_version", - version="1.6", - comparison_type="gte", - expected=True, - ), - VersionComparisonFixture( - test_id="gte_older_version_with_letter", - version="1.6b", - comparison_type="gte", - expected=True, - ), - VersionComparisonFixture( - test_id="gte_current_version", - version=str(get_version()), - comparison_type="gte", - expected=True, - ), - VersionComparisonFixture( - test_id="gte_newer_version", - version="4.0", - comparison_type="gte", - expected=False, - ), - VersionComparisonFixture( - test_id="gte_newer_version_with_letter", - version="4.0b", - comparison_type="gte", - expected=False, - ), - # Less than tests - VersionComparisonFixture( - test_id="lt_newer_version_with_letter", - version="4.0a", - comparison_type="lt", - expected=True, - ), - VersionComparisonFixture( - test_id="lt_newer_version", - version="4.0", - comparison_type="lt", - expected=True, - ), - VersionComparisonFixture( - test_id="lt_older_version", - version="1.7", - comparison_type="lt", - expected=False, - ), - VersionComparisonFixture( - test_id="lt_current_version", - version=str(get_version()), - comparison_type="lt", - expected=False, - ), - # Less than or equal tests - VersionComparisonFixture( - test_id="lte_newer_version_with_letter", - version="4.0a", - comparison_type="lte", - expected=True, - ), - VersionComparisonFixture( - test_id="lte_newer_version", - version="4.0", - comparison_type="lte", - expected=True, - ), - VersionComparisonFixture( - test_id="lte_current_version", - version=str(get_version()), - comparison_type="lte", - expected=True, - ), - VersionComparisonFixture( - test_id="lte_older_version", - version="1.7", - comparison_type="lte", - expected=False, - ), - VersionComparisonFixture( - test_id="lte_older_version_with_letter", - version="1.7b", - comparison_type="lte", - expected=False, - ), -] - - -@pytest.mark.parametrize( - list(VersionComparisonFixture._fields), - VERSION_COMPARISON_FIXTURES, - ids=[test.test_id for test in VERSION_COMPARISON_FIXTURES], -) -def test_version_comparison( - test_id: str, - version: str, - comparison_type: t.Literal["gt", "gte", "lt", "lte"], - expected: bool, -) -> None: - """Test version comparison functions.""" - comparison_funcs = { - "gt": has_gt_version, - "gte": has_gte_version, - "lt": has_lt_version, - "lte": has_lte_version, - } - assert comparison_funcs[comparison_type](version) == expected - - -class VersionParsingFixture(t.NamedTuple): - """Test fixture for version parsing and validation.""" - - test_id: str - mock_stdout: list[str] | None - mock_stderr: list[str] | None - mock_platform: str | None - expected_version: str | None - raises: bool - exc_msg_regex: str | None - - -VERSION_PARSING_FIXTURES: list[VersionParsingFixture] = [ - VersionParsingFixture( - test_id="master_version", - mock_stdout=["tmux master"], - mock_stderr=None, - mock_platform=None, - expected_version=f"{TMUX_MAX_VERSION}-master", - raises=False, - exc_msg_regex=None, - ), - VersionParsingFixture( - test_id="next_version", - mock_stdout=["tmux next-3.8"], - mock_stderr=None, - mock_platform=None, - expected_version="3.8", - raises=False, - exc_msg_regex=None, - ), - VersionParsingFixture( - test_id="openbsd_version", - mock_stdout=None, - mock_stderr=["tmux: unknown option -- V"], - mock_platform="openbsd 5.2", - expected_version=f"{TMUX_MAX_VERSION}-openbsd", - raises=False, - exc_msg_regex=None, - ), - VersionParsingFixture( - test_id="too_low_version", - mock_stdout=None, - mock_stderr=["tmux: unknown option -- V"], - mock_platform=None, - expected_version=None, - raises=True, - exc_msg_regex="does not meet the minimum tmux version requirement", - ), -] - - -@pytest.mark.parametrize( - list(VersionParsingFixture._fields), - VERSION_PARSING_FIXTURES, - ids=[test.test_id for test in VERSION_PARSING_FIXTURES], -) -def test_version_parsing( - monkeypatch: pytest.MonkeyPatch, - test_id: str, - mock_stdout: list[str] | None, - mock_stderr: list[str] | None, - mock_platform: str | None, - expected_version: str | None, - raises: bool, - exc_msg_regex: str | None, -) -> None: - """Test version parsing and validation.""" - - class MockTmuxOutput: - stdout = mock_stdout - stderr = mock_stderr - - def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: - return MockTmuxOutput() - - monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) - if mock_platform is not None: - monkeypatch.setattr(sys, "platform", mock_platform) - - if raises: - with pytest.raises(exc.LibTmuxException) as exc_info: - get_version() - if exc_msg_regex is not None: - exc_info.match(exc_msg_regex) - else: - assert get_version() == expected_version - if test_id in {"openbsd_version", "master_version"}: - assert not has_minimum_version(raises=False) - else: - assert has_minimum_version() - assert has_gte_version(TMUX_MIN_VERSION) - assert has_gt_version(TMUX_MAX_VERSION) - - -class VersionValidationFixture(t.NamedTuple): - """Test fixture for version validation tests.""" - - test_id: str - mock_min_version: str | None - mock_version: str | None - check_type: t.Literal["min_version", "has_version", "type_check"] - raises: bool - exc_msg_regex: str | None - - -VERSION_VALIDATION_FIXTURES: list[VersionValidationFixture] = [ - # Letter version tests - VersionValidationFixture( - test_id="accepts_letter_in_min_version_1_9a", - mock_min_version="1.9a", - mock_version=None, - check_type="min_version", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_letter_in_min_version_1_8a", - mock_min_version="1.8a", - mock_version=None, - check_type="min_version", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_version_1_8", - mock_min_version=None, - mock_version="1.8", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_version_1_8a", - mock_min_version=None, - mock_version="1.8a", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_version_1_9a", - mock_min_version=None, - mock_version="1.9a", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - # Version too low tests - VersionValidationFixture( - test_id="rejects_version_1_7", - mock_min_version=None, - mock_version="1.7", - check_type="min_version", - raises=True, - exc_msg_regex=r"libtmux only supports", - ), - # Additional test cases for version validation - VersionValidationFixture( - test_id="accepts_master_version", - mock_min_version=None, - mock_version="master", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_next_version", - mock_min_version=None, - mock_version="next-3.4", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_openbsd_version", - mock_min_version=None, - mock_version="3.3-openbsd", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_dev_version", - mock_min_version=None, - mock_version="3.3-dev", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), - VersionValidationFixture( - test_id="accepts_rc_version", - mock_min_version=None, - mock_version="3.3-rc2", - check_type="type_check", - raises=False, - exc_msg_regex=None, - ), -] - - -@pytest.mark.parametrize( - list(VersionValidationFixture._fields), - VERSION_VALIDATION_FIXTURES, - ids=[test.test_id for test in VERSION_VALIDATION_FIXTURES], -) -def test_version_validation( - monkeypatch: pytest.MonkeyPatch, - test_id: str, - mock_min_version: str | None, - mock_version: str | None, - check_type: t.Literal["min_version", "has_version", "type_check"], - raises: bool, - exc_msg_regex: str | None, -) -> None: - """Test version validation.""" - if mock_min_version is not None: - monkeypatch.setattr(legacy_version, "TMUX_MIN_VERSION", mock_min_version) - - if mock_version is not None: - - def mock_get_version(tmux_bin: str | None = None) -> LooseVersion: - return LooseVersion(mock_version) - - monkeypatch.setattr(libtmux.common, "get_version", mock_get_version) - if check_type == "min_version": - monkeypatch.setattr( - legacy_version, "_query_version", lambda **kwargs: mock_version - ) - - if check_type == "min_version": - if raises: - with pytest.raises(exc.LibTmuxException) as exc_info: - has_minimum_version() - if exc_msg_regex is not None: - exc_info.match(exc_msg_regex) - else: - assert has_minimum_version() - elif check_type == "type_check": - assert mock_version is not None # For type checker - assert isinstance(has_version(mock_version), bool) - - def test_tmux_cmd_pre_execution_logging( caplog: pytest.LogCaptureFixture, server: Server, From 08609ab8653c5f87853bfc62016175c86f6430e9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:24:52 -0500 Subject: [PATCH 30/36] tests(feat[legacy_api]): Assert -V helpers warn why: The helpers now read like deprecated API but nothing proved they warn, point at the caller, or name the replacement, so dropping a warning would go unnoticed. what: - Parametrize over all eight public helpers: each emits a DeprecationWarning that names the client executable, its own libtmux.common name and libtmux._internal.support, and points at the caller's file - Check TMUX_MIN_VERSION and TMUX_MAX_VERSION warn through libtmux.common and still return their values - Check an unknown attribute still raises AttributeError - Check get_version.cache_clear and get_version_str.cache_clear survive --- tests/legacy_api/test_deprecation_warnings.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/legacy_api/test_deprecation_warnings.py diff --git a/tests/legacy_api/test_deprecation_warnings.py b/tests/legacy_api/test_deprecation_warnings.py new file mode 100644 index 0000000000..4152e88961 --- /dev/null +++ b/tests/legacy_api/test_deprecation_warnings.py @@ -0,0 +1,60 @@ +"""The executable version helpers warn, and say what replaces them.""" + +from __future__ import annotations + +import typing as t + +import pytest + +import libtmux.common + +HELPERS: list[tuple[str, tuple[t.Any, ...]]] = [ + ("get_version", ()), + ("get_version_str", ()), + ("has_version", ("3.3a",)), + ("has_gt_version", ("3.3a",)), + ("has_gte_version", ("3.3a",)), + ("has_lte_version", ("3.3a",)), + ("has_lt_version", ("3.3a",)), + ("has_minimum_version", ()), +] + + +@pytest.mark.parametrize( + ("name", "args"), + HELPERS, + ids=[name for name, _ in HELPERS], +) +def test_helper_warns_and_names_replacement(name: str, args: tuple[t.Any, ...]) -> None: + """Each helper warns about the client executable and names the support API.""" + helper = getattr(libtmux.common, name) + with pytest.warns(DeprecationWarning, match="client executable") as record: + helper(*args) + + message = str(record[0].message) + assert f"libtmux.common.{name}()" in message + assert "libtmux._internal.support" in message + assert "server_version(server)" in message + assert record[0].filename == __file__, "warning points at the caller" + + +@pytest.mark.parametrize("name", ["TMUX_MIN_VERSION", "TMUX_MAX_VERSION"]) +def test_constants_warn_and_keep_their_values(name: str) -> None: + """The legacy constants stay importable through libtmux.common.""" + with pytest.warns(DeprecationWarning, match=name) as record: + value = getattr(libtmux.common, name) + + assert isinstance(value, str) + assert record[0].filename == __file__ + + +def test_unknown_attribute_still_raises() -> None: + """The module __getattr__ only serves the two legacy constants.""" + with pytest.raises(AttributeError, match="no attribute 'TMUX_NOPE'"): + libtmux.common.TMUX_NOPE # noqa: B018 + + +def test_cache_clear_is_still_offered() -> None: + """Callers that flush the executable caches keep a hook.""" + libtmux.common.get_version.cache_clear() + libtmux.common.get_version_str.cache_clear() From b1fc5a7789365dbe3d87826fdb52018685674186 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:25:24 -0500 Subject: [PATCH 31/36] Docs(refactor[doctest]): Gate on daemon release why: The doctests called has_gte_version, which reads the client executable and now warns. They run against the daemon the server fixture talks to, so that is the release to gate on. what: - Replace has_gte_version("X") with is_at_least(server_version(server), "X") in the Pane, Server and Window doctests, importing both from libtmux._internal.support --- src/libtmux/pane.py | 4 ++-- src/libtmux/server.py | 20 ++++++++++---------- src/libtmux/window.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 0554081e67..38b2d166de 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1512,8 +1512,8 @@ def new_pane( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.7"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.7"): ... floating = pane.new_pane(width=40, height=10, shell="sleep 5") ... is_floating = floating.pane_floating_flag ... else: diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c8060dee9d..3252768fda 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -909,8 +909,8 @@ def server_access( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.3"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.3"): ... result = server.server_access(list_access=True) ... assert isinstance(result, list) """ @@ -1166,8 +1166,8 @@ def confirm_before( works on 3.3+ but the ``send-keys -K -c `` round-trip used here is unreliable on 3.3a: - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.4"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.4"): ... with control_mode() as ctl: ... server.confirm_before( ... 'set -g @cf_test yes', @@ -1277,8 +1277,8 @@ def command_prompt( on 3.3+ but the ``send-keys -K -c `` round-trip used here is unreliable on 3.3a: - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.4"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.4"): ... with control_mode() as ctl: ... server.command_prompt( ... "set -g @cp_test '%1'", @@ -1770,8 +1770,8 @@ def show_prompt_history( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.3"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.3"): ... result = server.show_prompt_history() ... else: ... result = [] @@ -1804,8 +1804,8 @@ def clear_prompt_history( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.3"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.3"): ... server.clear_prompt_history() """ tmux_args: tuple[str, ...] = () diff --git a/src/libtmux/window.py b/src/libtmux/window.py index d3c00bccba..93131ea504 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -690,8 +690,8 @@ def new_pane( Examples -------- - >>> from libtmux.common import has_gte_version - >>> if has_gte_version("3.7"): + >>> from libtmux._internal.support import is_at_least, server_version + >>> if is_at_least(server_version(server), "3.7"): ... floating = window.new_pane(width=40, height=10, shell="sleep 5") ... is_floating = floating.pane_floating_flag ... else: From 1d6c64b23840bbeca9626892cc5c73ff1d3fd66d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:25:42 -0500 Subject: [PATCH 32/36] Docs(refactor[topics]): Gate on daemon release why: The floating-pane examples called has_gte_version, which reads the client executable and now warns, and the format-token page described the legacy helpers as if they were the way to read a version. what: - Use is_at_least(server_version(server), "X") from libtmux._internal.support in the floating-pane examples - Say on the format-token page that get_version and get_version_str are deprecated and point at server_version for the daemon's release --- docs/topics/floating_panes.md | 18 +++++++++--------- docs/topics/format-tokens.md | 6 ++++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 01f1c90871..c82af21ca6 100644 --- a/docs/topics/floating_panes.md +++ b/docs/topics/floating_panes.md @@ -32,9 +32,9 @@ floating by reading its {attr}`pane_floating_flag >> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... floating = window.new_pane(width=20, height=5, shell="sleep 30") ... is_floating = floating.pane_floating_flag ... else: @@ -52,14 +52,14 @@ window (tmux's `-X` / `-Y`). tmux reports the placement back through the {attr}`pane_y ` fields: ```python ->>> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... placed = window.new_pane(width=20, height=5, x=2, y=1, shell="sleep 30") ... position = (placed.pane_x, placed.pane_y) ... else: ... position = ("2", "1") ->>> border = 1 if has_gte_version("3.8") else 0 +>>> border = 1 if is_at_least(server_version(server), "3.8") else 0 >>> position == (str(2 + border), str(1 + border)) True ``` @@ -92,9 +92,9 @@ that stays on screen and waits for you after the command finishes instead of vanishing: ```python ->>> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... held = window.new_pane(width=20, height=5, shell="sleep 30", keep=True) ... remain = held.cmd("show-options", "-p", "-v", "remain-on-exit").stdout ... else: @@ -112,9 +112,9 @@ hold a pane — including filtering a window's {attr}`~libtmux.Window.panes` dow to just the floats: ```python ->>> from libtmux.common import has_gte_version +>>> from libtmux._internal.support import is_at_least, server_version ->>> if has_gte_version("3.7"): +>>> if is_at_least(server_version(server), "3.7"): ... _ = window.new_pane(width=20, height=5, shell="sleep 30") ... floating = [p for p in window.panes if p.pane_floating_flag == "1"] ... found = len(floating) >= 1 diff --git a/docs/topics/format-tokens.md b/docs/topics/format-tokens.md index 670b523619..183ea9c846 100644 --- a/docs/topics/format-tokens.md +++ b/docs/topics/format-tokens.md @@ -120,8 +120,10 @@ startup, they use the selected executable's report. This keeps field selection correct when the executable and daemon have different versions. {func}`libtmux.common.get_version` and -{func}`libtmux.common.get_version_str` describe the executable and cache their -results independently. Their caches do not authorize daemon operations. +{func}`libtmux.common.get_version_str` describe the executable, cache their +results independently and are deprecated. Their caches do not authorize daemon +operations; use `server_version(server)` from `libtmux._internal.support` to +read the daemon's release. See {doc}`/project/compatibility` for the tmux 3.3a core minimum and the version-check rules. From f2b46c8128d2384a61df32872df9776dfd6b37d1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:27:26 -0500 Subject: [PATCH 33/36] tests(refactor[tmux_client]): Add -V test gate why: The suite gates tests on the release of the tmux it launches, which is the executable. The deprecated libtmux.common helpers warn on every call, so the suite needs its own small gate to keep libtmux's tests free of deprecation warnings. what: - Add tests/tmux_client.py with client_gte, client_lte and clear_caches, built on the private helpers in libtmux._internal.version so the letter-stripping comparison stays what the tests were written against - Flush the caches through clear_caches in the autouse conftest fixture - Read the daemon's release in the non-UTF-8 locale test through Server._get_version - Make the newer-client-executable test fake the executable's release where the helper now lives --- tests/conftest.py | 20 +++++++++----------- tests/test_common.py | 3 +-- tests/test_versions.py | 6 +++++- tests/tmux_client.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 tests/tmux_client.py diff --git a/tests/conftest.py b/tests/conftest.py index f250744c5c..d0ea804487 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,8 @@ import pytest -from libtmux.common import get_version, get_version_str from libtmux.test.retry import retry_until +from tests.tmux_client import clear_caches if t.TYPE_CHECKING: from libtmux.server import Server @@ -23,17 +23,15 @@ @pytest.fixture(autouse=True) def _clear_get_version_cache() -> None: - """Flush the tmux-version caches before each test. - - Several tests in test_common.py and legacy_api/test_common.py - monkey-patch libtmux.common.tmux_cmd then call get_version() to - assert parsed-version behavior. With memoization, a prior test's - cached result would mask the mock — this fixture guarantees a fresh - subprocess lookup per test. get_version and get_version_str cache - independently, so flush both. + """Flush the executable's cached tmux release before each test. + + The tests under legacy_api monkey-patch libtmux.common.tmux_cmd then + call the deprecated get_version() to assert parsed-version behavior. + With memoization, a prior test's cached result would mask the mock -- + this fixture guarantees a fresh subprocess lookup per test. get_version + and get_version_str cache independently, so flush both. """ - get_version.cache_clear() - get_version_str.cache_clear() + clear_caches() @pytest.fixture diff --git a/tests/test_common.py b/tests/test_common.py index 8e688b3428..fb22aee2b6 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -15,7 +15,6 @@ from libtmux._compat import LooseVersion from libtmux.common import ( get_libtmux_version, - get_version, session_check_name, tmux_cmd, ) @@ -222,7 +221,7 @@ def test_tmux_cmd_format_separator_survives_non_utf8_locale( server = session.server - tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + tmux_version = server._get_version() _fields, fmt_str = get_output_format("list-sessions", tmux_version) old_lc_ctype = locale.setlocale(locale.LC_CTYPE) diff --git a/tests/test_versions.py b/tests/test_versions.py index e8ef6d3e67..d85106e8ef 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -15,6 +15,7 @@ import pytest from libtmux import Server, Window, exc +from libtmux._compat import LooseVersion from libtmux._internal import support from libtmux._internal.query_list import QueryList from libtmux._internal.support import versions @@ -230,7 +231,10 @@ def test_feature_flags_follow_the_daemon_not_the_client( ) -> None: """A newer client executable cannot select flags an older daemon rejects.""" monkeypatch.setattr(versions, "_run", lambda argv: _reply(argv, "3.3a")) - monkeypatch.setattr("libtmux.common.has_gte_version", lambda *args, **kwargs: True) + monkeypatch.setattr( + "libtmux._internal.version._get_version", + lambda *args, **kwargs: LooseVersion("9.9"), + ) server = Server(tmux_bin="/test/newer-client") assert server._has_gte_version("3.3a") assert not server._has_gte_version("3.4") diff --git a/tests/tmux_client.py b/tests/tmux_client.py new file mode 100644 index 0000000000..0ec803a76d --- /dev/null +++ b/tests/tmux_client.py @@ -0,0 +1,29 @@ +"""Gate tests on the tmux client executable's release. + +These read the executable the suite launches (``LIBTMUX_TMUX`` or ``PATH``), +with letter suffixes stripped, which is what the daemons the fixtures start +run. libtmux itself checks the connected daemon through +:mod:`libtmux._internal.support`; the deprecated ``libtmux.common`` helpers +are only exercised under ``tests/legacy_api``. +""" + +from __future__ import annotations + +from libtmux._compat import LooseVersion +from libtmux._internal import version as _legacy + + +def client_gte(minimum: str) -> bool: + """Return True if the client executable is at least *minimum*.""" + return _legacy._get_version() >= LooseVersion(minimum) + + +def client_lte(maximum: str) -> bool: + """Return True if the client executable is at most *maximum*.""" + return _legacy._get_version() <= LooseVersion(maximum) + + +def clear_caches() -> None: + """Forget the executable's cached release, for tests that fake ``tmux -V``.""" + _legacy._get_version.cache_clear() + _legacy._get_version_str.cache_clear() From f82c0bb25d0c23b4af61bb93d3e9e3c04b94f88c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:27:26 -0500 Subject: [PATCH 34/36] tests(refactor[tmux_client]): Use client_gte why: These tests called has_gte_version and has_lte_version from libtmux.common, which now warn that they read the client executable. what: - Replace the helper calls in the hooks, options, pane, capture-pane, server, session and window tests with client_gte and client_lte from tests/tmux_client.py, with the same version comparisons - Reword the window-name test docstring that cited get_version --- tests/test_hooks.py | 18 ++++---- tests/test_options.py | 58 ++++++++++++-------------- tests/test_pane.py | 34 +++++++-------- tests/test_pane_capture_pane.py | 6 +-- tests/test_server.py | 74 ++++++++++++++++----------------- tests/test_session.py | 4 +- tests/test_window.py | 19 ++++----- 7 files changed, 104 insertions(+), 109 deletions(-) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 5bb062c393..63e77ee104 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -8,7 +8,7 @@ from libtmux._internal.constants import Hooks from libtmux._internal.sparse_array import SparseArray -from libtmux.common import has_gte_version, has_lte_version +from tests.tmux_client import client_gte, client_lte if t.TYPE_CHECKING: from libtmux.server import Server @@ -106,7 +106,7 @@ def test_hooks_raw_cmd( ) assert session.cmd("show-hooks", "-s", "session-renamed[0]").stdout == [] - if has_gte_version("3.2"): + if client_gte("3.2"): # # Window # @@ -458,9 +458,9 @@ def test_hook_set_show_unset_cycle(server: Server, test_case: HookTestCase) -> N 2. Shown and verified 3. Unset cleanly """ - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") - if test_case.max_version and not has_lte_version(test_case.max_version): + if test_case.max_version and not client_lte(test_case.max_version): pytest.skip(f"Removed after tmux {test_case.max_version}") session = server.new_session(session_name="test_hook_cycle") @@ -512,7 +512,7 @@ def test_new_hooks_version_gated(server: Server, test_case: HookTestCase) -> Non These hooks are version-gated and will skip on older tmux versions. """ - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") session = server.new_session(session_name="test_new_hooks") @@ -847,7 +847,7 @@ def test_show_hooks_stores_string_values( test_case: ShowHooksTestCase, ) -> None: """Test that show_hooks() correctly stores string hook values.""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux >= {test_case.min_version}") session = server.new_session(session_name="test_show_hooks") @@ -870,7 +870,7 @@ def test_show_hooks_stores_string_values( def test_run_hook_basic(server: Server) -> None: """Test run_hook() method exists and can be called without error.""" - if not has_gte_version("3.2"): + if not client_gte("3.2"): pytest.skip("Requires tmux >= 3.2") session = server.new_session(session_name="test_run_hook") @@ -935,7 +935,7 @@ def test_set_hook_flag_combinations( test_case: SetHookFlagTestCase, ) -> None: """Test set_hook with various flag combinations.""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") session = server.new_session(session_name="test_flags") @@ -1020,7 +1020,7 @@ def test_hook_scope_handling( test_case: HookScopeTestCase, ) -> None: """Test hooks at different scopes (session, window, pane).""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") session = server.new_session(session_name="test_scope") diff --git a/tests/test_options.py b/tests/test_options.py index bc72061806..0c9835f942 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -17,10 +17,10 @@ WindowOptions, ) from libtmux._internal.sparse_array import SparseArray -from libtmux.common import has_gte_version from libtmux.constants import OptionScope from libtmux.options import TerminalOverrides, convert_values, explode_arrays from libtmux.pane import Pane +from tests.tmux_client import client_gte if t.TYPE_CHECKING: from typing_extensions import LiteralString @@ -62,12 +62,12 @@ def test_options(server: Server) -> None: obj._show_options(include_hooks=True) with pytest.raises(exc.OptionError): obj._show_option("test") - if has_gte_version("3.0"): + if client_gte("3.0"): obj._show_option("test", ignore_errors=True) with pytest.raises(exc.OptionError): obj.set_option("test", "invalid") if isinstance(obj, Pane): - if has_gte_version("3.0"): + if client_gte("3.0"): obj.set_option("test", "invalid", ignore_errors=True) else: with pytest.raises(exc.OptionError): @@ -84,10 +84,10 @@ def test_options_server(server: Server) -> None: server.set_option("buffer-limit", 100) assert server._show_option("buffer-limit") == 100 - if has_gte_version("3.0"): + if client_gte("3.0"): server.set_option("buffer-limit", 150, scope=OptionScope.Pane) - if has_gte_version("3.0"): + if client_gte("3.0"): # set-option and show-options w/ Pane (-p) does not exist until 3.0+ server.set_option( "buffer-limit", @@ -97,23 +97,23 @@ def test_options_server(server: Server) -> None: ) server.set_option("buffer-limit", 150, scope=OptionScope.Server) - if has_gte_version("3.0"): + if client_gte("3.0"): assert session._show_option("buffer-limit") == 150 # Server option in deeper objects - if has_gte_version("3.0"): + if client_gte("3.0"): pane.set_option("buffer-limit", 100) else: with pytest.raises(exc.OptionError): pane.set_option("buffer-limit", 100) - if has_gte_version("3.0"): + if client_gte("3.0"): assert pane._show_option("buffer-limit") == 100 assert window._show_option("buffer-limit") == 100 assert server._show_option("buffer-limit") == 100 server_options = ServerOptions(**server._show_options(scope=OptionScope.Server)) - if has_gte_version("3.0"): + if client_gte("3.0"): assert server._show_option("buffer-limit") == 100 assert server_options.buffer_limit == 100 @@ -217,11 +217,7 @@ def test_options_grid(server: Server) -> None: f"Expect {field.name} to be {expected} when " + f"scope={scope}, global_={global_}" ) - if ( - has_gte_version("3.0") - and scope == OptionScope.Window - and global_ - ): + if client_gte("3.0") and scope == OptionScope.Window and global_: assert objglobal__options.pane_base_index == 0 @@ -558,7 +554,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: "splitp": "split-window", } - if has_gte_version("3.2"): + if client_gte("3.2"): terminal_features = server.show_option("terminal-features") assert isinstance(terminal_features, dict) assert "screen*" in terminal_features @@ -572,17 +568,17 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: "title", ] # Additional features in tmux 3.4+ - if has_gte_version("3.4"): + if client_gte("3.4"): assert "rxvt*" in terminal_features assert terminal_features["rxvt*"] == ["ignorefkeys"] # Terminal overrides differ by version # tmux 3.0a-3.1b: has default values (xterm*, screen*) # tmux 3.2+: defaults removed, returns None unless explicitly set - if has_gte_version("3.2"): + if client_gte("3.2"): terminal_overrides = server.show_option("terminal-overrides") assert isinstance(terminal_overrides, (dict, type(None))) - elif has_gte_version("3.0"): + elif client_gte("3.0"): # tmux 3.0a/3.1b have defaults like "xterm*:XT:Ms=...,screen*:XT" terminal_overrides = server.show_option("terminal-overrides") assert isinstance(terminal_overrides, dict) @@ -596,7 +592,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: assert server.show_option("user-keys") is None # status-format was added in tmux 2.9 - if has_gte_version("2.9"): + if client_gte("2.9"): status_format = server.show_option("status-format") assert isinstance(status_format, (dict, type(None))) else: @@ -604,7 +600,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: server.show_option("status-format") # update-environment was added in tmux 3.0 - if has_gte_version("3.0"): + if client_gte("3.0"): update_env = server.show_option("update-environment") assert isinstance(update_env, (list, type(None))) else: @@ -614,7 +610,7 @@ def test_stable_baseline_options_and_hooks(server: Server) -> None: # List variables: Pane (pane-colours added in tmux 3.3) pane = session.active_pane assert pane is not None - if has_gte_version("3.3"): + if client_gte("3.3"): assert pane.show_option("pane-colours") is None @@ -651,7 +647,7 @@ def test_complex_option_values(server: Server) -> None: assert session.show_option("@empty-option") == "" # Test option inheritance (only for tmux >= 3.0) - if has_gte_version("3.0"): + if client_gte("3.0"): # Capture the inherited value (from global scope). Need include_inherited # because without an explicit session-level value, show_option returns None inherited_val = session.show_option("status-style", include_inherited=True) @@ -677,7 +673,7 @@ def test_style_option_validation(server: Server) -> None: assert isinstance(style, str) assert "fg=red" in str(style) - if has_gte_version("3.2"): + if client_gte("3.2"): # tmux 3.2+: literal string output assert "bg=default" in str(style) assert "bold" in str(style) @@ -691,14 +687,14 @@ def test_style_option_validation(server: Server) -> None: session.set_option("status-style", "invalid-style") # Test complex style with multiple attributes (tmux >= 3.0) - if has_gte_version("3.0"): + if client_gte("3.0"): session.set_option( "status-style", "fg=colour240,bg=#525252,bold,underscore", ) style = session.show_option("status-style") assert isinstance(style, str) - if has_gte_version("3.2"): + if client_gte("3.2"): # tmux 3.2+: literal string output assert style == "fg=colour240,bg=#525252,bold,underscore" else: @@ -706,7 +702,7 @@ def test_style_option_validation(server: Server) -> None: assert style == "fg=colour240,bg=#525252,bright,underscore" # Test style with variables (format expansion added in tmux 3.2) - if has_gte_version("3.2"): + if client_gte("3.2"): session.set_option("status-style", "fg=#{?pane_in_mode,red,green}") style = session.show_option("status-style") assert isinstance(style, str) @@ -728,7 +724,7 @@ def test_option_error_handling(server: Server) -> None: session.set_option("aggressive-resize", "invalid") # Test ambiguous option (if supported by tmux version) - if has_gte_version("2.4"): + if client_gte("2.4"): with pytest.raises(exc.OptionError) as exc_info: # Use a partial name that could match multiple options session.show_option( @@ -742,7 +738,7 @@ def test_terminal_features_edge_cases( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test edge cases for terminal-features option.""" - if not has_gte_version("3.2"): + if not client_gte("3.2"): pytest.skip("terminal-features requires tmux >= 3.2") # Test empty features @@ -1295,10 +1291,10 @@ def _build_option_params() -> list[t.Any]: @pytest.mark.parametrize("test_case", _build_option_params()) def test_option_set_show_cycle(server: Server, test_case: OptionTestCase) -> None: """Test set/show cycle for each option type.""" - if not has_gte_version("3.0"): + if not client_gte("3.0"): pytest.skip("Option tests require tmux 3.0+") - if test_case.min_version and not has_gte_version(test_case.min_version): + if test_case.min_version and not client_gte(test_case.min_version): pytest.skip(f"Requires tmux {test_case.min_version}+") # Get appropriate target object @@ -1405,7 +1401,7 @@ def test_show_options_returns_expected_keys( test_case: ShowOptionsTestCase, ) -> None: """Test that show_options() returns dict with expected scope-specific keys.""" - if not has_gte_version(test_case.min_version): + if not client_gte(test_case.min_version): pytest.skip(f"Requires tmux >= {test_case.min_version}") session = server.new_session(session_name="test_show_options") diff --git a/tests/test_pane.py b/tests/test_pane.py index c1280317ff..2fa37f6f89 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -10,9 +10,9 @@ import pytest from libtmux import exc -from libtmux.common import has_gte_version from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until +from tests.tmux_client import client_gte if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -518,7 +518,7 @@ def test_send_keys_flags( session: Session, ) -> None: """Test send_keys() with various flag combinations.""" - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") env = shutil.which("env") @@ -863,7 +863,7 @@ def test_display_message_flags( session: Session, ) -> None: """Test display_message() with various flag combinations.""" - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") pane = session.active_window.active_pane @@ -888,11 +888,11 @@ def test_display_message_warns_on_tmux_error(session: Session) -> None: def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte # tmux 3.4 has a regression in split-window -p; fixed in 3.5. # Per CHANGES FROM 3.4 TO 3.5: "Fix split-window -p." - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("split-window -p was broken in tmux 3.4 (fixed in 3.5)") window = session.new_window(window_name="test_split_pct") @@ -949,7 +949,7 @@ def test_copy_mode_source_pane(session: Session) -> None: def test_copy_mode_page_down(session: Session) -> None: """Test Pane.copy_mode(page_down=True) on tmux 3.5+.""" - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("page_down requires tmux 3.5+") pane = session.active_window.active_pane @@ -1101,7 +1101,7 @@ def test_display_popup_flags( verifying the marker file proves the popup invoked the command and the wrapper's flag-building branch was exercised. """ - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") marker = tmp_path / f"popup_{test_id}.marker" @@ -1641,7 +1641,7 @@ def test_capture_pane_3_7_flags( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): assert isinstance(pane.capture_pane(**kwargs), list) else: with pytest.warns(UserWarning, match=match): @@ -1655,7 +1655,7 @@ def test_split_empty(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): new_pane = pane.split(empty=True) assert new_pane in window.panes assert len(window.panes) == 2 @@ -1700,7 +1700,7 @@ def test_split_3_7_flags( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): new_pane = pane.split(**kwargs) assert new_pane in window.panes else: @@ -1748,7 +1748,7 @@ def test_split_preserves_empty_option_values( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): new_pane = pane.split(**option_kwargs) assert new_pane in window.panes else: @@ -1770,7 +1770,7 @@ def test_paste_buffer_no_vis(session: Session) -> None: retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) session.server.set_buffer("raw_content", buffer_name="novis_test") - if has_gte_version("3.7"): + if client_gte("3.7"): pane.paste_buffer(buffer_name="novis_test", no_vis=True) retry_until( lambda: "raw_content" in "\n".join(pane.capture_pane()), @@ -1815,11 +1815,11 @@ def test_new_pane_floating(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): floating = pane.new_pane(width=80, height=15, x=5, y=3, shell="sleep 30") assert floating.pane_floating_flag == "1" # tmux 3.8 counts the border in -x/-y/-X/-Y, so the content is smaller. - border = 2 if has_gte_version("3.8") else 0 + border = 2 if client_gte("3.8") else 0 assert floating.pane_width == str(80 - border) assert floating.pane_height == str(15 - border) assert floating.pane_x == str(5 + border // 2) @@ -1836,7 +1836,7 @@ def test_new_pane_keep(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): floating = pane.new_pane(width=80, height=15, keep=True, shell="sleep 30") assert floating.pane_floating_flag == "1" remain = floating.cmd("show-options", "-p", "-v", "remain-on-exit").stdout @@ -1861,7 +1861,7 @@ def test_new_pane_preserves_empty_option_values( pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): floating = pane.new_pane( width=40, height=10, @@ -1880,7 +1880,7 @@ def test_new_pane_error_tags_subcommand(session: Session) -> None: pane = window.active_pane assert pane is not None - if has_gte_version("3.7"): + if client_gte("3.7"): with pytest.raises(exc.LibTmuxException) as excinfo: pane.new_pane(target="%99999") assert excinfo.value.subcommand == "new-pane" diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5f9e85e0a0..c815cf6e01 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -12,8 +12,8 @@ import pytest -from libtmux.common import has_gte_version from libtmux.test.retry import retry_until +from tests.tmux_client import client_gte if t.TYPE_CHECKING: from libtmux.session import Session @@ -329,7 +329,7 @@ def test_capture_pane_flags( pytest fixture providing tmux session. """ # Skip if tmux version too old - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") # Find env for predictable shell @@ -495,7 +495,7 @@ def test_capture_pane_flag_smoke( returns a list without raising. Output-pattern assertions live in CAPTURE_PANE_CASES for the flags whose behaviour is observable. """ - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") pane = session.active_window.active_pane diff --git a/tests/test_server.py b/tests/test_server.py index 92df5f18af..f36659eb1c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -566,9 +566,9 @@ def test_confirm_before( server: Server, ) -> None: """Test Server.confirm_before() with send-keys -K confirmation.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") with control_mode() as ctl: @@ -636,9 +636,9 @@ def test_command_prompt( server: Server, ) -> None: """Test Server.command_prompt() with send-keys -K input.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") with control_mode() as ctl: @@ -681,9 +681,9 @@ def test_command_prompt_extra_flags( constructed argv instead, matching the test_display_menu_flags monkeypatch pattern. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") captured: list[tuple[str, ...]] = [] @@ -806,9 +806,9 @@ def test_display_menu_flags( the constructed argument vector instead — the only deviation from the test-suite's standard "use real tmux" pattern. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") captured: list[tuple[str, ...]] = [] @@ -878,9 +878,9 @@ def test_refresh_client_request_clipboard( escape sequence, which has no observable effect under the headless test server, so verify the constructed argv instead. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.7"): + if not client_gte("3.7"): pytest.skip("refresh-client -l clipboard query requires tmux 3.7+") captured: list[tuple[str, ...]] = [] @@ -915,9 +915,9 @@ def test_suspend_client( def test_server_access_list(server: Server) -> None: """Test Server.server_access() list mode.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("server-access added in tmux 3.3") server.new_session(session_name="access_test") @@ -927,9 +927,9 @@ def test_server_access_list(server: Server) -> None: def test_server_access_read_only_write_mutex(server: Server) -> None: """``read_only`` and ``write`` are mutually exclusive.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("server-access added in tmux 3.3") with pytest.raises(ValueError, match="mutually exclusive"): @@ -946,9 +946,9 @@ def test_server_access_argv( testing of the side-effect would tie the suite to system config. Verify the constructed argv instead. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("server-access added in tmux 3.3") captured: list[tuple[str, ...]] = [] @@ -1007,10 +1007,10 @@ def test_list_keys(server: Server) -> None: def test_list_keys_format(server: Server) -> None: """Server.list_keys(format_=...) applies -F on tmux 3.7+; warns below.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte server.new_session(session_name="listkeys_fmt") - if has_gte_version("3.7"): + if client_gte("3.7"): result = server.list_keys(format_="fmt") assert result assert all(line == "fmt" for line in result) @@ -1058,9 +1058,9 @@ def test_show_messages_terminals_jobs(server: Server) -> None: :c:func:`cmd_show_messages_exec` can take the ``-T``/``-J`` early-return paths, so this clientless codepath is unreachable. """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.6"): + if not client_gte("3.6"): pytest.skip("show-messages -T/-J without a client requires tmux 3.6+") server.new_session(session_name="showmsg_alt_test") @@ -1074,9 +1074,9 @@ def test_show_messages_terminals_jobs(server: Server) -> None: def test_show_prompt_history(server: Server) -> None: """Test Server.show_prompt_history() returns history.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("show-prompt-history added in tmux 3.3") server.new_session(session_name="showph_test") @@ -1086,9 +1086,9 @@ def test_show_prompt_history(server: Server) -> None: def test_clear_prompt_history(server: Server) -> None: """Test Server.clear_prompt_history() clears history.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip("clear-prompt-history added in tmux 3.3") server.new_session(session_name="clearph_test") @@ -1106,11 +1106,11 @@ def test_wait_for_set_flag(server: Server) -> None: def test_run_shell_basic(server: Server) -> None: """Test Server.run_shell() executes command and returns output.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte # tmux <3.5 does not write run-shell stdout back to the cmdq subprocess. # Restored by upstream commit fb37d52d, first released in 3.5. - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("run-shell stdout passthrough requires tmux 3.5+") server.new_session(session_name="run_shell_test") @@ -1128,9 +1128,9 @@ def test_run_shell_background(server: Server) -> None: def test_run_shell_cwd(server: Server, tmp_path: pathlib.Path) -> None: """``cwd=`` sets the working directory for the shell command.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.5"): + if not client_gte("3.5"): pytest.skip("run-shell stdout passthrough requires tmux 3.5+") server.new_session(session_name="run_shell_cwd_test") @@ -1141,9 +1141,9 @@ def test_run_shell_cwd(server: Server, tmp_path: pathlib.Path) -> None: def test_run_shell_show_stderr(server: Server) -> None: """``show_stderr=True`` captures the command's stderr into the output.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.6"): + if not client_gte("3.6"): pytest.skip("run-shell -E (JOB_SHOWSTDERR) requires tmux 3.6+") server.new_session(session_name="run_shell_stderr_test") @@ -1159,10 +1159,10 @@ def test_run_shell_show_stderr(server: Server) -> None: def test_run_shell_args(server: Server) -> None: """``args`` fill #{1}/#{2} in the command on tmux 3.7+; warn below.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte server.new_session(session_name="run_shell_args_test") - if has_gte_version("3.7"): + if client_gte("3.7"): result = server.run_shell("echo #{1}-#{2}", args=["alpha", "beta"]) assert result == ["alpha-beta"] else: @@ -1691,13 +1691,13 @@ def test_server_display_message_flags( returns empty stdout on that release (output dispatch via a control-mode client was unreliable until later versions). """ - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip( "display-message -p via control-mode client unreliable on tmux 3.2a" ) - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") with control_mode() as ctl: @@ -1728,9 +1728,9 @@ def test_server_display_message_target_client( server: Server, ) -> None: """``target_client`` is plumbed through as ``-c``; get_text=True returns stdout.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip( "display-message -p via control-mode client unreliable on tmux 3.2a" ) diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cca..e86ead7c13 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -772,11 +772,11 @@ def test_session_refresh_raises_when_session_id_is_none(server: Server) -> None: def test_kill_session_group(server: Server) -> None: """Session.kill(group=True) uses -g on tmux 3.7+; warn-and-ignore below.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte session = server.new_session(session_name="kill_group_base") - if has_gte_version("3.7"): + if client_gte("3.7"): session.kill(group=True) assert not server.has_session("kill_group_base") else: diff --git a/tests/test_window.py b/tests/test_window.py index 73e6f61089..6a736eba55 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -287,10 +287,9 @@ def test_new_window_name_colon_period_accepted( The lone tmux 3.7 point release briefly rejected these characters before 3.7a reverted the restriction (tmux commit 166267c8, "overly - pernickety"). libtmux's version helpers strip the letter suffix, so - :func:`~libtmux.common.get_version` cannot tell 3.7 from 3.7a -- both - report ``3.7`` -- so CI exercises 3.7a/3.7b rather than the superseded - 3.7 release. + pernickety"). The suite's executable gate strips the letter suffix, so it + cannot tell 3.7 from 3.7a -- both report ``3.7`` -- so CI exercises + 3.7a/3.7b rather than the superseded 3.7 release. """ window = session.new_window(window_name=window_name) assert window.window_name == window_name @@ -1163,9 +1162,9 @@ def test_window_display_message_flags( session: Session, ) -> None: """Window.display_message() resolves window-scoped formats.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if min_tmux_version and not has_gte_version(min_tmux_version): + if min_tmux_version and not client_gte(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") window = session.active_window @@ -1191,9 +1190,9 @@ def test_window_display_message_target_client( session: Session, ) -> None: """``target_client`` is plumbed through as ``-c``.""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte - if not has_gte_version("3.3"): + if not client_gte("3.3"): pytest.skip( "display-message -p via control-mode client unreliable on tmux 3.2a" ) @@ -1320,12 +1319,12 @@ def test_window_refresh_raises_when_window_id_is_none(session: Session) -> None: def test_new_pane(session: Session) -> None: """Window.new_pane() creates a floating pane on tmux 3.7+ (else raises).""" - from libtmux.common import has_gte_version + from tests.tmux_client import client_gte window = session.new_window(window_name="win_floating") window.resize(height=50, width=200) - if has_gte_version("3.7"): + if client_gte("3.7"): floating = window.new_pane(width=60, height=12, shell="sleep 30") assert floating.pane_floating_flag == "1" assert floating in window.panes From e291d2ea5706ae32cd3dc1ed50615be6418bf93b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 12:32:57 -0500 Subject: [PATCH 35/36] Development(feat[pytest]): Fail own -V helper use why: libtmux must not call the deprecated executable version helpers itself, but nothing stopped a doctest or a test from reintroducing one; the warning would only scroll past in the summary. what: - Turn a DeprecationWarning whose message starts with libtmux.common. into an error when it is raised from libtmux or from tests outside tests/legacy_api, which keeps exercising the helpers on purpose Each break fails and the restored tree passes: a doctest calling has_gte_version and a test outside legacy_api calling it. --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0002133c92..92a4470662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -275,6 +275,11 @@ addopts = [ "-p no:doctest", "--reruns=2" ] +filterwarnings = [ + # libtmux and its tests must not call the deprecated executable version + # helpers; tests/legacy_api exercises them on purpose. + "error:libtmux\\.common\\.:DeprecationWarning:(libtmux|tests)\\.(?!legacy_api)", +] doctest_optionflags = [ "ELLIPSIS", "NORMALIZE_WHITESPACE" From c27dd929fc25c3a95648759d8c0537a07ef60258 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 13:04:43 -0500 Subject: [PATCH 36/36] Changelog(docs): Record the tmux 3.3a minimum why: Users upgrading need to know that tmux servers below 3.3a are now refused, which version helpers warn and what replaces them, and what was fixed for newer tmux releases. what: - Record the tmux 3.3a minimum, checked against the running server - Record the deprecated executable version helpers with a migration - Record flags following the server's release and the tmux 3.8 hooks - Record the completion example that waits for real output --- CHANGES | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/CHANGES b/CHANGES index 7a691b0cc9..0580701c64 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,56 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Breaking changes + +#### tmux 3.3a is the minimum (#779) + +Minimum `tmux>=3.3a` (was `>=3.2a`). libtmux now checks the release of the +tmux server it talks to, not the `tmux` executable on `PATH`, and raises +{exc}`~libtmux.exc.VersionTooLow` before sending anything to an older server, +including 3.3 without its patch letter. Upgrading the executable does not +upgrade a server that is already running: restart it after saving its +sessions. See {doc}`project/compatibility` for the releases each feature needs. + +#### Executable version helpers are deprecated (#779) + +{func}`~libtmux.common.get_version`, {func}`~libtmux.common.get_version_str`, +{func}`~libtmux.common.has_version`, {func}`~libtmux.common.has_gt_version`, +{func}`~libtmux.common.has_gte_version`, {func}`~libtmux.common.has_lte_version`, +{func}`~libtmux.common.has_lt_version`, +{func}`~libtmux.common.has_minimum_version`, `TMUX_MIN_VERSION` and +`TMUX_MAX_VERSION` now emit a {exc}`DeprecationWarning`. They read the `tmux` +executable, which can be a different release from the server a +{class}`~libtmux.Server` talks to. They keep working. + +```python +# Before +from libtmux.common import has_gte_version + +has_gte_version("3.4") + +# After +from libtmux._internal.support import is_at_least, server_version + +is_at_least(server_version(server), "3.4") +``` + +The replacement lives in the private `libtmux._internal.support` module for +now, so its names can still change. + +### Fixes + +#### Optional flags follow the server's release (#779) + +Commands now pick their optional flags from the tmux server's release, not +the `tmux` executable's, so a newer executable talking to an older server no +longer sends flags that server rejects. + +#### Hooks from tmux 3.8 are read (#779) + +{meth}`~libtmux.hooks.HooksMixin.show_hook` knows the hooks tmux 3.8 adds, and +a hook a newer tmux adds no longer stops the hook table from being read. + ### Documentation #### Cleaner `from_env` examples (#719) @@ -57,6 +107,12 @@ environment-setup plumbing, so each example leads with the constructor call it demonstrates instead of the socket-path and `$TMUX` boilerplate needed to run it. +#### Completion example waits for real output (#779) + +The command-completion example in {doc}`topics/automation_patterns` runs a +real command and waits for its exact output line, so it no longer matches the +echoed input or misses output that arrives late. + ### Development #### CI actions updated to current majors