# -*- 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