sshmd 0.1.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.
sshm/process.py ADDED
@@ -0,0 +1,545 @@
1
+ """SSH subprocess management: sessions, scrollback, health checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import socket
9
+ import struct
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from .procutil import no_window_popen_flags, pid_alive
18
+
19
+ log = logging.getLogger("sshm.process")
20
+
21
+ RECONNECT_MIN = 1.0
22
+ RECONNECT_MAX = 60.0
23
+ STABLE_THRESHOLD = 30.0
24
+ SCROLLBACK_MAX = 16 * 1024 # scrollback buffer replayed to a newly attached client
25
+ READ_CHUNK = 4096
26
+
27
+ # On POSIX we run ssh under a real PTY so it can negotiate the remote terminal
28
+ # size and react to SIGWINCH. Windows has no pty module — there we keep the
29
+ # pipe-based model (the remote PTY stays at its default size). fcntl/termios are
30
+ # imported at module load (not inside the post-fork child) so preexec_fn can't
31
+ # deadlock on the import lock.
32
+ _HAS_PTY = sys.platform != "win32"
33
+ if _HAS_PTY:
34
+ import fcntl
35
+ import termios
36
+
37
+ # Pre-resolve everything the post-fork preexec_fn touches to plain globals, so
38
+ # the child (forked from a multithreaded daemon) does no module attribute
39
+ # lookups — only bound calls and bare syscalls — before exec.
40
+ _setsid = os.setsid
41
+ _ioctl = fcntl.ioctl
42
+ _TIOCSCTTY = termios.TIOCSCTTY
43
+
44
+
45
+ def _find_ssh() -> str:
46
+ ssh = shutil.which("ssh")
47
+ if not ssh:
48
+ raise RuntimeError("ssh not found in PATH")
49
+ return ssh
50
+
51
+
52
+ def _make_controlling_tty() -> None:
53
+ """preexec_fn (child side): own a new session with the slave PTY as our tty.
54
+
55
+ setsid() makes the child a session leader; TIOCSCTTY then makes its stdin
56
+ (the slave PTY) the controlling terminal, which is what lets the kernel
57
+ deliver SIGWINCH to ssh when we resize the master.
58
+ """
59
+ _setsid()
60
+ _ioctl(0, _TIOCSCTTY, 0)
61
+
62
+
63
+ def _set_winsize(fd: int, cols: int, rows: int) -> None:
64
+ # struct winsize is { ws_row, ws_col, ws_xpixel, ws_ypixel }
65
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
66
+
67
+
68
+ def _spawn_with_pty(cmd: list[str]) -> tuple[subprocess.Popen, int]:
69
+ """Spawn under a PTY; return (process, master_fd). POSIX only."""
70
+ import pty
71
+
72
+ master_fd, slave_fd = pty.openpty()
73
+ try:
74
+ proc = subprocess.Popen(
75
+ cmd,
76
+ stdin=slave_fd,
77
+ stdout=slave_fd,
78
+ stderr=slave_fd,
79
+ preexec_fn=_make_controlling_tty,
80
+ close_fds=True,
81
+ )
82
+ except BaseException:
83
+ os.close(master_fd)
84
+ os.close(slave_fd)
85
+ raise
86
+ os.close(slave_fd) # the child holds its own copy; the parent keeps the master
87
+ return proc, master_fd
88
+
89
+
90
+ @dataclass
91
+ class SshSession:
92
+ alias: str
93
+ name: str
94
+ process: subprocess.Popen | None = None
95
+ pid: int | None = None
96
+ started_at: float = 0.0
97
+ reconnect: bool = True
98
+ attached: bool = False
99
+ attached_pid: int | None = None
100
+ # PTY master fd that ssh's stdin/stdout/stderr are wired to (POSIX). None on
101
+ # Windows, where we fall back to the Popen pipes.
102
+ master_fd: int | None = None
103
+ # Last terminal size a client reported (cols, rows), re-applied across
104
+ # reconnects so the remote shell keeps its size.
105
+ last_winsize: tuple[int, int] | None = None
106
+
107
+ scrollback: bytearray = field(default_factory=bytearray, repr=False)
108
+ _active_socket: socket.socket | None = field(default=None, repr=False)
109
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
110
+ _reader_thread: threading.Thread | None = field(default=None, repr=False)
111
+ # Bumped on every (re)connect under _lock. A reader stamps the generation it
112
+ # was started for and stops appending once it no longer matches, so a dying
113
+ # reader can't pour stale output into the next process's scrollback.
114
+ _io_gen: int = 0
115
+ _backoff: float = RECONNECT_MIN
116
+ _last_attempt: float = 0.0
117
+
118
+ @property
119
+ def alive(self) -> bool:
120
+ return self.process is not None and self.process.poll() is None
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ return {
124
+ "alias": self.alias,
125
+ "name": self.name,
126
+ "pid": self.pid,
127
+ "started_at": self.started_at,
128
+ "uptime": time.time() - self.started_at if self.started_at else 0,
129
+ "alive": self.alive,
130
+ "attached": self.attached,
131
+ "port_forwards": [],
132
+ }
133
+
134
+ def adopt_process(self, proc: subprocess.Popen, master_fd: int | None = None) -> None:
135
+ """Take ownership of a freshly spawned SSH process and start reading it."""
136
+ with self._lock:
137
+ # Swap in the new process/master and bump the generation atomically, so
138
+ # a concurrent writer/resize sees a consistent (and open) master_fd and
139
+ # the previous reader stops appending into the fresh scrollback.
140
+ old_fd = self.master_fd
141
+ self.process = proc
142
+ self.master_fd = master_fd
143
+ self.pid = proc.pid
144
+ self.started_at = time.time()
145
+ self.scrollback = bytearray()
146
+ self._io_gen += 1
147
+ gen = self._io_gen
148
+ winsize = self.last_winsize
149
+
150
+ # Close the old master outside the lock: nothing reads self.master_fd as
151
+ # `old_fd` anymore, and the old reader ends when its captured fd goes away.
152
+ if old_fd is not None and old_fd != master_fd:
153
+ try:
154
+ os.close(old_fd)
155
+ except OSError:
156
+ pass
157
+
158
+ if master_fd is not None and winsize is not None:
159
+ self.set_winsize(*winsize) # re-apply the remembered size to the new PTY
160
+
161
+ self._start_reader(gen)
162
+
163
+ def set_winsize(self, cols: int, rows: int) -> None:
164
+ """Resize the remote terminal (ssh gets SIGWINCH from the master)."""
165
+ # struct winsize fields are unsigned 16-bit; clamp so a bogus size can't
166
+ # raise struct.error out of the ioctl pack.
167
+ cols = max(0, min(cols, 0xFFFF))
168
+ rows = max(0, min(rows, 0xFFFF))
169
+ with self._lock:
170
+ self.last_winsize = (cols, rows)
171
+ fd = self.master_fd
172
+ if fd is not None:
173
+ try:
174
+ _set_winsize(fd, cols, rows) # ioctl doesn't block — safe under the lock
175
+ except OSError:
176
+ pass
177
+
178
+ def _write_input(self, data: bytes) -> bool:
179
+ """Forward client bytes to the SSH process. Returns False on failure."""
180
+ with self._lock:
181
+ fd = self.master_fd
182
+ if fd is not None:
183
+ # dup under the lock so a concurrent _kill/reconnect closing
184
+ # master_fd can't make us write onto a reused fd; the private dup
185
+ # keeps the same PTY master alive for the duration of the write,
186
+ # which happens outside the lock (os.write can block on backpressure
187
+ # and must not stall the reader or deadlock a killer).
188
+ try:
189
+ wfd = os.dup(fd)
190
+ except OSError:
191
+ return False
192
+ stdin = None
193
+ else:
194
+ wfd = None
195
+ stdin = self.process.stdin if self.process else None
196
+
197
+ if wfd is not None:
198
+ try:
199
+ os.write(wfd, data)
200
+ return True
201
+ except (OSError, ValueError):
202
+ return False
203
+ finally:
204
+ try:
205
+ os.close(wfd)
206
+ except OSError:
207
+ pass
208
+
209
+ if not stdin:
210
+ return False
211
+ try:
212
+ stdin.write(data)
213
+ stdin.flush()
214
+ return True
215
+ except (OSError, ValueError):
216
+ return False
217
+
218
+ def _start_reader(self, gen: int) -> None:
219
+ """Background thread: SSH output → scrollback + attached socket, if any."""
220
+ process = self.process
221
+ if process is None:
222
+ return
223
+
224
+ # Read from the PTY master where we have one, else the stdout pipe.
225
+ if self.master_fd is not None:
226
+ fd = self.master_fd
227
+ elif process.stdout is not None:
228
+ fd = process.stdout.fileno()
229
+ else:
230
+ return
231
+
232
+ # Bind the Popen object to the thread for its lifetime (as a default arg,
233
+ # which the function object holds): a reconnect rebinds self.process, and
234
+ # without this reference the pipe fd could be closed (and reused) while we
235
+ # still read from it. The PTY master is owned by the session and closed
236
+ # explicitly on reconnect/kill, so the keepalive is only load-bearing for
237
+ # the Windows pipe path.
238
+ def _read(_keepalive: subprocess.Popen = process) -> None:
239
+ while True:
240
+ try:
241
+ data = os.read(fd, READ_CHUNK) # unbuffered — returns as soon as data is available
242
+ if not data:
243
+ break
244
+ except (OSError, ValueError):
245
+ break
246
+
247
+ with self._lock:
248
+ # A reconnect (new generation) reset scrollback for the new
249
+ # process; stop so we don't append this dying process's tail.
250
+ if self._io_gen != gen:
251
+ break
252
+ self.scrollback.extend(data)
253
+ if len(self.scrollback) > SCROLLBACK_MAX:
254
+ del self.scrollback[:-SCROLLBACK_MAX]
255
+ sock = self._active_socket
256
+
257
+ # Send to the client OUTSIDE the lock. A stalled or flow-controlled
258
+ # client (Ctrl-S, a suspended `sshm attach`, a full TCP window) makes
259
+ # this blocking send hang; holding _lock here would wedge detach,
260
+ # _kill, set_winsize and _write_input (all need the lock) — and detach
261
+ # closing the socket, the very thing that would unblock us, couldn't
262
+ # run. Detaching/closing the socket from elsewhere unblocks the send.
263
+ if sock is not None:
264
+ try:
265
+ sock.sendall(data)
266
+ except OSError:
267
+ with self._lock:
268
+ if self._active_socket is sock:
269
+ self._active_socket = None
270
+
271
+ log.info("Reader thread ended for %s/%s", self.alias, self.name)
272
+
273
+ self._reader_thread = threading.Thread(target=_read, daemon=True, name=f"reader-{self.name}")
274
+ self._reader_thread.start()
275
+
276
+ def detach(self) -> None:
277
+ """Drop the attached client, closing its socket if present."""
278
+ with self._lock:
279
+ if self._active_socket:
280
+ try:
281
+ self._active_socket.close()
282
+ except OSError:
283
+ pass
284
+ self._active_socket = None
285
+ self.attached = False
286
+ self.attached_pid = None
287
+
288
+ def bridge(self, conn: socket.socket, cli_pid: int | None = None) -> None:
289
+ """Bridge a TCP socket to this session's SSH I/O. Blocks until detach."""
290
+ with self._lock:
291
+ self.attached = True
292
+ self.attached_pid = cli_pid
293
+ # Snapshot scrollback to replay. Leave _active_socket unset for now so
294
+ # the reader doesn't stream live output until the replay has been sent.
295
+ replay = bytes(self.scrollback)
296
+
297
+ # Replay outside the lock — like the reader's send, a blocking sendall to a
298
+ # stalled client must not hold the session lock. Output produced during the
299
+ # replay stays in scrollback (it just isn't shown live to this attach).
300
+ if replay:
301
+ try:
302
+ conn.sendall(replay)
303
+ except OSError:
304
+ self.detach()
305
+ return
306
+ with self._lock:
307
+ self._active_socket = conn
308
+
309
+ # Socket → SSH input (PTY master, or stdin pipe on Windows)
310
+ try:
311
+ while True:
312
+ data = conn.recv(READ_CHUNK)
313
+ if not data:
314
+ break
315
+ if not self._write_input(data):
316
+ break
317
+ except OSError:
318
+ pass
319
+ finally:
320
+ self.detach()
321
+ log.info("Bridge ended for %s/%s", self.alias, self.name)
322
+
323
+
324
+ class ProcessManager:
325
+ def __init__(self) -> None:
326
+ self.sessions: dict[str, SshSession] = {}
327
+ self._ssh = _find_ssh()
328
+ # Guards every access to self.sessions. Reentrant because the public
329
+ # methods call one another (attach -> connect, rebuild -> _kill/connect).
330
+ # Lock order is always ProcessManager._lock -> SshSession._lock, never the
331
+ # reverse: bridge()/the reader thread take only the session lock, so they
332
+ # can run while a handler holds this one (e.g. during _kill's wait).
333
+ self._lock = threading.RLock()
334
+
335
+ @staticmethod
336
+ def _key(alias: str, name: str) -> str:
337
+ return f"{alias}/{name}"
338
+
339
+ def _next_name(self, alias: str) -> str:
340
+ existing = {s.name for s in self.sessions.values() if s.alias == alias}
341
+ n = 1
342
+ while f"{alias}-{n}" in existing:
343
+ n += 1
344
+ return f"{alias}-{n}"
345
+
346
+ def _spawn(self, alias: str) -> tuple[subprocess.Popen, int | None]:
347
+ cmd = [
348
+ self._ssh, alias,
349
+ "-tt", # force remote PTY
350
+ "-o", "ServerAliveInterval=10",
351
+ "-o", "ServerAliveCountMax=3",
352
+ ]
353
+ log.info("Spawning: %s", " ".join(cmd))
354
+ if _HAS_PTY:
355
+ return _spawn_with_pty(cmd)
356
+ proc = subprocess.Popen(
357
+ cmd,
358
+ stdout=subprocess.PIPE,
359
+ stderr=subprocess.STDOUT,
360
+ stdin=subprocess.PIPE,
361
+ **no_window_popen_flags(),
362
+ )
363
+ return proc, None
364
+
365
+ def connect(self, alias: str, name: str | None = None) -> SshSession:
366
+ with self._lock:
367
+ if name is None:
368
+ name = self._next_name(alias)
369
+
370
+ key = self._key(alias, name)
371
+ existing = self.sessions.get(key)
372
+ if existing and existing.alive:
373
+ return existing
374
+
375
+ session = SshSession(alias=alias, name=name)
376
+ proc, master_fd = self._spawn(alias)
377
+ session.adopt_process(proc, master_fd)
378
+ self.sessions[key] = session
379
+ log.info("Connected %s (PID %s)", key, session.pid)
380
+ return session
381
+
382
+ def attach(
383
+ self, alias: str, name: str | None = None, cli_pid: int | None = None
384
+ ) -> SshSession | None:
385
+ """Reserve the first unattached live session (or create one) for a client.
386
+
387
+ The session is marked attached here, atomically, so two concurrent attach
388
+ requests can never hand the same shell to two clients. bridge() later just
389
+ confirms the flag; check_orphaned_attaches reclaims it if the client dies
390
+ before bridging.
391
+ """
392
+ with self._lock:
393
+ if name:
394
+ session = self.sessions.get(self._key(alias, name))
395
+ if session and not session.attached and session.alive:
396
+ session.attached = True
397
+ session.attached_pid = cli_pid
398
+ return session
399
+ return None
400
+
401
+ for s in self.sessions.values():
402
+ if s.alias == alias and not s.attached and s.alive:
403
+ s.attached = True
404
+ s.attached_pid = cli_pid
405
+ return s
406
+
407
+ session = self.connect(alias)
408
+ session.attached = True
409
+ session.attached_pid = cli_pid
410
+ return session
411
+
412
+ # The disconnect paths pop the session(s) out of the dict under the lock, then
413
+ # _kill them OUTSIDE it: killing blocks on process.wait(), and holding the PM
414
+ # lock across that would serialize every other session operation for seconds.
415
+ def disconnect(self, alias: str, name: str) -> bool:
416
+ with self._lock:
417
+ session = self.sessions.pop(self._key(alias, name), None)
418
+ if not session:
419
+ return False
420
+ session.reconnect = False
421
+ self._kill(session)
422
+ return True
423
+
424
+ def disconnect_alias(self, alias: str) -> int:
425
+ with self._lock:
426
+ keys = [k for k, s in self.sessions.items() if s.alias == alias]
427
+ sessions = [self.sessions.pop(k) for k in keys]
428
+ for session in sessions:
429
+ session.reconnect = False
430
+ self._kill(session)
431
+ return len(sessions)
432
+
433
+ def disconnect_all(self) -> None:
434
+ with self._lock:
435
+ sessions = list(self.sessions.values())
436
+ self.sessions.clear()
437
+ for session in sessions:
438
+ session.reconnect = False
439
+ self._kill(session)
440
+
441
+ def _kill(self, session: SshSession) -> None:
442
+ """Terminate a session. Must be called on a session already removed from
443
+ the dict, and NOT while holding self._lock (it blocks on process.wait)."""
444
+ session.detach() # kick the attached client, if any
445
+
446
+ if session.alive:
447
+ try:
448
+ session.process.terminate()
449
+ session.process.wait(timeout=5)
450
+ except Exception:
451
+ try:
452
+ session.process.kill()
453
+ except Exception:
454
+ pass
455
+ log.info("Killed %s/%s (PID %s)", session.alias, session.name, session.pid)
456
+
457
+ # Null the fd under the session lock (so a racing writer sees None and
458
+ # won't dup it), then close it outside the lock.
459
+ with session._lock:
460
+ fd = session.master_fd
461
+ session.master_fd = None
462
+ if fd is not None:
463
+ try:
464
+ os.close(fd)
465
+ except OSError:
466
+ pass
467
+
468
+ def get_sessions(self, alias: str | None = None) -> list[SshSession]:
469
+ with self._lock:
470
+ if alias:
471
+ return [s for s in self.sessions.values() if s.alias == alias]
472
+ return list(self.sessions.values())
473
+
474
+ def count_unattached(self, alias: str) -> int:
475
+ with self._lock:
476
+ return sum(
477
+ 1 for s in self.sessions.values()
478
+ if s.alias == alias and not s.attached and s.alive
479
+ )
480
+
481
+ def ensure_unattached(self, alias: str) -> None:
482
+ with self._lock:
483
+ if self.count_unattached(alias) == 0:
484
+ log.info("Spawning new unattached session for %s", alias)
485
+ self.connect(alias)
486
+
487
+ def check_health(self) -> None:
488
+ with self._lock:
489
+ to_reconnect: list[SshSession] = []
490
+
491
+ for key, session in list(self.sessions.items()):
492
+ if not session.process:
493
+ continue
494
+
495
+ retcode = session.process.poll()
496
+ if retcode is None:
497
+ if time.time() - session.started_at > STABLE_THRESHOLD:
498
+ session._backoff = RECONNECT_MIN
499
+ continue
500
+
501
+ log.warning("Session %s exited (code %s)", key, retcode)
502
+ session.detach()
503
+
504
+ # Clean exit (exit/logout) = remove session, don't reconnect.
505
+ # Non-zero (e.g. 255 = connection lost) = reconnect if enabled.
506
+ if retcode == 0 or not session.reconnect:
507
+ self.sessions.pop(key, None)
508
+ else:
509
+ now = time.time()
510
+ if now - session._last_attempt >= session._backoff:
511
+ to_reconnect.append(session)
512
+ session._last_attempt = now
513
+
514
+ for session in to_reconnect:
515
+ log.info(
516
+ "Reconnecting %s/%s (backoff %.1fs)",
517
+ session.alias, session.name, session._backoff,
518
+ )
519
+ try:
520
+ proc, master_fd = self._spawn(session.alias)
521
+ session.adopt_process(proc, master_fd)
522
+ log.info("Reconnected %s/%s (PID %s)", session.alias, session.name, session.pid)
523
+ except Exception as e:
524
+ log.error("Failed to reconnect %s/%s: %s", session.alias, session.name, e)
525
+ finally:
526
+ session._backoff = min(session._backoff * 2, RECONNECT_MAX)
527
+
528
+ def check_orphaned_attaches(self) -> None:
529
+ with self._lock:
530
+ for s in self.sessions.values():
531
+ if s.attached and s.attached_pid and not pid_alive(s.attached_pid):
532
+ log.warning(
533
+ "CLI pid %s gone, auto-detaching %s/%s",
534
+ s.attached_pid, s.alias, s.name,
535
+ )
536
+ s.detach()
537
+
538
+ def rebuild_session(self, alias: str, name: str) -> SshSession | None:
539
+ """Kill and restart a session (e.g. after a config change)."""
540
+ with self._lock:
541
+ session = self.sessions.pop(self._key(alias, name), None)
542
+ if not session:
543
+ return None
544
+ self._kill(session) # outside the lock — blocks on process.wait
545
+ return self.connect(alias, name)
sshm/procutil.py ADDED
@@ -0,0 +1,60 @@
1
+ """Cross-platform process helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def pid_alive(pid: int) -> bool:
12
+ """Check whether a process with the given PID exists."""
13
+ try:
14
+ if sys.platform == "win32":
15
+ import ctypes
16
+
17
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
18
+ kernel32 = ctypes.windll.kernel32
19
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
20
+ if not handle:
21
+ return False
22
+ kernel32.CloseHandle(handle)
23
+ return True
24
+ os.kill(pid, 0)
25
+ return True
26
+ except (ValueError, OSError, ProcessLookupError):
27
+ return False
28
+
29
+
30
+ def daemon_interpreter() -> str:
31
+ """Interpreter for spawning the background daemon.
32
+
33
+ On Windows prefers pythonw.exe (GUI subsystem — can never open a console
34
+ window), falling back to the current interpreter.
35
+ """
36
+ if sys.platform == "win32":
37
+ pythonw = Path(sys.executable).with_name("pythonw.exe")
38
+ if pythonw.exists():
39
+ return str(pythonw)
40
+ return sys.executable
41
+
42
+
43
+ def detached_popen_flags() -> dict:
44
+ """Popen kwargs that detach the child from the current console/session."""
45
+ if sys.platform == "win32":
46
+ # CREATE_NO_WINDOW (hidden console) instead of DETACHED_PROCESS (no
47
+ # console): launcher/trampoline exes (uv venv python, script shims)
48
+ # re-spawn the real interpreter as a child, which would allocate a
49
+ # fresh *visible* console when the parent has none. A hidden console
50
+ # is inherited by such children.
51
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
52
+ return {"creationflags": CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
53
+ return {"start_new_session": True}
54
+
55
+
56
+ def no_window_popen_flags() -> dict:
57
+ """Popen kwargs that suppress a console window on Windows."""
58
+ if sys.platform == "win32":
59
+ return {"creationflags": subprocess.CREATE_NO_WINDOW}
60
+ return {}
sshm/protocol.py ADDED
@@ -0,0 +1,53 @@
1
+ """IPC message schemas and encoding for sshm daemon communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ # --- Request commands ---
10
+
11
+ CMD_LIST = "list"
12
+ CMD_CONNECT = "connect"
13
+ CMD_DISCONNECT = "disconnect"
14
+ CMD_ATTACH = "attach"
15
+ CMD_DETACH = "detach"
16
+ CMD_RESIZE = "resize"
17
+ CMD_PORT_ADD = "port_add"
18
+ CMD_PORT_REMOVE = "port_remove"
19
+ CMD_ENABLE = "enable"
20
+ CMD_DISABLE = "disable"
21
+ CMD_REMOVE = "remove"
22
+ CMD_RENAME = "rename"
23
+ CMD_STATUS = "status"
24
+ CMD_SHUTDOWN = "shutdown"
25
+
26
+
27
+ def make_request(cmd: str, token: str, **kwargs) -> dict[str, Any]:
28
+ return {"cmd": cmd, "token": token, **kwargs}
29
+
30
+
31
+ def make_response(ok: bool, data: Any = None, error: str | None = None) -> dict[str, Any]:
32
+ resp: dict[str, Any] = {"ok": ok}
33
+ if data is not None:
34
+ resp["data"] = data
35
+ if error is not None:
36
+ resp["error"] = error
37
+ return resp
38
+
39
+
40
+ def ok(data: Any = None) -> dict[str, Any]:
41
+ return make_response(True, data=data)
42
+
43
+
44
+ def err(message: str) -> dict[str, Any]:
45
+ return make_response(False, error=message)
46
+
47
+
48
+ def encode(msg: dict[str, Any]) -> bytes:
49
+ return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
50
+
51
+
52
+ def decode(data: bytes) -> dict[str, Any]:
53
+ return json.loads(data.decode("utf-8").strip())
sshm/py.typed ADDED
File without changes