Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

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()