52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
558 lines
25 KiB
Python
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('|', '|')
|