Files
willtools-demo/WillTools.extension/startup.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

819 lines
31 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."""
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.