← back to Schumacher Restore
restore.py
92 lines
#!/usr/bin/env python3
"""
Schumacher archived -> active restore tool. PREP / DRY-RUN BY DEFAULT.
Re-applies the staged product image (schumacher_catalog S3 url) and un-archives each
archived Schumacher product that has a staged image + cost. Writes a restore-map FIRST
so every action is one-command reversible (restore_reverse.py).
SAFE BY CONSTRUCTION:
* DRY_RUN is the default. It prints the planned actions and writes the restore-map,
but performs ZERO writes to Shopify.
* Live writes require BOTH: --apply AND env SCHU_RESTORE_CONFIRM=YES
* --canary N limits to the first N (do 20 first, verify, then widen).
* ~0.5s paced; image POST then status->active per product.
Usage:
python3 restore.py # DRY-RUN, whole work-list (no writes)
python3 restore.py --canary 20 # DRY-RUN of the first 20
SCHU_RESTORE_CONFIRM=YES python3 restore.py --apply --canary 20 # LIVE canary (Steve-gated)
Owner: vp-dw-commerce. Do not run --apply without Steve's explicit go + naming decision.
"""
import os, sys, json, time, urllib.request, urllib.error
HERE = os.path.dirname(os.path.abspath(__file__))
WORKLIST = os.path.join(HERE, "data", "restore-worklist.json")
RESTOREMAP = os.path.join(HERE, "data", "restore-map.json")
SHOP = "designer-laboratory-sandbox.myshopify.com"
API = "2024-10"
def token():
for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
if line.startswith("SHOPIFY_ADMIN_TOKEN="):
return line.split("=", 1)[1].strip()
sys.exit("no SHOPIFY_ADMIN_TOKEN")
def api(method, path, tok, body=None):
req = urllib.request.Request(f"https://{SHOP}/admin/api/{API}/{path}",
data=json.dumps(body).encode() if body else None,
method=method,
headers={"X-Shopify-Access-Token": tok,
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=40) as r:
return json.load(r)
def main():
apply = "--apply" in sys.argv
canary = None
if "--canary" in sys.argv:
canary = int(sys.argv[sys.argv.index("--canary") + 1])
confirmed = os.environ.get("SCHU_RESTORE_CONFIRM") == "YES"
live = apply and confirmed
if apply and not confirmed:
print("REFUSING --apply without SCHU_RESTORE_CONFIRM=YES. Running DRY-RUN instead.\n")
work = json.load(open(WORKLIST))
if canary:
work = work[:canary]
# restore-map written up front (reversibility), regardless of dry/live
json.dump([{"num": w["num"], "prev_status": w["prev_status"]} for w in work],
open(RESTOREMAP, "w"), indent=0)
mode = "LIVE-APPLY" if live else "DRY-RUN"
print(f"=== Schumacher restore [{mode}] — {len(work)} products "
f"(restore-map: {RESTOREMAP}) ===\n")
tok = token() if live else None
done = err = 0
for i, w in enumerate(work, 1):
retail = round(float(w["staged_cost"]) / 0.65 / 0.85, 2) # DW formula, for display only
line = (f"[{i}/{len(work)}] product {w['num']} mfr={w['mfr_sku']} "
f"cost=${w['staged_cost']} retail~${retail} img={w['staged_image']}")
if not live:
print("WOULD: re-image + un-archive |", line)
continue
try:
api("POST", f"products/{w['num']}/images.json", tok,
{"image": {"src": w["staged_image"]}})
api("PUT", f"products/{w['num']}.json", tok,
{"product": {"id": int(w["num"]), "status": "active"}})
done += 1; print("OK :", line)
except urllib.error.HTTPError as e:
err += 1; print(f"FAIL : {line} -> HTTP {e.code}")
time.sleep(0.5)
if live:
print(f"\napplied={done} failed={err}. Reverse with restore_reverse.py")
else:
print(f"\nDRY-RUN complete. {len(work)} planned. Nothing written to Shopify.")
if __name__ == "__main__":
main()