diff --git a/CHANGES b/CHANGES index a7af12a5f..115d0bc43 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,28 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Find where you are running (#714) + +Code running *inside* tmux — a script in a split, a hook, a test harness, an +agent — can now ask libtmux where it is. +{meth}`Pane.from_env() ` returns the pane the calling +process is running in, and {meth}`Server.from_env() `, +{meth}`Session.from_env() ` and +{meth}`Window.from_env() ` do the same for the rest of +the hierarchy. Each takes an optional environment mapping, so code that locates +itself stays testable outside a pane. + +The answer stays true as tmux moves things around: it survives a `move-window`, +and for a window linked into several sessions it names the session tmux itself +would act on. Outside tmux, or with an environment that does not parse, the +family raises {exc}`~libtmux.exc.NotInsideTmux` rather than guessing. + +See {ref}`self-location` for the whole story — why the session id tmux exports +goes stale, the window that *contains* you versus the one in front of you, and +how to test code that locates itself. + ### Fixes #### Linked windows resolve to the session tmux would use (#713) diff --git a/README.md b/README.md index 126d6fa4e..82594dcaa 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli - Typed, object-oriented control of tmux state - Query and [traverse](https://libtmux.git-pull.com/topics/traversal/) live sessions, windows, and panes +- [Locate yourself](https://libtmux.git-pull.com/topics/self_location/) from inside a pane with `from_env()` - Raw escape hatch via `.cmd(...)` on any object - Works with multiple tmux sockets and servers - [Context managers](https://libtmux.git-pull.com/topics/context_managers/) for automatic cleanup @@ -226,6 +227,27 @@ Window(@... ...:..., Session($... ...)) Session($... ...) ``` +### Know where you're running + +[**Learn more about Locating yourself**](https://libtmux.git-pull.com/topics/self_location/) + +Code *running inside* a pane — a script in a split, a tmux hook, an agent — can ask where it is. tmux writes `TMUX` and `TMUX_PANE` into every pane it spawns, and `Server`, `Session`, `Window`, and `Pane` each read them back: + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> monkeypatch.setenv("TMUX", f"{socket_path},1,{session.session_id}") +>>> monkeypatch.setenv("TMUX_PANE", pane.pane_id) + +>>> Pane.from_env() +Pane(%... ...) +>>> Session.from_env().session_name == session.session_name +True +``` + +In a real pane tmux has already set those two variables, so `from_env()` takes no arguments and there is nothing to arrange — this README is not running in a pane, so the example sets them first. Outside tmux there is no pane to return, and `from_env()` raises `NotInsideTmux`. + ## Core concepts | libtmux object | tmux concept | Notes | diff --git a/docs/AGENTS.md b/docs/AGENTS.md index b7bb29e95..e4e6176bb 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -63,17 +63,30 @@ requires them to actually execute — `testpaths` includes `docs/`, so pytest runs every one. Lead with a small, runnable example early rather than after paragraphs of prose; libtmux is code-first. -- Use the `doctest_namespace` fixtures — `server`, `session`, `window`, - `pane` (and `Server` / `Session` / `Window` / `Pane` / `Client`) — - instead of building a server by hand. +- Use the `doctest_namespace` fixtures instead of building a server by + hand. `conftest.py` seeds the namespace with `server`, `session`, + `window`, `pane`, the `Server` / `Session` / `Window` / `Pane` / + `Client` classes, `ControlMode` and `control_mode`, `monkeypatch`, + and `request`. - Fence a `>>>` session as a ```` ```python ```` block, and reach for `# doctest: +ELLIPSIS` when output varies (ids like `@1`, `$2`, socket names). Use a ```` ```console ```` block for shell commands at a `$` prompt. -- The code blocks on a page share one doctest session, so a later - block can use a `pane` an earlier block created. That makes their - **order load-bearing**: never reorder, add, or drop a code block when - you reshape the prose around it. +- **Every code block on a page is an independent doctest.** Blocks do + not share a session: each one gets a fresh copy of the namespace *and* + a fresh tmux server. A later block cannot use a `pane` an earlier + block created — the name is simply not defined there. So **write every + block self-contained**. The fixtures above are re-seeded for each + block, which makes that cheap: no block needs an import or a setup + preamble to get a `server`, a `session`, or a `pane`. Order is + load-bearing for the reader's narrative, not for state — reorder, add, + or drop a block as the prose demands, and keep the story coherent. +- Two of those names are not quite what they look like. `Server` is a + `TestServer` partial, not the real class; write + `>>> from libtmux.server import Server` when an example needs the + class itself. And the fixture server is created with a socket *name*, + so `server.socket_path` is `None` — an example that needs the path + must ask tmux for it with `display-message -p '#{socket_path}'`. ## What stays precise @@ -109,8 +122,8 @@ another link useful. Do not rely on a later reference section to satisfy the first-mention rule. If the first occurrence would be a heading, grid-card teaser, or introductory sentence, link that occurrence or retitle the heading so the first prose mention -can carry the link. Leave command examples, code blocks, Mermaid node labels, -and literal configuration values as code; link the surrounding prose instead. +can carry the link. Leave command examples, code blocks, and literal +configuration values as code; link the surrounding prose instead. ## A page that does this diff --git a/docs/glossary.md b/docs/glossary.md index f58f8bd42..64fce2b36 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -61,4 +61,27 @@ Pane Target A target, cited in the manual as ``[-t target]`` can be a session, window or pane. + +TMUX + Environment variable tmux exports into every {term}`Pane` it spawns. + + Holds ``socket_path,server_pid,session_id`` for the {term}`Server` + the pane belongs to. The session id is spelled bare, e.g. ``47``, where + libtmux spells the same session ``$47``. + + Written once, when the pane is spawned, and never revised — so its + session id records where the process was *launched*, and goes stale if + the pane's {term}`Window` later moves. + + libtmux takes only the socket path from it, and asks tmux for the rest. + See {ref}`self-location`. + +TMUX_PANE + Environment variable tmux exports into every {term}`Pane` it spawns. + + Holds that pane's ``pane_id``, e.g. ``%1``. Unlike ``TMUX`` it always + names the pane the process is really in, so it is the id libtmux + anchors on to answer where a process is running. + + Read back by libtmux in {ref}`self-location`. ``` diff --git a/docs/index.md b/docs/index.md index 6da452c68..683c17ebc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,6 +77,31 @@ Python object with traversal, filtering, and command execution. | {class}`~libtmux.window.Window` | tmux window | | {class}`~libtmux.pane.Pane` | tmux pane | +## Know where you're running + +Sometimes you hold no handle at all, because your code is *running inside* a +pane. You don't have to search the server for yourself — tmux writes `TMUX` and +`TMUX_PANE` into every pane it spawns, and each level of the hierarchy reads +them back: + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> monkeypatch.setenv("TMUX", f"{socket_path},1,{session.session_id}") +>>> monkeypatch.setenv("TMUX_PANE", pane.pane_id) + +>>> Pane.from_env().pane_id == pane.pane_id +True +>>> Session.from_env().session_name == session.session_name +True +``` + +Inside a pane tmux has already set those two variables, so {meth}`Pane.from_env() +` takes no arguments; these docs are not running in a +pane, so the example sets them first. Outside tmux there is no pane to return and +{exc}`~libtmux.exc.NotInsideTmux` is raised instead. See {ref}`self-location`. + ## Testing libtmux ships a [pytest plugin](api/testing/pytest-plugin/index.md) with diff --git a/docs/topics/clients.md b/docs/topics/clients.md index 29a648732..bc052ab17 100644 --- a/docs/topics/clients.md +++ b/docs/topics/clients.md @@ -35,6 +35,17 @@ stable for the lifetime of the attachment. | `client_session` | session name of the same attached view | No — snapshot | | `client_pid` / `client_tty` / `client_user` | terminal-level facts | Yes — identity-adjacent | +:::{seealso} +**Why there is no `Client.from_env()`.** A pane can name the +{class}`~libtmux.Session`, {class}`~libtmux.Window`, and +{class}`~libtmux.Pane` it is running in, because tmux writes those ids into +its environment. A client is the one thing it cannot name. Viewing is not +owning: no client may be attached at all — a detached session, a CI job, a +`send-keys` script — or several may be, each looking somewhere else. tmux +exports no client id into a pane because there is no single right answer to +export. See {ref}`self-location` for what a pane *can* resolve about itself. +::: + ## Live attachment lookup When you want the *current* attachment — not the snapshot — use the diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md index 340bb5be3..e1180c380 100644 --- a/docs/topics/configuration.md +++ b/docs/topics/configuration.md @@ -8,23 +8,51 @@ tmux through the standard object API, you're already configured correctly and can stop reading here. The rest of this page is for the rarer cases. It documents two lower -layers you can reach for when the defaults aren't enough: the tmux -environment variables that decide which server you connect to, and the -format-string system libtmux uses internally to read tmux state. +layers you can reach for when the defaults aren't enough: the +environment variables libtmux reads, and the format-string system +libtmux uses internally to read tmux state. ## Environment variables -libtmux reads almost nothing from the environment, so in normal use -there's nothing to set here. The one knob it does read is -`LIBTMUX_TMUX_FORMAT_SEPARATOR`, an advanced override for the separator -(default `␞`) libtmux uses internally to parse tmux's format output — you'd -touch it only if that character ever collided with your own data. What more -often matters is the tmux *server* you connect to: the standard tmux -variables `TMUX` (the address of an -existing server) and `TMUX_TMPDIR` (where tmux keeps its socket) shape -which server a fresh {class}`~libtmux.Server` finds. If you run several -servers, or point tmux at a custom socket directory, those two variables -decide which one you land on — otherwise you can ignore them. +You set almost nothing here. The two variables that matter most, tmux +writes for you and libtmux only reads back, so a normal Python process +driving tmux has nothing to arrange in this section. + +tmux exports both into every pane it spawns: + +| Variable | What tmux puts in it | +|---|---| +| `TMUX` | the server that pane belongs to, as `socket_path,server_pid,session_id` | +| `TMUX_PANE` | the id of the pane itself, e.g. `%1` | + +Code running *inside* a pane — a script you started in a split, a hook, a +test harness — reads them back to get a handle on itself, rather than +searching the server for a pane it already is. That is the `from_env` +family: {meth}`Server.from_env() `, +{meth}`Session.from_env() `, +{meth}`Window.from_env() `, and +{meth}`Pane.from_env() `. Outside a pane neither +variable is set, and all four raise {exc}`~libtmux.exc.NotInsideTmux`. +You never write them yourself: {ref}`self-location` covers what each call +does with them, why the session id in `TMUX` goes stale, and the `env` +mapping you hand `from_env` in tests instead of touching the real +environment. + +tmux reads `TMUX` too — it is how tmux notices you are already inside a +session and guards against nesting one. {meth}`Server.new_session() +` unsets it for the length of that one call +and restores it afterward, so creating a session from inside a pane works +without you arranging anything. + +That leaves the two variables that *are* yours to set, and most people +set neither. `TMUX_TMPDIR` is tmux's own — the directory it keeps sockets +in. libtmux never reads it, but the tmux binary it shells out to does, so +it shapes which server a bare {class}`~libtmux.Server` lands on; pass +`socket_name` or `socket_path` when you would rather name the server +outright. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one variable libtmux +itself defines: an advanced override for the separator (default `␞`) it +uses internally to parse tmux's format output — you'd touch it only if +that character ever collided with your own data. ## Format strings diff --git a/docs/topics/index.md b/docs/topics/index.md index cf9ac2c42..c955e5857 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -18,6 +18,13 @@ Navigate the {class}`~libtmux.Server`, {class}`~libtmux.Session`, {class}`~libtmux.Window`, {class}`~libtmux.Pane` hierarchy. ::: +:::{grid-item-card} Locating Yourself +:link: self_location +:link-type: doc +Code running inside a pane asking which pane, window, session, and server +it is in. +::: + :::{grid-item-card} Filtering :link: filtering :link-type: doc @@ -82,6 +89,7 @@ configuration design-decisions public-vs-internal traversal +self_location filtering pane_interaction floating_panes diff --git a/docs/topics/self_location.md b/docs/topics/self_location.md new file mode 100644 index 000000000..d06eaeac5 --- /dev/null +++ b/docs/topics/self_location.md @@ -0,0 +1,249 @@ +(self-location)= + +# Locating yourself + +Most libtmux code starts from a handle you already hold — you make a +{class}`~libtmux.Server`, you find a {class}`~libtmux.Session`, you walk down. +Sometimes you hold nothing, because your code is *running inside* a pane: a +script you launched in a split, a tmux hook, a test harness, an agent. Before it +can do anything useful it has to answer one question — **where am I?** + +You don't have to search the server for yourself. tmux already told you. It +writes two variables into every pane it spawns, and each level of the hierarchy +reads them back: + +| | | +|---|---| +| {meth}`Server.from_env() ` | the tmux server you are running on | +| {meth}`Session.from_env() ` | the session that holds you | +| {meth}`Window.from_env() ` | the window that contains you | +| {meth}`Pane.from_env() ` | the pane you are running in | + +If all you need is a handle on yourself, the first section is the whole story. +The rest is for the rarer cases: outside tmux, background panes, and windows that +live in more than one session. + +To follow along live, start tmux, then run `python` *inside* a pane — that is +the situation this page is about: + +```console +$ tmux +``` + +```console +$ python +``` + +## Ask where you are + +Inside a pane each call takes no arguments. It reads {data}`os.environ`, which is +where tmux put the answer: + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> monkeypatch.setenv("TMUX", f"{socket_path},1,{session.session_id}") +>>> monkeypatch.setenv("TMUX_PANE", pane.pane_id) + +>>> Pane.from_env().pane_id == pane.pane_id +True +>>> Window.from_env().window_id == window.window_id +True +>>> Session.from_env().session_id == session.session_id +True +``` + +That is the whole call — in a real pane tmux has already set those two variables +for you, and there is nothing to arrange. These docs are not running in a pane, +so the example sets them first. + +Once you hold any of the four you are back on the hierarchy, and everything in +{ref}`traversal` applies. + +Each call also accepts an environment *mapping* in place of {data}`os.environ`. +The examples below pass one explicitly, and it is the seam your own tests can use +— see {ref}`self-location-testing`. + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> env = { +... "TMUX": f"{socket_path},1,{session.session_id}", +... "TMUX_PANE": pane.pane_id, +... } + +>>> Session.from_env(env).session_id == session.session_id +True +``` + +## When you are not in tmux + +There is no pane to return, and answering with somebody else's would be worse +than not answering, so all four raise {exc}`~libtmux.exc.NotInsideTmux`. Catch it +when your program is meant to run inside a pane *and* out: + +```python +>>> from libtmux import exc + +>>> try: +... here = Pane.from_env({}) +... except exc.NotInsideTmux: +... here = None + +>>> here is None +True +``` + +## The window that contains you, not the one in front + +A background pane is still somewhere. {meth}`Window.from_env() +` returns the window that *contains* you, which is not +the window someone happens to be looking at: + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> worker_window = session.new_window(window_name="worker", attach=False) +>>> worker = worker_window.active_pane +>>> env = { +... "TMUX": f"{socket_path},1,{session.session_id}", +... "TMUX_PANE": worker.pane_id, +... } + +>>> session.active_window.window_id == worker_window.window_id +False + +>>> Window.from_env(env).window_id == worker_window.window_id +True +``` + +{attr}`session.active_window ` answers a different +question — *what is focused*. + +## The server answers, not the environment + +`TMUX` looks like it settles the session question on its own. Its three fields are +`socket_path,server_pid,session_id`, and that last one is a session id. + +Don't reach for it. tmux writes these variables into a pane's environment *once*, +when it spawns the pane, and never revises them — a running process's environment +is not something tmux can rewrite. Move the pane's window to another session and +the pane really is somewhere else, while `TMUX` still names the session it was +born in. + +So `from_env` anchors on `TMUX_PANE`, the one id tmux keeps answering for live, +and asks the server where that pane is *now*: + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> worker_window = session.new_window(window_name="worker", attach=False) +>>> worker = worker_window.active_pane +>>> env = { +... "TMUX": f"{socket_path},1,{session.session_id}", # names *this* session +... "TMUX_PANE": worker.pane_id, +... } + +>>> elsewhere = server.new_session(session_name="elsewhere") +>>> _ = worker_window.move_window(session=elsewhere.session_id, no_select=True) + +>>> Session.from_env(env).session_name # where the pane is, not where TMUX says +'elsewhere' +``` + +The same staleness is why {attr}`pane.session ` resolves +through {attr}`pane.window ` instead of reading the +`session_id` it is already carrying: a {class}`~libtmux.Pane` you fetched earlier +remembers the session it was in *then*. The extra round-trip is what keeps the +answer current. If you read it in a loop, bind it to a variable once. + +## When a window belongs to two sessions + +`link-window` puts a single window in several sessions at once, and then the pane +genuinely belongs to all of them. Asked "which session am I in?", there is more +than one true answer. + +libtmux does not invent a tie-break. tmux already has to settle this every time +you type a command with a `-t` target, so libtmux asks it, and hands you back the +session tmux itself would act on. Below, the pane's window is linked into a +second session — `holders` shows it really is in both — and libtmux answers with +the session tmux's own `display-message -t` names: + +```python +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> home = server.new_session(session_name="aaa-home") +>>> shared = home.new_window(window_name="shared", attach=False) +>>> worker = shared.active_pane +>>> env = { +... "TMUX": f"{socket_path},1,{home.session_id}", +... "TMUX_PANE": worker.pane_id, +... } + +>>> guest = server.new_session(session_name="zzz-guest") +>>> _ = server.cmd( +... "link-window", "-s", shared.window_id, "-t", f"{guest.session_id}:" +... ) + +>>> holders = {p.session_name for p in server.panes.filter(pane_id=worker.pane_id)} +>>> holders == {"aaa-home", "zzz-guest"} +True + +>>> tmux_says = server.cmd( +... "display-message", "-p", "-t", worker.pane_id, "#{session_name}" +... ).stdout[0] +>>> Session.from_env(env).session_name == tmux_says +True +``` + +So the rule, in full: `TMUX_PANE` says which pane you are, and tmux says which +session that pane is in — including when the answer is contested. The session id +in `TMUX` is never read, not even as a tie-break. It records where the process was +*spawned*, which is a different fact from where it is, and one that goes stale. + +## There is no `Client.from_env()` + +A {class}`~libtmux.Client` is an attached terminal, and a pane is not owned by +one. No client may be attached at all — a detached session, a CI job, a +`send-keys` script all run with a perfectly good `TMUX_PANE` and nobody watching +— or several may be, each with its own view. tmux exports no client id into a +pane, so there is nothing to read back. See {ref}`clients` for the +view-versus-identity model this follows from. + +(self-location-testing)= + +## Testing code that locates itself + +Every `from_env` takes an optional `env` mapping. Passing one is how you test a +function that locates itself without running your test suite inside a pane: + +```python +>>> def announce(env=None): +... """Report the session this code is running in.""" +... return Session.from_env(env).session_name + +>>> socket_path = server.cmd( +... "display-message", "-p", "-t", session.session_id, "#{socket_path}" +... ).stdout[0] +>>> env = { +... "TMUX": f"{socket_path},1,{session.session_id}", +... "TMUX_PANE": pane.pane_id, +... } + +>>> announce(env) == session.session_name +True +``` + +In production `announce()` takes no argument and reads the real environment. + +:::{seealso} +- {ref}`traversal` — walking the hierarchy once you hold a handle +- {ref}`clients` — why an attached terminal is a view, not an identity +- {class}`~libtmux.Server`, {class}`~libtmux.Session`, {class}`~libtmux.Window`, + {class}`~libtmux.Pane` for the full API +::: diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index 53c2350d2..600fa55f3 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -156,6 +156,19 @@ True True ``` +## Locating yourself + +Everything above starts from a handle you already hold. Sometimes you hold +nothing, because your code is *running inside* a pane — and tmux has already told +it where it is. {meth}`Pane.from_env() `, and its siblings +on {class}`~libtmux.Server`, {class}`~libtmux.Session` and +{class}`~libtmux.Window`, read that back, so you can pick up the hierarchy from +wherever you happen to be running. + +See {ref}`self-location` for the whole story, including the window that +*contains* you versus the one in front of you, and windows that live in more than +one session at once. + ## Filtering and finding objects Sometimes a property like {attr}`session.windows ` diff --git a/src/libtmux/_internal/env.py b/src/libtmux/_internal/env.py new file mode 100644 index 000000000..407b0934f --- /dev/null +++ b/src/libtmux/_internal/env.py @@ -0,0 +1,172 @@ +"""Readers for the tmux variables exported into every pane's environment. + +libtmux._internal.env +~~~~~~~~~~~~~~~~~~~~~ + +tmux exports two variables into the child environment of every pane it spawns: + +``TMUX`` + ``",,"``. The session id is spelled + *bare* -- ``47``, where libtmux spells the same session ``$47``. + +``TMUX_PANE`` + ``"%N"`` -- the pane's id. + +tmux also exports ``TMUX`` to the job children it spawns for ``run-shell`` and +``#()``, and those never get ``TMUX_PANE``. A ``#()`` job carries no session at +all, and its ``TMUX`` says so with a session id of ``-1``. So a process holding +a pane id always has a real session id beside it. + +Both are frozen at spawn time and tmux never revises them. The moment a pane's +window is moved or linked into another session, the session id baked into +``TMUX`` is stale, while ``TMUX_PANE`` stays valid for the life of the pane. + +libtmux therefore reads *only* the socket path out of ``TMUX`` and asks tmux +itself -- targeting ``TMUX_PANE`` -- for the pane's window and session. See +:meth:`libtmux.Pane.from_env`. +""" + +from __future__ import annotations + +import os +import typing as t + +from libtmux import exc + +TMUX: t.Final = "TMUX" +"""Environment variable tmux exports with ``socket_path,server_pid,session_id``.""" + +TMUX_PANE: t.Final = "TMUX_PANE" +"""Environment variable tmux exports with the pane's id, e.g. ``%3``.""" + + +def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]: + """Return *env*, defaulting to the live process environment. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + :class:`typing.Mapping` + The mapping to read tmux variables from. + + Examples + -------- + >>> from libtmux._internal.env import resolve_env + >>> resolve_env({"TMUX_PANE": "%1"}) + {'TMUX_PANE': '%1'} + + >>> resolve_env() is os.environ + True + """ + return os.environ if env is None else env + + +def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: + """Return the tmux socket path recorded in ``$TMUX``. + + ``$TMUX`` is ``",,"``. The pid and + session id are integers, so any comma in the value belongs to the socket + path -- split from the *right*. + + The pid and session id are deliberately discarded: both are frozen at pane + spawn, and the session id goes stale as soon as the pane's window is moved + between sessions. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + str + Path of the tmux server's socket. + + Raises + ------ + :exc:`~libtmux.exc.NotInsideTmux` + When ``$TMUX`` is unset, empty, or not shaped like tmux's triple. + + Examples + -------- + >>> from libtmux._internal.env import socket_path_from_env + >>> socket_path_from_env({"TMUX": "/tmp/tmux-1000/default,84215,0"}) + '/tmp/tmux-1000/default' + + A comma in the socket path is safe, because the split runs from the right: + + >>> socket_path_from_env({"TMUX": "/tmp/od,d/sock,84215,3"}) + '/tmp/od,d/sock' + + Outside tmux there is nothing to read: + + >>> socket_path_from_env({}) + Traceback (most recent call last): + ... + libtmux.exc.NotInsideTmux: Not inside a tmux pane: $TMUX is unset or empty + """ + raw = resolve_env(env).get(TMUX, "") + if not raw: + raise exc.NotInsideTmux(TMUX) + + parts = raw.rsplit(",", 2) + if len(parts) != 3 or not parts[0]: + raise exc.NotInsideTmux( + TMUX, + reason="not ',,'", + ) + return parts[0] + + +def pane_id_from_env(env: t.Mapping[str, str] | None = None) -> str: + """Return the pane id recorded in ``$TMUX_PANE``. + + The ``%`` sigil is load-bearing: libtmux passes this id straight to tmux as + a ``-t`` target, and tmux's ``cmd_find`` routes a target to its pane slot + *by sigil*. A sigil-less value would be matched against session names + instead, silently resolving to the wrong object. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + str + The pane id, e.g. ``"%3"``. + + Raises + ------ + :exc:`~libtmux.exc.NotInsideTmux` + When ``$TMUX_PANE`` is unset, empty, or is not a ``%``-prefixed id. + + Examples + -------- + >>> from libtmux._internal.env import pane_id_from_env + >>> pane_id_from_env({"TMUX_PANE": "%3"}) + '%3' + + >>> pane_id_from_env({}) + Traceback (most recent call last): + ... + libtmux.exc.NotInsideTmux: Not inside a tmux pane: $TMUX_PANE is unset or empty + + >>> pane_id_from_env({"TMUX_PANE": "3"}) + Traceback (most recent call last): + ... + libtmux.exc.NotInsideTmux: Not inside a tmux pane: $TMUX_PANE is not a pane id... + """ + pane_id = resolve_env(env).get(TMUX_PANE, "") + if not pane_id: + raise exc.NotInsideTmux(TMUX_PANE) + if not pane_id.startswith("%"): + raise exc.NotInsideTmux( + TMUX_PANE, + reason=f"not a pane id (expected '%N', got {pane_id!r})", + ) + return pane_id diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 0a0c8228c..e4337c63c 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -83,6 +83,57 @@ class TmuxCommandNotFound(LibTmuxException): """Application binary for tmux not found.""" +class NotInsideTmux(LibTmuxException): + """Raised when the process is not running inside a tmux pane. + + tmux exports ``$TMUX`` and ``$TMUX_PANE`` into the environment of every + pane it spawns. The ``from_env()`` family raises this when one of them is + missing or malformed -- i.e. the caller is not (or is no longer) + recognizable as a tmux pane's child process. + + Parameters + ---------- + variable : str, optional + Name of the offending environment variable, e.g. ``"TMUX"``. + reason : str + Why it is unusable. Defaults to ``"unset or empty"``. + *args : object + Forwarded to :class:`LibTmuxException`. + + Examples + -------- + >>> from libtmux import exc + >>> str(exc.NotInsideTmux("TMUX")) + 'Not inside a tmux pane: $TMUX is unset or empty' + + >>> str(exc.NotInsideTmux("TMUX_PANE", reason="not a pane id")) + 'Not inside a tmux pane: $TMUX_PANE is not a pane id' + + >>> str(exc.NotInsideTmux()) + 'Not inside a tmux pane' + + It is part of the :exc:`LibTmuxException` hierarchy: + + >>> issubclass(exc.NotInsideTmux, exc.LibTmuxException) + True + + .. versionadded:: 0.62 + """ + + def __init__( + self, + variable: str | None = None, + *args: object, + reason: str = "unset or empty", + ) -> None: + if variable is None: + return super().__init__("Not inside a tmux pane", *args) + return super().__init__( + f"Not inside a tmux pane: ${variable} is {reason}", + *args, + ) + + class TmuxObjectDoesNotExist(ObjectDoesNotExist): """The query returned multiple objects when only one was expected.""" diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 6b38b3051..5641d0d4d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -14,6 +14,7 @@ import warnings 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.constants import ( PANE_DIRECTION_FLAG_MAP, @@ -206,6 +207,66 @@ def from_pane_id(cls, server: Server, pane_id: str) -> Pane: ) return cls(server=server, **pane) + @classmethod + def from_env(cls, env: t.Mapping[str, str] | None = None) -> Pane: + """Return the pane this process is running inside of. + + Reads ``$TMUX`` for the server's socket and ``$TMUX_PANE`` for the pane + id, then asks tmux for the rest. The stale session id inside ``$TMUX`` + is never consulted, so the answer stays correct after the pane's window + has been moved or linked elsewhere. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`, which is what + a process running inside a pane wants; pass an explicit mapping to + resolve on behalf of another pane. + + Returns + ------- + :class:`Pane` + + Raises + ------ + :exc:`~libtmux.exc.NotInsideTmux` + When ``$TMUX`` or ``$TMUX_PANE`` is unset or malformed. + :exc:`~libtmux.exc.TmuxObjectDoesNotExist` + When the pane named by ``$TMUX_PANE`` is gone. + :exc:`~libtmux.exc.LibTmuxException` + When the server named by ``$TMUX`` is unreachable. + + Examples + -------- + >>> socket_path = server.cmd( + ... "display-message", "-p", "-t", pane.pane_id, "#{socket_path}" + ... ).stdout[0] + >>> env = {"TMUX": f"{socket_path},1,0", "TMUX_PANE": pane.pane_id} + + >>> Pane.from_env(env) + Pane(%1 Window(@1 1:..., Session($1 ...))) + + >>> Pane.from_env(env).pane_id == pane.pane_id + True + + Outside a pane there is nothing to resolve: + + >>> from libtmux import exc + >>> try: + ... Pane.from_env({}) + ... except exc.NotInsideTmux as e: + ... print(e) + Not inside a tmux pane: $TMUX is unset or empty + + .. versionadded:: 0.62 + """ + from libtmux.server import Server + + return cls.from_pane_id( + server=Server.from_env(env), + pane_id=pane_id_from_env(env), + ) + # # Relations # diff --git a/src/libtmux/server.py b/src/libtmux/server.py index ba5c939e6..615921571 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,6 +16,7 @@ import warnings from libtmux import exc +from libtmux._internal.env import socket_path_from_env from libtmux._internal.query_list import QueryList from libtmux.client import Client from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd @@ -189,16 +190,6 @@ def __init__( elif socket_name_factory is not None: self.socket_name = socket_name_factory() - tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp")) - socket_name = self.socket_name or "default" - if ( - tmux_tmpdir is not None - and self.socket_path is None - and self.socket_name is None - and socket_name != "default" - ): - self.socket_path = str(tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name) - if config_file: self.config_file = config_file @@ -208,6 +199,61 @@ def __init__( if on_init is not None: on_init(self) + @classmethod + def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server: + """Return the tmux server this process's pane is attached to. + + Reads the socket path out of ``$TMUX``, which tmux exports into every + pane it spawns as ``",,"``. Only + the socket path is used: the pid and the session id are frozen at pane + spawn, and the session id goes stale as soon as the pane's window is + moved between sessions. Costs no tmux subprocess -- it is a pure read + of the environment. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`, which is what + a process running inside a pane wants; pass an explicit mapping to + resolve on behalf of another pane. + + Returns + ------- + :class:`Server` + Server bound to the socket named by ``$TMUX``. Not verified to be + alive -- use :meth:`Server.is_alive` for that. + + Raises + ------ + :exc:`~libtmux.exc.NotInsideTmux` + When ``$TMUX`` is unset, empty, or not shaped like tmux's triple. + + Examples + -------- + >>> from libtmux.server import Server as TmuxServer + >>> socket_path = server.cmd( + ... "display-message", "-p", "-t", pane.pane_id, "#{socket_path}" + ... ).stdout[0] + >>> env = {"TMUX": f"{socket_path},1,0", "TMUX_PANE": pane.pane_id} + + >>> TmuxServer.from_env(env) + Server(socket_path=...) + + >>> TmuxServer.from_env(env).is_alive() + True + + Outside a pane there is no server to name: + + >>> try: + ... TmuxServer.from_env({}) + ... except exc.NotInsideTmux as e: + ... print(e) + Not inside a tmux pane: $TMUX is unset or empty + + .. versionadded:: 0.62 + """ + return cls(socket_path=socket_path_from_env(env)) + def __enter__(self) -> Self: """Enter the context, returning self. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 1b1dd49d4..1581b1018 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -151,7 +151,31 @@ def refresh(self) -> None: @classmethod def from_session_id(cls, server: Server, session_id: str) -> Session: - """Create Session from existing session_id.""" + """Create Session from existing session_id. + + Parameters + ---------- + server : :class:`~libtmux.server.Server` + The tmux server holding the session. + session_id : str + Session id, e.g. ``"$1"``. + + Returns + ------- + :class:`Session` + + Raises + ------ + :exc:`~libtmux.exc.TmuxObjectDoesNotExist` + When no such session exists on a reachable server. + + Examples + -------- + >>> Session.from_session_id( + ... server=session.server, session_id=session.session_id + ... ) + Session($1 ...) + """ session = fetch_obj( obj_key="session_id", obj_id=session_id, @@ -160,6 +184,63 @@ def from_session_id(cls, server: Server, session_id: str) -> Session: ) return cls(server=server, **session) + @classmethod + def from_env(cls, env: t.Mapping[str, str] | None = None) -> Session: + """Return the session this process's pane belongs to. + + The session id baked into ``$TMUX`` is frozen at pane spawn and goes + stale the moment the pane's window is moved or linked elsewhere, so it + is never read. tmux is asked instead: the pane row that + :meth:`Pane.from_env` fetches is stamped with the session tmux + considers canonical for that pane's window. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + :class:`Session` + + Raises + ------ + :exc:`~libtmux.exc.NotInsideTmux` + When ``$TMUX`` or ``$TMUX_PANE`` is unset or malformed. + :exc:`~libtmux.exc.TmuxObjectDoesNotExist` + When the pane named by ``$TMUX_PANE`` is gone. + :exc:`~libtmux.exc.LibTmuxException` + When the server named by ``$TMUX`` is unreachable. + ValueError + When the resolved pane carries no ``session_id``. Surfaces a clear + error under ``python -O``, where an ``assert`` would be stripped. + + Examples + -------- + >>> socket_path = server.cmd( + ... "display-message", "-p", "-t", pane.pane_id, "#{socket_path}" + ... ).stdout[0] + + Even a deliberately wrong session id in ``$TMUX`` cannot mislead it: + + >>> env = {"TMUX": f"{socket_path},1,999", "TMUX_PANE": pane.pane_id} + >>> Session.from_env(env).session_id == session.session_id + True + + >>> Session.from_env(env) + Session($1 ...) + + .. versionadded:: 0.62 + """ + pane = Pane.from_env(env) + if pane.session_id is None: + msg = "Pane must have a session_id to resolve its session" + raise ValueError(msg) + return cls.from_session_id( + server=pane.server, + session_id=pane.session_id, + ) + # # Relations # diff --git a/src/libtmux/window.py b/src/libtmux/window.py index f54eebdd7..2a7e624a1 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -222,6 +222,59 @@ def from_window_id(cls, server: Server, window_id: str) -> Window: ) return cls(server=server, **window) + @classmethod + def from_env(cls, env: t.Mapping[str, str] | None = None) -> Window: + """Return the window containing the pane this process runs inside of. + + Resolves through :meth:`Pane.from_env`, so the answer is the window + that *contains* the caller -- not the session's currently active + window, which may be a different one entirely. + + Parameters + ---------- + env : :class:`typing.Mapping`, optional + Environment to read. Defaults to :data:`os.environ`. + + Returns + ------- + :class:`Window` + + Raises + ------ + :exc:`~libtmux.exc.NotInsideTmux` + When ``$TMUX`` or ``$TMUX_PANE`` is unset or malformed. + :exc:`~libtmux.exc.TmuxObjectDoesNotExist` + When the pane named by ``$TMUX_PANE`` is gone. + :exc:`~libtmux.exc.LibTmuxException` + When the server named by ``$TMUX`` is unreachable. + ValueError + When the resolved pane carries no ``window_id``. Surfaces a clear + error under ``python -O``, where an ``assert`` would be stripped. + + Examples + -------- + >>> socket_path = server.cmd( + ... "display-message", "-p", "-t", pane.pane_id, "#{socket_path}" + ... ).stdout[0] + >>> env = {"TMUX": f"{socket_path},1,0", "TMUX_PANE": pane.pane_id} + + >>> Window.from_env(env) + Window(@1 1:..., Session($1 ...)) + + The caller's window is reported even while another window is active: + + >>> _ = session.new_window(window_name="foreground", attach=True) + >>> Window.from_env(env).window_id == window.window_id + True + + .. versionadded:: 0.62 + """ + pane = Pane.from_env(env) + if pane.window_id is None: + msg = "Pane must have a window_id to resolve its window" + raise ValueError(msg) + return cls.from_window_id(server=pane.server, window_id=pane.window_id) + @property def session(self) -> Session: """Parent session of window.""" diff --git a/tests/test_from_env.py b/tests/test_from_env.py new file mode 100644 index 000000000..2f8975e80 --- /dev/null +++ b/tests/test_from_env.py @@ -0,0 +1,519 @@ +"""Tests for the ``from_env()`` family — locating the pane libtmux runs inside. + +tmux exports ``$TMUX`` (``socket_path,server_pid,session_id``) and +``$TMUX_PANE`` (``%N``) into every pane's child environment, and never revises +either. libtmux reads the socket path out of ``$TMUX``, then asks tmux — via +``$TMUX_PANE`` — for the window and session, so the frozen session id can never +mislead it. +""" + +from __future__ import annotations + +import re +import typing as t + +import pytest + +from libtmux import exc +from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.pane import Pane +from libtmux.server import Server +from libtmux.session import Session +from libtmux.window import Window + +if t.TYPE_CHECKING: + from collections.abc import Callable, Mapping + + +def env_for(server: Server, pane_id: str, session_id: str) -> dict[str, str]: + """Build the ``TMUX``/``TMUX_PANE`` pair tmux exports into *pane_id*. + + The test :func:`~libtmux.pytest_plugin.server` fixture is socket-*name* + based, so ``Server.socket_path`` is unset; ask tmux for the path the way a + real pane's ``$TMUX`` would carry it. + """ + socket_path = server.cmd( + "display-message", + "-p", + "-t", + pane_id, + "#{socket_path}", + ).stdout[0] + return { + "TMUX": f"{socket_path},1,{session_id.removeprefix('$')}", + "TMUX_PANE": pane_id, + } + + +class SocketPathFixture(t.NamedTuple): + """Test fixture for ``$TMUX`` socket path extraction.""" + + test_id: str + tmux: str + expected_socket_path: str + + +SOCKET_PATH_FIXTURES: list[SocketPathFixture] = [ + SocketPathFixture( + test_id="default_socket", + tmux="/tmp/tmux-1000/default,84215,0", + expected_socket_path="/tmp/tmux-1000/default", + ), + SocketPathFixture( + test_id="named_socket", + tmux="/tmp/tmux-1000/libtmux_test0,1,47", + expected_socket_path="/tmp/tmux-1000/libtmux_test0", + ), + SocketPathFixture( + test_id="comma_inside_socket_path", + tmux="/tmp/od,d/socket,84215,3", + expected_socket_path="/tmp/od,d/socket", + ), + SocketPathFixture( + test_id="no_session", + tmux="/tmp/tmux-1000/default,84215,-1", + expected_socket_path="/tmp/tmux-1000/default", + ), +] + + +@pytest.mark.parametrize( + list(SocketPathFixture._fields), + SOCKET_PATH_FIXTURES, + ids=[test.test_id for test in SOCKET_PATH_FIXTURES], +) +def test_server_from_env_socket_path( + test_id: str, + tmux: str, + expected_socket_path: str, +) -> None: + """``Server.from_env()`` splits ``$TMUX`` from the right.""" + assert Server.from_env({"TMUX": tmux}).socket_path == expected_socket_path + + +class NotInsideTmuxFixture(t.NamedTuple): + """Test fixture for environments that don't describe a tmux pane.""" + + test_id: str + env: dict[str, str] + message: str + + +NOT_INSIDE_TMUX_FIXTURES: list[NotInsideTmuxFixture] = [ + NotInsideTmuxFixture( + test_id="empty_env", + env={}, + message="$TMUX is unset or empty", + ), + NotInsideTmuxFixture( + test_id="tmux_empty", + env={"TMUX": "", "TMUX_PANE": "%0"}, + message="$TMUX is unset or empty", + ), + NotInsideTmuxFixture( + test_id="tmux_missing_pid_and_session", + env={"TMUX": "/tmp/tmux-1000/default", "TMUX_PANE": "%0"}, + message="$TMUX is not ',,'", + ), + NotInsideTmuxFixture( + test_id="tmux_missing_socket_path", + env={"TMUX": ",84215,0", "TMUX_PANE": "%0"}, + message="$TMUX is not ',,'", + ), +] + + +@pytest.mark.parametrize( + list(NotInsideTmuxFixture._fields), + NOT_INSIDE_TMUX_FIXTURES, + ids=[test.test_id for test in NOT_INSIDE_TMUX_FIXTURES], +) +def test_server_from_env_not_inside_tmux( + test_id: str, + env: dict[str, str], + message: str, +) -> None: + """A missing or malformed ``$TMUX`` is :exc:`~libtmux.exc.NotInsideTmux`.""" + with pytest.raises(exc.NotInsideTmux, match=re.escape(message)): + Server.from_env(env) + + +class PaneEnvFixture(t.NamedTuple): + """Test fixture for environments whose ``$TMUX_PANE`` is unusable.""" + + test_id: str + tmux_pane: str | None + message: str + + +PANE_ENV_FIXTURES: list[PaneEnvFixture] = [ + PaneEnvFixture( + test_id="unset", + tmux_pane=None, + message="$TMUX_PANE is unset or empty", + ), + PaneEnvFixture( + test_id="empty", + tmux_pane="", + message="$TMUX_PANE is unset or empty", + ), + PaneEnvFixture( + test_id="sigil_less", + tmux_pane="0", + message="$TMUX_PANE is not a pane id", + ), + PaneEnvFixture( + test_id="session_sigil", + tmux_pane="$1", + message="$TMUX_PANE is not a pane id", + ), +] + + +@pytest.mark.parametrize( + list(PaneEnvFixture._fields), + PANE_ENV_FIXTURES, + ids=[test.test_id for test in PANE_ENV_FIXTURES], +) +def test_pane_from_env_bad_pane_id( + test_id: str, + tmux_pane: str | None, + message: str, +) -> None: + """``$TMUX_PANE`` must carry the ``%`` sigil tmux routes targets by.""" + env = {"TMUX": "/tmp/tmux-1000/default,84215,0"} + if tmux_pane is not None: + env["TMUX_PANE"] = tmux_pane + + with pytest.raises(exc.NotInsideTmux, match=re.escape(message)): + Pane.from_env(env) + + +class FromEnvFixture(t.NamedTuple): + """Test fixture pairing each constructor with its ``from_env``.""" + + test_id: str + from_env: Callable[[Mapping[str, str]], object] + + +FROM_ENV_FIXTURES: list[FromEnvFixture] = [ + FromEnvFixture(test_id="server", from_env=Server.from_env), + FromEnvFixture(test_id="session", from_env=Session.from_env), + FromEnvFixture(test_id="window", from_env=Window.from_env), + FromEnvFixture(test_id="pane", from_env=Pane.from_env), +] + + +@pytest.mark.parametrize( + list(FromEnvFixture._fields), + FROM_ENV_FIXTURES, + ids=[test.test_id for test in FROM_ENV_FIXTURES], +) +def test_from_env_outside_tmux( + test_id: str, + from_env: Callable[[Mapping[str, str]], object], +) -> None: + """Every constructor rejects an environment with no tmux in it.""" + with pytest.raises(exc.NotInsideTmux): + from_env({}) + + +def test_from_env_defaults_to_os_environ( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``env=None`` reads the live process environment, as a real pane would.""" + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + + for key, value in env_for(session.server, pane.pane_id, session.session_id).items(): + monkeypatch.setenv(key, value) + + assert Pane.from_env().pane_id == pane.pane_id + assert Window.from_env().window_id == session.active_window.window_id + assert Session.from_env().session_id == session.session_id + assert Server.from_env().is_alive() + + +def test_from_env_resolves_the_calling_pane(session: Session) -> None: + """The whole family agrees on the pane, window, and session.""" + window = session.active_window + pane = window.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + + env = env_for(session.server, pane.pane_id, session.session_id) + + assert Pane.from_env(env).pane_id == pane.pane_id + assert Window.from_env(env).window_id == window.window_id + assert Session.from_env(env).session_id == session.session_id + assert Pane.from_env(env).window.window_id == window.window_id + assert Pane.from_env(env).session.session_id == session.session_id + + +def test_from_env_ignores_stale_session_id(session: Session) -> None: + """The session id inside ``$TMUX`` is never read, however wrong it is.""" + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + + env = env_for(session.server, pane.pane_id, "$99999") + + assert Session.from_env(env).session_id == session.session_id + + +def test_from_env_follows_pane_after_move_window(session: Session) -> None: + """A moved window resolves to its new session, not the one in ``$TMUX``.""" + server = session.server + destination = server.new_session(session_name="destination") + + window = session.active_window + pane = window.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + assert destination.session_id is not None + + session.new_window() # keep the origin session alive after the move + env = env_for(server, pane.pane_id, session.session_id) + window.move_window(destination="99", session=destination.session_id) + + assert Session.from_env(env).session_id == destination.session_id + assert Window.from_env(env).session.session_id == destination.session_id + assert Pane.from_env(env).session.session_id == destination.session_id + + +def test_from_env_agrees_with_tmux_on_a_linked_window(session: Session) -> None: + """A window linked into two sessions gets tmux's own canonical answer.""" + server = session.server + guest = server.new_session(session_name="zzz-guest") + + window = session.active_window + pane = window.active_pane + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + assert session.session_id is not None + assert guest.session_name is not None + + server.cmd("link-window", "-s", window.window_id, "-t", guest.session_name) + + env = env_for(server, pane.pane_id, session.session_id) + tmux_says = server.cmd( + "display-message", + "-p", + "-t", + pane.pane_id, + "#{session_id}", + ).stdout[0] + + resolved = { + Session.from_env(env).session_id, + Window.from_env(env).session.session_id, + Pane.from_env(env).session.session_id, + Pane.from_env(env).window.session.session_id, + } + + assert resolved == {tmux_says} + + +def test_window_from_env_is_the_containing_window(session: Session) -> None: + """The caller's window is reported, not the session's active window.""" + caller_window = session.active_window + caller_pane = caller_window.active_pane + assert caller_pane is not None + assert caller_pane.pane_id is not None + assert session.session_id is not None + + env = env_for(session.server, caller_pane.pane_id, session.session_id) + + foreground = session.new_window(window_name="foreground", attach=True) + assert session.active_window.window_id == foreground.window_id + + assert Window.from_env(env).window_id == caller_window.window_id + + +class MissingPaneFixture(t.NamedTuple): + """Test fixture pairing each pane-derived constructor with its ``from_env``.""" + + test_id: str + from_env: Callable[[Mapping[str, str]], object] + + +MISSING_PANE_FIXTURES: list[MissingPaneFixture] = [ + MissingPaneFixture(test_id="session", from_env=Session.from_env), + MissingPaneFixture(test_id="window", from_env=Window.from_env), + MissingPaneFixture(test_id="pane", from_env=Pane.from_env), +] + + +@pytest.mark.parametrize( + list(MissingPaneFixture._fields), + MISSING_PANE_FIXTURES, + ids=[test.test_id for test in MISSING_PANE_FIXTURES], +) +def test_from_env_missing_pane_on_live_server( + test_id: str, + from_env: Callable[[Mapping[str, str]], object], + session: Session, +) -> None: + """A vanished pane on a reachable server is an object-lookup failure.""" + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + + env = env_for(session.server, pane.pane_id, session.session_id) + env["TMUX_PANE"] = "%99999" + + with pytest.raises(exc.TmuxObjectDoesNotExist): + from_env(env) + + +@pytest.mark.parametrize( + list(MissingPaneFixture._fields), + MISSING_PANE_FIXTURES, + ids=[test.test_id for test in MISSING_PANE_FIXTURES], +) +def test_from_env_dead_server( + test_id: str, + from_env: Callable[[Mapping[str, str]], object], + TestServer: Callable[..., Server], +) -> None: + """An unreachable server is a tmux failure, not a missing object. + + Uses :func:`~libtmux.pytest_plugin.TestServer` rather than the ``session`` + fixture because the server has to be killed inside the test body. + """ + server = TestServer() + session = server.new_session(session_name="doomed") + + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + + env = env_for(server, pane.pane_id, session.session_id) + server.kill() + + with pytest.raises(exc.LibTmuxException) as excinfo: + from_env(env) + + assert not isinstance(excinfo.value, ObjectDoesNotExist) + + +class UnidentifiedPaneFixture(t.NamedTuple): + """A ``from_env`` that traverses off a pane, and the id it needs.""" + + test_id: str + from_env: Callable[[Mapping[str, str]], object] + message: str + + +UNIDENTIFIED_PANE_FIXTURES: list[UnidentifiedPaneFixture] = [ + UnidentifiedPaneFixture( + test_id="session", + from_env=Session.from_env, + message="Pane must have a session_id to resolve its session", + ), + UnidentifiedPaneFixture( + test_id="window", + from_env=Window.from_env, + message="Pane must have a window_id to resolve its window", + ), +] + + +@pytest.mark.parametrize( + list(UnidentifiedPaneFixture._fields), + UNIDENTIFIED_PANE_FIXTURES, + ids=[test.test_id for test in UNIDENTIFIED_PANE_FIXTURES], +) +def test_from_env_pane_without_parent_id( + test_id: str, + from_env: Callable[[Mapping[str, str]], object], + message: str, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pane that names no parent is a ``ValueError`` on both constructors. + + :meth:`~libtmux.Session.from_env` and :meth:`~libtmux.Window.from_env` both + traverse off the pane :meth:`~libtmux.Pane.from_env` returns, and must fail + the same way when it carries no parent id. Uses ``monkeypatch`` rather than + the standard fixtures because tmux always stamps a real pane row with both + ids -- these guards stand in for the ``assert`` a ``python -O`` run strips. + """ + + def unidentified_pane( + cls: type[Pane], + env: Mapping[str, str] | None = None, + ) -> Pane: + return Pane(server=server, pane_id="%0") + + monkeypatch.setattr(Pane, "from_env", classmethod(unidentified_pane)) + + with pytest.raises(ValueError, match=re.escape(message)): + from_env({}) + + +class SubclassFixture(t.NamedTuple): + """A ``from_env`` constructor, and the class it is called on.""" + + test_id: str + subclass: type[Server] | type[Session] | type[Window] | type[Pane] + + +class MyServer(Server): + """Downstream subclass of :class:`~libtmux.Server`.""" + + +class MySession(Session): + """Downstream subclass of :class:`~libtmux.Session`.""" + + +class MyWindow(Window): + """Downstream subclass of :class:`~libtmux.Window`.""" + + +class MyPane(Pane): + """Downstream subclass of :class:`~libtmux.Pane`.""" + + +SUBCLASS_FIXTURES: list[SubclassFixture] = [ + SubclassFixture(test_id="server", subclass=MyServer), + SubclassFixture(test_id="session", subclass=MySession), + SubclassFixture(test_id="window", subclass=MyWindow), + SubclassFixture(test_id="pane", subclass=MyPane), +] + + +@pytest.mark.parametrize( + list(SubclassFixture._fields), + SUBCLASS_FIXTURES, + ids=[test.test_id for test in SUBCLASS_FIXTURES], +) +def test_from_env_returns_the_class_it_was_called_on( + session: Session, + test_id: str, + subclass: type[Server] | type[Session] | type[Window] | type[Pane], +) -> None: + """``from_env`` honours ``cls``, so subclasses get their own type back. + + A constructor that resolves by traversing off another object (rather than + through ``cls``) silently hands back the base class, and libtmux subclasses + lose their own behaviour. + """ + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + assert session.session_id is not None + + env = env_for(server, pane.pane_id, session.session_id) + + assert type(subclass.from_env(env)) is subclass diff --git a/tests/test_server.py b/tests/test_server.py index ba6cabc08..6175a7f9a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -51,6 +51,19 @@ def test_socket_path(server: Server) -> None: assert myserver.socket_path == "test" +def test_socket_path_not_derived_from_socket_name() -> None: + """A named socket leaves ``socket_path`` unset. + + tmux resolves a ``-L`` name against its own socket directory, so libtmux + has no reason to guess the path. Anything that needs the real path asks + tmux for it (``#{socket_path}``). + """ + myserver = Server(socket_name="libtmux_test_named_socket") + + assert myserver.socket_name == "libtmux_test_named_socket" + assert myserver.socket_path is None + + def test_config(server: Server) -> None: """``-f`` file for tmux(1) configuration.""" myserver = Server(config_file="test")