Files
willtools-demo/WillTools.extension/lib/vibe/configmodel.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

1632 lines
73 KiB
Python

# -*- coding: utf-8 -*-
"""Editing the job layer: what may be edited, what it came from, and the write.
`configdump.py` flattens the knowledge layers into one document. This is the
other half: the part that lets somebody CHANGE a setting without opening a
YAML file, and that refuses to let them change one without seeing which layer
they are about to beat.
Three rules shape everything here, and each one exists because of a measured
failure rather than a preference.
1. **The job layer is the only thing this writes.** Which crane setup a job
uses, which parameter f'c is read from, where the reports go -- those are
job facts. What a plant's hardware is, and which part number is a face
lifter, is not: one plant edit reaches every job, so editing the plant
layer stays a deliberate act somewhere else. Every setting below is
therefore either `job` (editable here) or `ro` (shown with its source and
a sentence saying why it is not editable here). There is no third state
and no hidden switch.
2. **Provenance is not decoration.** For every value the caller can get the
whole chain -- default, catalogue, plant, job, config -- with the layer in
force marked, because the failure this replaces was a job file silently
replacing five of the plant's exempt families with nothing in any output
saying an override was in force.
3. **Lists replace; they never append.** `knowledge._deep_update` assigns, so
a four-entry job list removes a four-entry plant list rather than making
it eight. `replace_warning()` is what a list editor must show before it
takes a value, and `start_from()` is the gesture that turns the trap into
a choice.
Nothing here imports Revit, `vibe.designspec` or `vibe.ticketqc`, at module
scope or inside a function: the caller passes in the layers it read and the
text it read, and gets back text to write. That is what makes the whole of it
testable on host CPython (`WillTools.extension/tests/test_configmodel.py`) and
what would let the pyRevit forms sequence in `ProjectConfig.pushbutton` be
replaced by a WPF window without redoing any of the thinking.
Written for IronPython 2.7: no f-strings, no pathlib, no modern typing.
"""
# pylint: disable=invalid-name
import datetime
import io
import os
import re
from vibe import yamlkit
from vibe.compat import string_types, text_type
# --------------------------------------------------------------- the surface
#
# One row per setting the Panel Lift configuration actually turns on. Fields:
#
# key dotted path into the merged knowledge tree -- the thing that is
# really read, never a friendly alias, so the YAML stays findable
# label the word the report uses for the same quantity (design section 9)
# section which screen it belongs on
# kind how it is entered: choice / float / int / bool / text / path /
# list / stages / map
# edit 'job' -- this button writes it, to the job layer
# 'ro' -- shown with its source; not editable here
# why for 'ro', the sentence that says why. A greyed field with no
# reason reads as a bug; a greyed field with a reason reads as a
# decision.
# choices for 'choice', the values that parse. Anything else is REFUSED,
# never guessed at.
# note one line of help.
def _s(key, label, section, kind, edit, why=None, choices=None, note=None):
return {'key': key, 'label': label, 'section': section, 'kind': kind,
'edit': edit, 'why': why, 'choices': choices, 'note': note}
PRODUCT_TYPES = ('flat', 'flat_reveal', 'fluted', 'sculptured')
FINISHES = ('exposed', 'smooth')
PCI_CASES = ('stripping', 'transportation', 'yard')
RIG_MODES = ('rolling', 'static')
# ------------------------------------------- which FILE a layer reads from
#
# The owner, 2026-09-02 (translated from Chinese): "under Project Config's
# job-knowledge section, add plant knowledge -- let the user choose the
# plant knowledge file. And public knowledge -- let the user choose the
# public knowledge file."
#
# THIS IS A DIFFERENT ACT FROM EVERYTHING ELSE `SETTINGS` DOES. Every row
# above lets a job TYPE OVER a value the tool reads out of a layer -- and the
# ruling on `parameters` in SUBTREES below is explicit that the UI's job is
# to show which parameter a value is read from, never to let somebody type
# over the office's template mapping. A file pointer is not that kind of
# edit: it does not restate a fact the plant or the catalogue already
# states, it names WHICH FILE that layer is read from at all. So these two
# keys are settable on the same footing as `handling.report_dir` -- a path,
# not a value -- while every plant fact and `catalogue.inserts` stay
# read-only exactly as the ruling requires.
#
# THE READ SIDE IS NOT WIRED HERE, and is deliberately not this change's job:
# `knowledge.plant_knowledge_path()` / `catalogue_path()` do not consult
# these keys yet, because `knowledge.py` and `vibe.plantpath` are off limits
# to it. This file only owns the write: the pointer this window writes to
# the job layer, its provenance, and the loud refusal when somebody names a
# file that is not there -- `validate()` below and `knowledge_source_problems`
# are that half. `knowledge.sources(doc)` remains the ground truth for "what
# is this REALLY reading right now", before and after the read side lands.
KNOWLEDGE_SOURCE_KEYS = (
('knowledge_sources.plant_file', 'Plant knowledge file'),
('knowledge_sources.public_file', 'Public knowledge file'),
)
SETTINGS = (
# ---- how the piece hangs: the job facts the ruling named by name -----
_s('handling.rig_mode', 'Lift type', 'How the piece hangs', 'choice',
'job', choices=RIG_MODES,
note='rolling = equalising blocks; static = fixed slings. This is the '
'crane setup, and it changes the mechanics -- 1.7x on one '
'measured stripping moment, not a coefficient.'),
_s('handling.stages', 'Stages', 'How the piece hangs', 'stages', 'job',
note='One list: the load multiplier, the strength and the rig mode at '
'each stage. Editing any cell writes the WHOLE list.'),
_s('handling.sling_angle_deg', 'Sling angle', 'How the piece hangs',
'float', 'job',
note='One angle for the whole job -- the solver reads it once. '
'Degrees from horizontal, 0 < a <= 90.'),
_s('handling.group_tolerance_ft', 'Pick row tolerance',
'How the piece hangs', 'float', 'job',
note='How far apart along the piece two inserts may sit and still hang '
'as one row. Feet, greater than zero.'),
# ---- strength and stress --------------------------------------------
_s('handling.tension_limit_sqrt_fc', 'Stress limit', 'Strength', 'float',
'job',
note="Multiplies sqrt(f'c), psi. 5.0 = rupture over a 1.5 factor, no "
"discernible cracking; 7.5 = the rupture modulus itself, first "
"cracking with no margin. A factor of 1.5 on every stress "
"verdict, and the engineer sets it per job."),
# The compression allowable's sibling field, ruled per job the same way,
# 2026-09-01: "0.6保持可调" ("0.6 stays adjustable"). 0.6 is ACI 318-19
# Table 24.5.3.1 ("all other locations", transfer) / Sec. 24.5.4.1
# (service); ACI permits 0.70*f'ci at member ends, deliberately NOT
# implemented here (the owner, same night: "先不用实现0.7...别搞这么复
# 杂") -- the flat ratio is the conservative choice everywhere on the
# piece, not an oversight.
_s('handling.compression_limit_ratio', 'Compression limit', 'Strength',
'float', 'job',
note="Multiplies f'c (or f'ci at the stripping stage), psi. 0.6 is "
"ACI 318-19 Table 24.5.3.1 / Sec. 24.5.4.1. ACI permits a higher "
"0.70 at member ends; this tool applies one flat ratio "
"everywhere on the piece instead, deliberately the more "
"conservative choice."),
_s('handling.product_type', 'Product type', 'Strength', 'choice', 'job',
choices=PRODUCT_TYPES, note='PCI Table 8.3.1 row.'),
_s('handling.finish', 'Finish', 'Strength', 'choice', 'job',
choices=FINISHES, note='PCI Table 8.3.1 column.'),
_s('handling.prestress_loss_pct', 'Prestress loss', 'Strength', 'float',
'job',
note='Everything lost between the jack and the stage, as one per cent. '
'LOWER IT AND EVERY PIECE GETS MORE CREDIT.'),
_s('handling.transfer_length_in', 'Transfer length', 'Strength', 'float',
'job',
note="Inches; blank uses ACI 318's f_se*db/3 from the stated pull."),
_s('concrete.min_fci_ratio', 'Minimum release ratio', 'Strength', 'float',
'job', note="Release strength as a fraction of f'c. Blank = no rule."),
# ---- dunnage ---------------------------------------------------------
_s('handling.dunnage.mode', 'Dunnage mode', 'Dunnage', 'choice', 'job',
choices=('lifter_rows', 'fraction'),
note='Blank = the old, labelled lift approximation for '
'transportation.'),
_s('handling.dunnage.fraction', 'Dunnage inset', 'Dunnage', 'float', 'job',
note='Only read when mode is fraction. 0 < f < 0.5 of the length, from '
'each end.'),
_s('handling.dunnage.opening_clearance_warn_in', 'Opening clearance warn',
'Dunnage', 'float', 'job', note='Inches. Decides whether to speak.'),
_s('handling.dunnage.opening_clearance_fail_in', 'Opening clearance fail',
'Dunnage', 'float', 'ro',
why='Inert: every dunnage finding is recorded as a WARN today, so this '
'number cannot change any outcome. An editable knob that decides '
'nothing is worse than no knob.'),
_s('handling.dunnage.opening_counts_only_if_through_thickness',
'Through-thickness openings only', 'Dunnage', 'bool', 'ro',
why='Not implemented: blockouts carry no depth, so nothing can measure '
'penetration.'),
# ---- which families are lifters at all -------------------------------
_s('families.lifter_include', 'Lifter families', 'Lifters', 'list', 'job',
note='Substring match, case-insensitive. The check refuses to run '
'without a match.'),
_s('families.lifter_exclude', 'Not lifters', 'Lifters', 'list', 'job'),
_s('families.cast_include', 'Cast concrete families', 'Lifters', 'list',
'job',
note='Cast concrete of the piece that is not the panel rectangle -- a '
'ledge, a haunch. Real weight: 7.5% of one measured piece.'),
_s('lifters.dynamic_factor', 'Dynamic factor', 'Lifters', 'float', 'job'),
_s('lifters.min_edge_distance_in', 'Minimum edge distance', 'Lifters',
'float', 'job', note='Inches. Blank = the catalogue figure.'),
_s('handling.lifter_groups', 'What part each insert plays', 'Lifters',
'map', 'ro',
why='Which part number is a face lifter and which is an edge anchor is '
'plant hardware knowledge, and this button writes only the job '
'layer. A job copy would REPLACE the plant map entirely -- every '
'group, not just the one edited. Edit it in the plant file.'),
_s('lifters.capacity_lb', 'Capacity overrides', 'Lifters', 'map', 'ro',
why='Checked BEFORE the catalogue, so a value here silently overrides '
'published data. Plant-level, and deliberately not a job edit.'),
_s('lifters.catalogue_alias', 'Part number -> catalogue', 'Lifters', 'map',
'ro', why='Plant hardware mapping.'),
# These moved on 2026-08-30. They were listed here as `rigging.*`, which
# is the name panellift's job file uses -- but that is the FILE's shape,
# not a knowledge path, and no layer ever declared it. So these two rows
# named keys that did not exist anywhere, and the screen reported them as
# unset for a reason that was about to stop being true.
# ...and they are NULL here as of 2026-08-31, which the `why` has to say
# or the screen reads as an unfilled setting. A safety factor belongs to
# the part that was tested, so it now lives inside each
# `catalogue.inserts` entry; this pair is only a fallback for hardware
# the catalogue does not cover, and it is empty on purpose.
_s('catalogue.safety_factors.tension_fos', 'Tension factor of safety',
'Lifters', 'float', 'ro',
why="NULL ON PURPOSE, and not an unfilled setting. The real factors "
"are PER PART, inside each catalogue.inserts entry, beside the "
"page that publishes them -- this pair was one file-wide value "
"for every manufacturer's insert until 2026-08-31 and its shear "
"half (2.67) had no published source at all. It is read only as "
"a fallback for hardware the catalogue does not cover; with "
"nothing set, an insert with no factor of its own is reported "
"unjudged rather than given a borrowed number. Applied only "
"where the catalogue quotes an ultimate rather than a working "
"load; every published working_load_lb is already an SWL."),
_s('catalogue.safety_factors.shear_fos', 'Shear factor of safety',
'Lifters', 'float', 'ro',
why="As tension_fos: null on purpose, a fallback and not the answer. "
"The QuikLift entries publish 4:1 in shear (ALP catalog p53, "
"02/2024 -- the capacity table's own heading, over both load "
"columns) and say so in their own entries."),
# ---- output and environment ------------------------------------------
_s('handling.report_dir', 'Report folder', 'Output', 'path', 'job',
note='Used exactly as written, so a share path works. Blank puts them '
'beside the model.'),
_s('handling.open_report', 'Open the report when done', 'Output', 'bool',
'job'),
_s('handling.weight_tolerance', 'Weight tolerance', 'Output', 'float',
'job',
note='The gate that catches a misread blockout, as a fraction. It '
'fails CLOSED, which is right; loosening it is how a bad section '
'modulus gets through.'),
_s('handling.fast_lifter_box', 'Insert position, one piece', 'Output',
'choice', 'job', choices=('fast', 'precise'),
note="fast = the cached bounding box centre; precise = the solved "
"centroid. A face lifter's position IS its moment arm."),
_s('handling.fast_lifter_box_batch', 'Insert position, batch', 'Output',
'choice', 'job', choices=('fast', 'precise')),
_s('handling.python_exe', 'CPython executable', 'Output', 'path', 'ro',
why='A machine fact, not a job fact. Baked into a job file it breaks '
'on the next machine, and the failure reads as "the check does not '
'work here".'),
# ---- what this job has declared it does not want asked --------------
#
# Editable here on purpose. Muting is a JOB decision with a reason
# attached, and the place a job decision gets made and recorded is this
# screen -- which already writes the override note that says who set a
# value and why. A mute typed into a YAML file by hand has no such note;
# a mute made here does.
_s('checks.muted', 'Mute a checklist item', 'Ticket QC checks', 'map',
'job',
note='Turn the volume down on one ticket-checklist row, with the '
'reason it does not apply to this job. The REASON IS REQUIRED. '
'MUTING DOES NOT STOP THE CHECK: it runs on every ticket exactly '
'as before, keeps its row on the table, and reports quietly '
'instead of red when the result is not a failure. A FAILURE IS '
'NEVER QUIETENED, and every finding still reaches the '
'machine-readable JSON. THE LAYERS MERGE THIS MAP entry by entry '
'(see MERGED_MAPS below), so a job mutes one more row without '
'restating what the plant already muted -- and this button writes '
'the job layer only.'),
)
# `fast_lifter_box` is stored as a boolean and presented as fast/precise --
# the config file already does this, and "insert_position: false" is not a
# sentence anybody can check.
BOOL_AS_CHOICE = {
'handling.fast_lifter_box': ('fast', 'precise'),
'handling.fast_lifter_box_batch': ('fast', 'precise'),
}
# Whole subtrees shown read-only, expanded leaf by leaf with their layer.
# `parameters` is here because the ruling was explicit: the UI's job is to
# display which parameter each value is read from and what was read, not to
# let somebody type over the office's template mapping.
SUBTREES = (
('parameters', "This office's Revit parameter name for each role the tool "
"reads. Shown, not edited: a wrong name here does not "
"raise -- the check quietly does not run."),
('catalogue.inserts', 'Published hardware ratings, shipped with the tool. '
'The right way to correct one is a repo edit, not a '
'per-job opinion with no audit trail.'),
('strands.area_in2', 'Published strand section areas.'),
('orientation', 'Which end of a piece is the top once erected. Plant '
'knowledge.'),
)
def setting(key):
for row in SETTINGS:
if row['key'] == key:
return row
return None
def sections():
"""Section names in the order the SETTINGS table lists them."""
out = []
for row in SETTINGS:
if row['section'] not in out:
out.append(row['section'])
return out
def editable():
return [row for row in SETTINGS if row['edit'] == 'job']
def read_only():
return [row for row in SETTINGS if row['edit'] == 'ro']
# ---------------------------------------------------------------- provenance
MISSING = object() # this layer does not mention the key at all
def _get(tree, dotted):
node = tree
for part in dotted.split('.'):
if not isinstance(node, dict) or part not in node:
return MISSING
node = node[part]
return node
def resolve(layers, key):
"""What one setting is, and every layer that had an opinion about it.
`layers` is [(label, path, exists, data)] in merge order -- exactly what
`configdump.layer_values()` returns. Returns a dict:
key the dotted path
chain [(label, path, value)] for the layers that SET it
value the value in force
layer the label of the layer that set it
known False when NO layer mentions it. Then `value` is None and
the caller must print "not set in any layer", never a
blank -- a blank reads as a value of nothing, and "could
not judge" is not "judged and found nothing".
overrides [(label, value)] the chain entries that a later layer
changed to something else, i.e. what an edit is beating
missing [labels] of layers whose file is not present
"""
chain = []
absent = []
for label, path, exists, data in layers:
if not exists:
absent.append(label)
continue
value = _get(data or {}, key)
if value is not MISSING:
chain.append((label, path, value))
if not chain:
return {'key': key, 'chain': [], 'value': None, 'layer': None,
'known': False, 'overrides': [], 'missing': absent}
winner = chain[-1]
overrides = [(label, value) for label, _p, value in chain[:-1]
if value != winner[2]]
return {'key': key, 'chain': chain, 'value': winner[2],
'layer': winner[0],
'known': True, 'overrides': overrides, 'missing': absent}
# ------------------------------------------------- maps the layers MERGE
#
# `knowledge._deep_update` recurses into a dict and ASSIGNS anything else, so
# two shapes of value behave differently across the layers and `resolve` above
# describes only one of them:
#
# a LIST replaces -- the winning layer's list is the whole answer. That is
# what `resolve` returns, and `replace_warning()` is the
# sentence about it.
# a MAP merges -- every layer's entries survive unless a LATER layer
# names the same sub-key. `resolve` returns the winning
# layer's map on its own, which for these keys is NOT
# what the run reads.
#
# The difference is not academic. `checks.muted` is a map on purpose
# (`knowledge.DEFAULTS` says so in as many words): a job mutes one more
# checklist item without restating the ones the plant already muted. Ask
# `resolve` for it while the job layer mutes 5.4 and the plant mutes 3.3, and
# it answers `{'5.4': ...}` -- so a screen built on that answer shows one mute,
# hides the other, and lets somebody "remove" a plant mute that the merge will
# put straight back. THE SAME FAILURE THIS FILE EXISTS TO PREVENT: the wrong
# outcome looks exactly like the right one.
#
# So a map key gets `merged_map()` for what the run reads and where each entry
# came from, and `layer_map()` for what ONE layer holds, which is the only
# thing a splice may start from. Both are the provenance machinery above,
# applied one level deeper -- not a second mechanism.
MERGED_MAPS = ('checks.muted',)
def merges(key):
"""True when the knowledge layers merge this key's entries rather than
replacing the whole value. See MERGED_MAPS."""
return key in MERGED_MAPS
def merged_map(layers, key):
"""Every entry the run really sees, with the layer that set THAT entry.
{subkey: {'value', 'layer', 'path', 'overrides': [(label, value)]}}
`overrides` is the earlier entries for the same sub-key that this one
beat. It is what lets a screen say that removing a job entry falls back to
the plant's, rather than claiming the item stops being muted.
Mirrors `_deep_update`, including its sharp edge: a layer that sets the key
to something that is not a map REPLACES everything above it rather than
merging into it. That case returns no entries at all -- the caller still
has `resolve()` for the raw winning value, and `validate()` refuses to
write one.
"""
merged = {}
for label, path, exists, data in layers:
if not exists:
continue
value = _get(data or {}, key)
if value is MISSING:
continue
if not isinstance(value, dict):
merged = {}
continue
for sub in value:
prior = merged.get(sub)
beat = list(prior['overrides']) if prior else []
if prior is not None:
beat.append((prior['layer'], prior['value']))
merged[sub] = {'value': value[sub], 'layer': label, 'path': path,
'overrides': beat}
return merged
def layer_map(layers, key, label='job'):
"""The map exactly as ONE layer holds it -- the baseline a splice replaces.
This window writes the job layer, so this is what an edit must start from.
Starting from the MERGE instead would copy the plant's entries into the job
file: they would stop tracking the plant from that moment on, and a plant
unmute would never reach this job again. Silently, of course.
Empty when that layer does not mention the key, which is also right --
`splice` creates a key that is not there yet.
"""
for lab, _path, exists, data in layers:
if lab != label:
continue
if not exists:
return {}
value = _get(data or {}, key)
if value is MISSING or not isinstance(value, dict):
return {}
return dict(value)
return {}
def map_layers(merged):
"""Which layers set at least one entry, in merge order. Never a set --
the order is the answer to "who beat whom"."""
order = []
for sub in sorted(merged or {}):
label = merged[sub]['layer']
if label not in order:
order.append(label)
return order
def map_chip(merged):
"""One letter for a merged map, and `+` when more than one layer set one.
A single letter here would be a lie the moment two layers contribute: the
map in force is not any one layer's map, and `J` on a row the plant also
writes is exactly the "an override and a typo look identical" failure at
one level down.
"""
order = map_layers(merged)
if not order:
return '?'
if len(order) > 1:
return '+'
return CHIP.get(order[0], '*')
def format_merged_map(merged):
"""A merged map as one line: how many entries, and who set each."""
if not merged:
return '(nothing)'
parts = ['{0} ({1})'.format(sub, merged[sub]['layer'])
for sub in sorted(merged)]
return '{0} entr{1}: {2}'.format(len(parts),
'y' if len(parts) == 1 else 'ies',
', '.join(parts))
def format_value(value):
"""A value as one line, and never as an empty string."""
if value is None:
return '(blank / null)'
if value is True:
return 'true'
if value is False:
return 'false'
if isinstance(value, list):
if not value:
return '(empty list)'
if all(not isinstance(v, (dict, list)) for v in value):
return ', '.join(text_type(v) for v in value)
return '{0} entries'.format(len(value))
if isinstance(value, dict):
if not value:
return '(empty)'
return '{0} keys: {1}'.format(len(value),
', '.join(sorted(value)[:6]))
text = text_type(value)
return text if text.strip() else "'' (empty text)"
def display_value(key, value):
"""format_value, but honouring the fast/precise presentation."""
if key in BOOL_AS_CHOICE and isinstance(value, bool):
yes, no = BOOL_AS_CHOICE[key]
return yes if value else no
return format_value(value)
CHIP = {'defaults': 'D', 'catalogue': 'C', 'plant': 'P', 'job': 'J',
'config': '!'}
def chip(res):
"""One letter for the layer in force. Distinguishable without colour."""
if not res['known']:
return '?'
return CHIP.get(res['layer'], '*')
def chain_lines(res, target='job'):
"""The provenance chain as text: what set it, what it beat, what I write.
This is the answer to the five questions a person has to be able to ask
without leaving the screen (design section 4a). It is deliberately text
rather than a widget, so the same wording serves a forms dialog, the
pyRevit output panel and a WPF popover later.
"""
key = res['key']
row = setting(key)
out = ['{0} [ {1} ]'.format(key, chip(res))]
if row:
out.append('{0} -- {1}'.format(row['label'], row.get('note') or ''))
out.append('')
if not res['known']:
out.append('NOT SET IN ANY LAYER. Nothing here could judge this '
'setting: no layer mentions it, so what the run uses is '
'whatever the code that reads it falls back to.')
else:
for label, path, value in res['chain']:
mark = ' <- in force' if (label, path, value) == res['chain'][-1] \
else ''
out.append(' {0:<10} {1}{2}'.format(
label, display_value(key, value), mark))
if path:
out.append(' {0:<10} {1}'.format('', path))
for label in res['missing']:
out.append(' {0:<10} (file not present)'.format(label))
out.append('')
if row and row['edit'] == 'ro':
out.append('NOT EDITABLE HERE. ' + (row['why'] or ''))
return out
out.append('Editing target: JOB ({0}).'.format(target))
if res['known'] and res['layer'] not in ('job',):
out.append('A job value will beat {0}.'.format(res['layer']))
config = [c for c in res['chain'] if c[0] == 'config']
if config:
out.append('')
out.append('WARNING: a project config file is live and it is the last '
'word. Your job edit will NOT reach this model until that '
'file is refreshed or cleared.')
if row and row['kind'] in ('list', 'stages'):
out.append('')
out.extend(replace_warning(res))
return out
def replace_warning(res):
"""The sentence a list editor must show, and the thing it replaces.
Lists do not merge in this tool. Writing four families to the job layer
removes the plant's four; it does not make eight. This is the single
surprise in the merge and it has produced a live failure, so it is stated
every time rather than once in a docstring.
"""
out = ['LISTS REPLACE. They do not merge.']
if res['known'] and isinstance(res['value'], list):
out.append(' In force ({0}): {1} entr{2}'.format(
res['layer'], len(res['value']),
'y' if len(res['value']) == 1 else 'ies'))
for label, value in res['overrides']:
if isinstance(value, list):
out.append(' This already REPLACED {0}: {1}'.format(
label, format_value(value)))
out.append(' Writing this list to the job layer replaces every entry in '
'force, not just the one you change.')
return out
def start_from(res):
"""The entries an editor should pre-fill: what is in force, or nothing."""
if res['known'] and isinstance(res['value'], list):
return list(res['value'])
return []
def overriding(res):
"""True when a job write here would contradict a layer that set it."""
return bool(res['known'] and res['layer'] != 'job')
def knowledge_source_problems(layers):
"""[(key, label, path)] for a chosen knowledge-source file that is gone.
Checked every time the window opens, not only at the moment a file is
picked -- a file this window pointed at can be moved or deleted by
something that has nothing to do with Project Config, and the next open
of this window is the first chance to say so loudly. Same discipline as
`knowledge._config_missing` / `_plant_missing`: NEVER fall back to the
ordinary discovery in silence.
EMPTY AND ABSENT ARE BOTH SILENT HERE, and deliberately so -- they are
the two shapes of "use the normal discovery", not a problem:
known is False no layer ever mentioned the key -- every install
before this feature existed is in this state.
known and value is None
the job layer explicitly cleared it -- a decision
made in this window, not a missing pointer.
Only a NAMED value that is not on disk right now is reported.
"""
out = []
for key, label in KNOWLEDGE_SOURCE_KEYS:
res = resolve(layers, key)
if not res['known'] or not res['value']:
continue
if not os.path.isfile(res['value']):
out.append((key, label, res['value']))
return out
# ------------------------------------------------------------------ entering
def parse_input(kind, text, choices=None):
"""Text from a box into a value. (ok, value, complaint).
Nothing is guessed. `static_lift` is refused rather than read as `static`,
for the reason `_merge_decisions` gives: a typo that looked like inherit
is how a setting nobody checked ends up deciding a moment arm.
"""
if text is None:
return False, None, 'cancelled'
raw = text_type(text).strip()
blank = raw == '' or raw.lower() in ('null', 'none', '~')
if kind in ('float', 'int'):
if blank:
return True, None, None
try:
value = float(raw) if kind == 'float' else int(raw)
except (TypeError, ValueError):
return False, None, '{0!r} is not a number.'.format(raw)
return True, value, None
if kind == 'bool':
low = raw.lower()
if low in ('true', 'yes', 'on', '1'):
return True, True, None
if low in ('false', 'no', 'off', '0'):
return True, False, None
return False, None, '{0!r} is not true or false.'.format(raw)
if kind == 'choice':
if blank:
return True, None, None
if choices and raw not in choices:
return False, None, '{0!r} is not one of: {1}.'.format(
raw, ', '.join(choices))
return True, raw, None
if kind == 'list':
entries = [part.strip() for part in re.split(r'[,\n]', raw)]
return True, [e for e in entries if e], None
if kind == 'map':
# ONE ENTRY PER LINE, `key = value` or `key: value`. A map is typed
# rather than picked because its keys are not knowable in advance
# (`checks.muted` is keyed by checklist number), and it is split on
# the FIRST separator only so a value may contain one -- the muting
# reason is a sentence, and a sentence contains colons.
if blank:
return True, {}, None
out = {}
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
eq, colon = line.find('='), line.find(':')
cuts = [i for i in (eq, colon) if i > 0]
if not cuts:
return False, None, (
'{0!r} is not `key = value`. One entry per line; the '
'value is REQUIRED -- a key with nothing after it is '
'refused rather than stored blank.'.format(line))
cut = min(cuts)
key, value = line[:cut].strip(), line[cut + 1:].strip()
if not key or not value:
return False, None, (
'{0!r} has no {1}. Both halves are required.'
.format(line, 'key' if not key else 'value'))
out[key] = value
return True, out, None
if blank and kind == 'path':
return True, None, None
return True, raw, None
def to_stored(key, value):
"""The value as the tree really holds it (fast/precise -> boolean)."""
if key in BOOL_AS_CHOICE and isinstance(value, string_types):
yes, _no = BOOL_AS_CHOICE[key]
return value == yes
return value
# ---------------------------------------------------------------- validation
def validate(key, value, know=None):
"""Everything wrong with this value, as sentences. Empty means fine.
Blocking rules only. A warning that is not a refusal belongs in
`warnings()`, because the two read the same on screen and mean opposite
things.
"""
out = []
know = know or {}
def positive(name):
if value is None:
return
try:
number = float(value)
except (TypeError, ValueError):
out.append('{0} must be a number.'.format(name))
return
if number <= 0:
out.append('{0} must be greater than zero; got {1}.'
.format(name, value))
if key == 'handling.rig_mode':
if value not in RIG_MODES:
out.append('rig_mode can only be {0}. A misspelled value is '
'REFUSED, never silently inherited.'
.format(' or '.join(RIG_MODES)))
elif key in BOOL_AS_CHOICE:
if not isinstance(value, bool):
out.append('{0} can only be {1}.'.format(
key, ' or '.join(BOOL_AS_CHOICE[key])))
elif key == 'handling.group_tolerance_ft':
positive('group_tolerance_ft')
if value is None:
out.append('group_tolerance_ft cannot be blank; a zero is '
'silently ignored by the merge, which is worse.')
elif key == 'handling.tension_limit_sqrt_fc':
# Zero or negative is the only REFUSAL here, and it is refused
# because it is not an opinion: a zero allowable fails every piece
# at any stress and a negative one inverts the comparison so that
# every piece passes. Everything else -- 3, 5, 7.5, 9 -- is a
# position somebody can hold, and `warnings()` argues with the ones
# worth arguing with instead of blocking them. The owner left this
# choice to the engineer on purpose ("I can see it both ways",
# 2026-08-30), so the validator does not get to have a doctrine.
positive('tension_limit_sqrt_fc')
elif key == 'handling.compression_limit_ratio':
# The exact mirror of tension_limit_sqrt_fc above: zero or negative
# is refused because it is not an opinion (zero fails every piece in
# compression at any stress; negative inverts the comparison so
# every piece passes), and 0.6 is the shipped default rather than a
# doctrine, so any other positive value is an engineer's choice this
# validator does not get to second-guess.
positive('compression_limit_ratio')
elif key == 'handling.sling_angle_deg':
positive('sling_angle_deg')
try:
if value is not None and float(value) > 90.0:
out.append('sling_angle_deg must be in (0, 90].')
except (TypeError, ValueError):
pass
elif key == 'handling.prestress_loss_pct':
try:
number = float(value)
except (TypeError, ValueError):
out.append('prestress_loss_pct must be a number of per cent.')
else:
if not 0.0 <= number < 100.0:
out.append('prestress_loss_pct must be at least 0 and less '
'than 100; got {0}.'.format(value))
elif key == 'handling.product_type':
if value not in PRODUCT_TYPES:
out.append('product_type must be one of: {0}.'
.format(', '.join(PRODUCT_TYPES)))
elif key == 'handling.finish':
if value not in FINISHES:
out.append('finish must be one of: {0}.'.format(
', '.join(FINISHES)))
elif key == 'handling.dunnage.mode':
if value not in (None, 'lifter_rows', 'fraction'):
out.append('dunnage.mode can only be lifter_rows, fraction, or '
'blank.')
if value == 'fraction':
fraction = _get(know, 'handling.dunnage.fraction')
if fraction is MISSING or not _in_range(fraction, 0.0, 0.5):
out.append('dunnage.mode is fraction, so '
'handling.dunnage.fraction must be set to a number '
'between 0 and 0.5. Set it first.')
elif key == 'handling.dunnage.fraction':
if value is not None and not _in_range(value, 0.0, 0.5):
out.append('dunnage.fraction must be between 0 and 0.5 '
'(exclusive); got {0!r}.'.format(value))
elif key == 'handling.weight_tolerance':
positive('weight_tolerance')
elif key == 'families.lifter_include':
if not value:
out.append('families.lifter_include cannot be empty: nothing '
'would be a lifting insert and the check refuses to '
'run.')
elif key == 'handling.stages':
out.extend(_validate_stages(value, know))
elif key == 'checks.muted':
out.extend(_validate_muted(value))
elif key in ('checks.not_applicable', 'checks.only_applies_to'):
out.extend(_validate_applicability(key, value, know))
elif key == 'knowledge_sources.plant_file':
out.extend(_validate_knowledge_source(
value, 'the WILLTOOLS_KNOWLEDGE environment variable / this '
"job's job.plant / the tool's own default"))
elif key == 'knowledge_sources.public_file':
out.extend(_validate_knowledge_source(
value, "the tool's shipped knowledge/catalogue.yaml"))
elif key == 'job.plant':
out.extend(_validate_job_plant(value))
return out
def _validate_job_plant(value):
"""`job.plant` -- blank (auto-detect) or a plant folder that is really
there, and never anything else.
The window's own dropdown (`configform.plant_options()`) only ever offers
those two shapes, so this fires only if something else wrote the job
file by hand -- same discipline as `_validate_knowledge_source`: a
pointer to a plant that is not there must never be written quietly,
because the read side (`knowledge.detect_plant` / `plant_choice`) would
then fall back to the machine-wide file and look completely normal.
"""
out = []
if value is None:
return out
from vibe import plantpath
if not plantpath.valid_plant_id(value):
out.append(
'job.plant must be a plant folder name (letters, digits, '
'. _ - only, starting with a letter or digit), or blank for '
'(auto-detect). {0!r} is not a usable folder name.'
.format(value))
return out
if value not in plantpath.plant_ids():
out.append(
'{0!r} is not a plant folder under {1} right now. A pointer to '
'a plant that is not there must never be written quietly -- '
'pick one that exists, or clear the box to (auto-detect).'
.format(value, plantpath.plant_root()))
return out
def _validate_knowledge_source(value, fallback):
"""A chosen knowledge-layer file must exist, or must not be written.
Same discipline `knowledge._config_missing` / `_plant_missing` apply
inside `knowledge.load()`: a pointer to a file that is gone must never
be written quietly, because the run that reads it later would use
whatever the layer's normal discovery falls back to and look completely
normal. Blank is not refused here -- blank means "go back to the normal
discovery", a real and distinct choice, not a typo.
"""
out = []
if value and not os.path.isfile(value):
out.append(
'{0!r} does not exist. A pointer to a file that is gone must '
'never be written quietly -- pick a file that is really there, '
'or clear the box to go back to {1}.'.format(value, fallback))
return out
def _validate_applicability(key, value, know=None):
"""`checks.not_applicable` / `checks.only_applies_to` -- the shape.
Every refusal here is something that would otherwise switch a safety
check off, or fail to, without anybody being able to see which. The
resolver (`knowledge.check_not_applicable`) refuses the same things at
run time and prints the refusal on the report -- belt and braces on
purpose, because a knowledge file may be edited by hand and never come
through this screen, which is exactly how `checks.muted` is guarded.
"""
out = []
if value in (None, {}, ''):
return out
if not isinstance(value, dict):
return ['{0} must be a map of checklist number to a map of panel '
'type to the declaration. A bare list carries no reason, no '
'date, and cannot deep-merge -- a job adding one entry would '
'DELETE the plant\'s whole table.'.format(key)]
try:
from vibe.ticketpage import CHECKLIST_ITEMS
known = set([item[0] for item in CHECKLIST_ITEMS])
except Exception:
known = None
try:
from vibe import knowledge as K
types = set([str(t) for t in K.panel_types(know or {})])
problem = (K._declaration_problem if key == 'checks.not_applicable'
else K._scope_problem)
except Exception:
types, problem = set(), None
for item in sorted(value):
if known is not None and str(item).strip() not in known:
out.append('{0}[{1}] is not a ticket-checklist item, so it would '
'switch nothing off -- and nothing is exactly what it '
'would look like it did. The items are {2}.'
.format(key, item, ', '.join(sorted(known))))
branch = value[item]
if not isinstance(branch, dict):
out.append('{0}[{1}] must be a map keyed by panel type (or `any` '
'for every piece this plant makes); {2!r} is not one.'
.format(key, item, branch))
continue
for condition in sorted(branch):
name = str(condition).strip()
if types and name != 'any' and name not in types:
out.append('{0}[{1}][{2}] names no declared panel type '
'(panel_types.types has {3}), so nothing would '
'ever match it.'
.format(key, item, condition, ', '.join(
sorted(types)) or 'none'))
if problem is not None:
said = problem(item, name, branch[condition])
if said:
out.append(said)
return out
def _validate_muted(value):
"""`checks.muted` -- a map of checklist number to a MANDATORY reason.
Three refusals, and each one is a thing that would otherwise fail
silently on a report somebody signs:
* NOT A MAP. A list of numbers would not deep-merge across the
knowledge layers, and would carry no reason.
* AN EMPTY REASON. The reason is the whole justification for a
feature that quietens rows on a checklist. A row that went quiet
with no sentence attached cannot be told apart from a bug, and the
person reading the report six months from now is not the person
who muted it. (Muting no longer stops the check -- see
`configform.MUTED_STATE` -- which makes the reason MORE important,
not less: the row is on the page saying something muted, and the
only thing that can explain why is the sentence.)
* A NUMBER THAT IS NOT A CHECKLIST ITEM. `3.9` mutes nothing, and
nothing is what it looks like it did. The report also flags this
(ticketpage.apply_muting), because a job file may be edited by
hand and never come through here -- belt and braces on purpose.
"""
out = []
if value in (None, {}, ''):
return out
if not isinstance(value, dict):
return ['checks.muted must be a map of checklist number to the '
'reason it is muted, e.g. `3.3 = no piece on this job has '
'one`. A bare list carries no reason and cannot merge.']
try:
from vibe.ticketpage import CHECKLIST_ITEMS
known = set([item[0] for item in CHECKLIST_ITEMS])
except Exception:
known = None
for key in sorted(value):
reason = value[key]
text = '' if reason is None else str(reason).strip()
if not text:
out.append('checks.muted[{0}] has no reason. A reason is '
'REQUIRED -- muting removes a row from the checklist, '
'and a row that disappeared for no stated reason '
'reads as a bug.'.format(key))
if known is not None and str(key).strip() not in known:
out.append('checks.muted[{0}] is not a ticket-checklist item, so '
'it would mute nothing. The items are {1}.'
.format(key, ', '.join(sorted(known))))
return out
def _in_range(value, low, high):
try:
number = float(value)
except (TypeError, ValueError):
return False
return low < number < high
def _validate_stages(stages, know):
out = []
if not isinstance(stages, list) or not stages:
return ['a job needs at least one stage.']
enabled = [s for s in stages if s.get('enabled', True)]
if not enabled:
out.append('a job needs at least one stage enabled.')
groups = _get(know, 'handling.lifter_groups')
known_groups = sorted(groups) if isinstance(groups, dict) else []
for index, stage in enumerate(stages):
where = stage.get('name') or 'stage {0}'.format(index + 1)
if stage.get('multiplier') is None and \
stage.get('pci_case') not in PCI_CASES:
out.append('{0}: pci_case must be one of {1} whenever the '
'multiplier is blank -- it is NOT guessed from the '
"stage's name.".format(where, ', '.join(PCI_CASES)))
if stage.get('pci_case') is not None and \
stage.get('pci_case') not in PCI_CASES:
out.append('{0}: pci_case {1!r} is not a PCI Table 8.3.1 row.'
.format(where, stage.get('pci_case')))
if stage.get('rig_mode') not in (None, '') and \
stage.get('rig_mode') not in RIG_MODES:
out.append('{0}: rig_mode {1!r} is neither {2} nor blank '
'(blank inherits the job default).'
.format(where, stage.get('rig_mode'),
' nor '.join(RIG_MODES)))
if stage.get('strength') not in ('fc', 'fci'):
out.append("{0}: strength must be fc or fci.".format(where))
for group in stage.get('groups') or []:
if known_groups and group not in known_groups:
out.append('{0}: insert group {1!r} is not one of the '
'handling.lifter_groups keys ({2}), so the stage '
'would pick nothing.'
.format(where, group, ', '.join(known_groups)))
return out
def warnings(key, value, know=None):
"""Things worth saying that are not refusals."""
out = []
know = know or {}
if key == 'handling.weight_tolerance' and _in_range(value, 0.05, 1e9):
out.append('A weight tolerance above 5% is wide enough to let a '
'misread blockout through. Say why in the note.')
# The stress limit: warned about, never refused. The two values people
# argue over are 5.0 and 7.5 and both are defensible, so the only thing
# to say is what the number in the box means -- and to say it loudly
# enough that a typo does not become a doctrine.
if key == 'handling.tension_limit_sqrt_fc':
if _in_range(value, 7.4, 7.6):
out.append('7.5 is the rupture modulus itself -- first cracking '
'with no margin. It is PCI-permitted and it is what '
'the commercial check of these panels used; it is '
'also 1.5x more permissive than the 5.0 default, so '
'every stress verdict moves with it.')
elif _in_range(value, 7.6, 1e9):
out.append('Above 7.5*sqrt(f\'c) is past the modulus of rupture '
'itself: the section is being credited with tension '
'beyond where plain concrete cracks. Nothing here '
'refuses it, but no reference in this tool supports '
'it -- say why in the note.')
elif _in_range(value, 0.0, 3.0):
out.append('Below 3*sqrt(f\'c) is far more conservative than the '
'5.0 default (rupture over a 1.5 factor), and pieces '
'that handle fine in the yard will read as failures. '
'That may be exactly what is wanted -- say so in the '
'note, because the report will not.')
return out
# ----------------------------------------------------------------- the write
def note_lines(key, res, reason, who='Project Config', when=None):
"""The comment written directly above an edited value.
`knowledge.py`'s docstring states the rule the code does not implement --
"an override should have to declare itself" -- and this is the only place
that knows an override is being created at the moment it is created. The
note is a YAML comment, so nothing downstream has to parse it and a
hand-edited file without one keeps working.
"""
when = when or datetime.datetime.now().strftime('%Y-%m-%d')
out = []
if merges(key):
# A merged map is not an override, and saying "override: plant says 2
# keys" over one would be plainly untrue: the entries written here ADD
# to the entries the other layers set, and removing one from this file
# does not remove theirs. The comment has to say which of the two it
# is, because the file is the only thing left six months later.
out.append('# merged: the layers merge this map entry by entry. What '
'follows is THIS file\'s entries only -- entries set by '
'another layer are untouched and still in force.')
if res and res.get('known') and res['layer'] not in ('job', None):
out.append('# merged: {0} sets entries here too; removing one from '
'this file leaves that layer\'s standing.'
.format(res['layer']))
elif res and res.get('known'):
if res['layer'] == 'job':
out.append('# was: {0}'.format(display_value(key, res['value'])))
else:
out.append('# override: {0} says {1}'.format(
res['layer'], display_value(key, res['value'])))
for label, value in res.get('overrides') or []:
if label != res['layer']:
# Prefixed `override:` so `_drop_note` recognises it. An
# unrecognised line stops the upward walk, and every re-edit
# then stacks another block of notes on the last -- leaving
# the OLDEST, which is the wrong one, readable at the top.
out.append('# override: ({0} said {1})'.format(
label, display_value(key, value)))
else:
out.append('# not set in any layer before this')
out.append('# {0} -- set here by {1}, {2}'.format(
reason or '(no reason given)', who, when))
return out
def _indent_of(line):
return len(line) - len(line.lstrip(' '))
def _is_key_line(text, name):
stripped = text.strip()
if stripped.startswith('#') or not stripped:
return False
for form in (name + ':', "'" + name + "':"):
if stripped == form or stripped.startswith(form + ' '):
return True
return False
def _find(lines, start, end, level, name):
"""(line index, block end) of `name:` at nesting `level` in [start, end).
`level` counts nesting, not spaces: `emit()` writes two spaces per level,
so level 1 is column 2. Comparing the two directly is a bug that reads as
"the key is not there" and quietly appends a SECOND copy of it.
"""
idx = start
while idx < end:
line = lines[idx]
if line.strip() and not line.strip().startswith('#') \
and _indent_of(line) == level * 2 and _is_key_line(line, name):
return idx, _block_end(lines, idx + 1, end, level)
idx += 1
return None
def _block_end(lines, start, end, level):
"""Where the block that began above ends -- comments left with the NEXT key.
A comment sitting immediately before a key describes that key, so it must
stay attached to it rather than being swallowed by the block above.
"""
idx = start
last = start
while idx < end:
line = lines[idx]
if line.strip() and not line.strip().startswith('#'):
if _indent_of(line) <= level * 2:
break
last = idx + 1
idx += 1
return last
def splice(text, dotted, value, note=None):
"""One setting written into a YAML file, and nothing else touched.
Not a re-emit of the parsed tree. A real job knowledge file is over a
thousand lines and most of them are comments explaining why a value is
what it is -- rewriting the document to change one key would throw all of
that away, which is a worse loss than the edit is a gain.
So this is a line editor for the shape `yamlkit.emit` writes: find the
key's own line and the block under it, replace exactly that, and leave
every other byte alone. A key that is not there yet is inserted at the end
of its parent's block, and a parent that is not there is appended.
"""
lines = text.split('\n')
parts = dotted.split('.')
note = list(note or [])
def noted(depth):
pad = ' ' * depth
return [pad + line for line in note]
start, end, level = 0, len(lines), 0
for depth, part in enumerate(parts):
found = _find(lines, start, end, level, part)
if found is None:
# Everything from here down has to be created.
tree = value
for name in reversed(parts[depth:]):
tree = {name: tree}
body = noted(level) + yamlkit.emit(tree, level)
at = _block_end(lines, start, end, level - 1) if depth else \
_top(lines)
return '\n'.join(lines[:at] + [''] + body + lines[at:])
idx, block_end = found
if depth == len(parts) - 1:
body = (noted(level) + _kept(lines, idx + 1, block_end, level) +
yamlkit.emit({part: value}, level))
head = _drop_note(lines, idx)
return '\n'.join(lines[:head] + body + lines[block_end:])
start, end, level = idx + 1, block_end, level + 1
raise ValueError('empty key')
def _top(lines):
"""Where a brand-new top-level section goes: under the file's header.
Not the end of the file. A knowledge file's last section is often the
longest, so a key appended after it is both the hardest to find and the
most exposed to whatever the reader made of the block above it -- one real
job file ends in a machine-written details dump that reads back
differently, and a section appended below it did not survive the round
trip. Under the header comment it is neither.
"""
at = 0
while at < len(lines) and (not lines[at].strip()
or lines[at].lstrip().startswith('#')):
at += 1
while at > 0 and not lines[at - 1].strip():
at -= 1
return at
def _kept(lines, start, end, level):
"""Full-line comments from inside a block being replaced.
A value's comment is usually the argument for the value -- why the
tolerance is what it is, which job broke the plant's rule -- and the
replacement is exactly the moment somebody would want to read it. So the
comments come back above the new value rather than going out with it.
"""
pad = ' ' * level
out = []
for idx in range(start, min(end, len(lines))):
text = lines[idx].strip()
if text.startswith('#'):
out.append(pad + text)
return out
# Every shape `note_lines` writes, and nothing else. The walk below is
# CONTIGUOUS -- one line it does not recognise stops it -- so a note shape
# missing from this pattern is not a cosmetic gap: it strands every note above
# it, and the stale ones stay on top where they read as current. `was:` and
# the `override: (X said Y)` continuation were both missing until 2026-08-30.
_NOTE = re.compile(
r'^\s*#\s*(override:|merged:|was:|not set in any layer before this'
r'|.* -- set here by )')
def _drop_note(lines, idx):
"""Where to start replacing: above a note this tool wrote last time.
Otherwise every edit of one key stacks another override comment on top of
the last, and the oldest -- the wrong one -- is the one that stays
readable at the top.
"""
at = idx
while at > 0 and _NOTE.match(lines[at - 1] or ''):
at -= 1
return at
def verify(before_text, after_text, dotted, value):
"""Did the splice do exactly what it said, and nothing else?
Cheap, and the whole reason a line editor is safe: parse both versions
with the same reader and insist that the only difference is the key that
was asked for. A splice that quietly ate the block below it fails here
rather than in a run three weeks later.
"""
try:
before = yamlkit.parse(before_text) or {}
after = yamlkit.parse(after_text) or {}
except Exception as exc: # pylint: disable=broad-except
return ['the file could not be read back after the edit: {0}'
.format(exc)]
got = _get(after, dotted)
if got is MISSING or got != value:
return ['the edit did not take: {0} reads back as {1!r}, not {2!r}.'
.format(dotted, None if got is MISSING else got, value)]
stripped_before = _without(before, dotted)
stripped_after = _without(after, dotted)
if stripped_before != stripped_after:
return ['the edit changed something other than {0}. Nothing has been '
'written.'.format(dotted)]
return []
def _without(tree, dotted):
"""A copy of the tree with one dotted key removed.
An empty parent left behind by the removal is dropped too, so a key that
the splice CREATED compares equal to a tree that never had it.
"""
parts = dotted.split('.')
if not isinstance(tree, dict):
return tree
out = {}
for key in tree:
if key != parts[0]:
out[key] = tree[key]
elif len(parts) > 1:
inner = _without(tree[key], '.'.join(parts[1:]))
if inner != {}:
out[key] = inner
return out
def _paths_of(node, prefix=''):
"""Every dotted leaf path in a parsed tree."""
if isinstance(node, dict) and node:
out = []
for key in node:
out.extend(_paths_of(node[key],
prefix + ('.' if prefix else '') + str(key)))
return out
return [prefix] if prefix else []
_OPEN_FLOW = re.compile(r':\s*\[[^\]]*$')
def wrapped_flow_lines(text):
"""Line numbers where a `[ ... ]` sequence opens and does not close.
A flow sequence spread over several lines is valid YAML and `yamlkit`
cannot read one -- it expects the whole `[...]` on the line. `emit()`
never writes one (`is_flat` keeps a flow list to 66 characters on one
line), so it can only arrive by hand, which is exactly the case
`reader_disagreement` was written for.
Reported as the LIKELY cause and never as the diagnosis: this is a scan
for one shape, and a file can stop being readable for reasons this does
not know about. Naming a line somebody can go and look at beats "the two
readers disagree" by more than the risk of naming the wrong one.
"""
out = []
for number, line in enumerate((text or '').split('\n'), 1):
body = line.split('#', 1)[0] if line.lstrip()[:1] != '#' else ''
if _OPEN_FLOW.search(body):
out.append(number)
return out
def reader_coverage(theirs, ours, text=None):
"""Sentences when one reader can see LESS OF THE FILE than the other.
THE DEFECT THIS EXISTS FOR, and it cost a real press. The owner tried to
set `snapshot.detached_dir` from the window and was told "the two YAML
readers disagree about snapshot.detached_dir. This file has to be edited
by hand." Both halves of that sentence were true and neither was useful:
there is nothing wrong with `snapshot.detached_dir`, and editing it by
hand would not have helped either.
What had actually happened is that `yamlkit.parse` STOPPED READING THE
FILE at line 461 of 1652 -- on a `parts: [P76, P89,` flow sequence that
wraps onto the next line -- and `snapshot`, `takeoff` and `catalogue`, the
last three sections, were simply not in its tree. The per-key check then
fired on whichever key was being written, which made a whole-file failure
look like a problem with one setting. Every key past the cut would have
produced the same message and sent the reader to the same wrong place.
So this is asked FIRST, and it asks the question the other check cannot:
not "do the two readers agree about this key" but "is one of them reading
the whole file at all". A splice driven by a reader that stops two thirds
of the way down is not safe at any key, including the ones above the cut:
`verify` re-parses with the same truncated reader and would compare two
truncated trees and find them identical.
"""
lost = sorted(set(_paths_of(theirs or {})) - set(_paths_of(ours or {})))
if not lost:
return []
out = ['The config editor\'s YAML reader cannot see all of this file. '
'{0} setting(s) that are really in it are invisible to it, '
'starting at {1}.'.format(len(lost), lost[0])]
tops = []
for path in lost:
head = path.split('.')[0]
if head not in tops:
tops.append(head)
out.append('Whole sections it cannot reach: {0}.'.format(
', '.join(tops[:8])))
wrapped = wrapped_flow_lines(text) if text else []
if wrapped:
out.append('The likely cause is a `[ ... ]` list written across more '
'than one line, which this reader cannot follow: line {0}. '
'Putting that list on ONE line, or writing it as a `- ` '
'item per line, makes the whole file readable again.'
.format(', '.join(str(n) for n in wrapped[:5])))
else:
out.append('Nothing in the file matches the one shape this knows how '
'to name, so the cause has to be found by hand: the last '
'setting the editor CAN see is where to start looking.')
out.append('NOTHING HAS BEEN WRITTEN. Splicing a file the editor reads '
'only part of is not safe at any key -- the verify step reads '
'it back with the same reader and would not notice.')
return out
def reader_disagreement(theirs, ours, dotted):
"""A sentence when the two YAML readers do not agree about one key.
Inside Revit the file is really read by `pyrevit.coreutils.yaml`; the
splice above is driven by `yamlkit.parse`. They agree on everything
`emit()` writes, and a hand-written file can contain something only one of
them understands. Where they disagree, this refuses the write: splicing a
file the real reader reads differently is how an edit lands somewhere
nobody looked.
"""
mine = _get(ours or {}, dotted)
yours = _get(theirs or {}, dotted)
if (mine is MISSING) != (yours is MISSING):
return ('The two YAML readers disagree about {0}: one finds it, the '
'other does not. This file has to be edited by hand.'
.format(dotted))
if mine is MISSING:
return None
if text_type(mine) != text_type(yours) and mine != yours:
return ('The two YAML readers disagree about {0}: {1!r} against '
'{2!r}. This file has to be edited by hand.'
.format(dotted, mine, yours))
return None
# ------------------------------------------------- run-time options, no layer
#
# Not knowledge, and deliberately not in the knowledge tree. Plant knowledge
# records what is true of a plant. Whether THIS RUN is willing to spend a
# minute a piece solving geometry is a property of the run, and filing it with
# the plant's dimensions would have mixed two kinds of fact in one file --
# with the layer machinery and the provenance chips attached to something that
# has neither.
#
# They live in one flat file beside the model, they have no layers, no
# provenance and no override chain, and the UI must show them somewhere the
# layered settings are not.
RUN_OPTIONS = (
{'key': 'expensive_geometry', 'default': True,
'label': 'Read the concrete solid on every ticket',
'note': 'Geometry is solved per distinct piece and the first read of an '
'untouched one takes 30-60 seconds. Default ON: a check that is '
'off by default is a check nobody runs, and one that is off '
'forever prints NOT RUN on every ticket until people stop '
'reading the row.'},
# OFF by default, and the difference from expensive_geometry above is
# deliberate. Reading a concrete solid spends seconds on a machine nobody
# is billing. A model call spends money and leaves the building, and this
# model has 1105 sheets. Off prints NOT RUN with a reason on the escalated
# rows, which is honest and free; on is a choice somebody made.
{'key': 'vision_escalation', 'default': False,
'label': 'Let a small model read the drawing where code cannot',
'note': 'Checks that no comparison can settle (5.5, bar marks legible '
'and unique) render one view and ask a small vision model about '
'it. Its answer is NEVER a pass and NEVER a fail: the checklist '
'row stays NOT RUN and a WARN says a person must look. Costs a '
'model call per view and needs an ANTHROPIC_API_KEY in the '
'environment. Default OFF -- an escalation nobody asked for is '
'a bill nobody expected.'},
# ON by default, and for the same reason expensive_geometry is: the
# recommendation is the point of pressing the button. Somebody who has
# selected one piece, isolated it, clicked its inserts stage by stage and
# waited wants to be told where the inserts should have gone -- a check
# that only says FAIL leaves them with the question they came with.
#
# It is here at all because the search is 93% of the run: 12.6 s of a
# 13.6 s median, and 49 s on the worst piece of the reference sweep. An
# engineer who is re-running a piece to see whether a fix passes, twenty
# times in an afternoon, is paying that for an answer they already have.
#
# It governs the SINGLE-PIECE check only. A batch never searches -- 184
# pieces is 41 minutes of searching and it is what made the batch
# unusable -- and its reports say so on the page rather than leaving the
# section blank.
{'key': 'recommend_positions', 'default': False,
'label': 'Search for better insert positions (single piece)',
# NO CHINESE IN A `note`. Every one of these is drawn on screen, under
# its control, in the Project Config window -- `configform.run_fields()`
# copies it straight through -- so a note is UI text, not a comment. The
# owner's ruling is quoted here in English for exactly that reason; the
# verbatim Chinese belongs in a `#` comment, which nothing renders.
# (He found the untranslated version in the window: "另外 UI 里面不要有
# 中文。啥玩意儿啊这是?", 2026-08-30.)
'note': 'Where the inserts SHOULD go, as well as whether the ones drawn '
'pass. It is 93% of the run -- typically 13 s a piece, up to 50 '
'-- so the default is OFF: the owner re-checks pieces far more '
'often than he asks where inserts should have gone (ruled '
'2026-08-30, "searching insert location is still very slow"). '
'Switch it on for the one piece that wants the answer. The '
'report always says when positions were not computed. A BATCH '
'never runs it whatever this says.'},
{'key': 'vision_escalation_max_calls', 'default': 25,
'label': 'Most model calls one Ticket QC run may make',
'note': 'A hard bound on the run, not a rate limit. Escalations past it '
'report NOT RUN naming the bound -- a dropped one would read as '
'a row that passed. 0 means none.'},
)
RUN_OPTIONS_HEADER = u"""# WillTools run-time options -- NOT knowledge.
#
# Nothing in here describes the plant or the job. These are choices about what
# a RUN is willing to spend: they have no layers, nothing overrides them, and
# they are not merged with anything. Delete the file and every option goes
# back to its default, which is the value the tool ships with.
#
# Written by Project Config.
"""
RUN_OPTIONS_FILENAME = 'run-options.yaml'
def run_options_path(root_dir):
"""Beside the model's QC data, not in the knowledge tree.
Takes the directory rather than a `doc` so that the one caller who has a
live document passes what it already has, and everything else -- tests
included -- can reach the same file without Revit.
"""
return os.path.join(root_dir, RUN_OPTIONS_FILENAME)
def load_run_options(root_dir):
"""Every option as this run should see it. Missing file = all defaults."""
path = run_options_path(root_dir)
if not os.path.isfile(path):
return default_run_options()
try:
with io.open(path, 'r', encoding='utf-8') as fh:
return read_run_options(fh.read())
except Exception: # pylint: disable=broad-except
# An unreadable options file must not stop a run: nothing in it is
# knowledge, and every value has a default that is the shipped
# behaviour.
return default_run_options()
def save_run_options(root_dir, options):
path = run_options_path(root_dir)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(render_run_options(options))
return path
def default_run_options():
return dict((row['key'], row['default']) for row in RUN_OPTIONS)
def run_option(key):
for row in RUN_OPTIONS:
if row['key'] == key:
return row
return None
def read_run_options(text):
"""Defaults, with whatever the file says on top. Unknown keys are kept.
A key nobody recognises is left in the returned dict rather than dropped,
so an option written by a newer build survives a run by an older one.
"""
out = default_run_options()
if not text:
return out
try:
data = yamlkit.parse(text)
except Exception: # pylint: disable=broad-except
return out
if isinstance(data, dict):
for key in data:
out[key] = data[key]
return out
def render_run_options(options):
lines = [RUN_OPTIONS_HEADER]
for row in RUN_OPTIONS:
lines.append(u'')
for part in row['note'].split('\n'):
lines.append(u'# ' + part)
lines.append(u'\n'.join(
yamlkit.emit({row['key']: options.get(row['key'],
row['default'])})))
extra = sorted(k for k in options if not run_option(k))
if extra:
lines.append(u'\n# Written by a newer build; kept as found.')
for key in extra:
lines.append(u'\n'.join(yamlkit.emit({key: options[key]})))
return u'\n'.join(lines) + u'\n'