8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1632 lines
73 KiB
Python
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'
|