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,335 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Phase C: write one timestamped detached copy, then quit. NO UI, NO USER.
|
||||
|
||||
pyrevit run <this file> --revit=2025 --purge
|
||||
|
||||
This is not a button. It runs in a Revit that `pyrevit run` started from a
|
||||
journal, with no engineer in front of it, AFTER the engineer's own Revit has
|
||||
exited -- that ordering is the whole point. The machine cannot hold two copies
|
||||
of a 300+ MB model (10+ GB open), so this session exists only because the
|
||||
other one is already gone.
|
||||
|
||||
WHY IT IS NOT IN A `.pushbutton` FOLDER
|
||||
----------------------------------------
|
||||
pyRevit builds the ribbon from `*.tab / *.panel / *.pushbutton` folder names
|
||||
and ignores everything else in an extension, so a plain `runners/` directory
|
||||
is invisible to the UI -- which is correct: this must never appear as a button
|
||||
somebody can press while a model is open. The extension's `lib/` is on
|
||||
`sys.path` for the session either way, so `from vibe import snapshotmeta`
|
||||
resolves normally.
|
||||
|
||||
HOW IT IS TOLD WHAT TO DO
|
||||
--------------------------
|
||||
`pyrevit run` passes NO arguments to the script it runs. The handoff is
|
||||
therefore a request file written by the button, named by the environment
|
||||
variable `WILLTOOLS_SNAPSHOT_REQUEST`, which `tools/snapshot_defer.py` sets on
|
||||
this process. The request carries the source model's identity, where the copy
|
||||
goes, and where to journal the outcome.
|
||||
|
||||
WHAT IT IS ALLOWED TO WRITE
|
||||
----------------------------
|
||||
Exactly one thing: a NEW `.rvt` file, at a path that did not exist before, via
|
||||
`SaveAs`. It opens the source DETACHED where detaching is legal (see the next
|
||||
section), so the document in memory has no connection to the central and
|
||||
cannot sync to it. It never calls `SynchronizeWithCentral`, never `Save`,
|
||||
never opens a Transaction, and never touches the model it copied. If the
|
||||
target path already exists it does not overwrite -- it journals a skip and
|
||||
quits.
|
||||
|
||||
A CLOUD MODEL CANNOT BE OPENED DETACHED, AND THIS USED TO GET IT WRONG
|
||||
----------------------------------------------------------------------
|
||||
Until 2026-09-01 this file set `DetachFromCentralOption.DetachAndPreserveWorksets`
|
||||
on EVERY open, including the cloud-GUID path -- which is the path this job
|
||||
actually takes, because the live model is an Autodesk Docs model. That throws.
|
||||
|
||||
"Detach option is not valid for cloud model."
|
||||
|
||||
Autodesk's own answer on the API forum is that the Revit team does not allow
|
||||
cloud models to be detached, that **SaveAs is the recommended way** to get a
|
||||
detached copy of one, and that the API is aligned to the UI -- where the
|
||||
"Detach from Central" checkbox is greyed out for anything opened from
|
||||
Autodesk Docs, because cloud worksharing is not the worksharing that option
|
||||
belongs to. Sources, read 2026-09-01:
|
||||
|
||||
forums.autodesk.com/t5/revit-api-forum/
|
||||
how-to-open-and-detach-a-cloud-workshared-model/td-p/10371092
|
||||
autodesk.com/support/technical/article/caas/sfdcarticles/sfdcarticles/
|
||||
Detach-from-Central-grayed-out-when-opening-from-BIM-360-shared-file-
|
||||
in-Revit.html
|
||||
|
||||
So the detach option is now applied ONLY to a file-path source. For a cloud
|
||||
source the model is opened plainly and the SEVERING IS THE SaveAs: a cloud
|
||||
workshared document saved to a local path with `SaveAsCentral = True` becomes
|
||||
a new local central with no route back to the cloud model. Same end state,
|
||||
reached the way the API allows.
|
||||
|
||||
This is a real defect that had never fired, because phase C end-to-end was
|
||||
never run on the cloud job (QNA/review-2026-08-30.md listed it as needing the
|
||||
bridge). It would have failed on the first live press.
|
||||
|
||||
WHY IT DOES NOT REBUILD THE INDEX
|
||||
----------------------------------
|
||||
Phase A already did, from the live document, at the same sync state -- the
|
||||
same numbers. Re-running it here would overwrite a good index with one built
|
||||
in a session whose job-folder resolution depends on the copy's title, which is
|
||||
exactly the thing this design had to be careful about. The detached copy's
|
||||
purpose is FUTURE re-scans: extractors that do not exist yet, run months from
|
||||
now against a state nobody can go back to. Its provenance says so in those
|
||||
words, so a later reader does not mistake it for the source of today's index.
|
||||
|
||||
ROUTES
|
||||
------
|
||||
`pyrevit run` uses the attached clone, so this session loads pyRevit normally
|
||||
and `startup.py` registers the bridge API. The routes SERVER is a separate
|
||||
question: pyRevit only activates one when `routes_server` is on in the user
|
||||
config, and when it does, `serverinfo._get_next_available_port` starts at the
|
||||
configured port and walks UP past every port already registered by a running
|
||||
Revit. There is nothing to clash with here anyway -- this session only exists
|
||||
after the engineer's Revit has exited -- but if he has restarted in the
|
||||
meantime, his session keeps 48884 and this one takes 48885.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
ENV = 'WILLTOOLS_SNAPSHOT_REQUEST'
|
||||
|
||||
|
||||
def _log(request, event, **fields):
|
||||
path = (request or {}).get('log')
|
||||
if not path:
|
||||
return
|
||||
row = {'event': event, 'run': (request or {}).get('run')}
|
||||
row.update(fields)
|
||||
S.append(path, row)
|
||||
|
||||
|
||||
def _read_request():
|
||||
path = os.environ.get(ENV)
|
||||
if not path:
|
||||
raise RuntimeError(
|
||||
'{0} is not set. This script is launched by '
|
||||
'tools/snapshot_defer.py, which writes it.'.format(ENV))
|
||||
handle = open(path, 'rb')
|
||||
try:
|
||||
return json.loads(handle.read().decode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
def _fallback_log():
|
||||
"""`<detached_dir>/snapshots.jsonl`, recovered with no request to read.
|
||||
|
||||
Same trick as `tools/snapshot_defer.py`'s `fallback_log_for`, and for the
|
||||
same reason: the request file IS where `log` lives, so a request that
|
||||
cannot be read has nothing to send a journal line to by the ordinary
|
||||
route. But `WILLTOOLS_SNAPSHOT_REQUEST` always names a file at
|
||||
`<detached_dir>/_requests/<run>.json`, so the well-known log path can be
|
||||
recovered from that FILENAME alone. None when the env var is unset or
|
||||
does not have that shape -- a guessed path is worse than no line.
|
||||
"""
|
||||
path = os.environ.get(ENV)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
requests_dir = os.path.dirname(os.path.abspath(path))
|
||||
if os.path.basename(requests_dir) != '_requests':
|
||||
return None
|
||||
return S.log_path(os.path.dirname(requests_dir))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _source_model_path(request):
|
||||
"""A Revit `ModelPath` for the model to copy, and how it was identified.
|
||||
|
||||
Cloud GUIDs first, the file path second. Which of the two a job has was
|
||||
never something to assume -- the engineer here works in a detached local
|
||||
file, and a tool that only knew how to reopen a cloud model would have
|
||||
failed on it with a message about the cloud.
|
||||
"""
|
||||
from Autodesk.Revit.DB import ModelPathUtils
|
||||
src = (request or {}).get('source') or {}
|
||||
cloud = src.get('cloud')
|
||||
if cloud:
|
||||
from System import Guid
|
||||
return (ModelPathUtils.ConvertCloudGUIDsToCloudPath(
|
||||
cloud['region'],
|
||||
Guid(cloud['project_guid']),
|
||||
Guid(cloud['model_guid'])), 'cloud GUIDs', True)
|
||||
path = src.get('path')
|
||||
if not path:
|
||||
raise RuntimeError(
|
||||
'the request names neither cloud GUIDs nor a file path, so there '
|
||||
'is nothing to reopen. Cloud lookup said: {0}'
|
||||
.format(src.get('cloud_error') or 'nothing'))
|
||||
if not os.path.isfile(path):
|
||||
raise RuntimeError('the source model is gone: {0}'.format(path))
|
||||
return (ModelPathUtils.ConvertUserVisiblePathToModelPath(path),
|
||||
'file path', False)
|
||||
|
||||
|
||||
def _open_detached(app, model_path, worksets, is_cloud=False):
|
||||
"""The source, open in memory. Detached where the API allows detaching.
|
||||
|
||||
`is_cloud` is not a style choice: setting ANY `DetachFromCentralOption`
|
||||
on a cloud model raises "Detach option is not valid for cloud model" and
|
||||
the copy never happens. See this module's header for the sources. The
|
||||
cloud model is opened plainly and severed by the `SaveAs` instead.
|
||||
"""
|
||||
from Autodesk.Revit.DB import (DetachFromCentralOption, OpenOptions,
|
||||
WorksetConfiguration,
|
||||
WorksetConfigurationOption)
|
||||
opts = OpenOptions()
|
||||
if is_cloud:
|
||||
# No detach option at all. Worksets still open in full, because the
|
||||
# copy is meant to be a complete model and a half-opened one measures
|
||||
# short without saying so.
|
||||
try:
|
||||
opts.SetOpenWorksetsConfiguration(WorksetConfiguration(
|
||||
WorksetConfigurationOption.OpenAllWorksets))
|
||||
except Exception:
|
||||
pass
|
||||
return app.OpenDocumentFile(model_path, opts)
|
||||
if (worksets or 'preserve') == 'discard':
|
||||
opts.DetachFromCentralOption = \
|
||||
DetachFromCentralOption.DetachAndDiscardWorksets
|
||||
else:
|
||||
opts.DetachFromCentralOption = \
|
||||
DetachFromCentralOption.DetachAndPreserveWorksets
|
||||
try:
|
||||
opts.SetOpenWorksetsConfiguration(WorksetConfiguration(
|
||||
WorksetConfigurationOption.OpenAllWorksets))
|
||||
except Exception:
|
||||
# A non-workshared source has no workset configuration to set.
|
||||
# Not fatal, and not worth losing the copy over.
|
||||
pass
|
||||
return app.OpenDocumentFile(model_path, opts)
|
||||
|
||||
|
||||
def _save_as(doc, path, worksets, is_cloud=False):
|
||||
"""The one write this whole feature is allowed to make. A NEW file.
|
||||
|
||||
For a CLOUD source this is not merely the write, it is the detach: a
|
||||
cloud workshared document saved to a local path as a new CENTRAL has no
|
||||
route back to the cloud model. `SaveAsCentral` is therefore forced on for
|
||||
a cloud source whatever `worksets` says -- `worksets: discard` describes
|
||||
what a detach-from-central does to worksets, and a cloud model was never
|
||||
detached in the first place, so there is nothing for it to describe.
|
||||
"""
|
||||
from Autodesk.Revit.DB import (ModelPathUtils, SaveAsOptions,
|
||||
WorksharingSaveAsOptions)
|
||||
opts = SaveAsOptions()
|
||||
opts.OverwriteExistingFile = False
|
||||
if is_cloud or (worksets or 'preserve') != 'discard':
|
||||
# A detached-with-worksets document can only be saved as a new
|
||||
# CENTRAL. Without this the SaveAs raises, and the message it raises
|
||||
# says nothing useful about why.
|
||||
try:
|
||||
ws = WorksharingSaveAsOptions()
|
||||
ws.SaveAsCentral = True
|
||||
opts.SetWorksharingOptions(ws)
|
||||
except Exception:
|
||||
pass
|
||||
doc.SaveAs(ModelPathUtils.ConvertUserVisiblePathToModelPath(path), opts)
|
||||
|
||||
|
||||
def main():
|
||||
request = None
|
||||
started = time.time()
|
||||
try:
|
||||
request = _read_request()
|
||||
except Exception:
|
||||
# There is no `request['log']` to journal to -- the request file IS
|
||||
# where it lives, and reading it is exactly what failed. Until
|
||||
# 2026-09-02 that made this branch genuinely silent: the deferred
|
||||
# host tool's own FAILED line covers "the pyRevit run finished but
|
||||
# nothing was written", which is a true sentence here too, but it
|
||||
# cannot say WHY, and this is the one exception in the whole feature
|
||||
# that carries a `RuntimeError` message worth keeping.
|
||||
# `_fallback_log` recovers `<detached_dir>/snapshots.jsonl` from the
|
||||
# env var's own path, with no JSON parse needed, so this failure
|
||||
# gets a line of its own instead of dissolving into the defer tool's
|
||||
# generic one.
|
||||
fallback = _fallback_log()
|
||||
if fallback:
|
||||
S.append(fallback, {
|
||||
'event': S.WATCHER_CRASHED, 'run': None,
|
||||
'error': 'the runner could not read its own request: {0}'
|
||||
.format(traceback.format_exc().splitlines()[-1]),
|
||||
'traceback': traceback.format_exc()})
|
||||
raise
|
||||
|
||||
plan = request.get('plan') or {}
|
||||
target = plan.get('path')
|
||||
try:
|
||||
if not target:
|
||||
raise RuntimeError('the request carries no target path')
|
||||
if os.path.isfile(target):
|
||||
_log(request, S.SKIPPED, path=target,
|
||||
reason='a copy with this timestamp is already there')
|
||||
return
|
||||
folder = os.path.dirname(target)
|
||||
if folder and not os.path.isdir(folder):
|
||||
os.makedirs(folder)
|
||||
|
||||
from pyrevit import HOST_APP
|
||||
app = HOST_APP.app
|
||||
|
||||
model_path, how, is_cloud = _source_model_path(request)
|
||||
doc = _open_detached(app, model_path, request.get('worksets'),
|
||||
is_cloud)
|
||||
try:
|
||||
_save_as(doc, target, request.get('worksets'), is_cloud)
|
||||
finally:
|
||||
try:
|
||||
doc.Close(False) # False: save nothing, ever
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prov = S.provenance(
|
||||
'C', S.FRESH_DETACH,
|
||||
source_path=(request.get('source') or {}).get('path'),
|
||||
# None, not plan['stamp']: that is THIS copy's stamp (already in
|
||||
# the folder name and the `at` field). The source is the live
|
||||
# central being reopened and carries no snapshot stamp of its own.
|
||||
source_stamp=None,
|
||||
source_title=(request.get('source') or {}).get('title'),
|
||||
revit='{0} ({1})'.format(getattr(app, 'VersionNumber', '?'),
|
||||
getattr(app, 'VersionBuild', '?')),
|
||||
job_folder=request.get('job_folder'),
|
||||
note=('an archival copy, not the source of any current index. '
|
||||
'Phase A read the live document at the same sync state and '
|
||||
'produced identical numbers. This file exists for re-scans '
|
||||
'by extractors that did not exist when it was written, and '
|
||||
'as the sandbox tools/snapshot_cycle.py drives. Reopened '
|
||||
'from {0}; severed by {1}.'
|
||||
.format(how, 'SaveAs to a new local central (a cloud model '
|
||||
'cannot be opened detached)' if is_cloud
|
||||
else 'open-detached from central')))
|
||||
try:
|
||||
handle = open(os.path.join(os.path.dirname(target),
|
||||
'snapshot.json'), 'wb')
|
||||
try:
|
||||
handle.write(json.dumps(prov, indent=2,
|
||||
sort_keys=True).encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_log(request, S.DONE, path=target, how=how,
|
||||
seconds=round(time.time() - started, 1), provenance=prov)
|
||||
except Exception:
|
||||
_log(request, S.FAILED, path=target,
|
||||
seconds=round(time.time() - started, 1),
|
||||
error=traceback.format_exc().splitlines()[-1],
|
||||
traceback=traceback.format_exc())
|
||||
raise
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user