arborist-viz/arborist_viz/__init__.py
russell@unturf.com 0cd96ab1d7
Initial commit: arborist VIZ — Merkle command center
Pyramid web app for inspecting arborist Merkle trees over sharded SQLite:
document roots, inclusion proofs, an interactive 3D Merkle lattice
(six.js / WebGL with graceful fallback), audit-chain linkage, and a live
SSE event stream.

Generated artifacts (env/, vendored static/six/ bundles, demo databases)
are gitignored and rebuilt via the Makefile (venv, six, init-db, serve).
2026-05-28 09:22:59 -04:00

153 lines
5.5 KiB
Python

"""arborist-viz — Pyramid app factory.
Phase 0 wires the dashboard shell, JSON read-API surface, and SSE
endpoints declared in ticket #000069. arborist provenance shards are
read-only via ``arborist.embed``; no SQLAlchemy access to arborist
tables.
"""
from __future__ import annotations
import logging
import os
import subprocess
from pyramid.config import Configurator
from pyramid.session import SignedCookieSessionFactory
log = logging.getLogger(__name__)
def _read_commit_hash() -> str:
"""Best-effort short git hash for the /version endpoint.
Matches the remarkbox pattern: stamp a ``commit-hash.txt`` at build
time, fall back to ``git rev-parse`` for dev, fall back to ``"dev"``.
"""
here = os.path.dirname(os.path.abspath(__file__))
for candidate in (
os.path.join(here, "..", "commit-hash.txt"),
os.path.join(here, "commit-hash.txt"),
):
try:
with open(candidate) as fh:
v = fh.read().strip()
if v:
return v[:12]
except OSError:
pass
try:
return subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=here,
stderr=subprocess.DEVNULL,
).decode().strip()
except Exception:
return "dev"
GIT_COMMIT = _read_commit_hash()
SERVICE_NAME = "arborist-viz"
# Per-process startup stamp — appended to GIT_COMMIT so static asset
# cache-busters change on every restart even when the git hash is
# stable (e.g. in a non-git checkout). The browser still gets long-
# lived caching for any given process run; restart = fresh assets.
import time as _time
STATIC_VERSION = "{0}-{1}".format(GIT_COMMIT, int(_time.time()))
def hex_chunk(value, group_size: int = 8, sep: str = " ") -> str:
"""Render an atomic hex identifier as space-separated chunks.
Atomic identifiers (SHA-256, IPFS CIDs, signatures, etc.) must never
wrap mid-token — visual diff degrades to a character scan when two
hashes wrap at different columns. Render them as fixed-width groups
with whitespace at predictable boundaries; CSS then wraps only at
those boundaries (`white-space: pre-wrap; word-break: keep-all`).
See ``docs/style-guide.md`` §4.4 (Mode B).
"""
if not value:
return value
s = str(value)
return sep.join(s[i:i + group_size] for i in range(0, len(s), group_size))
def _coerce(value: str):
"""Best-effort string -> bool/int/None coercion for .ini values."""
if value is None:
return None
v = value.strip()
low = v.lower()
if low in ("true", "yes", "on"):
return True
if low in ("false", "no", "off"):
return False
if low in ("none", "null", ""):
return None
try:
return int(v)
except ValueError:
return v
def main(global_config, **settings):
"""Pyramid WSGI app factory — entry point in ``setup.py``."""
# session: signed cookie, NOT encrypted (sessions carry no secrets).
session_factory = SignedCookieSessionFactory(
secret=settings.get("session.secret", "dev-secret-change-me"),
cookie_name="arborist_viz_session",
max_age=int(_coerce(settings.get("session.max_age", "86400")) or 86400),
timeout=int(_coerce(settings.get("session.timeout", "86400")) or 86400),
reissue_time=int(_coerce(settings.get("session.reissue_time", "7200")) or 7200),
set_on_exception=True,
secure=bool(_coerce(settings.get("session.secure", "false"))),
httponly=True,
samesite=settings.get("session.samesite", "lax"),
hashalg=settings.get("session.hashalg", "sha512"),
)
config = Configurator(settings=settings, session_factory=session_factory)
config.include("pyramid_jinja2")
def _register_jinja2_filters():
env = config.get_jinja2_environment()
env.filters["hex_chunk"] = hex_chunk
config.action(("jinja2-filters", "arborist_viz"), _register_jinja2_filters)
# `request.git_commit` is the short hash (footer); `static_version`
# is the cache-buster (footer hash + process start time).
config.add_request_method(lambda r: GIT_COMMIT, "git_commit", reify=True)
config.add_request_method(lambda r: STATIC_VERSION, "static_version", reify=True)
# privacy default-deny — §14.
reveal_private = bool(_coerce(settings.get("privacy.reveal_private_leaves", "false")))
config.add_request_method(
lambda r, _v=reveal_private: _v, "reveal_private_leaves", reify=True
)
# arborist shards (comma-separated paths, read-only).
# Env var ARBORIST_VIZ_SHARDS beats the .ini default so `make serve-demo`
# can point at data/demo_arborist.db without editing the config file.
shards_raw = os.environ.get("ARBORIST_VIZ_SHARDS") or settings.get("arborist.shards", "") or ""
shards = [s.strip() for s in shards_raw.split(",") if s.strip()]
config.add_request_method(lambda r, _v=shards: _v, "arborist_shards", reify=True)
# Expose an arborist.read.Shards handle per request. The handle is
# process-cached in storage.py, so this is O(1) after the first call.
# All schema knowledge lives behind this seam — VIZ never touches
# arborist's tables, columns, or helper modules directly.
from . import storage as _storage
def _shards(req):
return _storage.get_shards(req.arborist_shards)
config.add_request_method(_shards, "shards", reify=True)
config.include(".routes")
config.scan("arborist_viz.views")
log.info("arborist-viz %s booted (shards=%s)", GIT_COMMIT, shards or "none")
return config.make_wsgi_app()