#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Is a frozen job deck still what CURRENT knowledge would freeze today? python tools/deckcheck.py JOBS_DIR python tools/deckcheck.py JOBS_DIR --json OUT.json python tools/deckcheck.py JOBS_DIR --job-root DIR # skip the auto-guess JOBS_DIR is a sweep's `jobs/` folder -- `*.job.json` files, each one panel's complete frozen input deck (`docs/lift-check.md`, `docs/plan-panellift- three-stage.md` SS5). Job files are input decks on purpose: a deck must reproduce its own result forever, so `liftexport.py` freezes every knowledge- derived value into it at export and `panellift` never re-reads the catalogue. That is correct, and it is not what this tool checks. WHAT WENT WRONG, TWICE, ON 2026-09-01 -------------------------------------- 1. A capacity in `knowledge/catalogue.yaml` was corrected after 282 decks had already been exported carrying the old number. Five real panels showed FAIL at 113% off the stale value before anyone could say why, and explaining it took a git-history dig across two repositories. 2. A frozen `panel.design_number` was read as CURRENT by an agent answering the owner, and the model had moved since the export. Freezing was never the defect. The defect is that staleness was UNDETECTABLE -- nothing recorded which knowledge version froze a value, and nothing could tell a live deck from a fossil. This tool detects it, for the knowledge half only (point 4 below); a full-fidelity version, with a sha per layer folded into every future deck, is designed in `docs/plan-panellift-three-stage.md` SS5 and is not built here. WHAT IT DOES ------------ For every `*.job.json` in JOBS_DIR, it re-runs the SAME knowledge lookups `liftexport.py` ran at export time -- `knowledge.lifter_capacity()`, `lifter_basis_fc()`, `lifter_edge_distance()`, `lifter_safety_factor()`, and the stage-level `handling.tension_limit_sqrt_fc` -- against knowledge re-resolved from the files ON DISK RIGHT NOW, and compares the fresh answer to the number the deck already carries. A field is judged only when the deck actually carries it (an older deck missing a field is silently not compared on that field, never treated as a match). Four outcomes, never collapsed into each other: current the deck's frozen value is what current knowledge gives too stale they disagree -- named: mark, field, frozen value, current value, and (when `lifter_capacity`'s own `why` names one) which layer moved conditional `lifter_capacity`'s with-tension-bar branch depends on `assembly_families` -- every OTHER member's family name on the same assembly, which `liftexport.py` reads off the live model and NEVER WRITES INTO THE DECK (see its own comment on this: the basis/why strings ride along specifically so this information is not lost, and the gate's corroborating evidence is lost anyway). So when the family is gated and the deck cannot settle which side of the gate applies, this reports BOTH values the current catalogue could give and does not guess which one this piece would get. This is not a shortfall in this tool; it is a real gap in what the deck records, named rather than papered over. unjudgeable current knowledge cannot resolve the field at all (no catalogue alias, no matching stage spec, ...) -- named, with why. An unjudgeable deck is NOT a passing deck; the summary counts it apart from `current` for exactly that reason (see CLAUDE.md SS3, "could not judge" and "judged, found nothing" are different answers). WHAT IT DOES NOT DO -------------------- This checks a deck against KNOWLEDGE, not against the MODEL. A deck can pass every check here and still describe a panel whose geometry has since changed in Revit -- design numbers, lifter positions, strand layouts. Verifying that half needs the live model and is out of scope offline; incident 2 above is that half, and it is not what this tool answers. The caveat prints once, at the top of every run, so it cannot be scrolled past. WHY THIS DOES NOT CALL `knowledge.load(doc)` --------------------------------------------- `load()` takes a live Revit `doc` and its file layers merge through `knowledge._merge_file`, which reaches for `pyrevit.coreutils.yaml` -- a reader that exists only inside Revit. Off Revit that import fails on every layer, `_merge_file` records each one "unreadable" and returns the tree unmerged, and every lookup would silently run against bare `DEFAULTS`. This tool is the same substitution `tools/knowledge_drift.py` documents making, for the same reason: `build_knowledge()` below re-implements the identical five-layer order and the identical `job.plant` two-pass `load()`'s own docstring insists on, reading each YAML file with PyYAML instead and reusing `knowledge._coerce` / `knowledge._deep_update` / `knowledge.DEFAULTS` directly -- so a coercion rule fixed in `knowledge.py` (the leading-zero label rule, for one) is not a second bug waiting to happen here. CONSTRAINTS THIS RESPECTS -------------------------- Host CPython, no Revit, standalone. Imports `vibe.knowledge` and `vibe.plantpath` the way `tools/ticket_delta.py` / `tools/snapshot_defer.py` do -- see the stub registration below, and `WillTools.extension/tests/conftest.py`'s docstring for why a plain `import vibe.knowledge` cannot work off Revit. Prints no plant or client name of its own. Family names, marks and plant ids read out of a job file are DATA flowing through, exactly as `sweep.py` and `knowledge_drift.py` already print them at runtime; nothing about that is baked into this file's own strings. See `.scrub-terms` and CLAUDE.md SS "No client or job information in the repo" -- design numbers, marks and part codes are not client information. """ from __future__ import annotations import argparse import io import json import math import os import sys import time import types HERE = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.abspath(os.path.join(HERE, os.pardir)) LIB = os.path.join(REPO, 'WillTools.extension', 'lib') if LIB not in sys.path: sys.path.insert(0, LIB) # Same trick `tools/ticket_delta.py`, `tools/snapshot_defer.py` and # `WillTools.extension/tests/conftest.py` all use, and for the same reason: # `vibe/__init__.py` imports the Revit API at module scope, which is right # inside Revit and fatal on host CPython. Register a bare `vibe` package # pointed at the directory so the Revit-free submodules import normally and # `__init__.py` never runs. if 'vibe' not in sys.modules: _stub = types.ModuleType('vibe') _stub.__path__ = [os.path.join(LIB, 'vibe')] sys.modules['vibe'] = _stub from vibe import knowledge # noqa: E402 from vibe import plantpath # noqa: E402 try: import yaml except ImportError: # pragma: no cover sys.stderr.write('PyYAML is required: pip install pyyaml\n') raise SystemExit(2) CURRENT = 'current' STALE = 'stale' CONDITIONAL = 'conditional' UNJUDGEABLE = 'unjudgeable' # Worse first. A deck's overall status is the worst of its findings' -- the # same "not-judged-is-not-passed" rule as everywhere else in this codebase: # an unjudged deck is not a clean deck, and a conditional one is not either. _SEVERITY = {STALE: 3, CONDITIONAL: 2, UNJUDGEABLE: 1, CURRENT: 0} # ------------------------------------------------------------- knowledge class KnowledgeLayers(object): """What `build_knowledge` resolved, and what it found on disk. Kept apart from the merged dict itself so a report can name which layer's file is newest, and which layers were absent or unreadable, without re-deriving any path a second time. """ def __init__(self): self.defaults_path = None self.catalogue_path = None self.catalogue_status = None self.plant_id = None self.plant_path = None self.plant_status = None self.job_path = None self.job_status = None self.config_path = None self.config_status = None def rows(self): """(label, path, status) for every layer that names a path.""" return [ ('defaults', self.defaults_path, 'source'), ('catalogue', self.catalogue_path, self.catalogue_status), ('plant', self.plant_path, self.plant_status), ('job', self.job_path, self.job_status), ('config', self.config_path, self.config_status), ] def newest_mtime(self): best = None for _label, path, status in self.rows(): if not path or status in ('absent', 'missing', None): continue if not os.path.isfile(path): continue m = os.path.getmtime(path) if best is None or m > best: best = m return best def _read_yaml(path): with io.open(path, 'r', encoding='utf-8') as fh: return yaml.safe_load(fh) def _merge_layer(merged, path): """Merge one YAML layer file, PyYAML instead of pyRevit's reader. Mirrors `knowledge._merge_file`'s contract: a file that is not there is ordinary and silent ('absent'); a file that IS there and will not parse is recorded and skipped WHOLE, never partially merged ('unreadable') -- the same discipline `_merge_file`'s own docstring explains, because a layer that fails to load must never look like a layer that agrees with the defaults. """ if not path or not os.path.isfile(path): return 'absent' try: data = _read_yaml(path) except Exception: return 'unreadable' if isinstance(data, dict): knowledge._deep_update(merged, knowledge._coerce(data)) return 'merged' def build_knowledge(job_root): """Re-resolve knowledge the way `knowledge.load(doc)` does, no `doc`. Same five layers, same order, same `job.plant` two-pass `load()`'s own docstring insists cannot be collapsed into one pass (the plant file has to be read from the name the job file states, and the job file merges AFTER the plant layer -- so the name is read on its own, first, exactly as `knowledge.plant_id()` does for the live-doc path). `job_root` is the folder that holds the job's OWN `knowledge.yaml` -- for a sweep folder `/sweep/jobs/*.job.json`, that is `/knowledge.yaml`, the same relationship `designspec.root_dir()` / `sub_dir(doc, 'sweep')` establish for the live path. See `_guess_job_root` for how the CLI finds it when not told. The project config layer (last of the five) is included only in the thin sense the rest of this tool needs: unwrap `knowledge:` if present, else merge the file whole. The `decisions:` friendly-name mapping `knowledge._merge_decisions` does for a live project config is NOT replicated here -- no active-config.txt exists on the reference sweep this was built against, and a config layer written entirely under `decisions:` would be under-read by this tool. Named here rather than silently: if that ever matters, `_merge_config_file`'s mapping needs a host-side twin, not a guess bolted on here. """ layers = KnowledgeLayers() merged = knowledge._deep_copy(knowledge.DEFAULTS) layers.defaults_path = os.path.abspath(knowledge.__file__) layers.catalogue_path = knowledge.catalogue_path() layers.catalogue_status = _merge_layer(merged, layers.catalogue_path) job_path = os.path.join(job_root, 'knowledge.yaml') if job_root else None layers.job_path = job_path plant = None if job_path and os.path.isfile(job_path): try: raw = _read_yaml(job_path) or {} except Exception: raw = {} job_block = raw.get('job') if isinstance(raw, dict) else None if isinstance(job_block, dict) and job_block.get('plant') is not None: plant = str(job_block['plant']).strip() or None layers.plant_id = plant if plant is not None and not plantpath.valid_plant_id(plant): layers.plant_status = 'invalid_plant_id' else: plant_path = plantpath.plant_knowledge_path(plant) layers.plant_path = plant_path if plant is not None and not (plant_path and os.path.isfile(plant_path)): # Same discipline as `knowledge.load()`'s `_plant_missing`: # NEVER fall back to the machine-wide file when a plant was # named and its folder is not there. layers.plant_status = 'missing' else: layers.plant_status = _merge_layer(merged, plant_path) layers.job_status = _merge_layer(merged, job_path) if job_path else 'absent' if job_root: pointer = os.path.join(job_root, 'active-config.txt') if os.path.isfile(pointer): with io.open(pointer, 'r', encoding='utf-8') as fh: config_path = fh.read().strip() layers.config_path = config_path or None if config_path: if os.path.isfile(config_path): try: holder = _read_yaml(config_path) or {} except Exception: layers.config_status = 'unreadable' else: body = holder.get('knowledge') body = body if isinstance(body, dict) else holder knowledge._deep_update(merged, knowledge._coerce(body)) layers.config_status = 'merged' else: layers.config_status = 'missing' merged['_plant_id'] = plant return merged, layers def _guess_job_root(jobs_dir): """`/sweep/jobs` -> ``, verified against disk. `tools/sweep.py`'s `sweep_dir()` writes `jobs/` at `designspec.sub_dir(doc, 'sweep')/jobs`, i.e. two levels under `root_dir(doc)` -- the same folder `knowledge_path(doc)` puts `knowledge.yaml` in. So the parent of JOBS_DIR's parent is tried first; if it holds no `knowledge.yaml`, every ancestor up to five levels is tried in turn (a renamed sweep folder, or `--check`'s custom `--out`, can move the depth). None found returns the two-level guess anyway, so the caller always has a slightly wrong parent to complain about instead of no parent at all. """ jobs_dir = os.path.abspath(jobs_dir) two_up = os.path.dirname(os.path.dirname(jobs_dir)) candidate = two_up node = jobs_dir for _ in range(5): node = os.path.dirname(node) if not node or node == os.path.dirname(node): break if os.path.isfile(os.path.join(node, 'knowledge.yaml')): return node return two_up # ------------------------------------------------------------ comparisons def _num_eq(a, b): if a is None or b is None: return a is b try: return math.isclose(float(a), float(b), rel_tol=1e-9, abs_tol=1e-6) except (TypeError, ValueError): return a == b def _verdict(current_val, frozen_val): """current/stale/unjudgeable for one plain numeric field. Both None: current (frozen agreement on 'nothing known'). current is None, frozen is not: unjudgeable -- current knowledge cannot confirm OR deny the frozen value, which is not the same as it being wrong. frozen is None, current is not: stale -- something is knowable now that was not at export, which is exactly the kind of drift this tool exists to catch. Both known: compared with a float tolerance. """ if current_val is None and frozen_val is None: return CURRENT if current_val is None: return UNJUDGEABLE if frozen_val is None: return STALE return CURRENT if _num_eq(current_val, frozen_val) else STALE def check_capacity(know, family, kind, frozen_val, tension_bar_families): """(status, detail) for `capacity_lb` / `shear_capacity_lb`. The gate `knowledge.lifter_capacity` applies (tension only; see its own docstring) needs `assembly_families` -- every OTHER member's family name on the same assembly -- which the deck never carries (see this module's own docstring). Calling with `assembly_families=None` always resolves SOMETHING (the conservative without-bar figure, by design -- "an omitted list never earns the 2x rating by accident"), and that number can even happen to equal the frozen one. Trusting that match would be exactly the guess point 1 of the spec this was built against forbids: it is not evidence the piece lacks its tension bar, only that nothing here was told either way. So whenever the without-bar branch is the one that resolved, the with-bar branch is also computed (by forcing corroboration with the plant's own `tension_bar_families`) and, if it gives a different number, this reports CONDITIONAL naming both -- regardless of which one the frozen value happens to match. """ val, basis, why = knowledge.lifter_capacity( know, family, kind=kind, assembly_families=None) if kind == 'tension' and basis == 'without_bar': alt_val, alt_basis, alt_why = knowledge.lifter_capacity( know, family, kind=kind, assembly_families=tension_bar_families) if alt_basis == 'with_bar' and not _num_eq(alt_val, val): matches = [b for b, v in (('without_bar', val), ('with_bar', alt_val)) if _num_eq(v, frozen_val)] return CONDITIONAL, { 'frozen': frozen_val, 'without_bar': val, 'without_bar_why': why, 'with_bar': alt_val, 'with_bar_why': alt_why, 'frozen_matches': matches, 'reason': ( "gated on a welded tension bar; this deck does not " "carry the assembly's other member families, so " "whether the bar was corroborated on THIS piece cannot " "be re-derived offline. Current catalogue gives {0} lb " "without the bar or {1} lb with it corroborated; " "frozen {2} lb matches {3}." ).format(val, alt_val, frozen_val, '/'.join(matches) if matches else 'neither'), } status = _verdict(val, frozen_val) return status, {'current': val, 'basis': basis, 'why': why, 'frozen': frozen_val} def _match_current_stage_spec(know, stage): specs = knowledge.get(know, 'handling.stages', []) or [] pci = stage.get('pci_case') name = stage.get('name') for spec in specs: if pci and spec.get('pci_case') == pci: return spec for spec in specs: if name and spec.get('name') == name: return spec return None def check_stage_tension_limit(know, stage, job_default_limit): frozen = stage.get('tension_limit_sqrt_fc') source = stage.get('tension_limit_source') if source in (None, 'job'): current = job_default_limit why = 'handling.tension_limit_sqrt_fc, current job layer' elif source == 'stage': spec = _match_current_stage_spec(know, stage) if spec is None: return UNJUDGEABLE, { 'frozen': frozen, 'reason': ( 'no current handling.stages entry matches pci_case={0!r}' ' / name={1!r} to compare the pinned limit against' .format(stage.get('pci_case'), stage.get('name'))), } spec_limit = spec.get('tension_limit_sqrt_fc') current = spec_limit if spec_limit is not None else job_default_limit why = ('handling.stages[pci_case={0!r}].tension_limit_sqrt_fc' .format(stage.get('pci_case')) if spec_limit is not None else 'handling.tension_limit_sqrt_fc, current job layer (matched ' 'stage spec states none of its own)') else: return UNJUDGEABLE, { 'frozen': frozen, 'reason': 'unrecognised tension_limit_source {0!r} on this deck' .format(source), } return _verdict(current, frozen), {'current': current, 'frozen': frozen, 'why': why} # ------------------------------------------------------------- per deck def check_deck(path, know): """The findings for one job file. Never raises on a well-formed deck.""" with io.open(path, 'r', encoding='utf-8') as fh: deck = json.load(fh) panel = deck.get('panel') or {} mark = panel.get('mark') or os.path.splitext(os.path.basename(path))[0] tension_bar_families = knowledge.get(know, 'lifters.tension_bar_families', []) or [] findings = [] def add(field, status, detail, lifter_mark=None, family=None): findings.append({ 'deck': path, 'mark': mark, 'lifter_mark': lifter_mark, 'family': family, 'field': field, 'status': status, 'detail': detail, }) for lifter in panel.get('lifters') or []: family = lifter.get('family') lmark = lifter.get('mark') if not family: add('family', UNJUDGEABLE, {'reason': 'this lifter carries no family name in the deck'}, lmark, family) continue if 'capacity_lb' in lifter: status, detail = check_capacity( know, family, 'tension', lifter['capacity_lb'], tension_bar_families) add('capacity_lb', status, detail, lmark, family) if 'shear_capacity_lb' in lifter: status, detail = check_capacity( know, family, 'shear', lifter['shear_capacity_lb'], tension_bar_families) add('shear_capacity_lb', status, detail, lmark, family) if 'basis_fc_psi' in lifter: cur = knowledge.lifter_basis_fc(know, family) add('basis_fc_psi', _verdict(cur, lifter['basis_fc_psi']), {'current': cur, 'frozen': lifter['basis_fc_psi']}, lmark, family) if 'min_edge_distance_in' in lifter: cur = (knowledge.lifter_edge_distance(know, family) or knowledge.get(know, 'lifters.min_edge_distance_in')) add('min_edge_distance_in', _verdict(cur, lifter['min_edge_distance_in']), {'current': cur, 'frozen': lifter['min_edge_distance_in']}, lmark, family) if 'tension_fos' in lifter: cur, src, why = knowledge.lifter_safety_factor( know, family, kind='tension') add('tension_fos', _verdict(cur, lifter['tension_fos']), {'current': cur, 'frozen': lifter['tension_fos'], 'source': src, 'why': why}, lmark, family) if 'shear_fos' in lifter: cur, src, why = knowledge.lifter_safety_factor( know, family, kind='shear') add('shear_fos', _verdict(cur, lifter['shear_fos']), {'current': cur, 'frozen': lifter['shear_fos'], 'source': src, 'why': why}, lmark, family) job_default_limit = knowledge.get(know, 'handling.tension_limit_sqrt_fc', 5.0) for i, stage in enumerate(deck.get('stages') or []): if 'tension_limit_sqrt_fc' not in stage: continue status, detail = check_stage_tension_limit(know, stage, job_default_limit) field = 'stages[{0}:{1}].tension_limit_sqrt_fc'.format( i, stage.get('name') or stage.get('pci_case') or '?') add(field, status, detail) return findings def deck_status(findings): if not findings: return CURRENT return max((f['status'] for f in findings), key=lambda s: _SEVERITY[s]) # ----------------------------------------------------------------- CLI def _fmt_num(v): if v is None: return 'null' if isinstance(v, float) and v == int(v): return str(int(v)) return str(v) def _group_key(f): """Findings that would print an identical line collapse into one. A deck with several lifters of the same gated family produces the same CONDITIONAL (or STALE) numbers once per lifter; grouping is what keeps "one line per stale deck" true instead of one line per lifter on it. Two lifters of the same family that land on genuinely DIFFERENT numbers -- a plant override that only matched one of them, say -- still get separate lines, because the key includes the numbers themselves. """ d = f['detail'] if f['status'] == CONDITIONAL: tail = (d.get('frozen'), d.get('without_bar'), d.get('with_bar')) elif f['status'] == STALE: tail = (d.get('frozen'), d.get('current')) else: tail = (d.get('reason') or d.get('why'),) return (f['field'], f['status']) + tail def _deck_lines(mark, findings): """One line per DISTINCT (field, status, values) on this deck. Not one line per lifter -- see `_group_key`. `current` findings never print here; pass `--all` for the raw per-finding dump instead. """ non_current = [f for f in findings if f['status'] != CURRENT] if not non_current: return [] groups = {} for f in non_current: groups.setdefault(_group_key(f), []).append(f) lines = [] for key in sorted(groups, key=lambda k: (-_SEVERITY[k[1]], k)): fs = groups[key] f0 = fs[0] d = f0['detail'] families = sorted(set(x['family'] for x in fs if x['family'])) marks = sorted(set(x['lifter_mark'] for x in fs if x['lifter_mark'])) note = '' if marks: note = ' ({0} lifter{1}{2})'.format( len(marks), '' if len(marks) == 1 else 's', ' ' + '/'.join(families) if families else '') status = f0['status'] if status == CONDITIONAL: line = (' CONDITIONAL {0}{1} {2}: frozen={3} without_bar={4} ' 'with_bar={5} -- {6}' .format(mark, note, f0['field'], _fmt_num(d.get('frozen')), _fmt_num(d.get('without_bar')), _fmt_num(d.get('with_bar')), d.get('reason'))) elif status == STALE: why = d.get('why') or d.get('reason') or '' line = (' STALE {0}{1} {2}: frozen={3} -> current={4}{5}' .format(mark, note, f0['field'], _fmt_num(d.get('frozen')), _fmt_num(d.get('current')), (' (' + why + ')') if why else '')) else: line = (' UNJUDGEABLE {0}{1} {2}: {3}' .format(mark, note, f0['field'], d.get('reason') or d.get('why') or 'current knowledge could not resolve this')) lines.append(line) return lines def _finding_line(f): d = f['detail'] who = f['mark'] if f.get('lifter_mark'): who = '{0} lifter {1} ({2})'.format(f['mark'], f['lifter_mark'], f['family']) if f['status'] == CONDITIONAL: return (' CONDITIONAL {0} {1}: frozen={2} without_bar={3} ' 'with_bar={4} -- {5}' .format(who, f['field'], _fmt_num(d.get('frozen')), _fmt_num(d.get('without_bar')), _fmt_num(d.get('with_bar')), d.get('reason'))) if f['status'] == STALE: why = d.get('why') or d.get('reason') or '' return (' STALE {0} {1}: frozen={2} -> current={3}{4}' .format(who, f['field'], _fmt_num(d.get('frozen')), _fmt_num(d.get('current')), (' (' + why + ')') if why else '')) if f['status'] == UNJUDGEABLE: return (' UNJUDGEABLE {0} {1}: {2}' .format(who, f['field'], d.get('reason') or d.get('why') or 'current knowledge could not resolve this')) return ' current {0} {1}'.format(who, f['field']) def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('jobs_dir', help="a sweep's jobs/ folder of *.job.json") ap.add_argument('--job-root', help='the folder holding this job\'s own ' 'knowledge.yaml (default: guessed ' 'from jobs_dir, see _guess_job_root)') ap.add_argument('--json', dest='json_out', help='write machine-readable ' 'output here too') ap.add_argument('--all', action='store_true', help='print every finding, not just non-current ones') args = ap.parse_args(argv) jobs_dir = args.jobs_dir if not os.path.isdir(jobs_dir): sys.stderr.write('deckcheck: not a directory: {0}\n'.format(jobs_dir)) return 2 job_root = args.job_root or _guess_job_root(jobs_dir) try: know, layers = build_knowledge(job_root) except Exception as exc: # pragma: no cover sys.stderr.write('deckcheck: could not build current knowledge: ' '{0}: {1}\n'.format(type(exc).__name__, exc)) return 2 names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json')) if not names: sys.stderr.write('deckcheck: no *.job.json under {0}\n'.format(jobs_dir)) return 2 deck_paths = [os.path.join(jobs_dir, n) for n in names] newest_deck_mtime = max(os.path.getmtime(p) for p in deck_paths) newest_knowledge_mtime = layers.newest_mtime() print('=' * 78) print('DECKCHECK -- knowledge staleness only, NOT model staleness.') print('This verifies each deck against KNOWLEDGE, never against the live') print('MODEL: a deck can pass every check here and still describe a') print('panel whose geometry has since changed in Revit.') print('=' * 78) if newest_knowledge_mtime is not None: age_hours = (newest_knowledge_mtime - newest_deck_mtime) / 3600.0 if age_hours > 0: print('these decks predate the current knowledge by {0:.1f} hour(s)' .format(age_hours)) elif -age_hours > 0: print('current knowledge is {0:.1f} hour(s) OLDER than the newest ' 'deck -- decks were exported after the last knowledge edit' .format(-age_hours)) else: print('newest deck and newest knowledge layer share a timestamp') else: print('no knowledge layer file found to compare a timestamp against') print('') print('job root: {0}'.format(job_root)) for label, path, status in layers.rows(): print(' {0:<10} {1} [{2}]'.format(label, path or '(none)', status)) print('') all_findings = [] deck_statuses = [] stale_lines = [] for path in deck_paths: try: findings = check_deck(path, know) except Exception as exc: findings = [{ 'deck': path, 'mark': os.path.basename(path), 'lifter_mark': None, 'family': None, 'field': '(deck read)', 'status': UNJUDGEABLE, 'detail': {'reason': '{0}: {1}'.format(type(exc).__name__, exc)}, }] all_findings.extend(findings) status = deck_status(findings) deck_statuses.append(status) mark = findings[0]['mark'] if findings else os.path.basename(path) if args.all: stale_lines.extend(_finding_line(f) for f in findings) elif status != CURRENT: stale_lines.extend(_deck_lines(mark, findings)) if stale_lines: print('-' * 78) for line in stale_lines: print(line) print('-' * 78) n_total = len(deck_statuses) n_current = sum(1 for s in deck_statuses if s == CURRENT) n_stale = sum(1 for s in deck_statuses if s == STALE) n_conditional = sum(1 for s in deck_statuses if s == CONDITIONAL) n_unjudgeable = sum(1 for s in deck_statuses if s == UNJUDGEABLE) unjudgeable_reasons = sorted(set( f['detail'].get('reason') or f['detail'].get('why') or '(no reason given)' for f in all_findings if f['status'] == UNJUDGEABLE )) print('') print('{0} deck(s) checked, {1} current, {2} stale, {3} conditional, ' '{4} unjudgeable.' .format(n_total, n_current, n_stale, n_conditional, n_unjudgeable)) if n_unjudgeable: print('unjudgeable because:') for r in unjudgeable_reasons: print(' - {0}'.format(r)) if args.json_out: payload = { 'job_root': job_root, 'layers': [{'layer': label, 'path': path, 'status': status} for label, path, status in layers.rows()], 'newest_deck_mtime': newest_deck_mtime, 'newest_knowledge_mtime': newest_knowledge_mtime, 'decks_checked': n_total, 'current': n_current, 'stale': n_stale, 'conditional': n_conditional, 'unjudgeable': n_unjudgeable, 'findings': all_findings, } with io.open(args.json_out, 'w', encoding='utf-8') as fh: json.dump(payload, fh, indent=1, default=str) print('\njson written to {0}'.format(args.json_out)) return 0 if n_current == n_total else 1 if __name__ == '__main__': sys.exit(main())