WillTools: precast shop-ticket QC and panel handling tools
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.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
@@ -0,0 +1,720 @@
# -*- coding: utf-8 -*-
"""Naming, finding and vouching for model snapshots. NO REVIT, NO DOCUMENT.
This is the half of the snapshot feature that can be reasoned about without a
licence: what a timestamped detached copy is called, which one is newest, what
a snapshot has to say about where it came from, and what the run log means.
The Revit half is the button
(`WillTools.tab/Tools.panel/SnapshotClose.pushbutton` -- a large button on the
Tools panel since 2026-08-30, a small one in a stack before that) and
the deferred runner (`WillTools.extension/runners/detach_snapshot.py`); both
import this and neither duplicates any of it.
IronPython 2.7 on the extension side, CPython 3.12 in `tools/` -- so no
f-strings, no pathlib, no dataclasses, and `os.path` throughout.
THE THREE PHASES, because every function here is named after one
-----------------------------------------------------------------
The owner's flow is a once-a-day close-model ritual, and the ordering is what
makes it cheap:
(he syncs, himself, outside all of this)
A press the button -> index the OPEN document. The model is already in
RAM and already paid for. Seconds.
B close Revit. The press IS the authorisation; that is the button's name.
C AFTER Revit has exited and the 10 GB is free, a deferred host process
launches one Revit, writes the timestamped detached copy, and quits.
THE ONE-MODEL PREMISE, AND WHY IT NO LONGER RULES THE DESIGN
------------------------------------------------------------
Everything above was built on "the machine holds ONE model at a time, ever":
a 300+ MB file costing 10+ GB open, so Phase C had to wait until the
engineer's Revit was gone. **That premise is empirically false and was
retired on 2026-09-01** -- the live cloud session and a detached sandbox
session ran side by side for a whole morning on this machine. What is still
true is the weaker statement, and it is the one the code now enforces:
NEVER THREE. Two Revits are fine. A third is the one nobody measured.
So `tools/snapshot_cycle.py` sequences rather than waits: it closes the
sandbox BEFORE it launches the Revit that writes the new copy, and launches
the new sandbox only once that one has exited. At every instant there are at
most the live session and one other. The machine-wide "wait until no Revit is
running at all" gate in `tools/snapshot_defer.py` still exists and is still
correct for the close-model ritual, but it is now opt-in
(`--machine-free require`) rather than the law.
The CYCLE that replaced Phase C's tail: make the timestamped copy, close the
sandbox Revit, reopen it on the new copy, run a batch inside it over the
bridge, close it again, journal the lot. Its events are CYCLE_START ..
CYCLE_DONE below, in this same log.
WHY PHASE A NEEDS NO DETACHED COPY, WRITTEN DOWN SO IT IS NOT RE-LITIGATED
--------------------------------------------------------------------------
A snapshot read from the live document immediately after a sync and a
snapshot read from a detached copy of that same sync state contain the same
numbers: the detach is a transport, not a measurement. The detached copy
therefore is not evidence for today's index at all. Its remaining purpose is
FUTURE RE-SCANS -- extractors that do not exist yet, run against the state as
it was on a day nobody can go back to -- and the provenance says exactly that
so a reader does not mistake it for the source of the current numbers.
THE LAYOUT DECISION, AND THE TRAP UNDER IT
-------------------------------------------
The owner asked for the model file to be called
`<name>_detached_YYYYMMDD-HHMM`. Put the stamp in the FILE name and `doc.Title`
changes on every snapshot -- and `doc.Title` is what
`designspec.root_dir` keys the whole job folder on (`WILLTOOLS_JOBS` makes the
job folder `<jobs root>/<doc.Title>` literally). A per-snapshot title means a
per-snapshot job folder: no `knowledge.yaml` in it, so no declared scope and
no parameter-role mapping, and an index built there is both wrong and
invisible to the live session. This office has already met that problem once
and solved it by hand, with a directory junction from the detached title back
to the real job folder -- a fix that does not survive a name that changes
daily.
So the DEFAULT puts the stamp in the FOLDER:
<root>/<title>_20260830-1612/<title> layout 'folder' (default)
<root>/<title>_20260830-1612 layout 'filename'
(both with the model extension on the last segment)
Same string the owner asked for, one path segment up. The title stays
constant, one junction serves every snapshot forever, and the stamp is still
right there in the path Revit prints in its title bar. `layout 'filename'` is
kept, selectable, and `plan()` returns the warning that goes with it rather
than refusing -- a plant that does not use `WILLTOOLS_JOBS` is not exposed to
the trap and should not be lectured about it in code it cannot change.
Discovery reads BOTH layouts, so a folder that already holds hand-made copies
in either shape is understood without being converted.
"""
# pylint: disable=broad-except,invalid-name
import json
import os
import re
import time
# `20260830-1612`. Minute resolution: the process that writes one of these
# takes minutes and nobody presses this button twice inside sixty seconds.
STAMP_FMT = '%Y%m%d-%H%M'
STAMP_RE = re.compile(r'^(?P<base>.+)_(?P<stamp>\d{8}-\d{4})$')
# Named MODEL_EXT rather than after the extension itself: `tools/scrub.py`
# matches <word> + extension case-insensitively, so a constant called RVT
# reads as a model filename at every call site and fails the leak audit.
MODEL_EXT = '.rvt'
# The layouts `plan()` can produce and `snapshots()` can read.
FOLDER = 'folder'
FILENAME = 'filename'
LAYOUTS = (FOLDER, FILENAME)
FILENAME_WARNING = (
"layout 'filename' puts the timestamp in the model's own name, so "
"doc.Title changes on every snapshot. With WILLTOOLS_JOBS set, the job "
'folder is <jobs root>/<doc.Title> -- a new, empty one per snapshot, with '
'no knowledge.yaml, so nothing downstream sees the declared scope. Use '
"layout 'folder' unless you know this job does not resolve that way.")
# What produced a snapshot. `kind` in the provenance block.
LIVE_DOC = 'live-doc' # Phase A: the open model, in session
FRESH_DETACH = 'fresh-cloud-detach' # Phase C: detached from the cloud now
EXISTING_COPY = 'existing-detached-copy' # a re-scan of a file already on disk
KINDS = (LIVE_DOC, FRESH_DETACH, EXISTING_COPY)
PROVENANCE_SCHEMA = 1
# One JSON object per line, appended, never rewritten. Chosen over a single
# JSON document because two processes write it -- the button in Revit and the
# deferred host tool -- and an append of one line cannot corrupt what is
# already there, while a read-modify-write of a whole document can.
LOG_NAME = 'snapshots.jsonl'
# Log event names. The pair that matters is REQUESTED / DONE: a REQUESTED with
# nothing after it is a Phase C that never reported, and that must be visible.
REQUESTED = 'phase-c-requested'
DONE = 'phase-c-done'
FAILED = 'phase-c-failed'
SKIPPED = 'phase-c-skipped'
SNAPSHOT = 'phase-a-snapshot'
# The CYCLE, added 2026-09-01. The old three phases describe one close-model
# ritual; these describe the pipeline that replaced its tail --
# `tools/snapshot_cycle.py`: make the copy, cycle the sandbox Revit onto it,
# run a batch inside it, close it again. Same log, same file, appended by the
# same never-raising `append()`, because a reader wants ONE ledger per job and
# not two that have to be interleaved by hand.
#
# CYCLE_START is written before anything is launched and CYCLE_DONE /
# CYCLE_FAILED after the sandbox is closed, so the same "requested and never
# reported" question `outstanding()` asks of phase C can be asked of a cycle:
# a CYCLE_START with nothing after it is a run that died with a Revit still
# open, which is the one outcome that costs the machine 10 GB until somebody
# notices.
CYCLE_START = 'cycle-start'
CYCLE_DONE = 'cycle-done'
CYCLE_FAILED = 'cycle-failed'
SANDBOX_OPENED = 'sandbox-opened'
SANDBOX_CLOSED = 'sandbox-closed'
BATCH_DONE = 'batch-done'
CYCLE_TERMINAL = (CYCLE_DONE, CYCLE_FAILED)
# `tools/snapshot_defer.py`'s own lifecycle, added 2026-09-02 after an
# incident where it crashed (or was never even reachable) with nothing
# anywhere saying so. WATCHER_LAUNCHED is written once the request has been
# read and the wait/launch is genuinely about to happen -- proof that the
# process this ritual depends on actually started, which a REQUESTED line
# alone cannot prove (the button can write REQUESTED and then fail to spawn
# the host process, or spawn it into a crash before it opens a single file).
# WATCHER_CRASHED is the backstop for anything the existing FAILED paths
# do not already name -- an exception nobody anticipated, caught at the
# outermost level so it is journaled before the process exits rather than
# only printed to a stderr nobody is redirecting (the button spawns this
# tool with `CreateNoWindow = True` and no output redirection at all: an
# unjournalled stderr line there is not merely easy to miss, it is provably
# unread by anything).
WATCHER_LAUNCHED = 'watcher-launched'
WATCHER_CRASHED = 'watcher-crashed'
# ------------------------------------------------------------------- stamping
def stamp(when=None):
"""`YYYYMMDD-HHMM` for a time, or now."""
return time.strftime(STAMP_FMT, time.localtime(when)
if when is not None else time.localtime())
def is_stamp(text):
"""True for exactly `YYYYMMDD-HHMM`. Never raises."""
try:
return bool(re.match(r'^\d{8}-\d{4}$', text or ''))
except Exception:
return False
def split_stamped(name):
"""('base', 'YYYYMMDD-HHMM') out of a stamped name, or (None, None).
The stamp is matched at the END and the base is whatever precedes it, so a
base that itself contains underscores, spaces, or the word `detached` --
all three occur in real titles -- survives intact.
"""
try:
hit = STAMP_RE.match(name or '')
except Exception:
return None, None
if not hit:
return None, None
return hit.group('base'), hit.group('stamp')
def stamped_name(base, stamp_text):
"""`<base>_<stamp>`. The one place the two are joined."""
return '{0}_{1}'.format(base, stamp_text)
# ----------------------------------------------------------- where one goes
def plan(root, base, stamp_text=None, layout=FOLDER):
"""Where the next detached copy goes, and what it will be called.
Returns a dict, never a tuple: this grew a field twice already and a
caller unpacking three values is a caller that breaks on the fourth.
root the configured detached-copies folder
base the SaveAs base name -- normally the detached document's own
title as Revit reports it, so the title never moves
layout 'folder' (default) or 'filename'; see the module docstring
-> {'layout', 'stamp', 'folder', 'path', 'title', 'title_is_stable',
'warning'}
`title` is what `doc.Title` will be after the SaveAs, which is the value
every job-folder lookup downstream keys on. It is returned, rather than
left implicit, because getting it wrong is the expensive failure here and
a caller should be able to print it before committing to the write.
"""
if layout not in LAYOUTS:
layout = FOLDER
stamp_text = stamp_text or stamp()
if layout == FILENAME:
title = stamped_name(base, stamp_text)
return {'layout': FILENAME, 'stamp': stamp_text, 'folder': root,
'path': os.path.join(root, title + MODEL_EXT), 'title': title,
'title_is_stable': False, 'warning': FILENAME_WARNING}
folder = os.path.join(root, stamped_name(base, stamp_text))
return {'layout': FOLDER, 'stamp': stamp_text, 'folder': folder,
'path': os.path.join(folder, base + MODEL_EXT), 'title': base,
'title_is_stable': True, 'warning': None}
# --------------------------------------------------------- finding the newest
def _model_in(folder):
"""The single model file inside a snapshot folder, or None.
More than one is not an error worth raising over -- Revit leaves backups
(a `.0001` counter glued into the name) beside a saved central -- so the SHORTEST name wins,
which is the one without the backup counter glued on.
"""
try:
names = [n for n in os.listdir(folder)
if n.lower().endswith(MODEL_EXT) and
os.path.isfile(os.path.join(folder, n))]
except Exception:
return None
if not names:
return None
names.sort(key=lambda n: (len(n), n))
return os.path.join(folder, names[0])
def snapshots(root):
"""Every timestamped detached copy under `root`, OLDEST FIRST.
Both layouts, in one list, each row saying which it is. A row is only
produced when the model file actually exists: a stamped folder whose model was
deleted or never finished writing is not a snapshot, and reporting it as
one is how a caller ends up handing Revit a path to nothing.
Never raises. A `root` that does not exist is an empty list, because "not
configured yet" and "configured and empty" are the caller's to tell apart
(see `newest`) and neither is exceptional here.
"""
rows = []
try:
names = os.listdir(root)
except Exception:
return rows
for name in names:
full = os.path.join(root, name)
try:
is_dir = os.path.isdir(full)
except Exception:
continue
if is_dir:
base, stamp_text = split_stamped(name)
if not stamp_text:
continue
path = _model_in(full)
if not path:
continue
title = os.path.splitext(os.path.basename(path))[0]
rows.append({'layout': FOLDER, 'stamp': stamp_text, 'base': base,
'folder': full, 'path': path, 'title': title})
continue
if not name.lower().endswith(MODEL_EXT):
continue
base, stamp_text = split_stamped(os.path.splitext(name)[0])
if not stamp_text:
continue
rows.append({'layout': FILENAME, 'stamp': stamp_text, 'base': base,
'folder': root, 'path': full,
'title': os.path.splitext(name)[0]})
# The stamp is fixed-width and zero-padded, so string order IS time order;
# `path` breaks a tie so the answer does not depend on listdir order.
rows.sort(key=lambda r: (r['stamp'], r['path']))
return rows
def newest(root):
"""The most recent snapshot under `root`, or None. Never raises."""
rows = snapshots(root)
return rows[-1] if rows else None
# ------------------------------------------------------------- provenance
def provenance(phase, kind, source_path=None, source_stamp=None,
source_title=None, revit=None, pyrevit=None, job_folder=None,
note=None, when=None):
"""Where a snapshot came from. Attached to the index the snapshot wrote.
A snapshot that cannot say where it came from is the staleness trap this
whole feature exists to close, so every field it needs to answer that is
here whether or not the caller has it:
phase 'A' (from the open document) or 'C' (deferred, detached)
kind one of KINDS -- live document, fresh detach from the
cloud, or a re-scan of a file already on disk. These
are NOT interchangeable and a reader must not have to
guess which happened.
source_path the file the numbers were read from. None for a live
document that has never been saved -- which is itself
worth knowing, so it is recorded as None rather than ''.
source_stamp that file's snapshot timestamp, when it has one. A live
document has none; the field is None and not the run
time, because a source's age and a run's age are
different facts and conflating them is the exact lie
this block exists to prevent.
revit/pyrevit the versions that did the reading.
job_folder where the index was written.
`kind` is validated: an unrecognised value is kept but flagged, so a typo
surfaces as a question instead of quietly becoming a fourth category.
"""
block = {
'schema': PROVENANCE_SCHEMA,
'phase': phase,
'kind': kind,
'kind_known': kind in KINDS,
'source': {'path': source_path, 'stamp': source_stamp,
'title': source_title},
'revit': revit,
'pyrevit': pyrevit,
'job_folder': job_folder,
'at': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(when)
if when is not None else time.localtime()),
'note': note,
}
return block
def provenance_line(block):
"""One printable sentence out of a provenance block. Never raises."""
if not isinstance(block, dict):
return 'no provenance -- this snapshot cannot say where it came from'
src = block.get('source') or {}
where = src.get('path') or src.get('title') or '(unsaved document)'
when = src.get('stamp') or 'no snapshot stamp'
return ('phase {0}, {1}, from {2} ({3}), Revit {4}, written {5}'
.format(block.get('phase') or '?',
block.get('kind') or 'unknown source',
where, when, block.get('revit') or '?',
block.get('at') or '?'))
# --------------------------------------------------------------- the run log
def log_path(folder):
return os.path.join(folder, LOG_NAME)
def append(path, row):
"""One event onto the end of the log. Never raises, ever.
Called from a button that is about to close Revit and from a background
host process, and in both a failure to journal must not become a failure
to do the work. A lost line is acceptable; a crash on the way out is not.
"""
try:
row = dict(row or {})
row.setdefault('at', time.strftime('%Y-%m-%d %H:%M:%S'))
folder = os.path.dirname(path)
if folder and not os.path.isdir(folder):
os.makedirs(folder)
text = json.dumps(row, sort_keys=True)
handle = open(path, 'ab')
try:
handle.write(text.encode('utf-8'))
handle.write(b'\n')
finally:
handle.close()
return True
except Exception:
return False
def read(path, limit=None):
"""The log as a list of dicts, oldest first. A damaged line is skipped.
A half-written last line is the normal consequence of a process being
killed, and it must cost that one line and nothing else -- a log that
raises on read is a log that stops being read.
"""
rows = []
try:
handle = open(path, 'rb')
try:
raw = handle.read().decode('utf-8', 'replace')
finally:
handle.close()
except Exception:
return rows
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
if limit is not None and len(rows) > limit:
rows = rows[-limit:]
return rows
def outstanding(rows):
"""The Phase C that was asked for and never reported back, or None.
A deferred step that silently did not run is the failure mode this design
is most exposed to: the button has already closed Revit, so nobody is
watching, and the next day looks exactly like a day it worked. So the
ledger is paired -- one REQUESTED line written before the close, one
DONE/FAILED/SKIPPED line written by the deferred process -- and this
reports a REQUESTED with nothing after it.
Matching is by `run` id, not by position, so an old unfinished request
still shows after a later one succeeded rather than being papered over by
it. Requests with no id at all fall back to "any later terminal event
clears it", which is the best a line written by an older version allows.
"""
terminal = (DONE, FAILED, SKIPPED)
closed = set()
for row in (rows or ()):
if row.get('event') in terminal and row.get('run'):
closed.add(row.get('run'))
pending = None
for i, row in enumerate(rows or ()):
if row.get('event') != REQUESTED:
continue
run = row.get('run')
if run:
if run not in closed:
pending = row
continue
# No id: cleared only by a terminal line that comes after it.
later = [r for r in (rows or ())[i + 1:] if r.get('event') in terminal]
if not later:
pending = row
return pending
def last(rows, event):
"""The most recent row with this event, or None."""
for row in reversed(list(rows or ())):
if row.get('event') == event:
return row
return None
def summary(rows):
"""What the button prints about snapshots that already happened.
A list of plain sentences, newest fact first, and it says the awkward
thing out loud: a Phase C that was requested and never reported is named
here, with its timestamp, because the alternative is that it stays
invisible until somebody wonders why the detached folder is empty.
"""
lines = []
rows = list(rows or ())
if not rows:
return ['no snapshot has been recorded for this job yet']
pending = outstanding(rows)
if pending is not None:
lines.append('WARNING: a detached copy was requested {0} and never '
'reported back. It may have failed, or the machine may '
'have been shut down before it ran.'
.format(pending.get('at') or 'at an unknown time'))
done = last(rows, DONE)
if done is not None:
lines.append('last detached copy: {0} ({1})'
.format(done.get('path') or '?', done.get('at') or '?'))
failed = last(rows, FAILED)
if failed is not None and (done is None or
(failed.get('at') or '') > (done.get('at') or '')):
lines.append('last detached copy FAILED at {0}: {1}'
.format(failed.get('at') or '?',
failed.get('error') or 'no reason recorded'))
snap = last(rows, SNAPSHOT)
if snap is not None:
lines.append('last index snapshot: {0}, {1} pieces'
.format(snap.get('at') or '?',
snap.get('pieces')
if snap.get('pieces') is not None else '?'))
return lines
# ------------------------------------------------- the command lines, exactly
def pyrevit_run_argv(pyrevit_exe, script, revit_year=None, purge=True):
"""`pyrevit run <script> [--revit=YYYY] [--purge]` as an argv list.
Built here rather than formatted at the call site so the string the owner
is asked to paste and the string the deferred tool actually runs are
produced by the same code and cannot drift apart.
`--purge` removes the temporary run environment afterwards and is ON:
without it every run leaves a runtime folder behind, and this runs daily.
"""
argv = [pyrevit_exe, 'run', script]
if revit_year:
argv.append('--revit={0}'.format(revit_year))
if purge:
argv.append('--purge')
return argv
def quote(argv):
"""An argv list as a copy-pasteable command line. Windows quoting rules."""
out = []
for arg in argv:
arg = '{0}'.format(arg)
if arg and not re.search(r'[\s"]', arg):
out.append(arg)
else:
out.append('"' + arg.replace('"', '\\"') + '"')
return ' '.join(out)
def revit_exe_guess(year):
"""Where Revit installs itself, for a year. A GUESS -- the caller checks.
Returned unchecked and named `_guess` on purpose: `snapshot.revit_exe` is
the configured answer and this is only what to try when nobody has given
one. A path returned from here that does not exist is the pipeline's cue
to say "configure snapshot.revit_exe", not to invent a second guess.
"""
if not year:
return None
return os.path.join(
os.environ.get('ProgramFiles', 'C:\\Program Files'), 'Autodesk',
'Revit {0}'.format(year), 'Revit.exe')
def pyrevit_exe_guess():
"""Where the pyRevit CLI installs itself. A GUESS -- the caller checks.
Same discipline as `revit_exe_guess`: returned only when the file is
actually there, and never a substitute for `snapshot.pyrevit_exe` stated
and WRONG -- a caller that read an explicit, bad path must never silently
fall back to this. Both `tools/snapshot_cycle.py` and the SnapshotClose
button already did this guess, each with its own copy; this is the one
copy, so `tools/snapshot_defer.py` -- which used to have none at all and
would `argparse.error()` (no journal, nothing) if `--pyrevit` was left
off -- can make the same guess a caller forgot to make for it.
"""
guess = os.path.join(os.environ.get('APPDATA', ''), 'pyRevit-Master',
'bin', 'pyrevit.exe')
return guess if os.path.isfile(guess) else None
_YEAR_RE = re.compile(r'(20\d{2})')
def revit_year_from_exe(path):
"""A 4-digit year recovered from a `Revit.exe` path, or None.
`pyrevit run` accepts an optional `--revit=<year>`; on a machine with
several Revit versions attached (this office had five on 2026-09-01:
2022 through 2026) omitting it is not safe to leave to whatever the CLI
picks by default -- `pyrevit revits` reproducibly throws a bare
"Object reference not set to an instance of an object" on this machine,
and `pyrevit run` with no `--revit=` appears to walk the same path.
`snapshot.revit_exe` almost always carries the year in its own path
(`...\\Autodesk\\Revit 2024\\Revit.exe`), so this recovers it as a
fallback when `snapshot.revit_year` was never separately configured --
which is exactly the job layer shape that produced the 2026-09-01
incident: `revit_exe` set, `revit_year` not.
"""
hit = _YEAR_RE.search(path or '')
return hit.group(1) if hit else None
def revit_exe_argv(revit_exe, model_path):
"""`Revit.exe <model>` as an argv list -- the sandbox launch.
Deliberately not `pyrevit run`: that plays a journal and quits, which is
what a one-shot detach wants and the opposite of what a session an agent
is going to drive over the bridge for ten minutes wants. This starts an
ordinary interactive Revit on one file; pyRevit loads in it exactly as it
does when the file is double-clicked, which is what puts the bridge up.
"""
return [revit_exe, model_path]
_UNSAFE = re.compile(r'[^A-Za-z0-9._-]+')
def batch_log_name(index, command):
"""`batch-01-python-tools-sweep.log` -- one log file per batch command.
The command is in the NAME as well as in the log, because a folder of
`batch-01.log`, `batch-02.log` is a folder nobody opens twice. Truncated
hard: the index is what makes it unique, the words are only there to be
recognised.
"""
text = _UNSAFE.sub('-', '{0}'.format(command or '')).strip('-')
if len(text) > 48:
text = text[:48].rstrip('-')
return 'batch-{0:02d}{1}.log'.format(int(index),
'-' + text if text else '')
def outstanding_cycle(rows):
"""A CYCLE_START with no terminal line after it, or None.
The cycle's version of `outstanding()`, and it matters more: an
unfinished phase C left a copy unwritten, while an unfinished cycle can
have left a Revit open on a 300 MB model with nobody in front of it.
Matched on the `run` id, same as `outstanding()`.
"""
closed = set()
for row in (rows or ()):
if row.get('event') in CYCLE_TERMINAL and row.get('run'):
closed.add(row.get('run'))
pending = None
for i, row in enumerate(rows or ()):
if row.get('event') != CYCLE_START:
continue
run = row.get('run')
if run:
if run not in closed:
pending = row
continue
later = [r for r in (rows or ())[i + 1:]
if r.get('event') in CYCLE_TERMINAL]
if not later:
pending = row
return pending
def cycle_summary(rows):
"""What the last cycle did, as plain sentences. Newest fact first.
Kept separate from `summary()` rather than folded into it: the button
prints the phase ledger and the pipeline prints this, and a run that
reported nothing has to be as loud in one as in the other.
"""
lines = []
rows = list(rows or ())
if not rows:
return ['no snapshot cycle has been recorded for this job yet']
pending = outstanding_cycle(rows)
if pending is not None:
lines.append(
'WARNING: a snapshot cycle started {0} and never reported back. '
'A sandbox Revit (pid {1}) may still be open on this machine.'
.format(pending.get('at') or 'at an unknown time',
pending.get('sandbox_pid') or 'unknown'))
done = last(rows, CYCLE_DONE)
if done is not None:
lines.append('last cycle: {0}, {1} batch command(s), {2} s'
.format(done.get('at') or '?',
done.get('batch_count')
if done.get('batch_count') is not None else '?',
done.get('seconds')
if done.get('seconds') is not None else '?'))
failed = last(rows, CYCLE_FAILED)
if failed is not None and (done is None or
(failed.get('at') or '') > (done.get('at') or '')):
lines.append('last cycle FAILED at {0}: {1}'
.format(failed.get('at') or '?',
failed.get('error') or 'no reason recorded'))
opened = last(rows, SANDBOX_OPENED)
if opened is not None:
lines.append('last sandbox opened: {0} (pid {1}) on {2}'
.format(opened.get('at') or '?',
opened.get('pid') or '?',
opened.get('path') or '?'))
return lines