Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ Picks up libtmux 0.63.0, whose version check covers the
connected daemon before every command. tmuxp checks a daemon once per workspace
build, and a libtmux without that module is refused before custom code runs.

### What's new

#### Optional tmux operations

Plugins, custom builders, and `tmuxp shell` require a libtmux backend with
libtmux's command policy (`libtmux._internal.support.require_commands`). Individual
operations refuse before effects when the daemon lacks their requirement:
control clients need tmux 3.7+, exact named-buffer operations need 3.4+, and
captured shell stdout needs 3.5+. Ordinary workspace operations remain
available on 3.3a. Percentage splitting uses a portable encoding. Exact tmux
3.7 pane breaks require a window name and use the wrapper's name repair. See
{doc}`project/compatibility` for each operation's contract. Malformed literal
layouts refuse before dispatch on tmux 3.3a, preserving running sessions. Valid
built-in names and serialized layouts remain available.

### Documentation

#### Diagrams stay readable on narrow screens (#1076)
Expand Down
12 changes: 12 additions & 0 deletions MIGRATION
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ This release requires libtmux 0.63 or newer. Until libtmux 0.63 is released,
install a checkout with `uv sync --locked`.
Help, diagnostics, and configuration conversion do not require tmux.

### Optional tmux operations

Custom builders, plugins, and `tmuxp shell` use libtmux's command policy.
Control clients require tmux 3.7+, exact named-buffer deletion and renaming
require 3.4+, and captured foreground shell output requires 3.5+. On exact tmux
3.7, provide a window name to `Pane.break_pane`; unnamed and raw breaks refuse
before dispatch. Ordinary tmux 3.3a workspace operations remain available.
Percentage splits use the portable spelling. On tmux 3.3a, malformed literal
layouts refuse before dispatch; valid built-in names and serialized layouts
remain available. A libtmux without `libtmux._internal.support.require_commands` is
refused.

## tmuxp 1.18.0 (2022-10-30)

