Automating a Nightly BIM Export with Python
An unattended export has three failure modes that a manual one does not: it can hang, it can exit successfully having written nothing, and it can be read half-written by a consumer. The scheduler below handles all three — a wall-clock timeout, a stale-output check, and an atomic publish — and writes a manifest so the pipeline knows what it is consuming. This page belongs to Revit and Navisworks Export Paths.
What Goes Wrong Unattended
A desktop application driven headlessly behaves differently from one driven by a person, and the differences are all about waiting. A modal dialog that a person would dismiss blocks forever. A licence check that fails returns a non-zero exit that looks like any other error. A partially written file exists on disk and is perfectly readable, just incomplete.
The consequence is that neither the exit code nor the existence of an output file is sufficient evidence of success. What is sufficient is: the process finished within a bounded time, the output file is newer than the start of the run, and the file passes the acceptance checks for its format. The first two are the scheduler’s job and the third belongs to the format — for IFC it is described in the sibling guide on exporting Revit models to IFC.
Publishing is the fourth concern. A consumer polling a directory will happily read a file that is still being written. Writing to a temporary name and renaming into place makes the appearance atomic on any POSIX filesystem, so a consumer sees either the previous export or the new one, never a partial one.
Production-Ready Script
# Python 3.9+ — standard library only, so it runs on the export host unchanged
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass, asdict
from pathlib import Path
class ExportFailed(RuntimeError):
pass
@dataclass(frozen=True)
class Manifest:
source: str
source_mtime: float
config: str
output: str
output_bytes: int
started_at: float
duration_s: float
def run_export(
command: list[str],
*,
workdir: Path,
timeout_s: int,
) -> None:
"""Drive the authoring application, bounded in time."""
try:
proc = subprocess.run(command, cwd=workdir, timeout=timeout_s,
capture_output=True, text=True)
except subprocess.TimeoutExpired as exc:
raise ExportFailed(
f"export exceeded {timeout_s}s — likely waiting on a dialog"
) from exc
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-5:]
raise ExportFailed(f"exporter exited {proc.returncode}: {' | '.join(tail)}")
def publish_atomically(staged: Path, destination: Path) -> None:
"""Rename into place so a consumer never sees a partial file."""
destination.parent.mkdir(parents=True, exist_ok=True)
tmp = destination.with_suffix(destination.suffix + ".incoming")
shutil.copy2(staged, tmp)
os.replace(tmp, destination) # atomic within one filesystem
def nightly_export(
source_model: Path,
config: Path,
destination: Path,
command_template: list[str],
*,
timeout_s: int = 5400,
) -> Manifest:
started = time.time()
with tempfile.TemporaryDirectory() as tmpdir:
staged = Path(tmpdir) / destination.name
command = [c.format(model=source_model, out=staged, config=config)
for c in command_template]
run_export(command, workdir=Path(tmpdir), timeout_s=timeout_s)
if not staged.exists():
raise ExportFailed("exporter reported success but wrote no file")
if staged.stat().st_mtime < started:
raise ExportFailed("output predates the run — a stale file was left in place")
if staged.stat().st_size == 0:
raise ExportFailed("output is empty")
publish_atomically(staged, destination)
manifest = Manifest(
source=str(source_model),
source_mtime=source_model.stat().st_mtime,
config=str(config),
output=str(destination),
output_bytes=destination.stat().st_size,
started_at=started,
duration_s=time.time() - started,
)
destination.with_suffix(".manifest.json").write_text(
json.dumps(asdict(manifest), indent=2))
return manifest
if __name__ == "__main__":
print(nightly_export(
Path("//models/project.rvt"),
Path("./export-config.json"),
Path("//published/project.ifc"),
command_template=["ExportRunner.exe", "{model}", "{out}", "{config}"],
))
Key implementation notes:
- The export writes into a temporary directory and is copied into place only after the checks pass, so a failed run leaves the previous published export untouched.
os.replaceis atomic within a filesystem; the staging directory must therefore be on the same volume as the destination for the guarantee to hold. Where it is not, stage inside the destination directory instead.- The stale-output check compares against the run start time rather than against a stored previous time, so it works on the first run.
- Standard library only, deliberately. The export host is a licensed desktop machine, and every dependency added there is one that has to be maintained on a machine nobody wants to touch.
- The manifest is written after the publish, so its presence means the export completed.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
| Python | 3.9+ | subprocess.run with timeout |
| Host | the licensed authoring machine | exports cannot run elsewhere |
| Filesystem | staging and destination on one volume | required for atomic rename |
| Scheduler | any | cron, Task Scheduler, CI runner |
| Consumers | any | read the manifest, not the directory listing |
Fallback Strategies
1. Timeout on a model that legitimately grew. Raise the timeout from observed durations, and record the duration in the manifest so the trend is visible before it becomes a failure.
2. Licence unavailable. The exporter exits non-zero. Retry once after a delay — contention is common at fixed schedule times — and stagger the schedules of several exports rather than running them together.
3. Rename across filesystems. os.replace raises. Stage inside the destination directory so the rename is within one volume.
4. Consumers reading during publish. Should be impossible with the rename, but a consumer that opens by glob may still pick up the .incoming file. Publish under a suffix consumers do not match, as above.
5. A failed run leaves nothing fresh. By design: the previous export stays published. Make the consumer read the manifest timestamp and warn when the artefact is older than expected, so stale-but-valid is visible rather than invisible.
FAQ
Why not trigger the export from the pipeline?
Because it couples the pipeline’s latency to an application that takes minutes and its availability to a licence. A scheduled export publishes an artefact; the pipeline consumes whatever is current. That decoupling also means several consumers share one export instead of each triggering their own.
How do I detect a hung export?
A wall-clock timeout on the subprocess, set generously from observed durations rather than optimistically. A GUI application waiting on a modal dialog will wait forever, and without a timeout the scheduled job simply never finishes and the next night’s run finds the lock still held.
What belongs in the manifest?
Whatever a consumer would otherwise have to guess: the source model path and its modification time, the export configuration identifier, the schema produced, the element counts, and the timestamp. That turns “the data looks wrong” into a comparison between two manifests.
Related Pages
- Revit and Navisworks Export Paths — parent reference on which route to automate
- Exporting Revit Models to IFC for Python Pipelines — the acceptance checks this scheduler runs
- Batch Converting DWG to DXF with the ODA File Converter — the same supervision pattern for a different converter