[object Object]

← 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

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 →