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

1211 lines
57 KiB
Python

# -*- coding: utf-8 -*-
# Owner, verbatim (2026-08-30): "重组 project config ... 点开以后分几个模块:
# file_path; lifing&handeling; ticektqc; job_knowledge. 剩下的都不在这里显
# 示,让用户手动打开 config 文件去修改剩余的部分。后面三个模块全部做成可以互
# 动的样子。比如说 stripping rig mode 后面跟一个 static 的按钮,按一下变成
# rolling。sling angle 那个地方直接让用户可以自己填写。"
"""The four modules of the Project Config window, and the rules of its fields.
The owner, 2026-08-30:
"Restructure project config ... once opened, split it into a few
modules: file_path; lifting&handling; ticketqc; job_knowledge.
Everything else does not show here -- let the user open the config
file by hand to edit the rest. The last three modules should all be
made interactive. Like, put a static button after 'stripping rig
mode' that flips to 'rolling' when clicked. For the sling angle spot,
just let the user fill it in themselves directly."
So this is not a viewer with an edit menu bolted on. It is FOUR MODULES OF
CONTROLS, and everything that is not in one of them is deliberately absent --
reached by opening the file, not by scrolling further.
WHY THE MAPPING IS A TABLE HERE AND NOT A RULE
-----------------------------------------------
`configmodel.SETTINGS` carries a `section` ("Strength", "Lifters", "Output")
and it would be tempting to derive the modules from it. It does not line up:
the owner's four modules are about WHAT A PERSON COMES HERE TO CHANGE, and
`section` is about what a setting IS. `handling.report_dir` is in section
"Output" and belongs in `file_path`; `handling.weight_tolerance` is in the
same section and is not in this window at all.
So the mapping is `MODULE_OF` below, one line per key, and `NOT_SHOWN` is the
other half of it -- every editable setting that this window deliberately does
not carry, each with the reason. `unmapped()` returns anything in neither, and
`tests/test_configform.py` fails on a non-empty answer. That is the whole
point of writing it twice: A SETTING ADDED TO `configmodel` CANNOT QUIETLY
FAIL TO APPEAR. Somebody has to put it in a module or say why it is not in
one.
WHAT A FIELD IS
---------------
Four kinds of control, chosen by what the value is rather than by what is
convenient:
toggle a fixed, small set of values. The control shows the value it HAS
and clicking moves to the next one -- the owner's `static` button
that becomes `rolling`. Never a dropdown for two values.
combo a real dropdown, for a set of values too long to cycle through one
click at a time -- `job.plant`'s plant folders, which the "toggle"
kind would turn into a scroll-wheel. Only `job.plant` needs one so
far, and its options come from disk (`plant_options()`), never a
fixed tuple on the field.
number a box with the number in it.
text a box, for a path or a sentence.
list a box, comma separated, with the LISTS REPLACE warning attached.
mutes THE WHOLE TICKET CHECKLIST, one row each, with a speaker button
beside it -- the Windows volume affordance, click to mute, click
to unmute. The rows come from `ticketpage.CHECKLIST_ITEMS` and
never from a copy kept here (see `checklist_items()`). A REASON IS
REQUIRED and is typed by a person: no default text, no
placeholder, no pre-filled sentence anybody can accept without
reading it.
MUTING DOES NOT STOP THE CHECK. See MUTED_STATE below.
inert DECLARED AND NOT WIRED. A key that is really in the layers, that
the tool really reads nothing from, and whose being inert is a
decision somebody recorded rather than an oversight. Shown with
the ruling, never as a box you can type a number into: a control
that decides nothing is worse than no control, because the person
who typed in it believes something changed.
info shown, not editable. A derived path is a fact about where the
tool put something, not a setting; hiding it would send somebody
looking, and making it editable would be a lie.
NO REVIT. The caller passes the layers it read, the run options it loaded and
the derived paths it computed, and gets back the rows to build controls from.
That is what makes the whole of this testable on host CPython, where the
window itself cannot be opened at all.
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
import datetime
import re
from vibe import configmodel, plantpath
# ------------------------------------------------------------- the modules
FILE_PATH = 'file_path'
LIFTING = 'lifting'
TICKETQC = 'ticketqc'
JOB_KNOWLEDGE = 'job_knowledge'
# The producing-plant dropdown's first entry, for `job.plant`. Chosen means
# "clear job.plant and let `knowledge.detect_plant` work it out" -- never a
# real folder name, so it can never collide with one
# (`plantpath.valid_plant_id` requires the first character to be
# alphanumeric; `(` is not).
AUTO_DETECT = '(auto-detect)'
def plant_options():
"""The producing-plant dropdown's choices, read fresh every call.
`(auto-detect)` first, then every plant folder `vibe.plantpath.plant_ids()`
finds right now -- NOT baked into a module-level tuple at import time,
because a plant folder can be added to the machine mid-session and this
file, like every other `vibe` module, is held by pyRevit's engine until
Revit restarts (see `checklist_items()` below for the same reasoning
applied to the ticket checklist). `plantpath` is Revit-free -- pure path
arithmetic over the local disk -- so this stays callable from a host test
with `WILLTOOLS_KNOWLEDGE` pointed at a fixture root.
"""
return (AUTO_DETECT,) + tuple(plantpath.plant_ids())
# ------------------------------------------------------ what a mute now means
#
# The owner, 2026-08-30, specifying the control and its meaning in one breath:
#
# "config-project config 里面,ticketqc 下面,把 ticket qc 的每一个 item 都列
# 在这里。并且旁边加一个 mute 键,就是 windows 的那个音量图标。我点一下就
# mute 住,然后就默认跑 ticketqc 的时候是 notrun,但是其实还是偷偷跑,只不过
# 只要不是 fail,就给一个灰色的 notrun,而不是红色的 notrun。"
#
# MUTING IS A VOLUME CONTROL, NOT A SWITCH, and that is a change of meaning
# from what muting used to be here. It used to mean "declared out of scope":
# the row left the checklist table and the check was not asked. It now means
# the check RUNS, every time, and only its PRESENTATION changes -- a result
# that is not a failure reports quietly, in grey, instead of as a red row that
# trains the reader to skip red rows.
#
# WHY THE STATE IS ITS OWN VALUE AND NOT RED-WITH-A-GREY-OVERRIDE. A row that
# ran and passed quietly, and a row that could not run at all, are different
# answers, and this whole toolchain exists because the wrong outcome keeps
# looking exactly like the right one. If muted-and-fine were NOT RUN painted a
# different colour then a real NOT RUN -- a check that could not be asked,
# which is a defect in the tooling -- would be one CSS rule away from being
# invisible. So it is a distinct status word, and a renderer that does not
# know the word shows it rather than mistaking it for something it knows.
#
# AND THE WORD HAS TO BE TRUE. "NOT RUN" on a row that ran is a false sentence
# in a report whose whole premise is that it never prints one. `MUTED` is the
# recommendation and is one line to change; the owner has been asked which
# word he wants and has not answered yet.
MUTED_STATE = 'MUTED'
# The seam. `ticketpage` owns the checklist renderer and this file owns the
# window that sets the mute, and the two must agree on the word or the window
# promises something the report does not print.
# `tests/test_configform.py::test_the_muted_state_seam_with_ticketpage`
# holds them equal from the day `ticketpage` grows the constant.
MUTED_STATE_ATTR = 'MUTED_STATE'
# The Windows volume affordance, which is what he asked for by name. Segoe
# MDL2 Assets rather than the emoji speakers: these two code points are in the
# Basic Multilingual Plane, the font ships with Windows 10 and 11, and they
# are the glyphs the volume flyout itself draws. If they ever render as boxes
# on his machine, these two lines are the whole fix.
#
# Written as escapes rather than as the characters themselves: they are
# private-use code points with no glyph in an editor, a diff or a terminal,
# so a literal one is a byte nobody reviewing this file can see or check.
SPEAKER_FONT = 'Segoe MDL2 Assets'
SPEAKER_ON = u'' # Volume -- this row is asked at full volume
SPEAKER_MUTED = u'' # Mute -- this row is asked quietly
# What the button will DO, which is what a button should be labelled by --
# a speaker with no words beside it says the state, and the state is
# already on the row. This is the tooltip.
SPEAKER_HINT = {
False: 'Mute this row. The check still runs on every ticket; it '
'reports quietly instead of red unless it fails.',
True: 'Unmute this row. It goes back to reporting at full volume.',
}
MODULES = (
(FILE_PATH, 'File paths',
'Where this job writes. A blank path means "beside the model", which is '
'what most jobs want; a share path is used exactly as written.'),
(LIFTING, 'Lifting and handling',
'How the piece hangs, and what a Panel Lift run is willing to spend. '
'Click a value to change it; type in a box to set a number.'),
(TICKETQC, 'Ticket QC',
'MUTE A CHECKLIST ITEM HERE. Every row the ticket checklist asks is '
'listed below with a speaker beside it. Click the speaker to mute a row '
'and say why. A muted row STILL RUNS on every ticket -- muting turns the '
'volume down, not the check off -- and a result that is not a failure '
'reports quietly in grey instead of red. A FAILURE IS NEVER QUIETENED. '
'Also: how close two numbers have to be before a check calls them '
'equal.'),
(JOB_KNOWLEDGE, 'Job knowledge',
'Facts about THIS job that the checks read: whose pieces they are, what '
'a ticket counts, and the words this job draws with.'),
)
def module_title(name):
for key, title, _note in MODULES:
if key == name:
return title
return name
def placement():
"""{dotted key: the box that changes it} for everything the window carries.
Handed to `configpage.render` so the full view's every row can say where
it is edited. A key that is missing from this map is edited in the file,
and the page says exactly that -- the two halves of the answer have to
come from one table or they drift.
"""
out = {}
for field in fields():
if field['source'] in ('info', 'inert'):
# Neither is CHANGED anywhere in the window, and naming a box
# beside one would send somebody to a control that is text.
continue
out[field['key']] = module_title(field['module'])
return out
# Which module each editable setting lands in. The key is the dotted path, so
# a rename in `configmodel` shows up here as an unmapped key rather than as a
# field that quietly stopped appearing.
MODULE_OF = {
'handling.report_dir': FILE_PATH,
'handling.rig_mode': LIFTING,
'handling.stages': LIFTING,
'handling.sling_angle_deg': LIFTING,
# Moved here from NOT_SHOWN on the owner's ruling, 2026-08-30:
# "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自
# 己填写。I can see it both ways." The old reason -- that a stress limit
# is argued once and left alone -- was the argument for a doctrine, and
# he declined to have one: 5.0 and 7.5 are both defensible and the
# engineer picks per job. The note under the box carries both values, so
# the choice is made in front of the two numbers rather than from memory.
'handling.tension_limit_sqrt_fc': LIFTING,
# The compression allowable's sibling field. Same ruling, same module,
# 2026-09-01: "0.6保持可调" -- see `configmodel.SETTINGS`'s row for the
# ACI citation and the owner's ruling against implementing the higher
# member-end value.
'handling.compression_limit_ratio': LIFTING,
'handling.group_tolerance_ft': LIFTING,
'handling.dunnage.mode': LIFTING,
'handling.dunnage.fraction': LIFTING,
'checks.muted': TICKETQC,
}
# Editable in `configmodel`, deliberately NOT in this window. Each line is the
# reason, and the reason is always the same shape: it is not a thing this job
# changes between one press and the next, so it belongs in the file where a
# change is a deliberate act with a comment above it.
NOT_SHOWN = {
'handling.product_type':
'the PCI table row for the piece. Set once per job, in the file.',
'handling.finish': 'the PCI table column. Set once per job, in the file.',
'handling.prestress_loss_pct':
'lowering it gives every piece more credit, so it is an engineering '
'decision with a paragraph behind it, not a field.',
'handling.transfer_length_in': 'set with the prestress loss, in the file.',
'concrete.min_fci_ratio': 'a release rule, set with the strength numbers.',
'handling.dunnage.opening_clearance_warn_in':
'a dunnage tolerance; it moves with the two dunnage settings that '
'are here, and it is the one people do not touch.',
'families.lifter_include':
'which families ARE lifting inserts is a plant naming fact that a '
'job inherits. A job list REPLACES the plant list entirely.',
'families.lifter_exclude': 'as families.lifter_include.',
'families.cast_include': 'as families.lifter_include.',
'lifters.dynamic_factor':
'a hardware factor. It belongs with the catalogue, not on a screen '
'somebody is clicking through.',
'lifters.min_edge_distance_in': 'as lifters.dynamic_factor.',
'handling.weight_tolerance':
'the gate that catches a misread blockout. It fails closed, which is '
'right, and loosening it should cost more than one click.',
'handling.open_report': 'a convenience, not a job fact.',
'handling.fast_lifter_box':
"a face lifter's position IS its moment arm; fast/precise is a "
'measurement decision, made once.',
'handling.fast_lifter_box_batch': 'as handling.fast_lifter_box.',
}
# --------------------------------------------------------------- the fields
#
# `key` dotted path into the merged knowledge tree, or the run-option key
# `module` which of the four
# `kind` toggle / number / text / list / mutes / stages / info
# `source` 'job' -- written to the job knowledge layer through splice/verify
# 'run' -- written to run-options.yaml, which has no layers at all
# 'info' -- shown, never written
# `values` for a toggle, the cycle, in order. `None` is a real member of it
# and means blank/inherit.
# `label` the words on screen. Falls back to the SETTINGS label.
# `note` one line under the control.
def _f(key, module, kind, source='job', values=None, label=None, note=None):
return {'key': key, 'module': module, 'kind': kind, 'source': source,
'values': tuple(values) if values else None,
'label': label, 'note': note}
# Keys this window edits that `configmodel.SETTINGS` does not carry a row for.
# They are job-layer knowledge all the same -- `splice` writes any dotted key
# -- and they are the ones the owner named for the job_knowledge module.
EXTRA = (
_f('lateral.output_dir', FILE_PATH, 'text', label='Lateral export folder',
note='Where Lateral Extract writes. Blank puts it in the model\'s own '
'_ticketqc/lateral/ folder, never in the repository.'),
_f('snapshot.detached_dir', FILE_PATH, 'text',
label='Snapshot archive folder',
note='Where timestamped detached copies go. BLANK TURNS PHASE C OFF: '
'the button stops after the index, which is the useful half.'),
_f('tolerances.dimension_in', TICKETQC, 'number',
label='Dimension tolerance',
note='Inches. How far a ticket dimension may sit from the model '
'before the check calls it different.'),
# MUTED, NOT DELETED. Owner's ruling on drift D1, 2026-08-30: "不要删掉,
# 要'静音'掉。静音配置应该在 config 里面。" The drift audit found nothing
# anywhere compares a psi against this key -- the f'c and f'ci checks
# compare exactly -- and offered him wire or delete. He took neither: the
# key stays declared and settable in the file, and the fact that it is
# inert becomes a recorded decision instead of a silence.
#
# MUTING A KEY IS NOT MUTING A CHECKLIST ROW, and the two must not be run
# together. A checklist mute changes what a run reports, is a job fact,
# deep-merges across the layers, and is the engineer's to set -- which is
# why it is a control. This is the opposite on every count: it changes no
# run (nothing reads the key on any job), it is the same on every job, and
# its audience is whoever next reads the drift report. So it is a
# DECLARATION, shown here with the ruling, and not a second mute list.
#
# The other half of the same ruling is one line in
# `tools/knowledge_drift_allow.txt`, which is what actually stops the
# drift report calling this key a defect. Neither half is optional: the
# allowlist line without this row leaves a number box on screen that
# decides nothing, and this row without the line leaves the report
# reporting a decision as drift.
_f('tolerances.psi', TICKETQC, 'inert', source='inert',
label='Strength tolerance',
note="MUTED, NOT DELETED (2026-08-30). Absolute psi for f'c and f'ci. "
"Nothing compares a psi against it -- 3.1 and 3.2 compare the "
"ticket and the design summary exactly -- so this key decides "
"nothing on any job today. It is kept, declared and settable in "
"the file rather than deleted, so that the day a tolerance is "
"wanted the number and its name are already there. It is NOT a "
"box here, because a box that decides nothing is worse than no "
"box: whoever typed in it would believe something had changed."),
_f('tolerances.it_lb', TICKETQC, 'number', label='Tension tolerance',
note='Absolute pounds, for strand jacking force.'),
_f('scope.mark_prefixes', JOB_KNOWLEDGE, 'list',
label='My pieces start with',
note='Mark prefixes, e.g. PNL. EMPTY MEANS NO SCOPE DECLARED, which '
'means judge everything and say so -- it never means judge '
'nothing.'),
_f('scope.declared_by', JOB_KNOWLEDGE, 'text', label='Scope declared by',
note='Who said so, so it can be argued with.'),
_f('scope.note', JOB_KNOWLEDGE, 'text', label='Scope note',
note='One sentence, printed with the scope on every report.'),
_f('nomenclature.ticket_unit', JOB_KNOWLEDGE, 'toggle',
values=(None, 'control_number', 'piece_mark'),
label='One ticket per',
note='What one exported ticket covers. Read by the takeoff '
'(takeoffcount.counting_plan): control_number lets the exported '
'tickets count pieces; piece_mark WITHDRAWS them, because a '
'ticket filename is then not a piece count; and BLANK withdraws '
'them too -- a refusal that names this key on the page, never a '
'guess that one ticket is one piece.'),
_f('drawings.reinforcing_families', JOB_KNOWLEDGE, 'list',
label='Reinforcing families',
note='Generic-model families that ARE bars. Empty is safe and costs '
'accuracy: a bar this does not identify can be reported as '
'missing from the bed plan when it correctly lives in a section.'),
_f('drawings.full_length_bar_fraction', JOB_KNOWLEDGE, 'number',
label='Full-length bar fraction',
note='A fraction in (0, 1]. Blank stands 5.2\'s reinforcing half down '
'and says why, rather than guessing.'),
_f('strand.debond_callout_tokens', JOB_KNOWLEDGE, 'list',
label='Debond call-out words',
note='The words this job draws debonding with, e.g. DEBOND.'),
# ---- which plant folder this job is produced at ----------------------
#
# Gitea will/bridge issue #2. `knowledge.detect_plant` resolves a plant in
# three levels -- Project Information's manufacturer field, then
# titleblock family name patterns, then nothing -- and the owner's ruling
# is that when all three miss, the ENGINEER picks the plant once, here,
# and it is written to the job layer as `job.plant` so every later run
# (and every other tool that reads `knowledge.load()`) reads it without
# asking again.
#
# A DROPDOWN, NOT A TOGGLE, because `next_value`'s cycle is for two or
# three states a person can click through; a plant list is arbitrarily
# long and open one click at a time is a scroll-wheel wearing a button's
# clothes. See the 'combo' kind above and `plant_options()`.
#
# `(auto-detect)` is not a fourth plant-choice state, the same way `null`
# already means exactly what it needs to (see `docs/knowledge-override.md`
# on `left_rail_is_fixed` for the general shape of that argument): it
# clears `job.plant` and lets `knowledge.detect_plant` answer again,
# which is precisely what an install that has never named a plant has
# always done.
_f('job.plant', JOB_KNOWLEDGE, 'combo', label='Producing plant',
note='Which plant folder this job\'s plant-layer knowledge comes from. '
'{0} lets knowledge.detect_plant work it out itself, in order: '
'the producer named on Project Information matched against each '
'plant folder\'s aliases, then (only if that misses) the '
"titleblock family names matched against each plant's "
'titleblock_patterns. THE PLANT KNOWLEDGE FILE PICKED BELOW '
'(Plant knowledge file) ALWAYS WINS OVER THIS -- while it names a '
'file, this dropdown is stored but not read. Clear that box '
'first if this choice should take effect.'.format(AUTO_DETECT)),
# ---- which FILE the plant / public layer reads, not a value in it -----
#
# The owner, 2026-09-02 (translated): "add plant knowledge -- let the
# user choose the plant knowledge file. And public knowledge -- let the
# user choose the public knowledge file." See
# `configmodel.KNOWLEDGE_SOURCE_KEYS` for why choosing WHICH FILE a
# layer reads from is not the same act as typing over a value inside
# it, and is allowed here on that basis.
#
# WIRED INTO THE READ SIDE as of the three-level `detect_plant` /
# `plant_choice` rework (Gitea will/bridge issue #2): `knowledge.load()`
# (through `plant_id(doc)` / `plant_choice(doc)`) now consults this key
# FIRST, ahead of `job.plant` above and ahead of detection -- this
# paragraph used to say the opposite and that was the state of the code
# until this rework landed. The "currently read from" info row above it
# (built in `script.py` from `knowledge.sources(doc)`) remains the ground
# truth for what a run reads today, independent of this paragraph.
_f('knowledge_sources.plant_file', JOB_KNOWLEDGE, 'filepick',
label='Plant knowledge file',
note='Pick the plant knowledge.yaml this job should read. Blank goes '
'back to the normal discovery -- WILLTOOLS_KNOWLEDGE, then '
"this job's job.plant, then the tool's default. A chosen file "
'that later goes missing is refused loudly, never silently '
'reused as the default.'),
_f('knowledge_sources.public_file', JOB_KNOWLEDGE, 'filepick',
label='Public knowledge file',
note="Pick the catalogue.yaml this job should read in place of the "
"tool's shipped knowledge/catalogue.yaml. Blank goes back to "
'the shipped file. A chosen file that later goes missing is '
'refused loudly, never silently reused as the default.'),
)
# The run-time options, split between the two modules whose runs they govern.
# They are NOT knowledge -- no layers, no provenance, nothing overrides them
# -- and the window says so where they sit.
RUN_MODULE = {
'recommend_positions': LIFTING,
'expensive_geometry': LIFTING,
'vision_escalation': TICKETQC,
'vision_escalation_max_calls': TICKETQC,
}
# How a SETTINGS row is presented when this window carries it. Without an
# entry the kind is derived from `row['kind']`; with one, this wins -- which
# is how `handling.rig_mode` becomes a two-state button rather than a list.
#
# A `note` here replaces the SETTINGS note for this window only. The full
# view in `configpage` is a table of every key in every layer and its note
# has to stay one line; a control somebody is about to type a number into
# can afford the sentence that says what the number does.
PRESENT = {
'handling.rig_mode': {'kind': 'toggle', 'values': ('rolling', 'static')},
'handling.dunnage.mode': {'kind': 'toggle',
'values': (None, 'lifter_rows', 'fraction')},
'handling.stages': {'kind': 'stages'},
'checks.muted': {
'kind': 'mutes',
'note': 'Every row the ticket checklist asks is listed below. Click '
'the speaker beside one and type why this job never has it -- '
'the reason is REQUIRED and the date is added for you. MUTING '
'IS NOT SWITCHING OFF: the check runs on every ticket exactly '
'as before, keeps its row on the table, and reports {0} in '
'grey rather than a red NOT RUN when it did not fail. A '
'FAILURE IS NEVER QUIETENED -- it stays red on the table and '
'in the tally, and every finding still reaches the JSON. The '
'layers merge this list, so a mute set by the plant is shown '
'here and is not this window\'s to remove.'.format(
MUTED_STATE)},
'handling.report_dir': {'kind': 'text'},
'handling.sling_angle_deg': {'kind': 'number'},
'handling.group_tolerance_ft': {'kind': 'number'},
'handling.dunnage.fraction': {'kind': 'number'},
# The two reference values are ON the row, because the number typed here
# multiplies sqrt(f'c) and a person who does not already know which two
# numbers are argued about cannot tell 5 from 7.5 from 6. The owner
# deliberately left the choice per job -- so the window has to put the
# choice in front of whoever is making it.
'handling.tension_limit_sqrt_fc': {
'kind': 'number',
'note': "Multiplies sqrt(f'c), in psi -- 5.0 on 4000 psi concrete is "
"316 psi allowable. 5.0 = rupture over a 1.5 factor (no "
"discernible cracking, conservative); 7.5 = the rupture "
"modulus itself (first cracking, no margin, PCI-permitted "
"and what the commercial check used). A FACTOR OF 1.5 ON "
"EVERY STRESS VERDICT. Blank inherits; the report names the "
"limit it ran."},
'handling.compression_limit_ratio': {
'kind': 'number',
'note': "Multiplies f'c (f'ci at stripping), in psi -- 0.6 on 4000 "
"psi concrete is 2400 psi allowable. 0.6 is ACI 318-19 Table "
"24.5.3.1 (\"all other locations\", transfer) / Sec. "
"24.5.4.1 (service). ACI permits 0.70 AT MEMBER ENDS; this "
"tool deliberately does not implement that and applies one "
"flat ratio everywhere on the piece instead -- the "
"conservative choice. Blank inherits; the report names the "
"ratio it ran."},
}
_KIND_OF = {'float': 'number', 'int': 'number', 'path': 'text',
'bool': 'toggle', 'choice': 'toggle', 'list': 'list',
'map': 'mutes', 'stages': 'stages', 'text': 'text'}
BLANK = '(blank)'
# --------------------------------------------------------------- the mapping
def settings_fields():
"""One field per editable SETTINGS row this window carries, in table order.
Reads `configmodel.editable()` live, so a build that does not know a
setting simply does not produce a field for it -- rather than producing a
field whose write nothing would validate.
"""
out = []
for row in configmodel.editable():
module = MODULE_OF.get(row['key'])
if not module:
continue
shape = PRESENT.get(row['key']) or {}
kind = shape.get('kind') or _KIND_OF.get(row['kind']) or 'text'
values = shape.get('values')
if kind == 'toggle' and not values:
if row['kind'] == 'bool':
values = (True, False)
else:
values = tuple(row['choices'] or ()) or (None,)
field = _f(row['key'], module, kind, source='job', values=values,
label=row['label'],
note=shape.get('note') or row.get('note'))
field['setting'] = row
out.append(field)
return out
def run_fields():
"""One field per run-time option, in `configmodel.RUN_OPTIONS` order."""
out = []
for row in configmodel.RUN_OPTIONS:
module = RUN_MODULE.get(row['key'])
if not module:
continue
if isinstance(row['default'], bool):
field = _f(row['key'], module, 'toggle', source='run',
values=(True, False), label=row['label'],
note=row['note'])
else:
field = _f(row['key'], module, 'number', source='run',
label=row['label'], note=row['note'])
out.append(field)
return out
def fields(info=None):
"""Every field in the window, grouped nowhere yet. `info` is [(module,
label, value, note)] for the derived paths the caller worked out."""
out = list(settings_fields()) + list(EXTRA) + run_fields()
for module, label, value, note in info or []:
row = _f(label, module, 'info', source='info', label=label, note=note)
row['value'] = value
out.append(row)
return out
def unmapped():
"""Editable settings that are in no module and in no not-shown reason.
Empty is the contract. A non-empty answer means somebody added a setting
to `configmodel` and did not decide where a person changes it, which is
how a knob ships that nothing on screen can reach.
"""
return [row['key'] for row in configmodel.editable()
if row['key'] not in MODULE_OF and row['key'] not in NOT_SHOWN]
def not_shown_rows():
"""[(label, key, why)] for the settings this window deliberately omits."""
out = []
for row in configmodel.editable():
why = NOT_SHOWN.get(row['key'])
if why:
out.append((row['label'], row['key'], why))
return out
# ------------------------------------------------------ reading the current
def resolve_all(fields_, layers, run_options, know_get=None):
"""Each field with what it reads NOW: value, display text, chip, layer.
`know_get(key)` is only used for the EXTRA keys, which have no SETTINGS
row: their provenance still comes from `configmodel.resolve`, so this is
a single code path and a job override of `scope.mark_prefixes` shows the
same `J` chip as an override of `handling.rig_mode`.
"""
out = []
for field in fields_:
row = dict(field)
if field['source'] == 'info':
row['display'] = '{0}'.format(field.get('value'))
row['chip'] = ''
row['layer'] = ''
out.append(row)
continue
if field['source'] == 'run':
option = configmodel.run_option(field['key']) or {}
value = (run_options or {}).get(field['key'], option.get('default'))
row['value'] = value
row['display'] = display(field, value)
row['chip'] = '-'
row['layer'] = 'run options (no layers)'
# A run option has no layer to carry a chip, so "modified" is
# answered a different way for it: whether run-options.yaml
# actually names the key, not whether the value differs from the
# default -- a job that wrote its own value back to the default
# is still a decision on record, not a fold-away non-event.
row['is_set'] = field['key'] in (run_options or {})
out.append(row)
continue
res = configmodel.resolve(layers, field['key'])
row['res'] = res
if configmodel.merges(field['key']):
# THE VALUE AND THE WRITE TARGET ARE DIFFERENT THINGS HERE, and
# for a key the layers merge they have to be. `merged` is what the
# run reads, entry by entry, with the layer each entry came from;
# `value` is what THIS file holds, because that is the only thing
# a splice may replace. Using the merge as the write baseline
# would copy the plant's entries into the job file, where they
# would stop tracking the plant for good -- silently, which is the
# only way this project ever gets hurt.
row['merged'] = configmodel.merged_map(layers, field['key'])
row['value'] = configmodel.layer_map(layers, field['key'],
WRITE_LAYER)
row['known'] = res['known']
row['display'] = display(field, row['merged'])
row['chip'] = configmodel.map_chip(row['merged'])
row['layer'] = ' + '.join(
configmodel.map_layers(row['merged'])) or 'nothing'
out.append(row)
continue
row['value'] = res['value'] if res['known'] else None
row['known'] = res['known']
row['display'] = display(field, row['value'], known=res['known'])
row['chip'] = configmodel.chip(res)
row['layer'] = res['layer'] or 'nothing'
out.append(row)
return out
def by_module(rows):
"""[(module key, module title, note, [row])] in MODULES order."""
out = []
for key, title, note in MODULES:
out.append((key, title, note,
[row for row in rows if row['module'] == key]))
return out
# ---------------------------------------------------- folding, and its cost
#
# The owner, 2026-09-01, on the window having grown a fifth screenful:
#
# "project config现在有点儿太复杂了。。。给弄成每个部分都可以折叠的吧。
# 需要调啥,再把那个部分给打开,调整,完事儿。" ("Project Config is a
# bit too complicated now... make every section collapsible. Whatever
# needs adjusting, open that section, adjust it, done.")
#
# Every module folds, and folded is the DEFAULT -- his own interaction model
# is open -> adjust -> done, not scroll-past-four-screens-to-find-the-one-box.
# `script.py` builds each module inside a WPF `Expander` instead of a
# `GroupBox` for exactly this: the same content, the same border, and a
# native collapse the owner did not have to ask for a bespoke control to get.
#
# THE HAZARD THIS CREATES IS THE ONE THE REST OF THIS FILE EXISTS TO PREVENT.
# `docs/plan-panellift-three-stage.md` SS10 names it for a later, generated
# version of this window and the ruling is the same one here, today, on the
# hand-maintained one: a value folded away is a value hidden, and "the config
# becoming a place values hide" is this project's cardinal disease. So a
# module carrying a live override cannot simply fold to the same header as
# one that carries none -- `is_modified` and `modified_count` are what the
# header reads before it decides what to say, and `script.py` never folds a
# module without saying, on the header itself, how many of its rows are not
# the shipped default.
# Chips that mean "nothing here is a decision anybody made" -- 'D' is the
# code default, '?' is not known in any layer. Every other chip (`P`, `J`,
# `!`, `+`, or a merged map's single-layer letter) means a real layer put a
# real value in force, which is the thing a folded header must not hide.
_UNMODIFIED_CHIPS = ('D', '?')
def is_modified(row):
"""Whether this row carries a value that is not the shipped default.
`info` and `inert` rows are never counted: an `info` row is a derived
path, not a decision, and an `inert` row is declared-and-not-wired by
name (see the `inert` kind at the top of this file) -- whatever the file
says under it changes no run, so it is not the kind of override a folded
header exists to surface.
"""
if row['kind'] in ('info', 'inert'):
return False
if row['source'] == 'run':
return bool(row.get('is_set'))
return row.get('chip') not in _UNMODIFIED_CHIPS
def modified_count(rows):
"""How many of these rows carry a live override -- the number a folded
module's header states so folding it never hides one."""
return len([row for row in rows if is_modified(row)])
# `script.py` re-runs on every press of the button and throws its own
# globals away each time it does; this module does not -- pyRevit's engine
# holds `vibe` until Revit restarts (see CLAUDE.md SS "Editing an existing
# script.py needs no reload"). So a SESSION-ONLY memory of which module was
# left open lives HERE, in the module that survives between presses, rather
# than in the button script that does not.
#
# NOT WRITTEN TO DISK. Closing Revit forgets it, and that is the point: this
# is a screen convenience for the owner's own "open it, adjust it, close it"
# rhythm inside one sitting, not a setting, and it must never grow into one --
# the owner asked to remember it "if cheap," not to build persistence
# machinery for a WPF window's scroll position.
_EXPANDED = {}
def remembered_expanded(key):
"""Whether `key`'s module was left open the last time this window was
used in this Revit session. Collapsed (False) the very first time,
always: the owner's rhythm is open -> adjust -> done, and a module that
starts open because somebody looked at it once already undoes that."""
return bool(_EXPANDED.get(key, False))
def remember_expanded(key, is_expanded):
_EXPANDED[key] = bool(is_expanded)
def knowledge_source_state(res):
"""One sentence for a `knowledge_sources.*` row: never touched, cleared
back to the default, or pointing somewhere.
EMPTY AND ABSENT ARE DIFFERENT STATES and must read differently on
screen, the same distinction `configmodel.knowledge_source_problems`
keeps silent about on purpose: a job file that never mentions the key is
every install before this feature existed, unremarkable; a job file that
states the key as blank is a decision made in THIS window, on purpose,
to go back to the normal discovery. `res` is `configmodel.resolve`'s
answer for one of `configmodel.KNOWLEDGE_SOURCE_KEYS`.
"""
if not res['known']:
return 'Never set. Reads from the normal discovery.'
if not res['value']:
return 'Cleared here ({0}). Reads from the normal discovery.'.format(
res['layer'])
return 'Set ({0}): {1}'.format(res['layer'], res['value'])
def display(field, value, known=True):
"""What the control shows. Never an empty string.
A blank control and a control showing a blank VALUE look identical, and
only one of them means "nothing here decides this".
"""
kind = field['kind']
if kind == 'inert':
# BEFORE the `known` check, and that ordering is the point. "(not
# set)" on a key nothing reads is true and useless: it invites
# somebody to set it. What the row has to say is that setting it
# changes nothing, whether a layer has set it or not.
if value is None or not known:
return 'MUTED -- nothing reads it'
return 'MUTED -- nothing reads it (the file says {0})'.format(value)
if not known:
return '(not set)'
if kind == 'toggle':
if value is None:
return BLANK
if value is True:
return 'on' if field['source'] == 'run' else 'true'
if value is False:
return 'off' if field['source'] == 'run' else 'false'
return '{0}'.format(value)
if kind == 'combo':
# `None` here means "(auto-detect)", never BLANK -- BLANK is the
# toggle kind's "no opinion in any layer" word and this row always
# has an opinion: cleared IS auto-detect, not an absence of one.
return AUTO_DETECT if value is None else '{0}'.format(value)
if kind == 'list':
if not value:
return ''
return ', '.join('{0}'.format(part) for part in value)
if kind == 'stages':
if not isinstance(value, list):
return '(no stages)'
return '{0} stage(s)'.format(len(value))
if kind == 'mutes':
# TWO SHAPES REACH HERE and both are real. The row's `merged` is
# `configmodel.merged_map` -- {number: {'layer': ...}} -- and says who
# muted what; the row's `value`, and the before/after of a pending
# change, is this file's own plain {number: reason}. Told apart by
# looking rather than by a flag, because the caller that got it wrong
# would be the one printing the summary after the window has closed,
# where a traceback has nowhere to go.
if not isinstance(value, dict) or not value:
return 'nothing muted'
where = configmodel.map_layers(value) \
if all(isinstance(v, dict) and 'layer' in v
for v in value.values()) else []
if where:
return '{0} muted ({1})'.format(len(value), ' + '.join(where))
return '{0} muted: {1}'.format(len(value), ', '.join(sorted(value)))
if value is None:
return ''
return '{0}'.format(value)
# ---------------------------------------------------------------- the toggle
def next_value(field, value):
"""The value one click moves to. Wraps, so a mis-click is one more click.
The cycle is short on purpose. A toggle over six values is a dropdown
somebody has to click six times, and this is only used where the set is
two or three long.
"""
values = list(field.get('values') or ())
if not values:
return value
for index, candidate in enumerate(values):
if candidate == value and type(candidate) is type(value):
return values[(index + 1) % len(values)]
# Not one of ours -- including "not set in any layer". Start at the top
# rather than guessing which end of the cycle the reader meant.
return values[0]
# ---------------------------------------------------------------- the typing
def parse(field, text):
"""Text from a box into a value. (ok, value, complaint).
Delegates to `configmodel.parse_input`, which is where "nothing is
guessed" lives -- `static_lift` is refused rather than read as `static`.
"""
kind = field['kind']
if kind == 'number':
return configmodel.parse_input('float', text)
if kind == 'list':
return configmodel.parse_input('list', text)
return configmodel.parse_input('path', text)
def same(before, after):
"""Whether an edit actually changed anything.
Compared by value AND by type: `1` and `True` are equal in Python and are
not the same setting, and a write that changes nothing still stamps a new
override comment on a value nobody touched.
"""
if isinstance(before, bool) != isinstance(after, bool):
return False
return before == after
# ----------------------------------------------------------------- the mutes
#
# THE DEFECT THIS SHAPE EXISTS FOR. The owner went looking for the mute
# control twice and did not find it, on two separate days -- "我没找到配置静音
# 的位置啊。。。我以为在 project config 里面" and, that night, "我还想着打开一
# 下 config 调一调这个检查,结果没成功". It WAS in the Project Config window
# both times: a 60-pixel box you typed a checklist number into, under a
# heading that said "Muted checklist items", offering nothing to somebody who
# had a check in mind and did not know its number. A control you can only
# operate if you already know the answer is not a control, and looking for it
# twice and failing twice is the measurement that says so.
#
# So the module lists EVERY ROW THE CHECKLIST ASKS -- number, grade, name --
# and muting is clicking the speaker beside the one you mean. Nobody has to
# know that "release ratio" is 3.3.
#
# AND MUTING NO LONGER STOPS THE CHECK (see MUTED_STATE at the top of this
# file). The row runs on every ticket and reports quietly unless it failed.
# That is worth stating here as well as there, because everything below is
# about WHOSE mute an entry is, and it would read as an on/off switch
# otherwise.
#
# THE ROWS COME FROM `ticketpage.CHECKLIST_ITEMS`, NEVER FROM A COPY. That is
# not tidiness. `CHECKLIST_FOR_ENGINEER` was a hand-kept tuple holding
# `('5.5',)`, 5.3 was never added to it, and 5.3 printed a red NOT RUN on
# every ticket for as long as that lasted -- a day of somebody's attention
# spent on a row that was missing from a second list. A hand-kept copy here
# would fail the same way and worse: a checklist row this window never draws
# is a row nobody can mute, and nothing anywhere would say why.
WRITE_LAYER = 'job'
MUTE_WHO = 'Project Config'
# `<reason> (Project Config, 2026-08-30)` -- the shape the owner's own mutes
# already carry ("... (Will 2026-08-30)"), so a mute made here reads like a
# mute made by hand rather than like a machine's row.
_DATED = re.compile(r'\(\s*[^()]*\d{4}-\d{2}-\d{2}\s*\)\s*$')
def checklist_items():
"""The real checklist table: [(number, name, grade)], in its own order.
Imported HERE and lazily, in the shape `configmodel._validate_muted`
already uses. Two reasons, and neither is style: `vibe.ticketpage` is a
3000-line module this one otherwise has no business loading, and a Revit
session holds `vibe` until it restarts -- so this file can be newer than
the `ticketpage` beside it. An empty answer is a real answer and the
window says so on screen; it must never be a stack trace over a window
that was opened to change a path.
"""
try:
from vibe.ticketpage import CHECKLIST_ITEMS
except Exception: # pylint: disable=broad-except
return []
out = []
for item in CHECKLIST_ITEMS:
try:
out.append((item[0], item[1], item[2]))
except Exception: # pylint: disable=broad-except
continue
return out
def checklist_numbers():
return set(number for number, _name, _grade in checklist_items())
def stamp_reason(reason, when=None, who=MUTE_WHO):
"""The typed reason with today's date on it, and never twice.
THE DATE IS THE TOOL'S JOB AND THE SENTENCE IS THE PERSON'S. The reason
itself is never written, defaulted or suggested -- there is no placeholder
text anywhere in this feature, because a sentence somebody accepted
without reading is the same as no sentence at the moment it has to be
argued with. What a person should not have to do is remember to type the
date, so that half is added here.
Idempotent: a reason that already ends in a parenthesised date is left
exactly as it was, so re-muting an item does not stack `(Project Config,
2026-08-30) (Project Config, 2026-08-30)` on the end of it.
"""
text = ('{0}'.format(reason or '')).strip()
if not text:
return ''
if _DATED.search(text):
return text
when = when or datetime.datetime.now().strftime('%Y-%m-%d')
return '{0} ({1}, {2})'.format(text, who, when)
def add_mute(mutes, item, reason, when=None, known=None):
"""One checklist item muted, with the reason that is not optional.
Enforced HERE and not only in `configmodel.validate`, because the loaded
build of `configmodel` may predate the rule -- and a mute with no reason
is precisely the row that cannot be told apart from a bug by the person
reading the report six months from now.
`known` is the set of real checklist numbers. Passed in rather than looked
up so a caller that could not read the table (see `checklist_items`) can
say so by passing None, and get the old behaviour of accepting the number
on trust, instead of refusing every mute because a module would not load.
A number that is not a checklist item mutes NOTHING, and a mute that does
nothing is how somebody believes a row is off while it is on.
"""
out = dict(mutes or {})
number = ('{0}'.format(item or '')).strip()
why = ('{0}'.format(reason or '')).strip()
if not number:
return False, out, ('Which checklist item? Type its number, e.g. 3.3.')
if known is not None and number not in known:
return False, out, (
'{0} is not a ticket-checklist item, so muting it would mute '
'nothing at all. Press Mute on the row you mean.'.format(number))
if not why:
return False, out, (
'A reason is REQUIRED. Muting quietens a row on every report this '
'job produces, and a row that went quiet for no stated reason '
'cannot be told apart from a bug by whoever reads it next.')
out[number] = stamp_reason(why, when=when)
return True, out, None
def remove_mute(mutes, item):
out = dict(mutes or {})
number = ('{0}'.format(item or '')).strip()
if number in out:
del out[number]
return True, out, None
return False, out, '{0} is not muted.'.format(number or '(nothing)')
def mute_rows(value):
"""[(item, reason)] sorted, for building one control row each."""
if not isinstance(value, dict):
return []
return [(key, '{0}'.format(value[key])) for key in sorted(value)]
# The sentence a row muted by a layer this window does not write has to carry.
# Not "greyed out": a greyed control with no reason reads as a bug, and this
# one has a real reason that is also the whole design of the layer stack.
NOT_MINE = (
'Muted by the {0} layer, and this window writes the job layer only. The '
'layers MERGE this map, so a job entry cannot take a {0} mute away -- '
'unmuting it here would look like it worked and change nothing. Edit the '
'{0} file to unmute it.')
# And the sentence for the case that looks identical on screen and is not: a
# job mute sitting on top of a lower layer's mute of the SAME item.
FALLS_BACK = (
'Unmuting here removes this job\'s entry, and the {0} layer\'s mute of '
'{1} takes over: "{2}". The item stays muted.')
def mute_table(merged, job_mutes, items=None):
"""Every checklist row, its mute state, and whose mute it is.
Returns one dict per row:
number, name, grade from `ticketpage.CHECKLIST_ITEMS`
known False for a muted number that is not a
checklist item at all -- listed at the end
rather than dropped, because a number that
mutes nothing is a thing somebody believes is
working
muted, reason, layer the entry in force and the layer that set it
editable whether pressing a button here can change it
why_not for editable=False, the sentence saying why
falls_back for a job mute over a lower layer's mute of the
same item, the sentence saying that unmuting
does not unmute
`merged` is `configmodel.merged_map(layers, 'checks.muted')`; `job_mutes`
is the job layer's own map, which is what a splice replaces.
"""
merged = merged if isinstance(merged, dict) else {}
job_mutes = job_mutes if isinstance(job_mutes, dict) else {}
listed = checklist_items() if items is None else list(items)
out = []
for number, name, grade in listed:
out.append(_mute_row(number, name, grade, True, merged, job_mutes))
seen = set(number for number, _n, _g in listed)
for number in sorted(merged):
if number in seen:
continue
out.append(_mute_row(number, '(not a checklist item -- this mutes '
'nothing)', '', False, merged, job_mutes))
return out
def _mute_row(number, name, grade, known, merged, job_mutes):
row = {'number': number, 'name': name, 'grade': grade, 'known': known,
'muted': False, 'reason': '', 'layer': None, 'editable': True,
'why_not': None, 'falls_back': None}
entry = merged.get(number)
if entry is None:
# Not muted. Editable unless it is not a real row: muting a number
# that is not on the checklist is refused, not offered.
row['editable'] = bool(known)
if not known:
row['why_not'] = 'There is no checklist item {0}.'.format(number)
return row
row['muted'] = True
row['reason'] = '' if entry['value'] is None else \
'{0}'.format(entry['value']).strip()
row['layer'] = entry['layer']
if entry['layer'] != WRITE_LAYER or number not in job_mutes:
row['editable'] = False
row['why_not'] = NOT_MINE.format(entry['layer'])
return row
beaten = [pair for pair in entry.get('overrides') or []]
if beaten:
label, value = beaten[-1]
row['falls_back'] = FALLS_BACK.format(
label, number, '' if value is None else '{0}'.format(value))
return row
def mute_summary(rows):
"""One line above the list. A count that says how many rows exist too --
"2 muted" alone cannot tell you whether the list you are looking at is the
whole checklist or the part of it somebody remembered."""
rows = rows or []
real = [row for row in rows if row['known']]
muted = [row for row in rows if row['muted']]
mine = [row for row in muted if row['editable']]
if not real:
return ('The checklist table could not be read in this Revit session, '
'so there is no list to mute from. RESTART REVIT -- a pyRevit '
'reload is never the answer.')
line = '{0} of the {1} checklist rows are muted on this job.'.format(
len(muted), len(real))
if not muted:
return line + ' Every row reports at full volume.'
# Said on every screen that shows a mute, because it is the half people
# get wrong: the checks did not stop.
line += (' All {0} still run on every ticket -- muting only makes a '
'result that is not a failure report quietly.'.format(len(real)))
if len(mine) != len(muted):
line += (' {0} of the mutes {1} set by a layer this window does not '
'write and cannot be changed here.'.format(
len(muted) - len(mine),
'is' if len(muted) - len(mine) == 1 else 'are'))
return line
# ---------------------------------------------------------------- the stages
STAGE_MODES = ('rolling', 'static', None)
def stage_rows(value):
"""[(index, name, rig_mode, enabled)] -- one control row per stage.
The owner's example is exactly this: "put a static button after
'stripping rig mode' that flips to 'rolling' when clicked". The stage's name is the label and its rig mode
is the button.
"""
out = []
if not isinstance(value, list):
return out
for index, stage in enumerate(value):
if not isinstance(stage, dict):
continue
out.append((index, stage.get('name') or 'stage {0}'.format(index + 1),
stage.get('rig_mode'), stage.get('enabled', True) is not False))
return out
def set_stage(stages, index, name, value):
"""One cell changed, and the WHOLE list handed back.
`handling.stages` is one list and lists replace: writing it to the job
layer replaces the plant's entire list, not the one stage edited. So the
edit is made against a copy of everything in force, and the caller writes
all of it -- which is the only way the result is the list the reader saw
with one thing different.
"""
out = []
for position, stage in enumerate(stages or []):
copy = dict(stage) if isinstance(stage, dict) else {}
if position == index:
copy[name] = value
out.append(copy)
return out
def stage_display(mode):
return BLANK if mode is None else '{0}'.format(mode)
# --------------------------------------------------------------- the escape
def file_url(path):
"""A `file:///` link the output panel will actually open.
A Windows path is backslashed and a job folder usually has a space in it.
Markdown ends a link target at the first space, so `[x](file:///C:/My
Job/k.yaml)` silently becomes a broken link and half a sentence -- which
is exactly the affordance that is supposed to be the way OUT of this
window to the file. Escaped here, once, rather than at each caller.
"""
body = ('{0}'.format(path or '')).replace('\\', '/')
return 'file:///' + body.replace(' ', '%20').replace('(', '%28') \
.replace(')', '%29')
# ------------------------------------------------------------- the summary
def change_lines(changes):
"""One line per pending change, for the window's own status area.
Label first, value second, key last and small -- the same order the page
uses, because a person who has just clicked a button called "Lift type"
should not have to translate `handling.rig_mode` to check what they did.
"""
out = []
for field, before, after in changes or []:
out.append('{0}: {1} -> {2} ({3})'.format(
field.get('label') or field['key'],
display(field, before), display(field, after), field['key']))
return out