Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
+616
@@ -0,0 +1,616 @@
|
||||
#!/usr/bin/env python3
|
||||
"""rvt - talk to the live Revit session from your terminal.
|
||||
|
||||
Requires: pyRevit routes server running inside Revit (see ../README.md).
|
||||
Standard library only, Python 3.8+.
|
||||
|
||||
python tools/rvt.py status
|
||||
python tools/rvt.py exec scratch/scratch.py
|
||||
python tools/rvt.py exec -c "print(doc.Title)"
|
||||
python tools/rvt.py exec scratch/rename.py --tx "Rename walls"
|
||||
echo "print(len(list(FilteredElementCollector(doc))))" | python tools/rvt.py exec -
|
||||
python tools/rvt.py sel
|
||||
python tools/rvt.py collect OST_Walls --limit 20
|
||||
python tools/rvt.py params 123456
|
||||
python tools/rvt.py reload
|
||||
python tools/rvt.py --strict status # refuse an unverifiable answer
|
||||
|
||||
Every request carries a correlation id and every answer is checked against it.
|
||||
See `call()` and `REQUEST_CORRELATION` below for why that is not optional.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# pyRevit does not pin the routes port: `serverinfo._get_next_available_port`
|
||||
# starts at the configured one and walks UP past every port a running Revit
|
||||
# already holds. So with two sessions open, 48884 is "whichever Revit started
|
||||
# first" and the second is on 48885. That is not a detail - it is how a sweep
|
||||
# aimed at the sandbox lands in the live model instead.
|
||||
#
|
||||
# The port is therefore settable from the environment rather than hard-coded,
|
||||
# so `tools/snapshot_cycle.py` can scan the small range, find the session that
|
||||
# is actually the sandbox, and hand that port down to every batch command it
|
||||
# runs. Unset, this is exactly what it always was.
|
||||
DEFAULT_PORT = 48884
|
||||
PORT_ENV = "WILLTOOLS_BRIDGE_PORT"
|
||||
|
||||
|
||||
def _port() -> int:
|
||||
try:
|
||||
return int(os.environ.get(PORT_ENV) or DEFAULT_PORT)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_PORT
|
||||
|
||||
|
||||
def base_url() -> str:
|
||||
"""The bridge base URL for THIS call.
|
||||
|
||||
Read per call, not once at import: a caller that sets the variable and
|
||||
then makes a request is the ordinary case, and a module-level constant
|
||||
would silently serve the port that was set when the module loaded.
|
||||
"""
|
||||
return f"http://localhost:{_port()}/vibe"
|
||||
|
||||
|
||||
BASE = base_url()
|
||||
TIMEOUT = 300
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
SESSION_LOG = ROOT / ".bridge-sessions.log"
|
||||
|
||||
# --------------------------------------------------------- request correlation
|
||||
#
|
||||
# REQUEST_CORRELATION: why every request carries an id, and every answer is
|
||||
# checked against it.
|
||||
#
|
||||
# Measured 2026-09-01, twice, during a 282-piece sweep. One request timed out
|
||||
# client-side ("[260/282] no response from Revit within 300s"), and from then
|
||||
# on the pairing was off by one: `sweep_in_revit.py`'s `open_model_title()`
|
||||
# asked for `doc.Title` and got the SWEEP's progress string back, which it then
|
||||
# printed as the document title; an agent polling `doc.Title` as a drift guard
|
||||
# read the same stolen answer as "the model changed underneath me" and raised a
|
||||
# false alarm. The data on disk was fine. The attribution was not, and **a
|
||||
# response from the wrong request is indistinguishable from a valid answer**.
|
||||
#
|
||||
# WHERE THE DESYNC ACTUALLY LIVES -- not here. This client is stateless per
|
||||
# request: `urlopen` builds a new connection every call and urllib sends
|
||||
# `Connection: close`, so there is no client-side socket to go out of step and
|
||||
# nothing here to "drain". The shared state is inside pyRevit, in
|
||||
# `pyrevit/routes/server/server.py`:
|
||||
#
|
||||
# REQUEST_HNDLR = handler.RequestHandler() # ONE, module level
|
||||
# EVENT_HNDLR = UI.ExternalEvent.Create(REQUEST_HNDLR)
|
||||
#
|
||||
# The HTTP server is a `ThreadingMixIn` server, so each connection gets its own
|
||||
# thread -- but every one of those threads writes the SAME singleton
|
||||
# (`REQUEST_HNDLR.request = request`), calls the SAME `reset()` (clearing one
|
||||
# shared `_response` / `_done` pair), raises the SAME external event, and then
|
||||
# spins in `req_hndlr.join()` waiting on that one `_done` flag before reading
|
||||
# that one `_response`.
|
||||
#
|
||||
# So a client-side timeout does not cancel anything. The server thread is still
|
||||
# spinning, Revit still owes it an answer, and when the next request arrives:
|
||||
#
|
||||
# * it overwrites `REQUEST_HNDLR.request`/`.handler` with its own, so the
|
||||
# abandoned event -- if Revit has not serviced it yet -- executes against
|
||||
# the NEW request; and `ExternalEvent.Raise()` on an already-pending event
|
||||
# coalesces, so the new request's own raise can be swallowed and its
|
||||
# snippet never run at all; and
|
||||
# * both threads then wait on one `_done` flag and read one `_response`, so
|
||||
# whichever handler finishes first answers BOTH sockets. The abandoned
|
||||
# one's write goes to a closed socket and is lost; the live one carries the
|
||||
# abandoned request's body to a client that asked something else.
|
||||
#
|
||||
# That is the whole mechanism, and it is why the fix is in two halves that both
|
||||
# have to exist:
|
||||
#
|
||||
# 1. an id minted here and echoed by `WillTools.extension/startup.py`, so a
|
||||
# stolen answer is REFUSED loudly instead of returned (`_verified`); and
|
||||
# 2. a latch (`_latch_timeout`) written the moment a request times out, which
|
||||
# makes the NEXT call drain that shared slot with a self-identifying probe
|
||||
# before it sends anything real (`_drain_channel`). The latch is a file
|
||||
# because `tools/sweep.py` runs `rvt.py` as a fresh subprocess per call --
|
||||
# an in-process flag would not survive to the request it has to protect,
|
||||
# which is precisely the request that got the stolen answer.
|
||||
#
|
||||
# Nothing here can stop the theft happening inside Revit. It can stop the
|
||||
# result being mistaken for data, which is the part that cost real time.
|
||||
|
||||
CORRELATION_KEY = "_rid"
|
||||
UNVERIFIED_KEY = "_unverified"
|
||||
STRICT_ENV = "WILLTOOLS_BRIDGE_STRICT"
|
||||
|
||||
# Where the "the last request timed out" latch lives. One file per port: which
|
||||
# Revit owns which port is decided by start order (see DEFAULT_PORT above), so
|
||||
# a latch left by a call to 48885 must not gate a call to 48884.
|
||||
LATCH_DIR = ROOT
|
||||
|
||||
# Escalating waits for the drain probes. Short on purpose: the point is to find
|
||||
# out whether the channel is clear, not to sit out another long request. One
|
||||
# abandoned request leaves at most one stale answer, so two probes are the
|
||||
# minimum that can both consume it and then confirm - the third is headroom for
|
||||
# a first probe that gets no answer at all because Revit is still busy.
|
||||
DRAIN_TIMEOUTS = (15, 30, 60)
|
||||
|
||||
_STRICT = False
|
||||
|
||||
|
||||
class BridgeTimeout(Exception):
|
||||
"""A request that got no answer.
|
||||
|
||||
Deliberately an exception and never a value: "Revit did not answer" and
|
||||
"Revit answered" are different states, and the whole incident above is what
|
||||
happens when something downstream reads one as the other.
|
||||
"""
|
||||
|
||||
|
||||
def _strict() -> bool:
|
||||
"""Whether an unverifiable answer is refused rather than flagged."""
|
||||
return bool(_STRICT or (os.environ.get(STRICT_ENV) or "").strip())
|
||||
|
||||
|
||||
def warn(msg: str):
|
||||
"""Say something on stderr.
|
||||
|
||||
stderr, always: `tools/sweep.py` parses this process's STDOUT as JSON, so a
|
||||
warning printed there would corrupt the answer it is warning about.
|
||||
"""
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
"""A request id that is unique across processes and across time.
|
||||
|
||||
The pid alone is not enough (`sweep.py` runs one `rvt.py` per call, and pids
|
||||
are reused); a counter alone is not enough for the same reason. This is
|
||||
pid + wall clock + random, which is unique enough to make a match mean
|
||||
something and short enough to read in an error message.
|
||||
"""
|
||||
return "{0}-{1:x}-{2}".format(os.getpid(), int(time.time() * 1000),
|
||||
uuid.uuid4().hex[:8])
|
||||
|
||||
|
||||
def _latch_path() -> pathlib.Path:
|
||||
return LATCH_DIR / ".bridge-timeout-{0}.json".format(_port())
|
||||
|
||||
|
||||
def _latch_timeout(rid: str, path: str, reason: str):
|
||||
"""Record that this bridge may still owe an answer to a dead request."""
|
||||
body = {"rid": rid, "path": path, "reason": reason, "port": _port(),
|
||||
"at": time.time(), "pid": os.getpid()}
|
||||
try:
|
||||
_latch_path().write_text(json.dumps(body, indent=1), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
# A guard that silently failed to arm is worse than no guard: say so
|
||||
# here rather than letting the next call believe the channel is clean.
|
||||
warn("WARNING: could not write the bridge timeout latch at {0} ({1}).\n"
|
||||
" The NEXT request will not know to drain the channel first, so\n"
|
||||
" it may be handed this request's late answer. Treat the next\n"
|
||||
" answer as unattributed."
|
||||
.format(_latch_path(), exc))
|
||||
|
||||
|
||||
def _read_latch():
|
||||
"""The latch for this port, or None. An unreadable latch still counts."""
|
||||
path = _latch_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
body = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return {"rid": None, "path": None, "reason": "unreadable latch: {0}"
|
||||
.format(exc), "at": None}
|
||||
return body if isinstance(body, dict) else {"rid": None, "path": None,
|
||||
"reason": "malformed latch",
|
||||
"at": None}
|
||||
|
||||
|
||||
def _clear_latch():
|
||||
try:
|
||||
_latch_path().unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _drain_channel():
|
||||
"""Consume any late reply still sitting in the bridge's one response slot.
|
||||
|
||||
Returns (clear, sentence).
|
||||
|
||||
The probe is `/exec` with a snippet that returns a nonce this process just
|
||||
invented. That gives the answer TWO independent ways to identify itself:
|
||||
the echoed correlation id (only a current in-Revit half sends it) and the
|
||||
nonce coming back as `result` (works against any half, including the one
|
||||
running right now, which was installed before ids existed). An answer that
|
||||
is neither is somebody else's -- reading it here is what removes it from
|
||||
the shared slot, which is the actual draining.
|
||||
"""
|
||||
stale = 0
|
||||
notes = []
|
||||
for wait in DRAIN_TIMEOUTS:
|
||||
nonce = "vibe-drain-" + uuid.uuid4().hex[:16]
|
||||
rid = new_request_id()
|
||||
body = {"code": "result = {0!r}".format(nonce), CORRELATION_KEY: rid}
|
||||
try:
|
||||
res = _send("/exec", "POST", body, wait)
|
||||
except BridgeTimeout:
|
||||
notes.append("a {0}s probe got no answer at all".format(wait))
|
||||
continue
|
||||
if isinstance(res, dict) and res.get("refused"):
|
||||
return False, ("the bridge is refusing every route, so nothing can "
|
||||
"prove the channel is clean: {0}"
|
||||
.format(res.get("error")))
|
||||
echoed = res.get(CORRELATION_KEY) if isinstance(res, dict) else None
|
||||
if echoed is not None and echoed != rid:
|
||||
stale += 1
|
||||
notes.append("a probe came back stamped {0}, which is another "
|
||||
"request's id".format(echoed))
|
||||
continue
|
||||
if isinstance(res, dict) and res.get("result") == nonce:
|
||||
return True, ("the bridge answered its own probe{0}"
|
||||
.format("" if not stale else
|
||||
" after {0} stale repl{1} drained"
|
||||
.format(stale, "y" if stale == 1 else "ies")))
|
||||
stale += 1
|
||||
notes.append("a probe came back without its own nonce in `result` "
|
||||
"-- that answer belonged to something else")
|
||||
return False, "; ".join(notes) or "no probe could identify itself"
|
||||
|
||||
|
||||
def _resolve_timeout_latch():
|
||||
"""Refuse to reuse a channel that may still be carrying a dead request.
|
||||
|
||||
Called before every real request. Draining is attempted, loudly, and only a
|
||||
probe that identifies itself clears the latch -- "the bridge answered" is
|
||||
not the same fact as "the bridge answered ME", and this whole module exists
|
||||
because those two were once the same code path.
|
||||
"""
|
||||
latch = _read_latch()
|
||||
if latch is None:
|
||||
return
|
||||
warn("NOTE: the last request on port {0} ({1}) did not complete: {2}.\n"
|
||||
" pyRevit serves every route through one shared response slot, so a\n"
|
||||
" late answer to that request would be handed to this one. Draining\n"
|
||||
" before sending anything real."
|
||||
.format(_port(), latch.get("path") or "unknown route",
|
||||
latch.get("reason") or "unknown"))
|
||||
clear, why = _drain_channel()
|
||||
if clear:
|
||||
_clear_latch()
|
||||
warn(" channel clear: {0}. Continuing.".format(why))
|
||||
return
|
||||
die("REFUSING to send - this bridge cannot be proven clean.\n\n"
|
||||
" The previous request ({0}) never came back, and every probe since\n"
|
||||
" has failed to identify itself: {1}.\n\n"
|
||||
" Sending now risks being handed that request's answer and reading it\n"
|
||||
" as an answer to this one - which is not a hypothetical, it is what\n"
|
||||
" happened on 2026-09-01 (see REQUEST_CORRELATION in this file).\n\n"
|
||||
" Wait for Revit to finish what it is doing and run this again; the\n"
|
||||
" drain retries every time. If Revit is idle and this keeps failing,\n"
|
||||
" a modal dialog is blocking the API context, or the session needs a\n"
|
||||
" RESTART (never a pyRevit reload).\n\n"
|
||||
" Latch: {2}".format(latch.get("path") or "unknown route", why,
|
||||
_latch_path()))
|
||||
|
||||
|
||||
def check_bridge_safe():
|
||||
"""Refuse to talk to a bridge that has survived a pyRevit reload.
|
||||
|
||||
pyRevit's routes handler keeps pointing at the scripting engine from before
|
||||
the reload. The next request executes against a dead engine and kills Revit
|
||||
outright - an unhandled exception on a non-main thread, which Revit cannot
|
||||
catch. Our code never runs, so this has to be caught out here, before the
|
||||
request is ever sent.
|
||||
|
||||
startup.py appends one line per pyRevit load. Two lines for the same Revit
|
||||
process means a reload happened and the bridge is now a landmine.
|
||||
"""
|
||||
if not SESSION_LOG.exists():
|
||||
# Either Revit has never loaded the extension, or the tripwire in
|
||||
# startup.py is not firing. Say so - a safety net that silently stopped
|
||||
# working is worse than one that was never there.
|
||||
print(
|
||||
"WARNING: no bridge session log at "
|
||||
f"{SESSION_LOG}\n"
|
||||
" The reload tripwire is not recording. If Revit IS running with\n"
|
||||
" pyRevit loaded, the guard is broken - do not reload pyRevit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
entries = []
|
||||
for raw in SESSION_LOG.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
parts = raw.strip().split("|")
|
||||
if len(parts) == 3:
|
||||
entries.append((parts[0], parts[1], parts[2])) # when, pid, proc_start
|
||||
if not entries:
|
||||
return
|
||||
|
||||
_, pid, proc_start = entries[-1]
|
||||
loads = sum(1 for e in entries if e[1] == pid and e[2] == proc_start)
|
||||
if loads > 1:
|
||||
die(
|
||||
f"REFUSING to send - the bridge is unsafe right now.\n\n"
|
||||
f" pyRevit has been reloaded {loads - 1}x inside this Revit process (pid {pid}).\n"
|
||||
f" Its request handler still points at the scripting engine from before\n"
|
||||
f" the reload, so the next request would crash Revit outright.\n\n"
|
||||
f" Fix: restart Revit (not 'pyRevit > Reload'). A fresh process is safe,\n"
|
||||
f" and a restart picks up new buttons just as well as a reload does.\n\n"
|
||||
f" Log: {SESSION_LOG}"
|
||||
)
|
||||
|
||||
|
||||
def _send(path: str, method: str, payload, timeout: int):
|
||||
"""One HTTP round trip. Raises BridgeTimeout; never checks correlation.
|
||||
|
||||
Arguments always travel as a JSON body, never a query string, and never a
|
||||
header. pyRevit's routes `Request` does not reliably expose a parsed query
|
||||
string across versions - on pyRevit 5.0.1 it exposes none at all, under any
|
||||
attribute name (see startup.py's `_body` docstring) - so every endpoint
|
||||
reads `request.data` instead, and `payload` is how this client fills it in.
|
||||
A header would be no better: `base.Request.__init__` sets `_headers = {}`
|
||||
and `HttpRequestHandler._prepare_request` never fills it, so the request
|
||||
headers are as unreachable from handler code as the query string is. That
|
||||
is why the correlation id travels in the body, on GET routes too - the
|
||||
server reads a body whenever Content-Length is set, whatever the method.
|
||||
"""
|
||||
base = base_url()
|
||||
url = base + path
|
||||
data = None
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
except TimeoutError:
|
||||
raise BridgeTimeout(path)
|
||||
except urllib.error.URLError as exc:
|
||||
# A CONNECT timeout arrives wrapped in URLError (socket.timeout is an
|
||||
# OSError, and `do_open` wraps those); a READ timeout is raised bare and
|
||||
# is caught above. Both are the same answer - nobody replied - and must
|
||||
# not be reported as "cannot reach Revit", which reads as "Revit is
|
||||
# closed" and is a different fact.
|
||||
if isinstance(exc.reason, TimeoutError):
|
||||
raise BridgeTimeout(path)
|
||||
die(
|
||||
f"cannot reach Revit at {base}\n"
|
||||
f" -> {exc.reason}\n"
|
||||
" Is Revit open with pyRevit loaded? Routes server must be enabled\n"
|
||||
" (pyrevit configs routes enable) and Revit restarted once after install."
|
||||
)
|
||||
except ConnectionResetError as exc:
|
||||
die(
|
||||
f"Revit closed the connection ({type(exc).__name__}).\n"
|
||||
" Usually means the routes server or Revit itself went down mid-request.\n"
|
||||
" Check Revit is still running, then restart it if the bridge stays dead."
|
||||
)
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return body
|
||||
|
||||
|
||||
def _verified(res, rid: str, path: str):
|
||||
"""The answer, but only if it is an answer to THIS request.
|
||||
|
||||
Three outcomes, and they are three, not two:
|
||||
|
||||
matching id the answer, with the id stripped back off so callers see
|
||||
exactly the shape they always saw.
|
||||
wrong id REFUSED. Named loudly, both ids printed, nothing returned -
|
||||
a stolen answer is indistinguishable from a real one, so
|
||||
there is no safe value to hand back.
|
||||
no id at all the in-Revit half predates this and cannot say. Warned and
|
||||
flagged `_unverified`, or refused under --strict; see the
|
||||
warning text for why that is the default.
|
||||
"""
|
||||
got = res.get(CORRELATION_KEY) if isinstance(res, dict) else None
|
||||
|
||||
if got is not None and got != rid:
|
||||
# Proof, not suspicion: the channel is serving somebody else's answers.
|
||||
_latch_timeout(rid, path, "an answer arrived stamped with another "
|
||||
"request's id ({0})".format(got))
|
||||
die("REFUSING this answer - it belongs to a different request.\n\n"
|
||||
" asked as {rid}\n"
|
||||
" answered as {got}\n"
|
||||
" route {path}\n\n"
|
||||
" pyRevit serves every route through one shared response slot, so\n"
|
||||
" an abandoned request's answer can be handed to the next caller.\n"
|
||||
" This is that, caught. Nothing is returned, because a wrong\n"
|
||||
" answer and a right one look identical once they are a value.\n\n"
|
||||
" The channel is now latched; the next call drains it first."
|
||||
.format(rid=rid, got=got, path=path))
|
||||
|
||||
if got is None:
|
||||
if _strict():
|
||||
die("REFUSING this answer - it cannot be attributed ({0}).\n\n"
|
||||
" This client stamped the request {1} and the in-Revit half\n"
|
||||
" echoed nothing back, so there is no way to tell this answer\n"
|
||||
" from an abandoned request's answer served out of pyRevit's\n"
|
||||
" shared response slot.\n\n"
|
||||
" The in-Revit half only echoes ids once Revit has been\n"
|
||||
" RESTARTED after this version of the extension was installed\n"
|
||||
" (never a pyRevit reload). Drop --strict / unset {2} to take\n"
|
||||
" the answer with a warning instead."
|
||||
.format(path, rid, STRICT_ENV))
|
||||
warn("WARNING: this answer could not be attributed.\n"
|
||||
" Asked as {0} on {1}; the in-Revit half echoed no id back, so it\n"
|
||||
" predates request correlation and cannot say which request it is\n"
|
||||
" answering. It is almost certainly right - but if a request has\n"
|
||||
" timed out in this session, an answer like this one is exactly\n"
|
||||
" how the wrong body gets read as data.\n"
|
||||
" Fix: RESTART Revit (never a pyRevit reload) to load the current\n"
|
||||
" extension. Use --strict / {2}=1 to refuse instead of warn."
|
||||
.format(rid, path, STRICT_ENV))
|
||||
if isinstance(res, dict):
|
||||
res[UNVERIFIED_KEY] = True
|
||||
return res
|
||||
|
||||
res.pop(CORRELATION_KEY, None)
|
||||
return res
|
||||
|
||||
|
||||
def call(path: str, method: str = "GET", payload=None):
|
||||
"""Call one bridge endpoint, and prove the answer is this call's answer.
|
||||
|
||||
See REQUEST_CORRELATION at the top of this file for the incident and the
|
||||
mechanism. In short: a correlation id goes out in the body (every route,
|
||||
GET included), the current in-Revit half echoes it, and an answer that does
|
||||
not carry this request's id is refused rather than returned.
|
||||
"""
|
||||
check_bridge_safe()
|
||||
_resolve_timeout_latch()
|
||||
rid = new_request_id()
|
||||
body = dict(payload or {})
|
||||
body[CORRELATION_KEY] = rid
|
||||
try:
|
||||
res = _send(path, method, body, TIMEOUT)
|
||||
except BridgeTimeout:
|
||||
_latch_timeout(rid, path, "no answer within {0}s".format(TIMEOUT))
|
||||
die(
|
||||
f"COULD NOT JUDGE: no response from Revit within {TIMEOUT}s ({path}).\n"
|
||||
" This is not an answer about the model. Nothing was read and\n"
|
||||
" nothing was ruled out - the request may still be running inside\n"
|
||||
" Revit, and its answer may arrive after this process is gone.\n"
|
||||
" A modal dialog in Revit blocks the API context the bridge runs on -\n"
|
||||
" dismiss any open dialog, then retry.\n\n"
|
||||
f" The bridge is now latched ({_latch_path().name}): the next call\n"
|
||||
" drains that late answer out of pyRevit's shared response slot\n"
|
||||
" before it sends anything, so it cannot be served as data to\n"
|
||||
" whoever asks next."
|
||||
)
|
||||
return _verified(res, rid, path)
|
||||
|
||||
|
||||
def die(msg: str, code: int = 1):
|
||||
print(msg, file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def show(res):
|
||||
"""Print an /exec-style response in a human- and agent-friendly way."""
|
||||
if not isinstance(res, dict):
|
||||
print(res)
|
||||
return 0
|
||||
out = res.get("stdout")
|
||||
if out:
|
||||
print(out, end="" if out.endswith("\n") else "\n")
|
||||
if res.get("ok"):
|
||||
result = res.get("result")
|
||||
if result is not None:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
print("ERROR: " + str(res.get("error", res)), file=sys.stderr)
|
||||
if res.get("traceback"):
|
||||
print(res["traceback"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_status(_):
|
||||
return show_json(call("/status"))
|
||||
|
||||
|
||||
def show_json(res):
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
return 0 if isinstance(res, dict) and res.get("ok") else 1
|
||||
|
||||
|
||||
def cmd_exec(args):
|
||||
if args.code:
|
||||
code = args.code
|
||||
elif args.file == "-":
|
||||
code = sys.stdin.read()
|
||||
elif args.file:
|
||||
with open(args.file, "r", encoding="utf-8") as fh:
|
||||
code = fh.read()
|
||||
else:
|
||||
die("give a file, -c CODE, or - to read stdin")
|
||||
return show(call("/exec", "POST", {"code": code, "transaction": args.tx}))
|
||||
|
||||
|
||||
def cmd_sel(_):
|
||||
return show_json(call("/selection"))
|
||||
|
||||
|
||||
def cmd_collect(args):
|
||||
return show_json(
|
||||
call("/collect", "POST", {"category": args.category,
|
||||
"limit": args.limit,
|
||||
"types": bool(args.types)})
|
||||
)
|
||||
|
||||
|
||||
def cmd_params(args):
|
||||
return show_json(call("/params", "POST", {"id": args.id}))
|
||||
|
||||
|
||||
def cmd_reload(_):
|
||||
die(
|
||||
"Reloading over HTTP is not supported - it crashes Revit.\n"
|
||||
" Reloading rebuilds the pyRevit session, including the routes server\n"
|
||||
" that would be serving this request; the half-dead server then throws\n"
|
||||
" on a background thread and takes Revit with it.\n\n"
|
||||
" Use the ribbon instead: Vibe > System > Reload pyRevit\n"
|
||||
" (only needed after ADDING or RENAMING a button - editing an existing\n"
|
||||
" script.py is picked up on the next click with no reload.)"
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog="rvt", description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
# Before the subcommand: `rvt.py --strict status`. Also settable as
|
||||
# WILLTOOLS_BRIDGE_STRICT=1, which is how it reaches the tools that shell
|
||||
# out to this one (sweep.py, snapshot_cycle.py) without threading a flag
|
||||
# through every command line they build.
|
||||
ap.add_argument("--strict", action="store_true",
|
||||
help="refuse an answer that carries no correlation id "
|
||||
"(an in-Revit half from before this Revit restart) "
|
||||
"instead of warning and returning it")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("status", help="Revit + open model info").set_defaults(fn=cmd_status)
|
||||
|
||||
p = sub.add_parser("exec", help="run a Python snippet inside Revit")
|
||||
p.add_argument("file", nargs="?", help="path to a .py file, or - for stdin")
|
||||
p.add_argument("-c", "--code", help="inline code")
|
||||
p.add_argument("--tx", metavar="NAME",
|
||||
help="wrap in a Revit Transaction with this name")
|
||||
p.set_defaults(fn=cmd_exec)
|
||||
|
||||
sub.add_parser("sel", help="current selection").set_defaults(fn=cmd_sel)
|
||||
|
||||
p = sub.add_parser("collect", help="collect elements by BuiltInCategory")
|
||||
p.add_argument("category", help="e.g. OST_Walls")
|
||||
p.add_argument("--limit", type=int, default=100)
|
||||
p.add_argument("--types", action="store_true", help="element types, not instances")
|
||||
p.set_defaults(fn=cmd_collect)
|
||||
|
||||
p = sub.add_parser("params", help="dump all parameters of one element")
|
||||
p.add_argument("id", help="element id")
|
||||
p.set_defaults(fn=cmd_params)
|
||||
|
||||
sub.add_parser("reload", help="reload pyRevit (picks up new buttons)").set_defaults(fn=cmd_reload)
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
global _STRICT
|
||||
_STRICT = bool(args.strict)
|
||||
return args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user