muxplex-client 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ # Python bytecode
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+
7
+ # Virtual environment
8
+ .venv/
9
+ venv/
10
+ env/
11
+
12
+ # Distribution / packaging
13
+ dist/
14
+ build/
15
+ *.egg-info/
16
+
17
+ # Test/coverage artifacts
18
+ .coverage
19
+ .pytest_cache/
20
+ htmlcov/
21
+
22
+ # OS artifacts
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # IDE
27
+ .idea/
28
+ .vscode/
29
+ *.swp
30
+ *.swo
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: muxplex-client
3
+ Version: 0.19.0
4
+ Summary: Typed sync/async HTTP client for the muxplex API
5
+ Project-URL: Repository, https://github.com/bkrabach/muxplex
6
+ Project-URL: Issues, https://github.com/bkrabach/muxplex/issues
7
+ Author-email: Brian Krabach <brian@krabach.com>
8
+ License: MIT
9
+ Keywords: api,client,http,muxplex,tmux
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # muxplex-client
26
+
27
+ Typed sync/async HTTP client for the [muxplex](https://github.com/bkrabach/muxplex)
28
+ tmux-session-dashboard API.
29
+
30
+ Ships as its own PyPI distribution (import name `muxplex_client`) so a
31
+ consumer that only needs to make ~12 HTTP calls -- a Stream Deck sidecar, an
32
+ Amplifier tool module -- never has to install the server's dependencies
33
+ (fastapi, uvicorn, python-pam, ...) or its `muxplex` server console script.
34
+ See [`../muxplex-client-design.md`](../muxplex-client-design.md) for the full
35
+ design rationale.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install muxplex-client
41
+ ```
42
+
43
+ Runtime dependency: `httpx>=0.27.0`. Nothing else -- no `muxplex` server
44
+ dependency, no console script installed.
45
+
46
+ ## Usage
47
+
48
+ Synchronous:
49
+
50
+ ```python
51
+ from muxplex_client import MuxplexClient
52
+
53
+ with MuxplexClient("https://your-server:8088", "federation-key") as client:
54
+ for session in client.sessions():
55
+ if session.bell.needs_attention:
56
+ print(f"{session.name} needs attention")
57
+ ```
58
+
59
+ Asynchronous (Amplifier tool modules, or any asyncio caller):
60
+
61
+ ```python
62
+ from muxplex_client import AsyncMuxplexClient
63
+
64
+ async with AsyncMuxplexClient("https://your-server:8088", "federation-key") as client:
65
+ result = await client.run_shell_command("my-session", "pytest -q")
66
+ print(result.exit_code, result.elapsed)
67
+ ```
68
+
69
+ A localhost caller needs no credential -- `federation_key=None` is the
70
+ default and is fine when running on the same host as the server.
71
+
72
+ ## What's in here vs. what isn't
73
+
74
+ See `muxplex-client-design.md` §3 for the full included/excluded endpoint
75
+ table and rationale. Notably excluded: `PATCH /api/settings` (highest
76
+ blast-radius operation in the API; a v2 concern requiring CAS + 409 retry +
77
+ backstop discrimination), all federation endpoints (server-to-server
78
+ protocol, no client consumer), and `/api/internal/setup-hooks` (internal,
79
+ self-healing as of server v0.18.0).
80
+
81
+ ## Version alignment
82
+
83
+ `muxplex_client.__version__` is cut in lockstep with the muxplex server
84
+ version -- one `vX.Y.Z` tag publishes both wheels. This is provenance
85
+ ("cut against server X"), not a runtime requirement: the client declares no
86
+ dependency on the `muxplex` package and enforces no version at runtime.
87
+ `MIN_SERVER_VERSION` backs an opt-in `check_server()` helper the caller may
88
+ call; it is never invoked automatically.
89
+
90
+ The load-bearing correctness mechanism is
91
+ `muxplex/tests/test_client_contract.py`, living in the **server's** own test
92
+ suite: it drives this client over `httpx.ASGITransport` against the real
93
+ FastAPI app and asserts every field the client parses actually exists on the
94
+ real response, that mirrored constants (`KNOWN_KEYS`, `MAX_CAPTURE_LINES`,
95
+ `DEFAULT_CAPTURE_LINES`) equal their server originals, and that
96
+ `Bell.needs_attention` agrees with the server's own predicate across a truth
97
+ table.
98
+
99
+ ## The shell-command sentinel
100
+
101
+ `run_shell_command()` (and the lower-level `muxplex_client.sentinel` module)
102
+ implements the completion-detection convention from `AGENT_GUIDE.md` §6.2/§6.4:
103
+ wrap a command with `; rc=$?; ... ; echo "MUXPLEX_DONE_<token>_EXIT_$rc"` and
104
+ poll the pane for the marker.
105
+
106
+ **This assumes the target pane is an idle POSIX shell prompt.** It is *not* a
107
+ general HTTP contract -- it fails (types a garbage line into whatever is
108
+ actually running, then hangs until timeout) against vim, a REPL, `less`, a
109
+ TUI, an ssh session, or a non-POSIX shell without a matching `exit_expr`.
110
+
111
+ The one correctness rule worth calling out explicitly: matching must be
112
+ **digit-anchored** (`MUXPLEX_DONE_<token>_EXIT_(\d+)`), never a bare-token
113
+ substring check. tmux echoes the literal, unexpanded `...EXIT_$?` into the
114
+ pane the instant you send the line -- before the shell has even run it -- so
115
+ a bare-token match reports "done" with a bogus exit code immediately. See
116
+ `muxplex_client/sentinel.py`'s docstring and `tests/test_sentinel.py`'s
117
+ digit-anchor regression test.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ uv sync --extra dev # from this directory, or from the repo root via the
123
+ # [tool.uv.workspace] declaration
124
+ uv run pytest
125
+ ```
126
+
127
+ `tests/` is pure -- no network, no server import, and passes with `muxplex`
128
+ not installed at all.
@@ -0,0 +1,104 @@
1
+ # muxplex-client
2
+
3
+ Typed sync/async HTTP client for the [muxplex](https://github.com/bkrabach/muxplex)
4
+ tmux-session-dashboard API.
5
+
6
+ Ships as its own PyPI distribution (import name `muxplex_client`) so a
7
+ consumer that only needs to make ~12 HTTP calls -- a Stream Deck sidecar, an
8
+ Amplifier tool module -- never has to install the server's dependencies
9
+ (fastapi, uvicorn, python-pam, ...) or its `muxplex` server console script.
10
+ See [`../muxplex-client-design.md`](../muxplex-client-design.md) for the full
11
+ design rationale.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install muxplex-client
17
+ ```
18
+
19
+ Runtime dependency: `httpx>=0.27.0`. Nothing else -- no `muxplex` server
20
+ dependency, no console script installed.
21
+
22
+ ## Usage
23
+
24
+ Synchronous:
25
+
26
+ ```python
27
+ from muxplex_client import MuxplexClient
28
+
29
+ with MuxplexClient("https://your-server:8088", "federation-key") as client:
30
+ for session in client.sessions():
31
+ if session.bell.needs_attention:
32
+ print(f"{session.name} needs attention")
33
+ ```
34
+
35
+ Asynchronous (Amplifier tool modules, or any asyncio caller):
36
+
37
+ ```python
38
+ from muxplex_client import AsyncMuxplexClient
39
+
40
+ async with AsyncMuxplexClient("https://your-server:8088", "federation-key") as client:
41
+ result = await client.run_shell_command("my-session", "pytest -q")
42
+ print(result.exit_code, result.elapsed)
43
+ ```
44
+
45
+ A localhost caller needs no credential -- `federation_key=None` is the
46
+ default and is fine when running on the same host as the server.
47
+
48
+ ## What's in here vs. what isn't
49
+
50
+ See `muxplex-client-design.md` §3 for the full included/excluded endpoint
51
+ table and rationale. Notably excluded: `PATCH /api/settings` (highest
52
+ blast-radius operation in the API; a v2 concern requiring CAS + 409 retry +
53
+ backstop discrimination), all federation endpoints (server-to-server
54
+ protocol, no client consumer), and `/api/internal/setup-hooks` (internal,
55
+ self-healing as of server v0.18.0).
56
+
57
+ ## Version alignment
58
+
59
+ `muxplex_client.__version__` is cut in lockstep with the muxplex server
60
+ version -- one `vX.Y.Z` tag publishes both wheels. This is provenance
61
+ ("cut against server X"), not a runtime requirement: the client declares no
62
+ dependency on the `muxplex` package and enforces no version at runtime.
63
+ `MIN_SERVER_VERSION` backs an opt-in `check_server()` helper the caller may
64
+ call; it is never invoked automatically.
65
+
66
+ The load-bearing correctness mechanism is
67
+ `muxplex/tests/test_client_contract.py`, living in the **server's** own test
68
+ suite: it drives this client over `httpx.ASGITransport` against the real
69
+ FastAPI app and asserts every field the client parses actually exists on the
70
+ real response, that mirrored constants (`KNOWN_KEYS`, `MAX_CAPTURE_LINES`,
71
+ `DEFAULT_CAPTURE_LINES`) equal their server originals, and that
72
+ `Bell.needs_attention` agrees with the server's own predicate across a truth
73
+ table.
74
+
75
+ ## The shell-command sentinel
76
+
77
+ `run_shell_command()` (and the lower-level `muxplex_client.sentinel` module)
78
+ implements the completion-detection convention from `AGENT_GUIDE.md` §6.2/§6.4:
79
+ wrap a command with `; rc=$?; ... ; echo "MUXPLEX_DONE_<token>_EXIT_$rc"` and
80
+ poll the pane for the marker.
81
+
82
+ **This assumes the target pane is an idle POSIX shell prompt.** It is *not* a
83
+ general HTTP contract -- it fails (types a garbage line into whatever is
84
+ actually running, then hangs until timeout) against vim, a REPL, `less`, a
85
+ TUI, an ssh session, or a non-POSIX shell without a matching `exit_expr`.
86
+
87
+ The one correctness rule worth calling out explicitly: matching must be
88
+ **digit-anchored** (`MUXPLEX_DONE_<token>_EXIT_(\d+)`), never a bare-token
89
+ substring check. tmux echoes the literal, unexpanded `...EXIT_$?` into the
90
+ pane the instant you send the line -- before the shell has even run it -- so
91
+ a bare-token match reports "done" with a bogus exit code immediately. See
92
+ `muxplex_client/sentinel.py`'s docstring and `tests/test_sentinel.py`'s
93
+ digit-anchor regression test.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ uv sync --extra dev # from this directory, or from the repo root via the
99
+ # [tool.uv.workspace] declaration
100
+ uv run pytest
101
+ ```
102
+
103
+ `tests/` is pure -- no network, no server import, and passes with `muxplex`
104
+ not installed at all.
@@ -0,0 +1,95 @@
1
+ """muxplex_client -- typed sync/async HTTP client for the muxplex API.
2
+
3
+ Basic usage:
4
+
5
+ >>> from muxplex_client import MuxplexClient
6
+ >>> with MuxplexClient("https://your-server:8088", "federation-key") as client:
7
+ ... sessions = client.sessions()
8
+
9
+ Async usage:
10
+
11
+ >>> from muxplex_client import AsyncMuxplexClient
12
+ >>> async with AsyncMuxplexClient("https://your-server:8088", "federation-key") as client:
13
+ ... sessions = await client.sessions()
14
+
15
+ See ../README.md and ../../muxplex-client-design.md for the full design
16
+ rationale (why this is a second distribution, version-alignment policy, the
17
+ sentinel's digit-anchor rule, and what was deliberately excluded).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib.metadata
23
+
24
+ from .async_client import AsyncMuxplexClient
25
+ from .constants import (
26
+ DEFAULT_CAPTURE_LINES,
27
+ KNOWN_KEYS,
28
+ MAX_CAPTURE_LINES,
29
+ MAX_KEYS,
30
+ MIN_SERVER_VERSION,
31
+ )
32
+ from .errors import (
33
+ ApiError,
34
+ AuthError,
35
+ CommandTimeout,
36
+ InputForbidden,
37
+ MuxplexError,
38
+ SessionNotFound,
39
+ UnreachableError,
40
+ )
41
+ from .models import (
42
+ Bell,
43
+ CommandResult,
44
+ ConnectResult,
45
+ InputResult,
46
+ InstanceInfo,
47
+ ServerState,
48
+ Session,
49
+ SessionSnapshot,
50
+ Settings,
51
+ View,
52
+ ViewResult,
53
+ ViewSession,
54
+ )
55
+ from .sentinel import Sentinel, make_sentinel
56
+ from .sync_client import MuxplexClient
57
+
58
+ try:
59
+ __version__ = importlib.metadata.version("muxplex-client")
60
+ except importlib.metadata.PackageNotFoundError:
61
+ # Editable/workspace checkout with no build metadata yet (e.g. running
62
+ # straight from a source tree before an initial `uv sync`).
63
+ __version__ = "0.0.0.dev0"
64
+
65
+ __all__ = [
66
+ "__version__",
67
+ "ApiError",
68
+ "AsyncMuxplexClient",
69
+ "AuthError",
70
+ "Bell",
71
+ "CommandResult",
72
+ "CommandTimeout",
73
+ "ConnectResult",
74
+ "DEFAULT_CAPTURE_LINES",
75
+ "InputForbidden",
76
+ "InputResult",
77
+ "InstanceInfo",
78
+ "KNOWN_KEYS",
79
+ "MAX_CAPTURE_LINES",
80
+ "MAX_KEYS",
81
+ "MIN_SERVER_VERSION",
82
+ "MuxplexClient",
83
+ "MuxplexError",
84
+ "Sentinel",
85
+ "ServerState",
86
+ "Session",
87
+ "SessionNotFound",
88
+ "SessionSnapshot",
89
+ "Settings",
90
+ "UnreachableError",
91
+ "View",
92
+ "ViewResult",
93
+ "ViewSession",
94
+ "make_sentinel",
95
+ ]
@@ -0,0 +1,215 @@
1
+ """Pure protocol logic: response parsing and error mapping.
2
+
3
+ No I/O, no httpx import -- tested once in `tests/test_protocol.py` with no
4
+ network. `sync_client.py` and `async_client.py` are each a thin, ~120-line
5
+ await-shaped (or not) shell that calls into this module; duplication is
6
+ confined to that shell, where it is honest and cheap (this is what httpx
7
+ itself does for its own sync/async split).
8
+
9
+ `.get()`-based parsing throughout, deliberately: AGENTS.md requires clients
10
+ to tolerate unknown fields and the server to tolerate their absence. Missing
11
+ keys fall back to a sane default rather than raising KeyError.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ from .constants import DEFAULT_CAPTURE_LINES
19
+ from .errors import ApiError, AuthError, InputForbidden, MuxplexError, SessionNotFound
20
+ from .models import (
21
+ Bell,
22
+ ConnectResult,
23
+ InputResult,
24
+ InstanceInfo,
25
+ ServerState,
26
+ Session,
27
+ SessionSnapshot,
28
+ Settings,
29
+ View,
30
+ ViewResult,
31
+ ViewSession,
32
+ )
33
+
34
+ __all__ = [
35
+ "map_status_error",
36
+ "parse_bell",
37
+ "parse_session",
38
+ "parse_sessions",
39
+ "parse_session_snapshot",
40
+ "parse_view_session",
41
+ "parse_view_result",
42
+ "parse_server_state",
43
+ "parse_view",
44
+ "parse_settings",
45
+ "parse_instance_info",
46
+ "parse_connect_result",
47
+ "parse_input_result",
48
+ "version_tuple",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Response parsing
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def parse_bell(raw: Mapping[str, Any]) -> Bell:
58
+ return Bell(
59
+ last_fired_at=raw.get("last_fired_at"),
60
+ seen_at=raw.get("seen_at"),
61
+ unseen_count=int(raw.get("unseen_count", 0)),
62
+ )
63
+
64
+
65
+ def parse_session(raw: Mapping[str, Any]) -> Session:
66
+ return Session(
67
+ name=raw["name"],
68
+ snapshot=raw.get("snapshot", ""),
69
+ bell=parse_bell(raw.get("bell") or {}),
70
+ last_activity_at=raw.get("last_activity_at"),
71
+ )
72
+
73
+
74
+ def parse_sessions(raw: Sequence[Mapping[str, Any]]) -> list[Session]:
75
+ return [parse_session(item) for item in raw]
76
+
77
+
78
+ def parse_session_snapshot(raw: Mapping[str, Any]) -> SessionSnapshot:
79
+ return SessionSnapshot(
80
+ name=raw["name"],
81
+ snapshot=raw.get("snapshot", ""),
82
+ lines=int(raw.get("lines", DEFAULT_CAPTURE_LINES)),
83
+ bell=parse_bell(raw.get("bell") or {}),
84
+ last_activity_at=raw.get("last_activity_at"),
85
+ )
86
+
87
+
88
+ def parse_view_session(raw: Mapping[str, Any]) -> ViewSession:
89
+ return ViewSession(
90
+ name=raw["name"],
91
+ active=bool(raw.get("active", False)),
92
+ needs_attention=bool(raw.get("needs_attention", False)),
93
+ bell=parse_bell(raw.get("bell") or {}),
94
+ last_activity_at=raw.get("last_activity_at"),
95
+ )
96
+
97
+
98
+ def parse_view_result(raw: Mapping[str, Any]) -> ViewResult:
99
+ return ViewResult(
100
+ view=raw.get("view", "all"),
101
+ views=tuple(raw.get("views") or ()),
102
+ sort=raw.get("sort", "server"),
103
+ sessions=tuple(parse_view_session(s) for s in (raw.get("sessions") or ())),
104
+ )
105
+
106
+
107
+ def parse_server_state(raw: Mapping[str, Any]) -> ServerState:
108
+ return ServerState(
109
+ active_session=raw.get("active_session"),
110
+ active_view=raw.get("active_view") or "all",
111
+ settings_updated_at=raw.get("settings_updated_at"),
112
+ raw=raw,
113
+ )
114
+
115
+
116
+ def parse_view(raw: Mapping[str, Any]) -> View:
117
+ return View(
118
+ name=raw.get("name", ""),
119
+ sessions=frozenset(raw.get("sessions") or ()),
120
+ )
121
+
122
+
123
+ def parse_settings(raw: Mapping[str, Any]) -> Settings:
124
+ return Settings(
125
+ views=tuple(parse_view(v) for v in (raw.get("views") or ())),
126
+ hidden_sessions=frozenset(raw.get("hidden_sessions") or ()),
127
+ sort_order=raw.get("sort_order", "manual"),
128
+ raw=raw,
129
+ )
130
+
131
+
132
+ def parse_instance_info(raw: Mapping[str, Any]) -> InstanceInfo:
133
+ return InstanceInfo(
134
+ name=raw.get("name", ""),
135
+ device_id=raw.get("device_id", ""),
136
+ version=raw.get("version", ""),
137
+ federation_enabled=bool(raw.get("federation_enabled", False)),
138
+ tmux_socket_dir=raw.get("tmux_socket_dir"),
139
+ bell_hook_armed=raw.get("bell_hook_armed"),
140
+ raw=raw,
141
+ )
142
+
143
+
144
+ def parse_connect_result(raw: Mapping[str, Any]) -> ConnectResult:
145
+ return ConnectResult(
146
+ active_session=raw["active_session"],
147
+ ttyd_port=int(raw["ttyd_port"]),
148
+ )
149
+
150
+
151
+ def parse_input_result(raw: Mapping[str, Any]) -> InputResult:
152
+ return InputResult(
153
+ session=raw["session"],
154
+ snapshot=raw.get("snapshot", ""),
155
+ )
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Error mapping
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def map_status_error(
164
+ status: int,
165
+ path: str,
166
+ detail: str,
167
+ *,
168
+ session_name: str | None = None,
169
+ ) -> MuxplexError:
170
+ """Map an HTTP error status + path to the matching MuxplexError subclass.
171
+
172
+ Rules (applied in order):
173
+ - 401 -> AuthError (credential rejected).
174
+ - 403 from a path ending in "/input" -> InputForbidden (the operator's
175
+ allowlist fence, NOT a bad credential).
176
+ - 403 from any other path -> AuthError.
177
+ - 404 -> SessionNotFound (session_name is whatever the caller was
178
+ targeting; right after create_session() this can be the read-model
179
+ poll cache rather than a real failure).
180
+ - Anything else -> ApiError(status, detail).
181
+ """
182
+ if status == 401:
183
+ return AuthError(detail)
184
+ if status == 403:
185
+ if path.endswith("/input"):
186
+ return InputForbidden(session_name or "", detail)
187
+ return AuthError(detail)
188
+ if status == 404:
189
+ return SessionNotFound(session_name or "", detail)
190
+ return ApiError(status, detail)
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Version comparison (backs the opt-in check_server() helper)
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def version_tuple(version: str) -> tuple[int, ...]:
199
+ """Parse a dotted version string into a comparable tuple of ints.
200
+
201
+ Deliberately lenient: non-numeric suffixes (e.g. "1.2.3rc1") are reduced
202
+ to their leading digits, and an entirely non-numeric segment becomes 0
203
+ rather than raising -- this is a convenience comparison for an opt-in
204
+ caller, not a strict semver parser.
205
+ """
206
+ parts: list[int] = []
207
+ for piece in version.split("."):
208
+ digits = ""
209
+ for ch in piece:
210
+ if ch.isdigit():
211
+ digits += ch
212
+ else:
213
+ break
214
+ parts.append(int(digits) if digits else 0)
215
+ return tuple(parts)