52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
336 lines
15 KiB
Python
336 lines
15 KiB
Python
# -*- 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()
|