# -*- 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 from pyrevit import revit, script, HOST_APP 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: report(area, item, missing, '{0}: {1}'.format(type(ex).__name__, ex)) # ------------------------------------------------------------------- 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 - out-of-process tools 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) return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \ 'ticketchecks' 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') # ------------------------------------------------------------------- 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), designspec.plant_knowledge_path(), '; {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 _designs(): from vibe import ticketqc from vibe import knowledge grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc)) if not grouped: return 'FAIL', ('no assembly carries a design number - either the ' 'panel.design_number role is mapped to the wrong ' 'parameter, or this model does not group pieces so') 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', 'design number groups assemblies', _designs) probe('conventions', 'design summary views', _summaries) # ------------------------------------------------------------------ verdict 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')