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,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)
|
||||
)
|
||||
Reference in New Issue
Block a user