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:
@@ -0,0 +1,818 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Vibe-coding bridge for Revit.
|
||||
|
||||
Registers a local HTTP API (pyRevit Routes) that lets an editor / AI agent
|
||||
run Python *inside the live Revit session* without restarting Revit.
|
||||
|
||||
Base URL: http://localhost:48884/vibe/ (or the next free port up - pyRevit
|
||||
walks upward when a Revit already holds it, which is why every
|
||||
route below refuses outright in a model named by
|
||||
`bridge.refuse_titles`. See the live-model guard section.) A model
|
||||
named by `bridge.readonly_titles` instead is served normally for
|
||||
reads, and `/exec` refuses only the requests that could write --
|
||||
see the read-only guard section below the live-model one.
|
||||
|
||||
This file runs once per pyRevit session load, on the default IronPython 2.7
|
||||
engine. Keep it Python-2-compatible (no f-strings).
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from pyrevit import routes
|
||||
|
||||
|
||||
try:
|
||||
from StringIO import StringIO # IronPython 2.7
|
||||
except ImportError:
|
||||
from io import StringIO # CPython / IronPython 3
|
||||
|
||||
|
||||
api = routes.API('vibe')
|
||||
|
||||
MAX_ITEMS = 500
|
||||
|
||||
|
||||
# --------------------------------------------------------- reload tripwire
|
||||
#
|
||||
# pyRevit's routes RequestHandler is an IExternalEventHandler created by the
|
||||
# session that first loaded it. reload_pyrevit() tears that engine down but
|
||||
# leaves the handler and the HTTP server wired to it, so the NEXT request
|
||||
# executes against a dead engine -> unhandled exception on a non-main thread
|
||||
# -> Revit dies with no chance to catch it. Confirmed twice on 2026-08-14
|
||||
# (11 clean requests, one reload, next request fatal).
|
||||
#
|
||||
# Nothing inside Revit can guard against this: our code never gets to run.
|
||||
# So we leave a breadcrumb here instead, and tools/rvt.py refuses to send a
|
||||
# request when it sees a second load under the same Revit process.
|
||||
|
||||
def _stamp_session():
|
||||
"""Append one line per pyRevit load: when, which Revit process."""
|
||||
try:
|
||||
import datetime
|
||||
from System.Diagnostics import Process
|
||||
|
||||
# __file__ is not guaranteed to be defined for a pyRevit startup
|
||||
# script, and a silently broken tripwire is worse than none - fall
|
||||
# back to the known location rather than skipping the stamp.
|
||||
try:
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
except NameError:
|
||||
root = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
|
||||
if not os.path.isdir(root):
|
||||
root = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
|
||||
|
||||
proc = Process.GetCurrentProcess()
|
||||
line = '{0}|{1}|{2}\n'.format(
|
||||
datetime.datetime.now().isoformat(),
|
||||
proc.Id,
|
||||
proc.StartTime.ToString('o'),
|
||||
)
|
||||
with open(os.path.join(root, '.bridge-sessions.log'), 'a') as fh:
|
||||
fh.write(line)
|
||||
except Exception:
|
||||
# a broken tripwire must never stop the routes from registering
|
||||
pass
|
||||
|
||||
|
||||
_stamp_session()
|
||||
|
||||
|
||||
# ------------------------------------------------------- the live-model guard
|
||||
#
|
||||
# "不要碰我的live" -- the owner, repeatedly, and until 2026-09-01 it was only
|
||||
# ever a thing an agent had to remember. This is the mechanical version.
|
||||
#
|
||||
# The hole it closes is a port race, not a mistake anybody makes on purpose.
|
||||
# pyRevit's routes server takes the configured port if it is free and walks UP
|
||||
# if it is not (`serverinfo._get_next_available_port`), so which session owns
|
||||
# 48884 is decided by which Revit started first. Restart the sandbox while the
|
||||
# live session is up and 48884 is now the LIVE model -- and every request an
|
||||
# agent sends lands there, `status` says ok, the title matches (the snapshot
|
||||
# deliberately keeps the live title), and the whole sweep runs in the model it
|
||||
# must never touch.
|
||||
#
|
||||
# So the refusal is made here, where the document is. The rule and every piece
|
||||
# of matching live in `vibe/bridgeguard.py`, which imports nothing and reads
|
||||
# nothing, so it is unit-testable off-Revit -- see tests/test_bridgeguard.py.
|
||||
# This file only fetches the configuration and asks.
|
||||
|
||||
_GUARD_TTL = 5.0 # seconds; see _refuse_config
|
||||
_guard_cache = {}
|
||||
|
||||
|
||||
def _guard_pid():
|
||||
try:
|
||||
from System.Diagnostics import Process
|
||||
return Process.GetCurrentProcess().Id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _refuse_config(doc):
|
||||
"""(titles, roots, readonly_titles, error) for this document, memoised
|
||||
briefly.
|
||||
|
||||
`knowledge.load()` reads up to five YAML layers off disk. A sweep sends
|
||||
hundreds of requests, and paying for five file reads on each of them would
|
||||
make the guard the most expensive thing in the bridge. Five seconds of
|
||||
cache is short enough that an edit is picked up within one human pause and
|
||||
long enough that a batch does not re-read per call.
|
||||
|
||||
`readonly_titles` rides the SAME cache entry and the SAME `knowledge.load`
|
||||
call as `titles`/`roots` -- reading `bridge.readonly_titles` out of the
|
||||
tree `config_from` already walked costs nothing extra, and `_guard_exec`
|
||||
below calls this a second time per `/exec` request purely to get it, which
|
||||
is a cache hit, not a second file read.
|
||||
|
||||
A failure to READ the configuration is returned as `error`, not swallowed:
|
||||
"the denylist is empty" and "I could not find out what the denylist is"
|
||||
are opposite answers and `bridgeguard.refusal` treats the second as a
|
||||
refusal.
|
||||
"""
|
||||
import time as _time
|
||||
key = None
|
||||
try:
|
||||
key = (doc.Title, doc.PathName)
|
||||
except Exception:
|
||||
key = None
|
||||
now = _time.time()
|
||||
if key is not None:
|
||||
hit = _guard_cache.get(key)
|
||||
if hit and hit[0] > now:
|
||||
return hit[1], hit[2], hit[3], hit[4]
|
||||
|
||||
titles, roots, readonly_titles, error = [], [], [], None
|
||||
try:
|
||||
from vibe import bridgeguard, knowledge
|
||||
know = knowledge.load(doc)
|
||||
titles, roots = bridgeguard.config_from(know, knowledge.get)
|
||||
readonly_titles = bridgeguard.readonly_titles_from(know, knowledge.get)
|
||||
except Exception as ex:
|
||||
titles, roots, readonly_titles = [], [], []
|
||||
error = '{0}: {1}'.format(type(ex).__name__, ex)
|
||||
if key is not None:
|
||||
_guard_cache[key] = (now + _GUARD_TTL, titles, roots, readonly_titles,
|
||||
error)
|
||||
return titles, roots, readonly_titles, error
|
||||
|
||||
|
||||
def _guard(doc):
|
||||
"""The refusal body for this document, or None to let the request run.
|
||||
|
||||
Every route calls this first, `/status` included. A refused `/status` is
|
||||
the point rather than an inconvenience: it is the only signal that tells
|
||||
`tools/snapshot_cycle.py` "the live Revit is holding this port" apart from
|
||||
"the sandbox has not finished starting", and those two need opposite
|
||||
responses. The body still names the title and the pid, so a refusal
|
||||
answers "which session did I reach" even while it answers nothing else.
|
||||
"""
|
||||
try:
|
||||
from vibe import bridgeguard
|
||||
except Exception:
|
||||
# The guard module is not deployed beside this file. That is a
|
||||
# broken install, not a licence to run in the live model.
|
||||
title = None
|
||||
try:
|
||||
title = doc.Title if doc is not None else None
|
||||
except Exception:
|
||||
title = None
|
||||
return {'ok': False, 'refused': True, 'doc_title': title,
|
||||
'pid': _guard_pid(),
|
||||
'error': 'REFUSED: vibe.bridgeguard could not be imported, so '
|
||||
'this bridge cannot tell whether the open document '
|
||||
'is the live model. Reinstall the extension.'}
|
||||
try:
|
||||
if doc is None:
|
||||
return None
|
||||
title = doc.Title
|
||||
try:
|
||||
path = doc.PathName or None
|
||||
except Exception:
|
||||
path = None
|
||||
titles, roots, _readonly_titles, error = _refuse_config(doc)
|
||||
reason = bridgeguard.refusal(
|
||||
title, path, titles, roots,
|
||||
os.environ.get(bridgeguard.ENV_SANDBOX), error)
|
||||
if reason:
|
||||
body = bridgeguard.payload(title, reason)
|
||||
body['pid'] = _guard_pid()
|
||||
return body
|
||||
except Exception as ex:
|
||||
return {'ok': False, 'refused': True, 'doc_title': None,
|
||||
'pid': _guard_pid(),
|
||||
'error': 'REFUSED: the live-model guard itself failed ({0}: '
|
||||
'{1}), so this bridge cannot prove which model is '
|
||||
'open.'.format(type(ex).__name__, ex)}
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------- the read-only guard
|
||||
#
|
||||
# 2026-09-01, same day, the owner reversed refuse_titles for the live model:
|
||||
# "我要把revit桥给你。你直接来sample_job。铁律是在live里面只读不写。" The bridge
|
||||
# may run on it now; the rule is it may never write to it. `bridge.
|
||||
# readonly_titles` is that rule, and unlike refuse_titles it does not touch
|
||||
# `/status`, `/collect`, `/params` or `/selection` at all -- they are reads
|
||||
# by construction, with no transaction and no arbitrary code in any of them.
|
||||
# Only `/exec` can write, so only `/exec` calls `_guard_exec`, after `_guard`
|
||||
# above has already let the request through. The matching -- what counts as
|
||||
# "could write" -- lives in `vibe/bridgeguard.py`'s `WRITE_PATTERNS` and
|
||||
# `exec_refusal`, off-Revit-testable the same way `refusal` is.
|
||||
|
||||
def _guard_exec(doc, data):
|
||||
"""The refusal body for THIS `/exec` request, or None to let it run.
|
||||
|
||||
Called ONLY from `vibe_exec`, and only after `_guard(doc)` above has
|
||||
already passed -- a title on `bridge.refuse_titles` is refused there and
|
||||
never reaches this at all. This is the softer rule the owner reversed
|
||||
refuse_titles into on 2026-09-01: a title on `bridge.readonly_titles` may
|
||||
still run reads through `/exec`, but a request that carries a transaction
|
||||
or a snippet that matches known write-capable Revit API surface
|
||||
(`vibe.bridgeguard.WRITE_PATTERNS`) is refused. `/status`, `/collect`,
|
||||
`/params` and `/selection` never carry a transaction or arbitrary code --
|
||||
they are reads by construction -- so none of them call this.
|
||||
"""
|
||||
try:
|
||||
from vibe import bridgeguard
|
||||
except Exception:
|
||||
# _guard(doc) above already refused when the module could not be
|
||||
# imported at all, so reaching here means it succeeded and this
|
||||
# import cannot actually fail -- but if it somehow does, do not
|
||||
# invent a pass.
|
||||
return {'ok': False, 'refused': True, 'doc_title': None,
|
||||
'pid': _guard_pid(),
|
||||
'error': 'REFUSED: vibe.bridgeguard could not be imported '
|
||||
'for the read-only check, so this bridge cannot '
|
||||
'prove this request only reads.'}
|
||||
try:
|
||||
if doc is None:
|
||||
return None
|
||||
title = doc.Title
|
||||
try:
|
||||
path = doc.PathName or None
|
||||
except Exception:
|
||||
path = None
|
||||
titles, roots, readonly_titles, error = _refuse_config(doc)
|
||||
if error:
|
||||
# Same could-not-judge discipline as `_guard`: `titles` failing
|
||||
# to read already refused everything above, so an error reaching
|
||||
# here can only mean the SAME read failed -- readonly_titles is
|
||||
# read from the identical knowledge tree in the identical call.
|
||||
# Refuse rather than treat an unread list as an empty one.
|
||||
body = bridgeguard.payload(
|
||||
title,
|
||||
'REFUSED: this bridge cannot tell whether "{0}" is on '
|
||||
'bridge.readonly_titles -- the configuration could not be '
|
||||
'read ({1}). Nothing was run.'.format(title, error))
|
||||
body['pid'] = _guard_pid()
|
||||
return body
|
||||
reason = bridgeguard.exec_refusal(
|
||||
title, path, readonly_titles, roots,
|
||||
os.environ.get(bridgeguard.ENV_SANDBOX),
|
||||
tx_name=data.get('transaction'), code=data.get('code'))
|
||||
if reason:
|
||||
body = bridgeguard.payload(title, reason)
|
||||
body['pid'] = _guard_pid()
|
||||
return body
|
||||
except Exception as ex:
|
||||
return {'ok': False, 'refused': True, 'doc_title': None,
|
||||
'pid': _guard_pid(),
|
||||
'error': 'REFUSED: the read-only guard itself failed ({0}: '
|
||||
'{1}), so this bridge cannot prove this request '
|
||||
'only reads.'.format(type(ex).__name__, ex)}
|
||||
return None
|
||||
|
||||
|
||||
def _doc_of(uiapp):
|
||||
"""The active document behind a uiapp, or None. Never raises."""
|
||||
try:
|
||||
uidoc = getattr(uiapp, 'ActiveUIDocument', None)
|
||||
return getattr(uidoc, 'Document', None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- utilities
|
||||
|
||||
# Types hoisted into every snippet's namespace, by the sub-namespace they
|
||||
# actually live in. Missing names are skipped rather than killing the import -
|
||||
# Revit moves types between namespaces across versions, and one bad name must
|
||||
# not take down /exec entirely.
|
||||
_HOISTED = {
|
||||
'DB': (
|
||||
'FilteredElementCollector', 'BuiltInCategory', 'BuiltInParameter',
|
||||
'ElementId', 'XYZ', 'Transaction', 'TransactionGroup', 'SubTransaction',
|
||||
'Transform', 'Line', 'Arc', 'Curve', 'CurveLoop', 'Plane',
|
||||
'ElementCategoryFilter', 'ElementClassFilter', 'LogicalAndFilter',
|
||||
'LogicalOrFilter', 'ParameterFilterRuleFactory', 'ElementParameterFilter',
|
||||
'ElementLevelFilter', 'UnitUtils', 'SpecTypeId', 'UnitTypeId',
|
||||
'StorageType', 'ViewType', 'View', 'ViewPlan', 'ViewSheet',
|
||||
'ViewSchedule', 'Viewport', 'Level', 'Grid', 'Wall', 'Floor', 'Ceiling',
|
||||
'RoofBase', 'FamilyInstance', 'FamilySymbol', 'Family', 'Element',
|
||||
'ElementType', 'Category', 'Options', 'Solid', 'BoundingBoxXYZ',
|
||||
'LocationPoint', 'LocationCurve', 'TextNote', 'RevitLinkInstance',
|
||||
),
|
||||
'DB.Architecture': ('Room', 'Stairs', 'Railing'),
|
||||
'DB.Mechanical': ('Space', 'Duct'),
|
||||
'DB.Structure': ('StructuralType',),
|
||||
}
|
||||
|
||||
|
||||
def _revit_ns():
|
||||
"""Build the namespace that user snippets are executed in."""
|
||||
import clr
|
||||
clr.AddReference('RevitAPI')
|
||||
clr.AddReference('RevitAPIUI')
|
||||
|
||||
from Autodesk.Revit import DB, UI
|
||||
from pyrevit import revit, script, forms, HOST_APP
|
||||
|
||||
uiapp = HOST_APP.uiapp
|
||||
uidoc = getattr(uiapp, 'ActiveUIDocument', None)
|
||||
doc = getattr(uidoc, 'Document', None)
|
||||
|
||||
ns = {
|
||||
'__name__': '__vibe__',
|
||||
'clr': clr,
|
||||
'DB': DB,
|
||||
'UI': UI,
|
||||
'revit': revit,
|
||||
'script': script,
|
||||
'forms': forms,
|
||||
'HOST_APP': HOST_APP,
|
||||
'uiapp': uiapp,
|
||||
'uidoc': uidoc,
|
||||
'doc': doc,
|
||||
'app': uiapp.Application if uiapp else None,
|
||||
'view': doc.ActiveView if doc else None,
|
||||
}
|
||||
|
||||
# hoist the common types so snippets stay short
|
||||
for modpath, names in _HOISTED.items():
|
||||
mod = DB
|
||||
for part in modpath.split('.')[1:]: # 'DB.Architecture' -> Architecture
|
||||
mod = getattr(mod, part, None)
|
||||
if mod is None:
|
||||
break
|
||||
if mod is None:
|
||||
continue
|
||||
for name in names:
|
||||
obj = getattr(mod, name, None)
|
||||
if obj is not None:
|
||||
ns[name] = obj
|
||||
|
||||
# the shared helper library, if the extension's lib/ is importable
|
||||
try:
|
||||
import vibe
|
||||
ns['vibe'] = vibe
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return ns
|
||||
|
||||
|
||||
def _elem_brief(el, doc=None):
|
||||
"""Small JSON-safe dict describing a Revit element."""
|
||||
if el is None:
|
||||
return None
|
||||
info = {}
|
||||
try:
|
||||
info['id'] = el.Id.IntegerValue
|
||||
except Exception:
|
||||
try:
|
||||
info['id'] = int(el.Id.Value) # Revit 2024+ ElementId.Value
|
||||
except Exception:
|
||||
info['id'] = None
|
||||
for key, getter in (
|
||||
('name', lambda: el.Name),
|
||||
('class', lambda: type(el).__name__),
|
||||
('category', lambda: el.Category.Name if el.Category else None),
|
||||
('level', lambda: (doc or el.Document).GetElement(el.LevelId).Name),
|
||||
):
|
||||
try:
|
||||
info[key] = getter()
|
||||
except Exception:
|
||||
info[key] = None
|
||||
return info
|
||||
|
||||
|
||||
def _jsonable(obj, _depth=0):
|
||||
"""Best-effort conversion of any value into something JSON serializable."""
|
||||
if obj is None or isinstance(obj, (bool, int, float)):
|
||||
return obj
|
||||
try:
|
||||
if isinstance(obj, basestring): # noqa: F821 (py2)
|
||||
return obj
|
||||
except NameError:
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
|
||||
if _depth > 4:
|
||||
return str(obj)
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return dict((str(k), _jsonable(v, _depth + 1)) for k, v in obj.items())
|
||||
|
||||
# Revit element?
|
||||
if hasattr(obj, 'Id') and hasattr(obj, 'Category'):
|
||||
return _elem_brief(obj)
|
||||
|
||||
# iterable (incl. .NET collections)
|
||||
if not isinstance(obj, type):
|
||||
try:
|
||||
items = list(obj)
|
||||
return [_jsonable(i, _depth + 1) for i in items[:MAX_ITEMS]]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _error(exc):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': '{0}: {1}'.format(type(exc).__name__, exc),
|
||||
'traceback': traceback.format_exc(),
|
||||
}
|
||||
|
||||
|
||||
def _body(request):
|
||||
"""Parse a request's JSON body into a dict; ``{}`` if there isn't one.
|
||||
|
||||
Every endpoint that takes arguments reads them from here, never from a
|
||||
query string. `request.data` is set unconditionally in the `Request`
|
||||
constructor (`pyrevit/routes/server/base.py`), on every pyRevit version
|
||||
we have source for, and `/exec` has read it successfully the whole time
|
||||
this bridge has existed. A parsed query string is not that reliable: on
|
||||
this machine's pyRevit 5.0.1, `Request` has no `query_params` attribute,
|
||||
no `query` attribute, nothing -- `routes/server/server.py`'s
|
||||
`HttpRequestHandler._parse_api_path` builds the path from
|
||||
`urlparse(self.path).path` and the `.query` half is simply discarded, so
|
||||
there is no query-string channel to read on this version at all, under
|
||||
any name. See `/collect` and `/params` below, which is where that broke.
|
||||
"""
|
||||
data = request.data or {}
|
||||
if not isinstance(data, dict):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except Exception:
|
||||
data = {}
|
||||
return data
|
||||
|
||||
|
||||
# ------------------------------------------------------- request correlation
|
||||
#
|
||||
# Every route echoes the caller's request id back on its response, and
|
||||
# `tools/rvt.py` refuses any answer that does not carry the id it asked with.
|
||||
#
|
||||
# Why an id is needed at all: pyRevit serves every route through ONE shared
|
||||
# response slot. `routes/server/server.py` creates a single module-level
|
||||
# `REQUEST_HNDLR` / `EVENT_HNDLR` pair, and every connection thread of the
|
||||
# ThreadingMixIn server writes that same singleton's `.request`, calls the same
|
||||
# `reset()`, raises the same external event, then waits on the same `_done`
|
||||
# flag before reading the same `_response`. A client that gives up on a slow
|
||||
# request does not cancel any of that -- the server thread keeps spinning and
|
||||
# Revit still owes it an answer -- so the next request's thread can be released
|
||||
# by, and read, the ABANDONED request's response. Worse, `ExternalEvent.Raise()`
|
||||
# coalesces while an event is pending, so the new request's snippet may never
|
||||
# execute at all while its caller receives the old one's body.
|
||||
#
|
||||
# Measured 2026-09-01 during a 282-piece sweep: one request timed out at 300s,
|
||||
# and the calls after it were served the sweep's progress string. One of them
|
||||
# had asked for `doc.Title`, and both a report and an agent read that string as
|
||||
# the document's title. The files on disk were correct; only the attribution
|
||||
# was wrong, and a response from the wrong request is indistinguishable from a
|
||||
# valid answer -- which is the exact silence this tool is built against.
|
||||
#
|
||||
# The id travels in the JSON body, not a header: `base.Request.__init__` sets
|
||||
# `_headers = {}` and `HttpRequestHandler._prepare_request` never fills it, so
|
||||
# request headers are as unreachable from handler code as the query string is
|
||||
# (see `_body` above). The server parses a body whenever Content-Length is set,
|
||||
# whatever the method, so this works on the GET routes too.
|
||||
#
|
||||
# Backward compatible in the honest direction, both ways round: an old client
|
||||
# sends no id, so nothing is echoed and nothing changes for it; a new client
|
||||
# talking to an OLD in-Revit half gets no id back and says out loud that the
|
||||
# answer could not be attributed, rather than trusting it silently.
|
||||
|
||||
RID_KEY = '_rid'
|
||||
|
||||
|
||||
def _rid_of(request):
|
||||
"""The caller's correlation id for this request, or None."""
|
||||
try:
|
||||
return _body(request).get(RID_KEY)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _reply(request, body):
|
||||
"""Stamp the caller's correlation id onto a response body.
|
||||
|
||||
Applied at the outermost point of every route, so refusals and error
|
||||
payloads carry it too: "which request is this a refusal to" is the same
|
||||
question as "which request is this an answer to".
|
||||
"""
|
||||
rid = _rid_of(request)
|
||||
if rid is not None and isinstance(body, dict):
|
||||
body[RID_KEY] = rid
|
||||
return body
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- routes
|
||||
|
||||
@api.route('/status', methods=['GET'])
|
||||
def vibe_status(uiapp, request):
|
||||
"""Session heartbeat: is Revit alive, which model is open.
|
||||
|
||||
Takes `request` only to echo the correlation id -- it has no arguments of
|
||||
its own. `pyrevit.routes` hands `request` to any handler that names it.
|
||||
|
||||
Every route below has this same three-line shape: guard, then answer, and
|
||||
both answers leave through `_reply` so they carry the caller's id. The
|
||||
guard stays HERE rather than in the private half, where it is visible in
|
||||
the decorated function that `tests/test_bridgeguard.py` reads.
|
||||
"""
|
||||
refused = _guard(_doc_of(uiapp))
|
||||
if refused is not None:
|
||||
return _reply(request, refused)
|
||||
return _reply(request, _status(uiapp))
|
||||
|
||||
|
||||
def _status(uiapp):
|
||||
try:
|
||||
uidoc = getattr(uiapp, 'ActiveUIDocument', None)
|
||||
doc = getattr(uidoc, 'Document', None)
|
||||
app = uiapp.Application
|
||||
return {
|
||||
'ok': True,
|
||||
'pid': _guard_pid(),
|
||||
'revit_version': app.VersionNumber,
|
||||
'revit_build': app.VersionBuild,
|
||||
'username': app.Username,
|
||||
'doc_title': doc.Title if doc else None,
|
||||
'doc_path': doc.PathName if doc else None,
|
||||
'is_family': doc.IsFamilyDocument if doc else None,
|
||||
'is_modified': doc.IsModified if doc else None,
|
||||
'active_view': doc.ActiveView.Name if doc else None,
|
||||
'python': sys.version,
|
||||
}
|
||||
except Exception as ex:
|
||||
return _error(ex)
|
||||
|
||||
|
||||
@api.route('/exec', methods=['POST'])
|
||||
def vibe_exec(uiapp, request):
|
||||
"""Execute a Python snippet inside the live Revit session.
|
||||
|
||||
Body (JSON):
|
||||
code (str) required - the snippet
|
||||
transaction (str) optional - wrap the snippet in a Revit Transaction
|
||||
with this name (auto commit / rollback)
|
||||
|
||||
Returns: {ok, stdout, result, error, traceback}
|
||||
|
||||
The snippet gets `doc`, `uidoc`, `uiapp`, `DB`, `UI`, `revit`, and the
|
||||
common Revit types pre-imported. Set a variable named `result` (or make
|
||||
the whole snippet a single expression) to send a value back.
|
||||
|
||||
In a document named by `bridge.readonly_titles`: a request that carries
|
||||
`transaction`, or a `code` that matches write-capable Revit API surface,
|
||||
is refused (`_guard_exec`) before either the transaction or the snippet
|
||||
runs. A plain read runs normally.
|
||||
"""
|
||||
doc = _doc_of(uiapp)
|
||||
refused = _guard(doc)
|
||||
if refused is not None:
|
||||
return _reply(request, refused)
|
||||
data = _body(request)
|
||||
refused = _guard_exec(doc, data)
|
||||
if refused is not None:
|
||||
return _reply(request, refused)
|
||||
return _reply(request, _exec(uiapp, data))
|
||||
|
||||
|
||||
def _exec(uiapp, data):
|
||||
code = data.get('code')
|
||||
if not code:
|
||||
return {'ok': False, 'error': 'missing "code" in request body'}
|
||||
tx_name = data.get('transaction')
|
||||
|
||||
try:
|
||||
ns = _revit_ns()
|
||||
except Exception as ex:
|
||||
return _error(ex)
|
||||
|
||||
doc = ns.get('doc')
|
||||
if tx_name and doc is None:
|
||||
return {'ok': False, 'error': 'no active document; cannot open a transaction'}
|
||||
|
||||
old_stdout = sys.stdout
|
||||
buf = StringIO()
|
||||
sys.stdout = buf
|
||||
tx = None
|
||||
result = None
|
||||
try:
|
||||
if tx_name:
|
||||
tx = ns['Transaction'](doc, str(tx_name))
|
||||
tx.Start()
|
||||
|
||||
try:
|
||||
compiled = compile(code, '<vibe>', 'eval')
|
||||
result = eval(compiled, ns) # single-expression snippet
|
||||
except SyntaxError:
|
||||
exec(compile(code, '<vibe>', 'exec'), ns)
|
||||
result = ns.get('result')
|
||||
|
||||
if tx is not None:
|
||||
tx.Commit()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': buf.getvalue(),
|
||||
'result': _jsonable(result),
|
||||
'transaction': tx_name,
|
||||
}
|
||||
except Exception as ex:
|
||||
if tx is not None:
|
||||
try:
|
||||
if tx.HasStarted() and not tx.HasEnded():
|
||||
tx.RollBack()
|
||||
except Exception:
|
||||
pass
|
||||
payload = _error(ex)
|
||||
payload['stdout'] = buf.getvalue()
|
||||
return payload
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@api.route('/selection', methods=['GET'])
|
||||
def vibe_selection(uidoc, doc, request):
|
||||
"""Elements currently selected in Revit."""
|
||||
refused = _guard(doc)
|
||||
if refused is not None:
|
||||
return _reply(request, refused)
|
||||
return _reply(request, _selection(uidoc, doc))
|
||||
|
||||
|
||||
def _selection(uidoc, doc):
|
||||
try:
|
||||
if uidoc is None:
|
||||
return {'ok': True, 'count': 0, 'elements': []}
|
||||
ids = list(uidoc.Selection.GetElementIds())
|
||||
els = [doc.GetElement(i) for i in ids]
|
||||
return {
|
||||
'ok': True,
|
||||
'count': len(els),
|
||||
'elements': [_elem_brief(e, doc) for e in els[:MAX_ITEMS]],
|
||||
}
|
||||
except Exception as ex:
|
||||
return _error(ex)
|
||||
|
||||
|
||||
@api.route('/collect', methods=['POST'])
|
||||
def vibe_collect(doc, request):
|
||||
"""Collect elements by BuiltInCategory.
|
||||
|
||||
Body (JSON):
|
||||
category e.g. OST_Walls (required)
|
||||
limit default 100
|
||||
types true to collect types instead of instances
|
||||
|
||||
POST with a JSON body, not GET with a query string -- see `_body`'s
|
||||
docstring above for why: pyRevit's `Request` does not reliably expose a
|
||||
parsed query string across versions, and on this machine's pyRevit 5.0.1
|
||||
it exposes none at all. This used to read `request.query_params`, which
|
||||
does not exist on that `Request` (`AttributeError`), and would not have
|
||||
been safe to "fix" by trying another attribute name for the same reason.
|
||||
"""
|
||||
refused = _guard(doc)
|
||||
if refused is not None:
|
||||
return _reply(request, refused)
|
||||
return _reply(request, _collect(doc, _body(request)))
|
||||
|
||||
|
||||
def _collect(doc, data):
|
||||
try:
|
||||
if doc is None:
|
||||
return {'ok': False, 'error': 'no active document'}
|
||||
cat_name = data.get('category')
|
||||
if not cat_name:
|
||||
return {'ok': False, 'error': 'missing "category" in request body'}
|
||||
limit = data.get('limit')
|
||||
limit = int(limit) if limit is not None else 100
|
||||
want_types = bool(data.get('types', False))
|
||||
|
||||
from Autodesk.Revit.DB import FilteredElementCollector, BuiltInCategory
|
||||
bic = getattr(BuiltInCategory, cat_name, None)
|
||||
if bic is None:
|
||||
return {'ok': False, 'error': 'unknown BuiltInCategory: ' + cat_name}
|
||||
|
||||
col = FilteredElementCollector(doc).OfCategory(bic)
|
||||
col = col.WhereElementIsElementType() if want_types \
|
||||
else col.WhereElementIsNotElementType()
|
||||
els = list(col.ToElements())
|
||||
return {
|
||||
'ok': True,
|
||||
'category': cat_name,
|
||||
'total': len(els),
|
||||
'elements': [_elem_brief(e, doc) for e in els[:limit]],
|
||||
}
|
||||
except Exception as ex:
|
||||
return _error(ex)
|
||||
|
||||
|
||||
@api.route('/params', methods=['POST'])
|
||||
def vibe_params(doc, request):
|
||||
"""All parameters of one element. Body (JSON): {"id": 123456}
|
||||
|
||||
POST with a JSON body, not GET with `?id=...`. This handler is the
|
||||
diagnostic CLAUDE.md points to for "don't guess a parameter name", and it
|
||||
was broken on pyRevit 5.0.1: `request.query_params` raised
|
||||
`AttributeError('Request' object has no attribute 'query_params')`,
|
||||
because that `Request` type (`pyrevit/routes/server/base.py`) exposes
|
||||
`path`, `method`, `data`, `headers`, `params` (route-*pattern* captures
|
||||
like `<int:id>`, unrelated to the query string) and `callback_url` -- no
|
||||
`query_params`, and no query string reaches it under any name:
|
||||
`routes/server/server.py`'s `HttpRequestHandler._parse_api_path` keeps
|
||||
only `urlparse(self.path).path` and drops `.query` entirely before a
|
||||
`Request` is ever built. Swapping in a different attribute name would
|
||||
have "fixed" this machine and silently broken whichever one actually has
|
||||
`query_params`, so both the query string and the attribute-name guess are
|
||||
gone: this now uses `request.data`, the one channel `/exec` already uses
|
||||
successfully on every version this ships to.
|
||||
"""
|
||||
refused = _guard(doc)
|
||||
if refused is not None:
|
||||
return _reply(request, refused)
|
||||
return _reply(request, _params(doc, _body(request)))
|
||||
|
||||
|
||||
def _params(doc, data):
|
||||
try:
|
||||
if doc is None:
|
||||
return {'ok': False, 'error': 'no active document'}
|
||||
raw_id = data.get('id')
|
||||
if raw_id is None:
|
||||
return {'ok': False, 'error': 'missing "id" in request body'}
|
||||
|
||||
from Autodesk.Revit.DB import ElementId, StorageType
|
||||
try:
|
||||
eid = ElementId(int(raw_id))
|
||||
except Exception:
|
||||
eid = ElementId(long(raw_id)) # noqa: F821
|
||||
el = doc.GetElement(eid)
|
||||
if el is None:
|
||||
return {'ok': False, 'error': 'no element with id ' + str(raw_id)}
|
||||
|
||||
out = []
|
||||
for p in el.Parameters:
|
||||
try:
|
||||
st = p.StorageType
|
||||
if st == StorageType.String:
|
||||
val = p.AsString()
|
||||
elif st == StorageType.Integer:
|
||||
val = p.AsInteger()
|
||||
elif st == StorageType.Double:
|
||||
val = p.AsDouble()
|
||||
elif st == StorageType.ElementId:
|
||||
val = p.AsElementId().IntegerValue
|
||||
else:
|
||||
val = None
|
||||
out.append({
|
||||
'name': p.Definition.Name,
|
||||
'storage': str(st),
|
||||
'value': val,
|
||||
'display': p.AsValueString(),
|
||||
'readonly': p.IsReadOnly,
|
||||
'shared': p.IsShared,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
out.sort(key=lambda d: d['name'])
|
||||
return {'ok': True, 'element': _elem_brief(el, doc), 'parameters': out}
|
||||
except Exception as ex:
|
||||
return _error(ex)
|
||||
|
||||
|
||||
# NOTE: there is deliberately no /reload endpoint.
|
||||
#
|
||||
# Reloading pyRevit tears down and rebuilds the whole session - including the
|
||||
# routes server that would be serving the reload request. Doing that from
|
||||
# inside a handler leaves the server bound to the port but wired to a dead
|
||||
# engine; the next request throws on a non-main thread and takes Revit down
|
||||
# with it. This crashed Revit on 2026-08-14.
|
||||
#
|
||||
# To pick up NEW buttons/panels/tabs, RESTART REVIT. Not "pyRevit > Reload"
|
||||
# from the ribbon either -- this comment used to say that was safe and it is
|
||||
# not: the reload is what leaves the handler pointing at a dead engine, and it
|
||||
# does not matter whether the request to reload came over the bridge or from a
|
||||
# click. `rvt.py` refuses to talk to a process that has loaded pyRevit twice
|
||||
# for exactly this reason. See CLAUDE.md, "Never reload pyRevit".
|
||||
#
|
||||
# Editing the body of an existing script.py needs no reload at all -- pyRevit
|
||||
# re-reads it on every click.
|
||||
Reference in New Issue
Block a user