#!/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 `/ beamanalysis/.job.json` (the job actually run), `.ba.json` (`beamcalc.cli.summary(a)`), `.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 (`/beamanalysis/.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": , "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 `.job.json`, `.ba.json` (`cli.summary(a)`) and `.html` (`report.render(a)`) into the same `beamanalysis/` folder and answers {"ok": true, "result_url": "/results/.html"}. GET /results/ 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, `\t`, to `/beamanalysis/.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 `.client.log` -- into the FILE, never stdout. JOB ROOT resolution (where `.values.json` / `.job.json` / the results HTML all live, one folder per mark under `/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:/` -- 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 (/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 # /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 `/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 `.job.json` (the job actually run), `.ba.json` (`cli.summary(a)`) and `.html` (`report.render(a)`) into ``, 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())