# -*- coding: utf-8 -*- """No button may carry hover text. This is the test that keeps it that way. The owner, twice: hovering a button puts a wall of words on screen, and he does not want them. The words come from two places and pyRevit merges them, so trimming one and leaving the other looks fixed and is not: * `bundle.yaml`'s `tooltip:` -- read by `pyrevit.extensions.genericcomps.GenericUIComponent._read_bundle_metadata`; * the script's module docstring -- read by `GenericUICommand._read_bundle_metadata_from_python_script`, but ONLY `if not self.meta`, i.e. only when the button has no `bundle.yaml` at all. That `if not self.meta` is why the docstring half has to be guarded even though every button here does have a `bundle.yaml`: the day someone trims a `bundle.yaml` down to nothing, or a new button ships without one, every docstring in this extension becomes hover text again. Guard both, and the regression cannot come back through either door. `bundle.yaml`'s `author:` is guarded for the same reason. `pyrevit.loader.uimaker._make_button_tooltip` appends an `Author(s):` block whenever the button declares one, so an author line is three more lines of hover text. WHAT IS STILL LEFT ON SCREEN, AND WHY THIS TEST CANNOT REMOVE IT ----------------------------------------------------------------- `_make_button_tooltip` unconditionally appends Bundle Name: (pushbutton) That text is pyRevit's, not ours -- there is no bundle setting that suppresses it. With `tooltip:` and `author:` gone, those two lines under the button's own title are the whole of the hover text, which is the floor this repository can reach without patching the pyRevit install. Verified against the pyrevitlib shipped with pyRevit 5.0.1 at `%APPDATA%\\pyRevit-Master\\pyrevitlib\\pyrevit\\`. """ import ast import io import os import pytest from vibe import yamlkit _EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # The ribbon, and the whole of what this suite checks. # # There was a second root here until 2026-08-30: `parked/`, a folder outside # every `*.tab` holding buttons taken off the ribbon but kept whole. The # 2026-08-30 restructure emptied it -- Job Recon and Takeoff came back onto # the ribbon inside `Setup.panel/Config.pulldown` -- and the folder was # deleted with them. # # The constant went with it rather than staying as a tolerated-empty path. # `os.walk` over a directory that does not exist raises nothing and yields # nothing, so a `PARKED` left pointing at thin air is a guard that passes by # checking zero buttons, which is precisely the failure # `test_the_walk_found_the_buttons` exists to make impossible. If parking # comes back, bring the constant back WITH a count of its own. TAB = os.path.join(_EXT, 'WillTools.tab') # Anything longer than this in a lone string statement at the top of a script # is prose someone meant as documentation. Note the guard rejects an empty # docstring too -- see `_docstring_of`. _ADVICE = ( "Documentation belongs in `#` comments under the coding cookie, where " "pyRevit cannot render it. Move the prose down and delete the string.") def _buttons(where): """Every `*.pushbutton` folder under `where`, as (name, path) pairs. The walk is recursive on purpose: buttons live at two depths, some directly on a panel and some inside a `*.pulldown` folder under one. (Before 2026-08-30 the nested ones were inside `*.stack` folders instead; the walk never cared which, and that is the point of walking.) """ found = [] for root, dirs, _files in os.walk(where): for name in dirs: if name.endswith('.pushbutton'): found.append((name, os.path.join(root, name))) found.sort() return found BUTTONS = _buttons(TAB) # The ones that are NOT a direct child of a `*.panel` -- today, the five rows # behind Config and the two behind DesignHub. Counted separately below. NESTED = [b for b in BUTTONS if not os.path.basename(os.path.dirname(b[1])).endswith('.panel')] def _ids(pair): return pair[0] def test_the_walk_found_the_buttons(): """If the layout moves, this suite must fail loudly rather than pass by finding nothing to check. Every test below is parametrized over `BUTTONS`. An empty `BUTTONS` is not a failure in pytest -- it is a clean run of zero tests, which looks exactly like a pass. This is the test that stops that. Two counts, because one number cannot say both things. The first is the whole ribbon: twelve buttons as of 2026-08-30, four large ones on Tools, five behind Config, two behind DesignHub and GOD_MOD alone on Lab. Rename or move `WillTools.tab` and this goes to zero. The second is the nested ones: seven of the twelve are a row inside a `*.pulldown` rather than a direct child of a `*.panel`. That count exists because the first cannot see the failure that actually happened before -- a walk that stops recursing, or a container folder renamed to something the walk no longer descends into, still finds the five top-level buttons and looks healthy. Watch both and neither failure is quiet. """ assert len(BUTTONS) >= 12, ( 'expected at least 12 pushbuttons under {0}, found {1}. If the ribbon ' 'really shrank, lower this number; if the tab folder moved, fix TAB ' '-- do not let a tooltip guard pass because it checked nothing.' .format(TAB, len(BUTTONS))) assert len(NESTED) >= 7, ( 'expected at least 7 of the {0} pushbuttons under {1} to sit inside a ' 'container (a `*.pulldown` today) rather than directly on a `*.panel`, ' 'found {2}. If the ribbon really was flattened, lower this number; if ' 'the walk stopped descending, fix `_buttons` -- a guard that only ever ' 'reaches the top level checks the buttons nobody was worried about.' .format(len(BUTTONS), TAB, len(NESTED))) @pytest.mark.parametrize('button', BUTTONS, ids=_ids) def test_bundle_declares_no_tooltip(button): name, path = button meta_file = os.path.join(path, 'bundle.yaml') if not os.path.isfile(meta_file): return # no bundle.yaml is no tooltip meta = yamlkit.parse(io.open(meta_file, encoding='utf-8').read()) or {} tooltip = meta.get('tooltip') assert not tooltip, ( '{0} has hover text again: bundle.yaml declares\n' ' tooltip: {1!r}\n' 'Every button on this ribbon is meant to hover with nothing on it ' '(owner, 2026-08-30). Delete the `tooltip:` key from\n' ' {2}\n' '{3}'.format(name, tooltip, meta_file, _ADVICE)) @pytest.mark.parametrize('button', BUTTONS, ids=_ids) def test_bundle_declares_no_author(button): name, path = button meta_file = os.path.join(path, 'bundle.yaml') if not os.path.isfile(meta_file): return meta = yamlkit.parse(io.open(meta_file, encoding='utf-8').read()) or {} author = meta.get('author') or meta.get('authors') assert not author, ( '{0} has hover text again: bundle.yaml declares an author ({1!r}), ' 'and pyRevit prints an "Author(s):" block on hover for every button ' 'that has one. Delete the `author:` key from\n' ' {2}'.format(name, author, meta_file)) def _docstring_of(source): """What pyRevit's own reader would get. `coreutils.ScriptFileParser.get_docstring` is `ast.get_docstring` plus a truthiness check, so an empty or whitespace-only docstring reads as no docstring at all -- pyRevit renders nothing for it, no placeholder. This guard is stricter on purpose and rejects the empty string too: a `""" """` left at the top of a file is an invitation for the next agent to write a paragraph inside it, and that paragraph goes straight to the ribbon. """ return ast.get_docstring(ast.parse(source)) @pytest.mark.parametrize('button', BUTTONS, ids=_ids) def test_script_declares_no_author(button): """`__author__` (or `__authors__`) in `script.py` is the same trap as the module docstring, one door further in. `GenericUICommand._read_bundle_metadata_from_python_script` only reads a script's dunder metadata `if not self.meta` -- exactly like the docstring case above -- so on every button here, which all have a `bundle.yaml`, an `__author__` line sits inert today. It stops being inert the day a `bundle.yaml` is trimmed to nothing or a new button ships without one: then pyRevit reads `__author__` (or `__authors__`) straight off the script and prints the same "Author(s):" block that `test_bundle_declares_no_author` guards against on the bundle side. pyRevit 5.0.1 additionally has a bug at `genericcomps.py:733` where `__author__` is read and then immediately overwritten by `__authors__`, so on that version specifically the line is discarded either way. That is a bug, not a guarantee, and a later pyRevit fixing it is exactly the kind of change that would make an unremoved `__author__` line start talking again with nobody touching this file. Guard the source, not the version-specific bug. """ name, path = button script = os.path.join(path, 'script.py') assert os.path.isfile(script), 'no script.py under {0}'.format(path) source = io.open(script, encoding='utf-8').read() tree = ast.parse(source) for node in tree.body: if not isinstance(node, ast.Assign): continue for target in node.targets: if getattr(target, 'id', None) in ('__author__', '__authors__'): assert False, ( '{0} declares {1} in script.py again:\n' ' {2}:{3}\n' 'It reads as dead code today only because every button ' 'has a bundle.yaml -- pyRevit reads a script dunder for ' 'metadata only `if not self.meta`. Trim a bundle.yaml to ' 'nothing, or ship a button without one, and this becomes ' 'an "Author(s):" block on hover. Delete the {1} line ' 'from\n' ' {2}'.format(name, target.id, script, node.lineno)) @pytest.mark.parametrize('button', BUTTONS, ids=_ids) def test_script_has_no_module_docstring(button): name, path = button # `script.py` is what pyRevit parses for metadata (`self.script_file`); # side modules in the same folder are never read for a docstring. script = os.path.join(path, 'script.py') assert os.path.isfile(script), 'no script.py under {0}'.format(path) source = io.open(script, encoding='utf-8').read() body = ast.parse(source).body opener = body[0] if body else None value = getattr(opener, 'value', None) # `ast.Constant`, not the `ast.Str` alias -- the alias warns on 3.12 and # is gone on 3.14, and this suite runs on host CPython. is_string_stmt = (isinstance(opener, ast.Expr) and isinstance(value, ast.Constant) and isinstance(value.value, str)) assert not is_string_stmt, ( '{0} has a module docstring again:\n' ' {1}:{2}\n' ' {3!r}\n' "pyRevit renders the module docstring as the button's hover text " 'whenever the bundle has no `tooltip:` -- and for a button with no ' '`bundle.yaml` at all, it is the only thing it renders. The owner ' 'wants nothing on hover.\n' '{4}'.format(name, script, opener.lineno, _docstring_of(source), _ADVICE))