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.

Three unattended failures and what actually detects them Three ways an automated export fails that a manual one does not, with the evidence that identifies each. None is detected by the exit code: a hang produces no exit at all, a stale output produces a successful one, and a partial file is readable. The checks that work are a timeout, a modification-time comparison and an atomic publish. Failure Exit code says What detects it Hangs on a dialog nothing — never exits wall-clock timeout Writes nothing success output mtime vs run start Read while writing success stage then rename The exit code is evidence of none of the three.

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}"],
    ))
Staging and the atomic publish A sequence across the scheduler, a staging directory and the published location. The export writes into staging, the checks run there, and only a passing artefact is renamed into place. A consumer polling the published location therefore sees either the previous export or the new one, and a failed run leaves yesterday untouched. scheduler staging published export under a timeout file + mtime verify: newer, non-empty rename into place atomic — no partial read

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.replace is 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.

The manifest a consumer reads instead of a directory listing The facts a consumer would otherwise have to guess. The source model and its modification time say what was exported, the configuration identifies which export produced it, the duration reveals a trend before it becomes a timeout, and the timestamp lets a consumer warn when the artefact is older than expected. source //models/project.rvt what was exported source_mtime 2026-08-06T21:14:02Z the model state at export time config export-config.json@a41f which configuration produced it duration_s 1284 a rising trend precedes a timeout written_at 2026-08-07T02:31:44Z stale-but-valid is visible Written after the publish, so its presence means the export completed.

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.