**Restructuring** (#840)
Expand Down
26 changes: 23 additions & 3 deletions docs/project/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,32 @@ version differs from the version checked for that command.
`--help`, `--version`, `debug-info`, and workspace conversion remain available
without a running server or an installed tmux executable.

## Optional operations

Plugins, custom builders, and `tmuxp shell` receive libtmux objects. Their
optional operations have individual tmux requirements:

| Operation | Requirement |
| --- | --- |
| Acquire a control-mode client | Stable tmux 3.7+ and a natively compatible client |
| Preserve exact named-buffer identity when deleting, renaming, or aborting cleanup | tmux 3.4+ |
| Return foreground `run-shell` stdout | tmux 3.5+ |
| Break a pane on exact tmux 3.7 | Supply a window name; the wrapper repairs the native name handling. Unnamed and raw calls refuse. |
| Split by percentage | Available on tmux 3.3a+ through the portable `-l N%` spelling |
| Select a literal layout on tmux 3.3a | Use a unique built-in layout name or a serialized layout. Malformed headers refuse before the native 3.3a error path. |

An unavailable operation refuses before its native command runs. An
ordinary workspace still builds, splits, captures, and freezes on tmux
3.3a. Background shell jobs and jobs directed to a pane keep their separate
execution-only contract.

## Installing this checkout

This branch requires libtmux 0.63 or newer, the first release that carries
the daemon version check, and `pyproject.toml` says so (`libtmux~=0.63.0`).
Until that release exists, `uv.lock` pins an exact development revision.
Install the checkout with its lockfile:
the daemon version check and its `require_commands` policy, and
`pyproject.toml` says so (`libtmux~=0.63.0`). Until that release exists,
`uv.lock` pins an exact development revision. Install the checkout with its
lockfile:

```console
$ uv sync --locked
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ exclude = [
]

[tool.uv.sources]
libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "f6e4630a15f342382487a60e1e1477abcd52294d" }
libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "5df9cb3e89647577b7dd8484f844ffe1053129bb" }

[tool.uv.exclude-newer-package]
# git-pull packages release in lockstep with their workspaces, so a
Expand Down
2 changes: 1 addition & 1 deletion src/tmuxp/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _require_backend() -> t.Any:
raise exc.LibtmuxCompatibilityError(message) from error
if not all(
callable(getattr(support, name, None))
for name in ("require_version", "server_version")
for name in ("require_version", "server_version", "require_commands")
) or not isinstance(getattr(support, "MINIMUM", None), str):
raise exc.LibtmuxCompatibilityError(message)
support.require_version(support.MINIMUM, minimum="3.3a")
Expand Down
274 changes: 274 additions & 0 deletions tests/test_feature_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
"""Optional-feature command policy before custom workspace code."""

from __future__ import annotations

import json
import pathlib
import re
import shlex
import typing as t
import uuid

import pytest
from libtmux._internal import support
from libtmux.exc import LibTmuxException
from libtmux.server import Server
from libtmux.session import Session

from tmuxp import cli, exc
from tmuxp.cli import load
from tmuxp.workspace import freezer


@pytest.mark.parametrize("kind", ["builder", "plugin"])
def test_backend_without_command_policy_refuses_before_import(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
kind: str,
) -> None:
"""A libtmux that cannot check literal commands cannot run custom code."""
monkeypatch.delattr(support, "require_commands")
effect = tmp_path / "imported"
module = "tmuxp_policy_" + uuid.uuid4().hex
(tmp_path / f"{module}.py").write_text(
f"from pathlib import Path\nPath({str(effect)!r}).touch()\n"
)
config: dict[str, t.Any] = {
"session_name": "policy",
"windows": [{"panes": ["true"]}],
}
if kind == "builder":
config.update(
workspace_builder=f"{module}:Builder",
workspace_builder_paths=[str(tmp_path)],
)
else:
monkeypatch.syspath_prepend(str(tmp_path))
config["plugins"] = [f"{module}.Plugin"]
path = tmp_path / "workspace.json"
path.write_text(json.dumps(config))
with pytest.raises(exc.LibtmuxCompatibilityError, match=r"libtmux 0\.63"):
load.load_workspace(path, detached=True)
assert not effect.exists()


def test_backend_without_command_policy_refuses_shell_code(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Refuse a shell command before it can execute custom Python."""
monkeypatch.delattr(support, "require_commands")
effect = tmp_path / "executed"
command = f"from pathlib import Path; Path({str(effect)!r}).touch()"
with pytest.raises(SystemExit) as error:
cli.cli(["shell", "-c", command])
assert error.value.code == 1
assert not effect.exists()
output = capsys.readouterr()
assert "libtmux 0.63" in output.out
assert "Traceback" not in output.err


def test_ordinary_workspace_and_percentage_split(
tmp_path: pathlib.Path, server: Server
) -> None:
"""Keep ordinary workspaces and portable percentage splitting available."""
config = tmp_path / "ordinary.json"
config.write_text(
json.dumps(
{
"session_name": "ordinary",
"windows": [{"layout": "even-horizontal", "panes": ["true", "true"]}],
}
)
)
session = load.load_workspace(
config, socket_name=server.socket_name, detached=True, no_progress=True
)
assert session is not None
window = session.active_window
assert len(window.panes) == 2
pane = window.active_pane
assert pane is not None
assert pane.pane_height is not None
original_height = int(pane.pane_height)
split = pane.split(percentage=30)
assert split.pane_height is not None
assert abs(int(split.pane_height) - original_height * 0.3) <= 2
assert len(window.panes) == 3
assert isinstance(split.capture_pane(), list)
frozen = freezer.freeze(session)
assert frozen["session_name"] == "ordinary"
assert len(frozen["windows"][0]["panes"]) == 3


def test_serialized_workspace_layout_remains_available(
tmp_path: pathlib.Path, server: Server, session: Session
) -> None:
"""Load and freeze a workspace with a native serialized layout."""
layout = session.active_window.window_layout
assert layout is not None
config = tmp_path / "serialized.json"
config.write_text(
json.dumps(
{
"session_name": "serialized",
"windows": [{"layout": layout, "panes": ["true"]}],
}
)
)
loaded = load.load_workspace(
config, socket_name=server.socket_name, detached=True, no_progress=True
)
assert loaded is not None
assert len(loaded.active_window.panes) == 1
pane = loaded.active_window.active_pane
assert pane is not None
assert isinstance(pane.capture_pane(), list)
frozen = freezer.freeze(loaded)
assert frozen["windows"][0]["layout"] == loaded.active_window.window_layout


def test_invalid_workspace_layout_preserves_running_sessions(
tmp_path: pathlib.Path, server: Server, session: Session
) -> None:
"""A bad layout cannot terminate the daemon or send the pane command."""
effect = tmp_path / "pane-command"
config = tmp_path / "invalid-layout.json"
config.write_text(
json.dumps(
{
"session_name": "invalid-layout",
"windows": [
{
"layout": "not-a-layout",
"panes": [f"touch {shlex.quote(str(effect))}"],
}
],
}
)
)
with pytest.raises(LibTmuxException, match="layout"):
load.load_workspace(
config, socket_name=server.socket_name, detached=True, no_progress=True
)
assert not effect.exists()
assert session.name is not None
assert server.has_session(session.name)
assert session.active_window.panes


def test_shell_named_buffer_policy(
server: Server, session: Session, capsys: pytest.CaptureFixture[str]
) -> None:
"""A shell uses the backend's exact named-buffer requirement."""
socket_name = server.socket_name
session_name = session.name
assert socket_name is not None and session_name is not None
name = "tmuxp_feature_" + uuid.uuid4().hex
server.set_buffer("retained", buffer_name=name)
argv = [
"shell",
"-L",
socket_name,
session_name,
"-c",
f"server.delete_buffer(buffer_name={name!r})",
]
if server._get_version() == "3.3a":
with pytest.raises(SystemExit) as error:
cli.cli(argv)
assert error.value.code == 1
assert "3.4" in capsys.readouterr().out
assert server.show_buffer(buffer_name=name) == "retained"
else:
cli.cli(argv)
remaining = server.cmd("list-buffers", "-F", "#{buffer_name}")
assert name not in remaining.stdout


def test_shell_captured_job_policy(
tmp_path: pathlib.Path,
server: Server,
session: Session,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Refuse unavailable captured output before starting the shell job."""
socket_name = server.socket_name
session_name = session.name
assert socket_name is not None and session_name is not None
effect = tmp_path / "job-executed"
command = f"printf tmuxp-feature; touch {shlex.quote(str(effect))}"
argv = [
"shell",
"-L",
socket_name,
session_name,
"-c",
f"assert server.run_shell({command!r}) == ['tmuxp-feature']",
]
if server._get_version() in {"3.3a", "3.4"}:
with pytest.raises(SystemExit) as error:
cli.cli(argv)
assert error.value.code == 1
assert "3.5" in capsys.readouterr().out
assert not effect.exists()
else:
cli.cli(argv)
assert effect.exists()


def test_shell_control_acquisition_policy(
server: Server, session: Session, capsys: pytest.CaptureFixture[str]
) -> None:
"""Apply the control-client requirement inside a tmuxp shell."""
socket_name = server.socket_name
session_name = session.name
assert socket_name is not None and session_name is not None
command = (
"from libtmux._internal.control_mode import ControlMode\n"
"with ControlMode(server=server, session=session) as client:\n"
" assert client.client_name\n"
)
argv = ["shell", "-L", socket_name, session_name, "-c", command]
version = server._get_version()
release = re.fullmatch(r"(\d+)\.(\d+)[a-z]?", version)
if release is None or tuple(map(int, release.groups())) < (3, 7):
with pytest.raises(SystemExit) as error:
cli.cli(argv)
assert error.value.code == 1
assert "ControlMode" in capsys.readouterr().out
assert not server.list_clients()
else:
cli.cli(argv)
assert server.has_session(session_name)


def test_shell_break_policy(
server: Server, session: Session, capsys: pytest.CaptureFixture[str]
) -> None:
"""Keep a named break available and refuse an unsafe exact-3.7 break."""
socket_name = server.socket_name
session_name = session.name
assert socket_name is not None and session_name is not None
source = session.active_window
pane = source.active_pane
assert pane is not None
pane.split()
argv = ["shell", "-L", socket_name, session_name, "-c"]
if server._get_version() == "3.7":
panes = {item.pane_id for item in source.panes}
with pytest.raises(SystemExit) as error:
cli.cli([*argv, "pane.break_pane()"])
assert error.value.code == 1
assert "window_name" in capsys.readouterr().out
assert len(session.windows) == 1
assert {item.pane_id for item in source.panes} == panes
command = (
"result = pane.break_pane(window_name='tmuxp-break'); "
"assert result.window_name == 'tmuxp-break'"
)
cli.cli([*argv, command])
assert len(session.windows) == 2
assert session.windows.get(window_name="tmuxp-break") is not None
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading