← back to Pj Image Repair
TK-10467: fix writer handle-lookup + implement gated apply path; refresh memo to live 3392 active / showroom-only reality
9b3999e8118e708dd370de17bf225000ad651c99 · 2026-09-13 14:58:49 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drt9mecyUp3NCNzc8HsVzt
Files touched
M write_shopify_images.py
Diff
commit 9b3999e8118e708dd370de17bf225000ad651c99
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Sep 13 14:58:49 2026 -0700
TK-10467: fix writer handle-lookup + implement gated apply path; refresh memo to live 3392 active / showroom-only reality
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drt9mecyUp3NCNzc8HsVzt
---
write_shopify_images.py | 106 ++++++++++++++++++++++++++++++++----------------
1 file changed, 72 insertions(+), 34 deletions(-)
diff --git a/write_shopify_images.py b/write_shopify_images.py
index 46a7ba7..53969d6 100644
--- a/write_shopify_images.py
+++ b/write_shopify_images.py
@@ -5,13 +5,14 @@ Adds the resolved real product image as position-1 (featured) for each PJ produc
then (optionally) removes the logo image. HARD-GATED: DRY-RUN by default.
Requires --apply AND env CONFIRM_PJ_WRITE=1 to touch Shopify (customer-facing).
-Reads: data/pj_writable.tsv (dw_sku, mfr_sku, real_image, vendor_title, our_title)
+Reads: data/pj_writable.tsv (dw_sku, handle, mfr_sku, real_image, vendor_title, our_title)
Writes rollback journal: data/pj_write_journal.jsonl (old image ids per product)
Never runs without Steve's explicit approval — this file exists so the change is
-one-approval-from-executable, per the pending-approval memo.
+one-approval-from-executable, per the pending-approval memo. Lookup is by HANDLE
+(the writable TSV carries the real handle; dw_sku != handle).
"""
-import csv, os, sys, json, time, urllib.request, urllib.error
+import csv, os, sys, json, time, urllib.request, urllib.error, urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
MAP = os.path.join(HERE, "data", "pj_writable.tsv")
@@ -22,57 +23,94 @@ APPLY = "--apply" in sys.argv
REMOVE_LOGO = "--remove-logo" in sys.argv
LIMIT = None
for a in sys.argv:
- if a.startswith("--limit="): LIMIT = int(a.split("=")[1])
+ if a.startswith("--limit="):
+ LIMIT = int(a.split("=")[1])
def token():
with open(os.path.expanduser("~/Projects/secrets-manager/.env")) as f:
for line in f:
if line.startswith("SHOPIFY_ADMIN_TOKEN="):
- return line.split("=",1)[1].strip().strip('"').strip("'")
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit("no SHOPIFY_ADMIN_TOKEN")
TOK = token()
+
def api(method, path, body=None):
- url=f"https://{SHOP}/admin/api/{API}/{path}"
- data=json.dumps(body).encode() if body is not None else None
- req=urllib.request.Request(url, data=data, method=method,
- headers={"X-Shopify-Access-Token":TOK,"Content-Type":"application/json"})
+ url = f"https://{SHOP}/admin/api/{API}/{path}"
+ data = json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(url, data=data, method=method,
+ headers={"X-Shopify-Access-Token": TOK, "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=45) as r:
- return json.loads(r.read())
+ raw = r.read()
+ return json.loads(raw) if raw else {}
-def product_id_for_handle(handle):
- d=api("GET", f"products.json?handle={handle}&fields=id,image,images")
- ps=d.get("products",[])
+def product_by_handle(handle):
+ q = urllib.parse.urlencode({"handle": handle, "fields": "id,handle,image,images,status"})
+ d = api("GET", f"products.json?{q}")
+ ps = d.get("products", [])
return ps[0] if ps else None
+def is_logo(img):
+ src = (img.get("src") or "")
+ return "Logo" in src and src.lower().endswith(".png")
+
def main():
- if APPLY and os.environ.get("CONFIRM_PJ_WRITE")!="1":
+ if APPLY and os.environ.get("CONFIRM_PJ_WRITE") != "1":
print("REFUSING: --apply requires env CONFIRM_PJ_WRITE=1 (Steve-gated). Aborting.")
sys.exit(2)
- rows=list(csv.DictReader(open(MAP), delimiter="\t"))
- if LIMIT: rows=rows[:LIMIT]
+ rows = list(csv.DictReader(open(MAP), delimiter="\t"))
+ if LIMIT:
+ rows = rows[:LIMIT]
print(f"mode={'APPLY' if APPLY else 'DRY-RUN'} rows={len(rows)} remove_logo={REMOVE_LOGO}")
- jf=open(JRN,"a") if APPLY else None
- ok=err=0
- for i,r in enumerate(rows,1):
- handle=r["dw_sku"] # NOTE: handle lookup below uses real handle; dw_sku!=handle
- # handle is not dw_sku; resolve via mfr in tsv? we stored dw_sku only -> need handle.
- # The writable map carries dw_sku; Shopify lookup is by handle. Enrich step required.
- print("DRY-RUN row:", r["dw_sku"], r["real_image"][:60]) if not APPLY else None
+ jf = open(JRN, "a") if APPLY else None
+ ok = err = skip = 0
+ for i, r in enumerate(rows, 1):
+ handle = (r.get("handle") or "").strip()
+ real_image = (r.get("real_image") or "").strip()
+ if not handle or not real_image:
+ skip += 1
+ print(f" SKIP row {i} dw_sku={r.get('dw_sku')} (missing handle/image)")
+ continue
if not APPLY:
- ok+=1
- if i<=5: pass
+ print(f" DRY-RUN {handle} <- {real_image[:70]}")
+ ok += 1
continue
- # --- APPLY path (only reached with CONFIRM_PJ_WRITE=1) ---
+ # --- APPLY path (only reached with --apply AND CONFIRM_PJ_WRITE=1) ---
try:
- # (implementation intentionally left to run under approval;
- # productCreateMedia/image add at position 1 + optional logo delete,
- # journaling old image ids to JRN for rollback)
- raise NotImplementedError("apply path enabled only after approval wiring")
+ p = product_by_handle(handle)
+ if not p:
+ err += 1
+ print(f" ERR {handle}: product not found")
+ continue
+ pid = p["id"]
+ old_images = p.get("images", []) or []
+ # rollback record BEFORE any mutation
+ jf.write(json.dumps({
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
+ "handle": handle, "product_id": pid,
+ "old_images": [{"id": im.get("id"), "position": im.get("position"), "src": im.get("src")} for im in old_images],
+ "new_image": real_image,
+ }) + "\n")
+ jf.flush()
+ # add the real image at position 1 (featured)
+ api("POST", f"products/{pid}/images.json",
+ {"image": {"src": real_image, "position": 1}})
+ # optional: remove the logo image(s) after the real one is attached
+ if REMOVE_LOGO:
+ for im in old_images:
+ if is_logo(im) and im.get("id"):
+ api("DELETE", f"products/{pid}/images/{im['id']}.json")
+ ok += 1
+ print(f" OK {handle} (pid {pid})")
+ except urllib.error.HTTPError as e:
+ err += 1
+ print(f" ERR {handle}: HTTP {e.code} {e.read()[:200]}")
except Exception as e:
- err+=1; print("ERR",r["dw_sku"],e)
- time.sleep(0.2)
- print(f"done ok={ok} err={err} (DRY-RUN — no Shopify writes)" if not APPLY else f"done ok={ok} err={err}")
+ err += 1
+ print(f" ERR {handle}: {e}")
+ time.sleep(0.3) # ~3 req/s, under the 4/s REST bucket
+ tail = "(DRY-RUN — no Shopify writes)" if not APPLY else "(journal: data/pj_write_journal.jsonl)"
+ print(f"done ok={ok} err={err} skip={skip} {tail}")
-if __name__=="__main__":
+if __name__ == "__main__":
main()
← 1aea78b TK-10467: validate writable set (0 mis-maps, 0 logos); stren
·
back to Pj Image Repair
·
TK-10467: finalize gated PJ image writer — handle lookup, jo d041ab5 →