muxplex-client 0.19.0__py3-none-any.whl
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.
- muxplex_client/__init__.py +95 -0
- muxplex_client/_protocol.py +215 -0
- muxplex_client/async_client.py +262 -0
- muxplex_client/constants.py +46 -0
- muxplex_client/errors.py +80 -0
- muxplex_client/models.py +164 -0
- muxplex_client/sentinel.py +80 -0
- muxplex_client/sync_client.py +295 -0
- muxplex_client-0.19.0.dist-info/METADATA +128 -0
- muxplex_client-0.19.0.dist-info/RECORD +11 -0
- muxplex_client-0.19.0.dist-info/WHEEL +4 -0
|
@@ -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)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Asynchronous muxplex API client (httpx.AsyncClient transport).
|
|
2
|
+
|
|
3
|
+
Signature-identical to `sync_client.MuxplexClient` with `await` and an
|
|
4
|
+
`Async` prefix. Required for Amplifier tool modules: `async def mount(...)`
|
|
5
|
+
tool execution is awaited, and a synchronous httpx call inside an async tool
|
|
6
|
+
would block the event loop for its duration -- for `run_shell_command`
|
|
7
|
+
polling a ten-minute build, that blocks the provider stream, every other
|
|
8
|
+
tool, and every hook. See _protocol.py's docstring for the shared logic this
|
|
9
|
+
wraps.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Self, Sequence
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from . import _protocol as protocol
|
|
22
|
+
from .constants import MIN_SERVER_VERSION
|
|
23
|
+
from .errors import CommandTimeout, MuxplexError, UnreachableError
|
|
24
|
+
from .models import (
|
|
25
|
+
CommandResult,
|
|
26
|
+
ConnectResult,
|
|
27
|
+
InputResult,
|
|
28
|
+
InstanceInfo,
|
|
29
|
+
ServerState,
|
|
30
|
+
Session,
|
|
31
|
+
SessionSnapshot,
|
|
32
|
+
Settings,
|
|
33
|
+
ViewResult,
|
|
34
|
+
)
|
|
35
|
+
from .sentinel import make_sentinel
|
|
36
|
+
from .sync_client import _extract_detail
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncMuxplexClient:
|
|
40
|
+
"""Thin, typed, asynchronous HTTP client for the muxplex API.
|
|
41
|
+
|
|
42
|
+
See `sync_client.MuxplexClient` for the full rationale behind every
|
|
43
|
+
behavior here (Accept header, follow_redirects, ca_file, federation_key)
|
|
44
|
+
-- this class mirrors it exactly, `await`-shaped.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
server_url: str,
|
|
50
|
+
federation_key: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
ca_file: Path | str | None = None,
|
|
53
|
+
timeout: float = 5.0,
|
|
54
|
+
client: httpx.AsyncClient | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if client is not None:
|
|
57
|
+
self._client = client
|
|
58
|
+
self._owns_client = False
|
|
59
|
+
return
|
|
60
|
+
headers = {"Accept": "application/json"}
|
|
61
|
+
if federation_key:
|
|
62
|
+
headers["Authorization"] = f"Bearer {federation_key}"
|
|
63
|
+
verify: bool | str = str(ca_file) if ca_file else True
|
|
64
|
+
self._client = httpx.AsyncClient(
|
|
65
|
+
base_url=server_url,
|
|
66
|
+
headers=headers,
|
|
67
|
+
verify=verify,
|
|
68
|
+
timeout=timeout,
|
|
69
|
+
follow_redirects=False,
|
|
70
|
+
)
|
|
71
|
+
self._owns_client = True
|
|
72
|
+
|
|
73
|
+
async def aclose(self) -> None:
|
|
74
|
+
if self._owns_client:
|
|
75
|
+
await self._client.aclose()
|
|
76
|
+
|
|
77
|
+
async def __aenter__(self) -> Self:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
81
|
+
await self.aclose()
|
|
82
|
+
|
|
83
|
+
async def _request(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
path: str,
|
|
87
|
+
*,
|
|
88
|
+
params: dict[str, Any] | None = None,
|
|
89
|
+
json: dict[str, Any] | None = None,
|
|
90
|
+
session_name: str | None = None,
|
|
91
|
+
) -> Any:
|
|
92
|
+
try:
|
|
93
|
+
response = await self._client.request(
|
|
94
|
+
method, path, params=params, json=json
|
|
95
|
+
)
|
|
96
|
+
except httpx.HTTPError as exc:
|
|
97
|
+
raise UnreachableError(f"{method} {path} failed: {exc}") from exc
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
raise protocol.map_status_error(
|
|
100
|
+
response.status_code,
|
|
101
|
+
path,
|
|
102
|
+
_extract_detail(response),
|
|
103
|
+
session_name=session_name,
|
|
104
|
+
)
|
|
105
|
+
return response.json()
|
|
106
|
+
|
|
107
|
+
# ---- read ----
|
|
108
|
+
|
|
109
|
+
async def instance_info(self) -> InstanceInfo:
|
|
110
|
+
return protocol.parse_instance_info(
|
|
111
|
+
await self._request("GET", "/api/instance-info")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def sessions(self) -> list[Session]:
|
|
115
|
+
return protocol.parse_sessions(await self._request("GET", "/api/sessions"))
|
|
116
|
+
|
|
117
|
+
async def session(self, name: str, *, lines: int | None = None) -> SessionSnapshot:
|
|
118
|
+
params = {"lines": lines} if lines is not None else None
|
|
119
|
+
return protocol.parse_session_snapshot(
|
|
120
|
+
await self._request(
|
|
121
|
+
"GET", f"/api/sessions/{name}", params=params, session_name=name
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def view(self, *, sort: str | None = None) -> ViewResult:
|
|
126
|
+
params = {"sort": sort} if sort is not None else None
|
|
127
|
+
return protocol.parse_view_result(
|
|
128
|
+
await self._request("GET", "/api/view", params=params)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def state(self) -> ServerState:
|
|
132
|
+
return protocol.parse_server_state(await self._request("GET", "/api/state"))
|
|
133
|
+
|
|
134
|
+
async def settings(self) -> Settings:
|
|
135
|
+
return protocol.parse_settings(await self._request("GET", "/api/settings"))
|
|
136
|
+
|
|
137
|
+
# ---- lifecycle ----
|
|
138
|
+
|
|
139
|
+
async def create_session(
|
|
140
|
+
self,
|
|
141
|
+
name: str,
|
|
142
|
+
*,
|
|
143
|
+
wait: bool = True,
|
|
144
|
+
timeout: float = 6.0,
|
|
145
|
+
interval: float = 0.3,
|
|
146
|
+
) -> None:
|
|
147
|
+
await self._request(
|
|
148
|
+
"POST", "/api/sessions", json={"name": name}, session_name=name
|
|
149
|
+
)
|
|
150
|
+
if wait and not await self.wait_for_session(
|
|
151
|
+
name, timeout=timeout, interval=interval
|
|
152
|
+
):
|
|
153
|
+
raise TimeoutError(
|
|
154
|
+
f"session {name!r} did not appear in the read cache within {timeout}s"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def delete_session(self, name: str) -> None:
|
|
158
|
+
await self._request("DELETE", f"/api/sessions/{name}", session_name=name)
|
|
159
|
+
|
|
160
|
+
async def wait_for_session(
|
|
161
|
+
self, name: str, *, timeout: float = 6.0, interval: float = 0.3
|
|
162
|
+
) -> bool:
|
|
163
|
+
deadline = time.monotonic() + timeout
|
|
164
|
+
while True:
|
|
165
|
+
sessions = await self.sessions()
|
|
166
|
+
if any(s.name == name for s in sessions):
|
|
167
|
+
return True
|
|
168
|
+
if time.monotonic() >= deadline:
|
|
169
|
+
return False
|
|
170
|
+
await asyncio.sleep(interval)
|
|
171
|
+
|
|
172
|
+
async def connect(self, name: str) -> ConnectResult:
|
|
173
|
+
return protocol.parse_connect_result(
|
|
174
|
+
await self._request(
|
|
175
|
+
"POST", f"/api/sessions/{name}/connect", session_name=name
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def set_active_view(self, view: str) -> None:
|
|
180
|
+
await self._request("PATCH", "/api/state", json={"active_view": view})
|
|
181
|
+
|
|
182
|
+
# ---- input ----
|
|
183
|
+
|
|
184
|
+
async def send_input(
|
|
185
|
+
self,
|
|
186
|
+
name: str,
|
|
187
|
+
*,
|
|
188
|
+
text: str = "",
|
|
189
|
+
keys: Sequence[str] = (),
|
|
190
|
+
enter: bool = False,
|
|
191
|
+
lines: int | None = None,
|
|
192
|
+
) -> InputResult:
|
|
193
|
+
body: dict[str, Any] = {"text": text, "keys": list(keys), "enter": enter}
|
|
194
|
+
if lines is not None:
|
|
195
|
+
body["lines"] = lines
|
|
196
|
+
return protocol.parse_input_result(
|
|
197
|
+
await self._request(
|
|
198
|
+
"POST", f"/api/sessions/{name}/input", json=body, session_name=name
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def run_shell_command(
|
|
203
|
+
self,
|
|
204
|
+
name: str,
|
|
205
|
+
command: str,
|
|
206
|
+
*,
|
|
207
|
+
timeout: float = 600.0,
|
|
208
|
+
poll_interval: float = 2.0,
|
|
209
|
+
lines: int = 500,
|
|
210
|
+
bell_on_failure: bool = True,
|
|
211
|
+
exit_expr: str = "$?",
|
|
212
|
+
token: str | None = None,
|
|
213
|
+
) -> CommandResult:
|
|
214
|
+
"""Run *command* to completion and return its real exit code.
|
|
215
|
+
|
|
216
|
+
See `sync_client.MuxplexClient.run_shell_command` for the full
|
|
217
|
+
assumptions and rationale -- identical here, `await`-shaped. This is
|
|
218
|
+
the operation that matters most for the async client: it never
|
|
219
|
+
blocks the event loop while polling, unlike a synchronous call
|
|
220
|
+
parked inside an async tool.
|
|
221
|
+
"""
|
|
222
|
+
sentinel = make_sentinel(token)
|
|
223
|
+
wrapped = sentinel.wrap(
|
|
224
|
+
command, bell_on_failure=bell_on_failure, exit_expr=exit_expr
|
|
225
|
+
)
|
|
226
|
+
await self.send_input(name, text=wrapped, enter=True)
|
|
227
|
+
|
|
228
|
+
start = time.monotonic()
|
|
229
|
+
deadline = start + timeout
|
|
230
|
+
while True:
|
|
231
|
+
snap = await self.session(name, lines=lines)
|
|
232
|
+
exit_code = sentinel.search(snap.snapshot)
|
|
233
|
+
if exit_code is not None:
|
|
234
|
+
return CommandResult(
|
|
235
|
+
session=name,
|
|
236
|
+
exit_code=exit_code,
|
|
237
|
+
snapshot=snap.snapshot,
|
|
238
|
+
elapsed=time.monotonic() - start,
|
|
239
|
+
token=sentinel.token,
|
|
240
|
+
)
|
|
241
|
+
if time.monotonic() >= deadline:
|
|
242
|
+
raise CommandTimeout(
|
|
243
|
+
session=name,
|
|
244
|
+
token=sentinel.token,
|
|
245
|
+
elapsed=time.monotonic() - start,
|
|
246
|
+
snapshot=snap.snapshot,
|
|
247
|
+
)
|
|
248
|
+
await asyncio.sleep(poll_interval)
|
|
249
|
+
|
|
250
|
+
# ---- opt-in version check ----
|
|
251
|
+
|
|
252
|
+
async def check_server(self, min_version: str = MIN_SERVER_VERSION) -> InstanceInfo:
|
|
253
|
+
"""Fetch instance-info and raise MuxplexError if the server is older.
|
|
254
|
+
|
|
255
|
+
Never called automatically.
|
|
256
|
+
"""
|
|
257
|
+
info = await self.instance_info()
|
|
258
|
+
if protocol.version_tuple(info.version) < protocol.version_tuple(min_version):
|
|
259
|
+
raise MuxplexError(
|
|
260
|
+
f"server version {info.version!r} is older than required {min_version!r}"
|
|
261
|
+
)
|
|
262
|
+
return info
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Mirrored server constants.
|
|
2
|
+
|
|
3
|
+
These are convenience mirrors of values defined authoritatively on the
|
|
4
|
+
muxplex server (`muxplex.sessions`, `muxplex.terminal_input`). The client
|
|
5
|
+
does NOT pre-validate against them -- the server is authoritative and fails
|
|
6
|
+
closed; a client-side copy that silently disagreed with the server would be
|
|
7
|
+
exactly the drift `AGENTS.md` warns about. They exist so a caller can build a
|
|
8
|
+
tool schema enum or a UI without a round trip.
|
|
9
|
+
|
|
10
|
+
`muxplex/tests/test_client_contract.py` (in the server's own test suite)
|
|
11
|
+
asserts each of these equals its server original, converting what would
|
|
12
|
+
otherwise be a silent drift hazard into a CI-enforced invariant.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
# Cut-against server version. Provenance, not a runtime requirement -- see
|
|
18
|
+
# MuxplexClient.check_server()/AsyncMuxplexClient.check_server(), which is
|
|
19
|
+
# opt-in and never called automatically.
|
|
20
|
+
MIN_SERVER_VERSION = "0.18.0"
|
|
21
|
+
|
|
22
|
+
# Mirrors muxplex.sessions.DEFAULT_CAPTURE_LINES.
|
|
23
|
+
DEFAULT_CAPTURE_LINES = 30
|
|
24
|
+
|
|
25
|
+
# Mirrors muxplex.sessions.MAX_CAPTURE_LINES.
|
|
26
|
+
MAX_CAPTURE_LINES = 2000
|
|
27
|
+
|
|
28
|
+
# Mirrors muxplex.terminal_input.ALLOWED_KEYS.
|
|
29
|
+
KNOWN_KEYS: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"Enter",
|
|
32
|
+
"Escape",
|
|
33
|
+
"Tab",
|
|
34
|
+
"C-c",
|
|
35
|
+
"C-d",
|
|
36
|
+
"Up",
|
|
37
|
+
"Down",
|
|
38
|
+
"Left",
|
|
39
|
+
"Right",
|
|
40
|
+
"PageUp",
|
|
41
|
+
"PageDown",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Mirrors muxplex.terminal_input.MAX_KEYS.
|
|
46
|
+
MAX_KEYS = 64
|
muxplex_client/errors.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Exception hierarchy for the muxplex client.
|
|
2
|
+
|
|
3
|
+
Mapping from HTTP status to these types happens in `_protocol.map_status_error`
|
|
4
|
+
and is applied by both transport classes (`sync_client.MuxplexClient`,
|
|
5
|
+
`async_client.AsyncMuxplexClient`). See that function's docstring for the
|
|
6
|
+
exact rules.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MuxplexError(Exception):
|
|
13
|
+
"""Base class for all muxplex client errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnreachableError(MuxplexError):
|
|
17
|
+
"""Transport failure -- connection refused, timeout, DNS, TLS.
|
|
18
|
+
|
|
19
|
+
Raised when the request never got a response from the server at all
|
|
20
|
+
(an `httpx.HTTPError` other than an HTTP status response).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthError(MuxplexError):
|
|
25
|
+
"""Credential rejected (401, or 403 from any endpoint except /input)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionNotFound(MuxplexError):
|
|
29
|
+
"""404 for a session-scoped endpoint.
|
|
30
|
+
|
|
31
|
+
Right after a `create_session()` call, this can be the ~2s read-model
|
|
32
|
+
poll cache rather than a genuine failure -- see AGENT_GUIDE.md's "read
|
|
33
|
+
model is eventually consistent" section, and `wait_for_session()` /
|
|
34
|
+
`create_session(wait=True)`, which poll past this window.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str, detail: str = "") -> None:
|
|
38
|
+
self.name = name
|
|
39
|
+
self.detail = detail
|
|
40
|
+
super().__init__(detail or f"Session {name!r} not found")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InputForbidden(MuxplexError):
|
|
44
|
+
"""403 from POST /api/sessions/{name}/input -- the operator's fence.
|
|
45
|
+
|
|
46
|
+
Deliberately does NOT subclass `AuthError`: this is not a rejected
|
|
47
|
+
credential, it means the session is not allowlisted for input (see
|
|
48
|
+
`settings.input_enabled` / `settings.input_allowed_sessions` in the
|
|
49
|
+
muxplex server). The correct caller response is to stop and tell the
|
|
50
|
+
human what to add to `settings.json` -- a different action from
|
|
51
|
+
rotating a key (AGENT_GUIDE.md §7).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, name: str, detail: str) -> None:
|
|
55
|
+
self.name = name
|
|
56
|
+
self.detail = detail
|
|
57
|
+
super().__init__(detail)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ApiError(MuxplexError):
|
|
61
|
+
"""Any other non-2xx response not covered by a more specific error."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
64
|
+
self.status = status
|
|
65
|
+
self.detail = detail
|
|
66
|
+
super().__init__(f"HTTP {status}: {detail}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CommandTimeout(MuxplexError):
|
|
70
|
+
"""`run_shell_command()` did not observe the completion sentinel in time."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, session: str, token: str, elapsed: float, snapshot: str) -> None:
|
|
73
|
+
self.session = session
|
|
74
|
+
self.token = token
|
|
75
|
+
self.elapsed = elapsed
|
|
76
|
+
self.snapshot = snapshot
|
|
77
|
+
super().__init__(
|
|
78
|
+
f"command in session {session!r} did not complete within "
|
|
79
|
+
f"{elapsed:.1f}s (sentinel token={token!r})"
|
|
80
|
+
)
|
muxplex_client/models.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Response shapes for the muxplex client.
|
|
2
|
+
|
|
3
|
+
All frozen dataclasses, deliberately not pydantic -- see
|
|
4
|
+
../muxplex-client-design.md §8 ("Rejected: pydantic models"): heavy for six
|
|
5
|
+
shapes, and actively wrong here, since AGENTS.md requires clients to
|
|
6
|
+
tolerate unknown fields. `.get()`-based parsing in `_protocol.py` satisfies
|
|
7
|
+
that by construction; a strict model would have to be deliberately loosened
|
|
8
|
+
to avoid violating the contract.
|
|
9
|
+
|
|
10
|
+
Where a `raw` field appears (`ServerState`, `Settings`, `InstanceInfo` -- the
|
|
11
|
+
single-object, evolution-likely shapes) it is excluded from `__eq__`/`__hash__`
|
|
12
|
+
via `compare=False` so a frozen dataclass carrying a dict stays hashable, and
|
|
13
|
+
it is how "clients tolerate unknown fields" becomes usable rather than lossy:
|
|
14
|
+
a caller needing a field the model doesn't expose yet can still reach it.
|
|
15
|
+
`Session`/`Bell`/etc. are hot-path list items with a stable shape and omit it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Bell:
|
|
26
|
+
"""A session's bell-alert sub-state, as returned by GET /api/sessions."""
|
|
27
|
+
|
|
28
|
+
last_fired_at: float | None
|
|
29
|
+
seen_at: float | None
|
|
30
|
+
unseen_count: int
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def needs_attention(self) -> bool:
|
|
34
|
+
"""unseen_count > 0 and (seen_at is None or last_fired_at > seen_at).
|
|
35
|
+
|
|
36
|
+
Mirrors the server's `muxplex.bells.needs_attention` exactly --
|
|
37
|
+
contract-tested for agreement in `test_client_contract.py` across a
|
|
38
|
+
truth table of (unseen_count, seen_at, last_fired_at). Defensive on
|
|
39
|
+
`last_fired_at is None` combined with a non-None `seen_at` (should
|
|
40
|
+
not occur in practice, but treated as "not newer than seen_at"
|
|
41
|
+
rather than raising, matching the server).
|
|
42
|
+
"""
|
|
43
|
+
if self.unseen_count <= 0:
|
|
44
|
+
return False
|
|
45
|
+
if self.seen_at is None:
|
|
46
|
+
return True
|
|
47
|
+
if self.last_fired_at is None:
|
|
48
|
+
return False
|
|
49
|
+
return self.last_fired_at > self.seen_at
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Session:
|
|
54
|
+
"""One tmux session, as returned by GET /api/sessions."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
snapshot: str
|
|
58
|
+
bell: Bell
|
|
59
|
+
last_activity_at: float | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SessionSnapshot:
|
|
64
|
+
"""A single session's pane content at a caller-chosen depth.
|
|
65
|
+
|
|
66
|
+
Returned by GET /api/sessions/{name}. Unlike `Session` (the shared,
|
|
67
|
+
~2s-cycle poll cache), this is one fresh `capture-pane` call scoped to
|
|
68
|
+
a single session.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
snapshot: str
|
|
73
|
+
lines: int
|
|
74
|
+
bell: Bell
|
|
75
|
+
last_activity_at: float | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ViewSession:
|
|
80
|
+
"""One entry of GET /api/view's `sessions` list."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
active: bool
|
|
84
|
+
needs_attention: bool
|
|
85
|
+
bell: Bell
|
|
86
|
+
last_activity_at: float | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ViewResult:
|
|
91
|
+
"""The server-resolved current view: GET /api/view."""
|
|
92
|
+
|
|
93
|
+
view: str
|
|
94
|
+
views: tuple[str, ...]
|
|
95
|
+
sort: str # "server" | "alphabetical" | "attention"
|
|
96
|
+
sessions: tuple[ViewSession, ...]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ServerState:
|
|
101
|
+
"""GET /api/state."""
|
|
102
|
+
|
|
103
|
+
active_session: str | None
|
|
104
|
+
active_view: str # defaults to "all" when absent/empty
|
|
105
|
+
settings_updated_at: float | None = None
|
|
106
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class View:
|
|
111
|
+
"""One user-defined view, as returned by GET /api/settings."""
|
|
112
|
+
|
|
113
|
+
name: str
|
|
114
|
+
sessions: frozenset[str]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Settings:
|
|
119
|
+
"""GET /api/settings."""
|
|
120
|
+
|
|
121
|
+
views: tuple[View, ...]
|
|
122
|
+
hidden_sessions: frozenset[str]
|
|
123
|
+
sort_order: str # default "manual"
|
|
124
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class InstanceInfo:
|
|
129
|
+
"""GET /api/instance-info."""
|
|
130
|
+
|
|
131
|
+
name: str
|
|
132
|
+
device_id: str
|
|
133
|
+
version: str
|
|
134
|
+
federation_enabled: bool
|
|
135
|
+
tmux_socket_dir: str | None
|
|
136
|
+
bell_hook_armed: bool | None # None on servers < 0.18.0
|
|
137
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class ConnectResult:
|
|
142
|
+
"""POST /api/sessions/{name}/connect."""
|
|
143
|
+
|
|
144
|
+
active_session: str
|
|
145
|
+
ttyd_port: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class InputResult:
|
|
150
|
+
"""POST /api/sessions/{name}/input."""
|
|
151
|
+
|
|
152
|
+
session: str
|
|
153
|
+
snapshot: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class CommandResult:
|
|
158
|
+
"""Result of `run_shell_command()`."""
|
|
159
|
+
|
|
160
|
+
session: str
|
|
161
|
+
exit_code: int
|
|
162
|
+
snapshot: str
|
|
163
|
+
elapsed: float
|
|
164
|
+
token: str
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Completion sentinel for driving a shell command to completion over the API.
|
|
2
|
+
|
|
3
|
+
Pure, no I/O -- fully testable without a server, and directly reusable by a
|
|
4
|
+
caller who wants to run their own poll loop instead of `run_shell_command()`.
|
|
5
|
+
|
|
6
|
+
ASSUMES the target pane is an idle POSIX-ish shell prompt. None of this holds
|
|
7
|
+
for a pane running vim, a REPL, `less`, a TUI, an ssh session, or fish/nushell
|
|
8
|
+
(`$status`, `$env.LAST_EXIT_CODE`) unless the caller supplies a matching
|
|
9
|
+
`exit_expr`. See AGENT_GUIDE.md §6.2/§6.4 for the operational convention this
|
|
10
|
+
implements.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
# Length (in random bytes, before urlsafe-base64 encoding) of an
|
|
20
|
+
# auto-generated token. Short enough to keep the composed shell line
|
|
21
|
+
# readable, long enough that two concurrent commands in the same session
|
|
22
|
+
# don't collide.
|
|
23
|
+
_TOKEN_BYTES = 6
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Sentinel:
|
|
28
|
+
"""A single-use completion marker: a token plus its digit-anchored regex."""
|
|
29
|
+
|
|
30
|
+
token: str
|
|
31
|
+
pattern: re.Pattern[str] # MUXPLEX_DONE_<token>_EXIT_(\d+)
|
|
32
|
+
|
|
33
|
+
def wrap(
|
|
34
|
+
self, command: str, *, bell_on_failure: bool = True, exit_expr: str = "$?"
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Compose the one-liner from AGENT_GUIDE.md §6.2/§6.4:
|
|
37
|
+
|
|
38
|
+
<cmd>; rc=<exit_expr>; [ $rc -ne 0 ] && printf '\\a'; \\
|
|
39
|
+
echo "MUXPLEX_DONE_<token>_EXIT_$rc"
|
|
40
|
+
|
|
41
|
+
With bell_on_failure=False, the `printf '\\a'` clause is omitted
|
|
42
|
+
entirely (never fire the bell for a routine, expected-to-fail
|
|
43
|
+
check, per AGENT_GUIDE.md §6.4's recommended convention: ring on
|
|
44
|
+
nonzero exit only, never on routine success).
|
|
45
|
+
"""
|
|
46
|
+
parts = [command, f"rc={exit_expr}"]
|
|
47
|
+
if bell_on_failure:
|
|
48
|
+
parts.append("[ $rc -ne 0 ] && printf '\\a'")
|
|
49
|
+
parts.append(f'echo "MUXPLEX_DONE_{self.token}_EXIT_$rc"')
|
|
50
|
+
return "; ".join(parts)
|
|
51
|
+
|
|
52
|
+
def search(self, snapshot: str) -> int | None:
|
|
53
|
+
"""Digit-anchored match. Returns the real exit code, or None.
|
|
54
|
+
|
|
55
|
+
The digit anchor is load-bearing: tmux echoes your input line into
|
|
56
|
+
the pane BEFORE the shell runs it, so the literal, unexpanded
|
|
57
|
+
"...EXIT_$?" text is visible in the pane immediately -- before the
|
|
58
|
+
command has even started, let alone finished. A bare-token
|
|
59
|
+
substring check (`"MUXPLEX_DONE_<token>" in snapshot`) false-matches
|
|
60
|
+
on that echo and reports "done" with a bogus exit code instantly.
|
|
61
|
+
Shell expansion never happens inside that echoed input line, so
|
|
62
|
+
`\\d+` only ever matches the version the shell actually wrote back
|
|
63
|
+
out after running the command to completion.
|
|
64
|
+
|
|
65
|
+
Do NOT "simplify" this to a bare-token check -- see
|
|
66
|
+
`tests/test_sentinel.py::test_digit_anchor_regression` for the
|
|
67
|
+
regression this guards against.
|
|
68
|
+
"""
|
|
69
|
+
match = self.pattern.search(snapshot)
|
|
70
|
+
if match is None:
|
|
71
|
+
return None
|
|
72
|
+
return int(match.group(1))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def make_sentinel(token: str | None = None) -> Sentinel:
|
|
76
|
+
"""Create a new Sentinel. `token` defaults to a short random urlsafe string."""
|
|
77
|
+
if token is None:
|
|
78
|
+
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
|
79
|
+
pattern = re.compile(rf"MUXPLEX_DONE_{re.escape(token)}_EXIT_(\d+)")
|
|
80
|
+
return Sentinel(token=token, pattern=pattern)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Synchronous muxplex API client (httpx.Client transport).
|
|
2
|
+
|
|
3
|
+
Thin await-free shell over `_protocol.py` -- see that module's docstring.
|
|
4
|
+
Signature-identical to `async_client.AsyncMuxplexClient` with `await` and an
|
|
5
|
+
`Async` prefix.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Self, Sequence
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import _protocol as protocol
|
|
17
|
+
from .constants import MIN_SERVER_VERSION
|
|
18
|
+
from .errors import CommandTimeout, MuxplexError, UnreachableError
|
|
19
|
+
from .models import (
|
|
20
|
+
CommandResult,
|
|
21
|
+
ConnectResult,
|
|
22
|
+
InputResult,
|
|
23
|
+
InstanceInfo,
|
|
24
|
+
ServerState,
|
|
25
|
+
Session,
|
|
26
|
+
SessionSnapshot,
|
|
27
|
+
Settings,
|
|
28
|
+
ViewResult,
|
|
29
|
+
)
|
|
30
|
+
from .sentinel import make_sentinel
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MuxplexClient:
|
|
34
|
+
"""Thin, typed, synchronous HTTP client for the muxplex API.
|
|
35
|
+
|
|
36
|
+
Always sets `Accept: application/json` and `follow_redirects=False` in
|
|
37
|
+
the constructor -- not left to callers. Without the header the auth
|
|
38
|
+
middleware 307s an unauthenticated request to `/login`; a client that
|
|
39
|
+
follows redirects would get a `200 OK` full of login-page HTML instead
|
|
40
|
+
of the `401` it should see (AGENT_GUIDE.md §1).
|
|
41
|
+
|
|
42
|
+
`federation_key=None` means "no credential" -- a localhost caller needs
|
|
43
|
+
none (AGENT_GUIDE.md §1.1); an agent running on the same host as the
|
|
44
|
+
server should not be forced to invent one.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
server_url: str,
|
|
50
|
+
federation_key: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
ca_file: Path | str | None = None,
|
|
53
|
+
timeout: float = 5.0,
|
|
54
|
+
client: httpx.Client | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if client is not None:
|
|
57
|
+
self._client = client
|
|
58
|
+
self._owns_client = False
|
|
59
|
+
return
|
|
60
|
+
headers = {"Accept": "application/json"}
|
|
61
|
+
if federation_key:
|
|
62
|
+
headers["Authorization"] = f"Bearer {federation_key}"
|
|
63
|
+
# ca_file must be the CA, never the leaf -- a documented, expensive
|
|
64
|
+
# footgun in this project (see AGENTS.md's "GET /api/ca" section).
|
|
65
|
+
verify: bool | str = str(ca_file) if ca_file else True
|
|
66
|
+
self._client = httpx.Client(
|
|
67
|
+
base_url=server_url,
|
|
68
|
+
headers=headers,
|
|
69
|
+
verify=verify,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
follow_redirects=False,
|
|
72
|
+
)
|
|
73
|
+
self._owns_client = True
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
if self._owns_client:
|
|
77
|
+
self._client.close()
|
|
78
|
+
|
|
79
|
+
def __enter__(self) -> Self:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(self, *exc: object) -> None:
|
|
83
|
+
self.close()
|
|
84
|
+
|
|
85
|
+
def _request(
|
|
86
|
+
self,
|
|
87
|
+
method: str,
|
|
88
|
+
path: str,
|
|
89
|
+
*,
|
|
90
|
+
params: dict[str, Any] | None = None,
|
|
91
|
+
json: dict[str, Any] | None = None,
|
|
92
|
+
session_name: str | None = None,
|
|
93
|
+
) -> Any:
|
|
94
|
+
try:
|
|
95
|
+
response = self._client.request(method, path, params=params, json=json)
|
|
96
|
+
except httpx.HTTPError as exc:
|
|
97
|
+
raise UnreachableError(f"{method} {path} failed: {exc}") from exc
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
raise protocol.map_status_error(
|
|
100
|
+
response.status_code,
|
|
101
|
+
path,
|
|
102
|
+
_extract_detail(response),
|
|
103
|
+
session_name=session_name,
|
|
104
|
+
)
|
|
105
|
+
return response.json()
|
|
106
|
+
|
|
107
|
+
# ---- read ----
|
|
108
|
+
|
|
109
|
+
def instance_info(self) -> InstanceInfo:
|
|
110
|
+
"""GET /api/instance-info -- public, no auth required."""
|
|
111
|
+
return protocol.parse_instance_info(self._request("GET", "/api/instance-info"))
|
|
112
|
+
|
|
113
|
+
def sessions(self) -> list[Session]:
|
|
114
|
+
"""GET /api/sessions -- the shared, ~2s-cycle poll cache."""
|
|
115
|
+
return protocol.parse_sessions(self._request("GET", "/api/sessions"))
|
|
116
|
+
|
|
117
|
+
def session(self, name: str, *, lines: int | None = None) -> SessionSnapshot:
|
|
118
|
+
"""GET /api/sessions/{name} -- one fresh capture-pane at a chosen depth."""
|
|
119
|
+
params = {"lines": lines} if lines is not None else None
|
|
120
|
+
return protocol.parse_session_snapshot(
|
|
121
|
+
self._request(
|
|
122
|
+
"GET", f"/api/sessions/{name}", params=params, session_name=name
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def view(self, *, sort: str | None = None) -> ViewResult:
|
|
127
|
+
"""GET /api/view -- the server-resolved current view."""
|
|
128
|
+
params = {"sort": sort} if sort is not None else None
|
|
129
|
+
return protocol.parse_view_result(
|
|
130
|
+
self._request("GET", "/api/view", params=params)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def state(self) -> ServerState:
|
|
134
|
+
"""GET /api/state."""
|
|
135
|
+
return protocol.parse_server_state(self._request("GET", "/api/state"))
|
|
136
|
+
|
|
137
|
+
def settings(self) -> Settings:
|
|
138
|
+
"""GET /api/settings."""
|
|
139
|
+
return protocol.parse_settings(self._request("GET", "/api/settings"))
|
|
140
|
+
|
|
141
|
+
# ---- lifecycle ----
|
|
142
|
+
|
|
143
|
+
def create_session(
|
|
144
|
+
self,
|
|
145
|
+
name: str,
|
|
146
|
+
*,
|
|
147
|
+
wait: bool = True,
|
|
148
|
+
timeout: float = 6.0,
|
|
149
|
+
interval: float = 0.3,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""POST /api/sessions. With wait=True, polls until the session is
|
|
152
|
+
visible in the ~2s read cache -- 0.3s interval, 6s ceiling, the
|
|
153
|
+
measured schedule from AGENT_GUIDE.md §4. Raises TimeoutError if
|
|
154
|
+
it never appears.
|
|
155
|
+
"""
|
|
156
|
+
self._request("POST", "/api/sessions", json={"name": name}, session_name=name)
|
|
157
|
+
if wait and not self.wait_for_session(name, timeout=timeout, interval=interval):
|
|
158
|
+
raise TimeoutError(
|
|
159
|
+
f"session {name!r} did not appear in the read cache within {timeout}s"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def delete_session(self, name: str) -> None:
|
|
163
|
+
"""DELETE /api/sessions/{name}."""
|
|
164
|
+
self._request("DELETE", f"/api/sessions/{name}", session_name=name)
|
|
165
|
+
|
|
166
|
+
def wait_for_session(
|
|
167
|
+
self, name: str, *, timeout: float = 6.0, interval: float = 0.3
|
|
168
|
+
) -> bool:
|
|
169
|
+
"""Poll GET /api/sessions until *name* appears (poll-cache race)."""
|
|
170
|
+
deadline = time.monotonic() + timeout
|
|
171
|
+
while True:
|
|
172
|
+
if any(s.name == name for s in self.sessions()):
|
|
173
|
+
return True
|
|
174
|
+
if time.monotonic() >= deadline:
|
|
175
|
+
return False
|
|
176
|
+
time.sleep(interval)
|
|
177
|
+
|
|
178
|
+
def connect(self, name: str) -> ConnectResult:
|
|
179
|
+
"""POST /api/sessions/{name}/connect.
|
|
180
|
+
|
|
181
|
+
WARNING: active_session is server-global. This moves the human's
|
|
182
|
+
browser view too.
|
|
183
|
+
"""
|
|
184
|
+
return protocol.parse_connect_result(
|
|
185
|
+
self._request("POST", f"/api/sessions/{name}/connect", session_name=name)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def set_active_view(self, view: str) -> None:
|
|
189
|
+
"""PATCH /api/state {"active_view": view}.
|
|
190
|
+
|
|
191
|
+
WARNING: active_view is server-global, last-writer-wins.
|
|
192
|
+
"""
|
|
193
|
+
self._request("PATCH", "/api/state", json={"active_view": view})
|
|
194
|
+
|
|
195
|
+
# ---- input ----
|
|
196
|
+
|
|
197
|
+
def send_input(
|
|
198
|
+
self,
|
|
199
|
+
name: str,
|
|
200
|
+
*,
|
|
201
|
+
text: str = "",
|
|
202
|
+
keys: Sequence[str] = (),
|
|
203
|
+
enter: bool = False,
|
|
204
|
+
lines: int | None = None,
|
|
205
|
+
) -> InputResult:
|
|
206
|
+
"""POST /api/sessions/{name}/input.
|
|
207
|
+
|
|
208
|
+
At least one of text/keys/enter must be non-empty (else the server
|
|
209
|
+
400s). Send order is text -> keys -> enter. Note that
|
|
210
|
+
keys=["Enter"] with enter=True sends TWO Enters.
|
|
211
|
+
"""
|
|
212
|
+
body: dict[str, Any] = {"text": text, "keys": list(keys), "enter": enter}
|
|
213
|
+
if lines is not None:
|
|
214
|
+
body["lines"] = lines
|
|
215
|
+
return protocol.parse_input_result(
|
|
216
|
+
self._request(
|
|
217
|
+
"POST", f"/api/sessions/{name}/input", json=body, session_name=name
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def run_shell_command(
|
|
222
|
+
self,
|
|
223
|
+
name: str,
|
|
224
|
+
command: str,
|
|
225
|
+
*,
|
|
226
|
+
timeout: float = 600.0,
|
|
227
|
+
poll_interval: float = 2.0,
|
|
228
|
+
lines: int = 500,
|
|
229
|
+
bell_on_failure: bool = True,
|
|
230
|
+
exit_expr: str = "$?",
|
|
231
|
+
token: str | None = None,
|
|
232
|
+
) -> CommandResult:
|
|
233
|
+
"""Run *command* to completion and return its real exit code.
|
|
234
|
+
|
|
235
|
+
ASSUMES the target pane is an idle POSIX shell prompt. If it is not
|
|
236
|
+
(vim, a REPL, less, a TUI, an ssh session), this types a line into
|
|
237
|
+
whatever is running and raises CommandTimeout.
|
|
238
|
+
|
|
239
|
+
Composed entirely from send_input() + session() + Sentinel; rebuild
|
|
240
|
+
it yourself from those if this shape does not fit.
|
|
241
|
+
|
|
242
|
+
Raises CommandTimeout, InputForbidden, SessionNotFound.
|
|
243
|
+
"""
|
|
244
|
+
sentinel = make_sentinel(token)
|
|
245
|
+
wrapped = sentinel.wrap(
|
|
246
|
+
command, bell_on_failure=bell_on_failure, exit_expr=exit_expr
|
|
247
|
+
)
|
|
248
|
+
self.send_input(name, text=wrapped, enter=True)
|
|
249
|
+
|
|
250
|
+
start = time.monotonic()
|
|
251
|
+
deadline = start + timeout
|
|
252
|
+
while True:
|
|
253
|
+
snap = self.session(name, lines=lines)
|
|
254
|
+
exit_code = sentinel.search(snap.snapshot)
|
|
255
|
+
if exit_code is not None:
|
|
256
|
+
return CommandResult(
|
|
257
|
+
session=name,
|
|
258
|
+
exit_code=exit_code,
|
|
259
|
+
snapshot=snap.snapshot,
|
|
260
|
+
elapsed=time.monotonic() - start,
|
|
261
|
+
token=sentinel.token,
|
|
262
|
+
)
|
|
263
|
+
if time.monotonic() >= deadline:
|
|
264
|
+
raise CommandTimeout(
|
|
265
|
+
session=name,
|
|
266
|
+
token=sentinel.token,
|
|
267
|
+
elapsed=time.monotonic() - start,
|
|
268
|
+
snapshot=snap.snapshot,
|
|
269
|
+
)
|
|
270
|
+
time.sleep(poll_interval)
|
|
271
|
+
|
|
272
|
+
# ---- opt-in version check ----
|
|
273
|
+
|
|
274
|
+
def check_server(self, min_version: str = MIN_SERVER_VERSION) -> InstanceInfo:
|
|
275
|
+
"""Fetch instance-info and raise MuxplexError if the server is older.
|
|
276
|
+
|
|
277
|
+
Never called automatically.
|
|
278
|
+
"""
|
|
279
|
+
info = self.instance_info()
|
|
280
|
+
if protocol.version_tuple(info.version) < protocol.version_tuple(min_version):
|
|
281
|
+
raise MuxplexError(
|
|
282
|
+
f"server version {info.version!r} is older than required {min_version!r}"
|
|
283
|
+
)
|
|
284
|
+
return info
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_detail(response: httpx.Response) -> str:
|
|
288
|
+
"""Best-effort extraction of a FastAPI-style {"detail": ...} error body."""
|
|
289
|
+
try:
|
|
290
|
+
body = response.json()
|
|
291
|
+
if isinstance(body, dict) and "detail" in body:
|
|
292
|
+
return str(body["detail"])
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
return response.text
|
|
@@ -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,11 @@
|
|
|
1
|
+
muxplex_client/__init__.py,sha256=soQxrx4jsGdQWk4VLCI1-rdIfPE7Z7vGtjx2_Tg7EpY,2263
|
|
2
|
+
muxplex_client/_protocol.py,sha256=vazzprjvzLdJKjGm0uPQRe4_9GPZd0zK34wqFF7kgO8,6721
|
|
3
|
+
muxplex_client/async_client.py,sha256=OgOP6SlnUIHcLmm5XCOTPaI-j9pBglCkZQ5J5cOdPe0,8765
|
|
4
|
+
muxplex_client/constants.py,sha256=9ozPCO-RATK0rGaW3SMkdhzepToRGONUWIQcleYEfxI,1417
|
|
5
|
+
muxplex_client/errors.py,sha256=JMll3s1XRXtC9fCWr5D-YbUXDhxJVK072ZoMmCxLJqQ,2759
|
|
6
|
+
muxplex_client/models.py,sha256=b4xfBKKE9lmfiDVYw15oWiw-5tbUMQ8_9PX4UO9oKTg,4516
|
|
7
|
+
muxplex_client/sentinel.py,sha256=Dl-omK35y4yHfbSYx26ARnvJBWcO-rQ_L3jlnZxs9EQ,3333
|
|
8
|
+
muxplex_client/sync_client.py,sha256=Y4pfJpTdUWr5yPNgaCT0TXtR156RFCrfofIeWiyZ-5M,10168
|
|
9
|
+
muxplex_client-0.19.0.dist-info/METADATA,sha256=94pqY8ebVaZZx8hJo2VIhWDO9QJRMXRwvPOSMDtussE,5073
|
|
10
|
+
muxplex_client-0.19.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
muxplex_client-0.19.0.dist-info/RECORD,,
|