hugpy-control 0.2.0a0__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.
@@ -0,0 +1,49 @@
1
+ """hugpy-control: the job/settings/principal control plane of the Hugpy ecosystem.
2
+
3
+ Submodules (all stdlib + ``hugpy_platform``; none import an engine, fleet or
4
+ server package):
5
+
6
+ bus in-process typed message bus + topic constants
7
+ jobs JobStore — the one all-transport job store (chat, media, downloads)
8
+ shared SqliteMirror — the cross-process mirror the JobStore rides on
9
+ settings SettingsStore — namespaced runtime settings (F4)
10
+ principals PrincipalStore — identities, tokens, capability checks (F2)
11
+ calllog append-only call log + tail reader
12
+ job_schemas compat re-export of the download-job names
13
+
14
+ Submodules are resolved lazily (PEP 562) so ``import hugpy_control`` stays
15
+ side-effect free: the store singletons are only created when the module that
16
+ owns them is first imported.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+
22
+ try: # the installed distribution's version: the workspace tag/commit, never a literal
23
+ from importlib.metadata import version as _dist_version
24
+ __version__ = _dist_version("hugpy-control")
25
+ except Exception: # noqa: BLE001 — source tree without metadata
26
+ __version__ = "0.0.0+unknown"
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "bus",
31
+ "calllog",
32
+ "job_schemas",
33
+ "jobs",
34
+ "principals",
35
+ "settings",
36
+ "shared",
37
+ ]
38
+
39
+ _SUBMODULES = frozenset(n for n in __all__ if not n.startswith("__"))
40
+
41
+
42
+ def __getattr__(name: str):
43
+ if name in _SUBMODULES:
44
+ return importlib.import_module(f"{__name__}.{name}")
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
46
+
47
+
48
+ def __dir__():
49
+ return sorted(set(globals()) | _SUBMODULES)
hugpy_control/bus.py ADDED
@@ -0,0 +1,240 @@
1
+ """F1 — one typed, frozen, addressed message bus. Control rides the same bus.
2
+
3
+ Scope (deliberate): this is the STATE/CONTROL plane, not the token data
4
+ plane. Token streams stay on their SSE pipes — publishing every token through
5
+ a fan-out bus would tax the hot path for nothing. What travels here:
6
+
7
+ job.created / job.status / job.done lifecycle (published by the
8
+ JobStore adapter, see wire below)
9
+ control.cancel stop a generation, any transport
10
+ catalog.changed on-disk model inventory changed
11
+ (storage publishes, engine consumes)
12
+ control.restart / control.* (future) worker ops, module updates
13
+ worker.* / keeper.* (future) registries, health, keeper comms
14
+
15
+ Envelope: BusMessage — frozen, addressed (principal / channel / target /
16
+ job_id), serializable (to_dict/from_dict), so an HTTP or SSE relay can carry
17
+ it across processes verbatim. In-process today (one bus per process, same
18
+ one-gunicorn-process model as the JobStore); remote transports reach it
19
+ through thin route adapters that publish/subscribe on their behalf.
20
+
21
+ Delivery: each Subscription owns a bounded queue. Publishing never blocks —
22
+ on overflow the OLDEST message is dropped and the drop is counted loudly on
23
+ the subscription (a slow consumer must not stall cancels for everyone else).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import queue
29
+ import threading
30
+ import time
31
+ import uuid
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Iterator, Optional
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Topic conventions. Match exact ("control.cancel") or prefix-wildcard
38
+ # ("job.*" matches job.created, job.status, ...; "*" matches everything).
39
+ TOPIC_JOB_CREATED = "job.created"
40
+ TOPIC_JOB_STATUS = "job.status"
41
+ TOPIC_JOB_DONE = "job.done" # any terminal state; payload carries which
42
+ TOPIC_CONTROL_CANCEL = "control.cancel"
43
+ # Catalog invalidation (partition protocol, see EXTRACTION_GUIDE §4): published by
44
+ # hugpy_storage after a download/delete changes what is on disk; consumed by
45
+ # hugpy_engine to drop its catalog/manifest caches. Payload convention:
46
+ # {"model_key": str|None, "reason": "download"|"delete"|"prune"|"reconcile"|...}.
47
+ TOPIC_CATALOG_CHANGED = "catalog.changed"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class BusMessage:
52
+ topic: str
53
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
54
+ ts: float = field(default_factory=time.time)
55
+ source: Optional[str] = None # transport/component that published
56
+ principal: Optional[str] = None # who caused it (F2 threads through here)
57
+ channel: Optional[str] = None # conversational context
58
+ target: Optional[str] = None # addressed recipient; None = broadcast
59
+ job_id: Optional[str] = None
60
+ payload: dict = field(default_factory=dict)
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "topic": self.topic, "id": self.id, "ts": self.ts,
65
+ "source": self.source, "principal": self.principal,
66
+ "channel": self.channel, "target": self.target,
67
+ "job_id": self.job_id, "payload": dict(self.payload),
68
+ }
69
+
70
+ @classmethod
71
+ def from_dict(cls, d: dict) -> "BusMessage":
72
+ return cls(
73
+ topic=str(d.get("topic") or ""),
74
+ id=str(d.get("id") or uuid.uuid4().hex),
75
+ ts=float(d.get("ts") or time.time()),
76
+ source=d.get("source"), principal=d.get("principal"),
77
+ channel=d.get("channel"), target=d.get("target"),
78
+ job_id=d.get("job_id"), payload=dict(d.get("payload") or {}),
79
+ )
80
+
81
+
82
+ def _topic_matches(pattern: str, topic: str) -> bool:
83
+ if pattern == "*" or pattern == topic:
84
+ return True
85
+ if pattern.endswith(".*"):
86
+ return topic.startswith(pattern[:-1])
87
+ return False
88
+
89
+
90
+ class Subscription:
91
+ """One consumer's mailbox. Iterate it (blocking) or get(timeout=...).
92
+ close() detaches it from the bus and unblocks any pending get."""
93
+
94
+ _SENTINEL = object()
95
+
96
+ def __init__(self, bus: "Bus", topics: tuple[str, ...],
97
+ target: Optional[str], maxsize: int) -> None:
98
+ self._bus = bus
99
+ self.topics = topics
100
+ self.target = target
101
+ self._q: "queue.Queue[Any]" = queue.Queue(maxsize=maxsize)
102
+ self.dropped = 0
103
+ self.closed = False
104
+
105
+ def _matches(self, msg: BusMessage) -> bool:
106
+ if self.target is not None and msg.target not in (None, self.target):
107
+ return False
108
+ return any(_topic_matches(p, msg.topic) for p in self.topics)
109
+
110
+ def _offer(self, msg: BusMessage) -> None:
111
+ while True:
112
+ try:
113
+ self._q.put_nowait(msg)
114
+ return
115
+ except queue.Full:
116
+ try:
117
+ self._q.get_nowait()
118
+ self.dropped += 1
119
+ if self.dropped in (1, 100) or self.dropped % 1000 == 0:
120
+ logger.warning(
121
+ "bus subscription %s dropped %d message(s) "
122
+ "(slow consumer)", self.topics, self.dropped)
123
+ except queue.Empty:
124
+ pass
125
+
126
+ def get(self, timeout: Optional[float] = None) -> Optional[BusMessage]:
127
+ try:
128
+ item = self._q.get(timeout=timeout)
129
+ except queue.Empty:
130
+ return None
131
+ return None if item is self._SENTINEL else item
132
+
133
+ def __iter__(self) -> Iterator[BusMessage]:
134
+ while not self.closed:
135
+ item = self._q.get()
136
+ if item is self._SENTINEL:
137
+ return
138
+ yield item
139
+
140
+ def close(self) -> None:
141
+ self.closed = True
142
+ self._bus._detach(self)
143
+ try:
144
+ self._q.put_nowait(self._SENTINEL)
145
+ except queue.Full:
146
+ pass
147
+
148
+
149
+ class Bus:
150
+ def __init__(self) -> None:
151
+ self._subs: list[Subscription] = []
152
+ self._lock = threading.Lock()
153
+
154
+ def subscribe(self, *topics: str, target: Optional[str] = None,
155
+ maxsize: int = 256) -> Subscription:
156
+ sub = Subscription(self, topics or ("*",), target, maxsize)
157
+ with self._lock:
158
+ self._subs.append(sub)
159
+ return sub
160
+
161
+ def _detach(self, sub: Subscription) -> None:
162
+ with self._lock:
163
+ try:
164
+ self._subs.remove(sub)
165
+ except ValueError:
166
+ pass
167
+
168
+ def publish(self, topic: Optional[str] = None, *,
169
+ msg: Optional[BusMessage] = None, **fields: Any) -> BusMessage:
170
+ """publish("control.cancel", job_id=...) or publish(msg=BusMessage(...))."""
171
+ if msg is None:
172
+ if not topic:
173
+ raise ValueError("publish() needs a topic or a msg")
174
+ msg = BusMessage(topic=topic, **fields)
175
+ with self._lock:
176
+ subs = list(self._subs)
177
+ for sub in subs:
178
+ if not sub.closed and sub._matches(msg):
179
+ sub._offer(msg)
180
+ return msg
181
+
182
+
183
+ bus = Bus()
184
+
185
+ _WIRED: dict[tuple[int, int], threading.Thread] = {}
186
+ _WIRED_LOCK = threading.Lock()
187
+
188
+
189
+ def wire_cancel(the_bus: Optional[Bus] = None, store=None) -> threading.Thread:
190
+ """F1.3 — control messages act through the same substrate everywhere:
191
+ a daemon thread subscribes to control.cancel and calls JobStore.cancel,
192
+ which fires the cancel handle the owning stream attached. Idempotent per
193
+ (bus, store) pair; call it from every process entrypoint that serves jobs
194
+ (flask app factory, worker agent main)."""
195
+ from hugpy_control.jobs import job_store as _default_store
196
+ the_bus = the_bus or bus
197
+ store = store if store is not None else _default_store
198
+ key = (id(the_bus), id(store))
199
+ with _WIRED_LOCK:
200
+ th = _WIRED.get(key)
201
+ if th is not None and th.is_alive():
202
+ return th
203
+ sub = the_bus.subscribe(TOPIC_CONTROL_CANCEL)
204
+
205
+ def _run() -> None:
206
+ for m in sub:
207
+ if m.job_id:
208
+ store.cancel(m.job_id,
209
+ reason=str(m.payload.get("reason") or ""))
210
+
211
+ th = threading.Thread(target=_run, name="comms-cancel", daemon=True)
212
+ th.start()
213
+ _WIRED[key] = th
214
+ return th
215
+
216
+
217
+ def wire_job_events(the_bus: Optional[Bus] = None, store=None,
218
+ source: Optional[str] = None) -> None:
219
+ """Publish job lifecycle transitions onto the bus (job.created /
220
+ job.status / job.done) via the store's on_change hook — the store never
221
+ imports the bus, this adapter is the one seam between them."""
222
+ from hugpy_control.jobs import job_store as _default_store, TERMINAL_STATUSES
223
+ the_bus = the_bus or bus
224
+ store = store if store is not None else _default_store
225
+
226
+ def _on_change(job, prior: str) -> None:
227
+ status = job.to_dict()["status"]
228
+ if not prior:
229
+ topic = TOPIC_JOB_CREATED
230
+ elif status in TERMINAL_STATUSES:
231
+ topic = TOPIC_JOB_DONE
232
+ else:
233
+ topic = TOPIC_JOB_STATUS
234
+ the_bus.publish(topic, source=source, principal=job.principal,
235
+ channel=job.channel, target=job.worker,
236
+ job_id=job.id,
237
+ payload={"status": status, "prior": prior,
238
+ "kind": job.kind, "model_key": job.model_key})
239
+
240
+ store.on_change = _on_change
@@ -0,0 +1,131 @@
1
+ """CALL-LOG-20260910: append-only call log (one JSON line per event) + tail reader.
2
+
3
+ Events: phase "start" (job created; carries client/ua/route when a request
4
+ context exists) and phase "end" (job finished; carries status, worker, tokens,
5
+ duration_ms, error). The reader merges the two by job id, newest first.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import socket
13
+ import threading
14
+ import time
15
+
16
+ log = logging.getLogger(__name__)
17
+ _LOCK = threading.Lock()
18
+ _HOST = socket.gethostname()
19
+
20
+
21
+ def path() -> str:
22
+ env = (os.environ.get("HUGPY_CALL_LOG") or "").strip()
23
+ if env:
24
+ return env
25
+ db = (os.environ.get("HUGPY_COMMS_DB") or "").strip()
26
+ base = os.path.dirname(db) if db else ""
27
+ if not base:
28
+ try:
29
+ from hugpy_platform.constants import DEFAULT_ROOT
30
+ base = os.path.join(str(DEFAULT_ROOT), "comms")
31
+ except Exception: # noqa: BLE001
32
+ base = os.path.expanduser("~/.hugpy")
33
+ return os.path.join(base, "calls.jsonl")
34
+
35
+
36
+ def _request_context() -> dict:
37
+ try:
38
+ from flask import has_request_context, request
39
+ if not has_request_context():
40
+ return {}
41
+ xff = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
42
+ return {
43
+ "client": xff or request.remote_addr,
44
+ "ua": (request.headers.get("User-Agent") or "")[:160],
45
+ "route": request.path,
46
+ "method": request.method,
47
+ "host": request.host,
48
+ }
49
+ except Exception: # noqa: BLE001
50
+ return {}
51
+
52
+
53
+ def _err_text(err) -> str | None:
54
+ if err is None:
55
+ return None
56
+ for attr in ("message", "msg"):
57
+ v = getattr(err, attr, None)
58
+ if v:
59
+ return str(v)[:400]
60
+ if isinstance(err, dict):
61
+ return str(err.get("message") or err)[:400]
62
+ return str(err)[:400]
63
+
64
+
65
+ def record(phase: str, job, **extra) -> None:
66
+ """Append one event for `job`. Never raises — logging must not break serving."""
67
+ try:
68
+ row = {
69
+ "ts": time.time(), "phase": phase, "node": _HOST,
70
+ "id": getattr(job, "id", None), "kind": getattr(job, "kind", None),
71
+ "model_key": getattr(job, "model_key", None), "model": getattr(job, "model_name", None),
72
+ "principal": getattr(job, "principal", None), "transport": getattr(job, "transport", None),
73
+ "channel": getattr(job, "channel", None), "worker": getattr(job, "worker", None),
74
+ "status": getattr(job, "status", None), "tokens": getattr(job, "tokens", 0),
75
+ "started_ts": getattr(job, "started_ts", None),
76
+ }
77
+ if phase == "start":
78
+ row.update(_request_context())
79
+ else:
80
+ st = getattr(job, "started_ts", None)
81
+ row["duration_ms"] = int((time.time() - st) * 1000) if st else None
82
+ row["error"] = _err_text(getattr(job, "error", None))
83
+ row.update(extra)
84
+ line = json.dumps(row, default=str)
85
+ p = path()
86
+ os.makedirs(os.path.dirname(p), exist_ok=True)
87
+ with _LOCK, open(p, "a", encoding="utf-8") as fh:
88
+ fh.write(line + "\n")
89
+ except Exception: # noqa: BLE001
90
+ log.debug("call log write failed", exc_info=True)
91
+
92
+
93
+ def read(limit: int = 300, since: float | None = None, tail_bytes: int = 4 * 1024 * 1024) -> list[dict]:
94
+ """Newest-first merged calls from the tail of the log."""
95
+ p = path()
96
+ try:
97
+ with open(p, "rb") as fh:
98
+ fh.seek(0, 2)
99
+ size = fh.tell()
100
+ fh.seek(max(0, size - tail_bytes))
101
+ data = fh.read().decode("utf-8", errors="replace")
102
+ except OSError:
103
+ return []
104
+ lines = data.split("\n")
105
+ if size > tail_bytes and lines:
106
+ lines = lines[1:] # drop the partial first line
107
+ calls: dict = {}
108
+ order: list = []
109
+ for line in lines:
110
+ if not line.strip():
111
+ continue
112
+ try:
113
+ ev = json.loads(line)
114
+ except ValueError:
115
+ continue
116
+ jid = ev.get("id") or f"anon-{ev.get('ts')}"
117
+ if jid not in calls:
118
+ calls[jid] = {}
119
+ order.append(jid)
120
+ cur = calls[jid]
121
+ if ev.get("phase") == "start":
122
+ cur.update({k: v for k, v in ev.items() if k != "phase"})
123
+ cur.setdefault("started_ts", ev.get("ts"))
124
+ else:
125
+ cur.update({k: v for k, v in ev.items() if k not in ("phase", "ts") and v is not None})
126
+ cur["ended_ts"] = ev.get("ts")
127
+ rows = [calls[j] for j in order]
128
+ if since:
129
+ rows = [r for r in rows if (r.get("started_ts") or r.get("ts") or 0) >= since]
130
+ rows.sort(key=lambda r: r.get("started_ts") or r.get("ts") or 0, reverse=True)
131
+ return rows[:limit]
@@ -0,0 +1,35 @@
1
+ """Compat shim for the download-job schema names.
2
+
3
+ The Job/JobStore that used to live here (download jobs only) is now the shared,
4
+ all-transport store in :mod:`hugpy_control.jobs` (F5). Download callers keep
5
+ importing from here unchanged; the singleton is the same store every other
6
+ transport enqueues into.
7
+ """
8
+ # The Job/JobStore that used to live here (download jobs only) is now the
9
+ # shared, all-transport store in hugpy_control.jobs (F5): one
10
+ # schema for chat requests AND downloads, canonical lifecycle
11
+ # pending→processing→streaming→(done|cancelled|failed), with the old
12
+ # queued/running/completed names normalized on write. Download callers keep
13
+ # importing from here unchanged; the singleton is the same store every other
14
+ # transport enqueues into.
15
+ from hugpy_control.jobs import (
16
+ CANONICAL_STATUSES,
17
+ LEGACY_FOR_CANONICAL,
18
+ TERMINAL_STATUSES,
19
+ Job,
20
+ JobError,
21
+ JobStore,
22
+ job_store,
23
+ normalize_status,
24
+ )
25
+
26
+ __all__ = [
27
+ "CANONICAL_STATUSES",
28
+ "LEGACY_FOR_CANONICAL",
29
+ "TERMINAL_STATUSES",
30
+ "Job",
31
+ "JobError",
32
+ "JobStore",
33
+ "job_store",
34
+ "normalize_status",
35
+ ]