From 98a67ee6cf1bd6b225b25f8e710d8581d247184a Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 19 Jul 2026 19:17:52 -0700 Subject: [PATCH] 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 Co-authored-by: nesquena-hermes --- .gitignore | 1 + ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 + README.md | 2 +- api/updates.py | 16 ++- pyproject.toml | 56 ++++++-- server.py | 12 +- static/__init__.py | 1 + .../test_issue2695_packaged_runtime_layout.py | 124 ++++++++++++++++++ tests/test_issue3407_sigpipe_ignore.py | 71 ++++++++-- tests/test_version_badge.py | 34 ++++- 11 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 static/__init__.py create mode 100644 tests/test_issue2695_packaged_runtime_layout.py diff --git a/.gitignore b/.gitignore index f949255b..6e91264d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3d271b81..f657aad0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e30066fa..a1942f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 5b82ae90..0b8b1e6e 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/api/updates.py b/api/updates.py index e7258cb3..51e383f4 100644 --- a/api/updates.py +++ b/api/updates.py @@ -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' diff --git a/pyproject.toml b/pyproject.toml index 80edc1fd..1ddba525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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). diff --git a/server.py b/server.py index 8ccac36b..743ed765 100644 --- a/server.py +++ b/server.py @@ -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() diff --git a/static/__init__.py b/static/__init__.py new file mode 100644 index 00000000..57bf21f0 --- /dev/null +++ b/static/__init__.py @@ -0,0 +1 @@ +"""Package marker for installed WebUI static assets.""" diff --git a/tests/test_issue2695_packaged_runtime_layout.py b/tests/test_issue2695_packaged_runtime_layout.py new file mode 100644 index 00000000..c5668914 --- /dev/null +++ b/tests/test_issue2695_packaged_runtime_layout.py @@ -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" diff --git a/tests/test_issue3407_sigpipe_ignore.py b/tests/test_issue3407_sigpipe_ignore.py index cdf5ec1f..7fb32d2f 100644 --- a/tests/test_issue3407_sigpipe_ignore.py +++ b/tests/test_issue3407_sigpipe_ignore.py @@ -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 diff --git a/tests/test_version_badge.py b/tests/test_version_badge.py index 27112bd5..2653a0c7 100644 --- a/tests/test_version_badge.py +++ b/tests/test_version_badge.py @@ -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(