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:
@@ -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
|
||||
Reference in New Issue
Block a user