25592fa6c2
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.
890 lines
40 KiB
Python
890 lines
40 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Index the model as it stands, then hand the sandbox off -- or close Revit.
|
|
#
|
|
# WHAT THIS BUTTON IS NOW, 2026-09-01
|
|
# ------------------------------------
|
|
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
|
|
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
|
|
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
|
|
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
|
|
#
|
|
# So the whole cycle moved out of this button and into
|
|
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
|
|
# it, and can be started by Claude. What is left here is the one job that
|
|
# genuinely requires the live session and cannot be done anywhere else:
|
|
# handing over the model's IDENTITY. Only the open document can cheaply report
|
|
# its cloud region and GUIDs, and without those nothing else can find the
|
|
# model to copy.
|
|
#
|
|
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
|
|
# ----------------------------------------------
|
|
# The design this replaced expected an in-session background open --
|
|
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
|
|
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
|
|
# does not exist for this model, and the reason is not a version guard:
|
|
#
|
|
# * a cloud model cannot be opened detached at all. Any
|
|
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
|
|
# is not valid for cloud model"; Autodesk's answer is that the Revit team
|
|
# does not allow it and that SaveAs is the way; and the UI agrees --
|
|
# "Detach from Central" is greyed out for anything from Autodesk Docs.
|
|
# * Revit holds one Document per model per session, so the live session
|
|
# cannot open the model it already has open.
|
|
# * the only call that could copy the document already in memory is
|
|
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
|
|
# here it would leave HIS Revit sitting in a local copy, disconnected
|
|
# from the cloud. There is no SaveCopyAs.
|
|
#
|
|
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
|
|
# The copy is therefore made by a Revit that is not his, and this button's
|
|
# phase C is a spawn rather than an open.
|
|
#
|
|
# THE OLD RITUAL, KEPT
|
|
# ---------------------
|
|
# `snapshot.after_close` is NOT retired. The close-model path below is
|
|
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
|
|
# day, where the answer really is "close everything first". It is simply no
|
|
# longer the only way to get a copy, and it is no longer the default.
|
|
#
|
|
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
|
|
# ------------------------------------------------
|
|
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
|
|
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
|
|
# session and a detached sandbox ran side by side all morning. What nobody has
|
|
# measured is three, so the pipeline sequences to keep it to two and this
|
|
# button never opens a model at all.
|
|
#
|
|
# The close-model ritual, still here as one of the options:
|
|
#
|
|
# he syncs his act, outside this button. This button NEVER
|
|
# calls SynchronizeWithCentral and never asks about it.
|
|
# phase A this button indexes the OPEN document. The model is
|
|
# already in RAM and already paid for; a snapshot read
|
|
# from the live document at sync state and one read
|
|
# from a detached copy of that same state contain the
|
|
# same numbers. Seconds, not minutes, and no copy.
|
|
# phase B Revit closes. The press IS the authorisation for the
|
|
# close -- that is what the button is called.
|
|
# phase C AFTER Revit has exited and the 10 GB is free, a
|
|
# deferred host process opens the model detached, saves
|
|
# a timestamped copy and quits. Optional, off unless
|
|
# the job layer configures it, and its outcome lands in
|
|
# a log this button reads out on the next press.
|
|
#
|
|
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
|
|
# --------------------------------------------------------------------
|
|
# No Transaction is opened in this file or in anything it calls. Nothing is
|
|
# written to the model -- not a parameter, not a workset, not a save. The only
|
|
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
|
|
# file, in another process, after this one has exited. The sync that used to
|
|
# live here has been removed entirely at the owner's instruction.
|
|
#
|
|
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
|
|
# ----------------------------------------------------
|
|
# Revit is closed LAST and only after phase A has reported complete. A crash
|
|
# halfway through the index must leave the session open, with the traceback on
|
|
# screen, because the alternative is a closed Revit and no way to find out
|
|
# what went wrong. `PostCommand` is therefore reached on exactly one path.
|
|
#
|
|
# The other half of that discipline is phase C. It runs when nobody is
|
|
# watching, in a process this one has already outlived, and a day it silently
|
|
# did not run looks exactly like a day it worked. So the request is journalled
|
|
# BEFORE the close and the deferred process journals its own outcome; a
|
|
# request with nothing after it is reported, in capitals, at the top of the
|
|
# next press. Silence is not an acceptable report.
|
|
#
|
|
# JUDGEMENT CALLS, for the owner to overrule
|
|
# -------------------------------------------
|
|
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
|
|
# press elsewhere trusts the cheap signature, which cannot see an opening
|
|
# moved or resized in place. This is the daily archival snapshot and it is
|
|
# the one that gets trusted months later, so it pays the measured 6-10 s
|
|
# for an answer that owes nothing to yesterday's cache.
|
|
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
|
|
# hang on a mis-click, and "snapshot but stay open" is a real want (a
|
|
# lunchtime checkpoint). Cancel writes nothing at all.
|
|
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
|
|
# declared scope and no parameter-role mapping, so the index would measure
|
|
# the whole model and mark every piece could-not-judge on the review
|
|
# signal -- and would overwrite a good index to do it. That is worth a
|
|
# dialog. It is not worth a refusal on a machine that legitimately has no
|
|
# job layer yet.
|
|
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
|
|
# default and None means not configured, never "pick somewhere". The button
|
|
# says so instead of guessing a folder for a 300 MB file.
|
|
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
__title__ = 'Snapshot'
|
|
|
|
import os
|
|
import time
|
|
import traceback
|
|
|
|
from pyrevit import revit, script, forms
|
|
|
|
from vibe import knowledge
|
|
from vibe import liftexport
|
|
from vibe import reinforcecore as core
|
|
from vibe import reinforceindex as index
|
|
from vibe import runlog
|
|
from vibe import snapshotmeta as S
|
|
|
|
doc = revit.doc
|
|
output = script.get_output()
|
|
output.close_others()
|
|
|
|
TITLE = 'Snapshot & Close'
|
|
|
|
# Where this button sits, and therefore where the repo is. Hoisted to the top
|
|
# because three separate places need it now (the deferred tool, the cycle
|
|
# pipeline and the runner) and computing it three times is how two of them end
|
|
# up one directory apart.
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
|
REPO_ROOT = os.path.dirname(EXT_ROOT)
|
|
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
|
|
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
|
|
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
|
|
|
|
CYCLE = 'Snapshot, then run the sandbox cycle'
|
|
CLOSE = 'Snapshot, then close Revit'
|
|
STAY = 'Snapshot only, stay open'
|
|
CANCEL = 'Cancel'
|
|
|
|
|
|
def die(message):
|
|
runlog.record('Snapshot Close',
|
|
doc_title=doc.Title if doc is not None else None,
|
|
outcome='refused', report_text=message)
|
|
forms.alert(message, title=TITLE, exitscript=True)
|
|
|
|
|
|
if doc is None:
|
|
# `doc.Title` would itself fail on a None document -- record what there is
|
|
# to record, then hand the refusal to the dialog.
|
|
runlog.record('Snapshot Close', doc_title=None, outcome='refused',
|
|
report_text='No model is open, so there is nothing to '
|
|
'snapshot.')
|
|
die('No model is open, so there is nothing to snapshot.')
|
|
|
|
|
|
# --------------------------------------------------------------- versions
|
|
|
|
def _revit_version():
|
|
try:
|
|
app = doc.Application
|
|
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _pyrevit_version():
|
|
try:
|
|
from pyrevit import versionmgr
|
|
return versionmgr.get_pyrevit_version().get_formatted()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _revit_pid():
|
|
try:
|
|
from System.Diagnostics import Process
|
|
return Process.GetCurrentProcess().Id
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------- main
|
|
# The main body is wrapped so an unexpected exception still leaves a run-log
|
|
# record (outcome='error') before pyRevit shows its own error dialog.
|
|
try:
|
|
# ------------------------------------------------------ what already happened
|
|
#
|
|
# Read and printed FIRST, before anything is decided, because the one thing
|
|
# this design can lose is a phase C that never ran -- and the only moment
|
|
# anybody is looking is the moment they press this button again.
|
|
|
|
know = knowledge.load(doc)
|
|
job_folder = None
|
|
try:
|
|
from vibe import designspec
|
|
job_folder = designspec.root_dir(doc)
|
|
except Exception:
|
|
job_folder = None
|
|
|
|
log = S.log_path(job_folder) if job_folder else None
|
|
history = S.read(log) if log else []
|
|
|
|
output.print_md('# {0}'.format(TITLE))
|
|
for line in S.summary(history):
|
|
if line.startswith('WARNING'):
|
|
output.print_md('**{0}**'.format(line))
|
|
else:
|
|
output.print_md('- {0}'.format(line))
|
|
|
|
|
|
# ---------------------------------------------------- is this job set up
|
|
|
|
problems = knowledge.config_problems(doc, know)
|
|
if problems:
|
|
die('\n\n'.join(problems))
|
|
|
|
has_job_layer = False
|
|
for label, path, exists in knowledge.sources(doc):
|
|
if label == 'job' and exists:
|
|
has_job_layer = True
|
|
|
|
if not has_job_layer:
|
|
keep_going = forms.alert(
|
|
'This model has no job knowledge file.\n\n'
|
|
'Without it there is no declared scope and no parameter-name '
|
|
'mapping, so the index will measure every assembly in the model and '
|
|
'will record "could not judge" for the review signal on all of them '
|
|
'-- and it will overwrite whatever index is there now.\n\n'
|
|
'Snapshot anyway?',
|
|
title=TITLE, yes=True, no=True)
|
|
if not keep_going:
|
|
runlog.record('Snapshot Close', doc_title=doc.Title,
|
|
outcome='refused',
|
|
report_text='No job knowledge file; user declined to '
|
|
'snapshot anyway.')
|
|
script.exit()
|
|
|
|
|
|
# --------------------------------------------------------- phase C settings
|
|
#
|
|
# Resolved BEFORE the confirmation so the dialog can say what will actually
|
|
# happen after the close, rather than promising a detached copy that turns out
|
|
# not to be configured.
|
|
|
|
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
|
|
after_close = knowledge.get(know, 'snapshot.after_close', False)
|
|
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
|
|
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
|
|
# TEXT, so two call sites spelling the same value differently are reported as
|
|
# disagreeing about what "unset" means.
|
|
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
|
|
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
|
|
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
|
|
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
|
|
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
|
|
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
|
|
or S.revit_exe_guess(revit_year)
|
|
if not revit_year:
|
|
# `pyrevit run` (both the cycle route's detach step and the close-model
|
|
# route) takes an optional `--revit=<year>`, and a job layer that states
|
|
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
|
|
# job's shape -- used to leave it off entirely. On a machine with more
|
|
# than one Revit version attached that is not a neutral omission: see
|
|
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
|
|
# is named for. `revit_exe`'s own path almost always carries the year.
|
|
revit_year = S.revit_year_from_exe(revit_exe)
|
|
batch = knowledge.get(know, 'snapshot.batch', []) or []
|
|
|
|
|
|
def _pyrevit_cli():
|
|
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
|
|
if pyrevit_exe and os.path.isfile(pyrevit_exe):
|
|
return pyrevit_exe
|
|
if pyrevit_exe:
|
|
return None # stated and wrong: never fall back
|
|
return S.pyrevit_exe_guess()
|
|
|
|
|
|
def _copy_blocker():
|
|
"""What stops ANY detached copy being made, whichever route. Or None."""
|
|
if not detached_dir:
|
|
return ('not configured -- set snapshot.detached_dir in the job '
|
|
'knowledge file to switch it on')
|
|
if not os.path.isdir(detached_dir):
|
|
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
|
|
if _pyrevit_cli() is None:
|
|
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
|
|
if liftexport.find_python(know) is None:
|
|
return 'no host Python was found to run the deferred step'
|
|
return None
|
|
|
|
|
|
def phase_c_blocker():
|
|
"""Why the OLD after-close route cannot run, or None.
|
|
|
|
`snapshot.after_close` is deliberately still consulted here and nowhere
|
|
else: it gates the close-model ritual, which is the thing it has always
|
|
meant. The cycle route below does not read it -- it is not chained to an
|
|
exit, so "after close" has nothing to say about it, and making one key
|
|
mean two things is how a setting becomes untrustworthy.
|
|
"""
|
|
stop = _copy_blocker()
|
|
if stop:
|
|
return stop
|
|
if not after_close:
|
|
return ('switched off -- snapshot.after_close is false in the job '
|
|
'knowledge file')
|
|
return None
|
|
|
|
|
|
def cycle_blocker():
|
|
"""Why the sandbox cycle cannot be started, or None.
|
|
|
|
Note what is NOT in here: `after_close`, and any check on other Revits.
|
|
The cycle runs beside this session on purpose -- that is the whole point
|
|
of it -- and it sequences its own steps so that at most two Revits are
|
|
ever resident.
|
|
"""
|
|
stop = _copy_blocker()
|
|
if stop:
|
|
return stop
|
|
if not revit_exe:
|
|
return ('no Revit.exe to open the sandbox with -- set '
|
|
'snapshot.revit_exe or snapshot.revit_year')
|
|
if not os.path.isfile(revit_exe):
|
|
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
|
|
if not batch:
|
|
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
|
|
if not os.path.isfile(CYCLE_TOOL):
|
|
return 'tools/snapshot_cycle.py is not where this button expects it'
|
|
return None
|
|
|
|
|
|
blocker = phase_c_blocker()
|
|
cycle_stop = cycle_blocker()
|
|
|
|
|
|
# -------------------------------------------------------------- the decision
|
|
|
|
if cycle_stop is None:
|
|
cycle_tail = (
|
|
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
|
|
'own, then opened in a sandbox session, then {1} runs inside it, then '
|
|
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
|
|
.format(detached_dir, ' + '.join(batch)))
|
|
else:
|
|
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
|
|
|
|
if blocker is None:
|
|
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
|
|
'copy is written to:\n{0}'.format(detached_dir))
|
|
else:
|
|
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
|
|
|
|
choice = forms.alert(
|
|
'Sync first if you have not -- this button does not sync, and it never '
|
|
'touches the model.\n\n'
|
|
'It rebuilds the reinforcing index from the model as it stands now and '
|
|
'stamps it with where it came from.' + cycle_tail + close_tail,
|
|
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
|
|
|
|
if choice not in (CYCLE, CLOSE, STAY):
|
|
output.print_md('---')
|
|
output.print_md('Cancelled. Nothing was read, written or closed.')
|
|
runlog.record('Snapshot Close', doc_title=doc.Title,
|
|
outcome='refused',
|
|
report_text='User cancelled the snapshot dialog.')
|
|
script.exit()
|
|
|
|
closing = (choice == CLOSE)
|
|
cycling = (choice == CYCLE)
|
|
|
|
|
|
# ------------------------------------------------------------------ phase A
|
|
|
|
output.print_md('---')
|
|
output.print_md('## Phase A - indexing the open model')
|
|
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
|
|
'an earlier press. Measured at 6-10 s on this model.')
|
|
|
|
# The index AS IT STOOD BEFORE this press, captured so the update statistics
|
|
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
|
|
# only loads the file, it never touches the model. Absent or damaged is fine:
|
|
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
|
|
try:
|
|
old_cache = index.read(doc)
|
|
except Exception:
|
|
old_cache = None
|
|
|
|
report = None
|
|
cache = None
|
|
failure = None
|
|
try:
|
|
# An upper bound for the bar: a full rebuild measures at most every
|
|
# assembly in the model. The cheap collector pass costs ~0.3 s here and
|
|
# buys a progress bar that moves, instead of one pinned at 100%.
|
|
total = max(len(index.assemblies(doc)), 1)
|
|
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
|
|
cancellable=False) as bar:
|
|
state = {'n': 0}
|
|
|
|
def tick(_name):
|
|
state['n'] += 1
|
|
bar.update_progress(state['n'], total)
|
|
|
|
bar.update_progress(0, total)
|
|
cache, report = index.refresh(doc, rebuild_all=True, know=know,
|
|
on_piece=tick)
|
|
except Exception:
|
|
failure = traceback.format_exc()
|
|
|
|
if failure is not None:
|
|
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
|
|
output.print_md('## Phase A FAILED - Revit has not been closed')
|
|
output.print_md('```\n{0}\n```'.format(failure))
|
|
if log:
|
|
S.append(log, {'event': 'phase-a-failed',
|
|
'error': failure.splitlines()[-1]})
|
|
runlog.record('Snapshot Close', doc_title=doc.Title,
|
|
outcome='refused',
|
|
report_text='Phase A failed - Revit has not been closed.',
|
|
error=failure.splitlines()[-1])
|
|
script.exit()
|
|
|
|
|
|
# ------------------------------------------------- stamp it with where it came from
|
|
#
|
|
# `refresh` writes the index; this adds the block that says whose numbers
|
|
# those are, and rewrites. It is written on EVERY press, including the press
|
|
# where nothing changed and `refresh` therefore did not save -- an index whose
|
|
# provenance is a week older than its numbers is worse than none.
|
|
|
|
source_path = None
|
|
try:
|
|
source_path = doc.PathName or None
|
|
except Exception:
|
|
source_path = None
|
|
|
|
prov = S.provenance(
|
|
'A', S.LIVE_DOC,
|
|
source_path=source_path,
|
|
source_stamp=None, # a live document has no snapshot stamp
|
|
source_title=index.doc_key(doc),
|
|
revit=_revit_version(),
|
|
pyrevit=_pyrevit_version(),
|
|
job_folder=job_folder,
|
|
note=('read from the open document immediately after the engineer synced; '
|
|
'no detached copy was involved in these numbers'))
|
|
|
|
pieces = len(cache.get('pieces') or {})
|
|
try:
|
|
cache['provenance'] = prov
|
|
core.save(report['path'], cache)
|
|
stamped = True
|
|
except Exception:
|
|
stamped = False
|
|
|
|
output.print_md('- {0} assemblies seen, {1} pieces in the index'
|
|
.format(report['assemblies'], pieces))
|
|
output.print_md('- scope: {0}'.format(report['scope']))
|
|
output.print_md('- built in {0} s, written to `{1}`'
|
|
.format(report['seconds_total'], report['path']))
|
|
if stamped:
|
|
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
|
|
else:
|
|
output.print_md('- **the provenance stamp could not be written** -- this '
|
|
'index cannot say where it came from')
|
|
|
|
missing = (report.get('coverage') or {}).get('missing') or []
|
|
if missing:
|
|
output.print_md('- **{0} pieces could not be measured** and are not in '
|
|
'this snapshot'.format(len(missing)))
|
|
|
|
if log:
|
|
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
|
|
'path': report['path'], 'scope': report['scope'],
|
|
'seconds': report['seconds_total'],
|
|
'provenance': prov})
|
|
|
|
|
|
# ------------------------------------------------- update statistics
|
|
#
|
|
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
|
|
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
|
|
# press changed: which pieces are newly donors, how many features each carries,
|
|
# and which feature-IDs went into the shared vocabulary for the first time
|
|
# versus which were already known. READ ONLY -- this only reads the two caches
|
|
# and one cheap signature pass; nothing about the model is written.
|
|
#
|
|
# `index.refresh` already grew the registry (via `core.register_features`), so
|
|
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
|
|
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
|
|
# runs, redone here so the diff sees the model exactly as it stands now.
|
|
|
|
output.print_md('## Update statistics -- what this snapshot marked')
|
|
try:
|
|
live = index.live_signatures(doc, know=know)
|
|
stats = core.snapshot_stats(old_cache, cache, live)
|
|
except Exception:
|
|
stats = None
|
|
output.print_md('- **the update statistics could not be computed** -- the '
|
|
'index was still written; only this summary is missing')
|
|
|
|
if stats is not None:
|
|
newly = stats['newly']
|
|
if stats['first_snapshot']:
|
|
output.print_md('- **first snapshot for this index** -- there was '
|
|
'nothing to diff against, so every reinforced piece '
|
|
'below is new to the donor pool, not a change since '
|
|
'last press.')
|
|
if not newly:
|
|
output.print_md('- no piece was newly marked reinforced since the last '
|
|
'snapshot. (This is "nothing changed", not "nothing '
|
|
'is reinforced".)')
|
|
else:
|
|
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
|
|
'{1}'.format(len(newly), ', '.join(newly)))
|
|
output.print_md('- feature-IDs new to the vocabulary this press: '
|
|
'**{0}**; already known: **{1}**'
|
|
.format(len(stats['new_ids']),
|
|
len(stats['known_ids'])))
|
|
for panel in stats['panels']:
|
|
if panel['feature_count'] is None:
|
|
output.print_md(' - **{0}** -- could not be measured; no '
|
|
'features recorded'.format(panel['name']))
|
|
continue
|
|
bits = ['{0} feature(s)'.format(panel['feature_count'])]
|
|
if panel['new_ids']:
|
|
bits.append('new IDs {0}'.format(
|
|
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
|
|
if panel['known_ids']:
|
|
bits.append('known IDs {0}'.format(
|
|
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
|
|
if panel['unregistered']:
|
|
bits.append('{0} unregistered'.format(panel['unregistered']))
|
|
output.print_md(' - **{0}** -- {1}'.format(
|
|
panel['name'], '; '.join(bits)))
|
|
|
|
if log:
|
|
# Journal the same figures. Per-panel id lists are kept so the ledger
|
|
# can be replayed without the model; `first_snapshot` distinguishes a
|
|
# cold index from a genuine no-change press.
|
|
S.append(log, {'event': 'phase-a-update-stats',
|
|
'first_snapshot': stats['first_snapshot'],
|
|
'newly': newly,
|
|
'new_ids': stats['new_ids'],
|
|
'known_ids': stats['known_ids'],
|
|
'panels': stats['panels']})
|
|
|
|
|
|
# ---------------------------------------------- handing the model over
|
|
#
|
|
# Both routes below need the same two things: WHERE the copy goes (a plan) and
|
|
# HOW to find the model again (its identity). Built once, here, so the cycle
|
|
# and the close-model ritual cannot drift into naming files differently.
|
|
|
|
|
|
def _source_block():
|
|
"""How another session is to find this model again.
|
|
|
|
Cloud GUIDs and file path both, when both are available: a cloud model is
|
|
reopened from its GUIDs, a file-based one from its path, and which one a
|
|
job is was never something to assume. A cloud model reports no usable
|
|
local path, which is the ordinary case here, not an error.
|
|
"""
|
|
src = {'path': source_path, 'title': index.doc_key(doc)}
|
|
try:
|
|
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
|
|
mp = doc.GetCloudModelPath()
|
|
src['cloud'] = {
|
|
'region': str(mp.Region),
|
|
'project_guid': str(mp.GetProjectGUID()),
|
|
'model_guid': str(mp.GetModelGUID()),
|
|
}
|
|
except Exception as exc:
|
|
# Not a cloud model, or this Revit does not expose the GUIDs. The file
|
|
# path is then the only handle and the runner says so if it is absent.
|
|
src['cloud'] = None
|
|
src['cloud_error'] = str(exc)
|
|
return src
|
|
|
|
|
|
def _write_request(run_id, plan, source, extra=None):
|
|
"""The handoff file. Same schema for both routes -- one runner reads it."""
|
|
request = {
|
|
'run': run_id,
|
|
'base': plan['title'],
|
|
'layout': layout,
|
|
'worksets': worksets,
|
|
'detached_dir': detached_dir,
|
|
'plan': plan,
|
|
'log': log,
|
|
'job_folder': job_folder,
|
|
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'requested_by_revit': _revit_version(),
|
|
'source': source,
|
|
}
|
|
request.update(extra or {})
|
|
req_dir = os.path.join(detached_dir, '_requests')
|
|
req_path = os.path.join(req_dir, run_id + '.json')
|
|
if not os.path.isdir(req_dir):
|
|
os.makedirs(req_dir)
|
|
import json
|
|
handle = open(req_path, 'wb')
|
|
try:
|
|
handle.write(json.dumps(request, indent=2).encode('utf-8'))
|
|
finally:
|
|
handle.close()
|
|
|
|
# And the identity on its own, where the host pipeline looks for it.
|
|
# A cloud model is found by its region and two GUIDs, and NOTHING on this
|
|
# machine knows those except a session with the model open -- this one.
|
|
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
|
|
# later with no Revit anywhere and still find the model to copy. It is
|
|
# identity, never state: it says which model, never anything about a copy.
|
|
try:
|
|
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
|
|
try:
|
|
cache.write(json.dumps(source, indent=2,
|
|
sort_keys=True).encode('utf-8'))
|
|
finally:
|
|
cache.close()
|
|
except Exception:
|
|
pass # a lost cache costs a flag, not a snapshot
|
|
return req_path
|
|
|
|
|
|
def _spawn(argv):
|
|
"""Start a host process that outlives this one. Returns its pid.
|
|
|
|
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
|
|
minutes with nobody watching, and a console window popping up over the
|
|
engineer's Revit is the kind of thing that gets a tool switched off.
|
|
"""
|
|
from System.Diagnostics import Process, ProcessStartInfo
|
|
psi = ProcessStartInfo()
|
|
psi.FileName = argv[0]
|
|
psi.Arguments = S.quote(argv[1:])
|
|
psi.UseShellExecute = False
|
|
psi.CreateNoWindow = True
|
|
psi.WorkingDirectory = REPO_ROOT
|
|
return Process.Start(psi).Id
|
|
|
|
|
|
def _job_knowledge_path():
|
|
"""The job knowledge file, so the host pipeline reads the same layer.
|
|
|
|
Handed over as a path rather than as resolved values: the pipeline needs
|
|
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
|
|
block, and a button that copies six settings across is a button that
|
|
forgets the seventh the day one is added.
|
|
"""
|
|
try:
|
|
for label, path, exists in knowledge.sources(doc):
|
|
if label == 'job' and exists:
|
|
return path
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------- the sandbox cycle
|
|
#
|
|
# The new route, and the one the commission is about. Nothing here opens,
|
|
# saves or closes a model: it writes the handoff and starts a host process
|
|
# that does all of it in Revits that are not this one.
|
|
|
|
run_id = None
|
|
if cycling and cycle_stop is None:
|
|
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
|
base = index.doc_key(doc) or 'untitled'
|
|
plan = S.plan(detached_dir, base, layout=layout)
|
|
started_pid = None
|
|
argv = None
|
|
try:
|
|
req_path = _write_request(run_id, plan, _source_block(),
|
|
{'requested_by': 'SnapshotClose button, '
|
|
'cycle route'})
|
|
argv = [liftexport.find_python(know), CYCLE_TOOL,
|
|
'--fresh-detach',
|
|
'--request', req_path,
|
|
'--detached-dir', detached_dir,
|
|
'--pyrevit', _pyrevit_cli(),
|
|
'--revit-exe', revit_exe]
|
|
if revit_year:
|
|
argv.extend(['--revit', str(revit_year)])
|
|
if log:
|
|
argv.extend(['--log', log])
|
|
job_knowledge = _job_knowledge_path()
|
|
if job_knowledge:
|
|
argv.extend(['--knowledge', job_knowledge])
|
|
for command in batch:
|
|
argv.extend(['--run', command])
|
|
started_pid = _spawn(argv)
|
|
except Exception:
|
|
started_pid = None
|
|
cycle_stop = 'the pipeline could not be started: {0}'.format(
|
|
traceback.format_exc().splitlines()[-1])
|
|
|
|
output.print_md('---')
|
|
output.print_md('## The sandbox cycle - running now, beside this session')
|
|
if started_pid:
|
|
output.print_md(
|
|
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
|
|
'-- it is not closed, not saved, and not read again.'
|
|
.format(run_id, started_pid))
|
|
output.print_md('It writes:')
|
|
output.print_md('`{0}`'.format(plan['path']))
|
|
if plan.get('warning'):
|
|
output.print_md('**{0}**'.format(plan['warning']))
|
|
output.print_md('then opens that copy in a Revit of its own, runs '
|
|
'`{0}`, and closes it again. Its log lands beside the '
|
|
'copy and its outcome in `{1}`.'
|
|
.format(' + '.join(batch), log))
|
|
output.print_md('The command it is running:')
|
|
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
|
|
if log:
|
|
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
|
'path': plan['path'], 'request': req_path,
|
|
'route': 'cycle', 'host_pid': started_pid})
|
|
else:
|
|
output.print_md('**Not started: {0}**'.format(cycle_stop))
|
|
if log:
|
|
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
|
'route': 'cycle', 'reason': cycle_stop})
|
|
elif cycling:
|
|
output.print_md('---')
|
|
output.print_md('## The sandbox cycle - not run')
|
|
output.print_md('Not started: {0}'.format(cycle_stop))
|
|
if log:
|
|
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
|
|
'reason': cycle_stop})
|
|
|
|
|
|
# ------------------------------------------------------------------ phase C
|
|
#
|
|
# The OLD route, kept: requested here, run later, by a process that outlives
|
|
# this one. Everything it needs goes into a request file -- `pyrevit run`
|
|
# passes no arguments to the script it runs, so a file named by an environment
|
|
# variable is the only handoff there is.
|
|
#
|
|
# This is the only place `snapshot.after_close` still means anything.
|
|
|
|
if closing and blocker is None:
|
|
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
|
base = index.doc_key(doc) or 'untitled'
|
|
plan = S.plan(detached_dir, base, layout=layout)
|
|
launched = None
|
|
try:
|
|
req_path = _write_request(run_id, plan, _source_block())
|
|
argv = [liftexport.find_python(know), DEFER_TOOL,
|
|
'--request', req_path,
|
|
'--runner', RUNNER,
|
|
'--pyrevit', _pyrevit_cli(),
|
|
'--wait-pid', str(_revit_pid()),
|
|
'--wait-minutes', str(wait_minutes),
|
|
# The close-model ritual is the one case where waiting for the
|
|
# whole machine to be free is still the right answer: nobody
|
|
# is at the keyboard and nothing else is queued behind it.
|
|
'--machine-free', 'require']
|
|
if revit_year:
|
|
argv.extend(['--revit', str(revit_year)])
|
|
_spawn(argv)
|
|
launched = argv
|
|
except Exception:
|
|
launched = None
|
|
blocker = 'the deferred process could not be started: {0}'.format(
|
|
traceback.format_exc().splitlines()[-1])
|
|
|
|
output.print_md('---')
|
|
output.print_md('## Phase C - the detached copy, after Revit exits')
|
|
if launched:
|
|
output.print_md('Requested as `{0}`. It waits for this Revit '
|
|
'(pid {1}) to exit -- and then for the machine to be '
|
|
'free of Revit altogether -- then opens the model in a '
|
|
'session of its own and writes:'
|
|
.format(run_id, _revit_pid()))
|
|
output.print_md('`{0}`'.format(plan['path']))
|
|
if plan.get('warning'):
|
|
output.print_md('**{0}**'.format(plan['warning']))
|
|
output.print_md('The command it will run:')
|
|
output.print_md('```\n{0}\n```'.format(S.quote(
|
|
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
|
|
output.print_md('Its outcome is journalled to `{0}` and read out the '
|
|
'next time this button is pressed.'.format(log))
|
|
if log:
|
|
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
|
'path': plan['path'], 'request': req_path,
|
|
'route': 'after-close', 'wait_pid': _revit_pid()})
|
|
else:
|
|
output.print_md('**Not requested: {0}**'.format(blocker))
|
|
if log:
|
|
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
|
'route': 'after-close', 'reason': blocker})
|
|
elif closing:
|
|
output.print_md('---')
|
|
output.print_md('## Phase C - not run')
|
|
output.print_md('No detached copy: {0}'.format(blocker))
|
|
if log:
|
|
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
|
|
'reason': blocker})
|
|
|
|
|
|
# ------------------------------------------------------------------ phase B
|
|
|
|
output.print_md('---')
|
|
if not closing:
|
|
if cycling:
|
|
output.print_md('Revit stays open. The index is written and stamped, '
|
|
'and the cycle is running in processes of its own -- '
|
|
'nothing here was closed and your model was never '
|
|
'touched.')
|
|
else:
|
|
output.print_md('Revit stays open, as asked. The index is written and '
|
|
'stamped; nothing was closed.')
|
|
# A normal completion for the STAY / cycle choices: Revit stays open,
|
|
# the snapshot index is on disk. The CLOSE path below records its own
|
|
# completion (Revit is about to exit, so this is the last write).
|
|
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
|
|
report_path=report['path'] if report else None,
|
|
report_text=('Revit stays open. Index written: {0}'
|
|
.format(report['path'] if report else '?')))
|
|
script.exit()
|
|
|
|
output.print_md('## Phase B - closing Revit')
|
|
output.print_md('Revit will ask you about unsaved changes if it has any. '
|
|
'That question is yours to answer -- this button never syncs '
|
|
'and never saves the model.')
|
|
|
|
posted = False
|
|
# There is no PostableCommand for quitting, so the internal command id is
|
|
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
|
|
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
|
|
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
|
|
# helper is a version-dependent convenience and this is the one step of the
|
|
# button that cannot be retried by pressing it again.
|
|
try:
|
|
from pyrevit import HOST_APP
|
|
HOST_APP.post_command('ID_APP_EXIT')
|
|
posted = True
|
|
except Exception:
|
|
try:
|
|
from pyrevit import HOST_APP
|
|
from Autodesk.Revit.UI import RevitCommandId
|
|
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
|
|
if cmd is not None:
|
|
HOST_APP.uiapp.PostCommand(cmd)
|
|
posted = True
|
|
except Exception:
|
|
posted = False
|
|
|
|
if not posted:
|
|
output.print_md('**Revit would not accept the close command.** The '
|
|
'snapshot is written and stamped; close Revit yourself.')
|
|
|
|
# The CLOSE path's completion. If the close command was accepted Revit is
|
|
# about to exit, so this record is the button's last write; if not, the
|
|
# run still produced its snapshot and says so.
|
|
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
|
|
report_path=report['path'] if report else None,
|
|
report_text=('Close posted: {0}. Index written: {1}'
|
|
.format(posted,
|
|
report['path'] if report else '?')))
|
|
|
|
except Exception as e:
|
|
# The deliberate exits above never reach here (script.exit() raises
|
|
# SystemExit, which Exception does not catch) -- this is the
|
|
# unexpected-exception path, and pyRevit shows its own dialog after
|
|
# the record is written.
|
|
runlog.record('Snapshot Close',
|
|
doc_title=doc.Title if doc is not None else None,
|
|
outcome='error', error=str(e),
|
|
traceback_text=traceback.format_exc())
|
|
raise
|