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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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