* build(#2695): add packaging metadata for the current runtime layout * docs(changelog): stamp #6337 pip-installable packaging metadata --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
11 files changed
+285
-36
No files matched your search
@@ -37,6 +37,7 @@ final-check.png
|
||||
|
||||
# Version file written by Docker/CI build — generated, never committed
|
||||
api/_version.py
|
||||
api/_scm_version.py
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi
|
||||
bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open.
|
||||
start.sh Thin wrapper around bootstrap.py for shell-based startup.
|
||||
ctl.sh Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.
|
||||
pyproject.toml Tooling config (ruff lint gate). NOT a packaged distribution.
|
||||
pyproject.toml Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh.
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose config with named volume and optional auth
|
||||
.dockerignore Excludes .git, tests/, .env* from Docker builds
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **The repository is now pip-installable with standard packaging metadata.** `pyproject.toml` gains a `[build-system]` + `[project]` section (setuptools + setuptools-scm) so packagers and distros can `pip install .` and get a proper wheel (`api` + `static` bundled). The normal `bootstrap.py` / `start.sh` / `ctl.sh` source-checkout launch path is unchanged. setuptools-scm writes its version to a separate `api/_scm_version.py` (not the Docker-owned `api/_version.py`), and the runtime version detector reads it as an explicit fallback (normalizing the PEP 440 value to a `v…` form) so an installed wheel reports a real version instead of `unknown` — Docker and git-checkout version resolution are byte-identical to before. Thanks @rodboev. (#6337, #2695)
|
||||
|
||||
- **Transparent Stream can now hide its per-event timestamp chips.** A new opt-in setting (Settings → the Transparent Stream activity area) lets you suppress the small per-event timestamp chips inside Transparent Stream while keeping the assistant response footer time visible — narrowing the visual noise without removing timing information entirely. Default is unchanged (chips shown). Thanks @rodboev. (#6130, #6099)
|
||||
|
||||
- **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258)
|
||||
|
||||
@@ -636,7 +636,7 @@ boot.js Mobile nav, voice input, theme/skin boot, bfcache handler
|
||||
|
||||
```
|
||||
tests/ Pytest suite (~11,500 tests; isolated server/state fixtures)
|
||||
pyproject.toml Tooling config (ruff lint gate) — not a packaged distribution
|
||||
pyproject.toml Standard build metadata plus the Ruff lint gate; checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose with named volume and optional auth
|
||||
.github/workflows/ CI: ruff + sharded pytest, browser smoke, Docker smoke,
|
||||
|
||||
+14
-2
@@ -456,7 +456,7 @@ def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | No
|
||||
|
||||
|
||||
def _detect_webui_version() -> str:
|
||||
"""Detect the running WebUI version from git or a baked-in fallback file.
|
||||
"""Detect the running WebUI version from git or installed fallback files.
|
||||
|
||||
Resolution order:
|
||||
1. ``git describe --tags --always --dirty`` — works in any git checkout.
|
||||
@@ -466,7 +466,9 @@ def _detect_webui_version() -> str:
|
||||
2. ``api/_version.py`` — a fallback written by the Docker / CI release
|
||||
workflow when ``.git`` is not present in the image. Expected to define
|
||||
``__version__ = 'vX.Y.Z'``.
|
||||
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
|
||||
3. ``api/_scm_version.py`` — setuptools-scm output in an installed wheel.
|
||||
Its PEP 440 value is normalized to the channel-neutral ``v...`` form.
|
||||
4. ``'unknown'`` — last resort; displayed as-is in the settings badge.
|
||||
"""
|
||||
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
|
||||
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
|
||||
@@ -490,6 +492,16 @@ def _detect_webui_version() -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Installed-wheel fallback: setuptools-scm writes a generated module that
|
||||
# is separate from the Docker/Nix-owned _version.py contract above.
|
||||
try:
|
||||
from api._scm_version import __version__ as scm_version
|
||||
scm_version = str(scm_version).strip()
|
||||
if scm_version:
|
||||
return scm_version if scm_version.startswith(('v', 'exp-v')) else f'v{scm_version}'
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 'unknown'
|
||||
|
||||
|
||||
|
||||
+43
-13
@@ -1,17 +1,47 @@
|
||||
# Hermes WebUI — Python tooling config.
|
||||
[build-system]
|
||||
requires = ["setuptools>=80", "setuptools-scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-webui"
|
||||
description = "Hermes WebUI server and static runtime"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "Hermes WebUI contributors"}]
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dynamic = ["version", "dependencies"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/nesquena/hermes-webui"
|
||||
Issues = "https://github.com/nesquena/hermes-webui/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
packages = ["api", "static"]
|
||||
py-modules = ["bootstrap", "server", "mcp_server"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
static = ["**/*"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
dependencies = {file = ["requirements.txt"]}
|
||||
|
||||
[tool.setuptools_scm]
|
||||
version_file = "api/_scm_version.py"
|
||||
|
||||
# Hermes WebUI — build metadata plus Python tooling config.
|
||||
#
|
||||
# This project is NOT a packaged distribution. The app is a plain Python + vanilla
|
||||
# JavaScript server with no build step and no bundler (see AGENTS.md / README). This
|
||||
# file exists only to configure dev tooling — currently ruff, used as a curated,
|
||||
# forward-looking lint gate over the Python codebase. There is intentionally no
|
||||
# [build-system] section: nothing pip-installs this directory.
|
||||
#
|
||||
# The ruff gate is the Python twin of the ESLint runtime guard (package.json
|
||||
# `lint:runtime` + eslint.runtime-guard.config.mjs + tests/test_static_js_runtime_lint.py).
|
||||
# It is enforced on NEW/CHANGED code only — see scripts/ruff_lint.py and TESTING.md
|
||||
# > "Python lint gate (ruff)". The existing tree carries a cosmetic backlog (mostly
|
||||
# unused-import F401) that is deliberately NOT reformatted here; cleaning it is a
|
||||
# separate, maintainer-run, safe-fixes-only decision (tracked in #3273).
|
||||
# The project now exposes standard Python build metadata for the current flat
|
||||
# runtime layout while still keeping the source-checkout launch surface
|
||||
# (`bootstrap.py`, `start.sh`, `ctl.sh`) authoritative. The Ruff gate remains
|
||||
# forward-looking and line-scoped on NEW/CHANGED Python code; see
|
||||
# scripts/ruff_lint.py and TESTING.md > "Python lint gate (ruff)".
|
||||
|
||||
[tool.ruff]
|
||||
# Match the Python versions exercised in CI (tests.yml matrix: 3.11–3.13).
|
||||
|
||||
@@ -10,11 +10,10 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# Ignore SIGPIPE so a dropped client only aborts that write, not the whole WebUI process.
|
||||
_SIGPIPE = getattr(signal, "SIGPIPE", None)
|
||||
if _SIGPIPE is not None:
|
||||
signal.signal(_SIGPIPE, signal.SIG_IGN)
|
||||
def _ignore_sigpipe() -> None:
|
||||
"""Keep broken client writes from terminating the server process."""
|
||||
if (sigpipe := getattr(signal, "SIGPIPE", None)) is not None:
|
||||
signal.signal(sigpipe, signal.SIG_IGN)
|
||||
|
||||
# Test-mode network isolation keeps subprocess-backed tests hermetic.
|
||||
if os.environ.get("HERMES_WEBUI_TEST_NETWORK_BLOCK", "").strip() in ("1", "true", "yes"):
|
||||
@@ -548,6 +547,8 @@ def _abort_if_already_serving(host: str, port: int) -> None:
|
||||
def main() -> None:
|
||||
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
|
||||
|
||||
_ignore_sigpipe()
|
||||
|
||||
# Crash visibility FIRST (issue #4633): enable faulthandler + excepthooks +
|
||||
# exit audit before any heavy startup work so a native crash or a daemon /
|
||||
# handler-thread exception during startup or serving produces a diagnostic
|
||||
@@ -744,6 +745,5 @@ def main() -> None:
|
||||
stop_session_channel_reaper()
|
||||
except Exception:
|
||||
logger.debug("Failed to stop SessionChannel reaper during shutdown", exc_info=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Package marker for installed WebUI static assets."""
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _copy_repo_without_heavy_dirs(dst: Path) -> Path:
|
||||
repo_copy = dst / "repo"
|
||||
shutil.copytree(
|
||||
ROOT,
|
||||
repo_copy,
|
||||
ignore=shutil.ignore_patterns(
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
),
|
||||
)
|
||||
return repo_copy
|
||||
|
||||
|
||||
def _build_wheel(repo_copy: Path) -> Path:
|
||||
dist_dir = repo_copy / "dist"
|
||||
env = os.environ.copy()
|
||||
env["SETUPTOOLS_SCM_PRETEND_VERSION_FOR_HERMES_WEBUI"] = "0.52.2695"
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"wheel",
|
||||
".",
|
||||
"--no-deps",
|
||||
"--wheel-dir",
|
||||
str(dist_dir),
|
||||
],
|
||||
cwd=repo_copy,
|
||||
env=env,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
wheels = sorted(dist_dir.glob("hermes_webui-*.whl"))
|
||||
assert wheels, "wheel build must produce a hermes_webui wheel"
|
||||
return wheels[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def extracted_wheel(tmp_path_factory):
|
||||
tmp = tmp_path_factory.mktemp("packaged-runtime")
|
||||
repo_copy = _copy_repo_without_heavy_dirs(tmp)
|
||||
wheel = _build_wheel(repo_copy)
|
||||
extract_dir = tmp / "wheel"
|
||||
with zipfile.ZipFile(wheel) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
return wheel, extract_dir
|
||||
|
||||
|
||||
def test_wheel_build_contains_runtime_tree(extracted_wheel):
|
||||
wheel, _ = extracted_wheel
|
||||
with zipfile.ZipFile(wheel) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert "bootstrap.py" in names
|
||||
assert "server.py" in names
|
||||
assert "mcp_server.py" in names
|
||||
assert "api/config.py" in names
|
||||
assert "api/_scm_version.py" in names
|
||||
assert "static/__init__.py" in names
|
||||
assert "static/index.html" in names
|
||||
assert "static/ui.js" in names
|
||||
assert "static/style.css" in names
|
||||
assert "static/vendor/smd.min.js" in names
|
||||
|
||||
|
||||
def test_extracted_wheel_resolves_static_root_without_console_entrypoint_contract_change(extracted_wheel):
|
||||
_, extract_dir = extracted_wheel
|
||||
script = """
|
||||
import api.config as api_config
|
||||
from api.updates import WEBUI_VERSION
|
||||
print(api_config.__file__)
|
||||
print(api_config.get_static_root())
|
||||
print(api_config.get_index_html_path())
|
||||
print(WEBUI_VERSION)
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(extract_dir)
|
||||
env["GIT_CEILING_DIRECTORIES"] = str(extract_dir.parent)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=extract_dir,
|
||||
env=env,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
assert lines == [
|
||||
str((extract_dir / "api" / "config.py").resolve()),
|
||||
str((extract_dir / "static").resolve()),
|
||||
str((extract_dir / "static" / "index.html").resolve()),
|
||||
"v0.52.2695",
|
||||
]
|
||||
assert Path(lines[0]).exists()
|
||||
assert Path(lines[1]).is_dir()
|
||||
assert Path(lines[2]).is_file()
|
||||
|
||||
|
||||
def test_checkout_static_root_stays_repo_relative():
|
||||
import api.config as api_config
|
||||
|
||||
assert api_config.get_static_root() == ROOT / "static"
|
||||
assert api_config.get_index_html_path() == ROOT / "static" / "index.html"
|
||||
@@ -5,7 +5,7 @@ connection mid-response cannot kill the whole WebUI process (salvaged from
|
||||
Python's default SIGPIPE action is ``Term``: a single broken-pipe ``send()``
|
||||
in any ThreadingHTTPServer worker thread would terminate the entire server
|
||||
silently — no exception, no log, no ``/health`` response. server.py sets
|
||||
``SIGPIPE`` to ``SIG_IGN`` at import time so the kernel surfaces EPIPE as a
|
||||
``SIGPIPE`` to ``SIG_IGN`` during startup so the kernel surfaces EPIPE as a
|
||||
catchable ``BrokenPipeError`` and the server keeps serving.
|
||||
|
||||
The handler is guarded with ``getattr(signal, "SIGPIPE", None)`` because
|
||||
@@ -16,34 +16,82 @@ SIGPIPE is POSIX-only and does not exist on Windows (native-Windows support,
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def test_sigpipe_set_to_ignore_after_import():
|
||||
"""After importing server, SIGPIPE's handler must be SIG_IGN on POSIX."""
|
||||
def test_sigpipe_set_to_ignore_by_startup_helper():
|
||||
"""The startup helper installs SIG_IGN on POSIX."""
|
||||
if not hasattr(signal, "SIGPIPE"):
|
||||
# Windows / no-SIGPIPE platform: nothing to assert, importing server
|
||||
# must simply not raise (covered by test_import_does_not_raise below).
|
||||
return
|
||||
import server # noqa: F401 (import installs the handler at module load)
|
||||
|
||||
current = signal.getsignal(signal.SIGPIPE)
|
||||
assert current == signal.SIG_IGN, (
|
||||
"server.py must set SIGPIPE to SIG_IGN so a dropped client mid-response "
|
||||
f"cannot Term the whole process; got handler {current!r}"
|
||||
)
|
||||
import server
|
||||
previous = signal.getsignal(signal.SIGPIPE)
|
||||
try:
|
||||
server._ignore_sigpipe()
|
||||
current = signal.getsignal(signal.SIGPIPE)
|
||||
assert current == signal.SIG_IGN, (
|
||||
"server.py must set SIGPIPE to SIG_IGN so a dropped client mid-response "
|
||||
f"cannot Term the whole process; got handler {current!r}"
|
||||
)
|
||||
finally:
|
||||
signal.signal(signal.SIGPIPE, previous)
|
||||
|
||||
|
||||
def test_import_does_not_raise():
|
||||
"""Importing server must not raise — proves the getattr guard works on
|
||||
any platform (including a hypothetical no-SIGPIPE one)."""
|
||||
import server # noqa: F401
|
||||
import server
|
||||
|
||||
assert server is not None
|
||||
|
||||
|
||||
def test_import_does_not_change_sigpipe_handler():
|
||||
"""Plain import must leave process-global SIGPIPE handling unchanged."""
|
||||
if not hasattr(signal, "SIGPIPE"):
|
||||
return
|
||||
previous = signal.getsignal(signal.SIGPIPE)
|
||||
sys.modules.pop("server", None)
|
||||
try:
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
import server
|
||||
assert signal.getsignal(signal.SIGPIPE) == signal.SIG_DFL
|
||||
assert server is not None
|
||||
finally:
|
||||
signal.signal(signal.SIGPIPE, previous)
|
||||
|
||||
|
||||
def test_main_installs_sigpipe_ignore_before_startup():
|
||||
"""The real startup path must invoke the SIGPIPE helper before serving."""
|
||||
if not hasattr(signal, "SIGPIPE"):
|
||||
return
|
||||
import server
|
||||
|
||||
class _StopStartup(Exception):
|
||||
pass
|
||||
|
||||
previous = signal.getsignal(signal.SIGPIPE)
|
||||
original = server.install_crash_visibility
|
||||
try:
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
def _stop_startup():
|
||||
raise _StopStartup()
|
||||
|
||||
server.install_crash_visibility = _stop_startup
|
||||
try:
|
||||
server.main()
|
||||
except _StopStartup:
|
||||
pass
|
||||
assert signal.getsignal(signal.SIGPIPE) == signal.SIG_IGN
|
||||
finally:
|
||||
server.install_crash_visibility = original
|
||||
signal.signal(signal.SIGPIPE, previous)
|
||||
|
||||
|
||||
def test_sigpipe_handler_is_getattr_guarded_in_source():
|
||||
"""The handler must be guarded with getattr(signal, 'SIGPIPE', None) so the
|
||||
POSIX-only signal can't AttributeError on Windows (native-Windows support,
|
||||
@@ -54,3 +102,4 @@ def test_sigpipe_handler_is_getattr_guarded_in_source():
|
||||
"bare signal.SIGPIPE reference would AttributeError on native Windows."
|
||||
)
|
||||
assert "SIG_IGN" in src, "server.py must set the SIGPIPE handler to SIG_IGN"
|
||||
assert "def main() -> None:" in src and "_ignore_sigpipe()" in src
|
||||
@@ -26,7 +26,7 @@ REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
class TestDetectWebUIVersion:
|
||||
|
||||
def _fresh_detect(self, mock_run_git=None, version_file_content=None, tmp_path=None):
|
||||
def _fresh_detect(self, mock_run_git=None, version_file_content=None, scm_version=None, tmp_path=None):
|
||||
"""Call _detect_webui_version() with controlled dependencies."""
|
||||
import api.updates as upd
|
||||
|
||||
@@ -37,13 +37,18 @@ class TestDetectWebUIVersion:
|
||||
vf.parent.mkdir(parents=True, exist_ok=True)
|
||||
vf.write_text(version_file_content, encoding='utf-8')
|
||||
|
||||
scm_module = types.ModuleType('api._scm_version')
|
||||
if scm_version is not None:
|
||||
scm_module.__version__ = scm_version
|
||||
|
||||
def _run_git_side_effect(args, cwd, timeout=10):
|
||||
if mock_run_git is not None:
|
||||
return mock_run_git(args, cwd, timeout)
|
||||
return ('', False)
|
||||
|
||||
with patch.object(upd, '_run_git', side_effect=_run_git_side_effect), \
|
||||
patch.object(upd, 'REPO_ROOT', fake_root):
|
||||
patch.object(upd, 'REPO_ROOT', fake_root), \
|
||||
patch.dict(sys.modules, {'api._scm_version': scm_module}):
|
||||
return upd._detect_webui_version()
|
||||
|
||||
def test_git_success_returns_tag(self, tmp_path):
|
||||
@@ -79,6 +84,31 @@ class TestDetectWebUIVersion:
|
||||
)
|
||||
assert result == 'unknown'
|
||||
|
||||
def test_scm_fallback_normalizes_pep440_version(self, tmp_path):
|
||||
result = self._fresh_detect(
|
||||
mock_run_git=lambda args, cwd, timeout: ('', False),
|
||||
scm_version='0.52.2695',
|
||||
tmp_path=tmp_path,
|
||||
)
|
||||
assert result == 'v0.52.2695'
|
||||
|
||||
def test_docker_version_file_precedes_scm_fallback(self, tmp_path):
|
||||
result = self._fresh_detect(
|
||||
mock_run_git=lambda args, cwd, timeout: ('', False),
|
||||
version_file_content="__version__ = 'v0.52.100'\n",
|
||||
scm_version='0.52.2695',
|
||||
tmp_path=tmp_path,
|
||||
)
|
||||
assert result == 'v0.52.100'
|
||||
|
||||
def test_malformed_scm_module_returns_unknown(self, tmp_path):
|
||||
result = self._fresh_detect(
|
||||
mock_run_git=lambda args, cwd, timeout: ('', False),
|
||||
scm_version=None,
|
||||
tmp_path=tmp_path,
|
||||
)
|
||||
assert result == 'unknown'
|
||||
|
||||
def test_version_file_malformed_returns_unknown(self, tmp_path):
|
||||
"""Malformed _version.py (no recognisable __version__ assignment) returns 'unknown'."""
|
||||
result = self._fresh_detect(
|
||||
|
||||
Reference in new issue
Block a user