Files
willtools-demo/WillTools.extension/lib/vibe/configdump.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

769 lines
33 KiB
Python

# -*- coding: utf-8 -*-
"""The whole configuration of one project, written to one file.
Knowledge arrives in five layers -- built-in defaults, the catalogue, the
plant, the job, and the project config file -- and every one of them but the
defaults lives somewhere nobody browses to: the tool's own install, APPDATA,
a folder beside the model, and wherever the engineer chose to put the config
file. That is the right way to store it and the wrong way to hand it to
somebody. A reviewer asking "what was this checked against?" should get one
file, not a tour of three machines.
So this flattens the layers into a single document that says, for every
setting, what the value is and which layer it came from. `layer_values()` is
where the five are enumerated, and it is also where the one subtlety lives:
the project config layer is left out of the provenance by default, for the
reason set out in that function's own docstring. The knowledge section
is written in the same shape a knowledge.yaml has, which means the file is not
only a record: save it as the job's knowledge file and it reproduces exactly
the configuration it was taken from.
Written for IronPython 2.7: no f-strings, no pathlib, no OrderedDict literals.
"""
# pylint: disable=import-error,invalid-name,broad-except
import datetime
import os
import re
from vibe import knowledge
from vibe.compat import string_types, text_type
# The order sections come out in -- the order somebody setting a project up
# would work through them, which is not alphabetical. Anything not listed
# follows, sorted, so a new knowledge key never silently vanishes from a dump
# written by an older build.
SECTION_ORDER = (
'nomenclature', 'parameters', 'families', 'orientation', 'rails',
'legend', 'sheet', 'concrete', 'lifters', 'handling', 'interference',
'catalogue',
)
SECTION_NOTES = {
'nomenclature': 'How a design number is written, and what reduces to what',
'orientation': 'Which end of a piece is the top once it is erected',
'rails': 'The casting bed: which rail is fixed, how wide the bed is',
'parameters': "This office's Revit parameter name for each role the "
'tool reads',
'families': 'Which family names count as strand, mesh, or a lifting insert',
'lifters': 'Part number -> catalogue designation, and the checks on top',
'handling': ('Lifting and handling: stages, insert roles, where reports '
'go. rig_mode and dunnage are decided in the decisions '
'section at the top; a stage with no rig_mode here inherits '
'the default.'),
'interference': 'The clash check between members of one assembly',
'catalogue': 'Published hardware ratings. Not job data -- ships with the '
'tool',
'legend': 'The strand-pattern legend a ticket has to carry',
'sheet': 'What is compared against the printed ticket',
'concrete': 'Release strength rules',
}
def sections(know):
"""Knowledge section names, in reading order, nothing dropped.
Except the underscore keys. `_config_missing` and `_config_problems` are
notes load() leaves for config_problems ABOUT this run -- not settings --
and writing them into the file would make a transient complaint look like
a permanent setting, and hand it back to the next merge as one.
"""
listed = [key for key in SECTION_ORDER if key in know]
rest = sorted(key for key in know
if key not in SECTION_ORDER and not str(key).startswith('_'))
return listed + rest
# ------------------------------------------------------------ a YAML writer
_PLAIN = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_ ./+-]*$')
_RESERVED = ('true', 'false', 'null', 'yes', 'no', 'on', 'off', '~')
def _scalar(value):
if value is None:
return 'null'
if value is True:
return 'true'
if value is False:
return 'false'
if isinstance(value, float):
text = repr(value)
return text[:-2] if text.endswith('.0') else text
if not isinstance(value, string_types):
return str(value)
text = text_type(value)
if not text:
return "''"
if text.lower() in _RESERVED or not _PLAIN.match(text) \
or text != text.strip() or text.endswith(':'):
return "'" + text.replace("'", "''") + "'"
# A bare number-looking string has to be quoted or it reads back as a
# number, and a part number like 4.10 would come back as 4.1.
if re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', text):
return "'" + text + "'"
return text
def _is_flat(node):
"""A list of plain scalars, short enough to sit on one line."""
if not isinstance(node, list) or not node:
return False
if any(isinstance(v, (dict, list)) for v in node):
return False
return sum(len(_scalar(v)) + 2 for v in node) <= 66
def emit(node, indent=0):
"""A dict/list/scalar tree as YAML lines. Deterministic key order."""
pad = ' ' * indent
out = []
if isinstance(node, dict):
if not node:
return [pad + '{}']
for key in sorted(node):
value = node[key]
label = pad + _scalar(key) + ':'
if isinstance(value, dict) and value:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, list) and value:
if _is_flat(value):
out.append('{0} [{1}]'.format(
label, ', '.join(_scalar(v) for v in value)))
else:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, (dict, list)):
out.append('{0} {1}'.format(label,
'{}' if isinstance(value, dict)
else '[]'))
else:
out.append('{0} {1}'.format(label, _scalar(value)))
return out
if isinstance(node, list):
for item in node:
if isinstance(item, dict) and item:
lines = emit(item, indent + 1)
# The first key rides on the dash, the rest line up under it.
out.append(pad + '- ' + lines[0].strip())
out.extend(lines[1:])
elif isinstance(item, list) and item:
lines = emit(item, indent + 1)
out.append(pad + '-')
out.extend(lines)
else:
out.append(pad + '- ' + _scalar(item))
return out
return [pad + _scalar(node)]
# --------------------------------------------------------------- provenance
def _layer_rows(doc, include_config=False):
"""[(label, path, exists, data, unreadable)] -- what layer_values() and
layer_problems() are both built out of.
`unreadable` is `None` for an ordinary layer (absent, or present and read
clean) and the parser's message (a non-empty string) for a layer whose
file IS there and would not parse.
This used to be `layer_values()` itself, calling
`knowledge._merge_file(holder, path)` with NO `layer` argument -- which
means `_note_layer_unreadable` returned immediately (`if not layer: ...`)
and a file that exists and will not parse came back as `data = {}`,
indistinguishable from a file that genuinely sets nothing. The five-layer
merge in `knowledge.load()` was never fooled by this -- it always passes
the token -- so a real run's `_plant_unreadable` marker was sitting right
there in `know`; only THIS dump's own, separate re-read of the files
(needed to say which layer a value came from) was blind to it. Threading
the token through here, and popping the marker back out of `data` before
handing it to a caller, fixes that without changing what a marker-free
`data` dict means to `origins()` / `overridden()` / every existing caller
of `layer_values()`.
"""
from vibe import designspec
layers = [('defaults', None, True,
knowledge._deep_copy(knowledge.DEFAULTS), None)]
# The plant layer is per plant, and which plant is a key in the JOB layer
# -- so it has to be resolved through the same pre-pass load() uses, or
# this dump attributes settings to a file the run never read.
# The token doubles as the label for these three -- 'catalogue', 'plant'
# and 'job' are both what `_FILE_LAYERS` calls the layer and what a
# human-readable line calls it.
for label, path in (('catalogue', knowledge.catalogue_path()),
('plant',
designspec.plant_knowledge_path(
knowledge.plant_id(doc))),
('job', designspec.knowledge_path(doc))):
layer = label
data = {}
unreadable = None
exists = bool(path and os.path.isfile(path))
if exists:
holder = {}
knowledge._merge_file(holder, path, layer)
marker = holder.pop(knowledge._unreadable_key(layer), None)
if marker:
unreadable = marker[1]
data = holder
layers.append((label, path, exists, data, unreadable))
if include_config:
path = designspec.active_config_path(doc)
if path:
data = {}
unreadable = None
exists = os.path.isfile(path)
if exists:
# Through _merge_config_file, not _merge_file: the file's
# `decisions:` block is mapped back onto the keys really read,
# and it beats the tree below it inside the same file. A
# provenance view that read only the tree would name the wrong
# value for the eight settings that have both spellings.
# _merge_config_file already threads 'config' through to
# _merge_file on its own (`notes=merged`), so the marker
# lands on `holder` here the same way it does for the other
# three layers -- just popped back out below instead.
holder = {}
knowledge._merge_config_file(holder, path)
marker = holder.pop(knowledge._unreadable_key('config'), None)
if marker:
unreadable = marker[1]
data = holder
layers.append(('config', path, exists, data, unreadable))
return layers
def layer_values(doc, include_config=False):
"""[(label, path, exists, data)] for every layer, in the order they merge.
Read separately from load() so the dump can say which layer a value came
from. Saying only what a setting ended up as leaves the reader unable to
tell a deliberate plant rule from a default nobody has looked at.
The project config layer is deliberately NOT included by default. This is
what writes it: folding the previous version of the file into the
provenance would stamp every setting as coming from the file about to be
overwritten, and the answer would be true and useless. What a reader wants
is which of the underlying layers each setting really came from.
`include_config=True` adds it, for the one caller that needs the opposite
answer: an editor showing what is in force has to show the layer that is
WINNING, and while a config file is live that is the config file. Leaving
it out there would show somebody a plant value that nothing is reading.
The flag is additive rather than a change of default so the dump's own
provenance keeps meaning what it has always meant.
`data` never carries an `_<layer>_unreadable` marker -- `_layer_rows()`
pops it back out before this wraps the row. A caller that wants to know
whether a layer was unreadable wants `layer_problems()`, not a key it has
to know to go looking for inside `data`.
"""
return [(label, path, exists, data)
for label, path, exists, data, _unreadable
in _layer_rows(doc, include_config)]
def layer_problems(doc, include_config=False):
"""[(label, path, message)] for every layer whose file is on this machine
and would not parse -- in merge order, empty in the ordinary case.
This is the dump's own version of `knowledge.unreadable_layers()`: same
question, asked against the fresh per-file read `layer_values()` does
rather than against an already-merged `know` tree, because the dump
reads the layers itself (see `layer_values()`'s docstring) and has to be
right on its own -- a caller here may not have a merged tree with the
markers already on it.
"""
return [(label, path, unreadable)
for label, path, _exists, _data, unreadable
in _layer_rows(doc, include_config)
if unreadable]
def _paths(node, prefix=''):
"""Every leaf in a tree as a dotted path. Lists count as one leaf."""
found = []
if isinstance(node, dict):
for key in node:
found.extend(_paths(node[key],
prefix + '.' + str(key) if prefix else str(key)))
elif prefix:
found.append(prefix)
return found
def origins(doc):
"""{dotted key: the label of the LAST layer that set it}."""
out = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
out[path] = label
return out
def overridden(doc):
"""[(key, [(layer, value), ...])] for settings a later layer CHANGES.
The interesting rows: where a plant rule beats a default, or a job beats
its plant. A layer that restates a value it agrees with is not an
override, and listing it would bury the handful of rows that matter under
a hundred that do not.
"""
seen = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
seen.setdefault(path, []).append(
(label, knowledge.get(data, path)))
rows = []
for path in sorted(seen):
chain = seen[path]
values = [value for _label, value in chain]
if len(chain) > 1 and any(v != values[0] for v in values[1:]):
rows.append((path, chain))
return rows
# ------------------------------------------------------------ the document
def project_info(doc, know):
"""What piece of work this configuration belongs to."""
from vibe import designspec, ticketqc
info = {
'model': doc.Title,
'model_path': doc.PathName or '(unsaved)',
'qc_data_dir': designspec.root_dir(doc),
'written': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
}
try:
grouped = ticketqc.assemblies_by_design(doc, know)
specs = designspec.list_specs(doc) if doc.PathName else {}
designs = []
for key in sorted(grouped):
designs.append({
'design': key,
'pieces': len(grouped[key]),
'extracted_spec': key in specs,
})
info['designs'] = designs
info['piece_count'] = sum(len(v) for v in grouped.values())
except Exception as exc:
info['designs'] = []
info['designs_error'] = str(exc)
return info
DECISION_BANNER = u'''# =========================================================================
# YOUR DECISIONS
#
# Everything in this section is yours. None of it can be worked out from the
# model, and the tool will not guess at any of it -- it uses what is written
# here. Edit a value and the next run uses it.
#
# Everything after this section is either a record of how this file was built
# or the merged knowledge it was built from. Worth reading, rarely worth
# editing: anything named down there and also named up here is overruled by
# the copy up here.
# ========================================================================='''
# The comment for each decision, written directly above the value it belongs
# to rather than gathered into one block at the top of the section. A note
# four lines away from the line it describes is a note that gets edited past.
DECISION_NOTES = (
('report_dir', u'''# Where Panel Lift Check writes its HTML. Used exactly as written, so an
# absolute path anywhere works -- a job folder on a share, say. Takes effect
# on the next run.'''),
('open_report_when_done', u'''# Open each report as it is finished. The gap between a report being written
# and being read is one double-click that is easy not to take. Turn it off
# when working through a lot of pieces in a row, or forty browser tabs is the
# result.'''),
('prestress_loss_pct', u'''# Everything lost between the jack and the stage being checked, as one
# percentage. It is NOT calculated: a loss model needs an age, a humidity and
# a curing schedule this tool does not have, and a plausible-looking number
# from guessed inputs is worse than a figure you can argue with.
#
# 8 is conservative. A commercial check of one real piece computed 1.6 at
# release. LOWER IT AND EVERY PIECE GETS MORE CREDIT.'''),
('transfer_length_in', u'''# Where a cut strand has handed its force back to the concrete. Blank uses
# ACI 318's f_se * db / 3, worked out from the pull stated on the design
# rather than assumed.'''),
('insert_position', u'''# How an insert's position is measured, per run mode.
#
# VALID VALUES -- write exactly one of these:
# fast the element's cached bounding box centre. No geometry solve.
# precise the solved solid centroid.
#
# They agree on a symmetric embed and disagree by a few inches on an L-shaped
# one -- and a face lifter's position IS the moment arm it hangs on. On-edge
# anchors are snapped to the edge either way. 'precise' is roughly an order of
# magnitude slower.
#
# The two modes are set apart on purpose. A batch runs unattended, so the time
# costs nobody anything and it should be right. One piece at a time is
# somebody waiting at the screen -- and a piece close enough for a few inches
# to decide it is a piece to put through Eriksson, not to squeeze a sharper
# number out of here.
#
# one_piece Panel Lift Check on a single selected assembly
# batch Panel Lift Check on several, the sweep, and the run Ticket QC
# starts for itself'''),
('rig_mode', u'''# How the piece hangs. This changes the mechanics, not just a coefficient.
#
# VALID VALUES -- write exactly one of these:
# rolling equalising blocks / roller sheaves. Every line in a cable group
# carries the same force; two groups, statically determinate.
# static fixed-length slings / static lift. A continuous beam on rigid
# supports; the reactions follow stiffness, so a blockout beside a
# support really does change them.
#
# NOT VALID: static_lift, static_lifting, fixed, 1, yes. A misspelled value is
# REFUSED, not silently inherited -- a typo that looked like "inherit" is
# exactly the kind of quiet wrongness this section exists to prevent.
#
# "default" applies to every stage. A stage named below overrides it for that
# stage only; put null / leave it blank there to inherit the default.
#
# Example -- strip with static slings, erect with equalising blocks:
# default: rolling
# stripping: static
# transportation: null
# erection: rolling
#
# Worth settling before anything else is tuned. A commercial check of one real
# piece stripped on fixed slings and erected on an equalising block, and its
# stripping moment came out 14.1 k-ft where one rolling mode everywhere gives
# 23.8. Which is right depends on how YOUR yard rigs -- nothing here guesses
# it.'''),
('group_tolerance_ft', u'''# How far apart ALONG THE PIECE two inserts may sit and still count as one
# pick row.
#
# The beam solve hangs each ROW from one rigid support line -- but the job
# names individual inserts and nothing names rows, so this tolerance is what
# decides who belongs together. Top view, x running along the piece's length:
#
# x=14.92 x=15.92
# | |
# edge o--+ | the edge anchor and face insert B are flush;
# B o--+ | C belongs to the SAME row but sits 1.00 ft
# | C o---+ further back along the piece.
#
# gap <= group_tolerance_ft -> A, B, C hang as ONE row, one support line.
# gap > group_tolerance_ft -> C becomes an extra "row" of its own. The
# solve then balances on a support that does
# not exist: one line goes NEGATIVE (pulled
# down) while its neighbour overloads -- on
# a real panel this read -936 lb on one
# insert and 116% of rating on the next.
#
# SET IT just above the largest stagger your yard actually leaves within a
# row. Too large merges two rows that were genuinely separate -- the two
# failure modes point opposite ways, so this number is yours to set, not a
# default to trust.'''),
('dunnage', u'''# Where the piece sits on the trailer during transportation.
#
# VALID VALUES for mode:
# null old lift approximation (transportation is still checked as a
# pick; the report labels it approximate)
# lifter_rows dunnage under the same lines as the stripping rows
# fraction two dunnage lines inset fraction*L from each end
#
# fraction is only used when mode is fraction, e.g. 0.2 means supports at
# 0.2L and 0.8L. This is plant knowledge; it is lifted up here so you can see
# and edit it without collapsing the whole knowledge tree.'''),
)
def report_dir(doc, know):
"""Where handling reports go: what was set, or beside the model."""
from vibe import designspec
return (knowledge.get(know, 'handling.report_dir') or
os.path.join(designspec.root_dir(doc), 'handling'))
def decisions(doc, know):
"""Everything in this file that is the engineer's to decide, in one place.
Every one of these lives three or four levels down the merged knowledge
tree, which is where it is read from and a hopeless place to find it.
Each is here because it was asked for by name: where the reports go,
whether they open, the prestress loss, the transfer length, how the
piece hangs at each stage, and how much stagger within a pick row still
counts as one row.
They come FIRST in the file on purpose. A config file is read from the top
and abandoned somewhere in the middle, so the part somebody opened it to
change has to sit above the part they did not.
"""
modes = {'default': knowledge.get(know, 'handling.rig_mode', 'rolling')}
for stage in knowledge.get(know, 'handling.stages', []) or []:
if stage.get('name'):
modes[stage['name']] = stage.get('rig_mode')
fast = knowledge.get(know, 'handling.fast_lifter_box', True)
fast_batch = knowledge.get(know, 'handling.fast_lifter_box_batch', False)
return {
'report_dir': report_dir(doc, know),
'open_report_when_done': knowledge.get(know, 'handling.open_report',
True),
'prestress_loss_pct': knowledge.get(know,
'handling.prestress_loss_pct',
8.0),
'transfer_length_in': knowledge.get(know,
'handling.transfer_length_in'),
'group_tolerance_ft': knowledge.get(know,
'handling.group_tolerance_ft',
1.0),
'dunnage': knowledge.get(know, 'handling.dunnage') or {},
'insert_position': {
'one_piece': 'fast' if fast else 'precise',
'batch': 'fast' if fast_batch else 'precise',
},
'rig_mode': modes,
}
def decisions_block(doc, know):
"""The decisions section as text: every value under its own comment."""
body = decisions(doc, know)
out = [DECISION_BANNER, u'', u'decisions:']
for key, note in DECISION_NOTES:
out.append(u'')
out.append(u'\n'.join(u' ' + line for line in note.split(u'\n')))
out.extend(emit({key: body[key]}, 1))
return out
def output_locations(doc, know):
"""Where each thing this tool produces is written.
Every path here is DERIVED from where the model sits. None of it is a
setting any more -- the one that was, `lifting_check_reports`, moved up
into `decisions` as `report_dir`. Two editable copies of one path is
exactly the trap this file exists not to be.
They are still listed, because "where did the specs go" is a question
people have and printing the answer beats hunting for it.
"""
from vibe import designspec
return {
'extracted_specs': designspec.sub_dir(doc, 'specs'),
'evidence': designspec.sub_dir(doc, 'evidence'),
'logs': designspec.sub_dir(doc, 'log'),
'job_knowledge_file': designspec.knowledge_path(doc),
# Resolved for THIS job's plant, not the machine-wide file -- see
# knowledge.plant_id() and the two-pass note in knowledge.load().
'plant_knowledge_file': designspec.plant_knowledge_path(
knowledge.plant_id(doc)),
}
LOCATION_NOTES = (
u'# All derived from where the model sits. Shown so you do not have to\n'
u'# hunt for them; editing them does nothing. The one path here that IS a\n'
u'# setting is report_dir, at the top of this file.'
)
HEADER = u"""# WillTools project configuration
#
# model {model}
# written {when}
#
# THIS FILE IS LIVE. It is the configuration {model} runs on, and it is the
# last word -- every setting in here beats the same setting in the layered
# files it was built from. Edit it and the next run uses what you wrote.
#
# It got that way because somebody pressed Project Config. A one-line pointer
# beside the model records where this file is:
#
# {pointer}
#
# Delete that pointer, or press Project Config again, and the model goes back
# to the layered files. Nothing else reads this path.
#
# The layers underneath, in the order they override each other. This file sits
# after all of them:
#
{layers}
#
# One consequence worth knowing. The `knowledge:` section below is the MERGED
# result, so it contains the plant's settings as values of its own. While this
# file is live, editing the plant file no longer reaches this model -- the
# copy in here wins. That is the point of pinning a configuration to a job,
# and it is also how a plant-wide fix quietly fails to arrive. When you want
# the plant layer back, clear the pointer.
"""
# Only ever appended when `layer_problems()` is non-empty. An unreadable
# layer and an empty one are OPPOSITE conditions -- one is a 255KB plant file
# silently voided (the 2026-09-01 incident that `knowledge._merge_file`'s
# marker exists for), the other is genuinely nothing -- and this file is
# where a human looks first, so the two must never render the same way.
# `# set by:` in the KNOWLEDGE section below only ever names layers that DID
# contribute a value; a layer that could not be read contributes none, which
# is exactly why it needs a banner of its own instead of relying on that line
# to mention it.
UNREADABLE_LAYER_BANNER = u'''
# =========================================================================
# ONE OR MORE KNOWLEDGE LAYERS COULD NOT BE READ
#
# Each layer named below is a file that IS on this machine, and the YAML
# reader refused it. A half-parsed layer is never merged, so NONE of that
# file was used -- every value it would have set has fallen back to
# whatever the layer underneath it says. A layer that fails to load looks
# EXACTLY like a layer that agrees with the defaults; nothing below this
# banner can tell you that happened, only this can.
#
{rows}
# Fix the file and dump again -- a key stated twice inside one mapping is
# the usual cause, and `python tools/knowledgelint.py` names it with a line
# number. The tool will not guess at what the file said.
# ========================================================================='''
def unreadable_layer_banner(problems):
"""The banner text for `layer_problems()`'s rows, or '' when there are none.
Split out from `build()` so a caller assembling its own view of the same
facts (see `layer_problems()`) does not have to reconstruct this from
scratch -- and so a test can check the wording without going through a
live `doc`.
"""
if not problems:
return u''
rows = u'#\n'.join(
u'# {0} layer -- {1}\n# {2}\n'.format(label, path, message)
for label, path, message in problems)
return UNREADABLE_LAYER_BANNER.format(rows=rows)
def _layer_status_suffix(exists, unreadable):
"""The trailing note on one layer's line in the header's layer listing.
Three facts, three different suffixes -- collapsing any two of them is
exactly the bug this exists to prevent: 'read clean' (''), 'not present'
(the file is not there, ordinary and quiet) and 'unreadable' (the file
IS there and the YAML reader refused it, which is the loud one and must
never look like either of the other two, and least of all like 'read
clean and simply set nothing').
"""
if unreadable:
return u' !! UNREADABLE !!'
if not exists:
return u' -- not present'
return u''
def build(doc, know):
"""The whole document, as text."""
from vibe import designspec
rows = _layer_rows(doc)
layer_lines = [
u'# {0:<10} {1}{2}'.format(
label, path or '(built into the tool)',
_layer_status_suffix(exists, unreadable))
for label, path, exists, _data, unreadable in rows]
body = {
'decisions': decisions(doc, know),
'project': project_info(doc, know),
'output_locations': output_locations(doc, know),
'knowledge': know,
}
text = [HEADER.format(when=datetime.datetime.now()
.strftime('%Y-%m-%d %H:%M'),
model=doc.Title,
pointer=designspec.config_pointer_path(doc),
layers=u'\n'.join(layer_lines))]
problems = [(label, path, message)
for label, path, _exists, _data, message in rows
if message]
banner = unreadable_layer_banner(problems)
if banner:
text.append(banner)
# Above everything else, because a config file is read from the top and
# abandoned in the middle.
text.append(u'')
text.append(u'\n'.join(decisions_block(doc, know)))
text.append(u'\n# What this configuration belongs to. Read for the record,'
u' not by the tool.')
text.append(u'project:')
text.append(u'\n'.join(emit(body['project'], 1)))
text.append(u'\n# Where output goes.\n#\n' + LOCATION_NOTES)
text.append(u'output_locations:')
text.append(u'\n'.join(emit(body['output_locations'], 1)))
rows = overridden(doc)
if rows:
text.append(u'\n# ---- settings more than one layer touches ----------'
u'--------------------\n#\n'
u'# Where a plant rule beats a default, or a job beats its '
u'plant. The last\n# entry on each line is the one in '
u'force.\n#')
for path, chain in rows:
text.append(u'# {0}\n# {1}'.format(
path, u' -> '.join(
u'{0}: {1}'.format(label, _scalar(value))
for label, value in chain)))
text.append(u'\n# =================================================================')
text.append(u'# KNOWLEDGE -- everything below this line is the merged configuration.')
text.append(u'#')
text.append(u'# This whole section is one `knowledge:` block, so most editors let you')
text.append(u'# collapse it (click the triangle / fold marker next to `knowledge:`).')
text.append(u'# You almost never need to edit anything here. The values the engineer')
text.append(u'# actually decides are in the `decisions:` section at the top of this')
text.append(u'# file; anything also named there is overruled by the copy up there.')
text.append(u'#')
text.append(u'# The "set by" comments say which underlying layer each value came from,')
text.append(u'# so you can tell a deliberate plant rule from a default nobody has')
text.append(u'# looked at.')
text.append(u'\nknowledge:')
text.append(_knowledge_body(doc, know, indent=1))
return u'\n'.join(text) + u'\n'
def _knowledge_body(doc, know, indent):
"""The knowledge tree, section by section, each one labelled and sourced.
Emitting the whole tree in one go would be shorter and much harder to
read: a merged configuration is several hundred lines and every one of
them looks equally important until something says otherwise.
"""
where = origins(doc)
pad = ' ' * indent
out = []
for name in sections(know):
note = SECTION_NOTES.get(name)
layers = sorted(set(
where[path] for path in _paths({name: know[name]})
if path in where))
out.append(u'')
if note:
out.append(u'{0}# {1}'.format(pad, note))
if layers:
out.append(u'{0}# set by: {1}'.format(pad, ', '.join(layers)))
out.extend(emit({name: know[name]}, indent))
return u'\n'.join(out)