WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""Regression guard for the `rvt.py params <id>` AttributeError.
An agent hit this on 2026-08-31: `/params` read `request.query_params`, and
pyRevit's routes `Request` (`pyrevit/routes/server/base.py`, read from
`%APPDATA%\\pyRevit-Master\\pyrevitlib\\pyrevit\\` on this machine, pyRevit
5.0.1) has no such attribute -- it exposes `path`, `method`, `data`,
`headers`, `params` (route-*pattern* captures like `<int:id>`, not the query
string) and `callback_url`. Worse, there is no query-string channel to read
under ANY name on this version: `routes/server/server.py`'s
`HttpRequestHandler._parse_api_path` builds the routed path from
`urlparse(self.path).path` and drops `.query` entirely before a `Request` is
ever constructed, so query-string arguments never survive to reach handler
code at all here.
The fix moves `/collect` and `/params` onto the same channel `/exec` already
used successfully: a JSON POST body, read through `startup.py`'s `_body()`.
`startup.py` cannot be imported here -- it does `from pyrevit import routes`
at module scope, a hard host dependency, same category as `vibe.ticketqc`
(see `tests/conftest.py`'s docstring) -- so this suite checks the source
text/AST instead, the same approach `test_button_tooltips.py` uses for
bundle/script content that also cannot be imported on host CPython.
This suite cannot open Revit, so it cannot prove the endpoints work live.
See CLAUDE.md Sec 6 for the one bridge call that does, run by a human after a
Revit restart (never a pyRevit reload).
"""
import ast
import os
import pytest
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STARTUP = os.path.join(_EXT, 'startup.py')
RVT_PY = os.path.join(os.path.dirname(_EXT), 'tools', 'rvt.py')
def _read(path):
with open(path, 'r', encoding='utf-8') as fh:
return fh.read()
def _route_decorators(tree):
"""Every `@api.route(...)` call in the module, as (path, methods, func_name).
`methods` is the list of method strings if given as a literal list of
string constants, else None (not expected here, but a test that could
not see a change is worse than one that fails loudly).
"""
found = []
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
for dec in node.decorator_list:
if not isinstance(dec, ast.Call):
continue
func = dec.func
if not (isinstance(func, ast.Attribute) and func.attr == 'route'):
continue
path = None
if dec.args and isinstance(dec.args[0], ast.Constant):
path = dec.args[0].value
methods = None
for kw in dec.keywords:
if kw.arg == 'methods' and isinstance(kw.value, ast.List):
vals = [e.value for e in kw.value.elts
if isinstance(e, ast.Constant)]
methods = vals
found.append((path, methods, node.name))
return found
def test_startup_never_reads_query_params():
"""The exact AttributeError this suite exists for must never come back.
`request.query_params` does not exist on pyRevit 5.0.1's `Request`, and
swapping in some other version's attribute name (`request.query`,
`request.args`, ...) would just trade this machine's break for another
machine's -- CLAUDE.md's rule on guarding API names that moved between
versions applies here too. The fix removes the query-string channel
entirely rather than guessing a name for it, so no CODE in this file may
access it again.
AST-based, not a text search: the word `query_params` legitimately
appears in this file's own comments and docstrings (explaining exactly
this history), and a text search would flag its own explanation.
"""
tree = ast.parse(_read(STARTUP))
hits = [n for n in ast.walk(tree)
if isinstance(n, ast.Attribute) and n.attr == 'query_params']
assert not hits, (
'startup.py reads .query_params again at line(s) {0} -- that '
'attribute does not exist on pyRevit 5.0.1\'s routes Request and '
'raises exactly the AttributeError this test guards against. Route '
'arguments belong in a JSON body (see _body()), not a query string.'
.format([getattr(n, 'lineno', '?') for n in hits])
)
def test_body_helper_exists_and_is_shared():
"""`_body()` is the one channel every argument-taking route should use."""
tree = ast.parse(_read(STARTUP))
names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
assert '_body' in names, (
'_body() is gone from startup.py -- /exec, /collect and /params were '
'all rewritten to share it so a query-string channel never comes '
'back through only one of them.'
)
@pytest.mark.parametrize('route_path,expect_post', [
('/collect', True),
('/params', True),
('/exec', True),
('/status', False),
('/selection', False),
])
def test_argument_taking_routes_are_post(route_path, expect_post):
"""`/collect` and `/params` now take POST + JSON body, like `/exec`.
`/status` and `/selection` take no arguments and are deliberately left
as GET -- this parametrization pins both sides so a future change to
either group is a visible decision, not a silent drift.
"""
tree = ast.parse(_read(STARTUP))
routes = {path: methods for path, methods, _name in _route_decorators(tree)}
assert route_path in routes, (
'{0} route not found in startup.py -- did it get renamed or '
'removed?'.format(route_path)
)
methods = routes[route_path]
assert methods is not None, (
'{0}\'s @api.route(...) does not declare methods as a literal list; '
'this test cannot see what it allows.'.format(route_path)
)
if expect_post:
assert methods == ['POST'], (
'{0} is registered with methods={1!r}, expected [\'POST\'] -- '
'arguments travel as a JSON body now, not a query string.'
.format(route_path, methods)
)
else:
assert 'POST' not in methods, (
'{0} takes no arguments and was not expected to move to POST; '
'got methods={1!r}.'.format(route_path, methods)
)
def test_collect_and_params_read_body_not_query_string():
"""Both handlers call `_body(request)`, not `request.query_params`."""
tree = ast.parse(_read(STARTUP))
funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
for fn_name in ('vibe_collect', 'vibe_params'):
assert fn_name in funcs, '{0} missing from startup.py'.format(fn_name)
body_src = ast.dump(funcs[fn_name])
assert '_body' in body_src, (
'{0} no longer calls _body(request) -- check it still reads '
'arguments from the JSON body.'.format(fn_name)
)
def test_rvt_py_client_sends_json_body_not_query_string():
"""`rvt.py`'s client-side half of the same fix.
`call()` used to build a `?key=value` query string for /collect and
/params; that string was silently discarded server-side on pyRevit
5.0.1 (see this file's module docstring), so it must be gone from both
the helper and the two callers.
"""
source = _read(RVT_PY)
assert 'query=' not in source, (
'tools/rvt.py still builds a query= argument somewhere -- the '
'bridge server on pyRevit 5.0.1 never reads it back out, so this is '
'a silent no-op waiting to happen again.'
)
assert 'urlencode' not in source, (
'tools/rvt.py still references urlencode -- the query-string '
'channel it built was removed along with the last caller.'
)
tree = ast.parse(source)
funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
for fn_name in ('cmd_collect', 'cmd_params'):
assert fn_name in funcs, '{0} missing from tools/rvt.py'.format(fn_name)
src = ast.dump(funcs[fn_name])
assert "value='POST'" in src, (
'{0} no longer calls call(..., "POST", ...) -- /collect and '
'/params both require POST now that arguments travel in the '
'body.'.format(fn_name)
)