399 lines
21 KiB
Python
399 lines
21 KiB
Python
"""Restore ALL SHIP09G2 Source-cyan owners using genuine bounded cyan only.
|
|
|
|
The protected noncyan hull is the literal existing G2 native4 palette projection.
|
|
Source supplies every cyan owner/support (wake, halo, interior paint) and limits,
|
|
never replacement RGB. No gains, synthetic grain, new generation or cascading
|
|
fills. Reproduction is not independent artwork or publication approval.
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import stat
|
|
import sys
|
|
|
|
import numpy as np
|
|
from PIL import Image, __version__ as PILLOW_VERSION
|
|
|
|
METHOD = 'derived-SHIP09G2-complete2-all-Source-cyan-noncyan-hull-v2'
|
|
DECODER_SHA = '66184a4758027dadbadb5e20fe90b2add2b9fc218b7810304b1a1ea2de74f58e'
|
|
G2_HELPER_SHA = '0338a925ccf26f856c9d5cc2230a3878ecafe6cd3a3d25903742d9e819ac8d12'
|
|
G2_LIBRARY_SHA = '5b6bbd03874da298dae953dc1c0f472d24f27031df4f46616368faefb0801676'
|
|
ASSETS = ['2009f', '200a0']
|
|
MAX_BYTES = 32 * 1024 * 1024
|
|
DISTANCE_CAP = 5.5
|
|
MISSING_CAP = .90
|
|
AGGREGATE_CAP = .85
|
|
COLOR_TOLERANCE = 4
|
|
MIN_DONORS = 128
|
|
MIN_DONOR_COLORS = 32
|
|
|
|
|
|
def sha(payload):
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def keys(value, fields, label):
|
|
if type(value) is not dict or set(value) != set(fields):
|
|
raise ValueError(f'{label}: exact fields required')
|
|
|
|
|
|
def hash_string(value):
|
|
return type(value) is str and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
|
|
|
|
|
def regular_bytes(path):
|
|
"""Bounded nonblocking no-follow regular-descriptor read, consumed once."""
|
|
path = Path(path)
|
|
if any(p.is_symlink() for p in [path, *path.parents]):
|
|
raise ValueError('symlink input path')
|
|
before = path.lstat()
|
|
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES:
|
|
raise ValueError('bounded regular input required')
|
|
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
|
|
with os.fdopen(os.open(path, flags), 'rb') as stream:
|
|
opened = os.fstat(stream.fileno())
|
|
if not stat.S_ISREG(opened.st_mode) or opened.st_size > MAX_BYTES:
|
|
raise ValueError('bounded regular input required')
|
|
data = stream.read(MAX_BYTES + 1)
|
|
after = os.fstat(stream.fileno())
|
|
def identity(v):
|
|
return v.st_dev, v.st_ino, v.st_size, v.st_mtime_ns, v.st_mode
|
|
if (len(data) > MAX_BYTES or identity(before) != identity(opened)
|
|
or identity(opened) != identity(after) or identity(path.lstat()) != identity(before)):
|
|
raise ValueError('input changed during bounded read')
|
|
return data
|
|
|
|
|
|
def checked(binding, root):
|
|
keys(binding, {'path', 'sha256'}, 'binding')
|
|
name = binding['path']
|
|
if type(name) is not str or not name or '\\' in name or ':' in name:
|
|
raise ValueError('portable relative path required')
|
|
path = PurePosixPath(name)
|
|
if path.is_absolute() or any(p in ('', '.', '..') for p in name.split('/')):
|
|
raise ValueError('unsafe relative path')
|
|
if not hash_string(binding['sha256']):
|
|
raise ValueError('sha256 required')
|
|
data = regular_bytes(Path(root).joinpath(*path.parts))
|
|
if sha(data) != binding['sha256']:
|
|
raise ValueError('input hash mismatch: ' + name)
|
|
return data
|
|
|
|
|
|
def verified_decoder(project):
|
|
data = checked(dict(path='tools/hd_frame_policy.py', sha256=DECODER_SHA), project)
|
|
scope = dict(__name__='_verified_source_decoder', __file__='<verified-source-decoder-buffer>')
|
|
exec(compile(data, '<verified-source-decoder-buffer>', 'exec'), scope)
|
|
return scope['_indices']
|
|
|
|
|
|
def scale2(source):
|
|
b = np.concatenate([source[:1], source[:-1]], axis=0)
|
|
d = np.concatenate([source[:, :1], source[:, :-1]], axis=1)
|
|
f = np.concatenate([source[:, 1:], source[:, -1:]], axis=1)
|
|
g = np.concatenate([source[1:], source[-1:]], axis=0)
|
|
condition = (b != g) & (d != f)
|
|
out = np.empty((source.shape[0]*2, source.shape[1]*2), np.uint16)
|
|
out[0::2, 0::2] = np.where(condition & (d == b), d, source)
|
|
out[0::2, 1::2] = np.where(condition & (b == f), f, source)
|
|
out[1::2, 0::2] = np.where(condition & (d == g), d, source)
|
|
out[1::2, 1::2] = np.where(condition & (g == f), f, source)
|
|
return out
|
|
|
|
|
|
def water(source):
|
|
return (source >= 144) & (source <= 159)
|
|
|
|
|
|
def hull_guard(source):
|
|
"""Source noncyan row envelope plus exactly one logical-cell halo."""
|
|
solid = (source < 256) & ~water(source)
|
|
core = np.zeros(source.shape, bool)
|
|
for y in range(40):
|
|
xs = np.where(solid[y])[0]
|
|
if len(xs):
|
|
core[y, xs.min():xs.max()+1] = True
|
|
if not core.any():
|
|
raise ValueError('missing Source hull')
|
|
guard = core.copy()
|
|
yy, xx = np.where(core)
|
|
for dy in (-1, 0, 1):
|
|
for dx in (-1, 0, 1):
|
|
guard[np.clip(yy+dy, 0, 39), np.clip(xx+dx, 0, 71)] = True
|
|
return guard
|
|
|
|
|
|
def source_caps(sources, palette):
|
|
colors = np.concatenate([palette[s[water(s)]] for s in sources]).astype(np.int32)
|
|
if not len(colors):
|
|
raise ValueError('missing authentic Source cyan')
|
|
chroma = colors[:, 2]-colors[:, 0]
|
|
if np.any(chroma <= 0) or np.any(colors[:, 1] <= colors[:, 0]) or np.any(colors[:, 1] > colors[:, 2]):
|
|
raise ValueError('Source cyan hue domain required')
|
|
fractions = [(int(c[1]-c[0]), int(c[2]-c[0])) for c in colors]
|
|
minimum = min(fractions, key=lambda q:q[0]/q[1])
|
|
maximum = max(fractions, key=lambda q:q[0]/q[1])
|
|
light = colors @ np.array([54, 183, 19], np.int32)
|
|
return dict(rgb_min=np.maximum(0, colors.min(axis=0)-COLOR_TOLERANCE),
|
|
rgb_max=np.minimum(255, colors.max(axis=0)+COLOR_TOLERANCE),
|
|
hue_min_fraction=list(minimum), hue_max_fraction=list(maximum),
|
|
chroma_min=max(1, int(chroma.min())-COLOR_TOLERANCE),
|
|
luminance_min=int(light.min())-COLOR_TOLERANCE*256,
|
|
luminance_max=int(light.max())+COLOR_TOLERANCE*256)
|
|
|
|
|
|
def cyan_donors(rgb, caps):
|
|
c = rgb.astype(np.int32)
|
|
green = c[:, :, 1]-c[:, :, 0]
|
|
blue = c[:, :, 2]-c[:, :, 0]
|
|
low, high = caps['hue_min_fraction'], caps['hue_max_fraction']
|
|
light = c @ np.array([54, 183, 19], np.int32)
|
|
return (np.all((c >= caps['rgb_min']) & (c <= caps['rgb_max']), axis=2)
|
|
& (blue >= caps['chroma_min']) & (green > 0) & (green <= blue)
|
|
& (green*low[1] >= blue*low[0])
|
|
& (green*high[1] <= blue*high[0])
|
|
& (light >= caps['luminance_min']) & (light <= caps['luminance_max']))
|
|
|
|
|
|
def index_array(value, width, height, label):
|
|
if (type(value) is not dict or type(value.get('width')) is not int or type(value.get('height')) is not int
|
|
or value.get('width') != width or value.get('height') != height
|
|
or type(value.get('indices')) is not list or len(value['indices']) != width*height
|
|
or any(type(v) is not int or not 0 <= v <= 256 for v in value['indices'])):
|
|
raise ValueError(label + ': invalid indexed canvas')
|
|
return np.array(value['indices'], np.uint16).reshape(height, width)
|
|
|
|
|
|
def render_frame(source, palette, raw, crop, baseline_indices, caps):
|
|
"""Pure buffers; same-phase raw donors only, never derived output donors."""
|
|
source4 = scale2(scale2(source))
|
|
guard4 = (source4 < 256) & ~water(source4)
|
|
wake4 = water(source4)
|
|
if not wake4.any():
|
|
raise ValueError('missing Source cyan owners')
|
|
lut = np.zeros((257, 4), np.uint8)
|
|
lut[:256, :3] = palette
|
|
lut[:256, 3] = 255
|
|
baseline = lut[baseline_indices]
|
|
# All noncyan Source structural owners retain exact prior G2 RGBA.
|
|
# Source-cyan owners ALL receive eligible genuine RGB, including interior
|
|
# paint and the old halo. Source-transparent owners are never fabricated.
|
|
result = np.zeros(baseline.shape, np.uint8)
|
|
result[guard4] = baseline[guard4]
|
|
l, t, w, h = crop
|
|
sx, sy = w/72, h/40
|
|
region = raw[t:t+h, l:l+w]
|
|
eligible = ((region[:, :, 3] >= 128)
|
|
& cyan_donors(region[:, :, :3], caps))
|
|
donor_count = int(eligible.sum())
|
|
donor_colors = len(np.unique(region[:, :, :3][eligible], axis=0))
|
|
if donor_count < MIN_DONORS or donor_colors < MIN_DONOR_COLORS:
|
|
raise ValueError('insufficient genuine same-phase cyan texture')
|
|
out_y, out_x = np.where(wake4)
|
|
u = ((out_x+.5)/4)*sx
|
|
v = ((out_y+.5)/4)*sy
|
|
px, py = np.floor(u).astype(int), np.floor(v).astype(int)
|
|
direct = eligible[py, px]
|
|
fraction = float(np.mean(~direct))
|
|
if fraction > MISSING_CAP + 1e-12:
|
|
raise ValueError('fixed missing fraction exceeded')
|
|
maximum = 0.0
|
|
for point in np.where(~direct)[0]:
|
|
x0 = max(0, int(math.floor(u[point]-DISTANCE_CAP*sx-1)))
|
|
x1 = min(w, int(math.ceil(u[point]+DISTANCE_CAP*sx+1)))
|
|
y0 = max(0, int(math.floor(v[point]-DISTANCE_CAP*sy-1)))
|
|
y1 = min(h, int(math.ceil(v[point]+DISTANCE_CAP*sy+1)))
|
|
dy, dx = np.where(eligible[y0:y1, x0:x1])
|
|
if not len(dx):
|
|
raise ValueError('no bounded same-phase cyan donor')
|
|
dx, dy = dx+x0, dy+y0
|
|
distance2 = ((dx+.5-u[point])/sx)**2 + ((dy+.5-v[point])/sy)**2
|
|
nearest = int(np.argmin(distance2)) # row-major y/x tie order is fixed
|
|
distance = math.sqrt(float(distance2[nearest]))
|
|
if distance > DISTANCE_CAP + 1e-12:
|
|
raise ValueError('fixed donor distance exceeded')
|
|
px[point], py[point] = dx[nearest], dy[nearest]
|
|
maximum = max(maximum, distance)
|
|
chosen = region[py, px, :3]
|
|
result[out_y, out_x, :3] = chosen
|
|
result[out_y, out_x, 3] = 255
|
|
if (not np.array_equal(result[guard4], baseline[guard4])
|
|
or not np.array_equal(result[:, :, 3][~guard4], np.where(wake4, 255, 0)[~guard4])):
|
|
raise ValueError('noncyan hull or Source-cyan support changed')
|
|
return result, dict(samples=int(len(out_x)), repaired=int((~direct).sum()),
|
|
missing_fraction=fraction, maximum_donor_logical_distance=maximum,
|
|
raw_donors=donor_count, raw_donor_RGB_colors=donor_colors,
|
|
used_donor_RGB_colors=len(np.unique(chosen, axis=0)),
|
|
noncyan_structural_hull_RGBA_sha256=sha(result[guard4].tobytes()),
|
|
noncyan_structural_hull_exact_to_existing_G2_projection=True,
|
|
all_Source_cyan_alpha_exact_to_SourceScale4=True,
|
|
Original_RGB_samples_painted=0,
|
|
Source_cyan_RGB_outside_original_raw_donor_tuples=0)
|
|
|
|
|
|
def run_recipe(recipe_path, output, project_root=None):
|
|
recipe_path, output = Path(recipe_path).absolute(), Path(output).absolute()
|
|
if output.exists() or any(p.is_symlink() for p in [output, *output.parents]):
|
|
raise ValueError('fresh nonsymlink output required')
|
|
if not output.parent.is_dir():
|
|
raise ValueError('existing output parent required')
|
|
own_sha = sha(regular_bytes(Path(__file__)))
|
|
rb = regular_bytes(recipe_path)
|
|
recipe = json.loads(rb)
|
|
keys(recipe, {'format_version', 'method', 'raw_png', 'palette', 'candidate',
|
|
'runtime_qa', 'indexed_bank', 'actual_invocation', 'frames'}, 'recipe')
|
|
if type(recipe['format_version']) is not int or recipe['format_version'] != 1 or recipe['method'] != METHOD:
|
|
raise ValueError('fixed method/version required')
|
|
frames = recipe['frames']
|
|
if type(frames) is not list or len(frames) != 2 or [f.get('asset') for f in frames if type(f) is dict] != ASSETS:
|
|
raise ValueError('complete ordered SHIP09G2 pair required')
|
|
project = Path(project_root) if project_root else Path(__file__).resolve().parents[1]
|
|
decode = verified_decoder(project)
|
|
raw_payload = checked(recipe['raw_png'], recipe_path.parent)
|
|
with Image.open(io.BytesIO(raw_payload)) as im:
|
|
if im.format != 'PNG' or im.mode != 'RGBA' or im.size != (1536, 1024):
|
|
raise ValueError('exact1536x1024 RGBA raw atlas required')
|
|
raw = np.asarray(im).copy()
|
|
pb = checked(recipe['palette'], recipe_path.parent)
|
|
if len(pb) != 768 or max(pb) > 63:
|
|
raise ValueError('genuine VGA palette required')
|
|
p6 = np.frombuffer(pb, np.uint8).reshape(256, 3).astype(np.uint16)
|
|
palette = ((p6 << 2) | (p6 >> 4)).astype(np.uint8)
|
|
candidate_bytes = checked(recipe['candidate'], recipe_path.parent)
|
|
candidate = json.loads(candidate_bytes)
|
|
seqs = candidate.get('sequences')
|
|
if (type(seqs) is not list or len(seqs) != 1 or seqs[0].get('base_asset') != 0x2009f
|
|
or type(seqs[0]['base_asset']) is not int or seqs[0].get('frame_count') != 2
|
|
or type(seqs[0].get('frame_count')) is not int
|
|
or candidate.get('complete_family') is not True):
|
|
raise ValueError('literal complete candidate required')
|
|
cf = seqs[0].get('frames')
|
|
if type(cf) is not list or len(cf) != 2:
|
|
raise ValueError('candidate complete2 frame order required')
|
|
qa = json.loads(checked(recipe['runtime_qa'], recipe_path.parent))
|
|
rows = {asset: f for asset, f in zip(ASSETS, cf)}
|
|
binding = sha(json.dumps(rows, sort_keys=True, separators=(',', ':')).encode())
|
|
files = {f.get('file') for f in cf}
|
|
if (len(files) != 1 or any(type(f) is not str for f in files)
|
|
or qa.get('candidate_sha256') != sha(candidate_bytes)
|
|
or qa.get('frame_bindings_sha256') != binding
|
|
or type(qa.get('validated_frames')) is not int or qa.get('validated_frames') != 2 or qa.get('diagnostics') != []
|
|
or qa.get('actual_sourceG2_helper_sha256') != G2_HELPER_SHA
|
|
or qa.get('library_sha256') != G2_LIBRARY_SHA
|
|
or qa.get('source_newer_than_library') is not False
|
|
or qa.get('any_game_source_newer_than_library') is not False
|
|
or qa.get('artwork_sha256') != {next(iter(files)): sha(raw_payload)}):
|
|
raise ValueError('literal currentG2 QA/candidate/raw binding mismatch')
|
|
invocation = json.loads(checked(recipe['actual_invocation'], recipe_path.parent))
|
|
if (type(invocation.get('exit_code')) is not int or invocation.get('exit_code') != 0
|
|
or invocation.get('runtime_QA_sha256') != recipe['runtime_qa']['sha256']
|
|
or invocation.get('indexed_bank_sha256') != recipe['indexed_bank']['sha256']):
|
|
raise ValueError('actual invocation does not bind literal QA/indexed bank')
|
|
bank = json.loads(checked(recipe['indexed_bank'], recipe_path.parent))
|
|
if type(bank) is not list or len(bank) != 2 or [r.get('asset') for r in bank if type(r) is dict] != ASSETS:
|
|
raise ValueError('complete ordered actual bank required')
|
|
qr = qa.get('frames')
|
|
if type(qr) is not list or len(qr) != 2 or [r.get('asset') for r in qr if type(r) is dict] != ASSETS:
|
|
raise ValueError('complete ordered QA Source frames required')
|
|
sources, baselines, crops = [], [], []
|
|
for i, (frame, c, row) in enumerate(zip(frames, cf, bank)):
|
|
keys(frame, {'asset', 'source', 'crop'}, 'frame')
|
|
keys(c, {'frame', 'file', 'logical_width', 'logical_height', 'origin', 'source_sha256', 'crop'}, 'literal frame')
|
|
crop = frame['crop']
|
|
name = c['file']
|
|
if (type(name) is not str or not name or '\\' in name or ':' in name
|
|
or PurePosixPath(name).is_absolute() or any(p in ('', '.', '..') for p in name.split('/'))):
|
|
raise ValueError('portable literal artwork path required')
|
|
if (type(crop) is not list or len(crop) != 4 or any(type(v) is not int for v in crop)
|
|
or not 0 <= crop[0] < crop[0]+crop[2] <= 1536
|
|
or not 0 <= crop[1] < crop[1]+crop[3] <= 1024
|
|
or crop[2]*40 != crop[3]*72 or crop[2] < 288
|
|
or c['frame'] != i or type(c['frame']) is not int or c['crop'] != crop
|
|
or type(c['logical_width']) is not int or type(c['logical_height']) is not int
|
|
or type(c['origin']) is not list or any(type(v) is not int for v in c['origin'])
|
|
or c['logical_width'] != 72 or c['logical_height'] != 40 or c['origin'] != [0, 0]
|
|
or c['source_sha256'] != frame['source']['sha256']):
|
|
raise ValueError('exact canvas/crop/source binding required')
|
|
if (qr[i].get('source_sha256') != frame['source']['sha256'] or qr[i].get('logical_canvas') != [72, 40]):
|
|
raise ValueError('QA Source identity differs')
|
|
excluded = raw[:, i*768:(i+1)*768, 3].copy()
|
|
x, y, cw, ch = crop
|
|
excluded[y:y+ch, max(x, i*768)-i*768:min(x+cw, (i+1)*768)-i*768] = 0
|
|
if np.any(excluded >= 128):
|
|
raise ValueError('crop clips meaningful own-phase alpha')
|
|
if i == 1 and x < 768 and np.any(raw[y:y+ch, x:768, 3] >= 128):
|
|
raise ValueError('crop includes foreign phase alpha')
|
|
if not (i*768 <= crop[0] < crop[0]+crop[2] <= (i+1)*768):
|
|
# The authentic selected second crop starts5px before midpoint;
|
|
# neither crop may include the OTHER phase's alpha128 content.
|
|
if i != 1 or not 760 <= crop[0] < 768:
|
|
raise ValueError('crop escapes its own phase cell')
|
|
w, h, idx = decode(checked(frame['source'], recipe_path.parent))
|
|
if (w, h) != (72, 40) or any(v != 256 and not 0 <= v < 240 for v in idx):
|
|
raise ValueError('original72x40 ordinary Source required')
|
|
source = np.array(idx, np.uint16).reshape(40, 72)
|
|
if not np.array_equal(index_array(row.get('source'), 72, 40, 'bank Source'), source):
|
|
raise ValueError('bank Source differs from authenticated Source')
|
|
scales = row.get('scales')
|
|
if type(scales) is not list or len(scales) != 4 or [s.get('scale') for s in scales if type(s) is dict] != [1, 2, 3, 4]:
|
|
raise ValueError('ordered actual native1..4 bank required')
|
|
for k, s in enumerate(scales, 1):
|
|
if type(s.get('scale')) is not int:
|
|
raise ValueError('integer native scale required')
|
|
indexed = index_array(s, 72*k, 40*k, 'bank scale')
|
|
if np.any((indexed >= 240) & (indexed < 256)):
|
|
raise ValueError('ordinary Source bank gained reserved labels')
|
|
sources.append(source); baselines.append(indexed); crops.append(crop)
|
|
a = np.where(water(sources[0]), 256, sources[0])
|
|
b = np.where(water(sources[1]), 256, sources[1])
|
|
if not np.array_equal(a, b):
|
|
raise ValueError('authentic shared hull material differs between phases')
|
|
if not np.array_equal(hull_guard(sources[0]), hull_guard(sources[1])):
|
|
raise ValueError('shared hull envelope differs')
|
|
caps = source_caps(sources, palette)
|
|
payloads, reports = [], []
|
|
for frame, source, baseline, crop in zip(frames, sources, baselines, crops):
|
|
rgba, report = render_frame(source, palette, raw, crop, baseline, caps)
|
|
stream = io.BytesIO();Image.fromarray(rgba).save(stream, format='PNG', compress_level=9)
|
|
payloads.append((frame['asset'], stream.getvalue()))
|
|
reports.append(dict(asset=frame['asset'], RGBA_sha256=sha(rgba.tobytes()), **report))
|
|
if sum(r['repaired'] for r in reports)/sum(r['samples'] for r in reports) > AGGREGATE_CAP + 1e-12:
|
|
raise ValueError('complete2 aggregate repair cap exceeded')
|
|
if sha(regular_bytes(Path(__file__))) != own_sha:
|
|
raise ValueError('executing helper changed')
|
|
receipt = dict(execution=dict(status='success', method=METHOD, recipe_sha256=sha(rb),
|
|
tool_sha256=own_sha, source_sha256={f['asset']: f['source']['sha256'] for f in frames},
|
|
parent_raw_sha256=[sha(raw_payload)], output_sha256={a: sha(p) for a, p in payloads}),
|
|
decoder_sha256=DECODER_SHA, candidate_sha256=sha(candidate_bytes),
|
|
literal_QA_sha256=recipe['runtime_qa']['sha256'], indexed_bank_sha256=recipe['indexed_bank']['sha256'],
|
|
caps={k:v.tolist() if isinstance(v, np.ndarray) else v for k,v in caps.items()}, reports=reports,
|
|
Python=sys.version.split()[0], NumPy=np.__version__, Pillow=PILLOW_VERSION,
|
|
limits=['Every noncyan structural Source4 owner keeps exact existing G2 native4 palette RGBA of genuine raw, not an unquantized raw RGB identity claim.',
|
|
'All Source-cyan support/partition and Source-derived caps get zero generated-HD credit; every restored cyan RGB tuple comes unchanged from this phase raw.',
|
|
'Final alpha is exactly Source4cyan support plus baseline alpha at noncyan Source4 owners; Source4transparent owners are clear. Whole Original-alpha identity is not promised. Fresh G2allscale review required.',
|
|
'No generator invocation, grain or RGB gain; numerical reproduction does not confer independent art approval.'])
|
|
payloads.append(('execution-receipt', (json.dumps(receipt, indent=2, sort_keys=True)+'\n').encode()))
|
|
output.mkdir() # exclusive; every input/frame/cap already validated in memory
|
|
for name, payload in payloads:
|
|
suffix = '.json' if name == 'execution-receipt' else '.png'
|
|
with (output/(name+suffix)).open('xb') as stream:
|
|
stream.write(payload)
|
|
return receipt
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('recipe', type=Path);p.add_argument('output', type=Path)
|
|
p.add_argument('--project-root', type=Path)
|
|
a = p.parse_args()
|
|
print(json.dumps(run_recipe(a.recipe, a.output, a.project_root)['execution'], sort_keys=True))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|