WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Where an extracted design spec is saved, and what is still unresolved in it.
|
||||
|
||||
Nothing here touches Revit, a document, a form or the extraction itself -- it
|
||||
is paths and text in, paths and text out. That is deliberate, and it is the
|
||||
same boundary `specformat.py` sits on: the rules the Extract Design button
|
||||
follows when it asks the engineer where to put a file, and when it lists what
|
||||
the reader could not place, are exercised by an ordinary CPython test with no
|
||||
licence and no model (see `tests/conftest.py`).
|
||||
|
||||
THE TWO THINGS THIS EXISTS FOR
|
||||
------------------------------
|
||||
1. A SECOND COPY, NOT A MOVE. The canonical spec has to stay in
|
||||
`<model>/_ticketqc/specs/` -- `designspec.list_specs` reads that folder and
|
||||
nothing else, so Ticket QC and Panel Lift Check only ever see what is
|
||||
there. A spec "saved" somewhere else and nowhere else would look extracted
|
||||
and be invisible to every check downstream. So the engineer's chosen path
|
||||
gets a COPY, and `targets()` is the rule for naming it.
|
||||
|
||||
2. UNRESOLVED BY NAME, NOT BY COUNT. "3 unresolved notes" tells a reader
|
||||
there is something to look at and nothing about whether to look now.
|
||||
The entries are short, they are the whole point of the extraction being
|
||||
honest, and they belong on the page.
|
||||
|
||||
Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib.
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
import io
|
||||
import os
|
||||
|
||||
from vibe import specformat
|
||||
from vibe.compat import as_text, string_types
|
||||
|
||||
|
||||
# The pointer file that remembers where an extraction was last saved, kept
|
||||
# beside the rest of the job's QC data. It is a convenience, not a setting:
|
||||
# it only stops the dialog opening in the wrong folder every single press.
|
||||
# Where the files BELONG is the knowledge/config layers' business, and a
|
||||
# pointer that is stale or gone must never do anything but be ignored.
|
||||
POINTER = 'last-extract-dir.txt'
|
||||
|
||||
|
||||
def safe_name(name):
|
||||
"""A design number as a filename. The one rule -- `designspec._safe`
|
||||
delegates here so the copy cannot be named differently from the original.
|
||||
"""
|
||||
out = []
|
||||
for ch in (name or 'unnamed'):
|
||||
out.append(ch if (ch.isalnum() or ch in '._- ') else '_')
|
||||
return ''.join(out).strip()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ where it goes
|
||||
|
||||
def default_name(designs, ext='yaml'):
|
||||
"""The name to seed the save dialog with.
|
||||
|
||||
One design puts its own number in the box, which is the name the file
|
||||
already has and the one the engineer expects to see. Several designs
|
||||
cannot share a file name, so the box carries the count instead -- it is
|
||||
the FOLDER that is being picked in that case, and `targets()` says so.
|
||||
"""
|
||||
designs = [d for d in (designs or []) if d]
|
||||
if len(designs) == 1:
|
||||
return '{0}.{1}'.format(safe_name(designs[0]), ext)
|
||||
if not designs:
|
||||
return 'design-extraction.{0}'.format(ext)
|
||||
return 'design-extraction-{0}-designs.{1}'.format(len(designs), ext)
|
||||
|
||||
|
||||
def targets(chosen, designs, ext='yaml'):
|
||||
"""[(design, path)] -- where each design's copy is written.
|
||||
|
||||
A single design is written to EXACTLY the path that was typed. A file
|
||||
name box that quietly does nothing is a trap (CLAUDE.md says this about
|
||||
the config file and it is the same trap here), so where the name can be
|
||||
honoured it is.
|
||||
|
||||
Several designs cannot all be one file. The folder that was picked is
|
||||
used and each design keeps its own name, because a design number is how
|
||||
every other part of this tool finds the file -- renaming them to
|
||||
`something-1.yaml`, `something-2.yaml` would make the set unreadable.
|
||||
The button says this in the output rather than leaving it to be
|
||||
discovered.
|
||||
"""
|
||||
designs = [d for d in (designs or []) if d]
|
||||
if not chosen or not designs:
|
||||
return []
|
||||
if len(designs) == 1:
|
||||
return [(designs[0], chosen)]
|
||||
folder = os.path.dirname(chosen) or '.'
|
||||
suffix = os.path.splitext(chosen)[1] or ('.' + ext)
|
||||
return [(d, os.path.join(folder, safe_name(d) + suffix)) for d in designs]
|
||||
|
||||
|
||||
def href(path):
|
||||
"""A `file:///` URL for a Windows path.
|
||||
|
||||
A bare Windows path in a markdown link is a DEAD LINK in pyRevit's output
|
||||
panel: backslashes and no scheme, so the browser control renders the link
|
||||
and navigates nowhere. Same fix as PanelLiftCheck -- only the href gets
|
||||
the URL form; the visible text stays the real path, because that is what
|
||||
gets copied into Explorer.
|
||||
"""
|
||||
return 'file:///' + (path or '').replace('\\', '/')
|
||||
|
||||
|
||||
def link(path):
|
||||
"""`[the real path](file:///the/real/path)`, ready for print_md."""
|
||||
return '[{0}]({1})'.format(path, href(path))
|
||||
|
||||
|
||||
def same_file(one, other):
|
||||
"""Whether two paths name the same file.
|
||||
|
||||
The dialog opens on the folder the specs already live in, so pressing
|
||||
Save without moving is a perfectly ordinary thing to do -- and a "copy"
|
||||
onto itself is at best a pointless rewrite of a file something else may
|
||||
be reading. Compared by path rather than by `os.path.samefile`, which
|
||||
IronPython 2.7 does not carry on every Windows build; both paths come
|
||||
from this process, so normcase is enough.
|
||||
"""
|
||||
if not one or not other:
|
||||
return False # no path is never "the same file"
|
||||
return (os.path.normcase(os.path.abspath(one)) ==
|
||||
os.path.normcase(os.path.abspath(other)))
|
||||
|
||||
|
||||
# ------------------------------------------------- remembering the last one
|
||||
|
||||
def _pointer_path(root):
|
||||
return os.path.join(root or '', POINTER)
|
||||
|
||||
|
||||
def recall_dir(root):
|
||||
"""The folder an extraction was last saved to, or '' -- never a guess.
|
||||
|
||||
A pointer to a folder that has gone is not a folder: it is dropped, and
|
||||
the caller falls back to its own default. Silently seeding a dialog with
|
||||
a path that does not exist is how a save lands somewhere nobody meant.
|
||||
"""
|
||||
try:
|
||||
with io.open(_pointer_path(root), 'r', encoding='utf-8') as fh:
|
||||
folder = fh.read().strip()
|
||||
except Exception:
|
||||
return ''
|
||||
if folder and os.path.isdir(folder):
|
||||
return folder
|
||||
return ''
|
||||
|
||||
|
||||
def remember_dir(root, folder):
|
||||
"""Record where the engineer just saved. Failure here is never fatal."""
|
||||
if not folder:
|
||||
return ''
|
||||
try:
|
||||
with io.open(_pointer_path(root), 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(folder))
|
||||
return folder
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
# ------------------------------------------------------ what is not resolved
|
||||
|
||||
def unresolved_entries(spec):
|
||||
"""[(text, reason)] from the spec's `unresolved` list, verbatim.
|
||||
|
||||
Tolerant on the way in: an entry written as a bare string by an older
|
||||
extractor still reads as a note with no reason given, rather than
|
||||
disappearing out of the one list whose whole job is not losing lines.
|
||||
"""
|
||||
out = []
|
||||
for entry in (specformat.get_path(spec, 'unresolved', []) or []):
|
||||
if isinstance(entry, string_types):
|
||||
out.append((entry, ''))
|
||||
elif isinstance(entry, dict):
|
||||
out.append((entry.get('text') or '', entry.get('reason') or ''))
|
||||
else:
|
||||
out.append(('{0}'.format(entry), ''))
|
||||
return [(t, r) for t, r in out if t]
|
||||
|
||||
|
||||
def shared_reason(entries):
|
||||
"""The one reason every entry gives, or ''.
|
||||
|
||||
The mechanical reader gives the same sentence to every note it could not
|
||||
place. Printed once above the list it is an explanation; printed on all
|
||||
fourteen lines it is what stops the list being read.
|
||||
"""
|
||||
reasons = set([r for _t, r in (entries or [])])
|
||||
if len(reasons) == 1:
|
||||
return list(reasons)[0]
|
||||
return ''
|
||||
|
||||
|
||||
# The fields something downstream actually reads off a spec. A null here is
|
||||
# not necessarily an error -- the summary may genuinely not state it -- but a
|
||||
# blank field is the OTHER way a spec is unresolved, and it is invisible
|
||||
# unless it is named. `strand_layout.columns_in` is on the list knowing that
|
||||
# the regex reader never fills it (see `designspec.build_spec`): that is
|
||||
# exactly the sort of blank a reader should be able to see.
|
||||
KEY_FIELDS = (
|
||||
'concrete.fc_psi',
|
||||
'concrete.fci_psi',
|
||||
'concrete.unit_weight_pcf',
|
||||
'thickness_in',
|
||||
'envelope.length.max_ft',
|
||||
'envelope.width.max_ft',
|
||||
'reinforcing.wwf.mesh',
|
||||
)
|
||||
|
||||
LIST_FIELDS = (
|
||||
'reinforcing.strand_groups',
|
||||
'reinforcing.strand_layout.columns_in',
|
||||
)
|
||||
|
||||
|
||||
def blank_fields(spec):
|
||||
"""Dotted paths of the fields downstream reads that are still empty."""
|
||||
out = [path for path in KEY_FIELDS
|
||||
if specformat.get_path(spec, path) is None]
|
||||
out.extend([path for path in LIST_FIELDS
|
||||
if not (specformat.get_path(spec, path) or [])])
|
||||
return out
|
||||
|
||||
|
||||
def problem_sentence(entries, problems=None):
|
||||
"""Why this design's section opens itself, or None when it need not.
|
||||
|
||||
UNRESOLVED NOTES AND VALIDATION FAILURES OPEN IT. Blank fields do NOT,
|
||||
deliberately: the mechanical reader leaves the strand matrix empty on
|
||||
every single spec it writes, so opening on a blank field would open every
|
||||
section on every press and the fold's contract -- if nothing below is
|
||||
open, nothing needs you -- would be worth nothing. Blanks are listed
|
||||
inside the section, where a reader who opens it sees them.
|
||||
"""
|
||||
bits = []
|
||||
if entries:
|
||||
bits.append('{0} note{1} nothing could place'.format(
|
||||
len(entries), '' if len(entries) == 1 else 's'))
|
||||
if problems:
|
||||
bits.append('{0} validation problem{1}'.format(
|
||||
len(problems), '' if len(problems) == 1 else 's'))
|
||||
if not bits:
|
||||
return None
|
||||
return ' and '.join(bits) + ' -- read them before this spec is trusted'
|
||||
|
||||
|
||||
def summary_line(entries, blanks):
|
||||
"""The one-line summary a folded section carries on its header."""
|
||||
return '{0} unresolved note{1}, {2} field{3} blank'.format(
|
||||
len(entries or []), '' if len(entries or []) == 1 else 's',
|
||||
len(blanks or []), '' if len(blanks or []) == 1 else 's')
|
||||
Reference in New Issue
Block a user