8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
846 lines
33 KiB
Python
846 lines
33 KiB
Python
# -*- 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, and
|
|
whether this load followed a CLEAN (stop-first) Hot Reload.
|
|
|
|
Format is ``when|pid|proc_start`` with an optional 4th field ``hotreload``.
|
|
A load carrying that flag came up after Setup > Config > Hot Reload stopped
|
|
the routes server BEFORE reload_pyrevit(), so the fresh session re-registered
|
|
its server against the fresh engine and is safe to keep talking to. A load
|
|
WITHOUT the flag that is nonetheless a second load under one process is a
|
|
raw 'pyRevit > Reload' -- the landmine that leaves the old handler on a dead
|
|
engine. tools/rvt.py tells the two apart by this flag."""
|
|
try:
|
|
import datetime
|
|
import time
|
|
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()
|
|
pid = proc.Id
|
|
|
|
# The Hot Reload button drops a marker keyed by THIS pid right before
|
|
# it reloads. A reload is in-process, so the same pid loads again and
|
|
# sees it here. Honour it only if it is fresh: a marker left by a
|
|
# reload that never happened must not vouch for a later raw reload.
|
|
flag = ''
|
|
marker = os.path.join(root, '.bridge-hotreload-{0}.flag'.format(pid))
|
|
try:
|
|
if os.path.isfile(marker):
|
|
if (time.time() - os.path.getmtime(marker)) < 300:
|
|
flag = '|hotreload'
|
|
os.remove(marker)
|
|
except Exception:
|
|
pass
|
|
|
|
line = '{0}|{1}|{2}{3}\n'.format(
|
|
datetime.datetime.now().isoformat(),
|
|
pid,
|
|
proc.StartTime.ToString('o'),
|
|
flag,
|
|
)
|
|
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.
|