Files
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

764 lines
33 KiB
Python

#!/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
python tools/rvt.py bridges # list live bridge sessions
python tools/rvt.py use "sample_job" # resolve a model title to a port
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.
One exception, proven live 2026-09-03: a 4-field line stamped `hotreload`
is a load that came up AFTER the bridge was stopped cleanly first (the
stop-first Hot Reload under Setup > Config > Hot Reload). The engine is
fresh and the handler points at it, so a second load is safe when -- and
only when -- the LAST load of this process carries that flag. A second+
load with NO such flag is a raw `pyRevit > Reload` into a live bridge,
which is the crash.
"""
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("|")
# when | pid | proc_start [| hotreload]. The optional 4th field is the
# stop-first Hot Reload stamp (startup.py); an older half writes 3.
if len(parts) >= 3:
flag = parts[3] if len(parts) >= 4 else ""
entries.append((parts[0], parts[1], parts[2], flag))
if not entries:
return
# This process's lines are those of the LAST entry: startup.py stamps the
# pid and process start of whatever pyRevit load wrote the line, so the
# current engine is the one the newest line belongs to.
_, pid, proc_start, last_flag = entries[-1]
own = [e for e in entries if e[1] == pid and e[2] == proc_start]
if len(own) > 1:
if last_flag == "hotreload":
# A stop-first Hot Reload: the bridge was stopped cleanly BEFORE
# this load re-registered a fresh server against a fresh engine, so
# the handler is not pointing at a dead engine. Proven live
# 2026-09-03: 4 clean requests after a hot reload on a throwaway
# model, Revit unharmed.
return
die(
f"REFUSING to send - the bridge is unsafe right now.\n\n"
f" pyRevit has been reloaded {len(own) - 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: use Setup > Config > Hot Reload (it stops the bridge first), or\n"
f" restart Revit. A raw 'pyRevit > Reload' is what leaves the handler on\n"
f" a dead engine.\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 _probe_status(port: int, timeout: int = 3):
"""A bare GET /vibe/status probe for bridge DISCOVERY.
Deliberately not routed through `call()`/`_send()`: correlation, the drain
latch and the reload tripwire all belong to real requests, and this probe
is the opposite - it only asks "is anything live on this port". It is also
cheap and bounded (`timeout` seconds), where a real request may legitimately
take the full 300s. Returns the parsed JSON body, or None when the port
refuses the connection or answers something unparseable. `refused` bridges
DO answer HTTP (with a refusal body), so they come back as a dict - that is
how `bridges` distinguishes a live-but-protected session from a dead port.
"""
url = "http://localhost:{0}/vibe/status".format(port)
try:
req = urllib.request.Request(url, method="GET")
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 Exception:
return None
try:
return json.loads(body)
except ValueError:
return None
def discover_bridges(base=None, span=8, timeout=3, _probe=None):
"""Which of ports base..base+span-1 has a live bridge on it.
Returns a list of dicts (empty when nothing is up):
{"port": port, "model": <doc_title or None>, "refused": bool,
"ok": bool}
A port that will not answer at all contributes nothing. `_probe` is an
injection seam for tests and defaults to `_probe_status`; it must accept
(port, timeout) and return a dict, or None.
"""
probe = _probe or _probe_status
base = base or DEFAULT_PORT
found = []
for port in range(base, base + span):
d = probe(port, timeout)
if not isinstance(d, dict):
continue
found.append({"port": port,
"model": d.get("doc_title"),
"refused": bool(d.get("refused")),
"ok": bool(d.get("ok"))})
return found
def cmd_bridges(args):
found = discover_bridges(span=args.span, timeout=args.timeout)
if args.json:
print(json.dumps(found, ensure_ascii=False))
return 0 if found else 1
if not found:
warn("no live bridge was found on the scanned range (ports {0}..{1})"
.format(DEFAULT_PORT, DEFAULT_PORT + args.span - 1))
print("(none)")
return 1
for entry in found:
refused = " [REFUSED - live, protected]" if entry["refused"] else ""
print("{0} {1}{2}".format(entry["port"], entry["model"] or "", refused))
return 0
def cmd_use(args):
"""Resolve a model-title substring OR an explicit port to a live bridge."""
found = discover_bridges(span=args.span, timeout=args.timeout)
if args.target.isdigit():
want = int(args.target)
hits = [e for e in found if e["port"] == want]
if not hits:
die("no live bridge on port {0} (scanned ports {1}..{2})"
.format(want, DEFAULT_PORT, DEFAULT_PORT + args.span - 1))
port, model = hits[0]["port"], hits[0]["model"]
else:
needle = args.target.lower()
matches = [e for e in found
if e["model"] and needle in str(e["model"]).lower()]
if not matches:
live = ", ".join("{0} ({1})".format(e["port"], e["model"])
for e in found) or "(none)"
die("no live bridge matches {0!r}.\n live bridges: {1}"
.format(args.target, live))
if len(matches) > 1:
listed = ", ".join("{0} ({1})".format(e["port"], e["model"])
for e in matches)
die("ambiguous: {0} live bridges match {1!r}: {2}.\n"
" use a longer substring, or the port number."
.format(len(matches), args.target, listed))
port, model = matches[0]["port"], matches[0]["model"]
if args.json:
print(json.dumps({"port": port, "model": model}, ensure_ascii=False))
elif args.export:
print("export {0}={1}".format(PORT_ENV, port))
else:
print(port)
warn("bridge {0} -> {1}".format(port, model))
return 0
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)
p = sub.add_parser("bridges", help="list live bridge sessions")
p.add_argument("--span", type=int, default=8,
help="how many ports to scan up from the base")
p.add_argument("--timeout", type=int, default=3,
help="seconds per port probe")
p.add_argument("--json", action="store_true", help="print the list as JSON")
p.set_defaults(fn=cmd_bridges)
p = sub.add_parser("use", help="resolve a model title (or port) to a port")
p.add_argument("target", help="a model-title substring, or a port number")
p.add_argument("--span", type=int, default=8,
help="how many ports to scan up from the base")
p.add_argument("--timeout", type=int, default=3,
help="seconds per port probe")
p.add_argument("--json", action="store_true",
help="print {\"port\": ..., \"model\": ...} as JSON")
p.add_argument("--export", action="store_true",
help="print 'export WILLTOOLS_BRIDGE_PORT=<port>'")
p.set_defaults(fn=cmd_use)
args = ap.parse_args(argv)
global _STRICT
_STRICT = bool(args.strict)
return args.fn(args)
if __name__ == "__main__":
raise SystemExit(main())