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.
527 lines
21 KiB
Python
527 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The shape of a design spec, and how it reads and writes as text.
|
|
|
|
Nothing in this module touches Revit, a document, or the filesystem -- it is
|
|
pure data in, text or data out. That is deliberate: it is the part of the
|
|
design-spec pipeline that can be exercised by an ordinary CPython test with no
|
|
licence, no model and no running Revit, the same way `panellift` is the pure
|
|
side of the handling-check boundary (see CLAUDE.md). `designspec.py` is the
|
|
Revit side of THIS boundary -- it gathers evidence from the model and reads
|
|
and writes these shapes to disk. Anything about what a spec MEANS, or how it
|
|
prints, belongs here; anything about reading THIS model belongs there.
|
|
|
|
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib, no
|
|
dataclasses.
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
import datetime
|
|
import json
|
|
import re
|
|
|
|
from vibe import yamlkit
|
|
from vibe.compat import string_types
|
|
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
|
|
# ------------------------------------------------------------- spec shape
|
|
#
|
|
# The contract every downstream check relies on. Anything the reader is
|
|
# unsure about belongs in 'unresolved' rather than being guessed at.
|
|
#
|
|
# reinforcing.strand_layout is a MATRIX, not four parallel lists: columns_in
|
|
# is where the strands sit across the piece (cumulative distance from datum,
|
|
# same numbers a detailer would read off the dimension chain), and rows is
|
|
# one entry per layer through the thickness, each carrying which of those
|
|
# columns actually holds a strand at that height. A design where every column
|
|
# has a strand at every row -- the ordinary case -- just has 'strands' filled
|
|
# solid; a design that is not a full grid says so directly, column by column,
|
|
# rather than forcing a human to reverse-engineer it from a total count.
|
|
SPEC_TEMPLATE = {
|
|
'schema': SCHEMA_VERSION,
|
|
'design_number': None,
|
|
'piece_description': None,
|
|
'source': {'view': None, 'view_id': None, 'sheet': None,
|
|
'model': None, 'extracted': None, 'extractor': None,
|
|
'reviewed_by': None, 'reviewed_on': None},
|
|
'concrete': {'fc_psi': None, 'fci_psi': None,
|
|
'unit_weight_pcf': None, 'weight_class': None},
|
|
'reinforcing': {
|
|
'mild_grade_ksi': None,
|
|
'wwf': {'mesh': None, 'layers': None, 'per_face': None},
|
|
'strand_groups': [], # {qty, dia_in, grade_ksi, it_lb, role, note}
|
|
'strand_layout': {
|
|
'datum': None, # 'left_rail' | 'right_rail' | 'centerline'
|
|
'columns_in': [], # strand positions across the piece,
|
|
# cumulative distance from the datum
|
|
'rows': [], # [{'from_bottom_in':, 'strands': [...]}]
|
|
# one entry per layer through the
|
|
# thickness; 'strands' marks which column
|
|
# holds a strand at that height
|
|
'total_in': None, # the chain total; should equal the piece
|
|
# width
|
|
'note': None,
|
|
},
|
|
},
|
|
'envelope': {'length': {'min_ft': None, 'max_ft': None,
|
|
'min_text': None, 'max_text': None},
|
|
'width': {'min_ft': None, 'max_ft': None,
|
|
'min_text': None, 'max_text': None}},
|
|
'thickness_in': None,
|
|
'notes': [], # instructions that stay prose
|
|
'unresolved': [], # [{text, reason}] nothing would guess at
|
|
'confidence': {}, # field path -> high | medium | low
|
|
}
|
|
|
|
|
|
REQUIRED_PATHS = (
|
|
'design_number',
|
|
'concrete.fc_psi',
|
|
'concrete.fci_psi',
|
|
)
|
|
|
|
|
|
def get_path(spec, dotted, default=None):
|
|
node = spec
|
|
for part in dotted.split('.'):
|
|
if not isinstance(node, dict) or part not in node:
|
|
return default
|
|
node = node[part]
|
|
return node if node is not None else default
|
|
|
|
|
|
def validate_spec(spec):
|
|
"""Cheap sanity check. Returns a list of complaints, empty when happy."""
|
|
problems = []
|
|
if not isinstance(spec, dict):
|
|
return ['spec is not an object']
|
|
if spec.get('schema') != SCHEMA_VERSION:
|
|
problems.append('schema is {0}, expected {1}'
|
|
.format(spec.get('schema'), SCHEMA_VERSION))
|
|
for path in REQUIRED_PATHS:
|
|
if get_path(spec, path) is None:
|
|
problems.append('missing ' + path)
|
|
|
|
for i, group in enumerate(get_path(spec, 'reinforcing.strand_groups', []) or []):
|
|
for key in ('qty', 'dia_in'):
|
|
if group.get(key) is None:
|
|
problems.append('strand_groups[{0}] missing {1}'.format(i, key))
|
|
if group.get('qty') is not None and group['qty'] <= 0:
|
|
problems.append('strand_groups[{0}] qty is {1}'.format(i, group['qty']))
|
|
|
|
fc = get_path(spec, 'concrete.fc_psi')
|
|
fci = get_path(spec, 'concrete.fci_psi')
|
|
if fc and fci and fci > fc:
|
|
problems.append("f'ci ({0}) is higher than f'c ({1})".format(fci, fc))
|
|
for field, lo, hi in (('concrete.fc_psi', 2000, 15000),
|
|
('concrete.fci_psi', 1500, 12000),
|
|
('concrete.unit_weight_pcf', 90, 200)):
|
|
val = get_path(spec, field)
|
|
if val is not None and not (lo <= val <= hi):
|
|
problems.append('{0} = {1} is outside {2}..{3}'.format(field, val, lo, hi))
|
|
return problems
|
|
|
|
|
|
# ---------------------------------------------------------- design numbers
|
|
#
|
|
# THE ONE PLACE TWO DESIGN NUMBERS ARE COMPARED. It used to live in
|
|
# `designspec.py`, which imports `Autodesk.Revit.DB` at module scope, so the
|
|
# only way to ask "are these the same design?" was through a module that needs
|
|
# a licence. `reinforcebind` has to ask it -- both in Revit and in
|
|
# `tools/reinforce_offline.py`, which runs with no Revit at all -- so the
|
|
# function moved down here, where nothing imports Revit, and `designspec`
|
|
# re-exports it. Same rule, same name, one implementation: a second spelling
|
|
# of this question is how two answers start.
|
|
|
|
# Used only when nomenclature is unmapped. Letters plus the first number is
|
|
# the commonest convention, but it is a fallback, not a rule -- the plant's
|
|
# knowledge file is where the real pattern belongs.
|
|
FALLBACK_DESIGN_RE = r'^([A-Za-z]+)\s*(\d+)'
|
|
|
|
|
|
def _design_pattern(know, pattern=None):
|
|
"""The plant's design-number pattern, or the fallback.
|
|
|
|
`pattern` wins outright when it is given: it is how a caller that already
|
|
resolved the key -- `reinforcebind.DonorFilter`, which must stay free of
|
|
any knowledge import -- hands the same rule in without re-reading it.
|
|
|
|
Imported late: `knowledge` reaches for `designspec`, which imports this
|
|
module, so a module-level import would be circular.
|
|
"""
|
|
if pattern:
|
|
return pattern
|
|
if know is None:
|
|
return FALLBACK_DESIGN_RE
|
|
from vibe import knowledge as K
|
|
return K.get(know, 'nomenclature.design_number_re', FALLBACK_DESIGN_RE)
|
|
|
|
|
|
def design_key(value, know=None, pattern=None):
|
|
"""Reduce anything that names a design to the key everything matches on.
|
|
|
|
A design packet's cover sheet is numbered with a page suffix, so the same
|
|
design is written PNL1 on a piece and PNL1.0 on its summary sheet, with
|
|
PNL1.A and PNL1.B1 further into the packet. Comparing those raw splits one
|
|
design into several that each fail to find the others -- so every design
|
|
number, wherever it comes from, passes through here first.
|
|
|
|
Returns None for anything the pattern does not recognise, which keeps
|
|
junk out of the picker rather than inventing a design for it. None is
|
|
COULD NOT READ THIS, never "no design": a caller that treats it as a
|
|
value will happily match two pieces whose design numbers nobody parsed.
|
|
"""
|
|
text = (value or '')
|
|
try:
|
|
text = text.strip()
|
|
except AttributeError:
|
|
return None
|
|
if not text:
|
|
return None
|
|
try:
|
|
m = re.match(_design_pattern(know, pattern), text)
|
|
except Exception: # pylint: disable=W0703
|
|
m = re.match(FALLBACK_DESIGN_RE, text)
|
|
if not m:
|
|
return None
|
|
groups = [g for g in m.groups() if g] or [m.group(0)]
|
|
return ''.join([g.strip() for g in groups]).upper()
|
|
|
|
|
|
def new_spec(design, evidence=None):
|
|
"""A blank spec pre-filled with provenance."""
|
|
spec = json.loads(json.dumps(SPEC_TEMPLATE)) # deep copy
|
|
spec['design_number'] = design
|
|
if evidence:
|
|
views = evidence.get('summary_views') or []
|
|
sheets = evidence.get('sheets') or []
|
|
spec['source']['view'] = views[0]['name'] if views else None
|
|
spec['source']['view_id'] = views[0]['id'] if views else None
|
|
spec['source']['sheet'] = ('{0} - {1}'.format(sheets[0]['number'],
|
|
sheets[0]['name'])
|
|
if sheets else None)
|
|
spec['source']['model'] = evidence.get('model')
|
|
spec['source']['extracted'] = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
|
|
return spec
|
|
|
|
|
|
# An extractor that renames a field breaks every check that reads it, silently
|
|
# and in the safe-looking direction: the value reads as None and the check
|
|
# just does not run. Known synonyms are mapped back onto the canonical names
|
|
# on load, and validate_spec still complains about anything left missing.
|
|
_ALIASES = {
|
|
'qty': ('count', 'quantity', 'number', 'n'),
|
|
'dia_in': ('diameter_in', 'dia', 'diameter'),
|
|
'grade_ksi': ('grade', 'grade_k', 'ksi', 'strength_ksi'),
|
|
'it_lb': ('it_lbs', 'initial_tension_lb', 'it', 'tension_lb',
|
|
'initial_tension', 'it_lb_force'),
|
|
}
|
|
|
|
|
|
def _first_number(value):
|
|
"""270, '270K', '270 ksi' -> 270.0."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
m = re.search(r'[\d.]+', str(value).replace(',', ''))
|
|
return float(m.group(0)) if m else None
|
|
|
|
|
|
def normalize_spec(spec):
|
|
"""Map known field synonyms back onto the canonical names, in place."""
|
|
if not isinstance(spec, dict):
|
|
return spec
|
|
groups = get_path(spec, 'reinforcing.strand_groups', []) or []
|
|
for group in groups:
|
|
if not isinstance(group, dict):
|
|
continue
|
|
for canonical, synonyms in _ALIASES.items():
|
|
if group.get(canonical) is None:
|
|
for alt in synonyms:
|
|
if group.get(alt) is not None:
|
|
group[canonical] = group[alt]
|
|
break
|
|
for numeric in ('qty', 'dia_in', 'grade_ksi', 'it_lb'):
|
|
if group.get(numeric) is not None:
|
|
group[numeric] = _first_number(group[numeric])
|
|
if group.get('qty') is not None:
|
|
group['qty'] = int(group['qty'])
|
|
|
|
concrete = spec.get('concrete')
|
|
if isinstance(concrete, dict):
|
|
for canonical, alt in (('fc_psi', 'fc'), ('fci_psi', 'fci'),
|
|
('unit_weight_pcf', 'pcf')):
|
|
if concrete.get(canonical) is None and concrete.get(alt) is not None:
|
|
concrete[canonical] = concrete[alt]
|
|
if concrete.get(canonical) is not None:
|
|
concrete[canonical] = _first_number(concrete[canonical])
|
|
return spec
|
|
|
|
|
|
# --------------------------------------------------------- the strand matrix
|
|
|
|
_BLANK_MARKS = ('', 'null', 'none', '.', '-', '0')
|
|
|
|
|
|
def strand_positions(layout):
|
|
"""Cumulative distances from the datum, whichever spec shape wrote them.
|
|
|
|
New specs carry 'columns_in'; a spec written before the matrix shape
|
|
carries the same numbers under 'positions_in'. Either way this is what
|
|
check_strand_layout compares against what was actually modelled.
|
|
"""
|
|
if not isinstance(layout, dict):
|
|
return []
|
|
cols = layout.get('columns_in')
|
|
if cols is None:
|
|
cols = layout.get('positions_in')
|
|
return [c for c in (cols or []) if c is not None]
|
|
|
|
|
|
def strand_row_marks(row, n_columns):
|
|
"""Which of n_columns holds a strand in this row.
|
|
|
|
A row from a spec written before the matrix shape has no 'strands' list
|
|
at all -- it only ever said WHERE the row sits, not which columns it
|
|
fills -- so it is read as a full row, which is what every such spec
|
|
actually was.
|
|
"""
|
|
marks = row.get('strands') if isinstance(row, dict) else None
|
|
if marks is None:
|
|
return [True] * n_columns
|
|
out = []
|
|
for i in range(n_columns):
|
|
value = marks[i] if i < len(marks) else None
|
|
out.append(value is not None and
|
|
str(value).strip().lower() not in _BLANK_MARKS)
|
|
return out
|
|
|
|
|
|
def _fmt_in(value):
|
|
if value is None:
|
|
return '?'
|
|
text = '{0:.3f}'.format(float(value))
|
|
text = text.rstrip('0').rstrip('.')
|
|
return text if text else '0'
|
|
|
|
|
|
_DIAGRAM_WIDTH = 68 # target character width of the scaled ruler
|
|
|
|
|
|
def strand_diagram(layout):
|
|
"""An ASCII elevation of the strand matrix, drawn from the data itself.
|
|
|
|
Datum on the left, the chain's segment lengths along a scaled ruler, and
|
|
one row of marks per strand row at its height -- the same three things a
|
|
detailer reads off the dimension chain on the drawing, in the same left-
|
|
to-right order, so the two can be held side by side and compared. There
|
|
is nowhere for the picture to get a number the data does not have: every
|
|
character position is computed from columns_in / total_in / rows.
|
|
"""
|
|
if not isinstance(layout, dict):
|
|
return ['(no strand layout)']
|
|
columns = strand_positions(layout)
|
|
total = layout.get('total_in')
|
|
rows = [r for r in (layout.get('rows') or []) if isinstance(r, dict)
|
|
and r.get('from_bottom_in') is not None]
|
|
datum = layout.get('datum') or '(datum not set)'
|
|
|
|
if not columns or not total:
|
|
return ['datum: {0}'.format(datum),
|
|
'(not enough data to draw yet -- fill in columns_in, '
|
|
'total_in and rows)']
|
|
|
|
scale = min(1.0, float(_DIAGRAM_WIDTH) / float(total))
|
|
|
|
def col(pos):
|
|
return int(round(pos * scale))
|
|
|
|
width = col(total) + 1
|
|
ticks = [0] + [col(c) for c in columns] + [col(total)]
|
|
lengths = []
|
|
prev = 0.0
|
|
for c in columns:
|
|
lengths.append(c - prev)
|
|
prev = c
|
|
lengths.append(total - prev)
|
|
|
|
ruler = [u'-'] * width
|
|
for t in ticks:
|
|
ruler[t] = u'+'
|
|
for i in range(len(ticks) - 1):
|
|
a, b = ticks[i], ticks[i + 1]
|
|
label = _fmt_in(lengths[i])
|
|
gap = b - a - 1
|
|
if gap >= len(label):
|
|
start = a + 1 + (gap - len(label)) // 2
|
|
for j, ch in enumerate(label):
|
|
ruler[start + j] = ch
|
|
|
|
lines = [u'datum: {0} (rail on the left, {1}" total)'.format(datum,
|
|
_fmt_in(total)),
|
|
u''.join(ruler)]
|
|
|
|
for r in sorted(rows, key=lambda row: -row['from_bottom_in']):
|
|
marks = strand_row_marks(r, len(columns))
|
|
line = [u' '] * width
|
|
for pos, has in zip([col(c) for c in columns], marks):
|
|
line[pos] = u'X' if has else u'.'
|
|
n = sum(1 for m in marks if m)
|
|
lines.append(u'{0} {1}" from bottom ({2} strand{3})'.format(
|
|
u''.join(line), _fmt_in(r['from_bottom_in']), n,
|
|
u'' if n == 1 else u's'))
|
|
|
|
lines.append(u'columns (in, from datum): ' +
|
|
u', '.join(_fmt_in(c) for c in columns))
|
|
return lines
|
|
|
|
|
|
# --------------------------------------------------------- human-readable IO
|
|
|
|
SPEC_HEADER = u"""# WillTools design spec -- {design}
|
|
#
|
|
# A HAND-CORRECTABLE reading of the design summary sheet, not a certified
|
|
# transcription. Every value below was read by machine (see source.extractor)
|
|
# and NONE of it has been checked by a person unless source.reviewed_by is
|
|
# set. Ticket QC and Panel Lift Check read this file, not the drawing --
|
|
# compare the two before trusting either one.
|
|
#
|
|
# Edit it by hand where the machine got it wrong. It is read back exactly as
|
|
# written: comments and blank lines are ignored, nothing here is re-derived
|
|
# from anything else in the file.
|
|
"""
|
|
|
|
_SOURCE_NOTE = u'''# Where this reading came from, and whether anyone has checked it since.
|
|
# extractor says HOW it was read: 'regex' is the mechanical text/geometry
|
|
# reader -- no judgement applied, every value either matched a rule or is
|
|
# null. Nothing branches on the value; it is recorded so a spec written by
|
|
# one reader is not mistaken for a spec written by another.
|
|
# reviewed_by / reviewed_on are blank until a person sets them by hand after
|
|
# checking this file against the drawing.'''
|
|
|
|
_CONCRETE_NOTE = u'''# f'c and f'ci feed the lift check's concrete-strength gates directly
|
|
# (concrete.fc_psi, concrete.fci_psi) -- get one wrong and every handling
|
|
# stage is checked against the wrong strength, in whichever direction makes
|
|
# the piece look better than it is.'''
|
|
|
|
_MILD_NOTE = u'''# Mild reinforcing grade, where the design summary states one.'''
|
|
|
|
_WWF_NOTE = u'''# Welded wire fabric: the mesh designation as printed, how many layers, and
|
|
# whether it is one layer per face or shared.'''
|
|
|
|
_STRAND_GROUPS_NOTE = u'''# One entry per strand group the design summary calls out separately --
|
|
# shielded or de-tensioned strands get their own line. it_lb (initial
|
|
# tension) feeds the lift check's prestress force directly
|
|
# (reinforcing.strand_groups[].it_lb): a wrong qty or it_lb over- or
|
|
# understates the precompression credited on every handling stage.'''
|
|
|
|
_ENVELOPE_NOTE = u'''# The min/max piece size this design is good for, read by pairing each
|
|
# MAX./MIN. LENGTH/WIDTH label with the dimension text beside it on the
|
|
# drawing. Checked against the piece actually modelled.'''
|
|
|
|
_UNRESOLVED_NOTE = u'''# Every text note on the design summary that no rule above could place,
|
|
# verbatim, with why. This is not optional and it is not a bug list -- it is
|
|
# the mechanism that keeps a line of the drawing from being silently dropped
|
|
# just because nothing recognised it. A rule that does not match returns
|
|
# nothing on its own; landing here is what makes that visible instead of
|
|
# quiet. Read every line before trusting this spec, and fold anything real
|
|
# into the fields above by hand.'''
|
|
|
|
_NOTES_NOTE = u'''# Instructions that stay prose rather than being encoded as a field --
|
|
# stripping/blocking callouts, cut-down rules, anything that is an
|
|
# instruction rather than a number.'''
|
|
|
|
_CONFIDENCE_NOTE = u'''# Per-field confidence, field path -> high | medium | low. Only ever set
|
|
# where a reading made a judgement call -- and only for a field that same
|
|
# reading actually just filled, never for one the mechanical reader had
|
|
# already placed. The mechanical reader makes no judgement calls to rate, so
|
|
# this stays empty on a 'regex' spec.'''
|
|
|
|
|
|
def _strand_layout_note(layout):
|
|
diagram = strand_diagram(layout if isinstance(layout, dict) else {})
|
|
lines = [u'# Where the strands sit, as a matrix: columns_in is the position of',
|
|
u'# each strand across the piece (cumulative distance from datum, the',
|
|
u"# same numbers a detailer reads off the dimension chain); each row is",
|
|
u'# one layer through the thickness, marking which columns it fills. A',
|
|
u'# correct strand COUNT in the wrong PLACE is still a wrong piece, which',
|
|
u'# is why this is a grid rather than a total.',
|
|
u'#',
|
|
u'# The picture below is drawn from the data beneath it and cannot drift',
|
|
u'# from it -- if a number here looks wrong, the picture will look wrong',
|
|
u'# the same way. Hold it next to the drawing.',
|
|
u'#']
|
|
for line in diagram:
|
|
lines.append(u'# ' + line if line else u'#')
|
|
return u'\n'.join(lines)
|
|
|
|
|
|
_REINFORCING_ORDER = ('mild_grade_ksi', 'wwf', 'strand_groups', 'strand_layout')
|
|
_REINFORCING_NOTES = {
|
|
'mild_grade_ksi': _MILD_NOTE,
|
|
'wwf': _WWF_NOTE,
|
|
'strand_groups': _STRAND_GROUPS_NOTE,
|
|
}
|
|
|
|
|
|
def _reinforcing_block(reinforcing):
|
|
reinforcing = reinforcing or {}
|
|
lines = [u'reinforcing:']
|
|
for key in _REINFORCING_ORDER:
|
|
if key not in reinforcing:
|
|
continue
|
|
lines.append(u'')
|
|
if key == 'strand_layout':
|
|
lines.append(_strand_layout_note(reinforcing.get('strand_layout')))
|
|
else:
|
|
for note_line in _REINFORCING_NOTES[key].split(u'\n'):
|
|
lines.append(u' ' + note_line)
|
|
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
|
|
for key in reinforcing:
|
|
if key not in _REINFORCING_ORDER:
|
|
lines.extend(yamlkit.emit({key: reinforcing[key]}, 1))
|
|
return u'\n'.join(lines)
|
|
|
|
|
|
_SECTION_ORDER = ('schema', 'design_number', 'piece_description', 'source',
|
|
'concrete', 'reinforcing', 'envelope', 'thickness_in',
|
|
'notes', 'unresolved', 'confidence')
|
|
|
|
_SECTION_NOTES = {
|
|
'source': _SOURCE_NOTE,
|
|
'concrete': _CONCRETE_NOTE,
|
|
'envelope': _ENVELOPE_NOTE,
|
|
'notes': _NOTES_NOTE,
|
|
'unresolved': _UNRESOLVED_NOTE,
|
|
'confidence': _CONFIDENCE_NOTE,
|
|
}
|
|
|
|
|
|
def spec_text(spec):
|
|
"""The whole spec, as hand-editable, commented YAML."""
|
|
spec = spec or {}
|
|
out = [SPEC_HEADER.format(design=spec.get('design_number') or '?')]
|
|
for key in _SECTION_ORDER:
|
|
if key not in spec:
|
|
continue
|
|
out.append(u'')
|
|
if key == 'reinforcing':
|
|
out.append(_reinforcing_block(spec.get('reinforcing')))
|
|
continue
|
|
note = _SECTION_NOTES.get(key)
|
|
if note:
|
|
out.append(note)
|
|
out.extend(yamlkit.emit({key: spec[key]}, 0))
|
|
for key in spec:
|
|
if key not in _SECTION_ORDER:
|
|
out.append(u'')
|
|
out.extend(yamlkit.emit({key: spec[key]}, 0))
|
|
return u'\n'.join(out) + u'\n'
|
|
|
|
|
|
def parse_spec_text(text):
|
|
"""The inverse of spec_text(): hand-edited YAML back to a spec dict."""
|
|
return yamlkit.parse(text)
|