8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
721 lines
30 KiB
Python
721 lines
30 KiB
Python
# -*- 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
|