diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c40e0a25e..3cb594870e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: python-version: ['3.14'] - 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.7', '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..22d63a5866 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,104 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Breaking changes + +#### Minimum tmux 3.3a (#779) + +Minimum `tmux>=3.3a` (was `>=3.2a`). Ordinary reads, writes, batches, and raw +commands raise {exc}`~libtmux.exc.VersionTooLow` for an older daemon, including +3.3 without its patch letter. The daemon's version governs an existing +endpoint; the executable's version governs creation on an absent endpoint. + +```text +# Before +tmux>=3.2a + +# After +tmux>=3.3a +``` + +Upgrade tmux and restart existing daemons after saving their sessions. +Updating the client executable alone does not upgrade a running daemon. +A renamed tmux 3.3a executable still qualifies the minimum. +The version check runs no user hooks, and each +{class}`~libtmux.Server` probes its endpoint once, then relies on the version +fence to notice a replaced daemon. Optional flags follow the daemon's release, +not the client executable's. The version check reads the forms tmux reports +(`3.3a`, `3.8-rc`, `next-3.9`) and refuses a development suffix on a release, +`master` and leading zeros. {mod}`libtmux._internal.support` +lets programs built on libtmux reuse the check. +Binary version and help diagnostics remain available. {meth}`Server.kill() ` +remains a no-op for an absent endpoint. See {doc}`project/compatibility` for +support and feature requirements. + +#### Optional tmux feature requirements + +On tmux 3.3a, literal `select-layout` refuses unknown names and malformed +serialized headers before batch effects with +{exc}`~libtmux.exc.InvalidLayout`, a {exc}`~libtmux.exc.LibTmuxException` that is +also a `ValueError`; ordinary layouts retain the 3.3a minimum. Programs that run +their own tmux subprocesses can apply these checks through +{func}`libtmux._internal.support.require_commands`. + +Single-pane `Pane.break_pane()` returns its actual window on tmux 3.3a through +3.5, whose native relink path succeeds without printing the requested ID. + +Core support remains tmux 3.3a+. {class}`ControlMode +` requires a stable tmux 3.7+ +release and a local socket filesystem that permits a private hard link. +Control acquisition validates the retained endpoint before starting its client; +UTF-8 metadata and output remain usable under a C locale. Acquisition runs no +user hooks before it validates the endpoint and names its client locally +(`client-`), and any release ranking at or above 3.7, such as `3.8-rc2` or +`next-3.8`, qualifies while `next-3.7` and `3.7-rc*` do not. + +Exact named-buffer deletion and rename require tmux 3.4+. Captured shell-job +stdout from {meth}`Server.run_shell() ` requires +3.5+ and is refused before launching the job on older daemons. Background, +pane-directed and native tmux-command execution remain available on 3.3a+. + +{meth}`Server.run_shell(target_pane=...) ` now returns +`None`, as its signature documents, instead of an empty list: the job's output +goes to the pane, so there is no captured stdout to return. Code that iterates +the result of a pane-directed call must handle `None`. + +On exact tmux 3.7, window and session names containing `:` or `.` are refused +before dispatch (`new-window -n`, `new-session -s/-n`, `rename-window`, +`rename-session`); 3.7a accepts them again. + +Percentage splits use portable `-l N%`. On exact tmux 3.7, raw `break-pane` +and unnamed {meth}`Pane.break_pane() ` requests are +refused. An explicit `window_name` uses the named break and rename repair; +a failed rename can leave the completed break in place. Other supported +releases retain ordinary naming. See {doc}`project/compatibility` for the +operation boundaries and synchronization requirements. + +### Deprecations + +#### Executable version helpers warn (#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` +and the `TMUX_MIN_VERSION` and `TMUX_MAX_VERSION` constants now emit a +{exc}`DeprecationWarning`. They read the tmux client executable, not the +daemon a {class}`~libtmux.Server` is connected to, which can be a different +release. All of them stay importable from `libtmux.common`. + +```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") +``` + ### Documentation #### Cleaner `from_env` examples (#719) diff --git a/README.md b/README.md index 920af194ff..f9d7c212de 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,13 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli ## Requirements & support -- tmux: >= 3.2a +- tmux: >= 3.3a + +Optional operations have separate requirements. Control acquisition requires a +qualified tmux 3.7+ release and a retainable local socket; exact named-buffer +deletion/rename require 3.4+, and captured shell stdout requires 3.5+. +See the [compatibility policy](https://libtmux.git-pull.com/project/compatibility/) +for exact-3.7 break-pane restrictions and the operations available on 3.3a. - Python: >= 3.10 (CPython and PyPy) Maintenance-only backports (no new fixes): diff --git a/conftest.py b/conftest.py index 88a2656d29..6d56cc3f07 100644 --- a/conftest.py +++ b/conftest.py @@ -17,10 +17,9 @@ import pytest from _pytest.doctest import DoctestItem -from libtmux._internal.control_mode import ControlMode from libtmux.client import Client from libtmux.pane import Pane -from libtmux.pytest_plugin import USING_ZSH +from libtmux.pytest_plugin import USING_ZSH, _control_mode_for_test from libtmux.server import Server from libtmux.session import Session from libtmux.window import Window @@ -51,9 +50,9 @@ def add_doctest_fixtures( doctest_namespace["window"] = session.active_window doctest_namespace["pane"] = session.active_pane doctest_namespace["request"] = request - doctest_namespace["ControlMode"] = ControlMode + doctest_namespace["ControlMode"] = _control_mode_for_test doctest_namespace["control_mode"] = functools.partial( - ControlMode, + _control_mode_for_test, server=session.server, session=session, ) 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..30840a8e80 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -8,10 +8,123 @@ ## 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` with its documented output mode. + +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. + +### Optional features + +| Operation | Eligible daemon and behavior | +|-----------|------------------------------| +| Ordinary subprocess operations | 3.3a+. Core support continues independently of control availability. | +| `ControlMode` and the pytest control fixture | Stable numbered releases from 3.7, with a local Unix socket whose filesystem permits a private hard link. Refused before control startup on older or unqualified development builds. | +| Explicit named-buffer deletion and rename | 3.4+. On 3.3a, a missing name can select an unrelated top buffer, so these requests are refused before dispatch. | +| Buffer reads, writes, unnamed deletion and explicit named paste-and-delete | Available on 3.3a+. Named paste-and-delete rejects a missing name before any paste or deletion. It does not replace temporary-buffer abort cleanup. | +| Captured shell-job stdout from `Server.run_shell` | 3.5+. On 3.3a and 3.4, the method refuses the capture before the job starts. | +| Background jobs, pane-directed output and `as_tmux_command=True` | Available on 3.3a+. Background and pane-directed calls return `None`; background launch does not promise job completion. | +| `Pane.split(percentage=...)` | Available on 3.3a+, using `-l N%`. Direct `split-window -p` is refused on the affected 3.4 interval. | +| `break-pane` on exact 3.7 | Raw calls and unnamed typed calls are refused. `Pane.break_pane(window_name=...)` applies the explicit name after the move. A later rename failure can leave the moved pane in its new window. | +| `break-pane` on other supported releases | Native behavior is retained. The exact-3.7 restriction does not raise their minimum. | +| Single-pane `break-pane` before 3.6 | Native success prints no window ID. The typed method rereads the pane's actual window after the command. | +| Literal `select-layout` on 3.3a | Require a unique native preset or a serialized layout header before dispatch. Unknown names and malformed headers are refused before any batch effect; valid presets, layout cycling and undo remain available. | +| Window names containing `:` or `.` | Exact 3.7 rejects creation before creating a window. Other tested supported releases retain those names. There is no automatic name substitution. | + +Eligibility comes from the selected daemon, including when a newer client is +used. Native dispatch checks the release again before executing the +operation. Feature guards cover direct literal native built-in command items, +their native aliases, abbreviations and batches. They do not interpret opaque +user-authored programs or expand configured command aliases; those contents +remain outside this guarantee. +Raw command stdout is native client output; it does not promise capture of a +nested shell job's stdout. + +Programs that run their own tmux subprocesses can apply the same checks +through {mod}`libtmux._internal.support`: `require_commands` refuses the literal +commands in the table for a checked release. Each libtmux operation still +checks its daemon, and control acquisition still checks its socket carrier. + +On 3.3a, the layout guard validates the preset or serialized header before any +batch effect. Checksum, tree structure and pane-count errors retain native +reporting. The native missing-error-cause defect is fixed from 3.4 by +[the upstream layout repair](https://github.com/tmux/tmux/commit/f10854cfc5e43f646b02b3031bfc4bc3f5f6c513). + +The version check assumes library-owned metadata and guard commands retain tmux's +built-in semantics. Owner `command-alias` redefinitions of commands such as +`display-message` or `if-shell` are outside qualification: tmux expands aliases +before resolving even a canonical built-in name. + +Control holds a private socket alias for the owned client's lifetime. Failure +to retain or identify that socket refuses acquisition. The native alias and +replacement checks have been exercised on Linux/WSL; other filesystem and +platform combinations need their own qualification. A successful idle attach +does not establish concurrent or sustained-load stability. + +Control also needs a natively compatible client. Native clients 3.3a, 3.4 and +3.5 fail to register with an exact-3.7 daemon; the daemon remains alive. A +newer compatible client can register with that same daemon. Ordinary client +operations retain the 3.3a minimum. + +### Synchronization and testing + +Version eligibility does not remove readiness, buffering or completion waits. +`ControlMode` waits for its own client registration. Shell automation should +use a complete output marker or state predicate, and streams must retain their +framing and cancellation handling. A background command's reply confirms launch. + +CI retains ordinary older-server coverage. Only tests and documentation examples +that need an ineligible control client are skipped for that release; control +refusal tests still run. Captured-stdout and named-buffer tests assert refusals +on older daemons, and exact 3.7 tests exercise both single- and multi-pane naming. +The percentage-split test also runs on 3.4 through the portable encoding. + +The release boundaries come from tmux's +[3.3a buffer operations](https://github.com/tmux/tmux/blob/0b355ae8114511e1ff6359272b164f1cdf718e80/cmd-set-buffer.c), +[3.4 repair](https://github.com/tmux/tmux/blob/9ae69c3795ab5ef6b4d760f6398cd9281151f632/cmd-set-buffer.c), +[3.5 shell output](https://github.com/tmux/tmux/blob/ac44566c9c7e3e94d23be6def4c7ae83472543f5/cmd-run-shell.c), +and [exact-3.7 break behavior](https://github.com/tmux/tmux/blob/81f88f8517c9fc5371b56cf117530c6b477c96ac/cmd-break-pane.c). ## 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 ba96411085..074df56ed7 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -523,21 +523,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/filtering.md b/docs/topics/filtering.md index 02cbbf4b22..5f8873fb20 100644 --- a/docs/topics/filtering.md +++ b/docs/topics/filtering.md @@ -450,7 +450,7 @@ fan-out. | Filter language | libtmux's lookup operators (`__contains`, `__regex`, etc.) | tmux's [FORMATS](https://man.openbsd.org/tmux.1#FORMATS) grammar | | Round trips | one (full list, then filter in memory) | one (tmux returns only matches) | | Best for | rich Python checks, set membership, post-fetch composition | exact/glob matches over many rows | -| Stability | every libtmux version supports it | requires tmux ≥ 3.2 | +| Stability | every libtmux version supports it | requires tmux ≥ 3.3a (the supported minimum) | Both are valid; pick based on data volume and the filter language you want. 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/docs/topics/options_and_hooks.md b/docs/topics/options_and_hooks.md index b74f5cc8c4..8aac4f9ecb 100644 --- a/docs/topics/options_and_hooks.md +++ b/docs/topics/options_and_hooks.md @@ -182,13 +182,14 @@ Session(...) ## tmux version compatibility Options and hooks need a reasonably recent tmux, and a few specific hooks -arrived later still. The floor for everything on this page is tmux 3.2: +arrived later still. The floor for everything on this page is tmux 3.3a, the +supported minimum: | Feature | Minimum tmux | |---------|-------------| -| All options/hooks features | 3.2+ | -| Window/Pane hook scopes (`-w`, `-p`) | 3.2+ | -| `client-active`, `window-resized` hooks | 3.3+ | +| All options/hooks features | 3.3a+ | +| Window/Pane hook scopes (`-w`, `-p`) | 3.3a+ | +| `client-active`, `window-resized` hooks | 3.3a+ | | `pane-title-changed` hook | 3.5+ | :::{seealso} 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/control_mode.py b/src/libtmux/_internal/control_mode.py index 05945451eb..fcc0c79dfd 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -1,42 +1,131 @@ -"""Control-mode client context manager for tmux testing. - -Provides a context manager that spawns a ``tmux -C attach-session`` -subprocess, creating a real tmux client that satisfies commands -requiring an attached client (e.g. ``display-popup``, ``detach-client``). -""" +"""Control-mode client context manager for a qualified local tmux daemon.""" from __future__ import annotations +import contextlib import os +import pathlib +import stat import subprocess +import tempfile import typing as t +import uuid +from libtmux import exc +from libtmux._internal.support import versions from libtmux.test.retry import retry_until if t.TYPE_CHECKING: import types + from collections.abc import Iterator from typing_extensions import Self from libtmux.server import Server from libtmux.session import Session +# run-shell -C parses the expanded text, so the fields avoid whitespace and the +# socket path, which may hold anything, is single-quoted with quotes re-opened. +_IDENTITY = r"#{version},#{pid},#{start_time},'#{s/'/'\\''/:socket_path}'" + + +def _identity(binary: str, selectors: list[str]) -> str: + # run-shell -C fires no after-* or command-error hook; the expanded + # identity comes back in the error text of its failed parse. + marker = "libtmux-identity-" + uuid.uuid4().hex + "-" + result = versions._run( + [binary, "-u", *selectors, "-N", "run-shell", "-C", marker + _IDENTITY] + ) + prefix = f"unknown command: {marker}" + # A single line: a newline inside the socket path must not truncate it. + lines = result.stderr.rstrip("\n").split("\n") + report = ( + lines[0][len(prefix) :] + if result.returncode == 1 and len(lines) == 1 and lines[0].startswith(prefix) + else "" + ) + fields = report.split(",", 3) + if ( + not report + or len(fields) != 4 + or not fields[1].isdigit() + or not fields[2].isdigit() + or not pathlib.Path(fields[3]).is_absolute() + ): + detail = result.stderr.strip() or "tmux did not report its daemon identity" + msg = f"ControlMode cannot identify its daemon: {detail}" + raise exc.LibTmuxException(msg) + return report + + +def _require_release(version: str) -> None: + # Strict ranking: 3.8-rc2 and next-3.8 qualify, next-3.7 and 3.7-rc* do not. + try: + versions.require_version(version, minimum="3.7") + except exc.VersionTooLow: + msg = f"ControlMode requires tmux 3.7 or newer; daemon reports {version!r}" + raise exc.VersionTooLow(msg) from None + + +@contextlib.contextmanager +def _pinned_endpoint(server: Server) -> Iterator[list[str]]: + binary, selectors = server._version_endpoint() + identity = _identity(binary, selectors) + version, _, _, socket = identity.split(",", 3) + _require_release(version) + + original = pathlib.Path(socket) + with contextlib.ExitStack() as resources: + try: + original_stat = original.lstat() + if not stat.S_ISSOCK(original_stat.st_mode): + msg = "ControlMode requires a local Unix socket" + raise exc.LibTmuxException(msg) + parents = sorted( + dict.fromkeys((pathlib.Path(tempfile.gettempdir()), original.parent)), + key=lambda path: len(os.fsencode(path)), + ) + for parent in parents: + with contextlib.ExitStack() as attempt: + try: + folder = attempt.enter_context( + tempfile.TemporaryDirectory(prefix=".lt-", dir=parent) + ) + alias = pathlib.Path(folder) / "s" + os.link(original, alias, follow_symlinks=False) + except OSError: + if parent == parents[-1]: + raise + continue + resources.enter_context(attempt.pop_all()) + break + pinned = alias.lstat() + if (original_stat.st_dev, original_stat.st_ino) != ( + pinned.st_dev, + pinned.st_ino, + ) or _identity(binary, ["-S", str(alias)]) != identity: + msg = "ControlMode daemon changed before acquisition" + raise exc.LibTmuxException(msg) + except OSError as error: + msg = f"ControlMode cannot retain the selected tmux socket: {error}" + raise exc.LibTmuxException(msg) from error + # Keep the inode reachable until the owned control client exits. + yield [binary, "-u", "-N", "-S", str(alias)] + class ControlMode: - """Context manager that spawns a tmux control-mode client. - - Creates a real client attached to the session, visible in - ``Server.list_clients()``. The client communicates via the tmux - control protocol on stdout. + """Attach a control client to a local tmux 3.7 or newer release. - While active, ``Server.list_clients()`` will include this client. + The socket filesystem must permit a private hard link. An endpoint that + disappears or changes before acquisition is refused. Ordinary subprocess + operations remain available on tmux 3.3a and newer. Parameters ---------- server : Server - The tmux server instance. + Server whose local daemon to attach to. session : Session - The session to attach to. + Session on that server. Examples -------- @@ -50,83 +139,89 @@ class ControlMode: session: Session client_name: str stdout: t.IO[str] - _proc: subprocess.Popen[str] _write_fd: int + _resources: contextlib.ExitStack | None def __init__(self, server: Server, session: Session) -> None: self.server = server self.session = session + self._resources = None def __enter__(self) -> Self: - """Spawn control-mode client and wait for registration.""" - read_fd, self._write_fd = os.pipe() - - tmux_bin = self.server.tmux_bin or "tmux" - - if self.server.socket_name is not None: - socket_args = ["-L", str(self.server.socket_name)] - elif self.server.socket_path is not None: - socket_args = ["-S", str(self.server.socket_path)] - else: - socket_args = [] - - cmd = [ - tmux_bin, - *socket_args, - "-C", - "attach-session", - "-t", - str(self.session.session_id), - ] - - try: + """Check the daemon's version, acquire control and wait for registration.""" + if self._resources is not None: + msg = "ControlMode is already active" + raise exc.LibTmuxException(msg) + with contextlib.ExitStack() as resources: + endpoint = resources.enter_context(_pinned_endpoint(self.server)) + read_fd, self._write_fd = os.pipe() try: - self._proc = subprocess.Popen( - cmd, - stdin=read_fd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", + try: + self._proc = subprocess.Popen( + [ + *endpoint, + "-C", + "attach-session", + "-t", + str(self.session.session_id), + ], + stdin=read_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + finally: + os.close(read_fd) + except BaseException: + os.close(self._write_fd) + raise + resources.callback(self._close_client) + assert self._proc.stdout is not None + self.stdout = self._proc.stdout + client_pid = str(self._proc.pid) + + # tmux names a client with no tty "client-"; ask for the live + # client pids with run-shell -C, which fires no hook. + self.client_name = f"client-{client_pid}" + + def client_registered() -> bool: + if self._proc.poll() is not None: + assert self._proc.stderr is not None + detail = self._proc.stderr.read().strip() + msg = f"ControlMode client exited before registration: {detail}" + raise exc.LibTmuxException(msg) + marker = "libtmux-clients-" + uuid.uuid4().hex + "-" + result = versions._run( + [*endpoint, "run-shell", "-C", marker + "#{L:#{client_pid},}"] + ) + prefix = f"unknown command: {marker}" + return any( + client_pid in line[len(prefix) :].split(",") + for line in result.stderr.splitlines() + if line.startswith(prefix) ) - finally: - # subprocess owns read_fd now - os.close(read_fd) - except BaseException: - # __exit__ will not run if __enter__ fails - os.close(self._write_fd) - raise - - self.stdout = self._proc.stdout # type: ignore[assignment] - client_pid = str(self._proc.pid) - - def client_registered() -> bool: - result = self.server.cmd( - "list-clients", - "-F", - "#{client_pid}\t#{client_name}", - ) - for line in result.stdout: - pid, _, client_name = line.partition("\t") - if pid == client_pid and client_name: - self.client_name = client_name.strip() - return True - return False - try: retry_until(client_registered, 3, raises=True) - except Exception: - os.close(self._write_fd) - self._proc.terminate() + self._resources = resources.pop_all() + return self + + def _close_client(self) -> None: + os.close(self._write_fd) + try: + if self._proc.poll() is None: + self._proc.terminate() try: self._proc.wait(timeout=5) except subprocess.TimeoutExpired: self._proc.kill() - self._proc.wait() - raise - - return self + self._proc.wait(timeout=5) + finally: + if self._proc.stdout is not None: + self._proc.stdout.close() + if self._proc.stderr is not None: + self._proc.stderr.close() def __exit__( self, @@ -134,13 +229,7 @@ def __exit__( exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Terminate control-mode client.""" - # Close write end — causes the control-mode client to exit (EOF on stdin) - os.close(self._write_fd) - - self._proc.terminate() - try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() + """Close the owned client and its private socket alias.""" + if self._resources is not None: + resources, self._resources = self._resources, None + resources.close() 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..65191d708a --- /dev/null +++ b/src/libtmux/_internal/support/__init__.py @@ -0,0 +1,102 @@ +"""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:`require_commands` refuses literal commands that a checked release + cannot run safely, such as named-buffer deletion before 3.4. +- :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.server_version(server) == server._get_version() +True +>>> support.require_commands("3.3a", ["list-sessions"]) +""" + +from __future__ import annotations + +import typing as t + +from libtmux._internal.support.versions import ( + MINIMUM, + _require_commands, + 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_commands", + "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 + + +def require_commands( + version: str, arguments: list[str], *, repaired_break: bool = False +) -> None: + """Refuse literal tmux commands that *version* cannot run safely. + + *arguments* is the argv after any global options, with ``;`` ending each + command as tmux reads it. Raises :exc:`~libtmux.exc.VersionTooLow` for a + command whose feature needs a newer release (exact named-buffer deletion + and rename before 3.4, ``split-window -p`` on 3.4, raw ``break-pane`` on + 3.7 unless *repaired_break* says the caller names the window), and + :exc:`~libtmux.exc.InvalidLayout` for a layout 3.3a would mishandle. + """ + _require_commands(version, arguments, repaired_break=repaired_break) diff --git a/src/libtmux/_internal/support/versions.py b/src/libtmux/_internal/support/versions.py new file mode 100644 index 0000000000..645c69a0a1 --- /dev/null +++ b/src/libtmux/_internal/support/versions.py @@ -0,0 +1,426 @@ +"""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 _command_items(arguments: list[str]) -> list[list[str]]: + """Inspect the native argv command boundaries without rewriting them.""" + commands: list[list[str]] = [] + current: list[str] = [] + for argument in arguments: + if argument.endswith(";"): + value = argument[:-1] + if value.endswith("\\"): + current.append(value[:-1] + ";") + continue + if value: + current.append(value) + if current: + commands.append(current) + current = [] + else: + current.append(argument) + if current: + commands.append(current) + return commands + + +def _matches(name: str, full: str, alias: str) -> bool: + """Report whether *name* is tmux's alias or a long-enough prefix of *full*.""" + return name == alias or (len(name) >= 5 and full.startswith(name)) + + +def _require_names(version: str, command: list[str]) -> None: + """Refuse names that exact tmux 3.7 rejects, before any command runs. + + 3.7 rejects ``:`` and ``.`` in session and window names; 3.7a reverted it. + """ + if version != "3.7": + return + name = command[0] + values: list[str] = [] + if _matches(name, "new-window", "neww"): + options, _ = getopt.getopt(command[1:], "abdkPSt:F:c:e:n:") + values = [value for flag, value in options if flag == "-n"] + elif _matches(name, "new-session", "new"): + options, _ = getopt.getopt(command[1:], "AdDEPXc:e:f:F:n:s:t:x:y:") + values = [value for flag, value in options if flag in {"-n", "-s"}] + elif _matches(name, "rename-window", "renamew") or _matches( + name, "rename-session", "rename" + ): + _, operands = getopt.getopt(command[1:], "t:") + values = operands[-1:] + for value in values: + if ":" in value or "." in value: + msg = ( + f"invalid window or session name {value!r}: tmux 3.7 rejects " + "':' and '.' in names; choose another name or upgrade to 3.7a" + ) + raise exc.VersionTooLow(msg) + + +def _require_commands( + version: str, arguments: list[str], *, repaired_break: bool +) -> None: + for command in _command_items(arguments): + name = command[0] + _require_names(version, command) + if not is_at_least(version, "3.4") and ( + name == "selectl" or (len(name) >= 8 and "select-layout".startswith(name)) + ): + options, operands = getopt.getopt(command[1:], "Enopt:") + flags = {flag for flag, _ in options} + if not operands or flags & {"-E", "-n", "-p"}: + continue + layout = operands[0] + # Before 3.4 an invalid header leaves the native error cause unset. + if re.fullmatch(r"[0-9a-fA-F]{4},[\s\S]+", layout): + continue + presets = ( + "even-horizontal", + "even-vertical", + "main-horizontal", + "main-vertical", + "tiled", + ) + if ( + "-o" not in flags + and sum(item.startswith(layout) for item in presets) == 1 + ): + continue + msg = ( + "invalid layout refused before dispatch on tmux 3.3a; " + "use a unique built-in layout name or a serialized layout" + ) + raise exc.InvalidLayout(msg) + elif not is_at_least(version, "3.4") and ( + name == "deleteb" or "delete-buffer".startswith(name) + ): + options, _ = getopt.getopt(command[1:], "b:") + if any(flag == "-b" for flag, _ in options): + msg = "exact named-buffer deletion requires tmux 3.4 or newer" + raise exc.VersionTooLow(msg) + elif not is_at_least(version, "3.4") and ( + name == "setb" or (len(name) >= 5 and "set-buffer".startswith(name)) + ): + options, _ = getopt.getopt(command[1:], "ab:t:n:w") + flags = {flag for flag, _ in options} + if {"-b", "-n"} <= flags: + msg = "exact named-buffer rename requires tmux 3.4 or newer" + raise exc.VersionTooLow(msg) + elif version == "3.7" and (name == "breakp" or "break-pane".startswith(name)): + options, _ = getopt.getopt(command[1:], "abdPF:n:s:t:") + if not repaired_break or not any(flag == "-n" for flag, _ in options): + msg = ( + "raw break-pane is unsafe on tmux 3.7; use Pane.break_pane " + "with an explicit window_name or upgrade to 3.7a" + ) + raise exc.VersionTooLow(msg) + elif ( + is_at_least(version, "3.4") + and not is_at_least(version, "3.5") + and (name == "splitw" or "split-window".startswith(name)) + ): + options, _ = getopt.getopt(command[1:], "bc:de:fF:hIl:p:Pt:vZ") + if any(flag == "-p" for flag, _ in options): + msg = ( + "split-window -p is broken on tmux 3.4; " + "use -l with a percentage or Pane.split" + ) + raise exc.VersionTooLow(msg) + + +def prepare( + argv: list[str], + *, + cache: VersionCache | None = None, + minimum: str = MINIMUM, + expected_version: str | None = None, + repaired_break: bool = False, +) -> 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 any(flag == "-C" for flag, _ in options): + msg = "raw control startup cannot retain a daemon endpoint; use ControlMode" + 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") + if not is_at_least(version, minimum): + msg = ( + f"this operation requires tmux {minimum} or newer; " + f"daemon reports {version!r}" + ) + raise exc.VersionTooLow(msg) + if expected_version is not None and version != expected_version: + msg = "tmux daemon changed while selecting the operation's capabilities" + raise exc.VersionTooLow(msg) + try: + _require_commands(version, commands, repaired_break=repaired_break) + except getopt.GetoptError as error: + raise exc.LibTmuxException(str(error)) from error + 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..a04f5c00b8 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,29 +7,76 @@ 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"}) + + +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) + -#: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = "3.2a" +#: Version of the optional-feature policy contract for backend consumers. +TMUX_OPTIONAL_FEATURE_POLICY_VERSION = 2 -#: Most recent version of tmux supported -TMUX_MAX_VERSION = "3.7" SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] @@ -309,7 +356,15 @@ 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, + _minimum_version: str = versions.MINIMUM, + _expected_version: str | None = None, + _repaired_break: bool = False, + ) -> None: resolved = tmux_bin or shutil.which("tmux") if not resolved: raise exc.TmuxCommandNotFound @@ -319,6 +374,18 @@ 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) + + def prepare_dispatch() -> tuple[list[str], str | None]: + return versions.prepare( + cmd, + cache=_version_cache, + minimum=_minimum_version, + expected_version=_expected_version, + repaired_break=_repaired_break, + ) + + dispatched, marker = prepare_dispatch() if logger.isEnabledFor(logging.DEBUG): cmd_str = shlex.join(cmd) @@ -328,16 +395,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 = prepare_dispatch() + stdout, stderr, returncode = self._dispatch(dispatched) except FileNotFoundError: raise exc.TmuxCommandNotFound from None except Exception: @@ -349,6 +413,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 +442,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/exc.py b/src/libtmux/exc.py index 57bb06102f..3926059ca9 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -308,6 +308,10 @@ class VersionTooLow(LibTmuxException): """Raised if tmux below the minimum version to use libtmux.""" +class InvalidLayout(LibTmuxException, ValueError): + """Layout refused before dispatch because tmux 3.3a would mishandle it.""" + + class BadSessionName(LibTmuxException): """Disallowed session name for tmux (empty, contains periods or colons).""" 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..3d0415ed32 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: @@ -49,8 +50,9 @@ FIELD_VERSION: dict[str, str] = { - # Post-3.2a additions (verified against tmux's format.c at each gated - # release tag, e.g. https://github.com/tmux/tmux/blob/3.6a/format.c). + # Additions after 3.2a; the 3.3 entries sit below the 3.3a floor. Verified + # against tmux's format.c at each gated release tag, e.g. + # https://github.com/tmux/tmux/blob/3.6a/format.c. "pane_dead_signal": "3.3", "pane_dead_time": "3.3", # tmux 3.7 additions (verified against format.c / tmux.1 at the 3.7 tag). @@ -68,10 +70,10 @@ } """Minimum tmux version that registers each format token. -Field names absent from this dict default to ``"3.2a"`` (always-safe within -the supported tmux range). Entries here represent tokens added after 3.2a +Field names absent from this dict default to ``"3.3a"`` (always-safe within +the supported tmux range). Entries here represent tokens added after 3.3a that need explicit gating to keep the ``-F`` template compatible with older -tmux versions. +tmux versions; the 3.3 entries are met by every supported daemon. """ @@ -264,8 +266,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 +277,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 +916,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. @@ -924,8 +932,8 @@ def get_output_format( The tmux list subcommand the format string is being built for. Determines which token scopes are reachable. tmux_version : str - The live tmux version. Used to gate post-3.2a tokens. Defaults to - ``"3.2a"`` (the project's minimum) for safe fallback when the + The live tmux version. Used to gate post-3.3a tokens. Defaults to + ``"3.3a"`` (the project's minimum) for safe fallback when the caller can't yet detect the version. Returns @@ -985,7 +993,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 +1103,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 +1141,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 +1356,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..2dffd17443 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( @@ -1343,7 +1343,7 @@ def split( tmux_args += (f"-l{size}",) if percentage is not None: - tmux_args += (f"-p{percentage}",) + tmux_args += (f"-l{percentage}%",) if full_window_split: tmux_args += ("-f",) @@ -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( @@ -2400,7 +2371,7 @@ def join( Examples -------- >>> pane_to_join = window.split(shell='sleep 1m') - >>> new_window = pane_to_join.break_pane() + >>> new_window = pane_to_join.break_pane(window_name='join-source') >>> pane_to_join.join(window) """ tmux_args: tuple[str, ...] = () @@ -2451,7 +2422,9 @@ def break_pane( detach : bool, optional Do not switch to the new window (``-d`` flag), default True. window_name : str, optional - Name for the new window (``-n`` flag). + Name for the new window (``-n`` flag). Required on exact tmux + 3.7, whose unnamed break is unsafe. Named breaks on that release + apply the name again after the pane moves. Returns ------- @@ -2465,11 +2438,16 @@ def break_pane( >>> new_window.window_name 'broken' """ - # tmux 3.7 segfaults break-pane when -n is absent and ignores -n when - # 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" + # Exact 3.7 needs -n to avoid a crash, then rename-window to apply it. + # An unnamed placeholder also changes single-pane window names. + version = self.server._get_version() + breaks_without_name = version == "3.7" + if breaks_without_name and window_name is None: + msg = ( + "unnamed break-pane is unsafe on tmux 3.7; " + "provide window_name or upgrade to 3.7a" + ) + raise exc.VersionTooLow(msg) tmux_args: tuple[str, ...] = ("-P", "-F#{window_id}") @@ -2478,21 +2456,32 @@ def break_pane( if window_name is not None: tmux_args += ("-n", window_name) - elif breaks_without_name: - tmux_args += ("-n", "libtmux") tmux_args += ("-s", str(self.pane_id)) # Use server.cmd to avoid auto-adding -t from self.cmd - proc = self.server.cmd("break-pane", *tmux_args) + proc = self.server.cmd( + "break-pane", + *tmux_args, + _expected_version=version, + _repaired_break=breaks_without_name, + ) raise_if_stderr(proc, "break-pane") - window_id = proc.stdout[0].strip() - from libtmux.window import Window - window = Window.from_window_id(server=self.server, window_id=window_id) + if proc.stdout: + window = Window.from_window_id( + server=self.server, window_id=proc.stdout[0].strip() + ) + elif proc.returncode: + msg = f"break-pane failed with exit code {proc.returncode}" + raise exc.LibTmuxException(msg, subcommand="break-pane") + else: + # Before 3.6 the sole-pane path returns before printing -P. + self.refresh() + window = self.window if window_name is not None and breaks_without_name: window.rename_window(window_name) @@ -2590,7 +2579,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/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052d..ac193d7f27 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -13,7 +13,7 @@ import pytest from libtmux import exc -from libtmux._internal.control_mode import ControlMode +from libtmux._internal.control_mode import ControlMode, _require_release from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import get_test_session_name, namer @@ -318,7 +318,16 @@ def control_mode( ... with control_mode() as ctl: ... assert ctl.client_name != '' """ - return functools.partial(ControlMode, server=server, session=session) + return functools.partial(_control_mode_for_test, server=server, session=session) + + +def _control_mode_for_test(server: Server, session: Session) -> ControlMode: + """Skip positive control use when the daemon release is not qualified.""" + try: + _require_release(server._get_version()) + except exc.VersionTooLow as error: + pytest.skip(str(error)) + return ControlMode(server=server, session=session) @pytest.fixture diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..d454db389f 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 @@ -342,6 +359,9 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + _minimum_version: str = versions.MINIMUM, + _expected_version: str | None = None, + _repaired_break: bool = False, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -408,7 +428,15 @@ 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, + _minimum_version=_minimum_version, + _expected_version=_expected_version, + _repaired_break=_repaired_break, + ) @property def attached_sessions(self) -> list[Session]: @@ -457,6 +485,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 +502,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): @@ -557,15 +595,24 @@ def run_shell( Returns ------- list[str] | None - Stdout lines, or None when *background* is True. Empty list on - tmux 3.3a/3.4 (upstream stdout passthrough was broken until 3.5). + Stdout lines, or None when *background* is True or output is + directed to *target_pane*. Captured shell-job stdout requires + tmux 3.5+; older daemons are refused before starting the job. + Background jobs, pane-directed output and native tmux-command + execution remain available on tmux 3.3a+. Examples -------- - >>> result = server.run_shell('true') + >>> result = server.run_shell('display-message -p ready', as_tmux_command=True) >>> isinstance(result, list) True """ + version = self._get_version() + minimum = ( + "3.5" + if not background and target_pane is None and not as_tmux_command + else versions.MINIMUM + ) tmux_args: tuple[str, ...] = () if background: @@ -581,7 +628,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 versions.is_at_least(version, "3.4"): tmux_args += ("-c", str(cwd)) else: warnings.warn( @@ -590,7 +637,7 @@ def run_shell( ) if show_stderr: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if versions.is_at_least(version, "3.6"): tmux_args += ("-E",) else: warnings.warn( @@ -601,7 +648,7 @@ def run_shell( tmux_args += (command,) if args: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if versions.is_at_least(version, "3.7"): tmux_args += tuple(args) else: warnings.warn( @@ -609,11 +656,16 @@ def run_shell( stacklevel=2, ) - proc = self.cmd("run-shell", *tmux_args) + proc = self.cmd( + "run-shell", + *tmux_args, + _minimum_version=minimum, + _expected_version=version, + ) raise_if_stderr(proc, "run-shell") - if background: + if background or target_pane is not None: return None return proc.stdout @@ -784,7 +836,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 +929,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 +994,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 +1186,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 +1200,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 +1215,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 +1297,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 +1312,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 +1330,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 +1339,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 +1348,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 +1464,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 +1473,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 +1482,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 +1491,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 +1500,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 +1509,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 +1735,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 +1790,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 +1824,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: @@ -1880,12 +1912,16 @@ def delete_buffer(self, *, buffer_name: str | None = None) -> None: ---------- buffer_name : str, optional Name of the buffer to delete (``-b`` flag). Defaults to the most - recent. + recent. Exact named deletion requires tmux 3.4+; older releases + can delete an unrelated top buffer when the name is missing. Examples -------- >>> server.set_buffer('to_delete', buffer_name='del_buf') - >>> server.delete_buffer(buffer_name='del_buf') + >>> try: + ... server.delete_buffer(buffer_name='del_buf') + ... except exc.VersionTooLow: + ... assert server.show_buffer(buffer_name='del_buf') == 'to_delete' """ tmux_args: tuple[str, ...] = () @@ -2330,7 +2366,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 +2448,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 +2512,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 +2530,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_control_mode.py b/tests/test_control_mode.py index f72f68466b..15c1ca4270 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -2,19 +2,117 @@ from __future__ import annotations +import io import locale import os +import pathlib import select -import sys +import subprocess +import time import typing as t import pytest -from libtmux._internal.control_mode import ControlMode +from libtmux import exc +from libtmux._internal import support +from libtmux._internal.control_mode import ControlMode, _require_release from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: from libtmux.server import Server + from libtmux.session import Session + + +def test_ineligible_control_refuses_before_startup( + server: Server, session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """Refusal stays exercised on old and unqualified development daemons.""" + version = server._get_version() + if support.is_at_least(version, "3.7"): + pytest.skip("requires an ineligible control release") + original = subprocess.Popen + starts: list[list[str]] = [] + + def tracked( + argv: list[str], *args: t.Any, **kwargs: t.Any + ) -> subprocess.Popen[str]: + if "-C" in argv and "run-shell" not in argv: + starts.append(argv) + return original(argv, *args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", tracked) + with ( + pytest.raises(exc.VersionTooLow, match="ControlMode requires"), + ControlMode(server, session), + ): + pytest.fail("ineligible control client registered") + assert starts == [] + assert session.session_id in [item.session_id for item in server.sessions] + + +def test_unavailable_socket_link_refuses_before_control( + server: Server, session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed pin leaves no control client or temporary socket directory.""" + version = server._get_version() + if not support.is_at_least(version, "3.7"): + pytest.skip("requires an eligible control release") + socket = pathlib.Path( + server.cmd("display-message", "-p", "#{socket_path}").stdout[0] + ) + before = set(socket.parent.glob(".lt-*")) + original = subprocess.Popen + starts: list[list[str]] = [] + + def tracked( + argv: list[str], *args: t.Any, **kwargs: t.Any + ) -> subprocess.Popen[str]: + if "-C" in argv and "run-shell" not in argv: + starts.append(argv) + return original(argv, *args, **kwargs) + + def unavailable(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + msg = "socket hard links are unavailable" + raise OSError(msg) + + monkeypatch.setattr(subprocess, "Popen", tracked) + monkeypatch.setattr(os, "link", unavailable) + with ( + pytest.raises(exc.LibTmuxException, match=r"cannot retain.*socket"), + ControlMode(server, session), + ): + pytest.fail("control started without an endpoint pin") + assert starts == [] + assert set(socket.parent.glob(".lt-*")) == before + + +@pytest.mark.parametrize( + "version", + ["3.7", "3.7a", "3.7d", "3.8", "3.8-rc", "3.8-rc2", "next-3.8", "next-3.9"], +) +def test_control_admits_what_ranks_at_or_above_3_7(version: str) -> None: + """Candidates and development builds above 3.7 final qualify for control.""" + _require_release(version) + + +@pytest.mark.parametrize( + "version", + ["3.6b", "3.7-rc", "3.7-rc3", "next-3.7", "3.3a", "3.7a-dev", "master", ""], +) +def test_control_refuses_what_ranks_below_3_7(version: str) -> None: + """A build below 3.7 final does not inherit the 3.7 initialization fix.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.7"): + _require_release(version) + + +def test_control_client_is_named_from_its_pid( + control_mode: t.Callable[[], ControlMode], server: Server +) -> None: + """The name is derived locally, and it is the name tmux gave the client.""" + with control_mode() as ctl: + assert ctl.client_name == f"client-{ctl._proc.pid}" + names = set(server.cmd("list-clients", "-F", "#{client_name}").stdout) + assert ctl.client_name in names def test_control_mode_creates_client( @@ -28,6 +126,68 @@ def test_control_mode_creates_client( assert ctl.client_name != "" +def test_control_identity_probe_fires_no_user_hooks( + control_mode: t.Callable[[], ControlMode], + server: Server, +) -> None: + """Pinning a control endpoint never runs the user's display-message hooks.""" + from libtmux.test.retry import retry_until + + server.cmd("set-hook", "-g", "after-display-message", "set -ag @probe-after x") + server.cmd("set-hook", "-g", "command-error", "set -ag @probe-error y") + # Acquisition must not run any query a user could hook, such as the old + # list-clients name lookup. + for hook in ( + "after-list-clients", + "after-list-panes", + "after-list-sessions", + "after-refresh-client", + ): + server.cmd("set-hook", "-g", hook, "set -ag @probe-extra z") + with control_mode(): + pass + + # Hooks run in order, so one control firing afterwards proves the pinning + # left none: each sentinel holds exactly one letter. + server.cmd("display-message", "-p", "control") + assert server.cmd("kill-session", "-t", "=missing").returncode != 0 + + def settled() -> bool: + return server.cmd("show-option", "-gqv", "@probe-after").stdout == [ + "x" + ] and server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] + + assert retry_until(settled, seconds=5, raises=False) + assert server.cmd("show-option", "-gqv", "@probe-after").stdout == ["x"] + assert server.cmd("show-option", "-gqv", "@probe-error").stdout == ["y"] + assert server.cmd("show-option", "-gqv", "@probe-extra").stdout == [] + + +def test_control_identity_reports_an_unusual_socket_path( + control_mode: t.Callable[[], ControlMode], +) -> None: + """Quotes, separators and commas in a socket path survive the probe.""" + import shutil + import tempfile + + from libtmux import Server + from libtmux._internal.control_mode import _identity + + binary, _ = control_mode().server._version_endpoint() + folder = tempfile.mkdtemp(prefix="lt-") + path = pathlib.Path(folder) / "q'\"; {a} #~$x,\\b" + path.mkdir() + socket = path / "s" + server = Server(tmux_bin=binary, socket_path=str(socket), config_file="/dev/null") + try: + server.new_session("identity", window_command="exec sleep 123") + report = _identity(binary, ["-S", str(socket)]) + assert report.split(",", 3)[3] == str(socket) + finally: + server.cmd("kill-server") + shutil.rmtree(folder, ignore_errors=True) + + def test_control_mode_cleanup( control_mode: t.Callable[[], ControlMode], server: Server, @@ -69,31 +229,104 @@ def test_control_mode_client_name_matches_spawned_client( assert (str(second._proc.pid), second.client_name) in clients -@pytest.mark.skipif( - sys.flags.utf8_mode != 0, - reason="PYTHONUTF8 mode forces UTF-8, masking the locale bug", -) def test_control_mode_stdout_preserves_non_ascii_output( control_mode: t.Callable[[], ControlMode], + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Control-mode stdout must preserve non-ASCII tmux output.""" + """Control output stays UTF-8 when the client inherits a C locale.""" old_lc_ctype = locale.setlocale(locale.LC_CTYPE) try: locale.setlocale(locale.LC_CTYPE, "C") + for key in ("LC_ALL", "LC_CTYPE", "LANG"): + monkeypatch.setenv(key, "C") with control_mode() as ctl: + assert isinstance(ctl.stdout, io.TextIOWrapper) + assert ctl.stdout.encoding == "utf-8" os.write( ctl._write_fd, f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(), ) - - for _ in range(20): - ready, _, _ = select.select([ctl.stdout], [], [], 1) - assert ready, "timed out waiting for control-mode output" - - line = ctl.stdout.readline() - if FORMAT_SEPARATOR in line: + # Read bytes to avoid waiting on an fd after TextIOWrapper prefetched. + output = bytearray() + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + ready, _, _ = select.select( + [ctl.stdout.fileno()], [], [], max(0, deadline - time.monotonic()) + ) + if not ready: + break + chunk = os.read(ctl.stdout.fileno(), 65536) + if not chunk: break - else: - pytest.fail("FORMAT_SEPARATOR U+241E not found in control output") + output.extend(chunk) + if FORMAT_SEPARATOR.encode() in output: + break + assert FORMAT_SEPARATOR in output.decode("utf-8"), repr(bytes(output)) finally: locale.setlocale(locale.LC_CTYPE, old_lc_ctype) + + +def test_control_uses_a_short_private_socket_alias( + control_mode: t.Callable[[], ControlMode], +) -> None: + """A valid long native endpoint can still acquire a pinned control client.""" + import tempfile + + from libtmux import Server + + qualified = control_mode() + binary, _ = qualified.server._version_endpoint() + with tempfile.TemporaryDirectory(prefix="long-control-") as folder: + root = pathlib.Path(folder) + parent = root / ("p" * (96 - len(os.fsencode(root)) - 3)) + parent.mkdir(mode=0o700) + socket = parent / "s" + server = Server( + tmux_bin=binary, socket_path=str(socket), config_file="/dev/null" + ) + try: + session = server.new_session( + "long-control", window_command="exec sleep 123" + ) + assert server.sessions + context = ControlMode(server, session) + with context: + argv = context._proc.args + assert isinstance(argv, list) + alias = pathlib.Path(argv[argv.index("-S") + 1]) + assert len(os.fsencode(alias)) < len(os.fsencode(socket)) + assert alias.stat().st_ino == socket.stat().st_ino + assert alias.stat().st_dev == socket.stat().st_dev + assert alias.parent.stat().st_mode & 0o777 == 0o700 + assert context.client_name + assert context._proc.poll() is not None + assert not alias.exists() and not alias.parent.exists() + assert not server.list_clients() + finally: + server.kill() + + +def test_control_carrier_retries_a_cross_device_link( + control_mode: t.Callable[[], ControlMode], monkeypatch: pytest.MonkeyPatch +) -> None: + """A cross-device temporary root falls back to an owned peer alias.""" + import errno + + context = control_mode() + original_link = os.link + attempted: list[pathlib.Path] = [] + + def link(source: t.Any, target: t.Any, **kwargs: t.Any) -> None: + attempted.append(pathlib.Path(target)) + if len(attempted) == 1: + raise OSError(errno.EXDEV, "cross-device link") + original_link(source, target, **kwargs) + + monkeypatch.setattr(os, "link", link) + with context: + assert len(attempted) == 2 + alias = attempted[-1] + assert alias.is_socket() + assert context.client_name + assert all(not item.exists() and not item.parent.exists() for item in attempted) + assert context._proc.poll() is not None 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_optional_features.py b/tests/test_optional_features.py new file mode 100644 index 0000000000..8dfa161d96 --- /dev/null +++ b/tests/test_optional_features.py @@ -0,0 +1,321 @@ +"""Optional operations preserve the ordinary 3.3a support boundary.""" + +from __future__ import annotations + +import shlex +import subprocess +import typing as t + +import pytest + +from libtmux import Pane, Server, Session, exc +from libtmux._internal.support import versions +from libtmux.common import tmux_cmd + +if t.TYPE_CHECKING: + import pathlib + + +def _reply(argv: list[str], version: str) -> subprocess.CompletedProcess[str]: + """Answer a version probe the way a live daemon does.""" + return subprocess.CompletedProcess( + argv, 1, "", f"unknown command: {argv[-1].replace('#{version}', version)}\n" + ) + + +@pytest.mark.parametrize("verb", ["select-layout", "selectl", "select-l"]) +def test_malformed_layout_is_refused_before_any_batch_effect( + session: Session, tmp_path: pathlib.Path, verb: str +) -> None: + """Refuse the 3.3a layout error path before the batch prefix runs.""" + server = session.server + marker = tmp_path / "layout-prefix-effect" + command = ( + "run-shell", + f": > {shlex.quote(str(marker))}", + ";", + verb, + "-t", + session.active_window.window_id, + "not-a-layout", + ) + if server._get_version() == "3.3a": + with pytest.raises(exc.InvalidLayout, match="layout"): + server.cmd(*command) + assert not marker.exists() + else: + result = server.cmd(*command) + assert result.returncode != 0 + assert marker.exists() + assert server.is_alive() + + +def test_refused_layout_is_a_library_exception() -> None: + """Callers that catch LibTmuxException see the refusal on every release.""" + assert issubclass(exc.InvalidLayout, exc.LibTmuxException) + assert issubclass(exc.InvalidLayout, ValueError) + + +def test_33a_layout_validation_retains_native_safe_forms(session: Session) -> None: + """Preset prefixes, serialized forms and ignored operands stay available.""" + server = session.server + if server._get_version() != "3.3a": + pytest.skip("the narrow layout error guard applies to tmux 3.3a") + target = session.active_window.window_id + for flag in ("-n", "-p", "-E"): + assert server.cmd("selectl", flag, "-t", target, "not-a-layout").returncode == 0 + assert server.cmd("select-layout", "-t", target, "even-h").returncode == 0 + layout = server.cmd( + "display-message", "-p", "-t", target, "#{window_layout}" + ).stdout[0] + assert server.cmd("select-layout", "-t", target, layout).returncode == 0 + assert server.cmd("select-layout", "-o", "-t", target, layout).returncode == 0 + assert server.cmd("select-layout", "-o", "-t", target).returncode == 0 + invalid = server.cmd("select-layout", "-t", target, "0000,not-a-layout") + assert invalid.returncode != 0 + with pytest.raises(exc.InvalidLayout, match="layout"): + server.cmd("select-layout", "-o", "-t", target, "tiled") + assert server.is_alive() + + +@pytest.mark.parametrize("window_name", [None, "sole-after-break"]) +@pytest.mark.parametrize("stale_window", [False, True]) +def test_single_pane_break_returns_the_actual_window( + session: Session, window_name: str | None, stale_window: bool +) -> None: + """Old sole-pane relinking succeeds without printing its requested ID.""" + window = session.active_window + window.rename_window("sole-before-break") + pane = window.active_pane + assert pane is not None + before = window.window_id + expected_name = "sole-before-break" + if stale_window: + peer = session.new_window(window_name="actual-before-break") + peer_pane = peer.active_pane + assert peer_pane is not None + swapped = session.server.cmd( + "swap-pane", "-d", "-s", pane.pane_id, "-t", peer_pane.pane_id + ) + assert swapped.returncode == 0 + before = peer.window_id + expected_name = "actual-before-break" + if session.server._get_version() == "3.7" and window_name is None: + with pytest.raises(exc.VersionTooLow, match="unnamed break-pane"): + pane.break_pane() + pane.refresh() + assert pane.window.window_id == before + assert pane.window.window_name == expected_name + return + result = pane.break_pane(window_name=window_name) + assert result.window_id == before + assert result.window_name == (window_name or expected_name) + assert [item.pane_id for item in result.panes] == [pane.pane_id] + + +@pytest.mark.parametrize( + ("version", "arguments", "reason"), + [ + ("3.3a", ["delete-buffer", "-b", "missing"], "deletion"), + ("3.3a", ["deleteb", "-bmissing"], "deletion"), + ("3.3a", ["delete-bu", "-b", ""], "deletion"), + ("3.3a", ["set-buffer", "-bmissing", "-n", "new"], "rename"), + ("3.3a", ["setb", "-abmissing", "-nnew"], "rename"), + ("3.3a", ["set-b", "-t", "-n", "-bmissing", "-nnew"], "rename"), + ( + "3.3a", + ["set-option", "-g", "@probe", "changed;", "deleteb", "-bmissing"], + "deletion", + ), + ("3.4", ["split-window", "-p25"], "percentage"), + ("3.4", ["splitw", "-dp", "25"], "percentage"), + ("3.7", ["break-pane", "-s", "%1"], "break-pane"), + ("3.7", ["breakp", "-n", "requested", "-s", "%1"], "break-pane"), + ("3.7", ["break-p", "-nrequested", "-s%1"], "break-pane"), + ], +) +def test_refused_native_feature_has_no_dispatch( + monkeypatch: pytest.MonkeyPatch, + version: str, + arguments: list[str], + reason: str, +) -> None: + """Direct aliases, abbreviations and flat batches share one version check.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: _reply(argv, version), + ) + + def unexpected(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("requested feature process started before refusal") + + monkeypatch.setattr(subprocess, "Popen", unexpected) + with pytest.raises(exc.VersionTooLow, match=reason): + tmux_cmd(*arguments, tmux_bin="/test/tmux") + + +@pytest.mark.parametrize( + ("version", "arguments"), + [ + ("3.3a", ["set", "-g", "status", "off"]), + ("3.3a", ["setw", "-g", "mode-keys", "vi"]), + ("3.3a", ["delete-buffer"]), + ("3.3a", ["set-buffer", "-b", "name", "data"]), + ("3.3a", ["set-buffer", "-n", "new"]), + ("3.3a", ["paste-buffer", "-bmissing", "-d"]), + ("3.3a", ["display-message", "-p", "delete-buffer", "-bmissing"]), + ("3.3a", ["run-shell", "printf native-output"]), + ("3.3a", ["if-shell", "true", "delete-buffer -b missing", ""]), + ("3.3a", ["display-message", "-p", "literal\\;", "delete-buffer"]), + ("3.4", ["delete-buffer", "-bmissing"]), + ("3.4", ["set-buffer", "-bmissing", "-nnew"]), + ("3.4", ["split-window", "-l25%"]), + ("3.5", ["split-window", "-p25"]), + ("3.6", ["break-pane", "-s%1"]), + ("3.7a", ["break-pane", "-s%1"]), + ], +) +def test_native_feature_scope_preserves_arguments( + monkeypatch: pytest.MonkeyPatch, version: str, arguments: list[str] +) -> None: + """Keep ordinary commands and opaque programs in their native form.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: _reply(argv, version), + ) + guarded, _ = versions.prepare(["/test/tmux", *arguments]) + assert guarded[-len(arguments) :] == arguments + + +@pytest.mark.parametrize("control", ["-C", "-CC"]) +def test_raw_control_requires_owned_control_helper(control: str) -> None: + """Synchronous raw dispatch cannot own a pinned control client's lifetime.""" + with pytest.raises(exc.LibTmuxException, match="use ControlMode"): + versions.prepare(["/test/tmux", control, "attach-session"]) + + +def test_capture_refusal_uses_actual_daemon(monkeypatch: pytest.MonkeyPatch) -> None: + """A modern configured executable cannot qualify older job stdout.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: ( + subprocess.CompletedProcess(argv, 0, "tmux 3.7d\n", "") + if "-V" in argv + else _reply(argv, "3.3a") + ), + ) + + def unexpected(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("captured job started on an ineligible daemon") + + monkeypatch.setattr(subprocess, "Popen", unexpected) + server = Server(tmux_bin="/test/tmux") + assert not server._has_gte_version("3.4") + with pytest.raises(exc.VersionTooLow, match=r"3\.5"): + server.run_shell("touch /must-not-exist") + + +def test_unnamed_exact_37_break_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """Refuse before topology-dependent native naming or a NULL dereference.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: _reply(argv, "3.7"), + ) + pane = Pane(server=Server(tmux_bin="/test/tmux"), pane_id="%1") + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + pane.break_pane() + + +def test_capability_selection_cannot_cross_daemon_release( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A new probe cannot silently accept flags selected for another release.""" + monkeypatch.setattr( + versions, + "_run", + lambda argv: _reply(argv, "3.3a"), + ) + with pytest.raises(exc.VersionTooLow, match="selecting"): + versions.prepare( + ["/test/tmux", "run-shell", "-b", "true"], expected_version="3.7d" + ) + + +@pytest.mark.parametrize("multiple_panes", [False, True]) +def test_exact_37_break_names(session: Session, multiple_panes: bool) -> None: + """Both native topology branches preserve refusals and explicit names.""" + server = session.server + if server._get_version() != "3.7": + pytest.skip("exact tmux 3.7 regression") + window = session.new_window(window_name="original", window_shell="exec sleep 123") + pane = window.active_pane + assert pane is not None + if multiple_panes: + pane = pane.split(shell="exec sleep 123") + before = [(item.window_id, item.window_name) for item in session.windows] + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + pane.break_pane() + with pytest.raises(exc.VersionTooLow, match="raw break-pane"): + server.cmd("breakp", "-nignored", "-s", pane.pane_id) + assert [(item.window_id, item.window_name) for item in session.windows] == before + broken = pane.break_pane(window_name="requested") + assert broken.window_name == "requested" + + +def test_option_aliases_keep_native_meaning(session: Session) -> None: + """Exact aliases take precedence over buffer-command prefixes.""" + server = session.server + assert server.cmd("set", "-g", "@optional_alias", "server-value").returncode == 0 + assert server.cmd("setw", "-g", "mode-keys", "vi").returncode == 0 + assert server.cmd("show-options", "-gqv", "@optional_alias").stdout == [ + "server-value" + ] + assert server.cmd("show-window-options", "-gv", "mode-keys").stdout == ["vi"] + + +NAME_COMMANDS = [ + ["new-window", "-n", "a.b"], + ["neww", "-dn", "a:b", "-t", "=s"], + ["new-session", "-d", "-s", "a.b"], + ["new", "-d", "-n", "x:y", "-s", "ok"], + ["rename-window", "-t", "@1", "a.b"], + ["renamew", "a:b"], + ["rename-session", "-t", "=s", "a.b"], + ["rename", "a:b"], + ["set-option", "-g", "@x", "1", ";", "new-window", "-n", "a.b"], +] + + +@pytest.mark.parametrize("command", NAME_COMMANDS) +def test_exact_37_refuses_delimiters_in_window_and_session_names( + command: list[str], +) -> None: + """Window and session names are checked before the batch runs on 3.7.""" + with pytest.raises(exc.VersionTooLow, match="invalid window or session name"): + versions._require_commands("3.7", command, repaired_break=False) + + +@pytest.mark.parametrize("command", NAME_COMMANDS) +@pytest.mark.parametrize("version", ["3.3a", "3.6b", "3.7a", "3.8", "3.8-rc2"]) +def test_other_releases_keep_native_names(version: str, command: list[str]) -> None: + """Only the 3.7 release rejected these names; 3.7a and the rest accept them.""" + versions._require_commands(version, command, repaired_break=False) + + +@pytest.mark.parametrize( + "command", + [ + ["new-window", "-n", "plain"], + ["new-window", "-t", "=s:1", "-n", "plain"], + ["rename-window", "-t", "@1", "plain"], + ["new-session", "-d", "-s", "plain", "-n", "also-plain"], + ["list-windows", "-F", "#{window_name}.x"], + ], +) +def test_exact_37_allows_names_without_delimiters(command: list[str]) -> None: + """Targets and formats may hold delimiters; only the name does not.""" + versions._require_commands("3.7", command, repaired_break=False) 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..f5e2799a52 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,13 +888,6 @@ 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 - - # 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"): - pytest.skip("split-window -p was broken in tmux 3.4 (fixed in 3.5)") - window = session.new_window(window_name="test_split_pct") window.resize(height=40, width=80) pane = window.active_pane @@ -949,7 +942,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 +1084,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 +1094,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 +1103,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 +1119,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 +1150,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) @@ -1286,7 +1279,7 @@ def test_join_pane(session: Session) -> None: new_pane = pane.split(shell="sleep 1m") assert len(window.panes) == 2 - new_window = new_pane.break_pane() + new_window = new_pane.break_pane(window_name="join-source") window.refresh() assert len(window.panes) == 1 @@ -1309,7 +1302,7 @@ def test_join_pane_horizontal(session: Session) -> None: assert pane is not None new_pane = pane.split(shell="sleep 1m") - new_pane.break_pane() + new_pane.break_pane(window_name="join-source") new_pane.join(window, vertical=False) window.refresh() @@ -1326,6 +1319,13 @@ def test_break_pane_basic(session: Session) -> None: new_pane = pane.split(shell="sleep 1m") assert len(window.panes) == 2 + if session.server._get_version() == "3.7": + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + new_pane.break_pane() + assert len(session.windows) == initial_window_count + assert len(window.panes) == 2 + return + new_window = new_pane.break_pane() session.refresh() @@ -1358,7 +1358,18 @@ 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) + if session.server._get_version() == "3.7": + with pytest.raises(exc.VersionTooLow, match="provide window_name"): + new_pane.break_pane() + assert len(window.panes) == 2 + return broken = new_pane.break_pane() assert broken.window_name == "sleep" @@ -1635,7 +1646,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 +1660,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 +1705,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 +1753,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 +1775,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 +1820,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 +1841,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 +1866,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 +1885,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..a3ab60b153 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 @@ -444,35 +444,15 @@ def output_ready() -> bool: assert len(output) > 0 -def test_capture_pane_trim_trailing_warning( - session: Session, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test that trim_trailing issues a warning on tmux < 3.4.""" - import warnings - - from libtmux import pane as pane_module - - # 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") - - pane = session.active_window.split(shell="sh") - - def prompt_ready() -> bool: - return "$" in "\n".join(pane.capture_pane()) - - retry_until(prompt_ready, 2, raises=True) - - # Should issue a warning - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") +def test_capture_pane_trim_trailing_warning(session: Session) -> None: + """Warn and omit the unavailable flag on the actual older daemon.""" + if session.server._has_gte_version("3.4"): + pytest.skip("requires a daemon without capture-pane -T") + pane = session.active_window.active_pane + assert pane is not None + with pytest.warns(UserWarning, match="trim_trailing requires tmux 3.4"): pane.capture_pane(trim_trailing=True) - # Check warning was issued - assert len(w) == 1 - assert "trim_trailing requires tmux 3.4+" in str(w[0].message) - @pytest.mark.parametrize( ("kwargs", "min_tmux_version"), @@ -497,7 +477,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 @@ -524,7 +504,8 @@ def test_capture_pane_to_buffer(session: Session) -> None: contents = session.server.cmd("show-buffer", "-b", "cap_test_buf").stdout assert any("BUFFER_CAPTURE_MARKER" in line for line in contents) - session.server.cmd("delete-buffer", "-b", "cap_test_buf") + if session.server._has_gte_version("3.4"): + session.server.delete_buffer(buffer_name="cap_test_buf") def test_capture_pane_pending_emits_dash_P( diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..74db0357cf 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") @@ -1105,16 +1104,15 @@ def test_wait_for_set_flag(server: Server) -> None: server.wait_for("test_channel_set", set_flag=True) -def test_run_shell_basic(server: Server) -> None: - """Test Server.run_shell() executes command and returns output.""" - from libtmux.common import has_gte_version - - # 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"): - pytest.skip("run-shell stdout passthrough requires tmux 3.5+") - +def test_run_shell_basic(server: Server, tmp_path: pathlib.Path) -> None: + """Capture stdout where supported and refuse before jobs on older daemons.""" server.new_session(session_name="run_shell_test") + if not server._has_gte_version("3.5"): + marker = tmp_path / "captured-job" + with pytest.raises(exc.VersionTooLow, match=r"3\.5"): + server.run_shell(f"touch {marker}") + assert not marker.exists() + return result = server.run_shell("echo hello_from_run_shell") assert result is not None assert any("hello_from_run_shell" in line for line in result) @@ -1129,9 +1127,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 +1140,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,51 +1158,38 @@ 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: with pytest.warns(UserWarning, match=r"args requires tmux 3.7"): - server.run_shell("echo plain", args=["alpha"]) + server.run_shell("echo plain", args=["alpha"], background=True) def test_run_shell_cwd_warns_on_old_tmux( server: Server, - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, ) -> 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). - """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + """An actual older daemon warns when a background job cannot select cwd.""" server.new_session(session_name="run_shell_cwd_warn_test") + if server._has_gte_version("3.4"): + pytest.skip("requires a supported daemon below 3.4") with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): - server.run_shell("true", cwd=tmp_path) + server.run_shell("true", cwd=tmp_path, background=True) def test_run_shell_show_stderr_warns_on_old_tmux( server: Server, - monkeypatch: pytest.MonkeyPatch, ) -> 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. - """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + """An actual older daemon warns about unavailable stderr combination.""" server.new_session(session_name="run_shell_stderr_warn_test") + if server._has_gte_version("3.6"): + pytest.skip("requires a supported daemon below 3.6") with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): - server.run_shell("true", show_stderr=True) + server.run_shell("true", show_stderr=True, background=True) class BufferCase(t.NamedTuple): @@ -1280,6 +1265,12 @@ def test_buffer_delete(server: Server) -> None: # Verify it exists assert server.show_buffer(buffer_name="del_buf") == "to_delete" + if not server._has_gte_version("3.4"): + with pytest.raises(exc.VersionTooLow, match="named-buffer deletion"): + server.delete_buffer(buffer_name="del_buf") + assert server.show_buffer(buffer_name="del_buf") == "to_delete" + return + # Delete it server.delete_buffer(buffer_name="del_buf") @@ -1699,13 +1690,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 +1727,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..ba9329f397 --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,726 @@ +"""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_commands", + "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_public_command_policy() -> None: + """Consumers can ask whether a checked release may run a literal command.""" + with pytest.raises(exc.VersionTooLow, match=r"3\.4"): + support.require_commands("3.3a", ["delete-buffer", "-b", "named"]) + with pytest.raises(exc.InvalidLayout): + support.require_commands("3.3a", ["select-layout", "not-a-layout"]) + support.require_commands("3.4", ["delete-buffer", "-b", "named"]) + support.require_commands("3.3a", ["paste-buffer", "-d", "-b", "named"]) + with pytest.raises(exc.VersionTooLow, match="break-pane"): + support.require_commands("3.7", ["break-pane", "-s", "%1"]) + support.require_commands( + "3.7", ["break-pane", "-n", "named", "-s", "%1"], repaired_break=True + ) + + +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..650c7ae66d 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -264,9 +264,7 @@ class WindowNameSpecialCharFixture(t.NamedTuple): window_name: str -# The tmux 3.7 point release briefly rejected ':' and '.' in window names, -# then reverted it in 3.7a as "overly pernickety" (tmux commit 166267c8). -# Every tmux libtmux supports -- 3.2a-3.6 and 3.7a onward -- accepts them. +# Exact tmux 3.7 rejects these names; 3.7a reverts that validation. WINDOW_NAME_SPECIAL_CHAR_FIXTURES: list[WindowNameSpecialCharFixture] = [ WindowNameSpecialCharFixture(test_id="colon", window_name="project:frontend"), WindowNameSpecialCharFixture(test_id="period", window_name="app-v1.0"), @@ -283,15 +281,15 @@ def test_new_window_name_colon_period_accepted( test_id: str, window_name: str, ) -> None: - """Window names with ':' and '.' are accepted verbatim. - - 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. - """ + """Preserve native names or exact 3.7's rejection before creation.""" + if session.server._get_version() == "3.7": + before = [window.window_id for window in session.windows] + with pytest.raises( + exc.LibTmuxException, match="invalid window or session name" + ): + session.new_window(window_name=window_name) + assert [window.window_id for window in session.windows] == before + return window = session.new_window(window_name=window_name) assert window.window_name == window_name window.kill() @@ -1163,9 +1161,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 +1189,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 +1318,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()