WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
# -*- 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:
|
||||
<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))
|
||||
Reference in New Issue
Block a user