hugpy-platform 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.
- hugpy_platform/__init__.py +105 -0
- hugpy_platform/app_dirs.py +305 -0
- hugpy_platform/async_runtime.py +226 -0
- hugpy_platform/atomic_json.py +28 -0
- hugpy_platform/binaries.py +49 -0
- hugpy_platform/buildinfo.py +356 -0
- hugpy_platform/central.py +34 -0
- hugpy_platform/choices.py +9 -0
- hugpy_platform/client_liveness.py +207 -0
- hugpy_platform/compat_pydantic.py +177 -0
- hugpy_platform/constants.py +331 -0
- hugpy_platform/env.py +18 -0
- hugpy_platform/environment_status.py +19 -0
- hugpy_platform/except_utils.py +62 -0
- hugpy_platform/filesystem.py +54 -0
- hugpy_platform/formatting.py +23 -0
- hugpy_platform/hardware.py +169 -0
- hugpy_platform/model_keys.py +22 -0
- hugpy_platform/module_imports.py +305 -0
- hugpy_platform/no_think.py +25 -0
- hugpy_platform/paths.py +12 -0
- hugpy_platform/platform_facade.py +101 -0
- hugpy_platform/procutil.py +146 -0
- hugpy_platform/py.typed +0 -0
- hugpy_platform/results.py +48 -0
- hugpy_platform/runner_config.py +10 -0
- hugpy_platform/trust.py +56 -0
- hugpy_platform/utils.py +448 -0
- hugpy_platform/verdict.py +32 -0
- hugpy_platform/versioning.py +13 -0
- hugpy_platform-0.2.0a0.dist-info/METADATA +41 -0
- hugpy_platform-0.2.0a0.dist-info/RECORD +35 -0
- hugpy_platform-0.2.0a0.dist-info/WHEEL +5 -0
- hugpy_platform-0.2.0a0.dist-info/licenses/LICENSE +41 -0
- hugpy_platform-0.2.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""hugpy-platform: the stdlib-first foundation every Hugpy package stands on.
|
|
2
|
+
|
|
3
|
+
What lives here (and nothing above it — no engine, storage, fleet or server):
|
|
4
|
+
|
|
5
|
+
central canonical central base URL (``HUGPY_BASE_URL`` + aliases)
|
|
6
|
+
platform_facade OS facts (``IS_WINDOWS``/``IS_MACOS``/``IS_LINUX``) and the
|
|
7
|
+
sanitising ``env_value`` seam over ``abstract_essentials``
|
|
8
|
+
app_dirs per-OS data/config/cache/engine dirs and the ``~/.hugpy``
|
|
9
|
+
runtime-file accessors
|
|
10
|
+
constants storage roots, HF cache layout, fleet-wide defaults
|
|
11
|
+
(import has side effects: creates dirs — import it explicitly)
|
|
12
|
+
utils small filesystem / message / naming helpers
|
|
13
|
+
module_imports ``get_<package>()`` lazy accessors for heavy ML libraries
|
|
14
|
+
async_runtime one process-wide asyncio loop + sync bridges
|
|
15
|
+
client_liveness "is the HTTP client still there" probe (Flask optional)
|
|
16
|
+
binaries / procutil / hardware executables, process trees, RAM/GPU probes
|
|
17
|
+
compat_pydantic pure-Python pydantic stand-in for platforms without pydantic_core
|
|
18
|
+
except_utils ``caught`` / ``attempt`` / ``catching`` logging helpers
|
|
19
|
+
trust Hugging Face publisher trust tiers
|
|
20
|
+
buildinfo which build is this process: workspace version, sha/dirty,
|
|
21
|
+
editable source, lockstep check (``/health``, ``/build``, heartbeat)
|
|
22
|
+
|
|
23
|
+
This ``__init__`` re-exports only the light, side-effect-free names below; the
|
|
24
|
+
heavier modules are imported by dotted path. ``buildinfo`` is exported lazily
|
|
25
|
+
(``hugpy_platform.buildinfo`` resolves on first attribute access, PEP 562) so
|
|
26
|
+
importing the package never pays for it.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from hugpy_platform.app_dirs import (
|
|
31
|
+
cache_dir,
|
|
32
|
+
config_dir,
|
|
33
|
+
data_dir,
|
|
34
|
+
engine_dir,
|
|
35
|
+
ensure_hugpy_home,
|
|
36
|
+
hugpy_config_dir,
|
|
37
|
+
hugpy_home,
|
|
38
|
+
hugpy_logs_dir,
|
|
39
|
+
hugpy_run_dir,
|
|
40
|
+
hugpy_state_dir,
|
|
41
|
+
models_root,
|
|
42
|
+
)
|
|
43
|
+
from hugpy_platform.central import CENTRAL_ENV_VARS, DEFAULT_CENTRAL, central_base_url
|
|
44
|
+
from hugpy_platform.compat_pydantic import ensure_pydantic
|
|
45
|
+
from hugpy_platform.except_utils import FAILED, attempt, catching, caught, caught_block
|
|
46
|
+
from hugpy_platform.platform_facade import EXE_SUFFIX, IS_LINUX, IS_MACOS, IS_WINDOWS, env_value
|
|
47
|
+
from hugpy_platform.trust import trust_label, trust_tier
|
|
48
|
+
|
|
49
|
+
try: # the installed distribution's version: the workspace tag/commit, never a literal
|
|
50
|
+
from importlib.metadata import version as _dist_version
|
|
51
|
+
__version__ = _dist_version("hugpy-platform")
|
|
52
|
+
except Exception: # noqa: BLE001 — source tree without metadata
|
|
53
|
+
__version__ = "0.0.0+unknown"
|
|
54
|
+
|
|
55
|
+
_LAZY_SUBMODULES = ("buildinfo",)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def __getattr__(name: str):
|
|
59
|
+
"""Lazy submodule export: ``hugpy_platform.buildinfo`` without an import-time cost."""
|
|
60
|
+
if name in _LAZY_SUBMODULES:
|
|
61
|
+
import importlib
|
|
62
|
+
module = importlib.import_module(f"{__name__}.{name}")
|
|
63
|
+
globals()[name] = module
|
|
64
|
+
return module
|
|
65
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"__version__",
|
|
70
|
+
# central
|
|
71
|
+
"CENTRAL_ENV_VARS",
|
|
72
|
+
"DEFAULT_CENTRAL",
|
|
73
|
+
"central_base_url",
|
|
74
|
+
# platform facts + env seam
|
|
75
|
+
"EXE_SUFFIX",
|
|
76
|
+
"IS_LINUX",
|
|
77
|
+
"IS_MACOS",
|
|
78
|
+
"IS_WINDOWS",
|
|
79
|
+
"env_value",
|
|
80
|
+
# app dirs
|
|
81
|
+
"cache_dir",
|
|
82
|
+
"config_dir",
|
|
83
|
+
"data_dir",
|
|
84
|
+
"engine_dir",
|
|
85
|
+
"ensure_hugpy_home",
|
|
86
|
+
"hugpy_config_dir",
|
|
87
|
+
"hugpy_home",
|
|
88
|
+
"hugpy_logs_dir",
|
|
89
|
+
"hugpy_run_dir",
|
|
90
|
+
"hugpy_state_dir",
|
|
91
|
+
"models_root",
|
|
92
|
+
# pydantic shim
|
|
93
|
+
"ensure_pydantic",
|
|
94
|
+
# exception helpers
|
|
95
|
+
"FAILED",
|
|
96
|
+
"attempt",
|
|
97
|
+
"catching",
|
|
98
|
+
"caught",
|
|
99
|
+
"caught_block",
|
|
100
|
+
# trust tiers
|
|
101
|
+
"trust_label",
|
|
102
|
+
"trust_tier",
|
|
103
|
+
# build identity (lazy submodule)
|
|
104
|
+
"buildinfo",
|
|
105
|
+
]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Per-OS application directories — one source of truth.
|
|
2
|
+
|
|
3
|
+
Replaces the scattered hardcoded ``/srv/abstractendeavors/...``,
|
|
4
|
+
``~/.local/share/hugpy``, ``/etc/llama-swap``, and ``/mnt/llm_storage`` literals.
|
|
5
|
+
Every path is overridable by the same env vars the code already honoured, so
|
|
6
|
+
existing Linux deployments are unaffected; only the *defaults* become per-OS:
|
|
7
|
+
|
|
8
|
+
data_dir() Linux ~/.local/share/hugpy macOS ~/Library/Application Support/hugpy Windows %LOCALAPPDATA%\\hugpy
|
|
9
|
+
config_dir() Linux ~/.config/hugpy macOS ~/Library/Application Support/hugpy Windows %LOCALAPPDATA%\\hugpy
|
|
10
|
+
cache_dir() Linux ~/.cache/hugpy macOS ~/Library/Caches/hugpy Windows %LOCALAPPDATA%\\hugpy\\Cache
|
|
11
|
+
engine_dir() data_dir()/engine — where the fetched llama.cpp binary lands
|
|
12
|
+
models_root() DEFAULT_ROOT or data_dir()/llm_storage
|
|
13
|
+
|
|
14
|
+
We use ``platformdirs`` when available (added to base deps) and fall back to a
|
|
15
|
+
hand-rolled per-OS layout so this module never hard-fails on import.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from hugpy_platform.platform_facade import IS_MACOS, IS_WINDOWS, env_value
|
|
22
|
+
|
|
23
|
+
_APP = "hugpy"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _home(*parts: str) -> str:
|
|
27
|
+
return os.path.join(os.path.expanduser("~"), *parts)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fallback_data() -> str:
|
|
31
|
+
if IS_WINDOWS:
|
|
32
|
+
base = os.environ.get("LOCALAPPDATA") or _home("AppData", "Local")
|
|
33
|
+
return os.path.join(base, _APP)
|
|
34
|
+
if IS_MACOS:
|
|
35
|
+
return _home("Library", "Application Support", _APP)
|
|
36
|
+
return os.path.join(os.environ.get("XDG_DATA_HOME") or _home(".local", "share"), _APP)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fallback_config() -> str:
|
|
40
|
+
if IS_WINDOWS:
|
|
41
|
+
base = os.environ.get("LOCALAPPDATA") or _home("AppData", "Local")
|
|
42
|
+
return os.path.join(base, _APP)
|
|
43
|
+
if IS_MACOS:
|
|
44
|
+
return _home("Library", "Application Support", _APP)
|
|
45
|
+
return os.path.join(os.environ.get("XDG_CONFIG_HOME") or _home(".config"), _APP)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fallback_cache() -> str:
|
|
49
|
+
if IS_WINDOWS:
|
|
50
|
+
base = os.environ.get("LOCALAPPDATA") or _home("AppData", "Local")
|
|
51
|
+
return os.path.join(base, _APP, "Cache")
|
|
52
|
+
if IS_MACOS:
|
|
53
|
+
return _home("Library", "Caches", _APP)
|
|
54
|
+
return os.path.join(os.environ.get("XDG_CACHE_HOME") or _home(".cache"), _APP)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dirs():
|
|
58
|
+
try:
|
|
59
|
+
import platformdirs
|
|
60
|
+
|
|
61
|
+
return platformdirs.PlatformDirs(_APP, appauthor=False)
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def data_dir() -> str:
|
|
67
|
+
override = env_value("HUGPY_DATA_DIR")
|
|
68
|
+
if override:
|
|
69
|
+
return _ensure(override)
|
|
70
|
+
d = _dirs()
|
|
71
|
+
return _ensure(d.user_data_dir if d else _fallback_data())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def config_dir() -> str:
|
|
75
|
+
override = env_value("HUGPY_CONFIG_DIR")
|
|
76
|
+
if override:
|
|
77
|
+
return _ensure(override)
|
|
78
|
+
d = _dirs()
|
|
79
|
+
return _ensure(d.user_config_dir if d else _fallback_config())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cache_dir() -> str:
|
|
83
|
+
override = env_value("HUGPY_CACHE_DIR")
|
|
84
|
+
if override:
|
|
85
|
+
return _ensure(override)
|
|
86
|
+
d = _dirs()
|
|
87
|
+
return _ensure(d.user_cache_dir if d else _fallback_cache())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def engine_dir() -> str:
|
|
91
|
+
"""Where ``hugpy install-engine`` unpacks the native llama.cpp binaries."""
|
|
92
|
+
override = env_value("HUGPY_ENGINE_DIR") or env_value("LLAMA_CPP_DIR")
|
|
93
|
+
if override:
|
|
94
|
+
return _ensure(override)
|
|
95
|
+
return _ensure(os.path.join(data_dir(), "engine"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def models_root() -> str:
|
|
99
|
+
"""Model/upload/dataset storage root.
|
|
100
|
+
|
|
101
|
+
Honours the legacy ``DEFAULT_ROOT``/``MODELS_HOME`` env vars first — but only
|
|
102
|
+
if that path can actually be created and written. A stale/un-writable override
|
|
103
|
+
(e.g. ``DEFAULT_ROOT=/mnt/llm_storage`` carried in a server ``.env`` onto a
|
|
104
|
+
worker or a phone where ``/mnt`` is read-only) is ignored in favour of a
|
|
105
|
+
per-user dir under ``data_dir()``, so storage never lands on a dead path.
|
|
106
|
+
"""
|
|
107
|
+
override = env_value("DEFAULT_ROOT")
|
|
108
|
+
if override and _usable(override):
|
|
109
|
+
return override
|
|
110
|
+
# Preserve the historical Linux mount when it exists and is writable.
|
|
111
|
+
legacy = "/mnt/llm_storage"
|
|
112
|
+
try:
|
|
113
|
+
if os.path.isdir(legacy) and os.access(legacy, os.W_OK):
|
|
114
|
+
return legacy
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return _ensure(os.path.join(data_dir(), "llm_storage"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def demo_media_base() -> str:
|
|
121
|
+
"""Base URL the video arm's canned demo loads its sample media from."""
|
|
122
|
+
return env_value("HUGPY_DEMO_MEDIA_BASE") or "https://hugpy.ai/demo-media"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def demo_media_dir() -> str:
|
|
126
|
+
"""Local demo-media tree to serve at ``/demo-media/`` (self-hosters).
|
|
127
|
+
|
|
128
|
+
Empty string means "not configured" — deliberately NO default and NO
|
|
129
|
+
directory creation; the ``/demo-media/`` route only exists when this is set.
|
|
130
|
+
"""
|
|
131
|
+
return env_value("HUGPY_DEMO_MEDIA_DIR") or ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _usable(path: str) -> bool:
|
|
135
|
+
"""True only if *path* exists (or can be created) AND is writable."""
|
|
136
|
+
try:
|
|
137
|
+
os.makedirs(path, exist_ok=True)
|
|
138
|
+
return os.path.isdir(path) and os.access(path, os.W_OK)
|
|
139
|
+
except OSError:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ensure(path: str) -> str:
|
|
144
|
+
try:
|
|
145
|
+
os.makedirs(path, exist_ok=True)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
return path
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# hugpy runtime "mechanic" files (HUGPY_HOME, default ~/.hugpy)
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Historically these state/config/log files were written straight into $HOME
|
|
155
|
+
# (``~/todo.json``, ``~/steward-state.json``, ``~/.abstract_hugpy_worker.json``,
|
|
156
|
+
# ``~/model_metadata.db`` …), strewing the home directory. They now live under a
|
|
157
|
+
# single base dir — ``HUGPY_HOME`` (default ``~/.hugpy``) — split into::
|
|
158
|
+
#
|
|
159
|
+
# state/ todo.json, steward-state.json, model_metadata.db, flow.json, …
|
|
160
|
+
# config/ abstract_hugpy_worker.json (+ .settings.json/.update.json)
|
|
161
|
+
# logs/ bridge-mail.jsonl, bugreport.json
|
|
162
|
+
# run/ *.lock
|
|
163
|
+
#
|
|
164
|
+
# Each named accessor MIGRATES a legacy ``~/<name>`` file into its new home the
|
|
165
|
+
# first time it is resolved (atomic same-fs rename, cross-fs copy fallback), so
|
|
166
|
+
# upgrades are seamless and idempotent — no flag-day, no data loss. Every path
|
|
167
|
+
# stays overridable so existing deployments and tests can pin locations.
|
|
168
|
+
#
|
|
169
|
+
# NOTE: standalone station-stack scripts (keeper_relay.py, bugreport/scanner.py)
|
|
170
|
+
# run under the system interpreter and cannot import this package; they carry a
|
|
171
|
+
# byte-identical inline resolver. Keep the two in sync — this module is the spec.
|
|
172
|
+
|
|
173
|
+
def hugpy_home() -> str:
|
|
174
|
+
"""Single base directory for hugpy's runtime files. Default ``~/.hugpy``."""
|
|
175
|
+
return _ensure(env_value("HUGPY_HOME") or _home(".hugpy"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def hugpy_state_dir() -> str:
|
|
179
|
+
return _ensure(os.path.join(hugpy_home(), "state"))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def hugpy_config_dir() -> str:
|
|
183
|
+
return _ensure(os.path.join(hugpy_home(), "config"))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def hugpy_logs_dir() -> str:
|
|
187
|
+
return _ensure(os.path.join(hugpy_home(), "logs"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def hugpy_run_dir() -> str:
|
|
191
|
+
return _ensure(os.path.join(hugpy_home(), "run"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _relocate(new_path: str, *legacy_names: str) -> str:
|
|
195
|
+
"""Return *new_path*, first migrating a legacy ``~/<name>`` into it once.
|
|
196
|
+
|
|
197
|
+
Idempotent and safe: if *new_path* already exists nothing moves. Uses an
|
|
198
|
+
atomic rename on the same filesystem and falls back to a copy+unlink across
|
|
199
|
+
filesystems. Any failure leaves the legacy file untouched and returns the
|
|
200
|
+
new path anyway (a fresh file is then created there).
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
if os.path.exists(new_path):
|
|
204
|
+
return new_path
|
|
205
|
+
for name in legacy_names:
|
|
206
|
+
old = _home(name)
|
|
207
|
+
if not os.path.exists(old):
|
|
208
|
+
continue
|
|
209
|
+
if os.path.abspath(old) == os.path.abspath(new_path):
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
os.replace(old, new_path) # atomic, same filesystem
|
|
213
|
+
except OSError:
|
|
214
|
+
import shutil
|
|
215
|
+
shutil.move(old, new_path) # cross-filesystem fallback
|
|
216
|
+
break
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
return new_path
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- named accessors --------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def worker_id_file() -> str:
|
|
225
|
+
"""``config/abstract_hugpy_worker.json`` — the worker identity file.
|
|
226
|
+
|
|
227
|
+
Honors an explicit ``WORKER_ID_FILE`` override (used by tests / multi-worker
|
|
228
|
+
hosts); otherwise migrates the legacy ``~/.abstract_hugpy_worker.json`` plus
|
|
229
|
+
its ``.settings.json`` / ``.update.json`` sidecars into ``config/``.
|
|
230
|
+
"""
|
|
231
|
+
override = env_value("WORKER_ID_FILE")
|
|
232
|
+
if override:
|
|
233
|
+
return override
|
|
234
|
+
new = os.path.join(hugpy_config_dir(), "abstract_hugpy_worker.json")
|
|
235
|
+
_relocate(new, ".abstract_hugpy_worker.json")
|
|
236
|
+
for suffix in (".settings.json", ".update.json"):
|
|
237
|
+
_relocate(new + suffix, ".abstract_hugpy_worker.json" + suffix)
|
|
238
|
+
return new
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def gguf_worker_id_file() -> str:
|
|
242
|
+
"""``config/gguf_worker.json`` — sibling worker identity (same pattern)."""
|
|
243
|
+
override = env_value("WORKER_ID_FILE")
|
|
244
|
+
if override:
|
|
245
|
+
return override
|
|
246
|
+
new = os.path.join(hugpy_config_dir(), "gguf_worker.json")
|
|
247
|
+
return _relocate(new, ".gguf_worker.json")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def todo_file() -> str:
|
|
251
|
+
return _relocate(os.path.join(hugpy_state_dir(), "todo.json"), "todo.json")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def todo_history_file() -> str:
|
|
255
|
+
return _relocate(
|
|
256
|
+
os.path.join(hugpy_state_dir(), "todo-history.jsonl"), "todo-history.jsonl"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def todo_lock() -> str:
|
|
261
|
+
return _relocate(os.path.join(hugpy_run_dir(), "todo.lock"), ".todo.lock")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def steward_config() -> str:
|
|
265
|
+
return _relocate(os.path.join(hugpy_config_dir(), "steward.json"), "steward.json")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def steward_state() -> str:
|
|
269
|
+
return _relocate(
|
|
270
|
+
os.path.join(hugpy_state_dir(), "steward-state.json"), "steward-state.json"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def model_metadata_db() -> str:
|
|
275
|
+
return _relocate(
|
|
276
|
+
os.path.join(hugpy_state_dir(), "model_metadata.db"), "model_metadata.db"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def model_physical_json() -> str:
|
|
281
|
+
# The ``.lock`` sidecar is derived by callers as ``path + ".lock"`` and rides
|
|
282
|
+
# along in state/ next to the file — intentionally not split into run/.
|
|
283
|
+
return _relocate(
|
|
284
|
+
os.path.join(hugpy_state_dir(), "model_physical.json"), "model_physical.json"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def flow_json() -> str:
|
|
289
|
+
return _relocate(os.path.join(hugpy_state_dir(), "flow.json"), "flow.json")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def bridge_mail() -> str:
|
|
293
|
+
return _relocate(
|
|
294
|
+
os.path.join(hugpy_logs_dir(), "bridge-mail.jsonl"), ".bridge-mail.jsonl"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def bugreport_json() -> str:
|
|
299
|
+
return _relocate(os.path.join(hugpy_logs_dir(), "bugreport.json"), "bugreport.json")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def ensure_hugpy_home() -> str:
|
|
303
|
+
"""Create the full ``HUGPY_HOME`` skeleton. Called by installers/postinst."""
|
|
304
|
+
hugpy_state_dir(); hugpy_config_dir(); hugpy_logs_dir(); hugpy_run_dir()
|
|
305
|
+
return hugpy_home()
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Process-wide async runtime — ONE long-lived event loop in a daemon thread.
|
|
2
|
+
|
|
3
|
+
Replaces the per-request ``asyncio.new_event_loop()`` pattern every SSE / one-shot
|
|
4
|
+
endpoint used. That pattern caused two problems:
|
|
5
|
+
|
|
6
|
+
* Loop-binding crashes — an asyncio sync primitive (Semaphore/Lock/Event)
|
|
7
|
+
cached on a process singleton binds to the FIRST request's loop and then
|
|
8
|
+
raises "bound to a different event loop" on the next request. With one
|
|
9
|
+
persistent loop, cached primitives stay valid for the life of the process.
|
|
10
|
+
* Per-request loop churn — creating/closing a loop per request and pinning a
|
|
11
|
+
thread in run_until_complete for the whole stream. The shared loop interleaves
|
|
12
|
+
many streams cooperatively; blocking model work runs in the default executor
|
|
13
|
+
(asyncio.to_thread), so the loop stays responsive.
|
|
14
|
+
|
|
15
|
+
Sync callers submit coroutines via ``run()`` / ``iter_sync()``; the loop runs them
|
|
16
|
+
and the caller blocks on a ``concurrent.futures.Future``. All entry points are
|
|
17
|
+
thread-safe (``run_coroutine_threadsafe``). Usable from both central (gunicorn
|
|
18
|
+
threads) and the worker agent (its request threads).
|
|
19
|
+
|
|
20
|
+
ABANDON-ON-DISCONNECT (2026-07-27). These two functions are the ONE place a WSGI
|
|
21
|
+
thread parks while the loop works, so they are also the only place that can hand
|
|
22
|
+
the thread back when the caller gives up. Instead of blocking forever on
|
|
23
|
+
``fut.result()`` they wake every ``client_liveness.poll_s()`` and ask the
|
|
24
|
+
request's socket probe whether the client is still there; on a definite
|
|
25
|
+
disconnect they cancel the in-flight coroutine (CancelledError unwinds its
|
|
26
|
+
``finally`` blocks, releasing relay slots / cold-hold permits / httpx streams)
|
|
27
|
+
and give the request slot back. With no probe bound — a background thread, an
|
|
28
|
+
internal drain, a WSGI server that publishes no socket — ``alive`` is None and
|
|
29
|
+
the behaviour is byte-identical to before.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import inspect
|
|
35
|
+
import threading
|
|
36
|
+
import logging
|
|
37
|
+
import concurrent.futures as _cf
|
|
38
|
+
|
|
39
|
+
from hugpy_platform import client_liveness
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
_loop: "asyncio.AbstractEventLoop | None" = None
|
|
44
|
+
_thread: "threading.Thread | None" = None
|
|
45
|
+
_start_lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def loop() -> "asyncio.AbstractEventLoop":
|
|
49
|
+
"""The shared event loop, starting its daemon thread on first use."""
|
|
50
|
+
global _loop, _thread
|
|
51
|
+
lp = _loop
|
|
52
|
+
if lp is not None and lp.is_running():
|
|
53
|
+
return lp
|
|
54
|
+
with _start_lock:
|
|
55
|
+
if _loop is not None and _loop.is_running():
|
|
56
|
+
return _loop
|
|
57
|
+
lp = asyncio.new_event_loop()
|
|
58
|
+
ready = threading.Event()
|
|
59
|
+
|
|
60
|
+
def _run():
|
|
61
|
+
asyncio.set_event_loop(lp)
|
|
62
|
+
ready.set()
|
|
63
|
+
lp.run_forever()
|
|
64
|
+
|
|
65
|
+
t = threading.Thread(target=_run, name="hugpy-async-runtime", daemon=True)
|
|
66
|
+
t.start()
|
|
67
|
+
ready.wait(5)
|
|
68
|
+
_loop, _thread = lp, t
|
|
69
|
+
logger.info("async runtime started (thread=%s)", t.name)
|
|
70
|
+
return _loop
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def submit(coro) -> "_cf.Future":
|
|
74
|
+
"""Schedule a coroutine on the shared loop; return its concurrent Future."""
|
|
75
|
+
return asyncio.run_coroutine_threadsafe(coro, loop())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _client_gone(alive) -> bool:
|
|
79
|
+
"""Ask a liveness checker, treating ANY failure as "still connected"."""
|
|
80
|
+
if alive is None:
|
|
81
|
+
return False
|
|
82
|
+
try:
|
|
83
|
+
return bool(alive())
|
|
84
|
+
except Exception: # noqa: BLE001 — never abandon a caller on doubt
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _abandon(fut) -> None:
|
|
89
|
+
"""Cancel an in-flight step and let its ``finally`` blocks unwind."""
|
|
90
|
+
if fut is None or fut.done():
|
|
91
|
+
return
|
|
92
|
+
fut.cancel()
|
|
93
|
+
try:
|
|
94
|
+
fut.result(5)
|
|
95
|
+
except BaseException: # noqa: BLE001 — cancellation is the expected outcome
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run(coro, *, alive=None):
|
|
100
|
+
"""Run a coroutine on the shared loop from a sync thread; block for its result.
|
|
101
|
+
|
|
102
|
+
``alive`` is a zero-arg "is my caller still connected?"; it defaults to the
|
|
103
|
+
probe this thread's Flask request bound (see _platform.client_liveness). When
|
|
104
|
+
one is available and reports a disconnect, the coroutine is CANCELLED and
|
|
105
|
+
``ClientGone`` is raised — the WSGI thread is returned to the pool instead of
|
|
106
|
+
sitting out (up to) a 25-minute cold hold for a caller who left. No probe ⇒
|
|
107
|
+
plain blocking ``.result()``, exactly as before.
|
|
108
|
+
"""
|
|
109
|
+
if not asyncio.iscoroutine(coro):
|
|
110
|
+
# Tolerate already-resolved values (callers that may pass a plain result).
|
|
111
|
+
return coro
|
|
112
|
+
if alive is None:
|
|
113
|
+
alive = client_liveness.current_checker()
|
|
114
|
+
fut = submit(coro)
|
|
115
|
+
if alive is None:
|
|
116
|
+
return fut.result()
|
|
117
|
+
poll = client_liveness.poll_s()
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
return fut.result(poll)
|
|
121
|
+
except _cf.TimeoutError:
|
|
122
|
+
if not _client_gone(alive):
|
|
123
|
+
continue
|
|
124
|
+
_abandon(fut)
|
|
125
|
+
logger.info("client disconnected — abandoned in-flight work "
|
|
126
|
+
"and released the request slot")
|
|
127
|
+
raise client_liveness.ClientGone(
|
|
128
|
+
"the client disconnected before the reply was ready; the "
|
|
129
|
+
"in-flight work was cancelled and its slot released")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def await_sync(value):
|
|
133
|
+
"""Resolve an awaitable on the process loop or return a plain value."""
|
|
134
|
+
if not inspect.isawaitable(value):
|
|
135
|
+
return value
|
|
136
|
+
return run(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def call_soon_threadsafe(callback, *args) -> None:
|
|
140
|
+
"""Schedule a plain callback on the shared loop (e.g. ``Event.set`` from
|
|
141
|
+
another thread, which is otherwise unsafe to call cross-loop)."""
|
|
142
|
+
loop().call_soon_threadsafe(callback, *args)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _step_wait(heartbeat, heartbeat_secs: float, poll, waited: float):
|
|
146
|
+
"""How long to block on the current step: whichever of the heartbeat tick and
|
|
147
|
+
the disconnect poll comes first. None (block forever) only when neither
|
|
148
|
+
applies — the pre-existing internal-drain behaviour."""
|
|
149
|
+
hb = (heartbeat_secs - waited) if heartbeat is not None else None
|
|
150
|
+
if hb is not None and hb <= 0:
|
|
151
|
+
hb = heartbeat_secs
|
|
152
|
+
if poll is None:
|
|
153
|
+
return hb
|
|
154
|
+
if hb is None:
|
|
155
|
+
return poll
|
|
156
|
+
return max(0.01, min(hb, poll))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def iter_sync(agen, heartbeat: "bytes | None" = None, heartbeat_secs: float = 15.0,
|
|
160
|
+
alive=None):
|
|
161
|
+
"""Drive an async generator from a sync (WSGI) thread on the SHARED loop.
|
|
162
|
+
|
|
163
|
+
Mirrors the old per-request driver semantics:
|
|
164
|
+
* With ``heartbeat`` bytes, each step waits at most ``heartbeat_secs`` and
|
|
165
|
+
yields the keepalive on timeout while the SAME step keeps running — so a
|
|
166
|
+
slow first token can't trip an upstream proxy, and every keepalive write
|
|
167
|
+
lets the WSGI server notice a dead client and trigger teardown.
|
|
168
|
+
* ``heartbeat=None`` blocks for each real event (internal/worker drains).
|
|
169
|
+
* On teardown the in-flight step is cancelled, then ``aclose()`` cascades
|
|
170
|
+
GeneratorExit through every ``async for`` / ``async with`` so a relayed
|
|
171
|
+
worker's httpx stream is released rather than leaked.
|
|
172
|
+
|
|
173
|
+
ABANDON-ON-DISCONNECT: when a client probe is available (``alive``, defaulting
|
|
174
|
+
to this request thread's), the step wait is additionally bounded by the poll
|
|
175
|
+
interval and a definite disconnect ends the drain — teardown below then
|
|
176
|
+
cancels and acloses exactly as it does for any other early exit. This is what
|
|
177
|
+
covers ``heartbeat=None`` drains such as the /v1 non-streaming completion,
|
|
178
|
+
which never writes to the client and so can never learn of a disconnect from
|
|
179
|
+
a failed write.
|
|
180
|
+
"""
|
|
181
|
+
lp = loop()
|
|
182
|
+
if alive is None:
|
|
183
|
+
alive = client_liveness.current_checker()
|
|
184
|
+
poll = client_liveness.poll_s() if alive is not None else None
|
|
185
|
+
fut = None
|
|
186
|
+
waited = 0.0
|
|
187
|
+
try:
|
|
188
|
+
while True:
|
|
189
|
+
if fut is None:
|
|
190
|
+
fut = asyncio.run_coroutine_threadsafe(agen.__anext__(), lp)
|
|
191
|
+
waited = 0.0
|
|
192
|
+
step = _step_wait(heartbeat, heartbeat_secs, poll, waited)
|
|
193
|
+
try:
|
|
194
|
+
item = fut.result(step)
|
|
195
|
+
fut = None
|
|
196
|
+
except _cf.TimeoutError:
|
|
197
|
+
waited += step or 0.0
|
|
198
|
+
if _client_gone(alive):
|
|
199
|
+
logger.info("client disconnected mid-drain — abandoning the "
|
|
200
|
+
"stream and releasing the request slot")
|
|
201
|
+
break
|
|
202
|
+
# Next event still cooking — keep the connection warm, keep
|
|
203
|
+
# awaiting the SAME step (it's still running on the loop).
|
|
204
|
+
if heartbeat is not None and waited >= heartbeat_secs - 1e-9:
|
|
205
|
+
waited = 0.0
|
|
206
|
+
yield heartbeat
|
|
207
|
+
continue
|
|
208
|
+
except StopAsyncIteration:
|
|
209
|
+
fut = None
|
|
210
|
+
break
|
|
211
|
+
if isinstance(item, str):
|
|
212
|
+
item = item.encode("utf-8")
|
|
213
|
+
yield item
|
|
214
|
+
finally:
|
|
215
|
+
try:
|
|
216
|
+
# Cancel the in-flight step first: CancelledError unwinds the
|
|
217
|
+
# chain's `async with` blocks (closing the worker httpx stream),
|
|
218
|
+
# after which aclose() can finalize without "already running".
|
|
219
|
+
_abandon(fut)
|
|
220
|
+
closer = asyncio.run_coroutine_threadsafe(agen.aclose(), lp)
|
|
221
|
+
try:
|
|
222
|
+
closer.result(10)
|
|
223
|
+
except BaseException:
|
|
224
|
+
pass
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Atomic JSON writes for the small shared state files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_json_dict(path: str) -> dict | None:
|
|
11
|
+
"""Read a JSON object, returning None for absent or malformed files."""
|
|
12
|
+
try:
|
|
13
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
14
|
+
data = json.load(fh)
|
|
15
|
+
except Exception: # noqa: BLE001 — unreadable and malformed are both absent
|
|
16
|
+
return None
|
|
17
|
+
return data if isinstance(data, dict) else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_json(path: str, data: dict) -> None:
|
|
21
|
+
"""Write JSON with a unique temporary file and an atomic replacement."""
|
|
22
|
+
parent = os.path.dirname(path)
|
|
23
|
+
if parent:
|
|
24
|
+
os.makedirs(parent, exist_ok=True)
|
|
25
|
+
tmp = f"{path}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
|
26
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
27
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
28
|
+
os.replace(tmp, path)
|