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,300 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `tools/snapshot_defer.py` -- the parts that need no Revit.
|
||||
|
||||
The tool's job splits cleanly: WAIT for a process, then LAUNCH one. The wait
|
||||
is the half that can be got wrong invisibly (it can decide a live Revit is
|
||||
gone, and then two 10 GB models are resident at once, which is the single
|
||||
outcome the whole design exists to prevent), so its clock, its sleep and its
|
||||
liveness check are all injected and all tested here. The launch is one argv
|
||||
built by `vibe.snapshotmeta`, tested there and asserted end-to-end here.
|
||||
|
||||
Loaded by path, like `test_knowledge_drift.py`: this is host CPython 3.12 code
|
||||
sitting in a repository whose extension is IronPython 2.7.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
from vibe import snapshotmeta as S # noqa: E402 (registers the vibe stub)
|
||||
|
||||
snapshot_defer = pytest.importorskip('snapshot_defer')
|
||||
D = snapshot_defer
|
||||
|
||||
# Built, never spelled out -- see the note in test_snapshotmeta.py.
|
||||
MODEL = 'm_detached' + S.MODEL_EXT
|
||||
|
||||
|
||||
class Clock(object):
|
||||
"""A wall clock that only moves when something sleeps on it."""
|
||||
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
self.slept = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.slept.append(seconds)
|
||||
self.t += seconds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the wait
|
||||
|
||||
def test_returns_at_once_when_the_process_is_already_gone():
|
||||
clock = Clock()
|
||||
assert D.wait_for_exit(123, 600, alive=lambda pid: False,
|
||||
sleep=clock.sleep, clock=clock.now) is True
|
||||
assert clock.slept == []
|
||||
|
||||
|
||||
def test_waits_until_the_process_goes_and_then_returns_true():
|
||||
clock = Clock()
|
||||
calls = {'n': 0}
|
||||
|
||||
def alive(_pid):
|
||||
calls['n'] += 1
|
||||
return calls['n'] <= 3
|
||||
|
||||
assert D.wait_for_exit(123, 600, alive=alive, sleep=clock.sleep,
|
||||
clock=clock.now, poll=5) is True
|
||||
assert clock.slept == [5, 5, 5]
|
||||
|
||||
|
||||
def test_gives_up_and_says_so_rather_than_launching_anyway():
|
||||
# The Revit that never closes -- an unanswered save prompt. Returning
|
||||
# False is what makes that a reportable outcome instead of a second Revit
|
||||
# started next to a live one.
|
||||
clock = Clock()
|
||||
assert D.wait_for_exit(123, 60, alive=lambda pid: True, sleep=clock.sleep,
|
||||
clock=clock.now, poll=10) is False
|
||||
assert clock.t - 1000.0 >= 60
|
||||
|
||||
|
||||
def test_a_zero_pid_is_not_waited_on():
|
||||
assert D.wait_for_exit(0, 600, alive=lambda pid: True,
|
||||
sleep=lambda s: None, clock=lambda: 0.0) is True
|
||||
|
||||
|
||||
def test_process_alive_says_false_for_a_pid_that_cannot_exist():
|
||||
# 0xFFFFFFF0 is not a valid Windows pid. The point of the assertion is
|
||||
# that the real implementation answers rather than raising.
|
||||
assert D.process_alive(0xFFFFFFF0) in (True, False)
|
||||
|
||||
|
||||
def test_process_alive_is_false_for_no_pid():
|
||||
assert D.process_alive(0) is False
|
||||
assert D.process_alive(None) is False
|
||||
|
||||
|
||||
def test_this_very_process_is_alive():
|
||||
assert D.process_alive(os.getpid()) is True
|
||||
|
||||
|
||||
# ------------------------------------------- the machine, not just one pid
|
||||
|
||||
def test_a_revit_started_again_during_the_wait_is_seen():
|
||||
# The hole this closes: he closes Revit, the tool starts waiting, he
|
||||
# opens Revit again for something else. Waiting only on the original pid
|
||||
# would launch a second 10 GB model beside his live one.
|
||||
assert D.any_revit_running(run=lambda argv: 'Revit.exe 1234 Console') \
|
||||
is True
|
||||
|
||||
|
||||
def test_an_empty_task_list_means_the_machine_is_free():
|
||||
assert D.any_revit_running(
|
||||
run=lambda argv: 'INFO: No tasks are running.') is False
|
||||
|
||||
|
||||
def test_a_task_list_that_will_not_run_counts_as_busy():
|
||||
# Unknown must never read as free: being wrong that way thrashes the
|
||||
# workstation, being wrong the other way costs a wait.
|
||||
def explode(argv):
|
||||
raise OSError('tasklist is not on the path')
|
||||
|
||||
assert D.any_revit_running(run=explode) is True
|
||||
|
||||
|
||||
def test_the_machine_wide_wait_uses_the_same_loop_and_can_time_out():
|
||||
clock = Clock()
|
||||
assert D.wait_for_exit(1, 60, alive=lambda _pid: True, sleep=clock.sleep,
|
||||
clock=clock.now, poll=10) is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the command
|
||||
|
||||
def test_command_for_matches_what_the_shared_module_builds():
|
||||
assert D.command_for('p.exe', 'r.py', 2025) == \
|
||||
S.pyrevit_run_argv('p.exe', 'r.py', 2025)
|
||||
|
||||
|
||||
def test_command_for_without_a_year():
|
||||
assert D.command_for('p.exe', 'r.py') == ['p.exe', 'run', 'r.py', '--purge']
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the dry run
|
||||
|
||||
def write_request(tmp_path, **over):
|
||||
plan = S.plan(str(tmp_path / 'snap'), 'model_detached', '20260830-1612')
|
||||
request = {'run': 'r20260830-161200', 'base': 'model_detached',
|
||||
'layout': 'folder', 'worksets': 'preserve', 'plan': plan,
|
||||
'log': str(tmp_path / 'snapshots.jsonl'),
|
||||
'source': {'path': str(tmp_path / ('model' + S.MODEL_EXT)), 'cloud': None}}
|
||||
request.update(over)
|
||||
path = tmp_path / 'req.json'
|
||||
path.write_text(json.dumps(request), encoding='utf-8')
|
||||
return str(path), request
|
||||
|
||||
|
||||
def test_dry_run_prints_the_command_and_launches_nothing(tmp_path, capsys):
|
||||
req_path, request = write_request(tmp_path)
|
||||
code = D.main(['--request', req_path, '--runner', r'C:\x\run.py',
|
||||
'--pyrevit', r'C:\x\pyrevit.exe', '--revit', '2025',
|
||||
'--wait-pid', '4242', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert 'pyrevit.exe" run "C:\\x\\run.py" --revit=2025 --purge' in out \
|
||||
or 'pyrevit.exe run C:\\x\\run.py --revit=2025 --purge' in out
|
||||
assert request['plan']['path'] in out
|
||||
assert 'WILLTOOLS_SNAPSHOT_REQUEST' in out
|
||||
assert '4242' in out
|
||||
# nothing was created, nothing was journalled
|
||||
assert not os.path.exists(request['log'])
|
||||
|
||||
|
||||
def test_dry_run_names_the_target_the_finder_will_later_find(tmp_path, capsys):
|
||||
req_path, request = write_request(tmp_path)
|
||||
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
||||
'--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
target = request['plan']['path']
|
||||
assert target in out
|
||||
# and once that file exists, discovery agrees it is the newest snapshot
|
||||
os.makedirs(os.path.dirname(target))
|
||||
with open(target, 'w') as fh:
|
||||
fh.write('x')
|
||||
assert S.newest(request['plan']['folder'].rsplit(os.sep, 1)[0])['path'] \
|
||||
== target
|
||||
|
||||
|
||||
# ------------------------------------------------------- listing what exists
|
||||
|
||||
def test_newest_listing_reports_nothing_when_there_is_nothing(tmp_path, capsys):
|
||||
code = D.main(['--newest', str(tmp_path)])
|
||||
assert code == 1
|
||||
assert 'no snapshots' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_newest_listing_marks_the_newest(tmp_path, capsys):
|
||||
root = str(tmp_path)
|
||||
for stamp in ('20260828-0730', '20260830-1612'):
|
||||
folder = os.path.join(root, 'm_detached_' + stamp)
|
||||
os.makedirs(folder)
|
||||
with open(os.path.join(folder, MODEL), 'w') as fh:
|
||||
fh.write('x')
|
||||
code = D.main(['--newest', root])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert out.strip().endswith('m_detached_20260830-1612' + os.sep +
|
||||
MODEL)
|
||||
assert '* 20260830-1612' in out
|
||||
assert ' 20260828-0730' in out
|
||||
|
||||
|
||||
# --------------------------------------------------- the 2026-09-02 incident
|
||||
#
|
||||
# A press on 2026-09-01 was journalled as failed within seconds by the CYCLE
|
||||
# route (not the after-close route the report first assumed), and the
|
||||
# runner's own FAILED line was never written at all -- meaning it crashed
|
||||
# before it could read its own request. Separately, this machine has five
|
||||
# Revit versions attached and `pyrevit run` with no `--revit=` reproducibly
|
||||
# hits the same bare "Object reference not set to an instance of an object"
|
||||
# pyRevit CLI wraps every unhandled exception in. These tests lock in the
|
||||
# three changes that came out of that investigation: `--pyrevit`
|
||||
# auto-discovery (so a run started without it is no longer a silent
|
||||
# `argparse.error()`), a loud journal line even when the request itself
|
||||
# cannot be read, and a `--revit=<year>` reminder in both human-facing
|
||||
# outputs (dry-run print, and the FAILED line of a real run).
|
||||
|
||||
def test_pyrevit_is_auto_discovered_when_left_off(tmp_path, monkeypatch,
|
||||
capsys):
|
||||
req_path, _request = write_request(tmp_path)
|
||||
monkeypatch.setattr(S, 'pyrevit_exe_guess',
|
||||
lambda: r'C:\guessed\pyrevit.exe')
|
||||
code = D.main(['--request', req_path, '--runner', 'r.py', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert r'C:\guessed\pyrevit.exe' in out
|
||||
|
||||
|
||||
def test_a_run_with_no_pyrevit_anywhere_journals_instead_of_arg_erroring(
|
||||
tmp_path, monkeypatch, capsys):
|
||||
"""Until 2026-09-02 this was `ap.error()`: stderr only, exit 2, and the
|
||||
ledger stayed exactly as empty as a run that never started at all."""
|
||||
req_path, request = write_request(tmp_path)
|
||||
monkeypatch.setattr(S, 'pyrevit_exe_guess', lambda: None)
|
||||
code = D.main(['--request', req_path, '--runner', 'r.py'])
|
||||
assert code == 2
|
||||
rows = S.read(request['log'])
|
||||
assert rows and rows[-1]['event'] == S.FAILED
|
||||
assert 'pyRevit CLI was not found' in rows[-1]['error']
|
||||
|
||||
|
||||
def test_an_unreadable_request_still_journals_to_the_detached_dir(tmp_path):
|
||||
"""The request file IS where `log` lives, so a request this tool cannot
|
||||
even parse used to leave nothing anywhere -- not even the traceback had
|
||||
a home. `fallback_log_for` recovers `<detached_dir>/snapshots.jsonl`
|
||||
from the `_requests/<run>.json` shape of the path alone."""
|
||||
detached_dir = tmp_path / 'snaps'
|
||||
req_dir = detached_dir / '_requests'
|
||||
req_dir.mkdir(parents=True)
|
||||
bad_req = req_dir / 'c-broken.json'
|
||||
bad_req.write_text('not json at all {{{', encoding='utf-8')
|
||||
code = D.main(['--request', str(bad_req), '--runner', 'r.py',
|
||||
'--pyrevit', 'p.exe'])
|
||||
assert code == 2
|
||||
rows = S.read(str(detached_dir / S.LOG_NAME))
|
||||
assert rows and rows[-1]['event'] == S.WATCHER_CRASHED
|
||||
assert rows[-1]['run'] is None
|
||||
|
||||
|
||||
def test_dry_run_warns_when_no_revit_year_was_given(tmp_path, capsys):
|
||||
req_path, _request = write_request(tmp_path)
|
||||
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
||||
'--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert 'WARNING' in out and '--revit=<year>' in out
|
||||
|
||||
|
||||
def test_dry_run_does_not_warn_when_a_revit_year_was_given(tmp_path, capsys):
|
||||
req_path, _request = write_request(tmp_path)
|
||||
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
||||
'--revit', '2024', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert 'WARNING' not in out
|
||||
|
||||
|
||||
def test_a_real_run_journals_watcher_launched_before_touching_the_cli(
|
||||
tmp_path):
|
||||
"""The proof this process actually started and got as far as the wait/
|
||||
launch step -- a REQUESTED with no WATCHER_LAUNCHED after it now means
|
||||
this tool never got this far at all, not "still running"."""
|
||||
req_path, request = write_request(tmp_path)
|
||||
code = D.main(['--request', req_path, '--runner', 'r.py',
|
||||
'--pyrevit', sys.executable])
|
||||
assert code == 2 # sys.executable is not pyrevit.exe
|
||||
rows = S.read(request['log'])
|
||||
events = [row['event'] for row in rows]
|
||||
assert S.WATCHER_LAUNCHED in events
|
||||
assert events.index(S.WATCHER_LAUNCHED) < len(events) - 1
|
||||
launched = [r for r in rows if r['event'] == S.WATCHER_LAUNCHED][0]
|
||||
assert launched['pyrevit_exe'] == sys.executable
|
||||
assert launched['revit_year'] is None
|
||||
Reference in New Issue
Block a user