Files
willtools-demo/tools/ba_web.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

517 lines
24 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Beam Analysis's local server -- the CPython half of the HTML front/back end.
python tools/ba_web.py [--reads reads.json] [--job-root DIR]
[--port N|0] [--open] [--title T]
`tools/pa_web.py` with the tool swapped (docs/ba-web-spec.md section 5): same
argv, same first stdout line, same 127.0.0.1 bind, same
`http.server.ThreadingHTTPServer`, same reads/job-root/mark resolution. The
page the server feeds is built by `tools/ba_pagespec.py` (the job <-> values
half, `build_pagespec` / `geometry`), and `POST /api/run` runs the beamcalc
engine IN-PROCESS -- `beamcalc.model.from_job` -> `beamcalc.solve.analyse` --
the way PA's server calls `panelanalysis.build_page`, never a subprocess: one
interpreter, one traceback (spec section 5). Results are `<job-root>/
beamanalysis/<mark>.job.json` (the job actually run), `<mark>.ba.json`
(`beamcalc.cli.summary(a)`), `<mark>.html` (`beamcalc.report.render(a)`).
NO REVIT ANYWHERE ON THIS PATH. Like pa_web.py, this module registers the
bare `vibe` package stub at import (`vibe/__init__.py` imports the Revit
API, which is fatal off Revit), then imports `tools/pa_web.py` for its
job/reads plumbing and the shared static handler, and `tools/ba_pagespec.py`
for the pagespec and the job conversion. `beamcalc` is plain CPython
(imported by ba_pagespec the ordinary way, falling back to
`beamcalc/src` on `sys.path`); `beamcalc.report` pulls `panellift.report`,
which is CPython too.
Routes (shared handler and payload shapes inherited from pa_web.py -- the
PA `app.js` front end is reused for this tool, spec section 7):
GET / tools/ba_web/static/index.html when it exists,
else tools/pa_web/static/index.html (app.js,
draw.js, units.js and willtools.css are PA's
single copies -- ba_web's static dir only
overrides them).
GET /api/spec the pagespec JSON: `ba_pagespec.
build_pagespec(values=..., reads=...)` with the
values file a previous /api/save left for this
mark merged over the `--reads` prefill. reads
is a PARTIAL job (spec section 6): only what
the model could supply, plus `basis`.
POST /api/geometry body {"values": {...}} -- {"geometry":
ba_pagespec.geometry(ba_pagespec.job_from_values(
values))}, the SAME dict /api/spec computes off
the values the client currently holds (not a
save, not the prefill). A ValueError parsing the
values -- job_from_values raises one whose
message starts with the values key -- or any
other exception comes back {"geometry": {},
"error": text}, never a dead page.
POST /api/save body {"values": {...}} -- persists that dict as
this mark's saved edits
(`<job-root>/beamanalysis/<mark>.values.json`),
read back by the next /api/spec.
POST /api/run body {"values": {...}} -- `ba_pagespec.
job_from_values(values)`, `beamcalc.model.
from_job(job)` and `beamcalc.solve.analyse` in
process. On a ValueError from the conversion/
read (a value the engine refuses), {"ok": false,
"kind": "input", "problems": [{"key": <the
values key the message starts with, or ''>,
"text": ...}]}; on any other exception,
{"ok": false, "kind": "error", "problems":
[...]} with the exception text and the
traceback to stderr only. `kind` is what the
shared page renders the refusal by -- "input"
is the user's to fix, "error" is a bug (see
pa_web.run_analysis). On success it
writes `<mark>.job.json`, `<mark>.ba.json`
(`cli.summary(a)`) and `<mark>.html`
(`report.render(a)`) into the same
`beamanalysis/` folder and answers {"ok": true,
"result_url": "/results/<mark>.html"}.
GET /results/<file> serves a written results HTML (or anything else
landed in the same folder) by filename.
POST /api/close acknowledges, then shuts the server down (used
by a page's own Close button, and by the Revit
button once its WebView2 window closes).
POST /api/log body {"text": ...} (anything JSON) -- appends
one line, `<ISO timestamp>\t<body compact>`, to
`<job-root>/beamanalysis/<mark>.client.log` and
answers {"ok": true} -- the client-side error
sink PA's `app.js` posts to (ba-web-spec.md
section 5: the route PA's server already has
for Beam Analysis to reuse).
Static assets are served from `tools/ba_web/static/` first and
`tools/pa_web/static/` second (spec section 5): ba_web/static holds only
`index.html` and `ba_drawings.js`; `app.js`, `units.js`, `willtools.css`
and `draw.js` are PA's single copies, never duplicated. A static path in
neither directory is a 404.
Only binds 127.0.0.1. Threaded (`http.server.ThreadingHTTPServer`) so a slow
solve never blocks a concurrent static request. Every request and every solve
exception is logged to stderr; nothing here ever prints a plant, job or
client name (`--reads` / `--job-root` are data, supplied by the caller, never
this file's own business -- same rule `tools/pa_pagespec.py` follows; the
`mark`, the `job_root` and every path to a written file stay OFF stdout).
The one place a client-supplied value is written anywhere is `POST
/api/log`, into `<mark>.client.log` -- into the FILE, never stdout.
JOB ROOT resolution (where `<mark>.values.json` / `<mark>.job.json` / the
results HTML all live, one folder per mark under `<job-root>/beamanalysis/`):
identical to pa_web.py -- the `--reads` file's own `"job_root"` field wins
when present; else `--job-root`; else `%LOCALAPPDATA%\\WillTools\\panelanalysis`
(pa_web.py's own default; Beam Analysis deliberately keeps the same fallback
folder rather than invent a second). The MARK itself is `--reads`' own
`"mark"` field, slugged, or the literal string `'blank'` when there is no
mark at all -- so two blank-path runs never collide with a real beam's saved
files, and a re-run of the same mark picks its saved edits back up.
`--port 0` (the default) lets the OS pick a free port; the actual URL is
printed as `BA_WEB_URL=http://127.0.0.1:<port>/` -- the FIRST line on stdout,
flushed immediately, so a launcher (the Revit button, a test) can read it off
a pipe before the server does anything else. `--open` additionally opens it
in the default browser.
"""
from __future__ import print_function
import argparse
import io
import json
import os
import sys
import threading
import traceback
import types
import webbrowser
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
LIB = os.path.join(REPO, 'WillTools.extension', 'lib')
if LIB not in sys.path:
sys.path.insert(0, LIB)
# Same trick `tools/pa_web.py` / `tools/deckcheck.py` use: `vibe/__init__.py`
# imports the Revit API at module scope, which is fatal off Revit. Register a
# bare `vibe` package pointed at the directory so the Revit-free submodules
# import normally and `__init__.py` never runs. pa_web.py does the same at
# its own import, so whichever module registers first wins -- identical stub.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(LIB, 'vibe')]
sys.modules['vibe'] = _stub
if HERE not in sys.path:
sys.path.insert(0, HERE)
# `beamcalc` is plain CPython; ba_pagespec imports it the ordinary way and
# falls back to the in-repo source tree (<repo>/beamcalc/src on sys.path) when
# it is not installed -- importing ba_pagespec performs that fallback.
import pa_web # noqa: E402 -- job/reads plumbing + the shared handler
import ba_pagespec # noqa: E402 -- pagespec builder, job <-> values
import beamcalc.model as BM # noqa: E402
from beamcalc import cli as BC_CLI # noqa: E402
from beamcalc import report as BC_REPORT # noqa: E402
from beamcalc import solve as BC_SOLVE # noqa: E402
# Re-exported rather than re-derived (docs/ba-web-spec.md section 5: "same
# argv ... same job_root resolution ... files live under
# <job_root>/beamanalysis/"): these ARE pa_web.py's own functions, working on
# a Context that differs only in which folder it points at.
_load_reads = pa_web._load_reads
_slug = pa_web._slug
_ensure_dir = pa_web._ensure_dir
_default_job_root = pa_web._default_job_root
_send_file = pa_web.Handler._send_file
_serve_from = pa_web.Handler._serve_from
_read_json_body = pa_web.Handler._read_json_body
BA_DIR_NAME = 'beamanalysis'
STATIC_DIRS = (os.path.join(HERE, 'ba_web', 'static'),
os.path.join(HERE, 'pa_web', 'static'))
DEFAULT_JOB_ROOT = _default_job_root() # %LOCALAPPDATA%\WillTools\panelanalysis
DEFAULT_TITLE = 'Beam Analysis'
class Context(object):
"""Everything one server process needs, resolved once at startup -- the
beam analogue of `pa_web.Context`, with the folder swapped."""
def __init__(self, reads, job_root, ba_dir, mark):
self.reads = reads
self.job_root = job_root
self.ba_dir = ba_dir
self.mark = mark
def build_context(reads_path=None, job_root_override=None):
"""`(reads, job_root, ba_dir, mark)` resolved exactly the way pa_web.py
resolves its own: `--reads`' `job_root` field, else `job_root_override`,
else `pa_web._default_job_root()`; mark slugged off `--reads`' `mark`
field, else `'blank'`. The folder is `<job_root>/beamanalysis/`."""
reads, reads_err = _load_reads(reads_path)
if reads_err:
sys.stderr.write('ba_web: ' + reads_err + '\n')
job_root = reads.get('job_root') or job_root_override or DEFAULT_JOB_ROOT
ba_dir = os.path.join(job_root, BA_DIR_NAME)
_ensure_dir(ba_dir)
mark_raw = (reads.get('mark') or '').strip()
mark = _slug(mark_raw) if mark_raw else 'blank'
return Context(reads=reads, job_root=job_root, ba_dir=ba_dir, mark=mark)
# ------------------------------------------------------------- the pagespec
def _values_path(ctx):
return os.path.join(ctx.ba_dir, ctx.mark + '.values.json')
def _load_saved_values(ctx):
"""The `{key: value}` dict a previous `/api/save` for this mark left --
`{}` for anything else (no file yet, a file that will not parse, a file
that is not shaped `{"values": {...}}`). Never raises."""
path = _values_path(ctx)
if not os.path.isfile(path):
return {}
try:
with io.open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception:
return {}
if isinstance(data, dict):
values = data.get('values')
if isinstance(values, dict):
return values
return {}
def _save_values(ctx, values):
_ensure_dir(ctx.ba_dir)
blob = json.dumps({'values': values}, indent=1, sort_keys=True,
ensure_ascii=False)
with io.open(_values_path(ctx), 'w', encoding='utf-8') as fh:
fh.write(blob)
def build_spec_json(ctx):
"""The pagespec dict for GET /api/spec: prefill off `ctx.reads` -- a
PARTIAL job (ba-web-spec.md section 6: absent means unread) -- flattened
to values by `ba_pagespec.build_pagespec`, with any saved edits for this
mark folded over the result before the geometry is computed, so a saved
length/width edit also redraws the geometry, not just the raw number."""
try:
saved = _load_saved_values(ctx)
except Exception:
saved = {}
values = None
if isinstance(saved, dict) and saved:
# The saved dict is a complete values set from the last /api/save;
# build_pagespec copies it and fills geometry from it.
values = saved
return ba_pagespec.build_pagespec(values=values, reads=ctx.reads)
def geometry_for(ctx, values):
"""`{'geometry': {...}}` for `POST /api/geometry` -- the page's own
`values` dict, whatever the client currently holds, run back through
`ba_pagespec.geometry(ba_pagespec.job_from_values(values))` so the dict
reflects an edit the client made seconds ago and has not (or will not)
have saved yet. Never raises -- a `ValueError` from `job_from_values`
(its messages name the values key that will not parse) and any other
exception come back as an empty geometry dict plus an `error` string, so
a client-side redraw degrades to 'nothing to draw' rather than a dead
page."""
try:
geo = ba_pagespec.geometry(ba_pagespec.job_from_values(values))
return {'geometry': geo if isinstance(geo, dict) else {}}
except Exception as exc:
sys.stderr.write('ba_web: geometry rebuild raised:\n' +
traceback.format_exc())
return {'geometry': {},
'error': '{0}: {1}'.format(type(exc).__name__, exc)}
# ------------------------------------------------------------- run / solve
def _key_for_problem(message):
"""The values key a `ValueError` message is about, or `''` when none.
`ba_pagespec.job_from_values` and `beamcalc.model.from_job` both raise
`ValueError`s whose text names the key or job path that was refused
(`"extents.length_ft: not a number: 'abc'"`; `JobError('missing required
value: extents.section.width_in')`); the message may quote the key
(`'unknown section type: ...'` names no key at all). Best-effort: a
dotted key at the START of the message is the values key itself; a
dotted key inside the message maps to the values key when it is one the
spec renders; anything else is `''`."""
if not message:
return ''
head = message.split(':', 1)[0].strip()
if head and all(part.replace('_', '').isalnum() for part in head.split('.')):
return head
return ''
def run_analysis(ctx, values):
"""`{"ok": True, "result_url": ...}` or `{"ok": False, "kind": ...,
"problems": [...]}` -- `kind` as in `pa_web.run_analysis`: `'input'`
for a value the engine refuses, `'error'` for a solve/write that raised.
Runs `job_from_values` -> `model.from_job` -> `solve.analyse` IN-PROCESS
(the way PA's server calls `panelanalysis.build_page`), writes
`<mark>.job.json` (the job actually run), `<mark>.ba.json`
(`cli.summary(a)`) and `<mark>.html` (`report.render(a)`) into
`<ctx.ba_dir>`, and answers the result URL. Never raises: a `ValueError`
from the conversion/read is reported as a problem keyed to the values
key whose value caused it where that can be told (spec section 5); any
other exception is reported with the exception text and the traceback to
stderr only."""
job = None
try:
job = ba_pagespec.job_from_values(values)
except ValueError as exc:
return {'ok': False, 'kind': 'input', 'problems': [
{'key': _key_for_problem(str(exc)), 'text': str(exc)}]}
try:
beam = BM.from_job(job)
except ValueError as exc:
return {'ok': False, 'kind': 'input', 'problems': [
{'key': _key_for_problem(str(exc)), 'text': str(exc)}]}
try:
analysis = BC_SOLVE.analyse(beam)
except Exception as exc:
sys.stderr.write('ba_web: beamcalc.solve.analyse raised:\n' +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
_ensure_dir(ctx.ba_dir)
mark = ctx.mark
paths = {ext: os.path.join(ctx.ba_dir, mark + ext)
for ext in ('.job.json', '.ba.json', '.html')}
try:
with io.open(paths['.job.json'], 'w', encoding='utf-8') as fh:
json.dump(job, fh, indent=1, sort_keys=True, ensure_ascii=False)
with io.open(paths['.ba.json'], 'w', encoding='utf-8') as fh:
json.dump(BC_CLI.summary(analysis), fh, indent=1, sort_keys=True,
ensure_ascii=False)
with io.open(paths['.html'], 'w', encoding='utf-8') as fh:
fh.write(BC_REPORT.render(analysis))
except Exception as exc:
sys.stderr.write('ba_web: could not write results:\n' +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
return {'ok': True, 'result_url': '/results/' + mark + '.html'}
# ------------------------------------------------------------------ server
def make_server(ctx, port=0):
"""Bind 127.0.0.1 ONLY (never a routable interface), on `port` (0 = let
the OS pick). Threaded so a slow solve never blocks a concurrent static
request -- the same server shape as pa_web."""
server = pa_web.ThreadingHTTPServer(('127.0.0.1', port), Handler)
server.ctx = ctx
return server
class Handler(pa_web.Handler):
"""The route table, inherited plumbing from `pa_web.Handler` (the JSON
body reader, `_send_file`, `_serve_from`, the 404/403 handling, the
stderr request log). The static search walks ba_web/static FIRST and
pa_web/static SECOND, so `index.html` and `ba_drawings.js` come from the
beam folder and `app.js` / `draw.js` / `units.js` / `willtools.css` from
PA's single copies."""
server_version = 'BAWeb/1'
def log_message(self, fmt, *args):
try:
sys.stderr.write('ba_web: {0} - {1}\n'.format(
self.address_string(), fmt % args))
except Exception:
pass
def _static_candidates(self, path):
rel = path.lstrip('/') or 'index.html'
for root in STATIC_DIRS:
yield root, rel
# -------------------------------------------------------------- GET
def do_GET(self):
ctx = self.server.ctx
path = pa_web.unquote(pa_web.urlparse(self.path).path)
try:
if path == '/api/spec':
self._send_json(build_spec_json(ctx))
return
if path.startswith('/results/'):
self._serve_from(ctx.ba_dir, path[len('/results/'):],
'text/html')
return
if path.startswith('/api/'):
self._send_json({'ok': False, 'problems': [
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
return
for root, rel in self._static_candidates(path):
full = os.path.normpath(os.path.join(root, rel))
root_norm = os.path.normpath(root)
if not (full == root_norm or
full.startswith(root_norm + os.sep)):
self.send_error(403, 'forbidden')
return
if os.path.isfile(full):
self._send_file(full,
'text/html' if rel == 'index.html'
else 'application/octet-stream')
return
self.send_error(404, 'not found')
except Exception as exc:
self._send_error_json(exc)
# ------------------------------------------------------------- POST
def do_POST(self):
ctx = self.server.ctx
path = pa_web.urlparse(self.path).path
try:
if path == '/api/save':
values = self._read_json_body().get('values') or {}
_save_values(ctx, values)
self._send_json({'ok': True})
return
if path == '/api/run':
values = self._read_json_body().get('values') or {}
self._send_json(run_analysis(ctx, values))
return
if path == '/api/geometry':
values = self._read_json_body().get('values') or {}
self._send_json(geometry_for(ctx, values))
return
if path == '/api/log':
body = self._read_json_body()
try:
log_ctx = pa_web.Context(
reads=ctx.reads, know={}, job_root=ctx.job_root,
pa_dir=ctx.ba_dir, mark=ctx.mark,
static_dir=STATIC_DIRS[0])
pa_web._append_client_log(log_ctx, body)
except Exception as e: # the docstring's promise: never raises
sys.stderr.write('client.log append failed: %r\n' % (e,))
self._send_json({'ok': True})
return
if path == '/api/close':
self._send_json({'ok': True})
threading.Thread(target=self.server.shutdown).start()
return
self._send_json({'ok': False, 'problems': [
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
except Exception as exc:
self._send_error_json(exc)
# --------------------------------------------------------------------- CLI
def main(argv=None):
ap = argparse.ArgumentParser(
description='Serve the Beam Analysis HTML front/back end.')
ap.add_argument('--reads', default=None,
help='JSON file of the model reads dict -- a PARTIAL job '
'plus "basis" (the Revit gatherer writes one; omit '
'for a blank, still-complete form)')
ap.add_argument('--job-root', dest='job_root', default=None,
help='folder to store this run under (overridden by '
"--reads' own job_root field, if present)")
ap.add_argument('--port', type=int, default=0,
help='0 (default) picks a free port')
ap.add_argument('--open', action='store_true',
help='open the page in the default browser')
ap.add_argument('--title', default=DEFAULT_TITLE,
help='cosmetic only -- logged at startup')
args = ap.parse_args(argv)
ctx = build_context(args.reads, args.job_root)
try:
server = make_server(ctx, args.port or 0)
except OSError as exc:
sys.stderr.write('ba_web: could not bind 127.0.0.1:{0}: {1}\n'.format(
args.port, exc))
return 1
url = 'http://127.0.0.1:{0}/'.format(server.server_address[1])
# FIRST line on stdout, flushed immediately -- a launcher (the Revit
# button, a test) reads this off a pipe before anything else happens.
# The mark / job_root / folder paths are written to STDERR only.
print('BA_WEB_URL={0}'.format(url))
sys.stdout.flush()
sys.stderr.write('ba_web: serving {0} for mark={1} (job_root={2}, '
'title={3})\n'.format(url, ctx.mark, ctx.job_root,
args.title))
if args.open:
try:
webbrowser.open(url)
except Exception as exc:
sys.stderr.write('ba_web: could not open a browser: {0}\n'
.format(exc))
try:
server.serve_forever()
finally:
server.server_close()
return 0
if __name__ == '__main__':
raise SystemExit(main())