Files
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

558 lines
25 KiB
Python

# -*- coding: utf-8 -*-
"""The FULL VIEW of the configuration: everything, and where each thing is set.
The Project Config button is a window of four boxes of controls
(`vibe.configform`): the settings a person actually changes between one press
and the next, and nothing else. This is the other half -- the page behind its
"Full view" button. Every setting the job layer may carry, every layer file,
every read-only value with the sentence saying why it is read-only, and the
provenance chain of anything more than one file had an opinion about.
The two are deliberately not two answers to the same question. The window says
WHAT IS SET AND LETS YOU CHANGE IT; this says WHAT EXISTS AND WHERE IT IS
CHANGED, and the `change it in` column is the join: it names the box that
carries each setting, or says the file. That column comes from
`configform.placement()`, so the mapping is written once.
WHAT THIS FIXED, AND WHY IT IS STILL A PAGE
--------------------------------------------
The button used to be a `CommandSwitchWindow` menu, and it had three defects
in one press:
1. THE SENTENCE THAT EXPLAINS THE SCREEN WAS INSIDE THE SCREEN'S CHROME.
`CommandSwitchWindow`'s `message` is a WPF `Label` docked left in a
36-pixel row it shares with the search box, in a window fixed at 600 wide
with `ResizeMode="NoResize"`. A Label does not wrap, so the explanation
was cut off. Prose belongs where there is room for it: on this page, or in
the real window's wrapping header.
2. THE PAGE WAS PRINTED IN THE MIDDLE OF A DIALOG LOOP, so a modal popup came
straight back over it and dismissing that popup ended the run. Nothing is
printed now until every window is shut.
3. THE OPTIONS WERE NAMED BY THEIR DOTTED KEYS. Every row in
`configmodel.SETTINGS` already carries a `label` ("Report folder") and a
`section` ("Output"); the screen showed `handling.report_dir` instead.
Everything user-facing here is label first. The dotted key still appears
-- it is what somebody grepping the YAML needs -- but as reference small
print in its own column, never as the name of the thing.
THE SHAPE, WHICH IS THE TAB'S SHAPE
------------------------------------
`vibe.foldreport`: the answer is the headline and is never folded; everything
else starts closed; a section carrying a problem opens itself and says why in
a sentence. Here the answer is *every setting the job layer may carry, what it
reads now, who decided it, and where it is changed*, so that is the headline.
The three things that open a section by themselves are the three that mean
somebody is about to be surprised:
* a project config file is live (`!`), so the layers on screen are not what
the model reads;
* a setting no layer sets at all (`?`), which is not a value of nothing --
it means nothing here could judge it;
* a layer file that is not present, which is how a plant rule silently
stops applying.
NO REVIT AND NO `doc`. Everything Revit-shaped -- the layer list, the
locations, the run options -- is read by the button and passed in as data, so
the whole page can be built and asserted on host CPython
(`WillTools.extension/tests/test_configpage.py`).
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
from vibe import configmodel
from vibe import foldreport as fold
NOT_SET = '(not set in any layer)'
# Comma-separated rather than dot-separated on purpose: `foldreport.md_text`
# restores `—`, ` `, `†` and `&` and nothing else, so a
# `·` here would reach the plain-text copy of the page as its own
# source. Entities the text renderer does not know are not decoration that
# degrades, they are visible breakage.
LEGEND = ('> `D` defaults, `C` catalogue, `P` plant, `J` job, '
'`!` project config, `?` no layer sets it, `+` several layers each '
'set part of it (the layers MERGE that key, entry by entry). A `?` '
'is **not** a value of nothing: it means nothing here could judge '
'the setting.')
# ------------------------------------------------------------------ the rows
def value_text(key, res):
"""What one setting reads as, and never as a blank.
An unknown value gets a sentence rather than an empty cell, for the reason
`configmodel.resolve` gives: "judged and found nothing" and "could not
judge" are different answers and an empty cell says the first one.
"""
if not res or not res.get('known'):
return NOT_SET
return configmodel.display_value(key, res['value'])
def editable_rows(layers, settings=None):
"""Every setting the job layer may carry, in SETTINGS order.
`n` is a stable position, not an interface: the editing is done in the
four-module window (`vibe.configform`), and what this page adds is the
column saying WHICH BOX each setting is in -- or that it is in none of
them and is changed in the file.
`settings` overrides the source rows for tests. It defaults to
`configmodel.editable()`, which is whatever the loaded build of
`configmodel` knows about -- so a build that has not yet learned a setting
simply does not number one, rather than numbering a row it cannot edit.
"""
rows = configmodel.editable() if settings is None else list(settings)
out = []
for index, row in enumerate(rows):
res = configmodel.resolve(layers, row['key'])
chip = configmodel.chip(res)
value = value_text(row['key'], res)
if configmodel.merges(row['key']):
# A key the layers MERGE has no single winning layer, and
# `resolve` -- which answers for a key that replaces -- would name
# the last one that set an entry as though it owned all of them.
# `J` on a map the plant also writes is the same lie as a silent
# override, one level down.
merged = configmodel.merged_map(layers, row['key'])
chip = configmodel.map_chip(merged)
value = configmodel.format_merged_map(merged) if merged \
else NOT_SET
res = dict(res)
res['layer'] = ' + '.join(configmodel.map_layers(merged)) or None
out.append({'n': index + 1,
'key': row['key'],
'label': row['label'],
'section': row['section'],
'row': row,
'res': res,
'chip': chip,
'value': value})
return out
def group_rows(rows):
"""[(section, [row])] in the order the sections first appear."""
order = []
holder = {}
for row in rows or []:
if row['section'] not in holder:
holder[row['section']] = []
order.append(row['section'])
holder[row['section']].append(row)
return [(name, holder[name]) for name in order]
def unknown_rows(rows):
"""The settings nothing in any layer decides."""
return [row for row in rows or [] if not row['res']['known']]
def config_rows(rows):
"""The settings a live project config file is deciding, over the layers."""
return [row for row in rows or [] if row['chip'] == '!']
def contested_rows(rows):
"""The settings more than one layer had an opinion about.
The full chain of a setting exactly one file mentions is one line long and
it is already on the page. These are the ones where reading the chain
tells the reader something the headline could not.
"""
return [row for row in rows or []
if len(row['res']['chain']) > 1 or row['res']['overrides']]
# -------------------------------------------------------------- the whole page
def render(title=None, layers=None, rows=None, problems=None,
job_path=None, config_path=None, config_missing=False,
env=None, run_options=None, run_options_rows=None,
run_options_path=None, locations=None, report_dir=None,
subtrees=None, muted=None, muted_note=None, muted_rows=None,
notes=None, full_path=None, placement=None):
"""The page as `foldreport.Section`s: headline first, the rest folded.
Everything is data. `layers` is `configdump.layer_values(doc,
include_config=True)`; `subtrees` is [(name, note, [(key, value, layer)])];
`locations` is `configdump.output_locations()`; `notes` are extra sentences
for the headline (a build that could not show something says so there).
`placement` is {key: where a person changes it} -- the four-module
window's boxes. A key it does not name is changed in the file, and the
page says so rather than leaving the column blank: a blank there would
read as "nowhere", and everything in the job layer is changeable
somewhere.
"""
layers = list(layers or [])
rows = editable_rows(layers) if rows is None else rows
sections = [_headline(title, rows, problems, job_path, config_path,
config_missing, notes, full_path, placement)]
missing_layers = [label for label, _p, exists, _d in layers if not exists]
unknown = unknown_rows(rows)
if unknown:
sections.append(_unknown(unknown))
if config_path:
sections.append(_config(config_path, config_missing,
config_rows(rows)))
sections.append(_read_only(layers))
if muted is not None or muted_note or muted_rows:
sections.append(_muted(muted, muted_note, muted_rows))
sections.append(_run_options(run_options, run_options_rows,
run_options_path))
sections.extend(_subtrees(subtrees))
sections.append(_layers(layers, missing_layers, env, job_path))
sections.append(_output(locations, report_dir))
sections.append(_chains(contested_rows(rows)))
sections.append(_job_file())
return sections
def _headline(title, rows, problems, job_path, config_path, config_missing,
notes, full_path, placement=None):
section = fold.Section('now', 'What this job is set to', headline=True)
section.md('# Configuration of {0}'.format(title or 'this model'))
section.md('Every setting the job layer may carry, what it reads now, '
'and which layer decided it. The **change it in** column says '
'where: one of the four boxes in the Project Config window, or '
'the file itself. Everything is written to the job layer and '
'only the job layer: `{0}`'
.format(job_path or '(not created yet)'))
section.md('Everything else — what is read-only and why, where each '
'file lives, the whole provenance chain, the run-time options '
'— is folded below. **If nothing below is open, nothing '
'below needs you.**')
for line in notes or []:
section.md('> ' + line)
for line in problems or []:
section.md('**{0}**'.format(line))
if config_path:
section.md(
'> ## This model runs on a project config file\n>\n> `{0}`{1}\n>\n'
'> It is the last word. **An edit made here does not reach this '
'model** until that file is refreshed from the layers or cleared.'
.format(config_path, ' **(MISSING)**' if config_missing else ''))
for name, group in group_rows(rows):
section.md('### ' + name)
section.table(['setting', 'in force', 'from', 'change it in',
'key'],
[[_cell(row['label']), _cell(row['value']),
'[{0}] {1}'.format(row['chip'],
row['res']['layer'] or 'nothing'),
_where(placement, row['key']),
'`{0}`'.format(row['key'])]
for row in group])
section.md(LEGEND)
if full_path:
section.md('The whole page, nothing folded, is also written to `{0}` '
'on every press.'.format(full_path))
return section
def _unknown(unknown):
section = fold.Section(
'unknown', 'Settings no layer sets',
summary='{0} setting(s)'.format(len(unknown)),
problem='{0} setting(s) that nothing in any layer decides. That is '
'not a value of nothing -- whatever reads them falls back to '
'its own idea, and nothing here can tell you what that is.'
.format(len(unknown)))
section.table(['setting', 'key'],
[[_cell(row['label']), '`{0}`'.format(row['key'])]
for row in unknown])
section.md('Each one is still editable: in its box in the window, or in '
'the job file.')
return section
def _config(config_path, config_missing, overridden):
problem = ('A project config file is live, so the layers below are NOT '
'what this model reads.')
if config_missing:
problem = ('The project config file this model points at is MISSING. '
'Nothing is falling back quietly -- put it back, refresh '
'it, or go back to the layered files.')
section = fold.Section(
'config', 'The project config file, which is the last word',
summary='{0} setting(s) decided by it'.format(len(overridden)),
problem=problem)
section.md('`{0}`'.format(config_path))
if overridden:
section.table(['setting', 'in force', 'key'],
[[_cell(row['label']), _cell(row['value']),
'`{0}`'.format(row['key'])] for row in overridden])
else:
section.md('It sets none of the settings on this page, so every value '
'above still comes from the layer named beside it.')
section.md('Until it is refreshed from the layers or cleared, a '
'job-layer edit does not reach this model.')
return section
def _read_only(layers):
rows = []
whys = []
for row in configmodel.read_only():
res = configmodel.resolve(layers, row['key'])
rows.append([_cell(row['label']),
_cell(value_text(row['key'], res)),
'[{0}] {1}'.format(configmodel.chip(res),
res['layer'] or 'nothing'),
'`{0}`'.format(row['key'])])
whys.append('**{0}** — {1}'.format(row['label'],
row['why'] or ''))
section = fold.Section('readonly', 'Shown here, not editable here',
summary='{0} setting(s)'.format(len(rows)))
section.md('This button writes the job layer and nothing else. What a '
'plant\'s hardware is, and which part number is a face lifter, '
'is not a job fact: one plant edit reaches every job, so '
'editing that file stays a deliberate act somewhere else. '
'Every one of these is still shown, with the sentence saying '
'why it is not yours to change here.')
section.table(['setting', 'in force', 'from', 'key'], rows)
for line in whys:
section.md('> ' + line)
return section
WHERE_MUTES_ARE_SET = (
'Muting is configured in the **Ticket QC** box of the Project Config '
'window: it lists every row the checklist asks, with a speaker beside '
'each. Clicking one asks for the reason -- required, dated for you, '
'written to the job layer.')
WHAT_A_MUTE_DOES = (
'**Muting does not stop a check.** A muted row runs on every ticket '
'exactly as before and keeps its place on the table; what changes is how '
'it reports, quietly instead of as a red row, when the result is not a '
'failure. **A failure is never quietened** — it stays on the table, '
'in the tally, and in the machine-readable JSON, whatever this list says.')
def _muted(muted, muted_note, muted_rows=None):
"""What is muted, whose mute it is, and WHERE THE CONTROL IS.
That last sentence is on the page because its absence was the defect: the
owner went looking for the mute control twice and did not find it, and
this page -- the one surface that lists what is muted -- said nothing at
all about where muting is done. A page that shows a state and not the
thing that sets it sends the reader hunting.
`muted_rows` is `configform.mute_table(...)`: the same rows the window
draws, so the layer column here and the buttons there cannot disagree. It
is passed in rather than computed because it needs the ticket checklist
table, which this module has no other business loading. Without it the
section falls back to the flat map, which is what every older caller
hands over.
"""
muted = muted if isinstance(muted, dict) else {}
listed = [row for row in (muted_rows or []) if row.get('muted')]
theirs = [row for row in listed if not row.get('editable')]
stale = [row for row in listed if not row.get('known')]
problem = None
if muted:
problem = ('{0} checklist item(s) are muted on this job. Each one '
'still RUNS on every ticket and keeps its row -- muting '
'only makes a result that is not a failure report quietly '
'rather than red. It never quietens a FAIL, and its '
'findings still reach the JSON.'.format(len(muted)))
if stale:
problem = ('{0} muted number(s) are not checklist items at all, so '
'they mute NOTHING. Somebody believes a row is off while '
'it is on.'.format(len(stale)))
section = fold.Section('muted', 'Muted checklist items',
summary='{0} muted'.format(len(muted)),
problem=problem)
if listed:
section.table(['item', 'checklist row', 'why this job never has one',
'set by'],
[[_cell(row['number']), _cell(row['name']),
_cell(row['reason'] or '(no reason given)'),
_cell(row['layer'] or 'nothing')] for row in listed])
elif muted:
section.table(['item', 'why this job never has one'],
[[_cell(key), _cell(muted[key])]
for key in sorted(muted)])
else:
section.md('Nothing is muted on this job. Every checklist row reports '
'at full volume.')
section.md('> ' + WHAT_A_MUTE_DOES)
section.md('> ' + WHERE_MUTES_ARE_SET)
if theirs:
# Not editable there, and saying so HERE as well: this page is where
# somebody lands after failing to change one in the window, and a
# second silence would send them to the wrong file.
section.md('> **{0} of them are not the job layer\'s.** The knowledge '
'layers MERGE this map, so a job entry cannot take a mute '
'set higher up away -- {1}. Edit that layer\'s file to '
'unmute.'.format(
len(theirs),
', '.join('{0} ({1})'.format(row['number'], row['layer'])
for row in theirs)))
if muted_note:
section.md('> ' + muted_note)
return section
def _run_options(options, option_rows, path):
options = options or {}
option_rows = list(option_rows if option_rows is not None
else configmodel.RUN_OPTIONS)
section = fold.Section(
'runopts', 'Run-time options — not knowledge',
summary='{0} option(s)'.format(len(option_rows)))
section.md('> No layers, no provenance, nothing overrides them. They are '
'choices about what a run is willing to spend, which is a '
'property of the run and not of the plant. `{0}`'
.format(path or '(beside the model)'))
section.table(
['what it is', 'now', 'default', 'key'],
[[_cell(row['label']),
_cell('{0}'.format(options.get(row['key'], row['default']))),
_cell('{0}'.format(row['default'])), '`{0}`'.format(row['key'])]
for row in option_rows])
return section
def _subtrees(subtrees):
"""One section for the parameter map, one for everything else shown raw."""
out = []
rest = []
for name, note, leaves in subtrees or []:
if name == 'parameters':
section = fold.Section(
'params', 'Revit parameter names this tool reads',
summary='{0} role(s)'.format(len(leaves)),
problem=None if leaves else
'No parameter names are mapped, so the checks that read them '
'do not run and say nothing about it.')
section.md('> ' + note)
section.table(['role', 'parameter name read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
else:
rest.append((name, note, leaves))
if rest:
section = fold.Section(
'published', 'Published data and plant knowledge, read-only',
summary='{0} group(s)'.format(len(rest)))
for name, note, leaves in rest:
section.md('### ' + name)
section.md('> ' + note)
section.table(['key', 'value read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
return out
def _layers(layers, missing, env, job_path):
problem = None
if missing:
problem = ('{0} layer file(s) are not present: {1}. Whatever they say '
'is not being applied, and nothing else reports that.'
.format(len(missing), ', '.join(missing)))
section = fold.Section(
'layers', 'Where every layer lives',
summary='{0} layer(s)'.format(len(layers)), problem=problem)
section.md('Each one overrides the one above it.')
section.table(['layer', 'file', 'present'],
[[_cell(label), _cell(path or '(built into the tool)'),
'yes' if exists else 'MISSING']
for label, path, exists, _data in layers])
section.md('The job layer, which is the one this button writes: `{0}`'
.format(job_path or '(not created yet)'))
section.table(['environment variable', 'value'],
[[_cell(name), _cell(value)] for name, value in env or []])
return section
def _output(locations, report_dir):
locations = locations or {}
section = fold.Section('output', 'Where output goes',
summary='{0} place(s)'.format(len(locations)))
section.table(['what', 'where'],
[[_cell(key.replace('_', ' ')), _cell(locations[key])]
for key in sorted(locations)])
section.md('`{0}` is the report folder in force, and it is the one path '
'here that is a setting.'.format(report_dir))
return section
def _chains(rows):
section = fold.Section(
'chains', 'The full chain, where more than one layer had an opinion',
summary='{0} setting(s)'.format(len(rows)))
section.md('A setting exactly one file mentions is already answered on '
'the page above. These are the ones where a layer was beaten, '
'which is the case worth reading.')
for row in rows:
# No key line of its own: `chain_lines` leads with the dotted key and
# the chip, so printing one here would say it twice.
section.md('### ' + row['label'])
for line in configmodel.chain_lines(row['res']):
section.md(' ' + line)
if not rows:
section.md('Every setting comes from exactly one place.')
return section
def _job_file():
section = fold.Section('jobfile', 'What panellift is handed',
summary='8 field(s)')
section.md('> The job file (`*.job.json`) written beside every report is '
'not a second place to configure anything: it is generated '
'from the settings above on every run. These are the fields it '
'carries and where each one comes from, so the two halves of '
'"the job layer" read as one thing.')
section.table(['job file field', 'comes from'],
[['rigging.mode', 'handling.rig_mode'],
['rigging.sling_angle_deg', 'handling.sling_angle_deg'],
['rigging.group_tolerance_ft',
'handling.group_tolerance_ft'],
['stages[].rig_mode', 'handling.stages[].rig_mode'],
['stages[].multiplier / pci_case / strength',
'handling.stages[]'],
['stages[].lifters',
'clicked on the piece in Panel Lift Check'],
['rigging.tension_fos / shear_fos',
'catalogue.safety_factors.*'],
['panel.*', 'measured from the model']])
return section
def _where(placement, key):
"""Which of the window's boxes changes this setting, or the file.
Never blank. A blank cell in a column headed "change it in" reads as
"nowhere", and every setting on this page is changeable somewhere.
"""
if placement is None:
return ''
return placement.get(key) or 'the file'
def _cell(value):
"""One table cell. A pipe in a value would otherwise split the row.
`output.print_table` builds a markdown pipe table and hands it to the
markdown renderer, so a muting reason containing a `|` -- a sentence
somebody typed -- would silently become two columns and shift every cell
after it.
"""
text = '' if value is None else '{0}'.format(value)
return text.replace('|', '|')