From 9485045939e04b86a128ddea8bd6495be9790fdb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 17:18:23 -0500 Subject: [PATCH 1/7] Tests(fix): Wait for shell output before capture why: Pane capture can finish before the shell executes a sent command. The environment and capture tests must observe the expected output. what: - Wait for the initial prompt and exact command output - Bound polling instead of relying on an immediate read or fixed sleep --- tests/test_pane.py | 11 +++++++---- tests/test_session.py | 8 +++++++- tests/test_window.py | 11 +++++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce62..afc1d58126 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -79,17 +79,20 @@ def test_capture_pane(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None - pane_contents = "\n".join(pane.capture_pane()) - assert pane_contents == "$" + retry_until(lambda: "\n".join(pane.capture_pane()) == "$", 2, raises=True) pane.send_keys( r'printf "\n%s\n" "Hello World !"', literal=True, suppress_history=False, ) - pane_contents = "\n".join(pane.capture_pane()) - assert pane_contents == r'$ printf "\n%s\n" "Hello World !"{}'.format( + expected_contents = r'$ printf "\n%s\n" "Hello World !"{}'.format( "\n\nHello World !\n$", ) + retry_until( + lambda: "\n".join(pane.capture_pane()) == expected_contents, + 2, + raises=True, + ) def test_capture_pane_start(session: Session) -> None: diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cca..49d85ce769 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -16,6 +16,7 @@ from libtmux.session import Session from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import namer +from libtmux.test.retry import retry_until from libtmux.window import Window if t.TYPE_CHECKING: @@ -341,9 +342,14 @@ def test_new_window_with_environment( ) pane = window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 2, raises=True) for k, v in environment.items(): pane.send_keys(f"echo ${k}") - assert pane.capture_pane()[-2] == v + + def output_matches(expected: str = v) -> bool: + return pane.capture_pane()[-2:] == [expected, "$"] + + retry_until(output_matches, 2, raises=True) def test_session_new_window_with_direction( diff --git a/tests/test_window.py b/tests/test_window.py index 73e6f61089..3567726b8a 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -5,7 +5,6 @@ import logging import pathlib import shutil -import time import typing as t import pytest @@ -20,6 +19,7 @@ ) from libtmux.pane import Pane from libtmux.server import Server +from libtmux.test.retry import retry_until from libtmux.window import Window if t.TYPE_CHECKING: @@ -557,11 +557,14 @@ def test_split_with_environment( environment=environment, ) assert pane is not None - # wait a bit for the prompt to be ready as the test gets flaky otherwise - time.sleep(0.05) + retry_until(lambda: pane.capture_pane() == ["$"], 2, raises=True) for k, v in environment.items(): pane.send_keys(f"echo ${k}") - assert pane.capture_pane()[-2] == v + + def output_matches(expected: str = v) -> bool: + return pane.capture_pane()[-2:] == [expected, "$"] + + retry_until(output_matches, 2, raises=True) def test_split_window_zoom( From a3b40856dfe69f757ffa6505e856cbc4b8c12381 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 17:18:36 -0500 Subject: [PATCH 2/7] Lifecycle(feat): Add captured defaults and owners why: Examples need ordinary tmux defaults and external test isolation. Cleanup must retain its accepted target and expose failures for retry. what: - Capture endpoint, executable and child environment when constructing a Server, with explicit selectors ahead of environment defaults - Add generation-bound ownership for servers, sessions, windows and panes, including bounded cleanup and paired body/cleanup errors - Keep plain Server contexts borrowed and use owners for object scopes - Test unchanged examples under external socket defaults and document the Python 3.11 floor, changed defaults and remaining lifecycle work --- .github/CONTRIBUTING.md | 2 +- .github/WRITING.md | 6 + CHANGES | 59 ++++ README.md | 30 +- docs/api/index.md | 1 + docs/api/libtmux.lifecycle.md | 6 + docs/api/libtmux.neo.md | 4 +- docs/project/compatibility.md | 4 +- docs/quickstart.md | 4 +- docs/topics/configuration.md | 109 ++++--- docs/topics/context_managers.md | 154 ++++------ examples/session_scope.py | 11 + pyproject.toml | 10 +- src/libtmux/__init__.py | 3 + src/libtmux/_internal/control_mode.py | 15 +- src/libtmux/_internal/env.py | 56 +++- src/libtmux/common.py | 81 ++++- src/libtmux/exc.py | 4 + src/libtmux/hooks.py | 21 +- src/libtmux/lifecycle.py | 323 ++++++++++++++++++++ src/libtmux/neo.py | 25 +- src/libtmux/options.py | 10 +- src/libtmux/pane.py | 93 +++--- src/libtmux/pytest_plugin.py | 62 ++-- src/libtmux/server.py | 341 +++++++++++++-------- src/libtmux/session.py | 41 +-- src/libtmux/test/environment.py | 36 +-- src/libtmux/test/random.py | 7 +- src/libtmux/test/retry.py | 4 - src/libtmux/test/temporary.py | 23 +- src/libtmux/window.py | 44 +-- tests/test/test_environment.py | 38 +++ tests/test_cleanup_scopes.py | 120 ++++++++ tests/test_client_snapshot.py | 117 +++++++ tests/test_endpoint_defaults.py | 358 ++++++++++++++++++++++ tests/test_example_harness.py | 89 ++++++ tests/test_from_env.py | 9 +- tests/test_options.py | 2 +- tests/test_ownership.py | 272 +++++++++++++++++ tests/test_pane_capture_pane.py | 8 +- tests/test_server.py | 44 ++- uv.lock | 424 ++------------------------ 42 files changed, 2104 insertions(+), 966 deletions(-) create mode 100644 docs/api/libtmux.lifecycle.md create mode 100644 examples/session_scope.py create mode 100644 src/libtmux/lifecycle.py create mode 100644 tests/test_cleanup_scopes.py create mode 100644 tests/test_client_snapshot.py create mode 100644 tests/test_endpoint_defaults.py create mode 100644 tests/test_example_harness.py create mode 100644 tests/test_ownership.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c0edbe84a..e066056b44 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -25,7 +25,7 @@ $ cd libtmux $ uv sync --all-extras --dev ``` -This project uses Python 3.10+, [uv] for dependency management, [ruff] +This project uses Python 3.11+, [uv] for dependency management, [ruff] for linting and formatting, [mypy] for type checking, and [pytest] for testing (with [pytest-watcher] for continuous runs). diff --git a/.github/WRITING.md b/.github/WRITING.md index d1a252ee40..1148215d10 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -178,6 +178,12 @@ docstring. `ELLIPSIS` and `NORMALIZE_WHITESPACE` are enabled globally differences do not fail a comparison. Reach for an inline `# doctest: +FLAG` only for the block that needs something more. +**Standalone programs.** `tests/test_example_harness.py` compares the +ordinary README program with `examples/session_scope.py` and runs that file +unchanged in a child process. Its unprompted README block uses this test +instead of the doctest collector. The harness supplies endpoint defaults and +checks cleanup after both successful execution and a body exception. + **`# doctest: +SKIP` is not permitted.** It is a workaround that tests nothing. Use the fixtures. diff --git a/CHANGES b/CHANGES index 7a691b0cc9..7f222f5f58 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,65 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Breaking changes + +#### Server context and executable capture + +A plain `Server` context leaves remote state intact. Use `server.own()` to accept whole-daemon destruction. + +`Server` captures an absolute tmux executable path from its construction-time `PATH` and working directory. Commands, object queries, control clients, health checks and version probes use that executable and the captured child environment. A missing executable stays unresolved until a new handle is constructed. The read-only `tmux_bin` property reports the captured path or `None`. Version caches belong to each server handle; standalone `get_version()` and `get_version_str()` retain their existing cache behavior. + +#### Python 3.11 minimum + +Minimum Python 3.11 (was 3.10). Session cleanup uses native +`BaseExceptionGroup` to retain body and teardown failures, including +`KeyboardInterrupt` and `SystemExit`. + +#### Captured server endpoints + +{class}`~libtmux.Server` resolves explicit socket selectors, then +`LIBTMUX_SOCKET_PATH`, `LIBTMUX_SOCKET_NAME`, `TMUX`, and the default socket. +It retains the absolute endpoint and client environment for its lifetime. +Explicit paths must be absolute; passing both selectors raises `ValueError`. +Socket names reject separators, NUL, `.` and `..`. Named/default selectors +require an absolute `TMUX_TMPDIR` when set, and a missing root raises instead +of falling back to another endpoint. Create a new handle to select a changed +environment. + +```python +import pathlib +import libtmux + +# Before +server = libtmux.Server(socket_path="relative.sock") +# After +server = libtmux.Server(socket_path=pathlib.Path("relative.sock").absolute()) +``` + +### What's new + +#### Explicit remote ownership + +Call `.own()` on a server, session, window or pane to accept destruction responsibility. Owners retain the endpoint, daemon token and object ID, refuse replacement daemons, preserve paired body/cleanup failures and permit retry after failed teardown. Adoption reserves the server option `@libtmux_owner_generation`; invalid existing values fail acceptance. Session, window and pane contexts use the same ownership behavior. See {doc}`topics/context_managers` for complete examples. + +`resource.own(timeout=...)` bounds acceptance and cleanup, including time spent waiting for another close call. `Server.cmd(timeout=...)` terminates and reaps a timed-out client. A timeout cannot undo a remote operation that the client already dispatched. + +#### Client environment isolation and object cleanup + +{class}`~libtmux.Server` accepts `child_environment` overrides and exposes an +immutable captured mapping. Client launches omit `TMUX` and `TMUX_PANE` +without changing the host environment. Session, window and pane scopes target +object IDs, including after a rename or a move to another parent. +`BaseExceptionGroup` retains both a body exception and a cleanup exception. +A failed cleanup can be retried; a prior successful destruction is harmless. +`temp_window` uses the window scope's cleanup. Pytest finalizers retain exact +endpoints and report cleanup failures. + +{class}`~libtmux.test.environment.EnvironmentVarGuard` restores each key's +original state after repeated edits, including prior absence and exceptions. +See {doc}`topics/configuration` for endpoint precedence and the separate +client and tmux environment APIs. + ### Documentation #### Cleaner `from_env` examples (#719) diff --git a/README.md b/README.md index 920af194ff..ab27d64dd9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli ## Requirements & support - tmux: >= 3.2a -- Python: >= 3.10 (CPython and PyPy) +- Python: >= 3.11 (CPython and PyPy) Maintenance-only backports (no new fixes): @@ -84,6 +84,32 @@ libtmux = "0.50.*" ## 🚀 Quickstart +Create a session at your configured tmux endpoint and remove it on scope exit. +Save this program as `session_scope.py`, or run +[`examples/session_scope.py`](examples/session_scope.py): + +```python +"""Create and clean up a session at the ordinary configured endpoint.""" + +from __future__ import annotations + +import uuid + +import libtmux + +server = libtmux.Server() +with server.new_session(session_name=f"libtmux-example-{uuid.uuid4().hex}") as session: + print(session.session_id, flush=True) +``` + +`Server()` captures its endpoint at construction: explicit `socket_path` or +`socket_name`, then `LIBTMUX_SOCKET_PATH`, `LIBTMUX_SOCKET_NAME`, `TMUX`, or the +default socket. For named/default sockets, `TMUX_TMPDIR` selects the root. +Empty selector variables count as absent. Later environment changes do not +redirect an existing handle. The [external harness](tests/test_example_harness.py) +runs this same file unchanged under both private path and name defaults, +including a body failure that must still remove the session. + ### Open a tmux session First, start a tmux session to connect to: @@ -110,7 +136,7 @@ Connect to a live tmux session: >>> import libtmux >>> svr = libtmux.Server() >>> svr -Server(socket_path=/tmp/tmux-.../default) +Server(socket_path=.../tmux-.../default) ``` **Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your diff --git a/docs/api/index.md b/docs/api/index.md index 23cd9043b1..8197351c03 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -178,4 +178,5 @@ Options Hooks Constants Exceptions +Ownership ``` diff --git a/docs/api/libtmux.lifecycle.md b/docs/api/libtmux.lifecycle.md new file mode 100644 index 0000000000..fd17b1ab61 --- /dev/null +++ b/docs/api/libtmux.lifecycle.md @@ -0,0 +1,6 @@ +# Ownership + +```{eval-rst} +.. automodule:: libtmux.lifecycle + :members: Owned, OwnedIdentity +``` diff --git a/docs/api/libtmux.neo.md b/docs/api/libtmux.neo.md index 01868cc507..bf741ca8ee 100644 --- a/docs/api/libtmux.neo.md +++ b/docs/api/libtmux.neo.md @@ -33,9 +33,11 @@ Attach default tmux {class}`~libtmux.Server` to `t`: >>> import libtmux >>> t = libtmux.Server() >>> t -Server(socket_path=/tmp/tmux-.../default) +Server(socket_path=...) ``` +The representation shows the absolute endpoint selected from your arguments or environment. Its location depends on your tmux configuration. + ## Session Get the {class}`~libtmux.Session` object: diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 4dc5347e14..2ab2c8900e 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -2,8 +2,8 @@ ## Python -- **Minimum**: Python 3.10 -- **Tested**: Python 3.10, 3.11, 3.12, 3.13 +- **Minimum**: Python 3.11 +- **CI**: Python 3.14 - **Maximum**: Python < 4.0 ## tmux diff --git a/docs/quickstart.md b/docs/quickstart.md index b8f9188493..10332091b7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -148,9 +148,11 @@ First, we can grab a {class}`~libtmux.Server`. >>> import libtmux >>> server = libtmux.Server() >>> server -Server(socket_path=/tmp/tmux-.../default) +Server(socket_path=...) ``` +The representation shows the absolute endpoint selected from your arguments or environment. Its location depends on your tmux configuration. + :::{tip} You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md index e1180c3809..a7463bb001 100644 --- a/docs/topics/configuration.md +++ b/docs/topics/configuration.md @@ -1,58 +1,77 @@ # Configuration -You configure libtmux through Python: there are no config files, and you -set everything through method calls on {class}`~libtmux.Server`, -{class}`~libtmux.Session`, {class}`~libtmux.Window`, and -{class}`~libtmux.Pane` objects, with sensible defaults. If you're driving -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 -environment variables libtmux reads, and the format-string system -libtmux uses internally to read tmux state. +{class}`~libtmux.Server` captures one tmux endpoint when you construct it. +You can use ordinary defaults, pass a socket selector, or configure the same +program from its launch environment. Commands and cleanup retain that +endpoint after you change the host environment. ## Environment variables -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. +The first selected value wins: -tmux exports both into every pane it spawns: +1. An explicit `socket_path` or `socket_name`. Passing both raises `ValueError`. +2. Nonempty `LIBTMUX_SOCKET_PATH`. +3. Nonempty `LIBTMUX_SOCKET_NAME`. +4. Nonempty `TMUX`, parsed as `socket_path,server_pid,session_id`. +5. The named `default` socket. -| 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` | +Empty environment selectors count as absent. An invalid selected value raises +without trying a lower priority selector. Lower priority values cannot +invalidate an explicit choice. Paths must be absolute and contain no NUL; +libtmux preserves spaces and commas. Socket names must be nonempty leaf +names without `/`, `\`, NUL, `.` or `..`. -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() `, +For a named/default socket, libtmux captures nonempty `TMUX_TMPDIR` or `/tmp` +and derives `/tmux-/`. The supplied root must be absolute +and exist when you issue a command. Libtmux creates the private per-UID +directory if needed, checks its owner and permissions, and passes the captured +path to tmux. A missing or removed root raises; an explicit path does not +create its parent directory. `socket_path` exposes the captured absolute +path for named sockets too. `socket_name_factory` supplies an explicit name +when you omit both explicit selectors. + +`TMUX` splits from the last two commas so the socket path can contain commas. +Its PID must be positive ASCII decimal; its session field accepts a +nonnegative ASCII decimal ID, one optional `$` prefix, or tmux's `-1` +no-session sentinel. Malformed context raises +{exc}`~libtmux.exc.NotInsideTmux`. The selected path still requires an +absolute path. {meth}`Server.from_env() ` reads a +supplied mapping or the host `TMUX` context; child {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. +{meth}`Pane.from_env() ` also resolve `TMUX_PANE` +against the live server. See {ref}`self-location` for pane context. + +## Client and tmux environments + +`child_environment` supplies overrides for a copy of the host environment at +construction. The endpoint resolver reads that copy, then client launches +receive its immutable snapshot with `TMUX` and `TMUX_PANE` removed. Commands, +including {meth}`Server.new_session() `, leave the +host environment unchanged. `child_environment` cannot redirect an explicit +socket selector. There is no `LIBTMUX_SOCKET_ENV` variable. + +The constructor also resolves `tmux_bin` against the captured `PATH` and working directory. `server.tmux_bin` reports that absolute path, or `None` when a bare executable name was not found. Adding an executable to `PATH` later does not repair an existing handle; construct a new one. The captured path preserves filesystem traversal, including symlinks and `..`; it does not pin the executable's bytes against replacement on disk. Commands, object queries, control clients, health checks and version probes all use this path and the captured environment. Each handle caches its own version probe. The standalone `libtmux.common.get_version()` and `get_version_str()` functions keep their separate process-wide caches. + +The `environment=` argument to session, window and pane creation configures +tmux's environment for those resources. Methods such as +{meth}`~libtmux.common.EnvironmentMixin.set_environment` change tmux's +server/session environment. They do not edit a handle's captured client +environment or restore earlier tmux values on scope exit. + +{class}`~libtmux.test.environment.EnvironmentVarGuard` edits process-global +variables and restores their original value or absence after repeated edits +and exceptions. Concurrent writers share that host state. Prefer a child +environment for an external example harness: it leaves the parent's +environment unchanged and keeps whole-server cleanup outside the ordinary +program. The repository's `tests/test_example_harness.py` executes +`examples/session_scope.py` unchanged using both socket selector variables, +checks session cleanup after success and body failure, then destroys its +private server. On Linux it checks daemon exit through a retained PID handle +and checks the endpoint before releasing its temporary root. + +`LIBTMUX_TMUX_FORMAT_SEPARATOR` configures the separator (default `␞`) used +to parse tmux's format output. It has no role in endpoint selection. ## Format strings diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index d42b16b8b0..b0c09d1407 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -1,138 +1,92 @@ (context_managers)= -# Context managers +# Context managers and ownership -When you create tmux objects through libtmux, they normally live until you -explicitly kill them. A context manager hands that cleanup back to Python: you -scope an object to a block, and libtmux kills the underlying tmux object the -moment you leave it — whether you exit cleanly or an exception unwinds the -stack. The {class}`~libtmux.Server`, {class}`~libtmux.Session`, -{class}`~libtmux.Window`, and {class}`~libtmux.Pane` classes (all main tmux -objects) support this. +Use an owned scope to accept responsibility for destroying a remote tmux resource. Looking up a handle leaves that resource alive. A plain `Server` context also leaves remote state intact; `server.own()` accepts whole-daemon destruction. -Most readers never reach for this. If you're building a long-running -application, you typically let objects persist and tear them down yourself. The -context-manager form earns its keep in test fixtures and short-lived scripts, -where you want a tmux object to exist for exactly one block and then vanish. +## Ordinary session scope -Open two terminals: - -Terminal one: start tmux in a separate terminal: - -```console -$ tmux -``` - -Terminal two, `python` or `ptpython` if you have it: - -```console -$ python -``` - -Import `libtmux`: +This program uses your configured tmux endpoint, creates a session and removes that session at scope exit. Other sessions remain. The imports and server constructor are part of the example; a test harness can redirect the unchanged code through environment defaults. ```python >>> import libtmux +>>> server = libtmux.Server() +>>> with server.new_session() as session: +... print(session.session_id.startswith("$")) +True ``` -## Server context manager +Session, window and pane contexts accept ownership when entered. They retain the endpoint, daemon generation and object ID accepted at that point. Renaming a session or moving a window or pane does not redirect cleanup. A second active context on the same handle raises instead of replacing its unfinished cleanup scope. + +## Taking ownership of an existing object -You create a temporary server that will be killed when you're done: +Call `.own()` on a server, session, window or pane. This example looks up a session after creating it, then takes ownership of the returned handle: ```python ->>> with Server() as server: -... session = server.new_session() -... print(server.is_alive()) +>>> import libtmux +>>> server = libtmux.Server() +>>> created = server.new_session() +>>> existing = server.sessions.get(session_id=created.session_id) +>>> assert existing is not None +>>> owner = existing.own() +>>> with owner as session: +... print(session.session_id == created.session_id) +True +>>> print(owner.closed) True ->>> print(server.is_alive()) # Server is killed after exiting context -False ``` -## Session context manager +`owner.value` gives you the borrowed handle. `owner.identity` records the immutable cleanup target. Editing the handle's ID, parent or server after acceptance does not change that identity. You can call `owner.close()` without using a context; repeating a successful close has no effect. + +## Window and pane scopes -You create a temporary session that will be killed when you're done: +Window destruction removes its panes and links from all sessions. Pane destruction removes that pane. These scopes nest inside a session scope so the example removes the hierarchy it created: ```python ->>> server = Server() +>>> import libtmux +>>> server = libtmux.Server() >>> with server.new_session() as session: -... print(session in server.sessions) -... window = session.new_window() +... with session.new_window() as window: +... with window.split() as pane: +... print(pane.pane_id.startswith("%")) True ->>> print(session in server.sessions) # Session is killed after exiting context -False ``` -## Window context manager - -You create a temporary window that will be killed when you're done: - -```python ->>> server = Server() ->>> session = server.new_session() ->>> with session.new_window() as window: -... print(window in session.windows) -... pane = window.split() -True ->>> print(window in session.windows) # Window is killed after exiting context -False -``` +Cleanup runs from the innermost scope outward. tmux may terminate a server itself when its last session disappears; leaving a borrowed `Server` context does not send `kill-server`. -## Pane context manager +## Whole-server destruction -You create a temporary pane that will be killed when you're done: +A server owner destroys the daemon and its sessions, windows and panes. This cleanup demonstration uses a private endpoint because destroying your normal daemon would interrupt its sessions. Explicit isolation belongs in this example's subject; ordinary examples above use normal defaults. ```python ->>> server = Server() ->>> session = server.new_session() ->>> window = session.new_window() ->>> with window.split() as pane: -... print(pane in window.panes) -... pane.send_keys('echo "Hello"') +>>> import pathlib +>>> import tempfile +>>> import libtmux +>>> with tempfile.TemporaryDirectory(prefix="libtmux-owned-server-") as directory: +... server = libtmux.Server( +... socket_path=pathlib.Path(directory) / "tmux", +... config_file="/dev/null", +... ) +... session = server.new_session() +... with server.own() as owned_server: +... print(owned_server.is_alive()) +... print(server.is_alive()) True ->>> print(pane in window.panes) # Pane is killed after exiting context False ``` -## Nested context managers - -For complex setups, you can nest contexts to build a whole tmux hierarchy at -once and have every layer torn down for you: - -```python ->>> with Server() as server: -... with server.new_session() as session: -... with session.new_window() as window: -... with window.split() as pane: -... pane.send_keys('echo "Hello"') -... # Do work with the pane -... # Everything is cleaned up automatically when exiting contexts -``` - -This ensures that: +On Linux, the owner retains a process descriptor to observe daemon exit. Removing a socket or directory does not establish termination. If the accepted daemon remains alive but its endpoint disappears, cleanup raises and remains retryable. A replacement daemon at the same path raises `StaleTmuxOwner` before destruction. -1. The pane is killed when exiting its context -2. The window is killed when exiting its context -3. The session is killed when exiting its context -4. The server is killed when exiting its context +## Daemon identity -The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. +Ownership acceptance initializes the reserved server option `@libtmux_owner_generation` if it is absent, then reads that token, PID, start time and object ID through the same tmux connection. The token contains 32 ASCII hexadecimal characters. An existing empty or malformed value fails acceptance without replacing the value. Keep this reserved option unchanged for the daemon's lifetime and do not shadow it on sessions or windows. -## Benefits +The destructive command checks the token, PID and start time inside the receiving daemon's command dispatch. The random token distinguishes daemons even if the operating system reuses a PID within tmux's whole-second start-time precision. Changing the token after acceptance makes an existing owner stale; it does not authorize cleanup of another daemon. -Reaching for a context manager buys you a few things. Resources clean themselves -up the moment you leave the block, so you never manually call the -{meth}`~libtmux.Server.kill`, {meth}`~libtmux.Session.kill`, -{meth}`~libtmux.Window.kill`, or {meth}`~libtmux.Pane.kill` methods and the code -stays uncluttered. Because cleanup runs on the way out of the block, it fires -even when an exception unwinds the stack — so you don't leak a stray session or -pane on the error path. And when you nest contexts, the objects tear down in -hierarchical order, which keeps tmux's own bookkeeping consistent. +## Cleanup failures and cancellation -## When to use +If the body and teardown both fail, `BaseExceptionGroup` retains the body error followed by the cleanup error. This includes `KeyboardInterrupt` and `SystemExit`. `owner.cleanup_error` retains the latest teardown error, and `owner.closed` stays false until cleanup succeeds. Repair the cause and call `owner.close()` to retry. A successful retry clears the retained cleanup error. -Use context managers when you're writing test fixtures, running short-lived -sessions, or managing several tmux servers that each need to disappear cleanly. -They also pay off in any script that might raise partway through, or when you're -spinning up an isolated environment that has to be cleaned up afterward. +`resource.own(timeout=5.0)` bounds acceptance and each cleanup attempt, including time spent waiting for another cleanup call. A command timeout kills and reaps the tmux client process; it cannot undo a remote operation that was already dispatched. The owner remains open for inspection and retry. Garbage collection releases local observation descriptors and does not destroy remote resources. These APIs are synchronous; they do not provide an asynchronous task-cancellation supervisor. -[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS +The repository's `tests/test_ownership.py` executes adoption for all four resource types, replacement refusal, edited-handle cleanup, a missing socket with a live daemon, paired failures and retry after a hung client. `tests/test_example_harness.py` executes the ordinary example unchanged under both socket environment defaults. Creation recovery, discovery and find-or-create are separate implementation work; this ownership API alone does not establish those guarantees. diff --git a/examples/session_scope.py b/examples/session_scope.py new file mode 100644 index 0000000000..a075d94aef --- /dev/null +++ b/examples/session_scope.py @@ -0,0 +1,11 @@ +"""Create and clean up a session at the ordinary configured endpoint.""" + +from __future__ import annotations + +import uuid + +import libtmux + +server = libtmux.Server() +with server.new_session(session_name=f"libtmux-example-{uuid.uuid4().hex}") as session: + print(session.session_id, flush=True) diff --git a/pyproject.toml b/pyproject.toml index 0002133c92..7d4fa3358c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "libtmux" version = "0.62.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." -requires-python = ">=3.10,<4.0" +requires-python = ">=3.11,<4.0" authors = [ {name = "Tony Narlock", email = "tony@git-pull.com"} ] @@ -16,7 +16,6 @@ classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", "Programming Language :: Python", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -58,7 +57,6 @@ dev = [ "sphinx-autobuild", "types-docutils", # Testing - "typing-extensions; python_version < '3.11'", "gp-libs>=0.0.19", "pytest", "pytest-rerunfailures", @@ -81,7 +79,6 @@ docs = [ "sphinx-autobuild", ] testing = [ - "typing-extensions; python_version < '3.11'", "gp-libs>=0.0.19", "pytest", "pytest-rerunfailures", @@ -94,7 +91,6 @@ coverage =[ "pytest-cov", ] lint = [ - "typing-extensions; python_version < '3.11'", "ruff>=0.16.1", "mypy", ] @@ -142,7 +138,7 @@ sphinx-ux-badges = false [tool.mypy] strict = true -python_version = "3.10" +python_version = "3.11" files = [ "src", "tests", @@ -185,7 +181,7 @@ exclude_lines = [ ] [tool.ruff] -target-version = "py310" +target-version = "py311" [tool.ruff.lint] # `select` is deliberately unset: ruff 0.16 enables a curated default rule diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index c99fde4bda..16fbc7217d 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -15,6 +15,7 @@ __version__, ) from .client import Client +from .lifecycle import Owned, OwnedIdentity from .pane import Pane from .server import Server from .session import Session @@ -24,6 +25,8 @@ __all__ = ( "Client", + "Owned", + "OwnedIdentity", "Pane", "Server", "Session", diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 05945451eb..c46bc39a1c 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -15,8 +15,7 @@ if t.TYPE_CHECKING: import types - - from typing_extensions import Self + from typing import Self from libtmux.server import Server from libtmux.session import Session @@ -60,16 +59,11 @@ def __init__(self, server: Server, session: Session) -> None: def __enter__(self) -> Self: """Spawn control-mode client and wait for registration.""" + tmux_bin = self.server._require_tmux_bin() + self.server._prepare_socket_directory() 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 = [] + socket_args = ["-S", self.server.socket_path] cmd = [ tmux_bin, @@ -89,6 +83,7 @@ def __enter__(self) -> Self: stderr=subprocess.PIPE, text=True, encoding="utf-8", + env=self.server.child_environment, ) finally: # subprocess owns read_fd now diff --git a/src/libtmux/_internal/env.py b/src/libtmux/_internal/env.py index 407b0934fa..0dff14863f 100644 --- a/src/libtmux/_internal/env.py +++ b/src/libtmux/_internal/env.py @@ -29,6 +29,7 @@ from __future__ import annotations import os +import pathlib import typing as t from libtmux import exc @@ -114,7 +115,20 @@ def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: raise exc.NotInsideTmux(TMUX) parts = raw.rsplit(",", 2) - if len(parts) != 3 or not parts[0]: + if ( + len(parts) != 3 + or not parts[0] + or not parts[1].isascii() + or not parts[1].isdecimal() + or int(parts[1]) <= 0 + or ( + parts[2] != "-1" + and ( + not parts[2].removeprefix("$").isascii() + or not parts[2].removeprefix("$").isdecimal() + ) + ) + ): raise exc.NotInsideTmux( TMUX, reason="not ',,'", @@ -122,6 +136,46 @@ def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: return parts[0] +def _absolute_socket_path(value: str, selector: str) -> str: + """Validate a selected path without changing its spelling.""" + if "\0" in value or not pathlib.Path(value).is_absolute(): + msg = f"{selector} must be an absolute path without NUL" + raise ValueError(msg) + return value + + +def resolve_server_endpoint( + socket_path: str | pathlib.Path | None, + socket_name: str | None, + env: dict[str, str], +) -> tuple[str, str | None]: + """Capture an endpoint and its named-socket root in a private environment.""" + if socket_path is not None and socket_name is not None: + msg = "socket_path and socket_name are mutually exclusive" + raise ValueError(msg) + if socket_path is not None: + return _absolute_socket_path(str(socket_path), "socket_path"), None + if socket_name is None: + if path := env.get("LIBTMUX_SOCKET_PATH"): + return _absolute_socket_path(path, "LIBTMUX_SOCKET_PATH"), None + if name := env.get("LIBTMUX_SOCKET_NAME"): + socket_name = name + elif env.get(TMUX): + return _absolute_socket_path(socket_path_from_env(env), TMUX), None + else: + socket_name = "default" + if ( + not socket_name + or socket_name in {".", ".."} + or any(character in socket_name for character in ("/", "\\", "\0")) + ): + msg = "socket_name must be a nonempty leaf name without separators or NUL" + raise ValueError(msg) + root = _absolute_socket_path(env.get("TMUX_TMPDIR") or "/tmp", "TMUX_TMPDIR") + env["TMUX_TMPDIR"] = root + return str(pathlib.Path(root) / f"tmux-{os.getuid()}" / socket_name), socket_name + + def pane_id_from_env(env: t.Mapping[str, str] | None = None) -> str: """Return the pane id recorded in ``$TMUX_PANE``. diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..c85cf7ecad 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -9,6 +9,7 @@ import functools import logging +import os import re import shlex import shutil @@ -21,6 +22,10 @@ if t.TYPE_CHECKING: from collections.abc import Callable + from typing import Self + + from libtmux.lifecycle import Owned + from libtmux.server import Server logger = logging.getLogger(__name__) @@ -50,6 +55,28 @@ class CmdMixin: cmd: CmdProtocol + def own(self, *, timeout: float = 5.0) -> Owned[Self]: + """Accept remote destruction responsibility for this existing object. + + Parameters + ---------- + timeout : float + Maximum seconds for acceptance and for each cleanup attempt. + + Returns + ------- + Owned + Context manager retaining the endpoint, daemon and object identity. + """ + from libtmux.lifecycle import Owned + + return Owned(self, timeout=timeout) + + def _supports_version(self, minimum: str) -> bool: + """Check capabilities with this object's captured tmux client.""" + server = t.cast("Server", getattr(self, "server", self)) + return server._version >= LooseVersion(minimum) + class EnvironmentMixin: """Mixin for manager session and server level environment variables in tmux.""" @@ -280,6 +307,17 @@ def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: ) +def _run_cleanup(cleanup: Callable[[], None], body_error: BaseException | None) -> None: + """Run teardown and retain a body error if teardown also fails.""" + try: + cleanup() + except BaseException as cleanup_error: + if body_error is not None: + message = "resource body and cleanup both failed" + raise BaseExceptionGroup(message, [body_error, cleanup_error]) from None + raise + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. @@ -309,8 +347,19 @@ class tmux_cmd: Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: - resolved = tmux_bin or shutil.which("tmux") + def __init__( + self, + *args: t.Any, + tmux_bin: str | None = None, + env: t.Mapping[str, str] | None = None, + timeout: float | None = None, + ) -> None: + client_env = dict(os.environ if env is None else env) + client_env.pop("TMUX", None) + client_env.pop("TMUX_PANE", None) + resolved = tmux_bin or shutil.which( + "tmux", path=client_env.get("PATH", os.defpath) + ) if not resolved: raise exc.TmuxCommandNotFound @@ -335,8 +384,14 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: text=True, encoding="utf-8", errors="backslashreplace", + env=client_env, ) - stdout, stderr = self.process.communicate() + try: + stdout, stderr = self.process.communicate(timeout=timeout) + except BaseException: + self.process.kill() + self.process.communicate() + raise returncode = self.process.returncode except FileNotFoundError: raise exc.TmuxCommandNotFound from None @@ -397,7 +452,11 @@ def _no_version_flag_fallback() -> str: raise exc.LibTmuxException(msg) -def _query_version(tmux_bin: str | None = None) -> str: +def _query_version( + tmux_bin: str | None = None, + *, + env: t.Mapping[str, 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"``, @@ -408,6 +467,8 @@ def _query_version(tmux_bin: str | None = None) -> str: ---------- tmux_bin : str, optional Path to tmux binary. If *None*, uses the system tmux. + env : Mapping[str, str], optional + Captured environment for the version probe. Returns ------- @@ -422,7 +483,11 @@ def _query_version(tmux_bin: str | None = None) -> str: :exc:`~libtmux.exc.VersionTooLow` tmux reported another error on ``-V``. """ - proc = tmux_cmd("-V", tmux_bin=tmux_bin) + proc = ( + tmux_cmd("-V", tmux_bin=tmux_bin) + if env is None + else tmux_cmd("-V", tmux_bin=tmux_bin, env=env) + ) if proc.stderr: if proc.stderr[0] == "tmux: unknown option -- V": raise _TmuxVersionUnavailable @@ -506,7 +571,11 @@ def get_version(tmux_bin: str | None = None) -> LooseVersion: # OpenBSD base tmux lacks ``-V``; skip letter-stripping on the synthetic. return LooseVersion(_no_version_flag_fallback()) - # Allow latest tmux HEAD + return _parse_version(version) + + +def _parse_version(version: str) -> LooseVersion: + """Normalize a raw tmux version for capability comparisons.""" if version == "master": return LooseVersion(f"{TMUX_MAX_VERSION}-master") diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102f..d4f54c2d68 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -99,6 +99,10 @@ class TmuxCommandNotFound(LibTmuxException): """Application binary for tmux not found.""" +class StaleTmuxOwner(LibTmuxException): + """The endpoint now names a daemon other than the one accepted by an owner.""" + + class NotInsideTmux(LibTmuxException): """Raised when the process is not running inside a tmux pane. diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py index d9ad24d407..e4507ff966 100644 --- a/src/libtmux/hooks.py +++ b/src/libtmux/hooks.py @@ -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, @@ -48,7 +48,7 @@ from libtmux.options import handle_option_error if t.TYPE_CHECKING: - from typing_extensions import Self + from typing import Self HookDict = dict[str, t.Any] HookValues = dict[int, str] | SparseArray[str] | list[str] @@ -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,7 +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): + if flag in {"-p", "-w"} and not self._supports_version("3.2"): warnings.warn( "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", stacklevel=2, @@ -175,7 +168,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): + if flag in {"-p", "-w"} and not self._supports_version("3.2"): warnings.warn( "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", stacklevel=2, @@ -228,7 +221,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): + if flag in {"-p", "-w"} and not self._supports_version("3.2"): warnings.warn( "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", stacklevel=2, @@ -293,7 +286,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): + if flag in {"-p", "-w"} and not self._supports_version("3.2"): warnings.warn( "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", stacklevel=2, @@ -351,7 +344,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): + if flag in {"-p", "-w"} and not self._supports_version("3.2"): warnings.warn( "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", stacklevel=2, diff --git a/src/libtmux/lifecycle.py b/src/libtmux/lifecycle.py new file mode 100644 index 0000000000..c374ec91de --- /dev/null +++ b/src/libtmux/lifecycle.py @@ -0,0 +1,323 @@ +"""Explicit destruction responsibility for tmux resources. + +Borrowed handles remain useful after client scope exit. An ``Owned`` context +accepts destruction responsibility and retains the remote identity independently +of later edits to the borrowed handle. +""" + +from __future__ import annotations + +import copy +import dataclasses +import math +import os +import re +import secrets +import select +import shlex +import threading +import time +import typing as t +import weakref + +from libtmux import exc +from libtmux.common import _run_cleanup + +if t.TYPE_CHECKING: + import types + + from libtmux.server import Server + +Resource = t.TypeVar("Resource") +ObjectKind = t.Literal["server", "session", "window", "pane"] +_GENERATION_OPTION = "@libtmux_owner_generation" +_TOKEN = re.compile(r"[0-9a-fA-F]{32}\Z") + + +@dataclasses.dataclass(frozen=True) +class OwnedIdentity: + """The immutable endpoint, daemon generation and object accepted for cleanup. + + Attributes + ---------- + socket_path : str + Captured absolute endpoint. + server_pid : int + PID reported by the accepted tmux daemon. + start_time : int + Daemon start time reported by tmux, in whole seconds. + generation_token : str + Random daemon metadata that also distinguishes PID/start-time reuse. + kind : str + ``server``, ``session``, ``window`` or ``pane``. + object_id : str or None + Stable tmux ID; a whole-server owner has no object ID. + """ + + socket_path: str + server_pid: int + start_time: int + generation_token: str + kind: ObjectKind + object_id: str | None + + @property + def _condition(self) -> str: + """Test this generation within the daemon's destructive command dispatch.""" + return ( + "#{&&:" + f"#{{&&:#{{==:#{{pid}},{self.server_pid}}}," + f"#{{==:#{{start_time}},{self.start_time}}}}}," + f"#{{==:#{{{_GENERATION_OPTION}}},{self.generation_token}}}}}" + ) + + +def _kind_and_id(resource: object) -> tuple[Server, ObjectKind, str | None]: + """Validate an accepted public resource and its exact tmux ID.""" + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + if isinstance(resource, Server): + return resource, "server", None + if isinstance(resource, Session): + kind: ObjectKind = "session" + object_id = resource.session_id + elif isinstance(resource, Window): + kind = "window" + object_id = resource.window_id + elif isinstance(resource, Pane): + kind = "pane" + object_id = resource.pane_id + else: + message = "ownership requires a Server, Session, Window or Pane" + raise TypeError(message) + prefix = {"session": "$", "window": "@", "pane": "%"}[kind] + if object_id is None or not re.fullmatch(re.escape(prefix) + r"[0-9]+", object_id): + message = f"invalid {kind} ID: {object_id!r}" + raise ValueError(message) + return resource.server, kind, object_id + + +def _generation_setup() -> tuple[str, ...]: + """Initialize reserved server metadata on the accepting tmux connection.""" + token = secrets.token_hex(16) + return ( + "set-option", + "-soq", + _GENERATION_OPTION, + token, + ";", + ) + + +def _capture_identity( + client: Server, kind: ObjectKind, object_id: str | None, timeout: float +) -> OwnedIdentity: + """Accept token and numeric identity in the same connected command list.""" + setup = _generation_setup() + target = ("-t", object_id) if object_id is not None else () + identity_format = f"#{{pid}}\t#{{start_time}}\t#{{{_GENERATION_OPTION}}}\t" + ( + f"#{{{kind}_id}}" if object_id is not None else "" + ) + result = client.cmd( + setup[0], + *setup[1:], + "display-message", + "-p", + *target, + identity_format, + timeout=timeout, + ) + if result.returncode or result.stderr: + raise exc.LibTmuxException( + "\n".join(result.stderr) or f"exit status {result.returncode}", + subcommand="accept-ownership", + ) + fields = result.stdout[0].split("\t") if len(result.stdout) == 1 else [] + if ( + len(fields) != 4 + or not fields[0].isascii() + or not fields[0].isdecimal() + or int(fields[0]) <= 0 + or not fields[1].isascii() + or not fields[1].isdecimal() + or not _TOKEN.fullmatch(fields[2]) + or fields[3] != (object_id or "") + ): + message = ( + "tmux returned invalid ownership identity or reserved generation token" + ) + raise exc.LibTmuxException(message) + return OwnedIdentity( + client.socket_path, int(fields[0]), int(fields[1]), fields[2], kind, object_id + ) + + +class Owned(t.Generic[Resource]): + """Destroy an accepted remote resource at context exit or on ``close()``. + + Acceptance initializes the reserved server option + ``@libtmux_owner_generation`` if absent, then captures it with the daemon + and object IDs in one tmux connection. An existing malformed token fails + acceptance. Keep that reserved option unchanged during the daemon's life. + + Each close attempt is bounded by ``timeout``. A failed attempt preserves + ``cleanup_error`` and can be retried. Both body and cleanup failures survive + in a ``BaseExceptionGroup``. Garbage collection releases local process + observation handles only; explicit close or context exit destroys tmux state. + + Parameters + ---------- + value : Server, Session, Window or Pane + Existing resource whose destruction the caller accepts. + timeout : float + Positive finite seconds for acceptance and each cleanup attempt. + """ + + def __init__(self, value: Resource, *, timeout: float = 5.0) -> None: + if not math.isfinite(timeout) or timeout <= 0: + message = "ownership timeout must be positive and finite" + raise ValueError(message) + server, kind, object_id = _kind_and_id(value) + self._value = value + self._client = copy.copy(server) + self._timeout = timeout + self._identity = _capture_identity(self._client, kind, object_id, timeout) + self._closed = False + self._cleanup_error: BaseException | None = None + self._lock = threading.RLock() + self._pidfd: int | None = None + self._release_pidfd: weakref.finalize[[int], Owned[Resource]] | None = None + if hasattr(os, "pidfd_open"): + try: + self._pidfd = os.pidfd_open(self.identity.server_pid) + except ProcessLookupError: + # A completed process makes cleanup unnecessary if no replacement + # is listening. The guarded dispatch still checks that endpoint. + pass + else: + self._release_pidfd = weakref.finalize(self, os.close, self._pidfd) + + @property + def value(self) -> Resource: + """Return the borrowed object; editing it does not change cleanup identity.""" + return self._value + + @property + def identity(self) -> OwnedIdentity: + """Return the immutable remote identity accepted by this owner.""" + return self._identity + + @property + def closed(self) -> bool: + """Whether cleanup completed, rather than merely being attempted.""" + return self._closed + + @property + def cleanup_error(self) -> BaseException | None: + """Return the latest failed cleanup attempt, cleared after success.""" + return self._cleanup_error + + def _process_exited(self, timeout: float = 0) -> bool: + """Observe the accepted process independently of the socket pathname.""" + if self._pidfd is not None: + poller = select.poll() + poller.register(self._pidfd, select.POLLIN) + return bool(poller.poll(math.ceil(max(timeout, 0) * 1000))) + deadline = time.monotonic() + timeout + while True: + try: + os.kill(self.identity.server_pid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + if time.monotonic() >= deadline: + return False + time.sleep(min(0.01, max(0, deadline - time.monotonic()))) + + def _destroy(self, deadline: float) -> None: + """Dispatch destruction only if the connected daemon matches acceptance.""" + identity = self.identity + command = f"kill-{identity.kind}" + argv = [command] + if identity.object_id is not None: + argv += ["-t", identity.object_id] + stale = f"libtmux-stale-owner-{identity.generation_token}" + result = self._client.cmd( + "if-shell", + "-F", + identity._condition, + shlex.join(argv), + stale, + timeout=max(0.001, deadline - time.monotonic()), + ) + detail = "\n".join(result.stderr) + if result.returncode or result.stderr: + if stale in detail: + message = "the endpoint now names a different tmux daemon" + raise exc.StaleTmuxOwner(message) + if identity.object_id is not None and detail == ( + f"can't find {identity.kind}: {identity.object_id}" + ): + return + unreachable = ( + "no server running" in detail + or "error connecting to" in detail + or "server exited unexpectedly" in detail + ) + if unreachable and self._process_exited(): + return + if unreachable: + message = "accepted tmux daemon is unreachable and may still be alive" + raise exc.LibTmuxException(message, subcommand=command) + raise exc.LibTmuxException( + detail or f"exit status {result.returncode}", subcommand=command + ) + if identity.kind == "server" and not self._process_exited( + max(0, deadline - time.monotonic()) + ): + message = "accepted tmux daemon has not exited before the cleanup deadline" + raise exc.LibTmuxException(message, subcommand=command) + + def close(self) -> None: + """Destroy the accepted resource, retaining failure for inspection and retry.""" + deadline = time.monotonic() + self._timeout + if not self._lock.acquire(timeout=self._timeout): + message = "timed out waiting for another cleanup attempt" + error = TimeoutError(message) + self._cleanup_error = error + raise error + try: + if self._closed: + return + try: + self._destroy(deadline) + except BaseException as error: + self._cleanup_error = error + raise + self._closed = True + self._cleanup_error = None + if self._release_pidfd is not None: + self._release_pidfd() + self._pidfd = None + finally: + self._lock.release() + + def __enter__(self) -> Resource: + """Enter the owned scope and return its borrowed resource.""" + if self.closed: + message = "cannot enter a closed tmux owner" + raise RuntimeError(message) + return self.value + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the owner while preserving a body failure if teardown also fails.""" + _run_cleanup(self.close, exc_value) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..f9dc02d0c7 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -11,7 +11,7 @@ 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 from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -1095,20 +1095,10 @@ def fetch_objs( >>> 'session_id' in objs[0] True """ - tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + tmux_version = str(server._version) _fields, format_string = get_output_format(list_cmd, tmux_version) - cmd_args: list[str | int] = [] - - if server.socket_name: - cmd_args.insert(0, f"-L{server.socket_name}") - if server.socket_path: - cmd_args.insert(0, f"-S{server.socket_path}") - - tmux_cmds = [ - *cmd_args, - list_cmd, - ] + tmux_cmds: list[str | int] = [] if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) @@ -1121,7 +1111,7 @@ def fetch_objs( cmd_str: str | None = None if logger.isEnabledFor(logging.DEBUG): - cmd_str = shlex.join([str(x) for x in tmux_cmds]) + cmd_str = shlex.join([list_cmd, *(str(x) for x in tmux_cmds)]) logger.debug( "tmux list queried", extra={ @@ -1130,10 +1120,7 @@ def fetch_objs( }, ) - proc = tmux_cmd( - *tmux_cmds, - tmux_bin=server.tmux_bin, - ) + proc = server.cmd(list_cmd, *tmux_cmds) raise_if_stderr(proc, list_cmd) @@ -1141,7 +1128,7 @@ def fetch_objs( if logger.isEnabledFor(logging.DEBUG): if cmd_str is None: - cmd_str = shlex.join([str(x) for x in tmux_cmds]) + cmd_str = shlex.join([list_cmd, *(str(x) for x in tmux_cmds)]) logger.debug( "tmux list parsed", extra={ diff --git a/src/libtmux/options.py b/src/libtmux/options.py index ff8bb2239d..0b000e1043 100644 --- a/src/libtmux/options.py +++ b/src/libtmux/options.py @@ -86,9 +86,7 @@ from . import exc if t.TYPE_CHECKING: - from typing import TypeAlias - - from typing_extensions import Self + from typing import Self, TypeAlias from libtmux._internal.constants import TerminalFeatures from libtmux.common import tmux_cmd @@ -515,7 +513,7 @@ def explode_complex( try: term, features = item.split(":", maxsplit=1) new_val[term] = features.split(":") - except Exception: # NOQA: PERF203 + except Exception: logger.warning( "tmux options parse failed", extra={"tmux_option_key": key}, @@ -542,7 +540,7 @@ def explode_complex( new_overrides[term][k] = int(v) if v.isdigit() else v elif feature: new_overrides[term][feature] = None - except Exception: # NOQA: PERF203 + except Exception: logger.warning( "tmux options parse failed", extra={"tmux_option_key": key}, @@ -561,7 +559,7 @@ def explode_complex( ): options[key] = {} new_aliases[alias] = command - except Exception: # NOQA: PERF203 + except Exception: logger.warning( "tmux options parse failed", extra={"tmux_option_key": key}, diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f59619..882e9b1f92 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -15,7 +15,10 @@ 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, @@ -29,8 +32,8 @@ from libtmux.options import OptionsMixin if t.TYPE_CHECKING: - import sys import types + from typing import Self from libtmux._internal.types import StrPath @@ -38,11 +41,6 @@ from .session import Session from .window import Window - if sys.version_info >= (3, 11): - from typing import Self - else: - from typing_extensions import Self - logger = logging.getLogger(__name__) @@ -115,13 +113,12 @@ class Pane( server: Server def __enter__(self) -> Self: - """Enter the context, returning self. - - Returns - ------- - :class:`Pane` - The pane instance - """ + """Accept destruction responsibility and enter the pane scope.""" + previous = getattr(self, "_scope_owner", None) + if previous is not None and not previous.closed: + message = "this pane already has an active or failed cleanup scope" + raise RuntimeError(message) + self._scope_owner = self.own() return self def __exit__( @@ -130,22 +127,10 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Exit the context, killing the pane if it exists. - - Parameters - ---------- - exc_type : type[BaseException] | None - The type of the exception that was raised - exc_value : BaseException | None - The instance of the exception that was raised - exc_tb : types.TracebackType | None - The traceback of the exception that was raised - """ - if ( - self.pane_id is not None - and len(self.window.panes.filter(pane_id=self.pane_id)) > 0 - ): - self.kill() + """Destroy the accepted pane identity and retain paired failures.""" + owner = getattr(self, "_scope_owner", None) + if owner is not None: + owner.__exit__(exc_type, exc_value, exc_tb) def refresh(self) -> None: """Refresh pane attributes from tmux. @@ -641,7 +626,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._supports_version("3.4"): cmd.append("-T") else: warnings.warn( @@ -653,7 +638,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._supports_version("3.6"): cmd.append("-M") else: warnings.warn( @@ -663,7 +648,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._supports_version("3.7"): cmd.append("-H") else: warnings.warn( @@ -671,7 +656,7 @@ def capture_pane( stacklevel=2, ) if line_numbers: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.7"): cmd.append("-L") else: warnings.warn( @@ -679,7 +664,7 @@ def capture_pane( stacklevel=2, ) if line_flags: - if has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.7"): cmd.append("-F") else: warnings.warn( @@ -805,7 +790,7 @@ def send_keys( tmux_args += ("-H",) if key_name: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-K",) else: warnings.warn( @@ -820,7 +805,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._supports_version("3.4"): tmux_args += ("-c", target_client) else: warnings.warn( @@ -971,7 +956,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-l",) else: warnings.warn( @@ -983,7 +968,7 @@ def display_message( tmux_args += ("-N",) if update_pane: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.6"): tmux_args += ("-C",) else: warnings.warn( @@ -1365,7 +1350,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._supports_version("3.7"): tmux_args += ("-E",) else: warnings.warn( @@ -1380,7 +1365,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._supports_version("3.7"): for flag, value in styling.items(): if value is not None: tmux_args += (flag, value) @@ -1515,7 +1500,7 @@ def new_pane( >>> is_floating '1' """ - if not has_gte_version("3.7", tmux_bin=self.server.tmux_bin): + if not self._supports_version("3.7"): msg = "new_pane (floating panes) requires tmux 3.7+" raise exc.LibTmuxException(msg) @@ -1782,7 +1767,7 @@ 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): + if self._supports_version("3.3"): tmux_args += ("-T", title) else: warnings.warn( @@ -1791,7 +1776,7 @@ def display_popup( ) if border_lines is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.3"): tmux_args += ("-b", border_lines) else: warnings.warn( @@ -1800,7 +1785,7 @@ def display_popup( ) if style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.3"): tmux_args += ("-s", style) else: warnings.warn( @@ -1809,7 +1794,7 @@ def display_popup( ) if border_style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.3"): tmux_args += ("-S", border_style) else: warnings.warn( @@ -1818,7 +1803,7 @@ def display_popup( ) if environment: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.3"): for k, v in environment.items(): tmux_args += (f"-e{k}={v}",) else: @@ -1828,7 +1813,7 @@ def display_popup( ) if no_border: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.3"): tmux_args += ("-B",) else: warnings.warn( @@ -1837,7 +1822,7 @@ def display_popup( ) if close_on_any_key: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.6"): tmux_args += ("-k",) else: warnings.warn( @@ -1846,7 +1831,7 @@ def display_popup( ) if no_keys: - if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.6"): tmux_args += ("-N",) else: warnings.warn( @@ -1916,7 +1901,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._supports_version("3.7"): tmux_args += ("-S",) else: warnings.warn( @@ -2022,7 +2007,7 @@ def copy_mode( tmux_args += ("-M",) if page_down: - if has_gte_version("3.5", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.5"): tmux_args += ("-d",) else: warnings.warn( @@ -2469,7 +2454,7 @@ def break_pane( # given (NULL-deref); 3.7a reverted it. When needed, pass a placeholder # -n then set the real name via rename-window below. Compare the raw # version string to gate the workaround on the literal 3.7 release only. - breaks_without_name = get_version_str(tmux_bin=self.server.tmux_bin) == "3.7" + breaks_without_name = self.server._version_str == "3.7" tmux_args: tuple[str, ...] = ("-P", "-F#{window_id}") @@ -2590,7 +2575,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._supports_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..7ea3e5a637 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -25,8 +25,8 @@ USING_ZSH = "zsh" in os.getenv("SHELL", "") -def _reap_test_server(socket_name: str | None) -> None: - """Kill the tmux daemon on ``socket_name`` and unlink the socket file. +def _reap_test_server(server: Server | str | None) -> None: + """Kill the captured test daemon and unlink its socket file. Invoked from the :func:`server` and :func:`TestServer` fixture finalizers to guarantee teardown even when the daemon has already @@ -35,28 +35,24 @@ def _reap_test_server(socket_name: str | None) -> None: non-graceful exit, so ``/tmp/tmux-/`` otherwise accumulates stale entries across test runs. - Conservative: suppresses ``LibTmuxException`` / ``OSError`` on both - the kill and the unlink. A finalizer that raises replaces the real - test failure with a cleanup error, and cleanup failures are not - actionable (socket already gone, permissions changed, race with a - concurrent pytest-xdist worker). + Retains each fixture's endpoint and exposes command or unlink failures. + A string selects a named endpoint from the current environment; fixtures + pass the captured Server instead. """ - if not socket_name: + if server is None: return - - with contextlib.suppress(exc.LibTmuxException, OSError): - srv = Server(socket_name=socket_name) - if srv.is_alive(): - srv.kill() - - # ``Server(socket_name=...)`` does not populate ``socket_path`` — - # the Server class only derives the path when neither ``socket_name`` - # nor ``socket_path`` was supplied. Recompute the location tmux uses - # so we can unlink the file regardless of daemon state. - tmux_tmpdir = pathlib.Path(os.environ.get("TMUX_TMPDIR", "/tmp")) - socket_path = tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name - with contextlib.suppress(OSError): - socket_path.unlink(missing_ok=True) + if isinstance(server, str): + srv = Server(socket_name=server) + else: + # Tests may replace methods on their handle before fixture teardown. + srv = Server( + socket_name=server.socket_name, + socket_path=server.socket_path if server.socket_name is None else None, + child_environment=server.child_environment, + tmux_bin=server.tmux_bin, + ) + srv.kill() + pathlib.Path(srv.socket_path).unlink(missing_ok=True) @pytest.fixture(scope="session") @@ -172,10 +168,13 @@ def server( >>> result.assert_outcomes(passed=1) """ - server = Server(socket_name=f"libtmux_test{next(namer)}") + server = Server( + socket_name=f"libtmux_test{next(namer)}", + config_file=str(config_file), + ) def fin() -> None: - _reap_test_server(server.socket_name) + _reap_test_server(server) request.addfinalizer(fin) @@ -342,11 +341,11 @@ def TestServer( >>> server2.socket_name != server.socket_name True """ - created_sockets: list[str] = [] + created_servers: list[Server] = [] def on_init(server: Server) -> None: """Track created servers for cleanup.""" - created_sockets.append(server.socket_name or "default") + created_servers.append(server) def socket_name_factory() -> str: """Generate unique socket names.""" @@ -354,8 +353,15 @@ def socket_name_factory() -> str: def fin() -> None: """Kill all servers created with these sockets and unlink their sockets.""" - for socket_name in created_sockets: - _reap_test_server(socket_name) + failures: list[BaseException] = [] + for server in created_servers: + try: + _reap_test_server(server) + except BaseException as error: # noqa: BLE001 + failures.append(error) + if failures: + message = "test server cleanup failed" + raise BaseExceptionGroup(message, failures) request.addfinalizer(fin) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..a63745ea38 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -7,19 +7,30 @@ from __future__ import annotations +import functools import logging import os import pathlib import shutil +import stat import subprocess +import types import typing as t import warnings from libtmux import exc -from libtmux._internal.env import socket_path_from_env +from libtmux._compat import LooseVersion +from libtmux._internal.env import resolve_server_endpoint, 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 +from libtmux.common import ( + _no_version_flag_fallback, + _parse_version, + _query_version, + _TmuxVersionUnavailable, + 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 @@ -37,10 +48,7 @@ from .options import OptionsMixin if t.TYPE_CHECKING: - import types - from typing import TypeAlias - - from typing_extensions import Self + from typing import Self, TypeAlias from libtmux._internal.types import StrPath @@ -97,17 +105,27 @@ class Server( - :class:`Pane` - When instantiated stores information on live, running tmux server. + Capture an endpoint and client environment without starting a server. + Explicit selectors take precedence over ``LIBTMUX_SOCKET_PATH``, + ``LIBTMUX_SOCKET_NAME``, ``TMUX``, and the default socket, in that order. + Commands retain the selected endpoint after host environment changes. Parameters ---------- socket_name : str, optional + Nonempty leaf name. Mutually exclusive with ``socket_path``. socket_path : str, optional + Absolute path. A selected invalid path raises ``ValueError``. config_file : str, optional colors : str, optional on_init : callable, optional socket_name_factory : callable, optional tmux_bin : str or pathlib.Path, optional + child_environment : Mapping[str, str], optional + Overrides for a captured copy of the client process environment. + Endpoint defaults read this copy once. Child launches omit ``TMUX`` + and ``TMUX_PANE``; the host environment and tmux's server/session + environment remain unchanged. Examples -------- @@ -147,10 +165,21 @@ class Server( Accessed April 1st, 2018. """ - socket_name = None - """Passthrough to ``[-L socket-name]``""" - socket_path = None - """Passthrough to ``[-S socket-path]``""" + @property + def socket_name(self) -> str | None: + """Return the captured socket name, or None for a path selector.""" + return self._socket_name + + @property + def socket_path(self) -> str: + """Return the captured absolute endpoint, including for named sockets.""" + return self._socket_path + + @property + def child_environment(self) -> t.Mapping[str, str]: + """Return the immutable client environment captured at construction.""" + return self._child_environment + config_file = None """Passthrough to ``[-f file]``""" colors = None @@ -164,8 +193,6 @@ class Server( """For option management.""" default_hook_scope: OptionScope | None = OptionScope.Server """For hook management.""" - tmux_bin: str | None = None - """Custom path to tmux binary. Falls back to ``shutil.which("tmux")``.""" def __init__( self, @@ -176,19 +203,41 @@ def __init__( on_init: t.Callable[[Server], None] | None = None, socket_name_factory: t.Callable[[], str] | None = None, tmux_bin: str | pathlib.Path | None = None, + child_environment: t.Mapping[str, str] | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") - self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] - if socket_path is not None: - self.socket_path = socket_path - elif socket_name is not None: - self.socket_name = socket_name - elif socket_name_factory is not None: - self.socket_name = socket_name_factory() + client_env = dict(os.environ) + if child_environment is not None: + client_env.update(child_environment) + executable = str(tmux_bin) if tmux_bin is not None else "tmux" + resolved: str | None + if os.sep in executable: + resolved = executable + else: + resolved = shutil.which(executable, path=client_env.get("PATH", os.defpath)) + self._tmux_bin = ( + f"{pathlib.Path.cwd()}/{resolved}" + if resolved is not None and not pathlib.Path(resolved).is_absolute() + else resolved + ) + if ( + socket_path is None + and socket_name is None + and socket_name_factory is not None + ): + socket_name = socket_name_factory() + self._socket_path, self._socket_name = resolve_server_endpoint( + socket_path, + socket_name, + client_env, + ) + client_env.pop("TMUX", None) + client_env.pop("TMUX_PANE", None) + self._child_environment = types.MappingProxyType(client_env) if config_file: self.config_file = config_file @@ -199,6 +248,37 @@ def __init__( if on_init is not None: on_init(self) + @property + def tmux_bin(self) -> str | None: + """Absolute executable path captured at construction, or ``None`` if absent.""" + return self._tmux_bin + + def _require_tmux_bin(self) -> str: + """Return the captured executable without repeating PATH lookup.""" + if self.tmux_bin is None: + raise exc.TmuxCommandNotFound + return self.tmux_bin + + @functools.cached_property + def _version_str(self) -> str: + """Probe this client's executable once with its captured environment.""" + try: + return _query_version( + tmux_bin=self._require_tmux_bin(), env=self.child_environment + ) + except _TmuxVersionUnavailable: + return _no_version_flag_fallback() + + @functools.cached_property + def _version(self) -> LooseVersion: + """Return this client's normalized version for format and feature checks.""" + version = self._version_str + return ( + LooseVersion(version) + if version.endswith("-openbsd") + else _parse_version(version) + ) + @classmethod def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server: """Return the tmux server this process's pane is attached to. @@ -273,7 +353,7 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Exit the context, killing the server if it exists. + """Exit the borrowed client context, leaving the remote daemon alive. Parameters ---------- @@ -284,8 +364,6 @@ def __exit__( exc_tb : types.TracebackType | None The traceback of the exception that was raised """ - if self.is_alive(): - self.kill() def is_alive(self) -> bool: """Return True if tmux server alive. @@ -317,31 +395,42 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ - resolved = self.tmux_bin or shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound + resolved = self._require_tmux_bin() - cmd_args: list[str] = ["list-sessions"] - if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") + self._prepare_socket_directory() + cmd_args: list[str] = [f"-S{self.socket_path}", "list-sessions"] if self.config_file: cmd_args.insert(0, f"-f{self.config_file}") try: - subprocess.check_call([resolved, *cmd_args]) + subprocess.check_call([resolved, *cmd_args], env=self.child_environment) except FileNotFoundError: raise exc.TmuxCommandNotFound from None # # Command # + def _prepare_socket_directory(self) -> None: + """Create the private UID directory without a fallback socket root.""" + if self.socket_name is None: + return + directory = pathlib.Path(self.socket_path).parent + directory.mkdir(mode=0o700, exist_ok=True) + metadata = directory.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o007 + ): + msg = f"unsafe permissions or owner for tmux socket directory: {directory}" + raise PermissionError(msg) + def cmd( self, cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | None = None, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -379,6 +468,9 @@ def cmd( ---------- target : str, optional Optional custom target. + timeout : float, optional + Maximum seconds for the client command. A timeout terminates and + reaps the client process; a dispatched remote effect may be uncertain. Returns ------- @@ -390,12 +482,9 @@ def cmd( Renamed from ``.tmux`` to ``.cmd``. """ - svr_args: list[str | int] = [cmd] + self._prepare_socket_directory() + svr_args: list[str | int] = [f"-S{self.socket_path}", cmd] cmd_args: list[str | int] = [] - if self.socket_name: - svr_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - svr_args.insert(0, f"-S{self.socket_path}") if self.config_file: svr_args.insert(0, f"-f{self.config_file}") if self.colors: @@ -408,7 +497,13 @@ 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._require_tmux_bin(), + env=self.child_environment, + timeout=timeout, + ) @property def attached_sessions(self) -> list[Session]: @@ -503,6 +598,38 @@ def kill_session(self, target_session: str | int) -> Server: return self + def _kill_session_if_present(self, session_id: str) -> None: + """Destroy one retained session ID, tolerating its prior destruction.""" + self._kill_object_if_present("session", session_id) + + def _kill_object_if_present( + self, + kind: t.Literal["session", "window", "pane"], + object_id: str, + ) -> None: + """Destroy an exact object ID without consulting its cached parent.""" + prefix = {"session": "$", "window": "@", "pane": "%"}[kind] + number = object_id.removeprefix(prefix) + if not object_id.startswith(prefix) or not ( + number.isascii() and number.isdecimal() + ): + message = f"Invalid {kind} ID: {object_id!r}" + raise ValueError(message) + subcommand = f"kill-{kind}" + proc = self.cmd(subcommand, target=object_id) + if proc.returncode: + detail = "\n".join(proc.stderr) + if ( + _is_daemon_not_up_error(detail) + or detail == f"can't find {kind}: {object_id}" + ): + return + raise exc.LibTmuxException( + detail or f"exit status {proc.returncode}", + subcommand=subcommand, + ) + raise_if_stderr(proc, subcommand) + def run_shell( self, command: str, @@ -581,7 +708,7 @@ def run_shell( tmux_args += ("-t", target_pane) if cwd is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-c", str(cwd)) else: warnings.warn( @@ -590,7 +717,7 @@ def run_shell( ) if show_stderr: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if self._supports_version("3.6"): tmux_args += ("-E",) else: warnings.warn( @@ -601,7 +728,7 @@ def run_shell( tmux_args += (command,) if args: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._supports_version("3.7"): tmux_args += tuple(args) else: warnings.warn( @@ -784,7 +911,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._supports_version("3.7"): tmux_args += ("-F", format_) else: warnings.warn( @@ -882,7 +1009,7 @@ def server_access( ... result = server.server_access(list_access=True) ... assert isinstance(result, list) """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + if not self._supports_version("3.3"): msg = "server_access requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -946,7 +1073,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._supports_version("3.7"): tmux_args += ("-l",) else: warnings.warn( @@ -1152,7 +1279,7 @@ def confirm_before( >>> result 'yes' """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + if not self._supports_version("3.3"): msg = "confirm_before requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -1162,7 +1289,7 @@ def confirm_before( tmux_args += ("-p", prompt) if confirm_key is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-c", confirm_key) else: warnings.warn( @@ -1171,7 +1298,7 @@ def confirm_before( ) if default_yes: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-y",) else: warnings.warn( @@ -1268,7 +1395,7 @@ def command_prompt( >>> result 'hi' """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + if not self._supports_version("3.3"): msg = "command_prompt requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -1290,7 +1417,7 @@ def command_prompt( tmux_args += ("-F",) if literal: - if has_gte_version("3.6", tmux_bin=self.tmux_bin): + if self._supports_version("3.6"): tmux_args += ("-l",) else: warnings.warn( @@ -1299,7 +1426,7 @@ def command_prompt( ) if bspace_exit: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._supports_version("3.7"): tmux_args += ("-e",) else: warnings.warn( @@ -1308,7 +1435,7 @@ def command_prompt( ) if no_freeze: - if has_gte_version("3.7", tmux_bin=self.tmux_bin): + if self._supports_version("3.7"): tmux_args += ("-C",) else: warnings.warn( @@ -1424,7 +1551,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._supports_version("3.4"): tmux_args += ("-C", str(starting_choice)) else: warnings.warn( @@ -1433,7 +1560,7 @@ def display_menu( ) if border_lines is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-b", border_lines) else: warnings.warn( @@ -1442,7 +1569,7 @@ def display_menu( ) if style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-s", style) else: warnings.warn( @@ -1451,7 +1578,7 @@ def display_menu( ) if border_style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-S", border_style) else: warnings.warn( @@ -1460,7 +1587,7 @@ def display_menu( ) if selected_style is not None: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-H", selected_style) else: warnings.warn( @@ -1469,7 +1596,7 @@ def display_menu( ) if mouse: - if has_gte_version("3.5", tmux_bin=self.tmux_bin): + if self._supports_version("3.5"): tmux_args += ("-M",) else: warnings.warn( @@ -1695,7 +1822,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-l",) else: warnings.warn( @@ -1758,7 +1885,7 @@ def show_prompt_history( >>> isinstance(result, list) True """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + if not self._supports_version("3.3"): msg = "show_prompt_history requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -1792,7 +1919,7 @@ def clear_prompt_history( >>> if has_gte_version("3.3"): ... server.clear_prompt_history() """ - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + if not self._supports_version("3.3"): msg = "clear_prompt_history requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -2324,64 +2451,38 @@ def new_session( extra["tmux_session"] = str(session_name) logger.debug("creating session", extra=extra) - env = os.environ.get("TMUX") - - if env: - del os.environ["TMUX"] - - try: - tmux_version = str(get_version(tmux_bin=self.tmux_bin)) - _fields, format_string = get_output_format("list-sessions", tmux_version) - - tmux_args: tuple[str | int, ...] = ( - "-P", - f"-F{format_string}", - ) - - if detach_others: - tmux_args += ("-D",) - - if no_size: - tmux_args += ("-X",) - - if client_flags is not None: - tmux_args += ("-f", client_flags) - - if session_name is not None: - tmux_args += (f"-s{session_name}",) - - if not attach: - tmux_args += ("-d",) + tmux_version = str(self._version) + _fields, format_string = get_output_format("list-sessions", tmux_version) - if start_directory: - start_directory = pathlib.Path(start_directory).expanduser() - tmux_args += ("-c", str(start_directory)) - - if window_name: - tmux_args += ("-n", window_name) - - if x is not None: - tmux_args += ("-x", x) - - if y is not None: - tmux_args += ("-y", y) - - if environment: - for k, v in environment.items(): - tmux_args += (f"-e{k}={v}",) - - if window_command: - tmux_args += (window_command,) - - proc = self.cmd("new-session", *tmux_args) - - raise_if_stderr(proc, "new-session") - - session_stdout = proc.stdout[0] + tmux_args: tuple[str | int, ...] = ("-P", f"-F{format_string}") + if detach_others: + tmux_args += ("-D",) + if no_size: + tmux_args += ("-X",) + if client_flags is not None: + tmux_args += ("-f", client_flags) + if session_name is not None: + tmux_args += (f"-s{session_name}",) + if not attach: + tmux_args += ("-d",) + if start_directory: + start_directory = pathlib.Path(start_directory).expanduser() + tmux_args += ("-c", str(start_directory)) + if window_name: + tmux_args += ("-n", window_name) + if x is not None: + tmux_args += ("-x", x) + if y is not None: + tmux_args += ("-y", y) + if environment: + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) + if window_command: + tmux_args += (window_command,) - finally: - if env: - os.environ["TMUX"] = env + proc = self.cmd("new-session", *tmux_args) + raise_if_stderr(proc, "new-session") + session_stdout = proc.stdout[0] session_data = parse_output(session_stdout, "list-sessions", tmux_version) @@ -2683,16 +2784,12 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: """Representation of :class:`Server` object.""" - if self.socket_name is not None: + if self.socket_name is not None and self.socket_name != "default": return ( f"{self.__class__.__name__}" f"(socket_name={getattr(self, 'socket_name', 'default')})" ) - if self.socket_path is not None: - return f"{self.__class__.__name__}(socket_path={self.socket_path})" - return ( - f"{self.__class__.__name__}(socket_path=/tmp/tmux-{os.geteuid()}/default)" - ) + return f"{self.__class__.__name__}(socket_path={self.socket_path})" # # Legacy: Redundant stuff we want to remove diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a37..fea59136a6 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 @@ -31,17 +31,12 @@ ) if t.TYPE_CHECKING: - import sys import types + from typing import Self from libtmux._internal.types import StrPath from libtmux.common import tmux_cmd - if sys.version_info >= (3, 11): - from typing import Self - else: - from typing_extensions import Self - from .server import Server @@ -117,13 +112,12 @@ class Session( server: Server def __enter__(self) -> Self: - """Enter the context, returning self. - - Returns - ------- - :class:`Session` - The session instance - """ + """Accept destruction responsibility and enter the session scope.""" + previous = getattr(self, "_scope_owner", None) + if previous is not None and not previous.closed: + message = "this session already has an active or failed cleanup scope" + raise RuntimeError(message) + self._scope_owner = self.own() return self def __exit__( @@ -132,19 +126,10 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Exit the context, killing the session if it exists. - - Parameters - ---------- - exc_type : type[BaseException] | None - The type of the exception that was raised - exc_value : BaseException | None - The instance of the exception that was raised - exc_tb : types.TracebackType | None - The traceback of the exception that was raised - """ - if self.session_name is not None and self.server.has_session(self.session_name): - self.kill() + """Destroy the accepted session identity and retain paired failures.""" + owner = getattr(self, "_scope_owner", None) + if owner is not None: + owner.__exit__(exc_type, exc_value, exc_tb) def refresh(self) -> None: """Refresh session attributes from tmux. @@ -723,7 +708,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._supports_version("3.7"): flags += ("-g",) else: warnings.warn( diff --git a/src/libtmux/test/environment.py b/src/libtmux/test/environment.py index 33a4683504..e2099ab3d4 100644 --- a/src/libtmux/test/environment.py +++ b/src/libtmux/test/environment.py @@ -6,13 +6,8 @@ import typing as t if t.TYPE_CHECKING: - import sys import types - - if sys.version_info >= (3, 11): - from typing import Self - else: - from typing_extensions import Self + from typing import Self class EnvironmentVarGuard: @@ -29,27 +24,17 @@ class EnvironmentVarGuard: def __init__(self) -> None: self._environ = os.environ - self._unset: set[str] = set() - self._reset: dict[str, str] = {} + self._original: dict[str, str | None] = {} def set(self, envvar: str, value: str) -> None: """Set environment variable.""" - if envvar not in self._environ: - self._unset.add(envvar) - else: - self._reset[envvar] = self._environ[envvar] + self._original.setdefault(envvar, self._environ.get(envvar)) self._environ[envvar] = value def unset(self, envvar: str) -> None: """Unset environment variable.""" - if envvar in self._environ: - # If we previously set this variable in this context, remove it from _unset - if envvar in self._unset: - self._unset.remove(envvar) - # If we haven't saved the original value yet, save it - if envvar not in self._reset: - self._reset[envvar] = self._environ[envvar] - del self._environ[envvar] + self._original.setdefault(envvar, self._environ.get(envvar)) + self._environ.pop(envvar, None) def __enter__(self) -> Self: """Return context for for context manager.""" @@ -62,8 +47,9 @@ def __exit__( exc_tb: types.TracebackType | None, ) -> None: """Cleanup to run after context manager finishes.""" - for envvar, value in self._reset.items(): - self._environ[envvar] = value - for unset in self._unset: - if unset not in self._reset: # Don't delete variables that were reset - del self._environ[unset] + for envvar, value in self._original.items(): + if value is None: + self._environ.pop(envvar, None) + else: + self._environ[envvar] = value + self._original.clear() diff --git a/src/libtmux/test/random.py b/src/libtmux/test/random.py index e83037d211..be2f710eb3 100644 --- a/src/libtmux/test/random.py +++ b/src/libtmux/test/random.py @@ -10,15 +10,10 @@ ) if t.TYPE_CHECKING: # pragma: no cover - import sys # pragma: no cover - from libtmux.server import Server # pragma: no cover from libtmux.session import Session # pragma: no cover - if sys.version_info >= (3, 11): # pragma: no cover - pass # pragma: no cover - else: # pragma: no cover - pass # pragma: no cover + # pragma: no cover class RandomStrSequence: diff --git a/src/libtmux/test/retry.py b/src/libtmux/test/retry.py index bad491171e..a9d31eccc9 100644 --- a/src/libtmux/test/retry.py +++ b/src/libtmux/test/retry.py @@ -12,12 +12,8 @@ ) if t.TYPE_CHECKING: - import sys from collections.abc import Callable - if sys.version_info >= (3, 11): - pass - def retry_until( fun: Callable[[], bool], diff --git a/src/libtmux/test/temporary.py b/src/libtmux/test/temporary.py index 74a0a4bfa9..bd381396ef 100644 --- a/src/libtmux/test/temporary.py +++ b/src/libtmux/test/temporary.py @@ -8,16 +8,12 @@ from libtmux.test.random import get_test_session_name, get_test_window_name if t.TYPE_CHECKING: - import sys from collections.abc import Generator from libtmux.server import Server from libtmux.session import Session from libtmux.window import Window - if sys.version_info >= (3, 11): - pass - @contextlib.contextmanager def temp_session( @@ -60,13 +56,8 @@ def temp_session( else: session_name = get_test_session_name(server) - session = server.new_session(session_name, *args, **kwargs) - - try: + with server.new_session(session_name, *args, **kwargs) as session: yield session - finally: - if server.has_session(session_name): - session.kill() return @@ -117,16 +108,6 @@ def temp_window( else: window_name = kwargs.pop("window_name") - window = session.new_window(window_name, *args, **kwargs) - - # Get ``window_id`` before returning it, it may be killed within context. - window_id = window.window_id - assert window_id is not None - assert isinstance(window_id, str) - - try: + with session.new_window(window_name, *args, **kwargs) as window: yield window - finally: - if len(session.windows.filter(window_id=window_id)) > 0: - window.kill() return diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db99692..f9c8ee035a 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, @@ -32,8 +32,8 @@ from .options import OptionsMixin if t.TYPE_CHECKING: - import sys import types + from typing import Self from libtmux._internal.types import StrPath @@ -41,11 +41,6 @@ from .server import Server from .session import Session - if sys.version_info >= (3, 11): - from typing import Self - else: - from typing_extensions import Self - logger = logging.getLogger(__name__) @@ -131,13 +126,12 @@ class Window( server: Server def __enter__(self) -> Self: - """Enter the context, returning self. - - Returns - ------- - :class:`Window` - The window instance - """ + """Accept destruction responsibility and enter the window scope.""" + previous = getattr(self, "_scope_owner", None) + if previous is not None and not previous.closed: + message = "this window already has an active or failed cleanup scope" + raise RuntimeError(message) + self._scope_owner = self.own() return self def __exit__( @@ -146,22 +140,10 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Exit the context, killing the window if it exists. - - Parameters - ---------- - exc_type : type[BaseException] | None - The type of the exception that was raised - exc_value : BaseException | None - The instance of the exception that was raised - exc_tb : types.TracebackType | None - The traceback of the exception that was raised - """ - if ( - self.window_id is not None - and len(self.session.windows.filter(window_id=self.window_id)) > 0 - ): - self.kill() + """Destroy the accepted window identity and retain paired failures.""" + owner = getattr(self, "_scope_owner", None) + if owner is not None: + owner.__exit__(exc_type, exc_value, exc_tb) def refresh(self) -> None: """Refresh window attributes from tmux. @@ -1322,7 +1304,7 @@ def display_message( tmux_args += ("-v",) if no_expand: - if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + if self._supports_version("3.4"): tmux_args += ("-l",) else: warnings.warn( diff --git a/tests/test/test_environment.py b/tests/test/test_environment.py index 6c7cc83a73..a355ff26ef 100644 --- a/tests/test/test_environment.py +++ b/tests/test/test_environment.py @@ -5,9 +5,47 @@ import os import typing as t +import pytest + from libtmux.test.environment import EnvironmentVarGuard +@pytest.mark.parametrize("original", [None, "", "original"]) +@pytest.mark.parametrize("fail_body", [False, True]) +def test_environment_guard_restores_first_state( + monkeypatch: pytest.MonkeyPatch, + original: str | None, + fail_body: bool, +) -> None: + """Restore absence or the original value after repeated changes and exceptions.""" + key = "LIBTMUX_TEST_GUARD" + if original is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, original) + guard = EnvironmentVarGuard() + body_error = RuntimeError("body failed") + + def raise_body_error() -> None: + raise body_error + + try: + with guard: + guard.set(key, "first") + guard.set(key, "second") + guard.unset(key) + guard.set(key, "third") + guard.unset(key) + if fail_body: + raise_body_error() + except RuntimeError: + assert fail_body + assert os.environ.get(key) == original + assert (key in os.environ) == (original is not None) + guard.__exit__(None, None, None) + assert os.environ.get(key) == original + + def test_environment_var_guard_set() -> None: """Test setting environment variables with EnvironmentVarGuard.""" env = EnvironmentVarGuard() diff --git a/tests/test_cleanup_scopes.py b/tests/test_cleanup_scopes.py new file mode 100644 index 0000000000..be9f0a440e --- /dev/null +++ b/tests/test_cleanup_scopes.py @@ -0,0 +1,120 @@ +"""Remote object cleanup after moves and failures inside resource scopes.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux import Pane, Session, Window, exc + +if t.TYPE_CHECKING: + from libtmux.common import tmux_cmd + + +def test_window_scope_cleans_after_move(session: Session) -> None: + """Destroy the captured window even after another client moves its link.""" + server = session.server + destination = server.new_session(session_name="window_destination") + with session.new_window(window_name="move_me") as window: + window_id = window.window_id + result = server.cmd( + "move-window", "-s", window_id, "-t", f"{destination.session_id}:" + ) + assert result.returncode == 0, result.stderr + assert window_id in [item.window_id for item in destination.windows] + assert window_id not in [item.window_id for item in server.windows] + assert server.has_session("window_destination") + + +def test_pane_scope_cleans_after_move(session: Session) -> None: + """Destroy the captured pane after a raw join changes its parent window.""" + server = session.server + destination = session.new_window(window_name="pane_destination") + with session.active_window.split() as pane: + pane_id = pane.pane_id + result = server.cmd("join-pane", "-s", pane_id, "-t", destination.window_id) + assert result.returncode == 0, result.stderr + assert pane_id in [item.pane_id for item in destination.panes] + assert pane_id not in [item.pane_id for item in server.panes] + assert destination.window_id in [item.window_id for item in session.windows] + + +@pytest.mark.parametrize("kind", ["window", "pane"]) +@pytest.mark.parametrize( + "body_error", [RuntimeError("body failed"), KeyboardInterrupt(), SystemExit(3)] +) +def test_object_scope_preserves_both_failures_and_can_retry( + session: Session, + monkeypatch: pytest.MonkeyPatch, + kind: str, + body_error: BaseException, +) -> None: + """Deny teardown at the command boundary to expose both errors and retry.""" + obj: Window | Pane = ( + session.new_window() if kind == "window" else session.active_window.split() + ) + server = session.server + command = server.cmd + cleanup_error = PermissionError("cleanup denied") + + def denied(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if cmd == "if-shell" and any( + str(arg).startswith(f"kill-{kind} ") for arg in args + ): + raise cleanup_error + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", denied) + with pytest.raises(BaseExceptionGroup) as caught, obj: + raise body_error + assert caught.value.exceptions == (body_error, cleanup_error) + obj.refresh() + monkeypatch.setattr(obj._scope_owner._client, "cmd", command) + obj.__exit__(None, None, None) + obj.__exit__(None, None, None) + if isinstance(obj, Window): + assert obj.window_id not in [item.window_id for item in server.windows] + else: + assert obj.pane_id not in [item.pane_id for item in server.panes] + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +@pytest.mark.parametrize("stderr", [[], ["permission denied"]]) +def test_scope_reports_failed_command_without_disabling_retry( + session: Session, + monkeypatch: pytest.MonkeyPatch, + kind: str, + stderr: list[str], +) -> None: + """A failed exit status is observable even when tmux emits no diagnostic.""" + server = session.server + obj: Session | Window | Pane + if kind == "session": + obj = server.new_session() + elif kind == "window": + obj = session.new_window() + else: + obj = session.active_window.split() + command = server.cmd + + def denied(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if cmd == "if-shell" and any( + str(arg).startswith(f"kill-{kind} ") for arg in args + ): + result = command("display-message", "-p", "failure probe") + result.returncode = 64 + result.stdout = [] + result.stderr = stderr + return result + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", denied) + with pytest.raises(exc.LibTmuxException, match=r"denied|exit status 64"), obj: + pass + obj.refresh() + monkeypatch.setattr(obj._scope_owner._client, "cmd", command) + obj.__exit__(None, None, None) + obj.__exit__(None, None, None) diff --git a/tests/test_client_snapshot.py b/tests/test_client_snapshot.py new file mode 100644 index 0000000000..6e23c999aa --- /dev/null +++ b/tests/test_client_snapshot.py @@ -0,0 +1,117 @@ +"""Executable selection and metadata probes use the captured client environment.""" + +from __future__ import annotations + +import json +import os +import pathlib +import shutil + +import pytest + +from libtmux import Server, exc +from libtmux._internal.control_mode import ControlMode +from libtmux.common import get_version + + +def _recording_executable(path: pathlib.Path, log: pathlib.Path, binary: str) -> None: + path.write_text( + "#!/usr/bin/python3\n" + "import json, os, sys\n" + f"with open({str(log)!r}, 'a') as output:\n" + " output.write(json.dumps({'executable': sys.argv[0], " + "'args': sys.argv[1:], 'marker': os.getenv('LIBTMUX_CLIENT_MARKER')})" + " + '\\n')\n" + f"os.execv({binary!r}, [{binary!r}, *sys.argv[1:]])\n", + ) + path.chmod(0o700) + + +@pytest.mark.parametrize("selector", [None, "custom-tmux"]) +def test_server_freezes_executable_before_first_command( + server: Server, + tmp_path: pathlib.Path, + selector: str | None, +) -> None: + """Adding an earlier PATH candidate must not change an existing handle.""" + binary = shutil.which("tmux") + assert binary is not None + early, selected = tmp_path / "early", tmp_path / "selected" + early.mkdir() + selected.mkdir() + name = selector or "tmux" + log = tmp_path / "launches.jsonl" + _recording_executable(selected / name, log, binary) + captured = Server( + socket_path=server.socket_path, + tmux_bin=selector, + child_environment={"PATH": f"{early}:{selected}"}, + ) + _recording_executable(early / name, log, binary) + result = captured.cmd("-V") + assert result.returncode == 0 + launches = [json.loads(line) for line in log.read_text().splitlines()] + assert {item["executable"] for item in launches} == {str(selected / name)} + + +def test_version_and_control_launches_keep_captured_environment( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Observe real version, query, control and cleanup launches through a shim.""" + binary = shutil.which("tmux") + assert binary is not None + log = tmp_path / "launches.jsonl" + _recording_executable(tmp_path / "tmux", log, binary) + captured = Server( + socket_path=server.socket_path, + config_file="/dev/null", + child_environment={"PATH": str(tmp_path), "LIBTMUX_CLIENT_MARKER": "captured"}, + ) + monkeypatch.setenv("LIBTMUX_CLIENT_MARKER", "changed") + get_version.cache_clear() + with captured.new_session(session_name="captured_environment") as session: + with ControlMode(captured, session): + assert len(session.windows) == 1 + captured.raise_if_dead() + launches = [json.loads(line) for line in log.read_text().splitlines()] + assert any(item["args"] == ["-V"] for item in launches) + assert any("-C" in item["args"] for item in launches) + assert all(item["marker"] == "captured" for item in launches) + assert os.environ["LIBTMUX_CLIENT_MARKER"] == "changed" + + +def test_relative_executable_path_retains_construction_directory( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Changing cwd cannot redirect an explicit relative executable path.""" + binary = shutil.which("tmux") + assert binary is not None + log = tmp_path / "launches.jsonl" + _recording_executable(tmp_path / "tmux", log, binary) + monkeypatch.chdir(tmp_path) + captured = Server(socket_path=server.socket_path, tmux_bin="./tmux") + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + assert captured.cmd("-V").returncode == 0 + assert pathlib.Path(json.loads(log.read_text())["executable"]) == tmp_path / "tmux" + + +def test_missing_captured_executable_does_not_use_later_path( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """Failure to resolve an executable remains a failure after construction.""" + binary = shutil.which("tmux") + assert binary is not None + captured = Server( + socket_path=server.socket_path, child_environment={"PATH": str(tmp_path)} + ) + _recording_executable(tmp_path / "tmux", tmp_path / "unexpected.jsonl", binary) + with pytest.raises(exc.TmuxCommandNotFound): + captured.cmd("-V") + assert not (tmp_path / "unexpected.jsonl").exists() diff --git a/tests/test_endpoint_defaults.py b/tests/test_endpoint_defaults.py new file mode 100644 index 0000000000..716625f18e --- /dev/null +++ b/tests/test_endpoint_defaults.py @@ -0,0 +1,358 @@ +"""Captured endpoint selection and client environment contracts.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import typing as t + +import pytest + +from libtmux import Server, exc +from libtmux._internal.control_mode import ControlMode +from libtmux.pytest_plugin import _reap_test_server + +if t.TYPE_CHECKING: + from libtmux.common import tmux_cmd + + +def endpoint_environment(**overrides: str) -> dict[str, str]: + """Clear selectors in the child copy without touching the host.""" + return { + "LIBTMUX_SOCKET_PATH": "", + "LIBTMUX_SOCKET_NAME": "", + "TMUX": "", + "TMUX_PANE": "", + "TMUX_TMPDIR": "", + **overrides, + } + + +@pytest.mark.parametrize( + ("kwargs", "environment", "expected"), + [ + ({}, {}, f"/tmp/tmux-{os.getuid()}/default"), + ({}, {"TMUX_TMPDIR": "/tmp/root"}, f"/tmp/root/tmux-{os.getuid()}/default"), + ({}, {"TMUX": "/tmp/with, commas /socket,12,$3"}, "/tmp/with, commas /socket"), + ({}, {"TMUX": "/tmp/job,12,-1"}, "/tmp/job"), + ( + {}, + { + "LIBTMUX_SOCKET_PATH": "/tmp/env-path", + "LIBTMUX_SOCKET_NAME": "bad/name", + "TMUX": "bad", + "TMUX_TMPDIR": "bad", + }, + "/tmp/env-path", + ), + ( + {}, + {"LIBTMUX_SOCKET_NAME": "env-name", "TMUX": "bad"}, + f"/tmp/tmux-{os.getuid()}/env-name", + ), + ( + {"socket_path": "/tmp/explicit"}, + {"LIBTMUX_SOCKET_PATH": "bad", "TMUX": "bad", "TMUX_TMPDIR": "bad"}, + "/tmp/explicit", + ), + ( + {"socket_name": "explicit"}, + {"LIBTMUX_SOCKET_PATH": "bad", "TMUX": "bad"}, + f"/tmp/tmux-{os.getuid()}/explicit", + ), + ({}, {"LIBTMUX_SOCKET_PATH": "/tmp/ spaced "}, "/tmp/ spaced "), + ], +) +def test_endpoint_precedence( + kwargs: dict[str, str], + environment: dict[str, str], + expected: str, +) -> None: + """Resolve defaults and shadowed invalid inputs without launching tmux.""" + server = Server( + socket_path=kwargs.get("socket_path"), + socket_name=kwargs.get("socket_name"), + child_environment=endpoint_environment(**environment), + ) + assert server.socket_path == expected + + +@pytest.mark.parametrize( + "kwargs", + [ + {"socket_path": "/tmp/path", "socket_name": "name"}, + {"socket_path": "relative"}, + {"socket_path": ""}, + {"socket_path": "/tmp/bad\0path"}, + *({"socket_name": name} for name in ("", ".", "..", "a/b", "a\\b", "a\0b")), + ], +) +def test_invalid_explicit_selectors(kwargs: dict[str, str]) -> None: + """Reject invalid explicit inputs even when an environment fallback exists.""" + with pytest.raises(ValueError): + Server( + socket_path=kwargs.get("socket_path"), + socket_name=kwargs.get("socket_name"), + child_environment=endpoint_environment(LIBTMUX_SOCKET_PATH="/tmp/fallback"), + ) + + +@pytest.mark.parametrize( + "environment", + [ + {"LIBTMUX_SOCKET_PATH": "relative", "LIBTMUX_SOCKET_NAME": "fallback"}, + {"LIBTMUX_SOCKET_NAME": "../bad", "TMUX": "/tmp/fallback,1,0"}, + {"TMUX_TMPDIR": "relative"}, + {"TMUX_TMPDIR": "/tmp/bad\0root"}, + ], +) +def test_invalid_selected_environment(environment: dict[str, str]) -> None: + """Report the selected invalid value instead of trying a weaker selector.""" + with pytest.raises(ValueError): + Server(child_environment=endpoint_environment(**environment)) + + +@pytest.mark.parametrize( + "value", + [ + "/tmp/sock", + ",1,0", + "/tmp/sock,,0", + "/tmp/sock,0,0", + "/tmp/sock,-1,0", + "/tmp/sock,+1,0", + "/tmp/sock, 1,0", + "/tmp/sock,\uff11\uff12,0", + "/tmp/sock,1,", + "/tmp/sock,1,$", + "/tmp/sock,1,$$2", + "/tmp/sock,1,-2", + "/tmp/sock,1,+2", + "/tmp/sock,1, 2", + "/tmp/sock,1,2x", + "/tmp/sock,1,$-1", + ], +) +def test_malformed_tmux_context(value: str) -> None: + """Validate the selected context's numeric fields and shape.""" + with pytest.raises(exc.NotInsideTmux): + Server(child_environment=endpoint_environment(TMUX=value)) + + +def test_relative_tmux_path() -> None: + """Reject a relative path even inside a shaped TMUX context.""" + with pytest.raises(ValueError): + Server(child_environment=endpoint_environment(TMUX="relative,1,0")) + + +@pytest.mark.parametrize("remove_after_capture", [False, True]) +def test_missing_named_root_never_falls_back( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + remove_after_capture: bool, +) -> None: + """Block launch before tmux can choose an ambient fallback root.""" + root = tmp_path / "missing" + if remove_after_capture: + root.mkdir() + server = Server( + socket_name="libtmux_test_missing_root", + child_environment={"TMUX_TMPDIR": str(root)}, + ) + if remove_after_capture: + root.rmdir() + + def forbidden_launch(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + pytest.fail("missing root reached a subprocess launch") + + monkeypatch.setattr(subprocess, "Popen", forbidden_launch) + with pytest.raises(FileNotFoundError): + server.new_session(session_name="must_not_start") + assert not root.exists() + + +def test_explicit_path_does_not_create_parent(tmp_path: pathlib.Path) -> None: + """Leave caller-managed parents untouched for an explicit socket path.""" + parent = tmp_path / "missing" + server = Server(socket_path=parent / "libtmux_test_explicit") + with pytest.raises(exc.LibTmuxException): + server.new_session(session_name="must_not_start") + assert not parent.exists() + + +@pytest.mark.parametrize("mode", [0o700, 0o770, 0o775]) +def test_named_directory_permissions(tmp_path: pathlib.Path, mode: int) -> None: + """Accept tmux's group permissions while rejecting access for other users.""" + directory = tmp_path / f"tmux-{os.getuid()}" + directory.mkdir(mode=mode) + directory.chmod(mode) + server = Server( + socket_name="libtmux_test_permissions", + child_environment={"TMUX_TMPDIR": str(tmp_path)}, + ) + if mode & 0o007: + with pytest.raises(PermissionError): + server._prepare_socket_directory() + else: + server._prepare_socket_directory() + + +def test_named_directory_rejects_symlink(tmp_path: pathlib.Path) -> None: + """Keep the UID directory itself from redirecting a captured endpoint.""" + target = tmp_path / "target" + target.mkdir() + (tmp_path / f"tmux-{os.getuid()}").symlink_to(target) + server = Server( + socket_name="libtmux_test_symlink", + child_environment={"TMUX_TMPDIR": str(tmp_path)}, + ) + with pytest.raises(PermissionError): + server._prepare_socket_directory() + + +def test_endpoint_and_environment_are_captured(monkeypatch: pytest.MonkeyPatch) -> None: + """Retain the selected path and environment after callers edit either input.""" + overrides = endpoint_environment( + LIBTMUX_SOCKET_NAME="captured", + TMUX_TMPDIR="/tmp/one", + LIBTMUX_TEST_VALUE="before", + ) + server = Server(child_environment=overrides) + overrides["TMUX_TMPDIR"] = "/tmp/two" + monkeypatch.setenv("TMUX_TMPDIR", "/tmp/three") + monkeypatch.setenv("LIBTMUX_TEST_VALUE", "after") + assert server.socket_path == f"/tmp/one/tmux-{os.getuid()}/captured" + assert server.child_environment["TMUX_TMPDIR"] == "/tmp/one" + assert server.child_environment["LIBTMUX_TEST_VALUE"] == "before" + with pytest.raises(TypeError): + t.cast("dict[str, str]", server.child_environment)["TMUX_TMPDIR"] = "/tmp/four" + + +def test_commands_do_not_mutate_host_environment( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Observe host and child environments at real launches, including control mode.""" + monkeypatch.setenv("TMUX", "/tmp/unrelated,42,0") + monkeypatch.setenv("TMUX_PANE", "%42") + before = dict(os.environ) + actual_popen = subprocess.Popen + launches: list[dict[str, str]] = [] + + def observed_popen(*args: t.Any, **kwargs: t.Any) -> subprocess.Popen[str]: + assert dict(os.environ) == before + child = dict(kwargs["env"]) + assert "TMUX" not in child + assert "TMUX_PANE" not in child + launches.append(child) + return actual_popen(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(subprocess, "Popen", observed_popen) + with server.new_session(session_name="client_environment") as session: + server.raise_if_dead() + with ControlMode(server, session): + assert server.is_alive() + assert launches + assert dict(os.environ) == before + + +def test_session_scope_cleans_renamed_id(server: Server) -> None: + """Destroy the session after a raw rename leaves its cached name stale.""" + anchor = server.new_session(session_name="anchor") + with server.new_session(session_name="before") as session: + server.cmd("rename-session", "after", target=session.session_id) + assert [item.session_id for item in server.sessions] == [anchor.session_id] + + +def test_session_scope_tolerates_prior_destruction(server: Server) -> None: + """Permit repeated cleanup while another session keeps the server alive.""" + server.new_session(session_name="anchor") + with server.new_session(session_name="discard") as session: + session.kill() + session.__exit__(None, None, None) + + +@pytest.mark.parametrize( + "body_error", [RuntimeError("body failed"), KeyboardInterrupt(), SystemExit(3)] +) +def test_scope_preserves_body_and_cleanup_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + body_error: BaseException, +) -> None: + """Inject a teardown failure because a healthy private daemon cannot cause it.""" + session = server.new_session(session_name="dual_failure") + cleanup_error = PermissionError("cleanup denied") + + command = server.cmd + + def failed_cleanup(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if cmd == "if-shell" and any( + str(arg).startswith("kill-session ") for arg in args + ): + raise cleanup_error + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", failed_cleanup) + with pytest.raises(BaseExceptionGroup) as caught, session: + raise body_error + assert caught.value.exceptions == (body_error, cleanup_error) + monkeypatch.setattr(session._scope_owner._client, "cmd", command) + session.__exit__(None, None, None) + + +def test_factory_custom_path_finalizer( + request: pytest.FixtureRequest, + tmp_path: pathlib.Path, +) -> None: + """Verify the factory cleans its custom path after the test body exits.""" + path = tmp_path / "libtmux_test_factory" + + def check_finalization() -> None: + assert not path.exists() + assert not Server(socket_path=path).is_alive() + + request.addfinalizer(check_finalization) + factory = request.getfixturevalue("TestServer") + server = factory(socket_path=path, config_file="/dev/null") + server.new_session(session_name="owned") + assert server.is_alive() + + +def test_reaper_retains_exact_endpoint( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep finalization at the original root after a host environment change.""" + server = Server( + socket_name="libtmux_test_capture", + config_file="/dev/null", + child_environment={"TMUX_TMPDIR": str(tmp_path)}, + ) + server.new_session(session_name="owned") + try: + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path / "other")) + _reap_test_server(server) + assert not pathlib.Path(server.socket_path).exists() + assert not server.is_alive() + finally: + server.kill() + + +def test_reaper_exposes_cleanup_failure( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Expose a failed cleanup instead of suppressing or unlinking its socket.""" + server = Server(socket_path=tmp_path / "libtmux_test_denied") + + def fail(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + message = "cleanup denied" + raise PermissionError(message) + + monkeypatch.setattr(subprocess, "Popen", fail) + with pytest.raises(PermissionError, match="cleanup denied"): + _reap_test_server(server) diff --git a/tests/test_example_harness.py b/tests/test_example_harness.py new file mode 100644 index 0000000000..a5a624887e --- /dev/null +++ b/tests/test_example_harness.py @@ -0,0 +1,89 @@ +"""Run the ordinary example unchanged with an external private endpoint.""" + +from __future__ import annotations + +import os +import pathlib +import select +import subprocess +import sys + +import pytest + +from libtmux import Server +from libtmux.pytest_plugin import _reap_test_server + + +@pytest.mark.parametrize("selector", ["path", "name"]) +@pytest.mark.parametrize("fail_body", [False, True]) +def test_unchanged_session_example( + tmp_path: pathlib.Path, + selector: str, + fail_body: bool, +) -> None: + """Redirect the same program and force body failure through a closed pipe.""" + before = dict(os.environ) + child = dict( + before, + TMUX_TMPDIR=str(tmp_path), + LIBTMUX_SOCKET_PATH="", + LIBTMUX_SOCKET_NAME="", + ) + if selector == "path": + child["LIBTMUX_SOCKET_PATH"] = str(tmp_path / "libtmux_test_example.sock") + else: + child["LIBTMUX_SOCKET_NAME"] = "libtmux_test_example" + child["TMUX"] = "/tmp/ignored,42,0" + child["TMUX_PANE"] = "%42" + server = Server(config_file="/dev/null", child_environment=child) + example = pathlib.Path(__file__).parents[1] / "examples" / "session_scope.py" + readme = pathlib.Path(__file__).parents[1] / "README.md" + assert f"```python\n{example.read_text()}```" in readme.read_text() + read_fd, write_fd = os.pipe() + os.close(read_fd) + pid_fd = None + try: + anchor = server.new_session( + session_name="harness_anchor", window_command="sleep 60" + ) + pid = int(server.cmd("display-message", "-p", "#{pid}").stdout[0]) + pid_fd = os.pidfd_open(pid) + command = [sys.executable, str(example)] + result = subprocess.run( + command, + env=child, + text=True, + check=False, + timeout=20, + stdout=write_fd if fail_body else subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if fail_body: + assert result.returncode != 0 + assert "BrokenPipeError" in result.stderr + else: + assert result.returncode == 0, result.stderr + assert result.stdout.strip().startswith("$") + assert result.stdout.strip() != anchor.session_id + assert [session.session_id for session in server.sessions] == [ + anchor.session_id + ] + assert dict(os.environ) == before + finally: + os.close(write_fd) + try: + _reap_test_server(server) + if pid_fd is not None: + poller = select.poll() + poller.register(pid_fd, select.POLLIN) + assert any(events & select.POLLIN for _, events in poller.poll(5000)) + result_after = server.cmd("list-sessions") + assert result_after.returncode != 0 + assert any( + "No such file or directory" in line or "no server running" in line + for line in result_after.stderr + ) + assert not pathlib.Path(server.socket_path).exists() + finally: + if pid_fd is not None: + os.close(pid_fd) diff --git a/tests/test_from_env.py b/tests/test_from_env.py index 1d0c699a61..97d2d8e1ba 100644 --- a/tests/test_from_env.py +++ b/tests/test_from_env.py @@ -28,9 +28,7 @@ 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. + Ask tmux for the path the way a real pane's ``$TMUX`` carries it. """ socket_path = server.cmd( "display-message", @@ -69,6 +67,11 @@ class SocketPathFixture(t.NamedTuple): tmux="/tmp/od,d/socket,84215,3", expected_socket_path="/tmp/od,d/socket", ), + SocketPathFixture( + test_id="prefixed_session", + tmux="/tmp/tmux-1000/default,84215,$1", + expected_socket_path="/tmp/tmux-1000/default", + ), SocketPathFixture( test_id="no_session", tmux="/tmp/tmux-1000/default,84215,-1", diff --git a/tests/test_options.py b/tests/test_options.py index bc72061806..a9b39070df 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -23,7 +23,7 @@ from libtmux.pane import Pane if t.TYPE_CHECKING: - from typing_extensions import LiteralString + from typing import LiteralString from libtmux.server import Server from libtmux.session import Session diff --git a/tests/test_ownership.py b/tests/test_ownership.py new file mode 100644 index 0000000000..9476a2913b --- /dev/null +++ b/tests/test_ownership.py @@ -0,0 +1,272 @@ +"""Explicit ownership retains remote identity through teardown and failure.""" + +from __future__ import annotations + +import dataclasses +import os +import pathlib +import subprocess +import threading +import time +import typing as t + +import pytest + +from libtmux import Pane, Server, Session, Window, exc + +if t.TYPE_CHECKING: + from libtmux.common import tmux_cmd + + +def _resource(server: Server, kind: str) -> Server | Session | Window | Pane: + session = server.new_session(session_name="owned") + if kind == "server": + return server + if kind == "session": + return session + window = session.new_window(window_name="owned_window") + if kind == "window": + return window + return window.split(shell="/bin/sh") + + +def test_server_context_leaves_remote_daemon_alive(server: Server) -> None: + """Client scope exit does not accept responsibility for remote destruction.""" + server.new_session(session_name="keeper") + with server: + assert server.is_alive() + assert server.is_alive() + assert server.has_session("keeper") + + +@pytest.mark.parametrize("kind", ["server", "session", "window", "pane"]) +def test_explicit_owner_destroys_only_accepted_resource( + server: Server, kind: str +) -> None: + """Adoption accepts an existing object, while plain lookup leaves it alive.""" + server.new_session(session_name="keeper") + resource = _resource(server, kind) + owner = resource.own() + identity = owner.identity + assert not owner.closed + assert owner.value is resource + assert server.is_alive() + with owner as accepted: + assert accepted is resource + assert owner.closed + owner.close() + if kind == "server": + assert not server.is_alive() + else: + result = server.cmd( + f"list-{kind}s", + *(("-a",) if kind != "session" else ()), + "-F", + f"#{{{kind}_id}}", + ) + assert identity.object_id not in result.stdout + assert server.has_session("keeper") + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_owner_ignores_mutated_handle_fields(server: Server, kind: str) -> None: + """The object and its parent are mutable; the accepted cleanup identity is not.""" + resource = _resource(server, kind) + assert not isinstance(resource, Server) + alternate_session = server.new_session(session_name="alternate") + alternate = ( + alternate_session + if kind == "session" + else alternate_session.active_window + if kind == "window" + else alternate_session.active_pane + ) + assert alternate is not None + owner = resource.own() + original_id = owner.identity.object_id + alternate_id = getattr(alternate, f"{kind}_id") + setattr(resource, f"{kind}_id", alternate_id) + resource.server = Server(socket_path="/nonexistent/libtmux-owner-socket") + owner.close() + result = server.cmd( + f"list-{kind}s", + *(("-a",) if kind != "session" else ()), + "-F", + f"#{{{kind}_id}}", + ) + assert original_id not in result.stdout + assert alternate_id in result.stdout + + +@pytest.mark.parametrize("kind", ["server", "session", "window", "pane"]) +def test_stale_owner_refuses_replacement_daemon(server: Server, kind: str) -> None: + """A replacement can reuse IDs, but the prior owner must not destroy it.""" + resource = _resource(server, kind) + owner = resource.own() + server.kill() + replacement = _resource(server, kind) + replacement_id = None if kind == "server" else getattr(replacement, f"{kind}_id") + with pytest.raises(exc.StaleTmuxOwner): + owner.close() + assert not owner.closed + assert server.is_alive() + if kind != "server": + result = server.cmd( + f"list-{kind}s", + *(("-a",) if kind != "session" else ()), + "-F", + f"#{{{kind}_id}}", + ) + assert replacement_id in result.stdout + + +@pytest.mark.parametrize("body_error", [RuntimeError("body"), KeyboardInterrupt()]) +def test_owner_preserves_both_failures_then_retries( + server: Server, + monkeypatch: pytest.MonkeyPatch, + body_error: BaseException, +) -> None: + """Failed teardown retains its error and identity until a successful retry.""" + resource = _resource(server, "session") + owner = resource.own() + cleanup_error = PermissionError("cleanup denied") + + def denied(*args: t.Any, **kwargs: t.Any) -> tmux_cmd: + raise cleanup_error + + with monkeypatch.context() as patch: + patch.setattr(owner._client, "cmd", denied) + with pytest.raises(BaseExceptionGroup) as caught, owner: + raise body_error + assert caught.value.exceptions == (body_error, cleanup_error) + assert owner.cleanup_error is cleanup_error + assert not owner.closed + assert server.has_session("owned") + owner.close() + assert owner.closed + assert owner.cleanup_error is None + + +def test_unlinked_live_daemon_is_not_reported_as_cleaned( + server: Server, +) -> None: + """A missing socket does not prove that the accepted process has exited.""" + server.new_session(session_name="keeper") + owner = server.own() + path = pathlib.Path(server.socket_path) + alias = path.with_name(path.name + "-rescue") + alias.hardlink_to(path) + try: + path.unlink() + with pytest.raises(exc.LibTmuxException, match="unreachable"): + owner.close() + assert not owner.closed + finally: + if not path.exists(): + path.hardlink_to(alias) + alias.unlink() + owner.close() + + +@pytest.mark.parametrize("kind", ["server", "session", "window", "pane"]) +def test_generation_token_rejects_numeric_identity_collision( + server: Server, kind: str +) -> None: + """Model PID/start-second reuse while a real replacement owns the same IDs.""" + owner = _resource(server, kind).own() + server.kill() + replacement_owner = _resource(server, kind).own() + owner._identity = dataclasses.replace( + owner.identity, + server_pid=replacement_owner.identity.server_pid, + start_time=replacement_owner.identity.start_time, + ) + assert ( + owner.identity.generation_token != replacement_owner.identity.generation_token + ) + try: + with pytest.raises(exc.StaleTmuxOwner): + owner.close() + assert server.is_alive() + assert not owner.closed + finally: + replacement_owner.close() + + +@pytest.mark.parametrize("token", ["", "malformed", "f" * 31, "g" * 32]) +def test_adoption_preserves_malformed_reserved_metadata( + server: Server, token: str +) -> None: + """A reserved-key collision fails acceptance without replacing the value.""" + session = server.new_session(session_name="keeper") + result = server.cmd("set-option", "-s", "@libtmux_owner_generation", token) + assert result.returncode == 0 + with pytest.raises(exc.LibTmuxException, match="generation token"): + session.own() + result = server.cmd("show-options", "-sv", "@libtmux_owner_generation") + assert "\n".join(result.stdout) == token + assert server.has_session("keeper") + + +def test_cleanup_deadline_includes_waiting_for_another_close(server: Server) -> None: + """A competing close cannot make the public timeout wait without a bound.""" + session = server.new_session(session_name="keeper") + owner = session.own(timeout=0.1) + locked = threading.Event() + release = threading.Event() + + def competing_close() -> None: + with owner._lock: + locked.set() + release.wait(2) + + worker = threading.Thread(target=competing_close) + worker.start() + assert locked.wait(1) + started = time.monotonic() + try: + with pytest.raises(TimeoutError, match="another cleanup") as caught: + owner.close() + assert time.monotonic() - started < 0.8 + assert owner.cleanup_error is caught.value + assert not owner.closed + assert server.has_session("keeper") + finally: + release.set() + worker.join(3) + owner.close() + assert owner.closed + + +def test_hung_cleanup_client_is_reaped_and_owner_can_retry( + server: Server, tmp_path: pathlib.Path +) -> None: + """Timeout kills the client without claiming that remote destruction succeeded.""" + blocker = tmp_path / "block-cleanup" + blocker.write_text("block") + pid_file = tmp_path / "client-pid" + wrapper = tmp_path / "tmux-wrapper" + binary = server._require_tmux_bin() + wrapper.write_text( + "#!/usr/bin/python3\n" + "import os, pathlib, sys, time\n" + f"if 'if-shell' in sys.argv and pathlib.Path({str(blocker)!r}).exists():\n" + f" pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid()))\n" + " time.sleep(30)\n" + f"os.execv({binary!r}, [{binary!r}, *sys.argv[1:]])\n" + ) + wrapper.chmod(0o700) + client = Server(socket_path=server.socket_path, tmux_bin=wrapper) + session = client.new_session(session_name="timeout-owned") + owner = session.own(timeout=1.0) + with pytest.raises(subprocess.TimeoutExpired): + owner.close() + assert not owner.closed + assert isinstance(owner.cleanup_error, subprocess.TimeoutExpired) + with pytest.raises(ChildProcessError): + os.waitpid(int(pid_file.read_text()), os.WNOHANG) + assert server.has_session("timeout-owned") + blocker.unlink() + owner.close() + assert owner.closed + assert not server.has_session("timeout-owned") diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5111a06706..b501ee6f2a 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -450,14 +450,8 @@ def test_capture_pane_trim_trailing_warning( """Test that trim_trailing issues a warning on tmux < 3.4.""" import warnings - from libtmux import pane as pane_module - - # 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") + monkeypatch.setattr(pane, "_supports_version", lambda minimum: minimum != "3.4") def prompt_ready() -> bool: return "$" in "\n".join(pane.capture_pane()) diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..ae2a4280ab 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -44,24 +44,22 @@ def test_socket_name(server: Server) -> None: assert myserver.socket_name == "test" -def test_socket_path(server: Server) -> None: +def test_socket_path(server: Server, tmp_path: pathlib.Path) -> None: """``-S`` socket_path (alternative path for server socket).""" - myserver = Server(socket_path="test") + myserver = Server(socket_path=tmp_path / "test") - assert myserver.socket_path == "test" + assert myserver.socket_path == str(tmp_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}``). - """ +def test_socket_path_captured_from_socket_name() -> None: + """A named socket captures its root and absolute endpoint.""" myserver = Server(socket_name="libtmux_test_named_socket") assert myserver.socket_name == "libtmux_test_named_socket" - assert myserver.socket_path is None + root = os.environ.get("TMUX_TMPDIR") or "/tmp" + assert myserver.socket_path == str( + pathlib.Path(root) / f"tmux-{os.getuid()}" / myserver.socket_name, + ) def test_config(server: Server) -> None: @@ -354,14 +352,15 @@ def socket_name_factory() -> str: def test_server_context_manager(TestServer: type[Server]) -> None: - """Test Server context manager functionality.""" + """Server client scope leaves the daemon alive; explicit ownership destroys it.""" with TestServer() as server: session = server.new_session() assert server.is_alive() assert len(server.sessions) == 1 assert session in server.sessions - # Server should be killed after exiting context + assert server.is_alive() + server.own().close() assert not server.is_alive() @@ -474,8 +473,8 @@ def test_new_session_start_directory_pathlib( def test_tmux_bin_default(server: Server) -> None: - """Default tmux_bin is None, falls back to shutil.which.""" - assert server.tmux_bin is None + """Default tmux_bin records the executable found at construction.""" + assert server.tmux_bin == shutil.which("tmux") def test_tmux_bin_custom_path(caplog: pytest.LogCaptureFixture) -> None: @@ -1178,13 +1177,9 @@ def test_run_shell_cwd_warns_on_old_tmux( ) -> None: """``cwd=`` emits a warning and skips ``-c`` on tmux <3.4. - Simulates older tmux by patching ``has_gte_version`` in the - :mod:`libtmux.server` module namespace (where it's bound at - import time). + Simulates an older executable on this server handle. """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + monkeypatch.setattr(server, "_supports_version", lambda minimum: False) server.new_session(session_name="run_shell_cwd_warn_test") with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): server.run_shell("true", cwd=tmp_path) @@ -1196,12 +1191,9 @@ def test_run_shell_show_stderr_warns_on_old_tmux( ) -> None: """``show_stderr=True`` emits a warning and skips ``-E`` on tmux <3.6. - Simulates older tmux by patching ``has_gte_version`` in the - :mod:`libtmux.server` module namespace. + Simulates an older executable on this server handle. """ - import libtmux.server - - monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + monkeypatch.setattr(server, "_supports_version", lambda minimum: False) server.new_session(session_name="run_shell_stderr_warn_test") with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): server.run_shell("true", show_stderr=True) diff --git a/uv.lock b/uv.lock index cb6a14ce4d..85ec9448b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.10, <4.0" +requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", - "python_full_version < '3.11'", + "python_full_version < '3.15'", ] [options] @@ -56,7 +55,6 @@ name = "anyio" version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] @@ -166,22 +164,6 @@ version = "3.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3", size = 373348, upload-time = "2026-09-30T04:34:42.843Z" }, - { url = "https://files.pythonhosted.org/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e", size = 260860, upload-time = "2026-09-30T04:34:44.817Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5", size = 246491, upload-time = "2026-09-30T04:34:46.227Z" }, - { url = "https://files.pythonhosted.org/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa", size = 290875, upload-time = "2026-09-30T04:34:47.691Z" }, - { url = "https://files.pythonhosted.org/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320", size = 286987, upload-time = "2026-09-30T04:34:49.356Z" }, - { url = "https://files.pythonhosted.org/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249", size = 271105, upload-time = "2026-09-30T04:34:50.957Z" }, - { url = "https://files.pythonhosted.org/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e", size = 269833, upload-time = "2026-09-30T04:34:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9", size = 263048, upload-time = "2026-09-30T04:34:54.013Z" }, - { url = "https://files.pythonhosted.org/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5", size = 249237, upload-time = "2026-09-30T04:34:55.373Z" }, - { url = "https://files.pythonhosted.org/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c", size = 290756, upload-time = "2026-09-30T04:34:56.867Z" }, - { url = "https://files.pythonhosted.org/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c", size = 269026, upload-time = "2026-09-30T04:34:58.278Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be", size = 287601, upload-time = "2026-09-30T04:34:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45", size = 272660, upload-time = "2026-09-30T04:35:01.26Z" }, - { url = "https://files.pythonhosted.org/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4", size = 187301, upload-time = "2026-09-30T04:35:02.659Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275", size = 215446, upload-time = "2026-09-30T04:35:04.011Z" }, - { url = "https://files.pythonhosted.org/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c", size = 203389, upload-time = "2026-09-30T04:35:05.577Z" }, { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, @@ -360,20 +342,6 @@ version = "7.16.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, - { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, - { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, - { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, - { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, - { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, @@ -496,18 +464,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "execnet" version = "2.1.2" @@ -525,8 +481,7 @@ dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/f9/b9b10457b84125197d66770b85df90d61c49ecac1d575f40d5ce2969e2ed/gp_furo_theme-0.1.0a39.tar.gz", hash = "sha256:acf935869b12658c9224e4a73b972c9e979e71d91a736198e41a28d0811ae895", size = 34334, upload-time = "2026-09-06T17:25:58.44Z" } @@ -540,8 +495,7 @@ version = "0.0.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "myst-parser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/e3/947c93526c57541d7876b3c0177372220a904b4d8fa4024c54a47506a1c6/gp_libs-0.0.19.tar.gz", hash = "sha256:26fa76740dcfcc37b071903572b3fb3bfc71d2b545e07e7e63977838a3b2393e", size = 16022, upload-time = "2026-07-14T01:21:11.509Z" } wheels = [ @@ -556,14 +510,11 @@ dependencies = [ { name = "docutils" }, { name = "gp-libs" }, { name = "linkify-it-py" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "myst-parser" }, + { name = "sphinx" }, { name = "sphinx-autodoc-typehints-gp" }, { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-design" }, { name = "sphinx-fonts" }, { name = "sphinx-gp-llms" }, { name = "sphinx-gp-opengraph" }, @@ -631,20 +582,6 @@ version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, - { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, - { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, - { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, - { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, @@ -779,24 +716,20 @@ dev = [ { name = "pytest-watcher" }, { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, { name = "types-docutils" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ { name = "gp-sphinx" }, - { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, ] lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ { name = "gp-libs" }, @@ -804,7 +737,6 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [package.metadata] @@ -832,7 +764,6 @@ dev = [ { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a39" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.1.0a39" }, { name = "types-docutils" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ { name = "gp-sphinx", specifier = "==0.1.0a39" }, @@ -843,7 +774,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff", specifier = ">=0.16.1" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ { name = "gp-libs", specifier = ">=0.0.19" }, @@ -851,7 +781,6 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [[package]] @@ -863,29 +792,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] dependencies = [ { name = "mdurl" }, ] @@ -900,17 +810,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -984,8 +883,7 @@ name = "mdit-py-plugins" version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } wheels = [ @@ -1010,17 +908,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, - { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, - { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, - { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, @@ -1077,41 +968,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "myst-parser" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mdit-py-plugins" }, - { name = "pyyaml" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, -] - [[package]] name = "myst-parser" version = "5.1.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] dependencies = [ { name = "docutils" }, { name = "jinja2" }, - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "markdown-it-py" }, { name = "mdit-py-plugins" }, { name = "pyyaml" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } wheels = [ @@ -1160,12 +1027,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1216,7 +1081,6 @@ name = "pytest-watcher" version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "watchdog" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } @@ -1243,15 +1107,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1380,45 +1235,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, ] -[[package]] -name = "sphinx" -version = "8.1.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, -] - [[package]] name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] dependencies = [ { name = "alabaster" }, { name = "babel" }, @@ -1443,41 +1263,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, ] -[[package]] -name = "sphinx-autobuild" -version = "2024.10.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "colorama" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, - { name = "starlette" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, -] - [[package]] name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] dependencies = [ { name = "colorama" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "sphinx" }, { name = "starlette" }, { name = "uvicorn" }, { name = "watchfiles" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -1489,8 +1285,7 @@ name = "sphinx-autodoc-api-style" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, ] @@ -1505,8 +1300,7 @@ version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, { name = "sphinx-autodoc-typehints-gp" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, @@ -1521,8 +1315,7 @@ name = "sphinx-autodoc-typehints-gp" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/0e/1a9961998320c8669f152926a6c3960cb9035418f3c6d878b9e31f9f868b/sphinx_autodoc_typehints_gp-0.1.0a39.tar.gz", hash = "sha256:4eaf00be572a2ea55e1c907ce1233c9cdca90e00210171d54d6538722e3af21e", size = 52542, upload-time = "2026-09-06T17:26:05.541Z" } wheels = [ @@ -1534,8 +1327,7 @@ name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ @@ -1547,39 +1339,19 @@ name = "sphinx-copybutton" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, ] -[[package]] -name = "sphinx-design" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, -] - [[package]] name = "sphinx-design" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] dependencies = [ - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } wheels = [ @@ -1591,8 +1363,7 @@ name = "sphinx-fonts" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/0c/a11bf54fef30a72f2d823cd62c3db474e345d2373bbe0cd76cc1aca9aede/sphinx_fonts-0.1.0a39.tar.gz", hash = "sha256:be2ceda9adb92023ab85ca232a43275d17bed8d844a856e80233eaa8b69b90b1", size = 5874, upload-time = "2026-09-06T17:26:06.443Z" } wheels = [ @@ -1604,8 +1375,7 @@ name = "sphinx-gp-llms" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/62/79/9c14cf5bd35818aefa1b867240528f6df371531ba91aa1a4508f58a938bd/sphinx_gp_llms-0.1.0a39.tar.gz", hash = "sha256:d7cbd5dbc02e4438a896baab3c6bc7782ab5cf049fab13991882cf8cf6ea2a14", size = 10244, upload-time = "2026-09-06T17:26:08.05Z" } wheels = [ @@ -1617,8 +1387,7 @@ name = "sphinx-gp-opengraph" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/db/3369e9b30c5ccb0387fbb555c84a0613ccb8cc6228962520c7f9e64b9635/sphinx_gp_opengraph-0.1.0a39.tar.gz", hash = "sha256:a77a1152dd708622a4c3b880c5ec2d3fde22c3bf1b4c673c9fd69ac49cf5ba56", size = 11952, upload-time = "2026-09-06T17:26:09.836Z" } wheels = [ @@ -1630,8 +1399,7 @@ name = "sphinx-gp-sitemap" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/37/96ac86dae10acdd0d8bbe604eec7a0d162b8999d39cfe6733162b7aa928a/sphinx_gp_sitemap-0.1.0a39.tar.gz", hash = "sha256:8e35f0e50b44d6e4c03138e7c0388104c25b10ac51fc5cac48752138e1d0123c", size = 9955, upload-time = "2026-09-06T17:26:10.665Z" } wheels = [ @@ -1644,8 +1412,7 @@ version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gp-furo-theme" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/c6/285a25231934b931981c4bffe35e3c2d8085500c16fb65c3410394ea0aee/sphinx_gp_theme-0.1.0a39.tar.gz", hash = "sha256:37c655eb9f186e2c67aa5ee7511804500a6eb5d077ced45f087e4a0167dc390e", size = 18481, upload-time = "2026-09-06T17:26:11.432Z" } wheels = [ @@ -1657,8 +1424,7 @@ name = "sphinx-inline-tabs" version = "2025.12.21.14" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/6a/f39bde46a79b80a9983233d99b773bd24b468bdd9c1e87acb46ff69af441/sphinx_inline_tabs-2025.12.21.14.tar.gz", hash = "sha256:c71a75800326e613fb4e410eed92a0934214741326aca9897c18018b9f968cb6", size = 45572, upload-time = "2025-12-21T13:30:51.071Z" } wheels = [ @@ -1670,8 +1436,7 @@ name = "sphinx-ux-autodoc-layout" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/25/4fbe0e13d68b1578c98d2d78d7ebc3f8853e13d3b455c31c886f04f1f6b0/sphinx_ux_autodoc_layout-0.1.0a39.tar.gz", hash = "sha256:f1856fe0739c1576f3665e20b30642ccfcee3c6e76bce60572fddc843d88aa2a", size = 30863, upload-time = "2026-09-06T17:26:12.42Z" } wheels = [ @@ -1683,8 +1448,7 @@ name = "sphinx-ux-badges" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/50/bccae9abbb279dcf11632eb36f18e0599e468ca46559062542d817e28325/sphinx_ux_badges-0.1.0a39.tar.gz", hash = "sha256:ed4337e11483a82acbf2e5fd7749a9867afca459901f41a890263eca18fe95e1", size = 16890, upload-time = "2026-09-06T17:26:13.316Z" } wheels = [ @@ -1750,8 +1514,7 @@ name = "sphinxext-rediraffe" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/a9/ab13d156049eea633f992424f3e92cb40e3f1b606bb6d01d40a27457d38a/sphinxext_rediraffe-0.3.0.tar.gz", hash = "sha256:f319b3ccb7c3c3b6f63ffa6fd3eeb171b6d272df55075a9e84364394f391f507", size = 22114, upload-time = "2025-09-28T15:31:53.641Z" } wheels = [ @@ -1859,7 +1622,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } wheels = [ @@ -1872,9 +1634,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, @@ -1884,8 +1643,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -1941,133 +1698,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" }, ] -[[package]] -name = "websockets" -version = "16.1.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, - { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, - { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, - { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, - { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, - { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, - { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, - { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, - { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, - { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, - { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, - { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, - { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, - { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, - { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, - { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, - { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, - { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, - { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, - { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, - { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, - { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, - { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, - { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, - { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, - { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, - { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, - { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, - { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, - { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, - { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, - { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, - { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, - { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, - { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, - { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, -] - [[package]] name = "websockets" version = "17.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" }, From 445b1ea06793e438d3d3e1f8dd1f6280529f7b09 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 17:54:22 -0500 Subject: [PATCH 3/7] Lifecycle(fix): Recover failed resource creation why: Creation could return a tmux ID and then fail during snapshot decoding or context entry, leaving the resource behind. A later daemon at the same endpoint could also be accepted as the original creation. what: - Retain daemon and object receipts on the creating connection - Roll back failed materialization against the recorded generation - Preserve receipt bytes across client timeout and interruption - Expose failed rollback owners without replacing the original error - Refuse replacement daemons before returning or entering the scope --- CHANGES | 4 + docs/api/libtmux.lifecycle.md | 2 +- docs/topics/context_managers.md | 16 +- src/libtmux/__init__.py | 4 +- src/libtmux/common.py | 84 +++++-- src/libtmux/lifecycle.py | 240 +++++++++++++++++- src/libtmux/pane.py | 64 +++-- src/libtmux/server.py | 37 +-- src/libtmux/session.py | 61 +++-- src/libtmux/window.py | 2 +- tests/test_cleanup_scopes.py | 6 +- tests/test_creation_recovery.py | 424 ++++++++++++++++++++++++++++++++ tests/test_ownership.py | 6 +- 13 files changed, 841 insertions(+), 109 deletions(-) create mode 100644 tests/test_creation_recovery.py diff --git a/CHANGES b/CHANGES index 7f222f5f58..d8d5844fc9 100644 --- a/CHANGES +++ b/CHANGES @@ -82,6 +82,10 @@ server = libtmux.Server(socket_path=pathlib.Path("relative.sock").absolute()) ### What's new +#### Recovery after failed creation + +Session, window and pane creation retain the creating daemon's token and object ID before decoding snapshots. Failed readback or context entry rolls back that resource against the original daemon. Nonzero client results, timeouts and interruptions retain readable creation receipts. `CreationCleanupError` exposes a failed rollback's owner for inspection and retry; `UnknownCreation` reports missing or unreadable receipts without claiming that no resource was created. Creation now reserves `@libtmux_owner_generation`, and an existing malformed value fails before the create command. + #### Explicit remote ownership Call `.own()` on a server, session, window or pane to accept destruction responsibility. Owners retain the endpoint, daemon token and object ID, refuse replacement daemons, preserve paired body/cleanup failures and permit retry after failed teardown. Adoption reserves the server option `@libtmux_owner_generation`; invalid existing values fail acceptance. Session, window and pane contexts use the same ownership behavior. See {doc}`topics/context_managers` for complete examples. diff --git a/docs/api/libtmux.lifecycle.md b/docs/api/libtmux.lifecycle.md index fd17b1ab61..2c76ccd1ca 100644 --- a/docs/api/libtmux.lifecycle.md +++ b/docs/api/libtmux.lifecycle.md @@ -2,5 +2,5 @@ ```{eval-rst} .. automodule:: libtmux.lifecycle - :members: Owned, OwnedIdentity + :members: Owned, OwnedIdentity, CreationCleanupError, UnknownCreation ``` diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index b0c09d1407..48dcf5d0c9 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -16,7 +16,7 @@ This program uses your configured tmux endpoint, creates a session and removes t True ``` -Session, window and pane contexts accept ownership when entered. They retain the endpoint, daemon generation and object ID accepted at that point. Renaming a session or moving a window or pane does not redirect cleanup. A second active context on the same handle raises instead of replacing its unfinished cleanup scope. +Session, window and pane contexts accept ownership when entered. Objects returned by creation retain the creating daemon's identity; context entry verifies that identity instead of accepting a replacement daemon. Handles returned by lookup accept their identity at entry. Renaming a session or moving a window or pane does not redirect cleanup. A second active context on the same handle raises instead of replacing its unfinished cleanup scope. ## Taking ownership of an existing object @@ -79,7 +79,7 @@ On Linux, the owner retains a process descriptor to observe daemon exit. Removin ## Daemon identity -Ownership acceptance initializes the reserved server option `@libtmux_owner_generation` if it is absent, then reads that token, PID, start time and object ID through the same tmux connection. The token contains 32 ASCII hexadecimal characters. An existing empty or malformed value fails acceptance without replacing the value. Keep this reserved option unchanged for the daemon's lifetime and do not shadow it on sessions or windows. +Creation and ownership acceptance initialize the reserved server option `@libtmux_owner_generation` if it is absent, then read that token, PID, start time and object ID through the same tmux connection. The token contains 32 ASCII hexadecimal characters. An existing empty or malformed value fails before creation or acceptance without replacing the value. Keep this reserved option unchanged for the daemon's lifetime and do not shadow it on sessions or windows. The destructive command checks the token, PID and start time inside the receiving daemon's command dispatch. The random token distinguishes daemons even if the operating system reuses a PID within tmux's whole-second start-time precision. Changing the token after acceptance makes an existing owner stale; it does not authorize cleanup of another daemon. @@ -89,4 +89,14 @@ If the body and teardown both fail, `BaseExceptionGroup` retains the body error `resource.own(timeout=5.0)` bounds acceptance and each cleanup attempt, including time spent waiting for another cleanup call. A command timeout kills and reaps the tmux client process; it cannot undo a remote operation that was already dispatched. The owner remains open for inspection and retry. Garbage collection releases local observation descriptors and does not destroy remote resources. These APIs are synchronous; they do not provide an asynchronous task-cancellation supervisor. -The repository's `tests/test_ownership.py` executes adoption for all four resource types, replacement refusal, edited-handle cleanup, a missing socket with a live daemon, paired failures and retry after a hung client. `tests/test_example_harness.py` executes the ordinary example unchanged under both socket environment defaults. Creation recovery, discovery and find-or-create are separate implementation work; this ownership API alone does not establish those guarantees. +## Creation failures + +`Server.new_session()`, `Session.new_window()` and `Pane.split()` retain a creation receipt before decoding the returned object. The receipt contains the endpoint, daemon generation and new object ID. A failed snapshot, parser, context entry or final generation check triggers rollback of that known resource. The same rule applies when tmux returns an ID alongside a nonzero client status. A timeout or Ctrl-C kills and reaps the client, retains readable receipt bytes and attempts that rollback before re-raising the original failure. Rollback uses the original daemon identity and cannot destroy a replacement daemon. + +Each creation command, identity query and rollback has a five-second client deadline. This is a per-step limit, not a five-second limit on the whole Python call. Snapshot queries retain their existing timeout behavior. These synchronous calls do not supervise a killed Python process; the external harness must own that recovery boundary. + +If rollback also fails, the resulting `BaseExceptionGroup` retains the original operation error and a `CreationCleanupError`. That error's `__cause__` is the cleanup failure. Its `owner.identity` identifies the resource, `owner.cleanup_error` retains the failure, and `owner.close()` retries after the cause is repaired. An interruption during rollback remains a `BaseException` in the group alongside the recovery owner. + +`UnknownCreation` means the client returned no trustworthy receipt. This includes a successful empty response or a timeout without a readable ID. Inspect the endpoint before retrying; a new resource may exist. Ctrl-C without a receipt remains `KeyboardInterrupt` with a note explaining that uncertainty. The `select_existing=True` window option can also return no creation output when tmux selects an existing window; it does not supply a created/reused result. A valid new-window receipt still requires rollback if later work fails. + +The repository's `tests/test_creation_recovery.py` executes failed materialization, daemon replacement before return or context entry, nonzero results with valid IDs, paired rollback failures, retry, timeout, interruption and missing-receipt behavior. `tests/test_ownership.py` executes adoption for all four resource types, replacement refusal, edited-handle cleanup and a missing socket with a live daemon. `tests/test_example_harness.py` executes the ordinary example unchanged under both socket environment defaults. Discovery and explicit created/reused find-or-create APIs remain separate implementation work. diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index 16fbc7217d..bfae099353 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -15,7 +15,7 @@ __version__, ) from .client import Client -from .lifecycle import Owned, OwnedIdentity +from .lifecycle import CreationCleanupError, Owned, OwnedIdentity, UnknownCreation from .pane import Pane from .server import Server from .session import Session @@ -25,11 +25,13 @@ __all__ = ( "Client", + "CreationCleanupError", "Owned", "OwnedIdentity", "Pane", "Server", "Session", + "UnknownCreation", "Window", "__author__", "__copyright__", diff --git a/src/libtmux/common.py b/src/libtmux/common.py index c85cf7ecad..2582ab3ab3 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import concurrent.futures import functools import logging import os @@ -24,7 +25,7 @@ from collections.abc import Callable from typing import Self - from libtmux.lifecycle import Owned + from libtmux.lifecycle import Owned, _CreationReceipt from libtmux.server import Server logger = logging.getLogger(__name__) @@ -54,6 +55,7 @@ class CmdMixin: """Command mixin for tmux command.""" cmd: CmdProtocol + _creation_receipt: _CreationReceipt | None def own(self, *, timeout: float = 5.0) -> Owned[Self]: """Accept remote destruction responsibility for this existing object. @@ -72,6 +74,18 @@ def own(self, *, timeout: float = 5.0) -> Owned[Self]: return Owned(self, timeout=timeout) + def _enter_owned(self) -> Owned[Self]: + """Recover a known creation if accepting its automatic scope fails.""" + from libtmux.lifecycle import _CreationReceipt, _rollback_creation + + try: + return self.own() + except BaseException as failure: + receipt = getattr(self, "_creation_receipt", None) + if isinstance(receipt, _CreationReceipt): + _rollback_creation(receipt, failure) + raise + def _supports_version(self, minimum: str) -> bool: """Check capabilities with this object's captured tmux client.""" server = t.cast("Server", getattr(self, "server", self)) @@ -353,6 +367,7 @@ def __init__( tmux_bin: str | None = None, env: t.Mapping[str, str] | None = None, timeout: float | None = None, + _on_completion: Callable[[tmux_cmd], None] | None = None, ) -> None: client_env = dict(os.environ if env is None else env) client_env.pop("TMUX", None) @@ -386,13 +401,26 @@ def __init__( errors="backslashreplace", env=client_env, ) - try: - stdout, stderr = self.process.communicate(timeout=timeout) - except BaseException: - self.process.kill() - self.process.communicate() - raise - returncode = self.process.returncode + if _on_completion is None: + stdout, stderr, interrupted = self._communicate(timeout) + self._record_output(stdout, stderr) + else: + # SIGINT must not consume receipt bytes between read and append. + # A worker keeps draining while the main thread handles interruption. + def receive() -> BaseException | None: + stdout, stderr, failure = self._communicate(timeout) + self._record_output(stdout, stderr) + _on_completion(self) + return failure + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + reader = pool.submit(receive) + try: + interrupted = reader.result() + except BaseException as failure: # noqa: BLE001 - re-raised below + self.process.kill() + reader.result() + interrupted = failure except FileNotFoundError: raise exc.TmuxCommandNotFound from None except Exception: @@ -404,20 +432,8 @@ def __init__( ) raise - self.returncode = returncode - - stdout_split = stdout.split("\n") - # remove trailing newlines from stdout - while stdout_split and stdout_split[-1] == "": - stdout_split.pop() - - stderr_split = stderr.split("\n") - self.stderr = list(filter(None, stderr_split)) # filter empty values - - if "has-session" in cmd and len(self.stderr) and not stdout_split: - self.stdout = [self.stderr[0]] - else: - self.stdout = stdout_split + if interrupted is not None: + raise interrupted if logger.isEnabledFor(logging.DEBUG): logger.debug( @@ -432,6 +448,30 @@ def __init__( }, ) + def _record_output(self, stdout: str, stderr: str) -> None: + """Retain command output before the main thread resumes an interrupted call.""" + self.returncode = self.process.returncode + stdout_split = stdout.split("\n") + while stdout_split and stdout_split[-1] == "": + stdout_split.pop() + self.stderr = list(filter(None, stderr.split("\n"))) + if "has-session" in self.cmd and self.stderr and not stdout_split: + self.stdout = [self.stderr[0]] + else: + self.stdout = stdout_split + + def _communicate( + self, timeout: float | None + ) -> tuple[str, str, BaseException | None]: + """Reap a failed client before handing off its partial stdout receipt.""" + try: + stdout, stderr = self.process.communicate(timeout=timeout) + except BaseException as failure: # noqa: BLE001 - returned for caller to raise + self.process.kill() + stdout, stderr = self.process.communicate() + return stdout, stderr, failure + return stdout, stderr, None + class _TmuxVersionUnavailable(Exception): """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" diff --git a/src/libtmux/lifecycle.py b/src/libtmux/lifecycle.py index c374ec91de..4f40dbdec5 100644 --- a/src/libtmux/lifecycle.py +++ b/src/libtmux/lifecycle.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib import copy import dataclasses import math @@ -25,7 +26,9 @@ if t.TYPE_CHECKING: import types + from collections.abc import Generator + from libtmux.common import tmux_cmd from libtmux.server import Server Resource = t.TypeVar("Resource") @@ -34,6 +37,28 @@ _TOKEN = re.compile(r"[0-9a-fA-F]{32}\Z") +class UnknownCreation(exc.LibTmuxException): + """Creation returned no trustworthy receipt; inspect before retrying it.""" + + +class CreationCleanupError(exc.LibTmuxException): + """A failed creation rollback retains its exact owner for inspection or retry. + + Raised alongside the original operation error in a ``BaseExceptionGroup``. + ``__cause__`` retains the cleanup failure. ``owner.identity`` identifies the + accepted resource; ``owner.close()`` retries against that same generation. + + Parameters + ---------- + owner : Owned + Cleanup responsibility retained from the original creation receipt. + """ + + def __init__(self, owner: Owned[None]) -> None: + self.owner = owner + super().__init__("creation rollback failed; inspect or retry error.owner") + + @dataclasses.dataclass(frozen=True) class OwnedIdentity: """The immutable endpoint, daemon generation and object accepted for cleanup. @@ -72,6 +97,188 @@ def _condition(self) -> str: ) +@dataclasses.dataclass(frozen=True) +class _CreationReceipt: + client: Server + identity: OwnedIdentity + + +def _identity_from_fields( + client: Server, kind: ObjectKind, fields: list[str] +) -> OwnedIdentity: + """Decode daemon and object identity returned on one creation connection.""" + prefix = {"session": "$", "window": "@", "pane": "%"}.get(kind) + if ( + len(fields) != 4 + or not fields[0].isascii() + or not fields[0].isdecimal() + or int(fields[0]) <= 0 + or not fields[1].isascii() + or not fields[1].isdecimal() + or not _TOKEN.fullmatch(fields[2]) + or ( + not re.fullmatch(re.escape(prefix) + r"[0-9]+", fields[3]) + if prefix is not None + else bool(fields[3]) + ) + ): + message = ( + "tmux returned invalid ownership identity or reserved generation token" + ) + raise exc.LibTmuxException(message) + return OwnedIdentity( + client.socket_path, + int(fields[0]), + int(fields[1]), + fields[2], + kind, + fields[3] or None, + ) + + +def _creation_format(kind: ObjectKind, payload: str) -> str: + """Prefix object output with its same-connection creation receipt.""" + return ( + f"#{{pid}}\t#{{start_time}}\t#{{{_GENERATION_OPTION}}}\t" + f"#{{{kind}_id}}\t{payload}" + ) + + +def _rollback_creation(receipt: _CreationReceipt, failure: BaseException) -> None: + """Recover the recorded creation without accepting a later daemon token.""" + + def recover() -> None: + owner = Owned._from_receipt(None, receipt) + if not owner._process_exited(): + try: + owner.close() + except BaseException as cleanup_error: + if not isinstance(cleanup_error, Exception): + retry = CreationCleanupError(owner) + retry.__cause__ = cleanup_error + message = "creation rollback was interrupted" + raise BaseExceptionGroup(message, [cleanup_error, retry]) from None + raise CreationCleanupError(owner) from cleanup_error + + _run_cleanup(recover, failure) + + +def _require_creation_success(result: tmux_cmd) -> None: + """Surface the original client failure after retaining a creation receipt.""" + if result.returncode or result.stderr: + detail = "\n".join(result.stderr) + raise exc.LibTmuxException(detail or f"exit status {result.returncode}") + + +def _check_creation_guards(result: tmux_cmd, stale: str, invalid: str) -> None: + """Distinguish refusal before dispatch from a missing creation receipt.""" + detail = "\n".join(result.stderr) + if stale in detail: + message = "the creation endpoint now names a different tmux daemon" + raise exc.StaleTmuxOwner(message) + if invalid in detail: + message = "tmux returned an invalid reserved generation token before creation" + raise exc.LibTmuxException(message) + + +def _verify_identity(client: Server, identity: OwnedIdentity, timeout: float) -> None: + """Refuse handoff when a later connection identifies another generation.""" + accepted = _capture_identity(client, identity.kind, identity.object_id, timeout) + if accepted != identity: + message = "the daemon generation changed after creation" + raise exc.StaleTmuxOwner(message) + + +def _creation_receipt( + client: Server, kind: ObjectKind, command: str, result: tmux_cmd +) -> tuple[_CreationReceipt, str]: + """Accept a receipt before classifying the command's exit status.""" + fields = result.stdout[0].split("\t", 4) if result.stdout else [] + if len(fields) != 5: + detail = "\n".join(result.stderr) or f"exit status {result.returncode}" + message = ( + f"{command} returned no trustworthy creation receipt ({detail}); " + "inspect the endpoint before retry" + ) + raise UnknownCreation(message) + try: + identity = _identity_from_fields(client, kind, fields[:4]) + except Exception as failure: + message = ( + f"{command} returned no trustworthy creation receipt; " + "inspect the endpoint before retry" + ) + raise UnknownCreation(message) from failure + return _CreationReceipt(client, identity), fields[4] + + +@contextlib.contextmanager +def _creation( + server: Server, + kind: ObjectKind, + command: str, + arguments: tuple[str | int, ...], + *, + parent: object | None = None, +) -> Generator[tuple[tmux_cmd, _CreationReceipt], None, None]: + """Keep a creation receipt across materialization and its possible failure.""" + client = copy.copy(server) + operation = shlex.join([command, *(str(arg) for arg in arguments)]) + stale = "libtmux-creation-stale-" + secrets.token_hex(16) + invalid = "libtmux-creation-invalid-generation-" + secrets.token_hex(16) + if parent is not None: + _, parent_kind, parent_id = _kind_and_id(parent) + prior = getattr(parent, "_creation_receipt", None) + parent_identity = ( + prior.identity + if isinstance(prior, _CreationReceipt) + else _capture_identity(client, parent_kind, parent_id, 5.0) + ) + operation = shlex.join( + ["if-shell", "-F", parent_identity._condition, operation, stale] + ) + valid = "#{m/r:^" + "[0-9a-fA-F]" * 32 + "$,#{" + _GENERATION_OPTION + "}}" + arguments = ( + *(("start-server", ";") if kind == "session" else ()), + *_generation_setup(), + "if-shell", + "-F", + valid, + operation, + invalid, + ) + observed: list[tmux_cmd] = [] + result: tmux_cmd | None = None + receipt: _CreationReceipt | None = None + try: + result = client.cmd( + str(arguments[0]), + *arguments[1:], + timeout=5.0, + _on_completion=observed.append, + ) + _check_creation_guards(result, stale, invalid) + receipt, result.stdout[0] = _creation_receipt(client, kind, command, result) + _require_creation_success(result) + yield result, receipt + _verify_identity(client, receipt.identity, 5.0) + except BaseException as failure: + if receipt is None and observed: + with contextlib.suppress(UnknownCreation): + receipt, _ = _creation_receipt(client, kind, command, observed[0]) + if receipt is not None: + _rollback_creation(receipt, failure) + elif result is None: + message = ( + f"{command} returned no creation receipt; " + "inspect the endpoint before retry" + ) + if isinstance(failure, Exception): + raise UnknownCreation(message) from failure + failure.add_note(message) + raise + + def _kind_and_id(resource: object) -> tuple[Server, ObjectKind, str | None]: """Validate an accepted public resource and its exact tmux ID.""" from libtmux.pane import Pane @@ -181,10 +388,39 @@ def __init__(self, value: Resource, *, timeout: float = 5.0) -> None: message = "ownership timeout must be positive and finite" raise ValueError(message) server, kind, object_id = _kind_and_id(value) + receipt = getattr(value, "_creation_receipt", None) + if isinstance(receipt, _CreationReceipt): + identity = _capture_identity(receipt.client, kind, object_id, timeout) + if identity != receipt.identity: + message = "the endpoint no longer identifies the created tmux resource" + raise exc.StaleTmuxOwner(message) + self._initialize(value, receipt.client, receipt.identity, timeout) + return + client = copy.copy(server) + identity = _capture_identity(client, kind, object_id, timeout) + self._initialize(value, client, identity, timeout) + + @classmethod + def _from_receipt( + cls, value: Resource, receipt: _CreationReceipt, timeout: float = 5.0 + ) -> Owned[Resource]: + """Use the original creation identity without another acceptance query.""" + owner = cls.__new__(cls) + owner._initialize(value, receipt.client, receipt.identity, timeout) + return owner + + def _initialize( + self, + value: Resource, + client: Server, + identity: OwnedIdentity, + timeout: float, + ) -> None: + """Retain local state after an adoption or a same-connection receipt.""" self._value = value - self._client = copy.copy(server) + self._client = copy.copy(client) self._timeout = timeout - self._identity = _capture_identity(self._client, kind, object_id, timeout) + self._identity = identity self._closed = False self._cleanup_error: BaseException | None = None self._lock = threading.RLock() diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 882e9b1f92..fcbbc8d1af 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -28,6 +28,7 @@ ) from libtmux.formats import FORMAT_SEPARATOR from libtmux.hooks import HooksMixin +from libtmux.lifecycle import _creation, _creation_format from libtmux.neo import Obj, fetch_obj from libtmux.options import OptionsMixin @@ -118,7 +119,7 @@ def __enter__(self) -> Self: if previous is not None and not previous.closed: message = "this pane already has an active or failed cleanup scope" raise RuntimeError(message) - self._scope_owner = self.own() + self._scope_owner = self._enter_owned() return self def __exit__( @@ -1336,7 +1337,7 @@ def split( if zoom: tmux_args += ("-Z",) - tmux_args += ("-P", "-F{}".format("".join(tmux_formats))) # output + tmux_args += ("-P", f"-F{_creation_format('pane', ''.join(tmux_formats))}") if start_directory: start_path = pathlib.Path(start_directory).expanduser() @@ -1380,38 +1381,35 @@ def split( if shell: tmux_args += (shell,) - pane_cmd = self.cmd("split-window", *tmux_args, target=target) - - if pane_cmd.stderr: - if "pane too small" in pane_cmd.stderr: - raise exc.LibTmuxException(pane_cmd.stderr) - - raise exc.LibTmuxException( - pane_cmd.stderr, - self.__dict__, - self.window.panes, - ) - - pane_output = pane_cmd.stdout[0] - - pane_formatters = dict( - zip(["pane_id"], pane_output.split(FORMAT_SEPARATOR), strict=False), - ) - - pane = self.from_pane_id(server=self.server, pane_id=pane_formatters["pane_id"]) - - extra: dict[str, str] = { - "tmux_subcommand": "split-window", - "tmux_pane": str(pane.pane_id), - } - if self.session.session_name is not None: - extra["tmux_session"] = str(self.session.session_name) - if self.window.window_name is not None: - extra["tmux_window"] = str(self.window.window_name) + if target is None: + target = self.pane_id if target is not None: - extra["tmux_target"] = str(target) - - logger.info("pane created", extra=extra) + tmux_args = ("-t", str(target), *tmux_args) + with _creation(self.server, "pane", "split-window", tmux_args, parent=self) as ( + pane_cmd, + receipt, + ): + pane_output = pane_cmd.stdout[0] + pane_formatters = dict( + zip(["pane_id"], pane_output.split(FORMAT_SEPARATOR), strict=False), + ) + pane = self.from_pane_id( + server=self.server, pane_id=pane_formatters["pane_id"] + ) + pane._creation_receipt = receipt + + extra: dict[str, str] = { + "tmux_subcommand": "split-window", + "tmux_pane": str(pane.pane_id), + } + if self.session.session_name is not None: + extra["tmux_session"] = str(self.session.session_name) + if self.window.window_name is not None: + extra["tmux_window"] = str(self.window.window_name) + if target is not None: + extra["tmux_target"] = str(target) + + logger.info("pane created", extra=extra) return pane diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a63745ea38..3bea972ab3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -33,6 +33,7 @@ ) from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin +from libtmux.lifecycle import _creation, _creation_format from libtmux.neo import fetch_objs, get_output_format, parse_output from libtmux.pane import Pane from libtmux.session import Session @@ -431,6 +432,7 @@ def cmd( *args: t.Any, target: str | int | None = None, timeout: float | None = None, + _on_completion: t.Callable[[tmux_cmd], None] | None = None, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -497,12 +499,16 @@ def cmd( cmd_args = ["-t", str(target), *args] if target is not None else [*args] + completion: dict[str, t.Any] = {} + if _on_completion is not None: + completion["_on_completion"] = _on_completion return tmux_cmd( *svr_args, *cmd_args, tmux_bin=self._require_tmux_bin(), env=self.child_environment, timeout=timeout, + **completion, ) @property @@ -2454,7 +2460,10 @@ def new_session( tmux_version = str(self._version) _fields, format_string = get_output_format("list-sessions", tmux_version) - tmux_args: tuple[str | int, ...] = ("-P", f"-F{format_string}") + tmux_args: tuple[str | int, ...] = ( + "-P", + f"-F{_creation_format('session', format_string)}", + ) if detach_others: tmux_args += ("-D",) if no_size: @@ -2480,20 +2489,18 @@ def new_session( if window_command: tmux_args += (window_command,) - proc = self.cmd("new-session", *tmux_args) - raise_if_stderr(proc, "new-session") - session_stdout = proc.stdout[0] - - session_data = parse_output(session_stdout, "list-sessions", tmux_version) - - session = Session(server=self, **session_data) - - info_extra: dict[str, str] = { - "tmux_subcommand": "new-session", - } - if session.session_name is not None: - info_extra["tmux_session"] = str(session.session_name) - logger.info("session created", extra=info_extra) + with _creation(self, "session", "new-session", tmux_args) as (proc, receipt): + session_stdout = proc.stdout[0] + session_data = parse_output(session_stdout, "list-sessions", tmux_version) + session = Session(server=self, **session_data) + session._creation_receipt = receipt + + info_extra: dict[str, str] = { + "tmux_subcommand": "new-session", + } + if session.session_name is not None: + info_extra["tmux_session"] = str(session.session_name) + logger.info("session created", extra=info_extra) return session diff --git a/src/libtmux/session.py b/src/libtmux/session.py index fea59136a6..db0e00c395 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -18,6 +18,7 @@ from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, OptionScope, WindowDirection from libtmux.formats import FORMAT_SEPARATOR from libtmux.hooks import HooksMixin +from libtmux.lifecycle import _creation, _creation_format from libtmux.neo import Obj, fetch_obj, fetch_objs from libtmux.options import OptionsMixin from libtmux.pane import Pane @@ -117,7 +118,7 @@ def __enter__(self) -> Self: if previous is not None and not previous.closed: message = "this session already has an active or failed cleanup scope" raise RuntimeError(message) - self._scope_owner = self.own() + self._scope_owner = self._enter_owned() return self def __exit__( @@ -882,7 +883,7 @@ def new_window( start_directory = pathlib.Path(start_directory).expanduser() window_args += (f"-c{start_directory}",) - window_args += ("-F#{window_id}",) # output + window_args += (f"-F{_creation_format('window', '#{window_id}')}",) if window_name is not None and isinstance(window_name, str): window_args += ("-n", window_name) @@ -912,32 +913,38 @@ def new_window( if window_shell: window_args += (window_shell,) - cmd = self.cmd("new-window", *window_args, target=target) - - raise_if_stderr(cmd, "new-window") - - window_output = cmd.stdout[0] - - window_formatters = dict( - zip(["window_id"], window_output.split(FORMAT_SEPARATOR), strict=False), - ) - - window = Window.from_window_id( - server=self.server, - window_id=window_formatters["window_id"], - ) - - extra: dict[str, str] = { - "tmux_subcommand": "new-window", - } - if self.session_name is not None: - extra["tmux_session"] = str(self.session_name) - if window.window_name is not None: - extra["tmux_window"] = str(window.window_name) + if target is None: + target = self.session_id if target is not None: - extra["tmux_target"] = str(target) - - logger.info("window created", extra=extra) + window_args = ("-t", str(target), *window_args) + with _creation( + self.server, + "window", + "new-window", + window_args, + parent=self, + ) as (cmd, receipt): + window_output = cmd.stdout[0] + window_formatters = dict( + zip(["window_id"], window_output.split(FORMAT_SEPARATOR), strict=False), + ) + window = Window.from_window_id( + server=self.server, + window_id=window_formatters["window_id"], + ) + window._creation_receipt = receipt + + extra: dict[str, str] = { + "tmux_subcommand": "new-window", + } + if self.session_name is not None: + extra["tmux_session"] = str(self.session_name) + if window.window_name is not None: + extra["tmux_window"] = str(window.window_name) + if target is not None: + extra["tmux_target"] = str(target) + + logger.info("window created", extra=extra) return window diff --git a/src/libtmux/window.py b/src/libtmux/window.py index f9c8ee035a..0dd4c0133b 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -131,7 +131,7 @@ def __enter__(self) -> Self: if previous is not None and not previous.closed: message = "this window already has an active or failed cleanup scope" raise RuntimeError(message) - self._scope_owner = self.own() + self._scope_owner = self._enter_owned() return self def __exit__( diff --git a/tests/test_cleanup_scopes.py b/tests/test_cleanup_scopes.py index be9f0a440e..0373336996 100644 --- a/tests/test_cleanup_scopes.py +++ b/tests/test_cleanup_scopes.py @@ -66,7 +66,8 @@ def denied(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: return command(cmd, *args, **kwargs) with monkeypatch.context() as patch: - patch.setattr(server, "cmd", denied) + assert obj._creation_receipt is not None + patch.setattr(obj._creation_receipt.client, "cmd", denied) with pytest.raises(BaseExceptionGroup) as caught, obj: raise body_error assert caught.value.exceptions == (body_error, cleanup_error) @@ -111,7 +112,8 @@ def denied(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: return command(cmd, *args, **kwargs) with monkeypatch.context() as patch: - patch.setattr(server, "cmd", denied) + assert obj._creation_receipt is not None + patch.setattr(obj._creation_receipt.client, "cmd", denied) with pytest.raises(exc.LibTmuxException, match=r"denied|exit status 64"), obj: pass obj.refresh() diff --git a/tests/test_creation_recovery.py b/tests/test_creation_recovery.py new file mode 100644 index 0000000000..e96e165c25 --- /dev/null +++ b/tests/test_creation_recovery.py @@ -0,0 +1,424 @@ +"""Creation failures retain their daemon receipt and recover known resources.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import sys +import typing as t + +import pytest + +import libtmux.lifecycle as lifecycle_module +import libtmux.server as server_module +from libtmux import Pane, Server, Session, Window, exc +from libtmux.lifecycle import CreationCleanupError, UnknownCreation +from libtmux.neo import parse_output +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.common import tmux_cmd + + +def _create(server: Server, kind: str) -> Session | Window | Pane: + if kind == "session": + return server.new_session(session_name="created") + session = server.sessions.get(session_name="keeper") + assert session is not None + if kind == "window": + return session.new_window(window_name="created") + return session.active_window.split() + + +def _ids(server: Server, kind: str) -> list[str]: + result = server.cmd( + f"list-{kind}s", + *(("-a",) if kind != "session" else ()), + "-F", + f"#{{{kind}_id}}", + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +@pytest.mark.parametrize( + "failure", [RuntimeError("readback failed"), KeyboardInterrupt()] +) +def test_known_creation_rolls_back_failed_materialization( + server: Server, + monkeypatch: pytest.MonkeyPatch, + kind: str, + failure: BaseException, +) -> None: + """A real create succeeds before an injected parser or query failure.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + + def fail_readback(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + raise failure + + with monkeypatch.context() as patch: + if kind == "session": + patch.setattr(server_module, "parse_output", fail_readback) + elif kind == "window": + patch.setattr(Window, "from_window_id", fail_readback) + else: + patch.setattr(Pane, "from_pane_id", fail_readback) + with pytest.raises(type(failure)) as caught: + _create(server, kind) + assert caught.value is failure + assert _ids(server, kind) == before + assert server.has_session("keeper") + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +@pytest.mark.parametrize("interrupt", [False, True]) +def test_interrupted_client_retains_completed_creation_receipt( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + kind: str, + interrupt: bool, +) -> None: + """Timeout or interruption after stdout still recovers the reported ID.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + wrapper = tmp_path / "tmux-client" + pid_file = tmp_path / "wrapper-pid" + binary = server._require_tmux_bin() + operation = { + "session": "new-session", + "window": "new-window", + "pane": "split-window", + }[kind] + wrapper.write_text( + f"#!{sys.executable}\n" + "import os, pathlib, signal, subprocess, sys, time\n" + f"if any({operation + ' '!r} in arg for arg in sys.argv):\n" + f" result = subprocess.run([{binary!r}, *sys.argv[1:]], " + "capture_output=True)\n" + " assert result.returncode == 0 and result.stdout\n" + " os.write(1, result.stdout)\n" + " os.write(2, result.stderr)\n" + f" pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid()))\n" + f" if {interrupt!r}: os.kill(os.getppid(), signal.SIGINT)\n" + " time.sleep(30)\n" + f"os.execv({binary!r}, [{binary!r}, *sys.argv[1:]])\n" + ) + wrapper.chmod(0o700) + client = Server(socket_path=server.socket_path, tmux_bin=wrapper) + command = client.cmd + + def short_deadline(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if any(operation + " " in str(arg) for arg in args): + kwargs["timeout"] = 1.0 + return command(cmd, *args, **kwargs) + + monkeypatch.setattr(client, "cmd", short_deadline) + failure = KeyboardInterrupt if interrupt else subprocess.TimeoutExpired + with pytest.raises(failure): + _create(client, kind) + assert _ids(server, kind) == before + with pytest.raises(ChildProcessError): + os.waitpid(int(pid_file.read_text()), os.WNOHANG) + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_failed_acquisition_rollback_exposes_retry_owner( + server: Server, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + """Retain the operation error, cleanup error and exact owner for retry.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + command = server.cmd + failure = KeyboardInterrupt() + cleanup_error = PermissionError("rollback denied") + + def fail_readback(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + raise failure + + def deny_cleanup(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if any(str(arg).startswith(f"kill-{kind} ") for arg in args): + raise cleanup_error + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", deny_cleanup) + if kind == "session": + patch.setattr(server_module, "parse_output", fail_readback) + elif kind == "window": + patch.setattr(Window, "from_window_id", fail_readback) + else: + patch.setattr(Pane, "from_pane_id", fail_readback) + with pytest.raises(BaseExceptionGroup) as caught: + _create(server, kind) + assert caught.value.exceptions[0] is failure + recovery = caught.value.exceptions[1] + assert isinstance(recovery, CreationCleanupError) + assert recovery.__cause__ is cleanup_error + owner = recovery.owner + assert owner.cleanup_error is cleanup_error + assert not owner.closed + monkeypatch.setattr(owner._client, "cmd", command) + owner.close() + assert _ids(server, kind) == before + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_empty_successful_reply_exposes_unknown_creation( + server: Server, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + """Missing stdout cannot prove absence after a real successful create.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + command = server.cmd + operation = { + "session": "new-session", + "window": "new-window", + "pane": "split-window", + }[kind] + + def empty_reply(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + result = command(cmd, *args, **kwargs) + if any(operation + " " in str(arg) for arg in args): + assert result.returncode == 0 and result.stdout + result.stdout = [] + return result + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", empty_reply) + with pytest.raises(UnknownCreation, match="inspect the endpoint"): + _create(server, kind) + assert len(_ids(server, kind)) == len(before) + 1 + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_materialization_cannot_accept_a_replacement_daemon( + server: Server, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + """The receipt must remain authoritative across the first snapshot query.""" + server.new_session(session_name="keeper") + owner = server.own() + original: t.Callable[..., t.Any] + if kind == "session": + original = parse_output + elif kind == "window": + original = Window.from_window_id + else: + original = Pane.from_pane_id + replace = True + replacement: list[Session | Window | Pane] = [] + + def replace_before_readback(*args: t.Any, **kwargs: t.Any) -> t.Any: + nonlocal replace + if replace: + replace = False + owner.close() + server.new_session(session_name="keeper") + replacement.append(_create(server, kind)) + return original(*args, **kwargs) + + with monkeypatch.context() as patch: + if kind == "session": + patch.setattr(server_module, "parse_output", replace_before_readback) + elif kind == "window": + patch.setattr(Window, "from_window_id", replace_before_readback) + else: + patch.setattr(Pane, "from_pane_id", staticmethod(replace_before_readback)) + with pytest.raises(exc.StaleTmuxOwner): + _create(server, kind) + assert getattr(replacement[0], f"{kind}_id") in _ids(server, kind) + + +def test_interrupted_rollback_preserves_interrupt_and_retry_owner( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """An interrupt during rollback remains a BaseException in the group.""" + server.new_session(session_name="keeper") + before = _ids(server, "window") + failure = RuntimeError("snapshot failed") + interrupted = KeyboardInterrupt() + command = server.cmd + + def fail_readback(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + raise failure + + def interrupt_cleanup(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if any(str(arg).startswith("kill-window ") for arg in args): + raise interrupted + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", interrupt_cleanup) + patch.setattr(Window, "from_window_id", fail_readback) + with pytest.raises(BaseExceptionGroup) as caught: + _create(server, "window") + assert not isinstance(caught.value, Exception) + assert caught.value.exceptions[0] is failure + rollback = caught.value.exceptions[1] + assert isinstance(rollback, BaseExceptionGroup) + assert rollback.exceptions[0] is interrupted + recovery = rollback.exceptions[1] + assert isinstance(recovery, CreationCleanupError) + assert recovery.__cause__ is interrupted + assert recovery.owner.cleanup_error is interrupted + monkeypatch.setattr(recovery.owner._client, "cmd", command) + recovery.owner.close() + assert _ids(server, "window") == before + + +def test_select_existing_still_recovers_a_reported_new_window( + server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """The select flag cannot exempt a known newly created ID from rollback.""" + session = server.new_session(session_name="keeper") + before = _ids(server, "window") + failure = RuntimeError("window readback failed") + + def fail_readback(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: + raise failure + + with monkeypatch.context() as patch: + patch.setattr(Window, "from_window_id", fail_readback) + with pytest.raises(RuntimeError) as caught: + session.new_window(window_name="created", select_existing=True) + assert caught.value is failure + assert _ids(server, "window") == before + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_interruption_before_receipt_decode_recovers_observed_output( + server: Server, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + """An interruption between client completion and receipt decoding keeps its ID.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + decode = lifecycle_module._creation_receipt + interrupted = KeyboardInterrupt() + first = True + + def interrupt_once(*args: t.Any, **kwargs: t.Any) -> t.Any: + nonlocal first + if first: + first = False + raise interrupted + return decode(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(lifecycle_module, "_creation_receipt", interrupt_once) + with pytest.raises(KeyboardInterrupt) as caught: + _create(server, kind) + assert caught.value is interrupted + assert _ids(server, kind) == before + + +def test_creation_matches_native_names_and_preserves_environment( + server: Server, +) -> None: + """Command parsing preserves tmux naming behavior and literal environment data.""" + name = "quotes ' \" ; $HOME #{pane_id}" + value = "literal ; ' \" $HOME $(printf injected) #{pane_id}" + with ( + server.new_session(session_name="keeper") as session, + session.new_window( + window_name=name, + environment={"LIBTMUX_CREATION_TEST": value}, + window_shell="/bin/sh", + ) as window, + ): + window.set_option("automatic-rename", False) + native = server.cmd( + "new-window", + "-d", + "-t", + session.session_id, + "-n", + name, + "-P", + "-F", + "#{window_name}", + ) + assert native.returncode == 0 and native.stdout + assert window.window_name == native.stdout[0] + pane = window.active_pane + assert pane is not None + command = 'printf "%s" "$LIBTMUX_CREATION_TEST"' + pane.send_keys(command) + + def has_value() -> bool: + return any(value in line for line in pane.capture_pane()) + + retry_until(has_value, seconds=2) + assert has_value() + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_context_entry_keeps_the_creation_daemon(server: Server, kind: str) -> None: + """A replacement between create and context entry cannot become owned.""" + server.new_session(session_name="keeper") + created = _create(server, kind) + server.own().close() + server.new_session(session_name="keeper") + replacement = _create(server, kind) + replacement_id = getattr(replacement, f"{kind}_id") + assert getattr(created, f"{kind}_id") == replacement_id + with pytest.raises(exc.StaleTmuxOwner), created: + pytest.fail("entered ownership on a replacement daemon") + assert replacement_id in _ids(server, kind) + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_completed_failure_with_receipt_recovers_created_resource( + server: Server, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + """A nonzero client result must not discard its valid creation receipt.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + command = server.cmd + operation = { + "session": "new-session", + "window": "new-window", + "pane": "split-window", + }[kind] + + def failed_result(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + result = command(cmd, *args, **kwargs) + if any(operation + " " in str(arg) for arg in args): + assert result.returncode == 0 and result.stdout + result.returncode = 77 + result.stderr = ["failure after receipt"] + return result + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", failed_result) + with pytest.raises(exc.LibTmuxException, match="failure after receipt"): + _create(server, kind) + assert _ids(server, kind) == before + + +@pytest.mark.parametrize("kind", ["session", "window", "pane"]) +def test_context_acceptance_failure_recovers_known_creation( + server: Server, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + """Context entry recovers a known create if its acceptance query fails.""" + server.new_session(session_name="keeper") + before = _ids(server, kind) + created = _create(server, kind) + assert created._creation_receipt is not None + client = created._creation_receipt.client + command = client.cmd + failure = PermissionError("acceptance query failed") + + def denied(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if "display-message" in args: + raise failure + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(client, "cmd", denied) + with pytest.raises(PermissionError) as caught, created: + pytest.fail("entered ownership after a failed acceptance query") + assert caught.value is failure + assert _ids(server, kind) == before diff --git a/tests/test_ownership.py b/tests/test_ownership.py index 9476a2913b..7089d55cf4 100644 --- a/tests/test_ownership.py +++ b/tests/test_ownership.py @@ -6,6 +6,7 @@ import os import pathlib import subprocess +import sys import threading import time import typing as t @@ -248,9 +249,10 @@ def test_hung_cleanup_client_is_reaped_and_owner_can_retry( wrapper = tmp_path / "tmux-wrapper" binary = server._require_tmux_bin() wrapper.write_text( - "#!/usr/bin/python3\n" + f"#!{sys.executable}\n" "import os, pathlib, sys, time\n" - f"if 'if-shell' in sys.argv and pathlib.Path({str(blocker)!r}).exists():\n" + "if any(arg.startswith('kill-') for arg in sys.argv) " + f"and pathlib.Path({str(blocker)!r}).exists():\n" f" pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid()))\n" " time.sleep(30)\n" f"os.execv({binary!r}, [{binary!r}, *sys.argv[1:]])\n" From feee66aeb9bec9e11754047607f303fc2790be3e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 18:12:00 -0500 Subject: [PATCH 4/7] Compatibility(fix): Keep Python 3.10 and docs why: The lifecycle changes do not require raising the Python floor or removing the reference detail and cleanup examples users already have. what: - Restore Python 3.10 metadata, typing fallbacks and lock branches - Use conditional exception-group support and compatible error notes - Restore context-method reference sections and cleanup doctests - Keep unrelated typing and lint cleanup outside the lifecycle diff - Verify examples without requiring pidfd support from the interpreter --- .github/CONTRIBUTING.md | 2 +- CHANGES | 8 +- README.md | 2 +- docs/project/compatibility.md | 4 +- docs/topics/context_managers.md | 70 ++++- pyproject.toml | 13 +- src/libtmux/_compat.py | 5 + src/libtmux/_internal/control_mode.py | 3 +- src/libtmux/common.py | 8 +- src/libtmux/hooks.py | 2 +- src/libtmux/lifecycle.py | 7 +- src/libtmux/options.py | 10 +- src/libtmux/pane.py | 27 +- src/libtmux/pytest_plugin.py | 3 +- src/libtmux/server.py | 4 +- src/libtmux/session.py | 27 +- src/libtmux/test/environment.py | 7 +- src/libtmux/test/random.py | 7 +- src/libtmux/test/retry.py | 4 + src/libtmux/test/temporary.py | 4 + src/libtmux/window.py | 27 +- tests/test_cleanup_scopes.py | 1 + tests/test_creation_recovery.py | 29 ++ tests/test_endpoint_defaults.py | 4 +- tests/test_example_harness.py | 16 +- tests/test_options.py | 2 +- tests/test_ownership.py | 1 + uv.lock | 432 ++++++++++++++++++++++++-- 28 files changed, 654 insertions(+), 75 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e066056b44..7c0edbe84a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -25,7 +25,7 @@ $ cd libtmux $ uv sync --all-extras --dev ``` -This project uses Python 3.11+, [uv] for dependency management, [ruff] +This project uses Python 3.10+, [uv] for dependency management, [ruff] for linting and formatting, [mypy] for type checking, and [pytest] for testing (with [pytest-watcher] for continuous runs). diff --git a/CHANGES b/CHANGES index d8d5844fc9..3b41d25a61 100644 --- a/CHANGES +++ b/CHANGES @@ -53,12 +53,6 @@ A plain `Server` context leaves remote state intact. Use `server.own()` to accep `Server` captures an absolute tmux executable path from its construction-time `PATH` and working directory. Commands, object queries, control clients, health checks and version probes use that executable and the captured child environment. A missing executable stays unresolved until a new handle is constructed. The read-only `tmux_bin` property reports the captured path or `None`. Version caches belong to each server handle; standalone `get_version()` and `get_version_str()` retain their existing cache behavior. -#### Python 3.11 minimum - -Minimum Python 3.11 (was 3.10). Session cleanup uses native -`BaseExceptionGroup` to retain body and teardown failures, including -`KeyboardInterrupt` and `SystemExit`. - #### Captured server endpoints {class}`~libtmux.Server` resolves explicit socket selectors, then @@ -98,7 +92,7 @@ Call `.own()` on a server, session, window or pane to accept destruction respons immutable captured mapping. Client launches omit `TMUX` and `TMUX_PANE` without changing the host environment. Session, window and pane scopes target object IDs, including after a rename or a move to another parent. -`BaseExceptionGroup` retains both a body exception and a cleanup exception. +`BaseExceptionGroup` retains both a body exception and a cleanup exception. Python 3.10 uses the `exceptiongroup` backport; Python 3.11 and later use the built-in exception group. A failed cleanup can be retried; a prior successful destruction is harmless. `temp_window` uses the window scope's cleanup. Pytest finalizers retain exact endpoints and report cleanup failures. diff --git a/README.md b/README.md index ab27d64dd9..911b5215ac 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelli ## Requirements & support - tmux: >= 3.2a -- Python: >= 3.11 (CPython and PyPy) +- Python: >= 3.10 (CPython and PyPy) Maintenance-only backports (no new fixes): diff --git a/docs/project/compatibility.md b/docs/project/compatibility.md index 2ab2c8900e..4dc5347e14 100644 --- a/docs/project/compatibility.md +++ b/docs/project/compatibility.md @@ -2,8 +2,8 @@ ## Python -- **Minimum**: Python 3.11 -- **CI**: Python 3.14 +- **Minimum**: Python 3.10 +- **Tested**: Python 3.10, 3.11, 3.12, 3.13 - **Maximum**: Python < 4.0 ## tmux diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index 48dcf5d0c9..20a909153e 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -4,15 +4,21 @@ Use an owned scope to accept responsibility for destroying a remote tmux resource. Looking up a handle leaves that resource alive. A plain `Server` context also leaves remote state intact; `server.own()` accepts whole-daemon destruction. -## Ordinary session scope +## Session context manager -This program uses your configured tmux endpoint, creates a session and removes that session at scope exit. Other sessions remain. The imports and server constructor are part of the example; a test harness can redirect the unchanged code through environment defaults. +This program uses your configured tmux endpoint and removes each session it creates at scope exit. The outer session keeps the daemon alive while the example checks that the inner session was removed. Other sessions remain. The imports and server constructor are part of the example; a test harness can redirect the unchanged code through environment defaults. ```python >>> import libtmux >>> server = libtmux.Server() ->>> with server.new_session() as session: -... print(session.session_id.startswith("$")) +>>> with server.new_session() as keeper: +... with server.new_session() as session: +... print(session in server.sessions) +... window = session.new_window() +... print(session in server.sessions) +... print(keeper in server.sessions) +True +False True ``` @@ -38,9 +44,42 @@ True `owner.value` gives you the borrowed handle. `owner.identity` records the immutable cleanup target. Editing the handle's ID, parent or server after acceptance does not change that identity. You can call `owner.close()` without using a context; repeating a successful close has no effect. -## Window and pane scopes +## Window context manager + +Window destruction removes its panes and links from all sessions. The outer session scope removes the session created for this example: + +```python +>>> import libtmux +>>> server = libtmux.Server() +>>> with server.new_session() as session: +... with session.new_window() as window: +... print(window in session.windows) +... pane = window.split() +... print(window in session.windows) +True +False +``` + +## Pane context manager + +Pane destruction removes that pane. The window's original pane remains after the split pane's scope exits: + +```python +>>> import libtmux +>>> server = libtmux.Server() +>>> with server.new_session() as session: +... window = session.active_window +... with window.split() as pane: +... print(pane in window.panes) +... pane.send_keys('echo "Hello"') +... print(pane in window.panes) +True +False +``` + +## Nested context managers -Window destruction removes its panes and links from all sessions. Pane destruction removes that pane. These scopes nest inside a session scope so the example removes the hierarchy it created: +Nest session, window and pane scopes to clean up the hierarchy in reverse order: ```python >>> import libtmux @@ -54,6 +93,23 @@ True Cleanup runs from the innermost scope outward. tmux may terminate a server itself when its last session disappears; leaving a borrowed `Server` context does not send `kill-server`. +## Server context manager + +A plain server context leaves its sessions alive. This example checks that behavior, then explicitly removes the session it created: + +```python +>>> import libtmux +>>> with libtmux.Server() as server: +... session = server.new_session() +... print(session in server.sessions) +True +>>> print(session in server.sessions) +True +>>> session.own().close() +>>> print(session in server.sessions) +False +``` + ## Whole-server destruction A server owner destroys the daemon and its sessions, windows and panes. This cleanup demonstration uses a private endpoint because destroying your normal daemon would interrupt its sessions. Explicit isolation belongs in this example's subject; ordinary examples above use normal defaults. @@ -85,7 +141,7 @@ The destructive command checks the token, PID and start time inside the receivin ## Cleanup failures and cancellation -If the body and teardown both fail, `BaseExceptionGroup` retains the body error followed by the cleanup error. This includes `KeyboardInterrupt` and `SystemExit`. `owner.cleanup_error` retains the latest teardown error, and `owner.closed` stays false until cleanup succeeds. Repair the cause and call `owner.close()` to retry. A successful retry clears the retained cleanup error. +If the body and teardown both fail, `BaseExceptionGroup` retains the body error followed by the cleanup error. Python 3.10 uses the `exceptiongroup` backport; Python 3.11 and later use the built-in class. This includes `KeyboardInterrupt` and `SystemExit`. `owner.cleanup_error` retains the latest teardown error, and `owner.closed` stays false until cleanup succeeds. Repair the cause and call `owner.close()` to retry. A successful retry clears the retained cleanup error. `resource.own(timeout=5.0)` bounds acceptance and each cleanup attempt, including time spent waiting for another cleanup call. A command timeout kills and reaps the tmux client process; it cannot undo a remote operation that was already dispatched. The owner remains open for inspection and retry. Garbage collection releases local observation descriptors and does not destroy remote resources. These APIs are synchronous; they do not provide an asynchronous task-cancellation supervisor. diff --git a/pyproject.toml b/pyproject.toml index 7d4fa3358c..5ff88ec593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,8 @@ name = "libtmux" version = "0.62.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." -requires-python = ">=3.11,<4.0" +requires-python = ">=3.10,<4.0" +dependencies = ["exceptiongroup>=1.2; python_version < '3.11'"] authors = [ {name = "Tony Narlock", email = "tony@git-pull.com"} ] @@ -16,6 +17,7 @@ classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", "Programming Language :: Python", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -57,6 +59,7 @@ dev = [ "sphinx-autobuild", "types-docutils", # Testing + "typing-extensions; python_version < '3.11'", "gp-libs>=0.0.19", "pytest", "pytest-rerunfailures", @@ -68,6 +71,7 @@ dev = [ "coverage", "pytest-cov", # Lint + "exceptiongroup>=1.2", "ruff>=0.16.1", "mypy", ] @@ -79,6 +83,7 @@ docs = [ "sphinx-autobuild", ] testing = [ + "typing-extensions; python_version < '3.11'", "gp-libs>=0.0.19", "pytest", "pytest-rerunfailures", @@ -91,6 +96,8 @@ coverage =[ "pytest-cov", ] lint = [ + "typing-extensions; python_version < '3.11'", + "exceptiongroup>=1.2", "ruff>=0.16.1", "mypy", ] @@ -138,7 +145,7 @@ sphinx-ux-badges = false [tool.mypy] strict = true -python_version = "3.11" +python_version = "3.10" files = [ "src", "tests", @@ -181,7 +188,7 @@ exclude_lines = [ ] [tool.ruff] -target-version = "py311" +target-version = "py310" [tool.ruff.lint] # `select` is deliberately unset: ruff 0.16 enables a curated default rule diff --git a/src/libtmux/_compat.py b/src/libtmux/_compat.py index acf5ddbe6e..17d7d5990c 100644 --- a/src/libtmux/_compat.py +++ b/src/libtmux/_compat.py @@ -4,6 +4,11 @@ import types import typing as t +if sys.version_info >= (3, 11): + from builtins import BaseExceptionGroup as BaseExceptionGroup +else: + from exceptiongroup import BaseExceptionGroup as BaseExceptionGroup + console_encoding = sys.stdout.encoding diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index c46bc39a1c..c6bd0f434c 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -15,7 +15,8 @@ if t.TYPE_CHECKING: import types - from typing import Self + + from typing_extensions import Self from libtmux.server import Server from libtmux.session import Session diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2582ab3ab3..eadca57a07 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -19,11 +19,15 @@ import typing as t from . import exc -from ._compat import LooseVersion +from ._compat import BaseExceptionGroup, LooseVersion if t.TYPE_CHECKING: from collections.abc import Callable - from typing import Self + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self from libtmux.lifecycle import Owned, _CreationReceipt from libtmux.server import Server diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py index e4507ff966..3de467480c 100644 --- a/src/libtmux/hooks.py +++ b/src/libtmux/hooks.py @@ -48,7 +48,7 @@ from libtmux.options import handle_option_error if t.TYPE_CHECKING: - from typing import Self + from typing_extensions import Self HookDict = dict[str, t.Any] HookValues = dict[int, str] | SparseArray[str] | list[str] diff --git a/src/libtmux/lifecycle.py b/src/libtmux/lifecycle.py index 4f40dbdec5..f05ce30808 100644 --- a/src/libtmux/lifecycle.py +++ b/src/libtmux/lifecycle.py @@ -16,12 +16,14 @@ import secrets import select import shlex +import sys import threading import time import typing as t import weakref from libtmux import exc +from libtmux._compat import BaseExceptionGroup from libtmux.common import _run_cleanup if t.TYPE_CHECKING: @@ -275,7 +277,10 @@ def _creation( ) if isinstance(failure, Exception): raise UnknownCreation(message) from failure - failure.add_note(message) + if sys.version_info >= (3, 11): + failure.add_note(message) + else: + failure.__dict__.setdefault("__notes__", []).append(message) raise diff --git a/src/libtmux/options.py b/src/libtmux/options.py index 0b000e1043..ff8bb2239d 100644 --- a/src/libtmux/options.py +++ b/src/libtmux/options.py @@ -86,7 +86,9 @@ from . import exc if t.TYPE_CHECKING: - from typing import Self, TypeAlias + from typing import TypeAlias + + from typing_extensions import Self from libtmux._internal.constants import TerminalFeatures from libtmux.common import tmux_cmd @@ -513,7 +515,7 @@ def explode_complex( try: term, features = item.split(":", maxsplit=1) new_val[term] = features.split(":") - except Exception: + except Exception: # NOQA: PERF203 logger.warning( "tmux options parse failed", extra={"tmux_option_key": key}, @@ -540,7 +542,7 @@ def explode_complex( new_overrides[term][k] = int(v) if v.isdigit() else v elif feature: new_overrides[term][feature] = None - except Exception: + except Exception: # NOQA: PERF203 logger.warning( "tmux options parse failed", extra={"tmux_option_key": key}, @@ -559,7 +561,7 @@ def explode_complex( ): options[key] = {} new_aliases[alias] = command - except Exception: + except Exception: # NOQA: PERF203 logger.warning( "tmux options parse failed", extra={"tmux_option_key": key}, diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index fcbbc8d1af..4aa58baa1d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -33,8 +33,8 @@ from libtmux.options import OptionsMixin if t.TYPE_CHECKING: + import sys import types - from typing import Self from libtmux._internal.types import StrPath @@ -42,6 +42,11 @@ from .session import Session from .window import Window + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + logger = logging.getLogger(__name__) @@ -114,7 +119,13 @@ class Pane( server: Server def __enter__(self) -> Self: - """Accept destruction responsibility and enter the pane scope.""" + """Accept destruction responsibility and enter the pane scope. + + Returns + ------- + :class:`Pane` + The pane instance + """ previous = getattr(self, "_scope_owner", None) if previous is not None and not previous.closed: message = "this pane already has an active or failed cleanup scope" @@ -128,7 +139,17 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Destroy the accepted pane identity and retain paired failures.""" + """Destroy the accepted pane identity and retain paired failures. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the body exception, or ``None`` on normal exit. + exc_value : BaseException | None + The body exception, retained if cleanup also raises. + exc_tb : types.TracebackType | None + The traceback of the body exception, or ``None``. + """ owner = getattr(self, "_scope_owner", None) if owner is not None: owner.__exit__(exc_type, exc_value, exc_tb) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index 7ea3e5a637..ad888a3357 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -13,6 +13,7 @@ import pytest from libtmux import exc +from libtmux._compat import BaseExceptionGroup from libtmux._internal.control_mode import ControlMode from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX @@ -357,7 +358,7 @@ def fin() -> None: for server in created_servers: try: _reap_test_server(server) - except BaseException as error: # noqa: BLE001 + except BaseException as error: # noqa: BLE001, PERF203 failures.append(error) if failures: message = "test server cleanup failed" diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 3bea972ab3..3a74e12787 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -49,7 +49,9 @@ from .options import OptionsMixin if t.TYPE_CHECKING: - from typing import Self, TypeAlias + from typing import TypeAlias + + from typing_extensions import Self from libtmux._internal.types import StrPath diff --git a/src/libtmux/session.py b/src/libtmux/session.py index db0e00c395..85abfb2461 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -32,12 +32,17 @@ ) if t.TYPE_CHECKING: + import sys import types - from typing import Self from libtmux._internal.types import StrPath from libtmux.common import tmux_cmd + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + from .server import Server @@ -113,7 +118,13 @@ class Session( server: Server def __enter__(self) -> Self: - """Accept destruction responsibility and enter the session scope.""" + """Accept destruction responsibility and enter the session scope. + + Returns + ------- + :class:`Session` + The session instance + """ previous = getattr(self, "_scope_owner", None) if previous is not None and not previous.closed: message = "this session already has an active or failed cleanup scope" @@ -127,7 +138,17 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Destroy the accepted session identity and retain paired failures.""" + """Destroy the accepted session identity and retain paired failures. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the body exception, or ``None`` on normal exit. + exc_value : BaseException | None + The body exception, retained if cleanup also raises. + exc_tb : types.TracebackType | None + The traceback of the body exception, or ``None``. + """ owner = getattr(self, "_scope_owner", None) if owner is not None: owner.__exit__(exc_type, exc_value, exc_tb) diff --git a/src/libtmux/test/environment.py b/src/libtmux/test/environment.py index e2099ab3d4..a3f942d6f1 100644 --- a/src/libtmux/test/environment.py +++ b/src/libtmux/test/environment.py @@ -6,8 +6,13 @@ import typing as t if t.TYPE_CHECKING: + import sys import types - from typing import Self + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self class EnvironmentVarGuard: diff --git a/src/libtmux/test/random.py b/src/libtmux/test/random.py index be2f710eb3..e83037d211 100644 --- a/src/libtmux/test/random.py +++ b/src/libtmux/test/random.py @@ -10,10 +10,15 @@ ) if t.TYPE_CHECKING: # pragma: no cover + import sys # pragma: no cover + from libtmux.server import Server # pragma: no cover from libtmux.session import Session # pragma: no cover - # pragma: no cover + if sys.version_info >= (3, 11): # pragma: no cover + pass # pragma: no cover + else: # pragma: no cover + pass # pragma: no cover class RandomStrSequence: diff --git a/src/libtmux/test/retry.py b/src/libtmux/test/retry.py index a9d31eccc9..bad491171e 100644 --- a/src/libtmux/test/retry.py +++ b/src/libtmux/test/retry.py @@ -12,8 +12,12 @@ ) if t.TYPE_CHECKING: + import sys from collections.abc import Callable + if sys.version_info >= (3, 11): + pass + def retry_until( fun: Callable[[], bool], diff --git a/src/libtmux/test/temporary.py b/src/libtmux/test/temporary.py index bd381396ef..39522c3523 100644 --- a/src/libtmux/test/temporary.py +++ b/src/libtmux/test/temporary.py @@ -8,12 +8,16 @@ from libtmux.test.random import get_test_session_name, get_test_window_name if t.TYPE_CHECKING: + import sys from collections.abc import Generator from libtmux.server import Server from libtmux.session import Session from libtmux.window import Window + if sys.version_info >= (3, 11): + pass + @contextlib.contextmanager def temp_session( diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 0dd4c0133b..22799aa82f 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -32,8 +32,8 @@ from .options import OptionsMixin if t.TYPE_CHECKING: + import sys import types - from typing import Self from libtmux._internal.types import StrPath @@ -41,6 +41,11 @@ from .server import Server from .session import Session + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + logger = logging.getLogger(__name__) @@ -126,7 +131,13 @@ class Window( server: Server def __enter__(self) -> Self: - """Accept destruction responsibility and enter the window scope.""" + """Accept destruction responsibility and enter the window scope. + + Returns + ------- + :class:`Window` + The window instance + """ previous = getattr(self, "_scope_owner", None) if previous is not None and not previous.closed: message = "this window already has an active or failed cleanup scope" @@ -140,7 +151,17 @@ def __exit__( exc_value: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Destroy the accepted window identity and retain paired failures.""" + """Destroy the accepted window identity and retain paired failures. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the body exception, or ``None`` on normal exit. + exc_value : BaseException | None + The body exception, retained if cleanup also raises. + exc_tb : types.TracebackType | None + The traceback of the body exception, or ``None``. + """ owner = getattr(self, "_scope_owner", None) if owner is not None: owner.__exit__(exc_type, exc_value, exc_tb) diff --git a/tests/test_cleanup_scopes.py b/tests/test_cleanup_scopes.py index 0373336996..6f6641a6ed 100644 --- a/tests/test_cleanup_scopes.py +++ b/tests/test_cleanup_scopes.py @@ -7,6 +7,7 @@ import pytest from libtmux import Pane, Session, Window, exc +from libtmux._compat import BaseExceptionGroup if t.TYPE_CHECKING: from libtmux.common import tmux_cmd diff --git a/tests/test_creation_recovery.py b/tests/test_creation_recovery.py index e96e165c25..784be4c6cb 100644 --- a/tests/test_creation_recovery.py +++ b/tests/test_creation_recovery.py @@ -13,6 +13,7 @@ import libtmux.lifecycle as lifecycle_module import libtmux.server as server_module from libtmux import Pane, Server, Session, Window, exc +from libtmux._compat import BaseExceptionGroup from libtmux.lifecycle import CreationCleanupError, UnknownCreation from libtmux.neo import parse_output from libtmux.test.retry import retry_until @@ -422,3 +423,31 @@ def denied(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: pytest.fail("entered ownership after a failed acceptance query") assert caught.value is failure assert _ids(server, kind) == before + + +@pytest.mark.parametrize("failure", [KeyboardInterrupt(), SystemExit(3)]) +def test_interruption_without_receipt_retains_error_and_note( + server: Server, + monkeypatch: pytest.MonkeyPatch, + failure: BaseException, +) -> None: + """Interrupt dispatch before receipt capture to check the Python 3.10 fallback.""" + server.new_session(session_name="keeper") + command = server.cmd + + def interrupted(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + if kwargs.get("_on_completion") is not None: + raise failure + return command(cmd, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server, "cmd", interrupted) + with pytest.raises(type(failure)) as caught: + server.new_session(session_name="interrupted") + assert caught.value is failure + assert any( + "returned no creation receipt" in note + for note in getattr(failure, "__notes__", []) + ) + assert not server.has_session("interrupted") + assert server.has_session("keeper") diff --git a/tests/test_endpoint_defaults.py b/tests/test_endpoint_defaults.py index 716625f18e..4adb3004c0 100644 --- a/tests/test_endpoint_defaults.py +++ b/tests/test_endpoint_defaults.py @@ -10,6 +10,7 @@ import pytest from libtmux import Server, exc +from libtmux._compat import BaseExceptionGroup from libtmux._internal.control_mode import ControlMode from libtmux.pytest_plugin import _reap_test_server @@ -296,7 +297,8 @@ def failed_cleanup(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: return command(cmd, *args, **kwargs) with monkeypatch.context() as patch: - patch.setattr(server, "cmd", failed_cleanup) + assert session._creation_receipt is not None + patch.setattr(session._creation_receipt.client, "cmd", failed_cleanup) with pytest.raises(BaseExceptionGroup) as caught, session: raise body_error assert caught.value.exceptions == (body_error, cleanup_error) diff --git a/tests/test_example_harness.py b/tests/test_example_harness.py index a5a624887e..de07ed4ad9 100644 --- a/tests/test_example_harness.py +++ b/tests/test_example_harness.py @@ -12,6 +12,7 @@ from libtmux import Server from libtmux.pytest_plugin import _reap_test_server +from libtmux.test.retry import retry_until @pytest.mark.parametrize("selector", ["path", "name"]) @@ -42,12 +43,14 @@ def test_unchanged_session_example( read_fd, write_fd = os.pipe() os.close(read_fd) pid_fd = None + pid = None try: anchor = server.new_session( session_name="harness_anchor", window_command="sleep 60" ) pid = int(server.cmd("display-message", "-p", "#{pid}").stdout[0]) - pid_fd = os.pidfd_open(pid) + if hasattr(os, "pidfd_open"): + pid_fd = os.pidfd_open(pid) command = [sys.executable, str(example)] result = subprocess.run( command, @@ -77,6 +80,17 @@ def test_unchanged_session_example( poller = select.poll() poller.register(pid_fd, select.POLLIN) assert any(events & select.POLLIN for _, events in poller.poll(5000)) + elif pid is not None: + + def process_exited() -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + return False + + retry_until(process_exited, seconds=5) + assert process_exited() result_after = server.cmd("list-sessions") assert result_after.returncode != 0 assert any( diff --git a/tests/test_options.py b/tests/test_options.py index a9b39070df..bc72061806 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -23,7 +23,7 @@ from libtmux.pane import Pane if t.TYPE_CHECKING: - from typing import LiteralString + from typing_extensions import LiteralString from libtmux.server import Server from libtmux.session import Session diff --git a/tests/test_ownership.py b/tests/test_ownership.py index 7089d55cf4..7c2dd7ea49 100644 --- a/tests/test_ownership.py +++ b/tests/test_ownership.py @@ -14,6 +14,7 @@ import pytest from libtmux import Pane, Server, Session, Window, exc +from libtmux._compat import BaseExceptionGroup if t.TYPE_CHECKING: from libtmux.common import tmux_cmd diff --git a/uv.lock b/uv.lock index 85ec9448b5..1158445203 100644 --- a/uv.lock +++ b/uv.lock @@ -1,9 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.11, <4.0" +requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", ] [options] @@ -55,6 +56,7 @@ name = "anyio" version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] @@ -164,6 +166,22 @@ version = "3.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3", size = 373348, upload-time = "2026-09-30T04:34:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e", size = 260860, upload-time = "2026-09-30T04:34:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5", size = 246491, upload-time = "2026-09-30T04:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa", size = 290875, upload-time = "2026-09-30T04:34:47.691Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320", size = 286987, upload-time = "2026-09-30T04:34:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249", size = 271105, upload-time = "2026-09-30T04:34:50.957Z" }, + { url = "https://files.pythonhosted.org/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e", size = 269833, upload-time = "2026-09-30T04:34:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9", size = 263048, upload-time = "2026-09-30T04:34:54.013Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5", size = 249237, upload-time = "2026-09-30T04:34:55.373Z" }, + { url = "https://files.pythonhosted.org/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c", size = 290756, upload-time = "2026-09-30T04:34:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c", size = 269026, upload-time = "2026-09-30T04:34:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be", size = 287601, upload-time = "2026-09-30T04:34:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45", size = 272660, upload-time = "2026-09-30T04:35:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4", size = 187301, upload-time = "2026-09-30T04:35:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275", size = 215446, upload-time = "2026-09-30T04:35:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c", size = 203389, upload-time = "2026-09-30T04:35:05.577Z" }, { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, @@ -342,6 +360,20 @@ version = "7.16.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, @@ -464,6 +496,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -481,7 +525,8 @@ dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/f9/b9b10457b84125197d66770b85df90d61c49ecac1d575f40d5ce2969e2ed/gp_furo_theme-0.1.0a39.tar.gz", hash = "sha256:acf935869b12658c9224e4a73b972c9e979e71d91a736198e41a28d0811ae895", size = 34334, upload-time = "2026-09-06T17:25:58.44Z" } @@ -495,7 +540,8 @@ version = "0.0.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, - { name = "myst-parser" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/e3/947c93526c57541d7876b3c0177372220a904b4d8fa4024c54a47506a1c6/gp_libs-0.0.19.tar.gz", hash = "sha256:26fa76740dcfcc37b071903572b3fb3bfc71d2b545e07e7e63977838a3b2393e", size = 16022, upload-time = "2026-07-14T01:21:11.509Z" } wheels = [ @@ -510,11 +556,14 @@ dependencies = [ { name = "docutils" }, { name = "gp-libs" }, { name = "linkify-it-py" }, - { name = "myst-parser" }, - { name = "sphinx" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-typehints-gp" }, { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-fonts" }, { name = "sphinx-gp-llms" }, { name = "sphinx-gp-opengraph" }, @@ -582,6 +631,20 @@ version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, @@ -696,6 +759,9 @@ wheels = [ name = "libtmux" version = "0.62.0" source = { editable = "." } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, +] [package.dev-dependencies] coverage = [ @@ -706,6 +772,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "exceptiongroup" }, { name = "gp-libs" }, { name = "gp-sphinx" }, { name = "mypy" }, @@ -716,20 +783,25 @@ dev = [ { name = "pytest-watcher" }, { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sphinx-autobuild" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, { name = "types-docutils" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ { name = "gp-sphinx" }, - { name = "sphinx-autobuild" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, ] lint = [ + { name = "exceptiongroup" }, { name = "mypy" }, { name = "ruff" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ { name = "gp-libs" }, @@ -737,9 +809,11 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [package.metadata] +requires-dist = [{ name = "exceptiongroup", marker = "python_full_version < '3.11'", specifier = ">=1.2" }] [package.metadata.requires-dev] coverage = [ @@ -750,6 +824,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "exceptiongroup", specifier = ">=1.2" }, { name = "gp-libs", specifier = ">=0.0.19" }, { name = "gp-sphinx", specifier = "==0.1.0a39" }, { name = "mypy" }, @@ -764,6 +839,7 @@ dev = [ { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a39" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.1.0a39" }, { name = "types-docutils" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ { name = "gp-sphinx", specifier = "==0.1.0a39" }, @@ -772,8 +848,10 @@ docs = [ { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.1.0a39" }, ] lint = [ + { name = "exceptiongroup", specifier = ">=1.2" }, { name = "mypy" }, { name = "ruff", specifier = ">=0.16.1" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ { name = "gp-libs", specifier = ">=0.0.19" }, @@ -781,6 +859,7 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [[package]] @@ -792,10 +871,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] dependencies = [ { name = "mdurl" }, ] @@ -810,6 +908,17 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -883,7 +992,8 @@ name = "mdit-py-plugins" version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } wheels = [ @@ -908,10 +1018,17 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, @@ -968,17 +1085,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + [[package]] name = "myst-parser" version = "5.1.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] dependencies = [ { name = "docutils" }, { name = "jinja2" }, - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, { name = "mdit-py-plugins" }, { name = "pyyaml" }, - { name = "sphinx" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } wheels = [ @@ -1027,10 +1168,12 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1081,6 +1224,7 @@ name = "pytest-watcher" version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "watchdog" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } @@ -1107,6 +1251,15 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1235,10 +1388,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, ] +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + [[package]] name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] dependencies = [ { name = "alabaster" }, { name = "babel" }, @@ -1263,17 +1451,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, ] +[[package]] +name = "sphinx-autobuild" +version = "2024.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, +] + [[package]] name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] dependencies = [ { name = "colorama" }, - { name = "sphinx" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, { name = "starlette" }, { name = "uvicorn" }, { name = "watchfiles" }, - { name = "websockets" }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -1285,7 +1497,8 @@ name = "sphinx-autodoc-api-style" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, ] @@ -1300,7 +1513,8 @@ version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-typehints-gp" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, @@ -1315,7 +1529,8 @@ name = "sphinx-autodoc-typehints-gp" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/0e/1a9961998320c8669f152926a6c3960cb9035418f3c6d878b9e31f9f868b/sphinx_autodoc_typehints_gp-0.1.0a39.tar.gz", hash = "sha256:4eaf00be572a2ea55e1c907ce1233c9cdca90e00210171d54d6538722e3af21e", size = 52542, upload-time = "2026-09-06T17:26:05.541Z" } wheels = [ @@ -1327,7 +1542,8 @@ name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ @@ -1339,19 +1555,39 @@ name = "sphinx-copybutton" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, ] +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + [[package]] name = "sphinx-design" version = "0.7.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } wheels = [ @@ -1363,7 +1599,8 @@ name = "sphinx-fonts" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/0c/a11bf54fef30a72f2d823cd62c3db474e345d2373bbe0cd76cc1aca9aede/sphinx_fonts-0.1.0a39.tar.gz", hash = "sha256:be2ceda9adb92023ab85ca232a43275d17bed8d844a856e80233eaa8b69b90b1", size = 5874, upload-time = "2026-09-06T17:26:06.443Z" } wheels = [ @@ -1375,7 +1612,8 @@ name = "sphinx-gp-llms" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/62/79/9c14cf5bd35818aefa1b867240528f6df371531ba91aa1a4508f58a938bd/sphinx_gp_llms-0.1.0a39.tar.gz", hash = "sha256:d7cbd5dbc02e4438a896baab3c6bc7782ab5cf049fab13991882cf8cf6ea2a14", size = 10244, upload-time = "2026-09-06T17:26:08.05Z" } wheels = [ @@ -1387,7 +1625,8 @@ name = "sphinx-gp-opengraph" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/db/3369e9b30c5ccb0387fbb555c84a0613ccb8cc6228962520c7f9e64b9635/sphinx_gp_opengraph-0.1.0a39.tar.gz", hash = "sha256:a77a1152dd708622a4c3b880c5ec2d3fde22c3bf1b4c673c9fd69ac49cf5ba56", size = 11952, upload-time = "2026-09-06T17:26:09.836Z" } wheels = [ @@ -1399,7 +1638,8 @@ name = "sphinx-gp-sitemap" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/37/96ac86dae10acdd0d8bbe604eec7a0d162b8999d39cfe6733162b7aa928a/sphinx_gp_sitemap-0.1.0a39.tar.gz", hash = "sha256:8e35f0e50b44d6e4c03138e7c0388104c25b10ac51fc5cac48752138e1d0123c", size = 9955, upload-time = "2026-09-06T17:26:10.665Z" } wheels = [ @@ -1412,7 +1652,8 @@ version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gp-furo-theme" }, - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/c6/285a25231934b931981c4bffe35e3c2d8085500c16fb65c3410394ea0aee/sphinx_gp_theme-0.1.0a39.tar.gz", hash = "sha256:37c655eb9f186e2c67aa5ee7511804500a6eb5d077ced45f087e4a0167dc390e", size = 18481, upload-time = "2026-09-06T17:26:11.432Z" } wheels = [ @@ -1424,7 +1665,8 @@ name = "sphinx-inline-tabs" version = "2025.12.21.14" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/6a/f39bde46a79b80a9983233d99b773bd24b468bdd9c1e87acb46ff69af441/sphinx_inline_tabs-2025.12.21.14.tar.gz", hash = "sha256:c71a75800326e613fb4e410eed92a0934214741326aca9897c18018b9f968cb6", size = 45572, upload-time = "2025-12-21T13:30:51.071Z" } wheels = [ @@ -1436,7 +1678,8 @@ name = "sphinx-ux-autodoc-layout" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/25/4fbe0e13d68b1578c98d2d78d7ebc3f8853e13d3b455c31c886f04f1f6b0/sphinx_ux_autodoc_layout-0.1.0a39.tar.gz", hash = "sha256:f1856fe0739c1576f3665e20b30642ccfcee3c6e76bce60572fddc843d88aa2a", size = 30863, upload-time = "2026-09-06T17:26:12.42Z" } wheels = [ @@ -1448,7 +1691,8 @@ name = "sphinx-ux-badges" version = "0.1.0a39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/50/bccae9abbb279dcf11632eb36f18e0599e468ca46559062542d817e28325/sphinx_ux_badges-0.1.0a39.tar.gz", hash = "sha256:ed4337e11483a82acbf2e5fd7749a9867afca459901f41a890263eca18fe95e1", size = 16890, upload-time = "2026-09-06T17:26:13.316Z" } wheels = [ @@ -1514,7 +1758,8 @@ name = "sphinxext-rediraffe" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/a9/ab13d156049eea633f992424f3e92cb40e3f1b606bb6d01d40a27457d38a/sphinxext_rediraffe-0.3.0.tar.gz", hash = "sha256:f319b3ccb7c3c3b6f63ffa6fd3eeb171b6d272df55075a9e84364394f391f507", size = 22114, upload-time = "2025-09-28T15:31:53.641Z" } wheels = [ @@ -1622,6 +1867,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } wheels = [ @@ -1634,6 +1880,9 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, @@ -1643,6 +1892,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -1698,10 +1949,133 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" }, ] +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, + { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, + { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, + { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, + { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + [[package]] name = "websockets" version = "17.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" }, From 2e2915a23ca09b25676e7661524ed4f6043e53ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 18:33:55 -0500 Subject: [PATCH 5/7] Command(fix): Bound failed client output draining why: A descendant retaining an output pipe makes the post-timeout communicate call wait beyond the command deadline. Receipt capture must still finish before creation rollback handles an interruption. what: - Give draining and reaping finite cleanup intervals - Let the receipt worker observe main-thread interruption between reads - Retain partial output, text decoding and paired failures - Test inherited pipes and real creation rollback on Python 3.10 and 3.12 --- CHANGES | 2 +- docs/topics/context_managers.md | 2 + src/libtmux/common.py | 71 ++++++++++++++++++++++++++---- src/libtmux/server.py | 2 + tests/test_common.py | 76 +++++++++++++++++++++++++++++++++ tests/test_creation_recovery.py | 38 ++++++++++++++--- 6 files changed, 176 insertions(+), 15 deletions(-) diff --git a/CHANGES b/CHANGES index 3b41d25a61..85696b1baa 100644 --- a/CHANGES +++ b/CHANGES @@ -84,7 +84,7 @@ Session, window and pane creation retain the creating daemon's token and object Call `.own()` on a server, session, window or pane to accept destruction responsibility. Owners retain the endpoint, daemon token and object ID, refuse replacement daemons, preserve paired body/cleanup failures and permit retry after failed teardown. Adoption reserves the server option `@libtmux_owner_generation`; invalid existing values fail acceptance. Session, window and pane contexts use the same ownership behavior. See {doc}`topics/context_managers` for complete examples. -`resource.own(timeout=...)` bounds acceptance and cleanup, including time spent waiting for another close call. `Server.cmd(timeout=...)` terminates and reaps a timed-out client. A timeout cannot undo a remote operation that the client already dispatched. +`resource.own(timeout=...)` bounds acceptance and cleanup, including time spent waiting for another close call. `Server.cmd(timeout=...)` terminates and reaps a timed-out client. Output draining and reaping each allow up to 0.1 additional seconds, even when another process holds the client's output pipes. A timeout cannot undo a remote operation that the client already dispatched. #### Client environment isolation and object cleanup diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index 20a909153e..db48da15ad 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -145,6 +145,8 @@ If the body and teardown both fail, `BaseExceptionGroup` retains the body error `resource.own(timeout=5.0)` bounds acceptance and each cleanup attempt, including time spent waiting for another cleanup call. A command timeout kills and reaps the tmux client process; it cannot undo a remote operation that was already dispatched. The owner remains open for inspection and retry. Garbage collection releases local observation descriptors and does not destroy remote resources. These APIs are synchronous; they do not provide an asynchronous task-cancellation supervisor. +After timeout or interruption, output draining and client reaping each allow up to 0.1 seconds. If another process keeps the output pipes open, the client closes its readers and retains the bytes already captured. Cleanup targets the launched client process; it does not terminate other processes holding those pipes. Creation receipt capture observes an interruption within its next 0.05-second read interval before starting that cleanup. + ## Creation failures `Server.new_session()`, `Session.new_window()` and `Pane.split()` retain a creation receipt before decoding the returned object. The receipt contains the endpoint, daemon generation and new object ID. A failed snapshot, parser, context entry or final generation check triggers rollback of that known resource. The same rule applies when tmux returns an ID alongside a nonzero client status. A timeout or Ctrl-C kills and reaps the client, retains readable receipt bytes and attempts that rollback before re-raising the original failure. Rollback uses the original daemon identity and cannot destroy a replacement daemon. diff --git a/src/libtmux/common.py b/src/libtmux/common.py index eadca57a07..8c87762782 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -16,6 +16,8 @@ import shutil import subprocess import sys +import threading +import time import typing as t from . import exc @@ -411,8 +413,10 @@ def __init__( else: # SIGINT must not consume receipt bytes between read and append. # A worker keeps draining while the main thread handles interruption. + cancelled = threading.Event() + def receive() -> BaseException | None: - stdout, stderr, failure = self._communicate(timeout) + stdout, stderr, failure = self._communicate(timeout, cancelled) self._record_output(stdout, stderr) _on_completion(self) return failure @@ -422,8 +426,13 @@ def receive() -> BaseException | None: try: interrupted = reader.result() except BaseException as failure: # noqa: BLE001 - re-raised below - self.process.kill() - reader.result() + cancelled.set() + cleanup_error = reader.result() + if cleanup_error is not None: + message = "client interruption and cleanup both failed" + raise BaseExceptionGroup( + message, [failure, cleanup_error] + ) from None interrupted = failure except FileNotFoundError: raise exc.TmuxCommandNotFound from None @@ -465,16 +474,60 @@ def _record_output(self, stdout: str, stderr: str) -> None: self.stdout = stdout_split def _communicate( - self, timeout: float | None + self, timeout: float | None, cancelled: threading.Event | None = None ) -> tuple[str, str, BaseException | None]: - """Reap a failed client before handing off its partial stdout receipt.""" + """Bound failed-client cleanup even when another process holds its pipes.""" + deadline = None if timeout is None else time.monotonic() + timeout + failures: list[BaseException] = [] try: - stdout, stderr = self.process.communicate(timeout=timeout) + while cancelled is None or not cancelled.is_set(): + remaining = ( + None if deadline is None else max(0.0, deadline - time.monotonic()) + ) + interval = remaining + if cancelled is not None: + interval = 0.05 if remaining is None else min(0.05, remaining) + try: + stdout, stderr = self.process.communicate(timeout=interval) + except subprocess.TimeoutExpired as failure: + if cancelled is None or ( + deadline is not None and time.monotonic() >= deadline + ): + if timeout is not None: + failure.timeout = timeout + raise + else: + return stdout, stderr, None except BaseException as failure: # noqa: BLE001 - returned for caller to raise + failures.append(failure) + try: self.process.kill() - stdout, stderr = self.process.communicate() - return stdout, stderr, failure - return stdout, stderr, None + except OSError as failure: + failures.append(failure) + + stdout, stderr = "", "" + try: + stdout, stderr = self.process.communicate(timeout=0.1) + except subprocess.TimeoutExpired as partial: + # TimeoutExpired retains bytes even for text-mode Popen streams. + stdout = (partial.output or b"").decode("utf-8", "backslashreplace") + stderr = (partial.stderr or b"").decode("utf-8", "backslashreplace") + stdout = stdout.replace("\r\n", "\n").replace("\r", "\n") + stderr = stderr.replace("\r\n", "\n").replace("\r", "\n") + except BaseException as failure: # noqa: BLE001 - retained with initial failure + failures.append(failure) + finally: + for stream in (self.process.stdout, self.process.stderr): + if stream is not None: + stream.close() + try: + self.process.wait(timeout=0.1) + except (OSError, subprocess.TimeoutExpired) as failure: + failures.append(failure) + if len(failures) > 1: + message = "client command and cleanup failed" + return stdout, stderr, BaseExceptionGroup(message, failures) + return stdout, stderr, failures[0] if failures else None class _TmuxVersionUnavailable(Exception): diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 3a74e12787..047201db69 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -475,6 +475,8 @@ def cmd( timeout : float, optional Maximum seconds for the client command. A timeout terminates and reaps the client process; a dispatched remote effect may be uncertain. + Output draining and reaping each allow up to 0.1 additional seconds. + Other processes holding the client's output pipes are not terminated. Returns ------- diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d573..61c9f191c2 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -4,8 +4,12 @@ import locale import logging +import os +import pathlib import re +import subprocess import sys +import time import typing as t import pytest @@ -35,6 +39,78 @@ version_regex = re.compile(r"([0-9]\.[0-9])|(master)") +@pytest.mark.parametrize("retain_completion", [False, True]) +@pytest.mark.parametrize("interrupt", [False, True]) +@pytest.mark.parametrize("detached", [False, True]) +@pytest.mark.skipif(sys.platform != "linux", reason="observes descendant exit in /proc") +def test_failed_client_does_not_wait_for_inherited_output_pipes( + tmp_path: pathlib.Path, + retain_completion: bool, + interrupt: bool, + detached: bool, +) -> None: + """Reap the client and retain receipts without waiting for another pipe owner.""" + wrapper = tmp_path / "tmux-client" + client_pid = tmp_path / "client-pid" + child_pid = tmp_path / "child-pid" + wrapper.write_text( + f"#!{sys.executable}\n" + "import os, pathlib, signal, time\n" + f"pathlib.Path({str(client_pid)!r}).write_text(str(os.getpid()))\n" + "child = os.fork()\n" + "if child == 0:\n" + f" if {detached!r}: os.setsid()\n" + " time.sleep(2)\n" + " os._exit(0)\n" + f"pathlib.Path({str(child_pid)!r}).write_text(str(child))\n" + "os.write(1, b'completed receipt\\r\\n\\xe2\\x98\\x83\\xff\\r')\n" + "os.write(2, b'client diagnostic\\r\\n')\n" + f"if {interrupt!r}:\n" + " time.sleep(0.05)\n" + " os.kill(os.getppid(), signal.SIGINT)\n" + "time.sleep(30)\n" + ) + wrapper.chmod(0o700) + observed: list[tmux_cmd] = [] + started = time.monotonic() + try: + with pytest.raises( + KeyboardInterrupt if interrupt else subprocess.TimeoutExpired + ) as caught: + tmux_cmd( + tmux_bin=str(wrapper), + timeout=None if interrupt else 0.5, + _on_completion=observed.append if retain_completion else None, + ) + elapsed = time.monotonic() - started + assert elapsed < 1.2, f"command waited for a pipe owner: {elapsed:.3f}s" + if not interrupt: + assert isinstance(caught.value, subprocess.TimeoutExpired) + assert caught.value.timeout == 0.5 + assert b"completed receipt" in caught.value.output + with pytest.raises(ChildProcessError): + os.waitpid(int(client_pid.read_text()), os.WNOHANG) + if retain_completion: + assert len(observed) == 1 + assert observed[0].stdout == ["completed receipt", "\u2603\\xff"] + assert observed[0].stderr == ["client diagnostic"] + assert observed[0].returncode is not None + finally: + # The finite-lived descendant owns no tmux resources. Observe its exit + # before pytest removes the wrapper directory, including on a red test. + if child_pid.exists(): + status = pathlib.Path(f"/proc/{int(child_pid.read_text())}/stat") + deadline = time.monotonic() + 4 + while status.exists(): + try: + if status.read_text().rsplit(")", 1)[1].split()[0] == "Z": + break + except FileNotFoundError: + break + assert time.monotonic() < deadline, "pipe owner did not exit" + time.sleep(0.01) + + def test_has_version() -> None: """Test has_version().""" assert has_version(str(get_version())) diff --git a/tests/test_creation_recovery.py b/tests/test_creation_recovery.py index 784be4c6cb..94044016cd 100644 --- a/tests/test_creation_recovery.py +++ b/tests/test_creation_recovery.py @@ -6,6 +6,7 @@ import pathlib import subprocess import sys +import time import typing as t import pytest @@ -76,18 +77,23 @@ def fail_readback(*args: t.Any, **kwargs: t.Any) -> t.NoReturn: @pytest.mark.parametrize("kind", ["session", "window", "pane"]) @pytest.mark.parametrize("interrupt", [False, True]) +@pytest.mark.parametrize("hold_pipe", [False, True]) def test_interrupted_client_retains_completed_creation_receipt( server: Server, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, kind: str, interrupt: bool, + hold_pipe: bool, ) -> None: """Timeout or interruption after stdout still recovers the reported ID.""" + if hold_pipe and sys.platform != "linux": + pytest.skip("observes descendant exit in /proc") server.new_session(session_name="keeper") before = _ids(server, kind) wrapper = tmp_path / "tmux-client" pid_file = tmp_path / "wrapper-pid" + child_pid = tmp_path / "pipe-owner-pid" binary = server._require_tmux_bin() operation = { "session": "new-session", @@ -104,6 +110,13 @@ def test_interrupted_client_retains_completed_creation_receipt( " os.write(1, result.stdout)\n" " os.write(2, result.stderr)\n" f" pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid()))\n" + f" if {hold_pipe!r}:\n" + " child = os.fork()\n" + " if child == 0:\n" + " os.setsid()\n" + " time.sleep(3)\n" + " os._exit(0)\n" + f" pathlib.Path({str(child_pid)!r}).write_text(str(child))\n" f" if {interrupt!r}: os.kill(os.getppid(), signal.SIGINT)\n" " time.sleep(30)\n" f"os.execv({binary!r}, [{binary!r}, *sys.argv[1:]])\n" @@ -119,11 +132,26 @@ def short_deadline(cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: monkeypatch.setattr(client, "cmd", short_deadline) failure = KeyboardInterrupt if interrupt else subprocess.TimeoutExpired - with pytest.raises(failure): - _create(client, kind) - assert _ids(server, kind) == before - with pytest.raises(ChildProcessError): - os.waitpid(int(pid_file.read_text()), os.WNOHANG) + started = time.monotonic() + try: + with pytest.raises(failure): + _create(client, kind) + assert time.monotonic() - started < 2.0 + assert _ids(server, kind) == before + assert server.has_session("keeper") + with pytest.raises(ChildProcessError): + os.waitpid(int(pid_file.read_text()), os.WNOHANG) + finally: + if child_pid.exists(): + status = pathlib.Path(f"/proc/{int(child_pid.read_text())}/stat") + + def descendant_exited() -> bool: + try: + return status.read_text().rsplit(")", 1)[1].split()[0] == "Z" + except FileNotFoundError: + return True + + assert retry_until(descendant_exited, seconds=4, interval=0.01) @pytest.mark.parametrize("kind", ["session", "window", "pane"]) From d6d992042d90e1819681630087fdf46c8be35a05 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 18:44:45 -0500 Subject: [PATCH 6/7] Command(fix): Retain errors from reader cleanup why: A pipe-reader close failure can replace an interruption and skip client reaping. A close failure inside communicate can also discard output already captured before the timeout. what: - Retain the latest captured output across drain and close failures - Close the remaining reader and attempt the bounded client wait - Preserve the original failure alongside each cleanup failure - Exercise both readers with timeout, interruption and inherited pipes --- src/libtmux/common.py | 27 ++++++++---- tests/test_common.py | 96 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 8c87762782..bbd8accc70 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -479,6 +479,7 @@ def _communicate( """Bound failed-client cleanup even when another process holds its pipes.""" deadline = None if timeout is None else time.monotonic() + timeout failures: list[BaseException] = [] + partial_output: subprocess.TimeoutExpired | None = None try: while cancelled is None or not cancelled.is_set(): remaining = ( @@ -490,6 +491,7 @@ def _communicate( try: stdout, stderr = self.process.communicate(timeout=interval) except subprocess.TimeoutExpired as failure: + partial_output = failure if cancelled is None or ( deadline is not None and time.monotonic() >= deadline ): @@ -505,25 +507,34 @@ def _communicate( except OSError as failure: failures.append(failure) - stdout, stderr = "", "" + output: tuple[str, str] | None = None try: - stdout, stderr = self.process.communicate(timeout=0.1) + output = self.process.communicate(timeout=0.1) except subprocess.TimeoutExpired as partial: - # TimeoutExpired retains bytes even for text-mode Popen streams. - stdout = (partial.output or b"").decode("utf-8", "backslashreplace") - stderr = (partial.stderr or b"").decode("utf-8", "backslashreplace") - stdout = stdout.replace("\r\n", "\n").replace("\r", "\n") - stderr = stderr.replace("\r\n", "\n").replace("\r", "\n") + partial_output = partial except BaseException as failure: # noqa: BLE001 - retained with initial failure failures.append(failure) finally: for stream in (self.process.stdout, self.process.stderr): if stream is not None: - stream.close() + try: + stream.close() + except BaseException as failure: # noqa: BLE001 - preserve receipt + failures.append(failure) try: self.process.wait(timeout=0.1) except (OSError, subprocess.TimeoutExpired) as failure: failures.append(failure) + if output is None: + # TimeoutExpired retains bytes even for text-mode Popen streams. + raw_stdout = partial_output.output if partial_output is not None else None + raw_stderr = partial_output.stderr if partial_output is not None else None + stdout = (raw_stdout or b"").decode("utf-8", "backslashreplace") + stderr = (raw_stderr or b"").decode("utf-8", "backslashreplace") + stdout = stdout.replace("\r\n", "\n").replace("\r", "\n") + stderr = stderr.replace("\r\n", "\n").replace("\r", "\n") + else: + stdout, stderr = output if len(failures) > 1: message = "client command and cleanup failed" return stdout, stderr, BaseExceptionGroup(message, failures) diff --git a/tests/test_common.py b/tests/test_common.py index 61c9f191c2..a594dde676 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -16,7 +16,7 @@ import libtmux from libtmux import exc -from libtmux._compat import LooseVersion +from libtmux._compat import BaseExceptionGroup, LooseVersion from libtmux.common import ( TMUX_MAX_VERSION, TMUX_MIN_VERSION, @@ -111,6 +111,100 @@ def test_failed_client_does_not_wait_for_inherited_output_pipes( time.sleep(0.01) +@pytest.mark.parametrize("stream_name", ["stdout", "stderr"]) +@pytest.mark.parametrize("interrupt", [False, True]) +@pytest.mark.parametrize("hold_pipe", [False, True]) +@pytest.mark.skipif(sys.platform != "linux", reason="observes descendant exit in /proc") +def test_reader_close_failure_retains_receipt_and_reaps_client( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + stream_name: str, + interrupt: bool, + hold_pipe: bool, +) -> None: + """Inject a close error after a real client's timeout or interruption.""" + wrapper = tmp_path / "tmux-client" + child_pid = tmp_path / "child-pid" + wrapper.write_text( + f"#!{sys.executable}\n" + "import os, pathlib, signal, time\n" + "os.write(1, b'completed receipt\\n')\n" + "os.write(2, b'client diagnostic\\n')\n" + f"if {hold_pipe!r}:\n" + " child = os.fork()\n" + " if child == 0:\n" + " time.sleep(1)\n" + " os._exit(0)\n" + f" pathlib.Path({str(child_pid)!r}).write_text(str(child))\n" + f"if {interrupt!r}:\n" + " time.sleep(0.1)\n" + " os.kill(os.getppid(), signal.SIGINT)\n" + "time.sleep(30)\n" + ) + wrapper.chmod(0o700) + processes: list[subprocess.Popen[str]] = [] + original_popen = subprocess.Popen + close_error = OSError("reader close failed") + + def launch(*args: t.Any, **kwargs: t.Any) -> subprocess.Popen[str]: + process = original_popen(*args, **kwargs) + processes.append(process) + stream = getattr(process, stream_name) + original_close = stream.close + failed = False + + def close() -> None: + nonlocal failed + if not failed: + failed = True + raise close_error + original_close() + + monkeypatch.setattr(stream, "close", close) + return process + + monkeypatch.setattr(subprocess, "Popen", launch) + observed: list[tmux_cmd] = [] + try: + with pytest.raises(BaseExceptionGroup) as caught: + tmux_cmd( + tmux_bin=str(wrapper), + timeout=None if interrupt else 0.3, + _on_completion=observed.append, + ) + assert len(caught.value.exceptions) == 2 + assert isinstance( + caught.value.exceptions[0], + KeyboardInterrupt if interrupt else subprocess.TimeoutExpired, + ) + assert caught.value.exceptions[1] is close_error + assert len(observed) == 1 + assert observed[0].stdout == ["completed receipt"] + assert observed[0].stderr == ["client diagnostic"] + assert observed[0].returncode is not None + with pytest.raises(ChildProcessError): + os.waitpid(processes[0].pid, os.WNOHANG) + finally: + for process in processes: + if process.returncode is None: + process.kill() + process.wait(timeout=2) + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + if child_pid.exists(): + status = pathlib.Path(f"/proc/{int(child_pid.read_text())}/stat") + deadline = time.monotonic() + 3 + while status.exists(): + try: + if status.read_text().rsplit(")", 1)[1].split()[0] == "Z": + break + except FileNotFoundError: + break + assert time.monotonic() < deadline, "pipe owner did not exit" + time.sleep(0.01) + + def test_has_version() -> None: """Test has_version().""" assert has_version(str(get_version())) From 2a1de09befc9382213e93e9ec12f6b23a10890a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 9 Oct 2026 18:55:22 -0500 Subject: [PATCH 7/7] Command(fix): Keep output through reader failures why: A close error during the final subprocess drain can discard a receipt received after the preceding timeout snapshot, preventing rollback of a known creation. what: - Retain bytes with a selector reader before closing output streams - Keep bounded cancellation cleanup, grouped errors and client reaping - Exercise late output with both reader errors, timeout and Ctrl-C --- src/libtmux/common.py | 110 +++++++++++++++++++++++++----------------- tests/test_common.py | 24 +++++++-- 2 files changed, 84 insertions(+), 50 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index bbd8accc70..51117e8cbd 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -8,10 +8,12 @@ from __future__ import annotations import concurrent.futures +import contextlib import functools import logging import os import re +import selectors import shlex import shutil import subprocess @@ -479,62 +481,80 @@ def _communicate( """Bound failed-client cleanup even when another process holds its pipes.""" deadline = None if timeout is None else time.monotonic() + timeout failures: list[BaseException] = [] - partial_output: subprocess.TimeoutExpired | None = None + output = (bytearray(), bytearray()) + selector: selectors.BaseSelector | None = None + completed = False + + def read_ready(reader: selectors.BaseSelector, interval: float | None) -> None: + for key, _ in reader.select(interval): + chunk = os.read(key.fd, 32768) + if chunk: + # Preserve receipt bytes before EOF handling can fail. + output[key.data].extend(chunk) + else: + reader.unregister(key.fd) + try: - while cancelled is None or not cancelled.is_set(): - remaining = ( - None if deadline is None else max(0.0, deadline - time.monotonic()) - ) + selector = selectors.DefaultSelector() + for index, stream in enumerate((self.process.stdout, self.process.stderr)): + if stream is not None: + selector.register(stream, selectors.EVENT_READ, index) + while selector.get_map() or self.process.poll() is None: + if cancelled is not None and cancelled.is_set(): + break + remaining = None if deadline is None else deadline - time.monotonic() + if timeout is not None and remaining is not None and remaining <= 0: + failures.append(subprocess.TimeoutExpired(self.cmd, timeout)) + break interval = remaining if cancelled is not None: interval = 0.05 if remaining is None else min(0.05, remaining) - try: - stdout, stderr = self.process.communicate(timeout=interval) - except subprocess.TimeoutExpired as failure: - partial_output = failure - if cancelled is None or ( - deadline is not None and time.monotonic() >= deadline - ): - if timeout is not None: - failure.timeout = timeout - raise + if selector.get_map(): + read_ready(selector, interval) else: - return stdout, stderr, None + with contextlib.suppress(subprocess.TimeoutExpired): + self.process.wait(timeout=interval) + else: + completed = True except BaseException as failure: # noqa: BLE001 - returned for caller to raise failures.append(failure) - try: - self.process.kill() - except OSError as failure: - failures.append(failure) - output: tuple[str, str] | None = None - try: - output = self.process.communicate(timeout=0.1) - except subprocess.TimeoutExpired as partial: - partial_output = partial - except BaseException as failure: # noqa: BLE001 - retained with initial failure - failures.append(failure) - finally: - for stream in (self.process.stdout, self.process.stderr): - if stream is not None: - try: - stream.close() - except BaseException as failure: # noqa: BLE001 - preserve receipt - failures.append(failure) + if not completed: + try: + self.process.kill() + except BaseException as failure: # noqa: BLE001 - preserve initial failure + failures.append(failure) + if selector is not None: + drain_deadline = time.monotonic() + 0.1 + try: + while selector.get_map(): + remaining = drain_deadline - time.monotonic() + if remaining <= 0: + break + read_ready(selector, remaining) + except BaseException as failure: # noqa: BLE001 - preserve receipt + failures.append(failure) + + for resource in (self.process.stdout, self.process.stderr, selector): + if resource is not None: + try: + resource.close() + except BaseException as failure: # noqa: BLE001 - preserve receipt + failures.append(failure) try: self.process.wait(timeout=0.1) - except (OSError, subprocess.TimeoutExpired) as failure: + except BaseException as failure: # noqa: BLE001 - preserve initial failure failures.append(failure) - if output is None: - # TimeoutExpired retains bytes even for text-mode Popen streams. - raw_stdout = partial_output.output if partial_output is not None else None - raw_stderr = partial_output.stderr if partial_output is not None else None - stdout = (raw_stdout or b"").decode("utf-8", "backslashreplace") - stderr = (raw_stderr or b"").decode("utf-8", "backslashreplace") - stdout = stdout.replace("\r\n", "\n").replace("\r", "\n") - stderr = stderr.replace("\r\n", "\n").replace("\r", "\n") - else: - stdout, stderr = output + for command_error in failures: + if isinstance(command_error, subprocess.TimeoutExpired): + command_error.output = bytes(output[0]) or None + command_error.stderr = bytes(output[1]) or None + stdout, stderr = ( + raw.decode("utf-8", "backslashreplace") + .replace("\r\n", "\n") + .replace("\r", "\n") + for raw in output + ) if len(failures) > 1: message = "client command and cleanup failed" return stdout, stderr, BaseExceptionGroup(message, failures) diff --git a/tests/test_common.py b/tests/test_common.py index a594dde676..ca81ccbef0 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -113,7 +113,10 @@ def test_failed_client_does_not_wait_for_inherited_output_pipes( @pytest.mark.parametrize("stream_name", ["stdout", "stderr"]) @pytest.mark.parametrize("interrupt", [False, True]) -@pytest.mark.parametrize("hold_pipe", [False, True]) +@pytest.mark.parametrize( + ("hold_pipe", "late_output"), + [(False, False), (True, False), pytest.param(False, True, id="late-output")], +) @pytest.mark.skipif(sys.platform != "linux", reason="observes descendant exit in /proc") def test_reader_close_failure_retains_receipt_and_reaps_client( tmp_path: pathlib.Path, @@ -121,6 +124,7 @@ def test_reader_close_failure_retains_receipt_and_reaps_client( stream_name: str, interrupt: bool, hold_pipe: bool, + late_output: bool, ) -> None: """Inject a close error after a real client's timeout or interruption.""" wrapper = tmp_path / "tmux-client" @@ -128,12 +132,22 @@ def test_reader_close_failure_retains_receipt_and_reaps_client( wrapper.write_text( f"#!{sys.executable}\n" "import os, pathlib, signal, time\n" - "os.write(1, b'completed receipt\\n')\n" - "os.write(2, b'client diagnostic\\n')\n" - f"if {hold_pipe!r}:\n" + f"if not {late_output!r}:\n" + " os.write(1, b'completed receipt\\n')\n" + " os.write(2, b'client diagnostic\\n')\n" + f"if {hold_pipe or late_output!r}:\n" + " parent = os.getpid()\n" " child = os.fork()\n" " if child == 0:\n" - " time.sleep(1)\n" + f" if {late_output!r}:\n" + " deadline = time.monotonic() + 2\n" + " while os.getppid() == parent:\n" + " if time.monotonic() >= deadline: os._exit(3)\n" + " time.sleep(0.001)\n" + " os.write(1, b'completed receipt\\n')\n" + " os.write(2, b'client diagnostic\\n')\n" + " else:\n" + " time.sleep(1)\n" " os._exit(0)\n" f" pathlib.Path({str(child_pid)!r}).write_text(str(child))\n" f"if {interrupt!r}:\n"