WillTools 2026-09-09 -- clean snapshot of the development repo for review
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:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,8 @@
title: Self Check
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,405 @@
# -*- coding: utf-8 -*-
#
# Report whether this machine, this Revit and this model can run the QC tools.
#
# Written for the moment the extension lands on a computer it was not developed
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
# so a failure names itself instead of arriving as a traceback from four frames
# deep in a checking routine.
#
# Reads nothing but metadata and does not touch the model.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Self Check'
import os
import sys
import traceback
from pyrevit import revit, script, HOST_APP
from vibe import runlog
output = script.get_output()
output.close_others()
doc = revit.doc
rows = []
def report(area, item, state, detail):
rows.append([area, item, state, detail])
def probe(area, item, fn, missing='FAIL'):
"""Run one probe. An exception is a finding, never a crash."""
try:
state, detail = fn()
report(area, item, state or missing, detail)
except Exception as ex:
# Name the frame that blew, not just the exception: a bare
# "SystemError: Object reference not set to an instance of an object"
# (IronPython's face for a .NET NullReferenceException) says nothing
# about WHERE, and 2026-09-05 one of these cost a round trip to the
# owner's machine to find out. Innermost frame of our own code first.
where = ''
try:
import traceback
frames = traceback.extract_tb(sys.exc_info()[2])
ours = [f for f in frames if 'WillTools' in (f[0] or '')] or frames
if ours:
fn, ln, func, _ = ours[-1]
where = ' @ {0}:{1} in {2}()'.format(fn.replace('\\', '/').split('/')[-1], ln, func)
except Exception:
pass
report(area, item, missing, '{0}: {1}{2}'.format(type(ex).__name__, ex, where))
# ------------------------------------------------------------------- host
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
def _revit():
app = HOST_APP.app
version = app.VersionNumber
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
if version == DEVELOPED_ON['revit']:
return 'OK', detail
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
DEVELOPED_ON['revit'])
def _pyrevit():
from pyrevit import versionmgr
version = versionmgr.get_pyrevit_version().get_formatted()
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
return state, version
def _engine():
detail = sys.version.split('\n')[0]
major = sys.version_info[0]
if major == 2:
return 'OK', detail
# The code is written for both, but only Python 2 has been exercised.
return 'WARN', detail + ' (only Python 2 has been run in anger)'
def _routes():
from pyrevit.userconfig import user_config
if not user_config.routes_server:
return 'WARN', ('routes server off - the Claude Code bridge will not '
'connect. The buttons themselves do not need it.')
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
user_config.routes_port)
probe('host', 'Revit', _revit)
probe('host', 'pyRevit', _pyrevit)
probe('host', 'python engine', _engine)
probe('host', 'routes bridge', _routes, missing='WARN')
# ---------------------------------------------------------------- library
def _import_all():
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
ticketqc, ticketchecks, aiextract)
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
'ticketchecks, aiextract'
def _units():
from vibe import ft, mm
round_trip = mm(ft(1000.0))
if abs(round_trip - 1000.0) > 1e-6:
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
return 'OK', '1000 mm round-trips exactly'
def _checklist():
from vibe import ticketpage
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
return 'OK', '{0} row(s) from {1}'.format(
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
probe('library', 'modules import', _import_all)
probe('library', 'unit conversion', _units)
probe('library', 'published checklist', _checklist, missing='WARN')
# ----------------------------------------------------------------- tooling
def _claude():
from vibe import aiextract
found = aiextract.find_claude()
if not found:
return 'WARN', ('not found - Extract Design cannot run. Install the '
'CLI or set CLAUDE_CODE_EXE. Ticket QC is unaffected.')
return 'OK', found
probe('tooling', 'Claude Code CLI', _claude, missing='WARN')
# ------------------------------------------------------------------- model
def _saved():
if not doc.PathName:
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
return 'OK', doc.PathName
def _data_dir():
from vibe import designspec
return 'OK', designspec.root_dir(doc)
def _knowledge():
"""Every knowledge layer, in the order load() merges them.
Which layers exist decides what the tool actually believes, so the answer
is the list, not a yes/no: naming conventions that should be plant-wide
sitting only in a project file is a real and invisible mistake.
"""
from vibe import knowledge
layers = knowledge.sources(doc)
detail = ' | '.join(
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
for label, path, ok in layers])
# A project config that has gone missing is worse than one never set: the
# run looks entirely normal and uses a configuration nobody chose.
gone = [path for label, path, ok in layers
if label == 'project config' and not ok]
if gone:
return 'FAIL', ('the project config this model runs on is not there: '
'{0} || {1}'.format(gone[0], detail))
if not any(ok for _label, _p, ok in layers):
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
return 'OK', detail
def _knowledge_parses():
"""Read every layer file that IS there, exactly the way load() reads it.
The layer list above says which files exist. It does not say whether any
of them can be READ, and those are different questions with the same
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
mapping raises `Duplicate key` and it returns nothing -- and a layer that
fails to load looks exactly like a layer that agrees with the defaults.
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
layer, every parameter role came back None, the sweep enumerated 0
assemblies, and every report rendered normally.
So this re-reads each file through the same reader and prints ok or the
parser's own message. `knowledge.config_problems()` reports the same
failure from the merge itself; this is the early warning, on the layer
list, before a button is pressed. `python tools/knowledgelint.py` is the
same check on the host, where the edit is made.
"""
from pyrevit.coreutils import yaml as pyyaml
from vibe import knowledge
notes = []
bad = []
for label, path, ok in knowledge.sources(doc):
if not ok:
notes.append('{0}: absent'.format(label))
continue
try:
pyyaml.load_as_dict(path)
notes.append('{0}: ok'.format(label))
except Exception as ex:
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
label, path, type(ex).__name__, ex))
if bad:
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
'was used, and the run fell back to the layers under '
'it. Fix the file, then run '
'tools/knowledgelint.py to confirm.')
if not notes:
return 'WARN', 'no knowledge layer to read yet'
return 'OK', ' | '.join(notes)
def _specs():
from vibe import designspec
specs = designspec.list_specs(doc)
if not specs:
return 'WARN', 'none yet - run Extract Design'
unreviewed = [d for d, s in specs.items()
if not (s.get('source') or {}).get('reviewed_by')]
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
if unreviewed:
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
return 'OK', detail
def _scope():
"""What the job declares this engineer is answerable for.
Never a FAIL. An undeclared scope is a legitimate state -- it means every
button judges every piece -- and it is reported as such rather than as a
misconfiguration, because 'judge everything' is the honest default and
the only wrong answer here would be a scope the tool chose for itself.
"""
from vibe import knowledge
from vibe import scope as scopelib
the_scope = scopelib.of(knowledge.load(doc))
if not the_scope.is_declared:
return 'WARN', ('no scope declared -- every button judges every '
'piece on the job, including work whose plant '
'knowledge has never been validated. Set '
'scope.mark_prefixes in the job knowledge file to '
'be told what is being taken on trust.')
detail = the_scope.label()
if the_scope.declared_by:
detail += ' (declared by {0})'.format(the_scope.declared_by)
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
probe('model', 'saved', _saved)
probe('model', 'declared scope', _scope, missing='WARN')
probe('model', 'data folder', _data_dir)
probe('model', 'knowledge layers', _knowledge, missing='WARN')
probe('model', 'knowledge layers parse', _knowledge_parses)
probe('model', 'design specs', _specs, missing='WARN')
# --------------------------------------------------- project conventions
# These are the assumptions most likely to be wrong on a different job: they
# are naming and parameter conventions, not Revit API.
def _mapping():
"""Which parameter roles this office has told us the names for.
Nothing in the code knows what a panel's length parameter is called, so
an unmapped role is not a detail -- it is a check that will not run. It
reports before the convention probes below, because they depend on it.
"""
from vibe import knowledge, designspec
know = knowledge.load(doc)
missing, not_applicable = knowledge.unmapped_roles(know)
total = len(knowledge.PARAMETER_ROLES)
if missing:
# The template is the plant's, so that is where the names belong; a
# project file may still override one.
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
len(missing), total, ', '.join(missing),
knowledge.plant_file_in_use(know),
'; {0} declared not applicable: {1}'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
if not_applicable else '')
mapped = ['{0}={1}'.format(r.split('.')[-1],
knowledge.param_names(know, r)[0])
for r in knowledge.PARAMETER_ROLES
if knowledge.param_names(know, r)]
extra = ''
if not_applicable:
extra = '; {0} declared not applicable at this plant ({1})'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
def _plant_gaps():
"""Plant-owned data the buttons need, checked at setup time.
The per-button refusals gate on `handling.lifter_groups`,
`handling.stages` and friends, so a gap is normally found by the user
as a per-piece NOT RUN. This probe runs the same gate off the merged
tree and says which plant file to edit, so the developer finds it here
instead -- see knowledge.plant_layer_gaps for the wording.
"""
from vibe import knowledge
know = knowledge.load(doc)
gaps = knowledge.plant_layer_gaps(know)
if not gaps:
return 'OK', 'plant layer names everything the buttons need: {0}'.format(
knowledge.plant_file_in_use(know))
texts = [g['text'] for g in gaps]
state = 'FAIL' if any(g['severity'] == 'FAIL' for g in gaps) else 'WARN'
return state, '{0} gap(s): {1}'.format(len(gaps), ' || '.join(texts))
def _designs():
from vibe import ticketqc
from vibe import knowledge
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
if not grouped:
# Two states that used to share one FAIL (issue #20). A model with
# NO assemblies at all is a job that has not reached the reinforcing
# stage -- owner, 2026-09-05: "还没进入reinforce阶段,所以就是没有
# assembly。这个情况下这玩意应该是warn不是fail" -- so that is a WARN.
# Assemblies present but none carrying a design number is the
# convention failure this probe exists for.
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
n_asm = FilteredElementCollector(doc).OfClass(AssemblyInstance).GetElementCount()
if n_asm == 0:
return 'WARN', ('no assemblies in this model yet - the job has not '
'reached the reinforcing stage; the design-number '
'grouping is checked once assemblies exist')
return 'FAIL', ('{0} assemblies, none carries a design number - either '
'the panel.design_number role is mapped to the wrong '
'parameter, or this model does not group pieces so'
.format(n_asm))
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
return 'OK', '{0} designs, {1} pieces: {2}'.format(
len(grouped), sum([len(v) for v in grouped.values()]),
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
def _summaries():
from vibe import ticketqc
summaries, sheets = ticketqc.collect_context(doc)
if not summaries:
return 'FAIL', ('no drafting view is named after a design number - '
'Extract Design has nothing to read')
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
len(sheets))
probe('conventions', 'parameter role mapping', _mapping)
probe('conventions', 'plant layer completeness', _plant_gaps)
probe('conventions', 'design number groups assemblies', _designs)
probe('conventions', 'design summary views', _summaries)
# ------------------------------------------------------------------ verdict
#
# The whole report body is wrapped so an unexpected exception still leaves a
# run-log record (outcome='error') before pyRevit shows its own error dialog.
try:
output.print_md('# Self check')
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
fails = len([r for r in rows if r[2] == 'FAIL'])
warns = len([r for r in rows if r[2] == 'WARN'])
if fails:
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
output.print_md('A **FAIL** means a QC button will not work here. Start '
'with the first one - they cascade.')
elif warns:
output.print_md('## Ready, with {0} warning(s)'.format(warns))
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
else:
output.print_md('## Everything checks out')
outcome = 'ok' if not fails else 'fail'
runlog.record(
'Self Check', doc_title=doc.Title,
outcome=outcome,
report_text='\n'.join(
['{0} | {1} | {2} | {3}'.format(*row) for row in rows]))
except Exception as exc:
runlog.record('Self Check',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(exc),
traceback_text=traceback.format_exc())
raise