426 lines
22 KiB
Python
426 lines
22 KiB
Python
"""Bounded exact-source CUDA plumbing gate; never a flow-accuracy certificate.
|
|
|
|
Run this driver externally against exact dev with --mode baseline first, then
|
|
against the experimental source with --mode candidate --baseline <receipt>.
|
|
Only scalar receipts/hashes and actual compiler sidecars are retained. Whole-step
|
|
mass/momentum are diagnostics: no verified FP32 conservation bound is claimed.
|
|
"""
|
|
import argparse
|
|
from contextlib import contextmanager
|
|
from dataclasses import replace
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
BASE = 'ed9d8c937d446b854d6963bd92d718bb4fb98b96'
|
|
PRODUCTION = 'a135252ae78b47b57d7e77d19001700b3bedac8b'
|
|
STOPS = (0, 1, 2, 8)
|
|
OPERATOR_ATOL, OPERATOR_RTOL = 6e-8, 2e-7
|
|
REST_ATOL, REST_RTOL = 3e-7, 1e-6
|
|
REPLAY_ATOL = 3e-6
|
|
E = np.array([(0,0),(1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,-1),(1,-1),(-1,1)])
|
|
W = np.array([4/9]+[1/9]*4+[1/36]*4)
|
|
|
|
|
|
def digest(array):
|
|
"""All finite bits (including signed zero) and exact NaN mask; no NaN payloads."""
|
|
array = np.asarray(array)
|
|
if np.isinf(array).any():
|
|
raise AssertionError('infinite population')
|
|
finite = np.isfinite(array)
|
|
h = hashlib.sha256()
|
|
h.update(str((array.shape, array.dtype.str)).encode())
|
|
h.update(np.ascontiguousarray(finite).tobytes())
|
|
h.update(np.ascontiguousarray(array[finite]).tobytes())
|
|
return h.hexdigest()
|
|
|
|
|
|
def independent_explosion(coarse, pair, layout, kind):
|
|
"""Coordinate oracle; never reads the production neighbor/offset tables."""
|
|
ny, nx = pair.coarse_shape
|
|
legal = np.zeros((ny,nx), bool)
|
|
legal.ravel()[pair.ghost_coarse] = True
|
|
legal &= layout.owned & ~pair.covered & (kind == 0)
|
|
if not legal.ravel()[pair.ghost_coarse].all():
|
|
raise AssertionError('illegal center donor')
|
|
out = np.empty((9,len(pair.ghost_fine)))
|
|
flat = np.asarray(coarse, np.float64).reshape(9,-1)
|
|
for row, (target, donor) in enumerate(zip(pair.ghost_fine,pair.ghost_coarse)):
|
|
y,x = divmod(int(donor),nx)
|
|
gradient = np.zeros((9,2))
|
|
for axis, (dx,dy) in enumerate(((1,0),(0,1))):
|
|
ends = [(x-dx,y-dy),(x+dx,y+dy)]
|
|
if not layout.periodic[axis] and any(not (0<=xx<nx and 0<=yy<ny) for xx,yy in ends):
|
|
continue
|
|
ends = [(xx%nx,yy%ny) for xx,yy in ends]
|
|
if all(legal[yy,xx] for xx,yy in ends):
|
|
(xm,ym),(xp,yp) = ends
|
|
gradient[:,axis] = (flat[:,yp*nx+xp]-flat[:,ym*nx+xm])/2
|
|
for q in range(1,9):
|
|
gradient[q] -= E[q]*np.dot(E[q],gradient[q])/np.dot(E[q],E[q])
|
|
fy,fx = divmod(int(target),pair.fine_shape[1])
|
|
offset = np.array([pair.fine_origin[0]+(fx+.5)/2-(x+.5),
|
|
pair.fine_origin[1]+(fy+.5)/2-(y+.5)])
|
|
out[:,row] = flat[:,int(donor)] + gradient@offset
|
|
return out
|
|
|
|
|
|
def default_options(selector):
|
|
# Exact dev has no explosion keyword. Baseline and omitted paths pass none.
|
|
return {} if selector == 'omitted' else {'explosion':selector}
|
|
|
|
|
|
def snapshot(run, raw):
|
|
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST
|
|
stored, public = raw(run), run.download()
|
|
hashes, totals = [], np.zeros(3)
|
|
for level,(values,visible,kind) in enumerate(zip(stored,public,run.hierarchy.kinds)):
|
|
active = kind == ACTIVE
|
|
if not np.isfinite(visible[:,active]).all() or not np.isnan(visible[:,~active]).all():
|
|
raise AssertionError('public ACTIVE/invalid mask mismatch')
|
|
if not np.isnan(values[:,kind==OUTER_GHOST]).all():
|
|
raise AssertionError('OUTER owner core is not poisoned')
|
|
population = visible[:,active].sum(axis=1,dtype=np.float64)*(.25**level)
|
|
totals += np.r_[population.sum(), E.T@population]
|
|
hashes.append(dict(raw=digest(values), public=digest(visible), active=int(active.sum())))
|
|
if not np.isfinite(totals).all():
|
|
raise AssertionError('nonfinite mass/momentum diagnostic')
|
|
return dict(step=run.step_count, levels=hashes, mass_momentum=totals.tolist())
|
|
|
|
|
|
def initialize(run, rest=False):
|
|
arrays, origin, spacing = [], np.zeros(2), 1.
|
|
for index,level in enumerate(run._levels):
|
|
if index:
|
|
origin += spacing*np.asarray(run.hierarchy.pairs[index-1].fine_origin)
|
|
spacing /= 2
|
|
y,x = np.indices(level.shape[1:],dtype=np.float64)
|
|
x,y = origin[0]+spacing*(x+.5),origin[1]+spacing*(y+.5)
|
|
q = np.arange(9)[:,None,None]
|
|
values = np.broadcast_to(W[:,None,None],level.shape).copy()
|
|
if not rest:
|
|
# q-distinct smooth non-equilibrium G; same physical frame on all levels.
|
|
values += 1e-4*np.sin(.17*x+.23*y+q)
|
|
arrays.append(values)
|
|
run.initialize(*arrays)
|
|
|
|
|
|
def operator_case(run, index):
|
|
import pycuda.driver as cuda
|
|
parent,child = run._levels[index:index+2]
|
|
geometry,pair = run.hierarchy.pairs[index],run._pairs[index]
|
|
c = np.random.default_rng(19+index).normal(0,.001,(9,*geometry.coarse_shape))
|
|
if not run._ddf_shifting:
|
|
c += W[:,None,None]
|
|
c = c.astype(np.float32)
|
|
legal = np.zeros(geometry.coarse_shape,bool)
|
|
legal.ravel()[geometry.ghost_coarse] = True
|
|
c[:,~legal] = np.nan
|
|
packed = parent.layout.to_blocked(c)
|
|
output = np.full(child.layout.blocked_shape,np.nan,np.float32)
|
|
cuda.memcpy_htod(parent.current,packed)
|
|
cuda.memcpy_htod(child.current,output)
|
|
calls, hop = [],run._hop
|
|
try:
|
|
run._hop = lambda *a,**kw: calls.append(a)
|
|
run._advance(index+1,parent.current,block=run._block,stream=run._stream)
|
|
run._stream.synchronize()
|
|
finally:
|
|
run._hop = hop
|
|
if calls != [(index+1,child.current,child.spare),(index+1,child.spare,child.current)]:
|
|
raise AssertionError('explosion changed two-hop launch contract')
|
|
cuda.memcpy_dtoh(output,child.current)
|
|
target = child.layout.owner.ravel()[geometry.ghost_fine]
|
|
slots = np.array([child.layout.slot(target,q) for q in range(9)])
|
|
actual = output.ravel()[slots]
|
|
expected = independent_explosion(c,geometry,parent.layout,run.hierarchy.kinds[index])
|
|
constant = c.reshape(9,-1)[:,geometry.ghost_coarse]
|
|
if not np.isfinite(actual).all() or np.max(abs(expected-constant)) <= 1e-5:
|
|
raise AssertionError('operator must exercise finite nonzero projected slopes')
|
|
np.testing.assert_allclose(actual,expected,atol=OPERATOR_ATOL,rtol=OPERATOR_RTOL)
|
|
untouched = np.ones(output.size,bool); untouched[slots.ravel()] = False
|
|
if not np.isnan(output.ravel()[untouched]).all():
|
|
raise AssertionError('explosion wrote outside its targets')
|
|
return dict(pair=index,rows=len(target),parent_stride=pair.parent_cells,
|
|
child_stride=int(np.prod(child.layout.local_shape)),
|
|
max_error_by_q=np.max(abs(actual-expected),axis=1).tolist(),
|
|
nonzero_projection=True,poisoned_nondonors=True,untouched_preserved=True)
|
|
|
|
|
|
def replay_two_hops(values, pair, layout, kind):
|
|
"""Existing FP64 two-hop algebra, using the independent coordinate oracle."""
|
|
from tests.unit.test_projected_explosion import collide
|
|
coarse,fine = (a.copy() for a in values)
|
|
fine.reshape(9,-1)[:,pair.ghost_fine] = independent_explosion(coarse,pair,layout,kind)
|
|
for _ in range(2):
|
|
fine = np.array([np.roll(fine[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
|
fine[:,pair.fine_kind==2] = np.nan
|
|
active = pair.fine_kind==0
|
|
fine[:,active] = collide(fine[:,active],pair.tau_fine)
|
|
out = np.array([np.roll(coarse[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
|
out.reshape(-1)[pair.coalesce_coarse] = fine.reshape(-1)[pair.coalesce_fine].mean(axis=1)
|
|
out[:,~pair.covered] = collide(out[:,~pair.covered],pair.tau_coarse)
|
|
out[:,pair.covered] = np.nan
|
|
return out,fine
|
|
|
|
|
|
def run_case(ctx, case, shifted, selector, raw, projected=False, replay=False):
|
|
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
|
cfg,refs,blocks = case
|
|
cfg = replace(cfg,velocity=0,ddf_shifting=shifted)
|
|
with FixedPatchStepper(cfg,context=ctx,refinements=refs,level_blocks=blocks,
|
|
**default_options(selector)) as run:
|
|
operator = [operator_case(run,i) for i in range(len(run._pairs))] if projected else []
|
|
counts = [0]*len(run._levels)
|
|
for index,level in enumerate(run._levels):
|
|
def counted(*a, index=index, kernel=level.kernel, **kw):
|
|
counts[index] += 1
|
|
return kernel(*a,**kw)
|
|
level.kernel = counted
|
|
def check_counts(stop):
|
|
if counts != [stop*2**index for index in range(len(counts))]:
|
|
raise AssertionError('actual level hop counts differ from acoustic schedule')
|
|
rest = []
|
|
if projected:
|
|
initialize(run,rest=True)
|
|
for stop in STOPS:
|
|
run.step(stop-run.step_count)
|
|
check_counts(stop)
|
|
snapshot(run,raw) # actual public and raw invalid masks at every stop
|
|
maximum = 0.
|
|
for values,kind in zip(run.download(),run.hierarchy.kinds):
|
|
active = values[:,kind==0]
|
|
np.testing.assert_allclose(active,np.broadcast_to(W[:,None],active.shape),
|
|
atol=REST_ATOL,rtol=REST_RTOL)
|
|
maximum = max(maximum,float(np.max(abs(active-W[:,None]))))
|
|
rest.append(dict(step=stop,max_absolute_error=maximum))
|
|
counts[:] = [0]*len(counts)
|
|
initialize(run)
|
|
reference = tuple(a.astype(np.float64) for a in run.download()) if replay else None
|
|
snapshots,replay_errors,previous = [],[],0
|
|
for stop in STOPS:
|
|
run.step(stop-run.step_count)
|
|
if replay:
|
|
for _ in range(stop-previous):
|
|
reference = replay_two_hops(reference,run.hierarchy.pairs[0],
|
|
run._levels[0].layout,run.hierarchy.kinds[0])
|
|
errors = []
|
|
for actual,expected,kind in zip(run.download(),reference,run.hierarchy.kinds):
|
|
np.testing.assert_allclose(actual[:,kind==0],expected[:,kind==0],
|
|
atol=REPLAY_ATOL,rtol=0)
|
|
errors.append(np.max(abs(actual[:,kind==0]-expected[:,kind==0]),axis=1).tolist())
|
|
replay_errors.append(dict(step=stop,max_error_by_level_q=errors))
|
|
check_counts(stop)
|
|
snapshots.append(dict(snapshot(run,raw),observed_level_hops=counts.copy())); previous = stop
|
|
change = (np.array(snapshots[-1]['mass_momentum'])-
|
|
np.array(snapshots[0]['mass_momentum'])).tolist()
|
|
return dict(operator=operator,snapshots=snapshots,rest_fixed_point=rest,
|
|
replay_max_errors=replay_errors,mass_momentum_change_diagnostic=change,
|
|
conservation_status='diagnostic_only_no_FP32_bound')
|
|
|
|
|
|
def compare_snapshots(actual,expected):
|
|
if len(actual) != len(expected):
|
|
raise AssertionError('snapshot count differs')
|
|
for got,want in zip(actual,expected):
|
|
if (got['step'] != want['step'] or got['levels'] != want['levels']
|
|
or got.get('observed_level_hops') != want.get('observed_level_hops')):
|
|
raise AssertionError('owner bits, public values or masks differ')
|
|
|
|
|
|
def cases():
|
|
from CelerisLab.config import LBMConfig
|
|
from tests.unit.test_hierarchy import CFG, FOUR
|
|
scalar = (LBMConfig(nx=16,ny=16,viscosity=.02),
|
|
[(4,4,12,12),(8,8,16,16),(8,8,16,16)],
|
|
[(4,4),(4,8),(6,4),(4,6)])
|
|
four = (CFG,FOUR,[(6,8),(8,6),(12,8),(16,12)])
|
|
concave = (LBMConfig(nx=30,ny=30,viscosity=.1),
|
|
[[(4,4,5,7),(4,5,7,7)]],[(6,5),(4,6)])
|
|
return dict(scalar=scalar,four=four,concave=concave)
|
|
|
|
|
|
def baseline_receipt(receipt, driver_hash, keys):
|
|
if (receipt.get('completed') is not True or receipt.get('dry_run') is not False
|
|
or receipt.get('mode') != 'baseline' or receipt.get('production_revision') != BASE
|
|
or receipt.get('source',{}).get('revision') != BASE
|
|
or receipt.get('source',{}).get('dirty') is not False
|
|
or receipt.get('driver_sha256') != driver_hash
|
|
or receipt.get('stops') != list(STOPS)
|
|
or receipt.get('operator_tolerance') != [OPERATOR_ATOL,OPERATOR_RTOL]
|
|
or receipt.get('rest_tolerance') != [REST_ATOL,REST_RTOL]
|
|
or receipt.get('replay_tolerance') != [REPLAY_ATOL,0]
|
|
or set(receipt.get('default_snapshots',{})) != set(keys)
|
|
or not receipt.get('actual_build_sidecars')):
|
|
raise ValueError('baseline receipt does not match the frozen exact-dev gate')
|
|
return receipt
|
|
|
|
|
|
def remap_case(ctx, shifted, raw):
|
|
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
|
cfg,refs,blocks = cases()['four']
|
|
with FixedPatchStepper(replace(cfg,velocity=0,ddf_shifting=shifted),context=ctx,
|
|
refinements=refs[:2],level_blocks=blocks[:3],explosion='projected_linear') as run:
|
|
initialize(run); run.step(1)
|
|
before = snapshot(run,raw)
|
|
with run.remap_finest([(22,22,42,42),(72,68,76,76)]) as clone:
|
|
if clone._explosion != 'projected_linear' or any(p.neighbors is None for p in clone._pairs):
|
|
raise AssertionError('remap lost projected selection')
|
|
if clone.step_count != 1: raise AssertionError('remap lost synchronized count')
|
|
initial = snapshot(clone,raw)
|
|
clone.step(1)
|
|
after = snapshot(clone,raw)
|
|
if clone.step_count != 2: raise AssertionError('remap did not continue count')
|
|
compare_snapshots([snapshot(run,raw)],[before])
|
|
return dict(shifted=shifted,source_unchanged=True,initial=initial,continued=after)
|
|
|
|
|
|
@contextmanager
|
|
def capture_builds(report, save, baseline=None):
|
|
from CelerisLab.cuda import compiler_v2 as compiler
|
|
original = compiler.load_module
|
|
def load(path):
|
|
path = Path(path)
|
|
metadata = json.loads(Path(str(path)+'.json').read_text())
|
|
if metadata['ptx_sha256'] != hashlib.sha256(path.read_bytes()).hexdigest():
|
|
raise AssertionError('actual PTX and build sidecar disagree')
|
|
if baseline and (metadata['arch'],metadata['flags']) not in [
|
|
(m['arch'],m['flags']) for m in baseline['actual_build_sidecars']]:
|
|
raise AssertionError('baseline build architecture or flags differ')
|
|
report['actual_build_sidecars'].append(metadata)
|
|
save() # capture AMR metadata before production cleanup removes scratch
|
|
return original(str(path))
|
|
compiler.load_module = load
|
|
try:
|
|
yield
|
|
finally:
|
|
compiler.load_module = original
|
|
|
|
|
|
def preflight(repo, revision, mode):
|
|
git = lambda *a: subprocess.check_output(['git','-C',str(repo),*a],text=True).strip()
|
|
if git('rev-parse','HEAD') != revision or git('status','--porcelain'):
|
|
raise ValueError('requires the exact expected clean checkout')
|
|
source = BASE if mode == 'baseline' else PRODUCTION
|
|
if mode == 'baseline' and revision != BASE:
|
|
raise ValueError('baseline must be exact ed9d8c9')
|
|
if subprocess.call(['git','-C',str(repo),'diff','--quiet',source,'--','src']):
|
|
raise ValueError('production source differs from the frozen source revision')
|
|
return source
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--repo',type=Path,required=True)
|
|
parser.add_argument('--expected-revision',required=True)
|
|
parser.add_argument('--mode',choices=('baseline','candidate'),required=True)
|
|
parser.add_argument('--baseline',type=Path)
|
|
parser.add_argument('--output',type=Path,required=True)
|
|
parser.add_argument('--run-gpu',action='store_true')
|
|
parser.add_argument('--dry-run',action='store_true')
|
|
args = parser.parse_args()
|
|
if args.output.exists(): parser.error('output already exists')
|
|
selected = os.environ.get('CUDA_VISIBLE_DEVICES','')
|
|
if not args.dry_run and (not args.run_gpu or not selected or ',' in selected or selected=='-1'):
|
|
parser.error('--run-gpu and exactly one explicit CUDA_VISIBLE_DEVICES are required')
|
|
repo = args.repo.resolve()
|
|
source = preflight(repo,args.expected_revision,args.mode)
|
|
baseline = None
|
|
if args.mode == 'candidate':
|
|
if args.baseline is None: parser.error('candidate requires --baseline receipt')
|
|
baseline = json.loads(args.baseline.read_text())
|
|
sys.path[:0] = [str(repo/'src'),str(repo),str(repo/'tests/validation/multigrid'),
|
|
str(repo/'tests/validation/taylor_green')]
|
|
import CelerisLab
|
|
if Path(CelerisLab.__file__).resolve() != repo/'src/CelerisLab/__init__.py':
|
|
raise RuntimeError('wrong imported checkout')
|
|
from run_case import provenance
|
|
driver_hash = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
|
keys = [f'{name}:shifted={shifted}' for name in cases() for shifted in (False,True)]
|
|
if baseline is not None: baseline_receipt(baseline,driver_hash,keys)
|
|
report = dict(mode=args.mode,production_revision=source,source=provenance(repo),
|
|
driver_sha256=driver_hash,
|
|
device_selection=selected,completed=False,dry_run=args.dry_run,
|
|
operator_tolerance=[OPERATOR_ATOL,OPERATOR_RTOL],
|
|
rest_tolerance=[REST_ATOL,REST_RTOL],replay_tolerance=[REPLAY_ATOL,0],stops=STOPS,
|
|
conservation_status='diagnostic_only_no_FP32_bound',
|
|
actual_build_sidecars=[],default_snapshots={},default_equivalence=[],
|
|
projected_operator=[],union_multilevel_smoke=[],changed_remap=[])
|
|
args.output.parent.mkdir(parents=True,exist_ok=True)
|
|
def save():
|
|
text = json.dumps(report,indent=2,allow_nan=False)+'\n'
|
|
if len(text.encode()) > 8*1024*1024: raise RuntimeError('receipt exceeds 8 MiB cap')
|
|
temporary = args.output.with_suffix(args.output.suffix+'.tmp')
|
|
temporary.write_text(text); os.replace(temporary,args.output)
|
|
save()
|
|
if args.dry_run:
|
|
print('Source preflight only; no CUDA imports or execution.'); return
|
|
try:
|
|
from CelerisLab.cuda.context import CudaContext
|
|
from test_refinement_blocked_base import raw
|
|
with CudaContext(0) as ctx, capture_builds(report,save,baseline):
|
|
import pycuda.driver as cuda
|
|
report['device'] = dict(name=ctx.device.name(),pci_bus_id=ctx.device.pci_bus_id(),
|
|
capability=list(ctx.device.compute_capability()),
|
|
driver=cuda.get_driver_version(),runtime=list(cuda.get_version()))
|
|
nvcc = shutil.which('nvcc') or '/usr/local/cuda/bin/nvcc'
|
|
report['nvcc_version'] = subprocess.check_output([nvcc,'--version'],text=True).strip()
|
|
if baseline and (report['device'] != baseline.get('device')
|
|
or report['nvcc_version'] != baseline.get('nvcc_version')
|
|
or selected != baseline.get('device_selection')):
|
|
raise ValueError('baseline GPU/device selection or toolchain differs')
|
|
for name,case in cases().items():
|
|
for shifted in (False,True):
|
|
key = f'{name}:shifted={shifted}'
|
|
report['active_case'] = f'default:{key}'
|
|
result = run_case(ctx,case,shifted,'omitted',raw)
|
|
report['default_snapshots'][key] = result['snapshots']
|
|
if baseline:
|
|
compare_snapshots(result['snapshots'],baseline['default_snapshots'][key])
|
|
explicit = run_case(ctx,case,shifted,'constant',raw)
|
|
compare_snapshots(explicit['snapshots'],result['snapshots'])
|
|
report['default_equivalence'].append(dict(case=key,passed=True))
|
|
save()
|
|
if baseline:
|
|
for name,case in cases().items():
|
|
for shifted in (False,True):
|
|
report['active_case'] = f'projected:{name}:shifted={shifted}'
|
|
result = run_case(ctx,case,shifted,'projected_linear',raw,True,name=='concave')
|
|
cfg,refs,blocks = case
|
|
if name == 'four':
|
|
variant_refs = [list(reversed(rects)) for rects in refs]
|
|
elif name == 'concave':
|
|
variant_refs = [[(4,5,7,7),(4,4,5,5)]] # same union, disjoint decomposition
|
|
else:
|
|
variant_refs = refs
|
|
variant = (cfg,variant_refs,[(2,2)]*len(blocks))
|
|
alternate = run_case(ctx,variant,shifted,'projected_linear',raw,True)
|
|
compare_snapshots(alternate['snapshots'],result['snapshots'])
|
|
report['projected_operator'].append(dict(case=name,shifted=shifted,rows=result['operator']))
|
|
report['union_multilevel_smoke'].append(dict(case=name,shifted=shifted,result=result,
|
|
alternate_bits_identical=True))
|
|
save()
|
|
for shifted in (False,True):
|
|
report['active_case'] = f'changed-remap:shifted={shifted}'
|
|
report['changed_remap'].append(remap_case(ctx,shifted,raw)); save()
|
|
expected_builds = {(s['arch'],tuple(s['flags'])) for s in baseline['actual_build_sidecars']}
|
|
actual_builds = {(s['arch'],tuple(s['flags'])) for s in report['actual_build_sidecars']}
|
|
if actual_builds != expected_builds:
|
|
raise AssertionError('baseline build architecture or compiler flags differ')
|
|
report['completed'] = True
|
|
save()
|
|
except Exception as error:
|
|
report['error'] = f'{type(error).__name__}: {error}'
|
|
save(); raise
|
|
print(json.dumps(dict(completed=True,mode=args.mode,receipt=str(args.output))))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|