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).
153 lines
5.5 KiB
Python
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()
|