devman-runtime 0.1.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.
- devman_runtime-0.1.0/LICENSE +21 -0
- devman_runtime-0.1.0/PKG-INFO +49 -0
- devman_runtime-0.1.0/README.md +35 -0
- devman_runtime-0.1.0/pyproject.toml +27 -0
- devman_runtime-0.1.0/setup.cfg +4 -0
- devman_runtime-0.1.0/src/devman_runtime/__init__.py +23 -0
- devman_runtime-0.1.0/src/devman_runtime/client.py +138 -0
- devman_runtime-0.1.0/src/devman_runtime/db.py +111 -0
- devman_runtime-0.1.0/src/devman_runtime/protocol.py +55 -0
- devman_runtime-0.1.0/src/devman_runtime/server.py +794 -0
- devman_runtime-0.1.0/src/devman_runtime/telegraf.py +125 -0
- devman_runtime-0.1.0/src/devman_runtime.egg-info/PKG-INFO +49 -0
- devman_runtime-0.1.0/src/devman_runtime.egg-info/SOURCES.txt +13 -0
- devman_runtime-0.1.0/src/devman_runtime.egg-info/dependency_links.txt +1 -0
- devman_runtime-0.1.0/src/devman_runtime.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kenji Shu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devman-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python runtime (protocol, client, server core) for devman-gen generated device-manager bridges
|
|
5
|
+
Author: Kenji Shu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/kenji0923/devman-runtime
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# devman-runtime
|
|
16
|
+
|
|
17
|
+
Python runtime library for bridges generated by [devman-gen](https://github.com/kenji0923/devman-gen).
|
|
18
|
+
|
|
19
|
+
devman-gen is a language-agnostic generator that wraps a device-control
|
|
20
|
+
library (e.g. `caen_libs` for CAEN HV crates) in a client/server pair with
|
|
21
|
+
resource-ownership management. This package is the Python-side runtime that
|
|
22
|
+
those generated packages import, so runtime fixes reach every bridge through
|
|
23
|
+
a normal dependency upgrade instead of regeneration.
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
26
|
+
|
|
27
|
+
- `devman_runtime.protocol` — length-prefixed JSON over TCP.
|
|
28
|
+
- `devman_runtime.client` — `ManagerClient`: sessions, `acquire`/`release`
|
|
29
|
+
resource ownership, `invoke`, link-group registration
|
|
30
|
+
(`set_link_groups` / `list_link_groups`).
|
|
31
|
+
- `devman_runtime.server` — `ManagerCore` and `serve_manager`: request
|
|
32
|
+
dispatch with ownership enforcement, device lifecycle/periodic hooks,
|
|
33
|
+
`TripWatchdog` (powers off linked partners when a channel trips, with
|
|
34
|
+
lease-based stale-group janitoring).
|
|
35
|
+
- `devman_runtime.db` — SQLite-backed ownership and link-group registry.
|
|
36
|
+
|
|
37
|
+
## Server features
|
|
38
|
+
|
|
39
|
+
- `trip_watchdog_interval` / `--trip-watchdog-interval`: poll registered
|
|
40
|
+
link groups and power off partners of a tripped channel, even when the
|
|
41
|
+
registering client is offline.
|
|
42
|
+
- `client_lease_sec` / `--client-lease-sec`: any authenticated request
|
|
43
|
+
renews a client lease; groups of lease-expired clients are janitored when
|
|
44
|
+
all their channels are off, and kept protected while energized.
|
|
45
|
+
- Periodic hook (`periodic_file` / `periodic_function` +
|
|
46
|
+
`periodic_interval_sec`): run a user callback on an interval with access
|
|
47
|
+
to the device singleton.
|
|
48
|
+
|
|
49
|
+
This package is used by, e.g., `caenhv-devman-client`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# devman-runtime
|
|
2
|
+
|
|
3
|
+
Python runtime library for bridges generated by [devman-gen](https://github.com/kenji0923/devman-gen).
|
|
4
|
+
|
|
5
|
+
devman-gen is a language-agnostic generator that wraps a device-control
|
|
6
|
+
library (e.g. `caen_libs` for CAEN HV crates) in a client/server pair with
|
|
7
|
+
resource-ownership management. This package is the Python-side runtime that
|
|
8
|
+
those generated packages import, so runtime fixes reach every bridge through
|
|
9
|
+
a normal dependency upgrade instead of regeneration.
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- `devman_runtime.protocol` — length-prefixed JSON over TCP.
|
|
14
|
+
- `devman_runtime.client` — `ManagerClient`: sessions, `acquire`/`release`
|
|
15
|
+
resource ownership, `invoke`, link-group registration
|
|
16
|
+
(`set_link_groups` / `list_link_groups`).
|
|
17
|
+
- `devman_runtime.server` — `ManagerCore` and `serve_manager`: request
|
|
18
|
+
dispatch with ownership enforcement, device lifecycle/periodic hooks,
|
|
19
|
+
`TripWatchdog` (powers off linked partners when a channel trips, with
|
|
20
|
+
lease-based stale-group janitoring).
|
|
21
|
+
- `devman_runtime.db` — SQLite-backed ownership and link-group registry.
|
|
22
|
+
|
|
23
|
+
## Server features
|
|
24
|
+
|
|
25
|
+
- `trip_watchdog_interval` / `--trip-watchdog-interval`: poll registered
|
|
26
|
+
link groups and power off partners of a tripped channel, even when the
|
|
27
|
+
registering client is offline.
|
|
28
|
+
- `client_lease_sec` / `--client-lease-sec`: any authenticated request
|
|
29
|
+
renews a client lease; groups of lease-expired clients are janitored when
|
|
30
|
+
all their channels are off, and kept protected while energized.
|
|
31
|
+
- Periodic hook (`periodic_file` / `periodic_function` +
|
|
32
|
+
`periodic_interval_sec`): run a user callback on an interval with access
|
|
33
|
+
to the device singleton.
|
|
34
|
+
|
|
35
|
+
This package is used by, e.g., `caenhv-devman-client`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devman-runtime"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python runtime (protocol, client, server core) for devman-gen generated device-manager bridges"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Kenji Shu" }]
|
|
14
|
+
dependencies = []
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/kenji0923/devman-runtime"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
package-dir = { "" = "src" }
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Python runtime library for devman-gen generated bridges.
|
|
2
|
+
|
|
3
|
+
Generated client packages use ManagerClient; generated server packages use
|
|
4
|
+
serve_manager / ManagerCore. The generator itself (devman-gen) is language
|
|
5
|
+
agnostic and is not required at runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import ManagerClient, ManagerError
|
|
9
|
+
from .db import OwnershipDB
|
|
10
|
+
from .server import ManagerCore, RuntimeFunctionSpec, TripWatchdog, serve_manager
|
|
11
|
+
from .telegraf import TelegrafSender, build_line
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ManagerClient",
|
|
15
|
+
"ManagerError",
|
|
16
|
+
"ManagerCore",
|
|
17
|
+
"OwnershipDB",
|
|
18
|
+
"RuntimeFunctionSpec",
|
|
19
|
+
"TelegrafSender",
|
|
20
|
+
"TripWatchdog",
|
|
21
|
+
"build_line",
|
|
22
|
+
"serve_manager",
|
|
23
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .protocol import recv_message, send_message
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ManagerError(RuntimeError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ManagerClient:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
host: str,
|
|
17
|
+
port: int,
|
|
18
|
+
client_name: str,
|
|
19
|
+
timeout: float = 5.0,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.host = host
|
|
22
|
+
self.port = int(port)
|
|
23
|
+
self.client_name = str(client_name).strip()
|
|
24
|
+
if not self.client_name:
|
|
25
|
+
raise ValueError("client_name is required")
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self._session: str | None = None
|
|
28
|
+
|
|
29
|
+
def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
|
|
31
|
+
send_message(sock, payload)
|
|
32
|
+
response = recv_message(sock)
|
|
33
|
+
|
|
34
|
+
if response.get("status") != "ok":
|
|
35
|
+
error = response.get("error", "unknown manager error")
|
|
36
|
+
raise ManagerError(str(error))
|
|
37
|
+
return response
|
|
38
|
+
|
|
39
|
+
def connect(self, force: bool = False) -> None:
|
|
40
|
+
if self._session:
|
|
41
|
+
return
|
|
42
|
+
response = self._request({"op": "connect", "client": self.client_name, "force": bool(force)})
|
|
43
|
+
session = response.get("session")
|
|
44
|
+
if not session:
|
|
45
|
+
raise ManagerError("manager did not return a session token")
|
|
46
|
+
self._session = str(session)
|
|
47
|
+
|
|
48
|
+
def disconnect(self) -> None:
|
|
49
|
+
if not self._session:
|
|
50
|
+
return
|
|
51
|
+
session = self._session
|
|
52
|
+
self._session = None
|
|
53
|
+
self._request(
|
|
54
|
+
{
|
|
55
|
+
"op": "disconnect",
|
|
56
|
+
"client": self.client_name,
|
|
57
|
+
"session": session,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
self.disconnect()
|
|
63
|
+
|
|
64
|
+
def __enter__(self) -> "ManagerClient":
|
|
65
|
+
self.connect()
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
69
|
+
self.disconnect()
|
|
70
|
+
|
|
71
|
+
def _with_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
self.connect()
|
|
73
|
+
assert self._session is not None
|
|
74
|
+
data = dict(payload)
|
|
75
|
+
data["client"] = self.client_name
|
|
76
|
+
data["session"] = self._session
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
def acquire(self, resource: str) -> bool:
|
|
80
|
+
response = self._request(self._with_session({"op": "acquire", "resource": resource}))
|
|
81
|
+
return bool(response.get("acquired", False))
|
|
82
|
+
|
|
83
|
+
def release(self, resource: str) -> bool:
|
|
84
|
+
response = self._request(self._with_session({"op": "release", "resource": resource}))
|
|
85
|
+
return bool(response.get("released", False))
|
|
86
|
+
|
|
87
|
+
def owner_of(self, resource: str) -> str | None:
|
|
88
|
+
response = self._request(self._with_session({"op": "owner_of", "resource": resource}))
|
|
89
|
+
owner = response.get("owner")
|
|
90
|
+
if owner is None:
|
|
91
|
+
return None
|
|
92
|
+
return str(owner)
|
|
93
|
+
|
|
94
|
+
def owners_of(self, resources: list[str]) -> dict[str, str | None]:
|
|
95
|
+
response = self._request(self._with_session({"op": "owners_of", "resources": list(resources)}))
|
|
96
|
+
owners = response.get("owners")
|
|
97
|
+
if not isinstance(owners, dict):
|
|
98
|
+
return {}
|
|
99
|
+
result: dict[str, str | None] = {}
|
|
100
|
+
for key, value in owners.items():
|
|
101
|
+
if value is None:
|
|
102
|
+
result[str(key)] = None
|
|
103
|
+
else:
|
|
104
|
+
result[str(key)] = str(value)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
def set_link_groups(self, groups: list[list[str]]) -> int:
|
|
108
|
+
response = self._request(
|
|
109
|
+
self._with_session({"op": "set_link_groups", "groups": [list(g) for g in groups]})
|
|
110
|
+
)
|
|
111
|
+
return int(response.get("groups", 0))
|
|
112
|
+
|
|
113
|
+
def list_link_groups(self) -> dict[str, list[list[str]]]:
|
|
114
|
+
response = self._request(self._with_session({"op": "list_link_groups"}))
|
|
115
|
+
registered = response.get("link_groups")
|
|
116
|
+
return registered if isinstance(registered, dict) else {}
|
|
117
|
+
|
|
118
|
+
def invoke(
|
|
119
|
+
self,
|
|
120
|
+
function: str,
|
|
121
|
+
args: list[Any],
|
|
122
|
+
kwargs: dict[str, Any],
|
|
123
|
+
resources: list[str],
|
|
124
|
+
handle: str | None = None,
|
|
125
|
+
) -> Any:
|
|
126
|
+
payload = self._with_session(
|
|
127
|
+
{
|
|
128
|
+
"op": "call",
|
|
129
|
+
"function": function,
|
|
130
|
+
"args": args,
|
|
131
|
+
"kwargs": kwargs,
|
|
132
|
+
"resources": resources,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
if handle is not None:
|
|
136
|
+
payload["handle"] = handle
|
|
137
|
+
response = self._request(payload)
|
|
138
|
+
return response.get("result")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OwnershipDB:
|
|
8
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
9
|
+
self.db_path = str(db_path)
|
|
10
|
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
11
|
+
self._conn.execute(
|
|
12
|
+
"""
|
|
13
|
+
CREATE TABLE IF NOT EXISTS ownership (
|
|
14
|
+
resource TEXT PRIMARY KEY,
|
|
15
|
+
owner TEXT NOT NULL,
|
|
16
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
17
|
+
)
|
|
18
|
+
"""
|
|
19
|
+
)
|
|
20
|
+
self._conn.execute(
|
|
21
|
+
"""
|
|
22
|
+
CREATE TABLE IF NOT EXISTS link_groups (
|
|
23
|
+
client TEXT NOT NULL,
|
|
24
|
+
group_idx INTEGER NOT NULL,
|
|
25
|
+
resource TEXT NOT NULL,
|
|
26
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
27
|
+
PRIMARY KEY (client, group_idx, resource)
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
)
|
|
31
|
+
self._conn.commit()
|
|
32
|
+
|
|
33
|
+
def owner_of(self, resource: str) -> str | None:
|
|
34
|
+
row = self._conn.execute(
|
|
35
|
+
"SELECT owner FROM ownership WHERE resource = ?", (resource,)
|
|
36
|
+
).fetchone()
|
|
37
|
+
if row is None:
|
|
38
|
+
return None
|
|
39
|
+
return str(row[0])
|
|
40
|
+
|
|
41
|
+
def acquire(self, resource: str, owner: str) -> bool:
|
|
42
|
+
current = self.owner_of(resource)
|
|
43
|
+
if current is None:
|
|
44
|
+
self._conn.execute(
|
|
45
|
+
"INSERT INTO ownership(resource, owner) VALUES(?, ?)", (resource, owner)
|
|
46
|
+
)
|
|
47
|
+
self._conn.commit()
|
|
48
|
+
return True
|
|
49
|
+
if current == owner:
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def release(self, resource: str, owner: str) -> bool:
|
|
54
|
+
current = self.owner_of(resource)
|
|
55
|
+
if current != owner:
|
|
56
|
+
return False
|
|
57
|
+
self._conn.execute("DELETE FROM ownership WHERE resource = ?", (resource,))
|
|
58
|
+
self._conn.commit()
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def release_all_by_owner(self, owner: str) -> int:
|
|
62
|
+
cur = self._conn.execute("DELETE FROM ownership WHERE owner = ?", (owner,))
|
|
63
|
+
self._conn.commit()
|
|
64
|
+
return int(cur.rowcount)
|
|
65
|
+
|
|
66
|
+
def set_link_groups(self, client: str, groups: list[list[str]]) -> int:
|
|
67
|
+
"""Replace all link groups registered by a client.
|
|
68
|
+
|
|
69
|
+
Groups persist across client disconnects so a server-side watchdog
|
|
70
|
+
can keep protecting them while the client application is closed.
|
|
71
|
+
"""
|
|
72
|
+
self._conn.execute("DELETE FROM link_groups WHERE client = ?", (client,))
|
|
73
|
+
count = 0
|
|
74
|
+
for idx, group in enumerate(groups):
|
|
75
|
+
members = sorted({str(resource) for resource in group})
|
|
76
|
+
if len(members) < 2:
|
|
77
|
+
continue
|
|
78
|
+
for resource in members:
|
|
79
|
+
self._conn.execute(
|
|
80
|
+
"INSERT OR REPLACE INTO link_groups(client, group_idx, resource) VALUES(?, ?, ?)",
|
|
81
|
+
(client, idx, resource),
|
|
82
|
+
)
|
|
83
|
+
count += 1
|
|
84
|
+
self._conn.commit()
|
|
85
|
+
return count
|
|
86
|
+
|
|
87
|
+
def link_groups_by_idx(self) -> dict[str, dict[int, list[str]]]:
|
|
88
|
+
rows = self._conn.execute(
|
|
89
|
+
"SELECT client, group_idx, resource FROM link_groups ORDER BY client, group_idx, resource"
|
|
90
|
+
).fetchall()
|
|
91
|
+
grouped: dict[str, dict[int, list[str]]] = {}
|
|
92
|
+
for client, group_idx, resource in rows:
|
|
93
|
+
grouped.setdefault(str(client), {}).setdefault(int(group_idx), []).append(str(resource))
|
|
94
|
+
return grouped
|
|
95
|
+
|
|
96
|
+
def all_link_groups(self) -> dict[str, list[list[str]]]:
|
|
97
|
+
return {
|
|
98
|
+
client: [members for _idx, members in sorted(groups.items())]
|
|
99
|
+
for client, groups in self.link_groups_by_idx().items()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def remove_link_group(self, client: str, group_idx: int) -> int:
|
|
103
|
+
cur = self._conn.execute(
|
|
104
|
+
"DELETE FROM link_groups WHERE client = ? AND group_idx = ?",
|
|
105
|
+
(client, int(group_idx)),
|
|
106
|
+
)
|
|
107
|
+
self._conn.commit()
|
|
108
|
+
return int(cur.rowcount)
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self._conn.close()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, is_dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import struct
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProtocolError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json_default(value: Any) -> Any:
|
|
16
|
+
if is_dataclass(value):
|
|
17
|
+
return asdict(value)
|
|
18
|
+
if isinstance(value, Enum):
|
|
19
|
+
return value.name
|
|
20
|
+
if isinstance(value, (bytes, bytearray)):
|
|
21
|
+
return value.decode("utf-8", errors="replace")
|
|
22
|
+
if hasattr(value, "__dict__"):
|
|
23
|
+
return vars(value)
|
|
24
|
+
return str(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def send_message(sock: socket.socket, payload: dict[str, Any]) -> None:
|
|
28
|
+
body = json.dumps(payload, default=_json_default, separators=(",", ":")).encode("utf-8")
|
|
29
|
+
sock.sendall(struct.pack("!I", len(body)))
|
|
30
|
+
sock.sendall(body)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _recv_exact(sock: socket.socket, size: int) -> bytes:
|
|
34
|
+
chunks: list[bytes] = []
|
|
35
|
+
remaining = size
|
|
36
|
+
while remaining > 0:
|
|
37
|
+
chunk = sock.recv(remaining)
|
|
38
|
+
if not chunk:
|
|
39
|
+
raise ProtocolError("unexpected EOF while reading message")
|
|
40
|
+
chunks.append(chunk)
|
|
41
|
+
remaining -= len(chunk)
|
|
42
|
+
return b"".join(chunks)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def recv_message(sock: socket.socket) -> dict[str, Any]:
|
|
46
|
+
header = _recv_exact(sock, 4)
|
|
47
|
+
(size,) = struct.unpack("!I", header)
|
|
48
|
+
raw = _recv_exact(sock, size)
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(raw.decode("utf-8"))
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
raise ProtocolError(f"invalid message JSON: {exc}") from exc
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise ProtocolError("message must be a JSON object")
|
|
55
|
+
return data
|