# -*- coding: utf-8 -*- """Regression guard for the `rvt.py params ` 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 ``, 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) )