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.
- muxplex_client-0.19.0/.gitignore +30 -0
- muxplex_client-0.19.0/PKG-INFO +128 -0
- muxplex_client-0.19.0/README.md +104 -0
- muxplex_client-0.19.0/muxplex_client/__init__.py +95 -0
- muxplex_client-0.19.0/muxplex_client/_protocol.py +215 -0
- muxplex_client-0.19.0/muxplex_client/async_client.py +262 -0
- muxplex_client-0.19.0/muxplex_client/constants.py +46 -0
- muxplex_client-0.19.0/muxplex_client/errors.py +80 -0
- muxplex_client-0.19.0/muxplex_client/models.py +164 -0
- muxplex_client-0.19.0/muxplex_client/sentinel.py +80 -0
- muxplex_client-0.19.0/muxplex_client/sync_client.py +295 -0
- muxplex_client-0.19.0/pyproject.toml +48 -0
- muxplex_client-0.19.0/tests/test_protocol.py +287 -0
- muxplex_client-0.19.0/tests/test_sentinel.py +90 -0
|
@@ -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)
|