WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
+632
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Panel Analysis's local server -- the CPython half of the HTML front/back end.
|
||||
|
||||
python tools/pa_web.py [--reads reads.json] [--job-root DIR]
|
||||
[--port N|0] [--open] [--title T]
|
||||
|
||||
Owner ruling, 2026-09-05: every calculation tool has an HTML 前端 (input page)
|
||||
and HTML 后端 (results page) served by a local CPython HTTP server; the engine
|
||||
is CPython (`panellift` + `tools/panelanalysis.py`). Revit is only an OPTIONAL
|
||||
prefill plug-in -- its button reads the model, writes a reads JSON, launches
|
||||
THIS script and shows the page in a WebView2 window. Without Revit, this same
|
||||
server opens the page from a saved reads JSON, or blank-with-defaults, and Run
|
||||
works end to end. See `docs/pa-web-spec.md` for the pagespec schema and the
|
||||
host<->page protocol this implements over plain HTTP instead of postMessage.
|
||||
|
||||
NO REVIT ANYWHERE ON THIS PATH. `vibe.panelanalysisform`, `vibe.pa_pagespec`
|
||||
and `vibe.pa_defaults` are Revit-free (same `vibe` package stub trick
|
||||
`tools/pa_pagespec.py` and `tools/deckcheck.py` use); `tools/panelanalysis.py`
|
||||
(the solve + results-HTML renderer) is plain CPython too.
|
||||
|
||||
Routes:
|
||||
|
||||
GET / tools/pa_web/static/index.html (and every other
|
||||
static asset under that folder, by relative path)
|
||||
GET /api/spec the pagespec JSON: `vibe.pa_pagespec.
|
||||
build_pagespec()`, built off `--reads` (or a
|
||||
blank/defaulted form when absent) with any
|
||||
previously SAVED user edits merged over the
|
||||
prefill.
|
||||
POST /api/geometry body {"values": {...}} -- `{"geometry": {...}}`,
|
||||
the SAME `geometry` dict `/api/spec` returns,
|
||||
rebuilt off the values the client currently
|
||||
holds (not a save, not the prefill) -- how a
|
||||
row-table edit (an opening, a restraint, a
|
||||
support) or a geometry field the client has
|
||||
not saved yet gets its outline/section/free-
|
||||
body drawing redrawn without a full page
|
||||
reload. See `app.js`'s `scheduleGeometryRefresh`.
|
||||
POST /api/save body {"values": {...}} -- persists that dict as
|
||||
this mark's saved edits (`<job-root>/
|
||||
panelanalysis/<mark>.values.json`), read back by
|
||||
the next `/api/spec`.
|
||||
POST /api/run body {"values": {...}} -- `panelanalysisform.
|
||||
validate()`; on problems, {"ok": false,
|
||||
"kind": "input", "problems": [{"key", "text"},
|
||||
...]} (best-effort key per problem sentence, see
|
||||
`_key_for_problem`, '' when it cannot be matched
|
||||
to one FIELDS key). `kind` says WHOSE fault the
|
||||
refusal is: "input" is the piece / the form still
|
||||
missing a value (nothing broke), "error" is the
|
||||
solve or a write raising (a bug, traceback on
|
||||
stderr). The page renders the two differently --
|
||||
a validation refusal and a crash used to share
|
||||
one red banner and read as the same failure;
|
||||
otherwise `build_deck()`, write `<mark>.pa.json`,
|
||||
run the SAME solve `tools/panelanalysis.py`
|
||||
performs on that deck shape (`build_page()`),
|
||||
write the results HTML beside it, and answer
|
||||
{"ok": true, "result_url": "/results/<mark>.html"}.
|
||||
A solve exception is caught and reported the same
|
||||
shape, never a dead server.
|
||||
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>/panelanalysis/<mark>.client.log` and
|
||||
answers {"ok": true} (the client-side error
|
||||
sink `app.js`'s `logError` posts to -- the route
|
||||
`docs/ba-web-spec.md` §5 says PA's server gains
|
||||
for Beam Analysis to reuse).
|
||||
|
||||
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 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>.pa.json` / the
|
||||
results HTML all live, one folder per mark under `<job-root>/panelanalysis/`):
|
||||
the `--reads` file's own `"job_root"` field wins when present (the Revit
|
||||
button always writes one -- `designspec.root_dir(doc)`, the same folder
|
||||
`knowledge.yaml` lives beside); else `--job-root`; else
|
||||
`%LOCALAPPDATA%\\WillTools\\panelanalysis`. The MARK itself (the file-name
|
||||
stem) 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 panel'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 `PA_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 datetime
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import types
|
||||
import webbrowser
|
||||
|
||||
try: # Python 2 vs 3
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, unquote
|
||||
except ImportError: # pragma: no cover
|
||||
from BaseHTTPServer import BaseHTTPRequestHandler # noqa: F401
|
||||
from SocketServer import ThreadingMixIn
|
||||
from BaseHTTPServer import HTTPServer
|
||||
from urlparse import urlparse # noqa: F401
|
||||
from urllib import unquote # noqa: F401
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
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_pagespec.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.
|
||||
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)
|
||||
|
||||
import deckcheck # noqa: E402 -- sets up the `vibe` stub as a side effect
|
||||
import panelanalysis # noqa: E402 -- the CPython solve + results renderer
|
||||
from vibe import pa_pagespec # noqa: E402
|
||||
from vibe import panelanalysisform as F # noqa: E402
|
||||
|
||||
|
||||
STATIC_DIR = os.path.join(HERE, 'pa_web', 'static')
|
||||
|
||||
|
||||
# ------------------------------------------------------------- job / reads
|
||||
|
||||
def _load_reads(path):
|
||||
"""`(reads_dict, error_or_None)` -- mirrors `tools/pa_pagespec.py`'s own
|
||||
loader exactly: a path not given, or not there, is the ordinary "no
|
||||
Revit at all" case (`{}`, no error); a path that IS there and will not
|
||||
parse as a JSON object is reported, never silently treated as empty."""
|
||||
if not path:
|
||||
return {}, None
|
||||
if not os.path.isfile(path):
|
||||
return {}, "'{0}' does not exist; running with reads = {{}}.".format(
|
||||
path)
|
||||
try:
|
||||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||||
data = json.load(fh)
|
||||
except Exception as exc:
|
||||
return {}, "'{0}' could not be read as JSON ({1!r}); running with " \
|
||||
'reads = {{}}.'.format(path, exc)
|
||||
if not isinstance(data, dict):
|
||||
return {}, "'{0}' is not a JSON object; running with reads = {{}}." \
|
||||
.format(path)
|
||||
return data, None
|
||||
|
||||
|
||||
def _default_job_root():
|
||||
base = os.environ.get('LOCALAPPDATA') or os.path.expanduser('~')
|
||||
return os.path.join(base, 'WillTools', 'panelanalysis')
|
||||
|
||||
|
||||
def _slug(text):
|
||||
text = text or ''
|
||||
out = ''.join(ch if (ch.isalnum() or ch in '-_.') else '_' for ch in text)
|
||||
return out or 'panel'
|
||||
|
||||
|
||||
def _ensure_dir(path):
|
||||
if not os.path.isdir(path):
|
||||
os.makedirs(path)
|
||||
|
||||
|
||||
def _append_client_log(ctx, body):
|
||||
"""Append one `<ISO timestamp>\t<json body compact>` line to this mark's
|
||||
`client.log` and return the written path. Never raises -- logging must
|
||||
never take the request down with it. The body is only ever written into
|
||||
the file, never printed (this module promises nothing about a plant, job
|
||||
or client name reaches stdout)."""
|
||||
_ensure_dir(ctx.pa_dir)
|
||||
path = os.path.join(ctx.pa_dir, ctx.mark + '.client.log')
|
||||
line = datetime.datetime.now().isoformat() + '\t' + \
|
||||
json.dumps(body, separators=(',', ':'), ensure_ascii=False) + '\n'
|
||||
with io.open(path, 'a', encoding='utf-8') as fh:
|
||||
fh.write(line)
|
||||
return path
|
||||
|
||||
|
||||
class Context(object):
|
||||
"""Everything one server process needs, resolved once at startup."""
|
||||
|
||||
def __init__(self, reads, know, job_root, pa_dir, mark, static_dir):
|
||||
self.reads = reads
|
||||
self.know = know
|
||||
self.job_root = job_root
|
||||
self.pa_dir = pa_dir
|
||||
self.mark = mark
|
||||
self.static_dir = static_dir
|
||||
|
||||
|
||||
def build_context(reads_path=None, job_root_override=None,
|
||||
static_dir=STATIC_DIR):
|
||||
reads, reads_err = _load_reads(reads_path)
|
||||
if reads_err:
|
||||
sys.stderr.write('pa_web: ' + reads_err + '\n')
|
||||
|
||||
job_root = reads.get('job_root') or job_root_override or \
|
||||
_default_job_root()
|
||||
pa_dir = os.path.join(job_root, 'panelanalysis')
|
||||
_ensure_dir(pa_dir)
|
||||
|
||||
try:
|
||||
know, _layers = deckcheck.build_knowledge(job_root)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
'pa_web: knowledge could not be resolved off {0!r} ({1!r}); '
|
||||
'running with the bare shipped defaults.\n'.format(
|
||||
job_root, exc))
|
||||
know = {}
|
||||
|
||||
mark_raw = (reads.get('mark') or '').strip()
|
||||
mark = _slug(mark_raw) if mark_raw else 'blank'
|
||||
|
||||
return Context(reads=reads, know=know, job_root=job_root, pa_dir=pa_dir,
|
||||
mark=mark, static_dir=static_dir)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the pagespec
|
||||
|
||||
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 = os.path.join(ctx.pa_dir, ctx.mark + '.values.json')
|
||||
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.pa_dir)
|
||||
path = os.path.join(ctx.pa_dir, ctx.mark + '.values.json')
|
||||
blob = json.dumps({'values': values}, indent=1, sort_keys=True,
|
||||
ensure_ascii=False)
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(blob)
|
||||
return path
|
||||
|
||||
|
||||
def geometry_for(ctx, values):
|
||||
"""`{'geometry': {...}}` for `POST /api/geometry` -- the page's own
|
||||
`values` dict, whatever the client currently holds (not re-prefilled,
|
||||
not merged with a save on disk), run back through `pa_pagespec.
|
||||
build_pagespec` so its `geometry` dict reflects an edit the client made
|
||||
seconds ago and has not (or will not) have saved yet -- an opening row
|
||||
added, a length typed in, a support moved. Rebuilding the WHOLE pagespec
|
||||
to get one dict back is wasteful (every page's blocks/tables/choices are
|
||||
recomputed too) but simple and safe: `build_pagespec` is pure and cheap,
|
||||
and a second, geometry-only code path would be a second place for the
|
||||
two to disagree. Never raises -- an exception comes 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:
|
||||
spec = pa_pagespec.build_pagespec(values=values, know=ctx.know,
|
||||
reads=ctx.reads)
|
||||
return {'geometry': spec.get('geometry') or {}}
|
||||
except Exception as exc:
|
||||
sys.stderr.write('pa_web: geometry rebuild raised:\n' +
|
||||
traceback.format_exc())
|
||||
return {'geometry': {},
|
||||
'error': '{0}: {1}'.format(type(exc).__name__, exc)}
|
||||
|
||||
|
||||
def build_spec_json(ctx):
|
||||
"""The pagespec dict for GET /api/spec: prefill off `ctx.reads`/`ctx.
|
||||
know`, with any saved edits for this mark folded over it before
|
||||
`build_pagespec` computes choices/tables/geometry off the RESULT -- so a
|
||||
saved length/width edit also redraws the geometry, not just the raw
|
||||
number."""
|
||||
try:
|
||||
values = F.prefill(ctx.reads, know=ctx.know)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
'pa_web: panelanalysisform.prefill() raised: {0!r}\n'.format(exc))
|
||||
values = {}
|
||||
saved = _load_saved_values(ctx)
|
||||
if saved:
|
||||
values = dict(values)
|
||||
values.update(saved)
|
||||
return pa_pagespec.build_pagespec(values=values, know=ctx.know,
|
||||
reads=ctx.reads)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- run / solve
|
||||
|
||||
_DISPLAY_LABELS = None
|
||||
|
||||
|
||||
def _display_label_pairs():
|
||||
"""`[(label_text, key), ...]`, longest label first -- see
|
||||
`_key_for_problem`. Built once; never raises (a `FIELDS` entry whose
|
||||
`display_label` fails is simply skipped)."""
|
||||
global _DISPLAY_LABELS
|
||||
if _DISPLAY_LABELS is None:
|
||||
pairs = []
|
||||
for f in F.FIELDS:
|
||||
key = f.get('key')
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
label = F.display_label(key)
|
||||
except Exception:
|
||||
continue
|
||||
if label:
|
||||
pairs.append((label, key))
|
||||
pairs.sort(key=lambda p: -len(p[0]))
|
||||
_DISPLAY_LABELS = pairs
|
||||
return _DISPLAY_LABELS
|
||||
|
||||
|
||||
def _key_for_problem(text):
|
||||
"""The `FIELDS` key a `validate()` error sentence is most likely about,
|
||||
or `''` when nothing matches -- best-effort, per the deliverable's own
|
||||
admission that `validate()`'s plain-English errors do not carry a key of
|
||||
their own. Matches by the error sentence STARTING WITH that field's own
|
||||
`display_label(key)` (`'{0} is required'` / `'{0} {1}'` / `'{0}: {1}'`
|
||||
are exactly the three shapes `validate()` builds its per-field errors
|
||||
from), longest label first so e.g. a wythe-2 field is not shadowed by
|
||||
wythe-1's shorter, textually-overlapping label."""
|
||||
if not text:
|
||||
return ''
|
||||
for label, key in _display_label_pairs():
|
||||
if text.startswith(label):
|
||||
return key
|
||||
return ''
|
||||
|
||||
|
||||
def run_analysis(ctx, values):
|
||||
"""`{"ok": True, "result_url": ...}` or `{"ok": False, "kind": ...,
|
||||
"problems": [...]}`.
|
||||
|
||||
Never raises -- a `build_deck` / solve exception is caught and reported
|
||||
the same problems shape the deliverable specifies, with an empty `key`
|
||||
(there is no single field a solve exception can be pinned on).
|
||||
|
||||
`kind` is `'input'` when `validate()` refused (the piece or the form is
|
||||
still missing a value -- the user's to supply, nothing broke) and
|
||||
`'error'` when the solve or a write raised (a bug -- the developer's,
|
||||
traceback on stderr). The two are different answers -- "could not run
|
||||
for want of data" against "ran and broke" -- and the reply names which,
|
||||
because with only `problems` to go on the page rendered both through one
|
||||
red banner and a validation refusal read as a crash (issue #19)."""
|
||||
errors, parsed = F.validate(values)
|
||||
if errors:
|
||||
return {'ok': False, 'kind': 'input', 'problems': [
|
||||
{'key': _key_for_problem(t), 'text': t} for t in errors]}
|
||||
|
||||
try:
|
||||
deck = F.build_deck(parsed)
|
||||
except Exception as exc:
|
||||
sys.stderr.write('pa_web: build_deck() raised:\n' +
|
||||
traceback.format_exc())
|
||||
return {'ok': False, 'kind': 'error', 'problems': [
|
||||
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
|
||||
|
||||
_ensure_dir(ctx.pa_dir)
|
||||
pa_path = os.path.join(ctx.pa_dir, ctx.mark + '.pa.json')
|
||||
try:
|
||||
blob = json.dumps(deck, indent=1, sort_keys=True, ensure_ascii=False)
|
||||
with io.open(pa_path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(blob)
|
||||
except Exception as exc:
|
||||
sys.stderr.write('pa_web: could not write {0}:\n'.format(pa_path) +
|
||||
traceback.format_exc())
|
||||
return {'ok': False, 'kind': 'error', 'problems': [
|
||||
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
|
||||
|
||||
try:
|
||||
html = panelanalysis.build_page(pa_path)
|
||||
except Exception as exc:
|
||||
sys.stderr.write('pa_web: panelanalysis.build_page() raised:\n' +
|
||||
traceback.format_exc())
|
||||
return {'ok': False, 'kind': 'error', 'problems': [
|
||||
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
|
||||
|
||||
result_name = ctx.mark + '.html'
|
||||
result_path = os.path.join(ctx.pa_dir, result_name)
|
||||
try:
|
||||
with io.open(result_path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(html)
|
||||
except Exception as exc:
|
||||
sys.stderr.write('pa_web: could not write {0}:\n'.format(
|
||||
result_path) + traceback.format_exc())
|
||||
return {'ok': False, 'kind': 'error', 'problems': [
|
||||
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
|
||||
|
||||
return {'ok': True, 'result_url': '/results/' + result_name}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ server
|
||||
|
||||
def make_server(ctx, port=0):
|
||||
server = ThreadingHTTPServer(('127.0.0.1', port), Handler)
|
||||
server.ctx = ctx
|
||||
return server
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'PAWeb/1'
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
try:
|
||||
sys.stderr.write('pa_web: {0} - {1}\n'.format(
|
||||
self.address_string(), fmt % args))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------- plumbing
|
||||
|
||||
def _send_json(self, obj, status=200):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _send_error_json(self, exc, status=500):
|
||||
sys.stderr.write('pa_web: {0} {1} failed:\n{2}'.format(
|
||||
self.command, self.path, traceback.format_exc()))
|
||||
self._send_json({'ok': False, 'kind': 'error', 'problems': [
|
||||
{'key': '', 'text': '{0}: {1}'.format(
|
||||
type(exc).__name__, exc)}]}, status=status)
|
||||
|
||||
def _read_json_body(self):
|
||||
try:
|
||||
length = int(self.headers.get('Content-Length') or 0)
|
||||
except (TypeError, ValueError):
|
||||
length = 0
|
||||
raw = self.rfile.read(length) if length else b''
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw.decode('utf-8'))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _send_file(self, path, default_type):
|
||||
ctype, _enc = mimetypes.guess_type(path)
|
||||
ctype = ctype or default_type
|
||||
# Every text-ish type this server ever serves (html/js/css/json) is
|
||||
# written as UTF-8 -- say so, or a browser guesses cp1252/latin-1 and
|
||||
# every non-ASCII glyph (the title's en dash, the row x buttons, the
|
||||
# section-arrow triangles) renders as mojibake. mimetypes.guess_type
|
||||
# never appends a charset on its own.
|
||||
if ctype.startswith('text/') or ctype in (
|
||||
'application/javascript', 'application/json'):
|
||||
ctype += '; charset=utf-8'
|
||||
try:
|
||||
with open(path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
except Exception as exc:
|
||||
self._send_error_json(exc)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', ctype)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _serve_from(self, root, rel, default_type):
|
||||
rel = rel.lstrip('/') or 'index.html'
|
||||
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 not os.path.isfile(full):
|
||||
self.send_error(404, 'not found')
|
||||
return
|
||||
self._send_file(full, default_type)
|
||||
|
||||
# -------------------------------------------------------------- GET
|
||||
|
||||
def do_GET(self):
|
||||
ctx = self.server.ctx
|
||||
path = unquote(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.pa_dir, path[len('/results/'):],
|
||||
'text/html')
|
||||
return
|
||||
if path.startswith('/api/'):
|
||||
self._send_json({'ok': False, 'problems': [
|
||||
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
|
||||
return
|
||||
self._serve_from(ctx.static_dir, path, 'application/octet-stream')
|
||||
except Exception as exc:
|
||||
self._send_error_json(exc)
|
||||
|
||||
# ------------------------------------------------------------- POST
|
||||
|
||||
def do_POST(self):
|
||||
ctx = self.server.ctx
|
||||
path = 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:
|
||||
_append_client_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 Panel Analysis HTML front/back end.')
|
||||
ap.add_argument('--reads', default=None,
|
||||
help='JSON file of the model reads dict (the Revit '
|
||||
'button 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='Panel Analysis',
|
||||
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('pa_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.
|
||||
print('PA_WEB_URL={0}'.format(url))
|
||||
sys.stdout.flush()
|
||||
sys.stderr.write('pa_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('pa_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())
|
||||
Reference in New Issue
Block a user