Release: pip-installable packaging metadata (#6337, @rodboev) (#6344)

* 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:
authored and GitHub committed 2026-07-20 02:17:52 +00:00
1 parent 4d9965b37a
commit 98a67ee6cf
11 files changed
+285 -36

No files matched your search

+1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+6 -6
View File
@@ -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()
+1
View File
@@ -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"
+60 -11
View File
@@ -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
+32 -2
View File
@@ -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(