diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 340cff9627..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.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', '3.8', 'master'] steps: - uses: actions/checkout@v7 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 79c289585f..070d97225c 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 — @@ -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/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 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/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 diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 4dc5347e14..0e14e7e3ad 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -8,10 +8,52 @@ ## 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 {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 +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. + +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. + +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. ## 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/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() diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 7aa96a6c3d..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,17 +52,25 @@ 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") ->>> position -('2', '1') +>>> border = 1 if is_at_least(server_version(server), "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 @@ -84,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: @@ -104,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 237c19feaf..183ea9c846 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,18 @@ 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, 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. ## See also 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" 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/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/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/_internal/support/__init__.py b/src/libtmux/_internal/support/__init__.py new file mode 100644 index 0000000000..677b93d8e2 --- /dev/null +++ b/src/libtmux/_internal/support/__init__.py @@ -0,0 +1,82 @@ +"""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. + + 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, 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. Pass *server* + so its next command reads the replacement daemon's release afresh. + """ + try: + check_result(stderr, marker) + except VersionTooLow: + if server is not None: + server._version_cache.clear() + raise diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py new file mode 100644 index 0000000000..a42f84c721 --- /dev/null +++ b/src/libtmux/_internal/support/versions.py @@ -0,0 +1,278 @@ +"""Version checks for ordinary commands on a live or newly created tmux endpoint.""" + +from __future__ import annotations + +import getopt +import os +import pathlib +import re +import subprocess +import threading +import typing as t +import uuid + +from libtmux import exc + +MINIMUM = "3.3a" + +_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 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( + 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 + + +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 __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 ( + "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") + 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 = 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): + # A vanished daemon must not restart an older configured executable. + globals_.append("-N") + 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, *, 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) + + +def live_version(binary: str, selectors: list[str]) -> str | None: + """Check an existing daemon's version, returning None for an absent endpoint.""" + # 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 _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], *, 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) + + +def _binary_version(binary: str) -> str: + result = _run([binary, "-V"]) + # 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[len(prefix) :].strip() + require_version(version) + return version 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 2871547700..3560246195 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,29 +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 = "3.2a" +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] @@ -309,7 +352,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 @@ -319,6 +367,8 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: cmd = [str(c) for c in cmd] self.cmd = 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) @@ -328,16 +378,13 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) try: - self.process = subprocess.Popen( - cmd, - 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: @@ -349,6 +396,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) raise + versions.check_result(stderr, marker, cache=_version_cache) self.returncode = returncode stdout_split = stdout.split("\n") @@ -377,277 +425,17 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: }, ) - -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) - - return proc.stdout[0].split("tmux ")[1] - - -@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 tmux meets version requirement. Version >= 3.2a. - - 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 `_. - """ - current_version = get_version(tmux_bin=tmux_bin) - if current_version < LooseVersion(TMUX_MIN_VERSION): - 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) - return False - return True + 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 def session_check_name(session_name: str | None) -> None: 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..eceaec1bd4 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: @@ -264,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 -------- @@ -274,7 +276,12 @@ 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") return LooseVersion(version) @@ -908,7 +915,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 +992,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 +1102,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] = [] @@ -1133,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) @@ -1347,6 +1355,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/pane.py b/src/libtmux/pane.py index e0c2f59619..38b2d166de 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) @@ -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 @@ -1506,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: @@ -1515,7 +1521,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) @@ -1684,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 ---------- @@ -1782,62 +1789,26 @@ 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): + if self.server._has_gte_version("3.6"): tmux_args += ("-k",) else: warnings.warn( @@ -1846,7 +1817,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( @@ -1916,7 +1887,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( @@ -2022,7 +1993,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( @@ -2469,7 +2440,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}") @@ -2590,7 +2561,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 e650557c34..3252768fda 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -18,8 +18,9 @@ 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.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 @@ -76,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 [] @@ -180,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] = [] @@ -295,6 +299,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 +323,33 @@ 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}") + selectors = ["-S", str(self.socket_path), *selectors] + return binary, selectors - try: - subprocess.check_call([resolved, *cmd_args]) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None + def _get_version(self) -> str: + 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) # # Command @@ -408,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]: @@ -457,6 +479,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) @@ -472,7 +496,15 @@ def kill(self) -> None: >>> svr.is_alive() False """ + # 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): @@ -581,7 +613,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( @@ -590,7 +622,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( @@ -601,7 +633,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( @@ -784,7 +816,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( @@ -877,15 +909,11 @@ 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) """ - 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) @@ -946,7 +974,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( @@ -1138,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', @@ -1152,17 +1180,13 @@ 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: 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( @@ -1171,7 +1195,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( @@ -1253,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'", @@ -1268,10 +1292,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: @@ -1290,7 +1310,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( @@ -1299,7 +1319,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( @@ -1308,7 +1328,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( @@ -1424,7 +1444,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( @@ -1433,7 +1453,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( @@ -1442,7 +1462,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( @@ -1451,7 +1471,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( @@ -1460,7 +1480,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( @@ -1469,7 +1489,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( @@ -1695,7 +1715,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( @@ -1750,18 +1770,14 @@ 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 = [] >>> 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: @@ -1788,14 +1804,10 @@ 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() """ - 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: @@ -2330,7 +2342,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, ...] = ( @@ -2412,12 +2424,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) @@ -2473,6 +2488,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 ------- @@ -2490,6 +2506,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/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 b57db99692..93131ea504 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, @@ -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([]) @@ -654,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 @@ -685,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: @@ -1322,7 +1327,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/conftest.py b/tests/conftest.py index c0015e4e65..d0ea804487 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,21 +2,88 @@ 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 +from tests.tmux_client import clear_caches + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session @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. + """ + clear_caches() + + +@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. """ - get_version.cache_clear() - get_version_str.cache_clear() + 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/legacy_api/test_common.py b/tests/legacy_api/test_common.py index a6631f7136..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, @@ -36,7 +37,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 +51,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", ( @@ -86,7 +89,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", ( @@ -118,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 @@ -133,20 +136,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: - has_minimum_version() - excinfo.match(r"libtmux only supports") - - with pytest.raises(exc.LibTmuxException) as excinfo: + """Refuse an old executable without dropping its version report.""" + monkeypatch.setattr(legacy_version, "_query_version", lambda **kwargs: "1.7") + with pytest.raises(exc.VersionTooLow, match="libtmux only supports"): 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/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() 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 426b72d573..fb22aee2b6 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -14,16 +14,7 @@ from libtmux import exc from libtmux._compat import LooseVersion 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, ) @@ -35,135 +26,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", "") @@ -255,388 +117,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 - 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(libtmux.common, "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": - 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, @@ -741,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_hooks.py b/tests/test_hooks.py index 4e03f0a26b..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 +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 # @@ -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 ) @@ -440,8 +458,10 @@ 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 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") window = session.active_window @@ -492,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") @@ -827,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") @@ -850,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") @@ -915,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") @@ -1000,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 416032ce62..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 @@ -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: @@ -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" @@ -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) @@ -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" @@ -1635,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): @@ -1649,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 @@ -1694,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: @@ -1742,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: @@ -1764,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()), @@ -1809,11 +1815,15 @@ 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" - 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 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) + 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) @@ -1826,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 @@ -1851,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, @@ -1870,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 5111a06706..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 @@ -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") @@ -497,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 6175a7f9ad..f36659eb1c 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" @@ -567,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: @@ -637,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: @@ -682,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, ...]] = [] @@ -807,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, ...]] = [] @@ -879,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, ...]] = [] @@ -916,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") @@ -928,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"): @@ -947,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, ...]] = [] @@ -1008,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) @@ -1059,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") @@ -1075,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") @@ -1087,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") @@ -1107,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") @@ -1129,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") @@ -1142,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") @@ -1160,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: @@ -1178,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) @@ -1196,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) @@ -1699,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: @@ -1736,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_versions.py b/tests/test_versions.py new file mode 100644 index 0000000000..d85106e8ef --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,710 @@ +"""tmux version checks before ordinary public dispatch.""" + +from __future__ import annotations + +import copy +import itertools +import os +import pathlib +import pickle +import shutil +import subprocess +import tempfile +import typing as t + +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 +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" + ) + + +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( + "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_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", "allowed"), + [ + ("3.8-rc2", True), + ("next-3.8", True), + ("next-3.7", False), + ("3.7-rc2", False), + ("3.7", True), + ], +) +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( + "route", + [ + "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 +) -> 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 _reply(argv, "3.3") + + 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() + 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 + 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_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._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") + assert not server._has_gte_version("3.7") + + +def test_support_surface(monkeypatch: pytest.MonkeyPatch) -> None: + """Consumers reach the version check through libtmux._internal.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")) + 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:8] == ["-N", "if-shell", "-F", "#{==:#{version},3.3a}", ""] + assert guarded[8] == 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") + + +@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(versions, "_run", lambda argv: _reply(argv, "3.3a")) + guarded, _ = versions.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]] = [] + + 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 + + +@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", floor_report) + 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, +) -> None: + """A native mismatch aborts every requested command in the same batch.""" + server.new_session("versioned") + server._version_cache.clear() + run = versions._run + + def changed(argv: list[str]) -> subprocess.CompletedProcess[str]: + result = run(argv) + if _is_probe(argv) and result.returncode == 1: + return _reply(argv, "3.99") + 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 _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]] = [] + + 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 _is_probe(calls[0]) + + +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") + server._version_cache.clear() + run = versions._run + + def disappear(argv: list[str]) -> subprocess.CompletedProcess[str]: + result = run(argv) + if _is_probe(argv) and result.returncode == 1: + 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 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): + 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() + + +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 + 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 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 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()