WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
+405
@@ -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
|
||||
Reference in New Issue
Block a user