← back to Designerwallcoverings
fix(dwpw-grs-migrate): image verify accepts ATTACHED media, not just featuredImage (eventual-consistency, TK-11306)
d46e031614bc554b73a6519c3fc51d5ee18d1074 · 2026-09-08 13:42:48 -0700 · Steve Abrams
Freshly-created GRS products failed step-2 image verify with NEEDS_IMAGE and
were held DRAFT even though their source URL was a validated HTTP 200 and the
media was attached. Root cause: Shopify ingests attached images asynchronously
(PENDING->PROCESSING->READY); product.featuredImage/images read back empty until
READY, so the old check (v_image = chk.has_image = bool(featuredImage)) saw "no
image" for the ~seconds after productCreateMedia and skipped publish.
Fix:
- Read product media in find_grs / find_grs_by_id (media(first:20){...MediaImage});
has_image now = featuredImage OR an image media node is attached.
- New verify_image(pid): bounded read-your-writes poll by product id (~20s) for
status READY; if still PROCESSING/UPLOADED after the window, ACCEPT as present
(source URL was pre-validated 200). Genuine NEEDS_IMAGE only when NO media is
attached at all, or every attached image FAILED to ingest (dead-URL rows).
- APPLY step-2 uses verify_image instead of featuredImage; DRY-RUN plan mirrors
the same media-attached semantics (existing_img_ok) so it predicts publish
truthfully.
Dry-run over the 12 previously-failing rows: all show image present / would-
publish; offline decision-table test confirms FAILED + NONE still correctly
read NEEDS_IMAGE. Also keeps --grs comma-list support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6KGNY395sd4PKXbEzXbgV
Files touched
M scripts/dwpw-grs-migrate.py
Diff
commit d46e031614bc554b73a6519c3fc51d5ee18d1074
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 8 13:42:48 2026 -0700
fix(dwpw-grs-migrate): image verify accepts ATTACHED media, not just featuredImage (eventual-consistency, TK-11306)
Freshly-created GRS products failed step-2 image verify with NEEDS_IMAGE and
were held DRAFT even though their source URL was a validated HTTP 200 and the
media was attached. Root cause: Shopify ingests attached images asynchronously
(PENDING->PROCESSING->READY); product.featuredImage/images read back empty until
READY, so the old check (v_image = chk.has_image = bool(featuredImage)) saw "no
image" for the ~seconds after productCreateMedia and skipped publish.
Fix:
- Read product media in find_grs / find_grs_by_id (media(first:20){...MediaImage});
has_image now = featuredImage OR an image media node is attached.
- New verify_image(pid): bounded read-your-writes poll by product id (~20s) for
status READY; if still PROCESSING/UPLOADED after the window, ACCEPT as present
(source URL was pre-validated 200). Genuine NEEDS_IMAGE only when NO media is
attached at all, or every attached image FAILED to ingest (dead-URL rows).
- APPLY step-2 uses verify_image instead of featuredImage; DRY-RUN plan mirrors
the same media-attached semantics (existing_img_ok) so it predicts publish
truthfully.
Dry-run over the 12 previously-failing rows: all show image present / would-
publish; offline decision-table test confirms FAILED + NONE still correctly
read NEEDS_IMAGE. Also keeps --grs comma-list support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6KGNY395sd4PKXbEzXbgV
---
scripts/dwpw-grs-migrate.py | 120 +++++++++++++++++++++++++++++++++++---------
1 file changed, 97 insertions(+), 23 deletions(-)
diff --git a/scripts/dwpw-grs-migrate.py b/scripts/dwpw-grs-migrate.py
index 505765f..33f3bd9 100644
--- a/scripts/dwpw-grs-migrate.py
+++ b/scripts/dwpw-grs-migrate.py
@@ -160,37 +160,94 @@ def metafields_for(row):
return mfs
# ---------------------------------------------------------------- reads
-def find_grs(grs):
- """Return {id, handle, status, variants:[{id,sku,title,price,position,tracked}], has_image} or None."""
- q = '''query($qy:String!){ products(first:5, query:$qy){ edges{ node{
+# Shared product-node projection. Includes media(...) so image presence reflects
+# "media ATTACHED" (even while Shopify ingests it async) rather than only the
+# featuredImage, which reads back empty until the media status reaches READY.
+_PRODUCT_FIELDS = '''
id handle status featuredImage{ url }
- variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } } } } } }'''
+ media(first:20){ edges{ node{ mediaContentType status ... on MediaImage { id } } } }
+ variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } }'''
+
+def _media_info(n):
+ """Return (has_image_media, [statuses]) for a product node's IMAGE media."""
+ has_media = False
+ statuses = []
+ for e in ((n.get("media") or {}).get("edges") or []):
+ node = e["node"]
+ if node.get("mediaContentType") == "IMAGE":
+ has_media = True
+ if node.get("status"):
+ statuses.append(node["status"])
+ return has_media, statuses
+
+def _parse_node(n):
+ vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
+ "price": x["node"]["price"], "position": x["node"]["position"],
+ "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
+ featured = bool(n.get("featuredImage"))
+ has_media, statuses = _media_info(n)
+ return {"id": n["id"], "handle": n["handle"], "status": n["status"], "variants": vs,
+ "featured": featured, "has_media": has_media, "media_statuses": statuses,
+ # image is "present" when a media node is attached OR the featuredImage
+ # is already populated — NOT featuredImage-only (which lags ingest).
+ "has_image": featured or has_media}
+
+def find_grs(grs):
+ """Return the shared product dict (see _parse_node) or None, via the SKU search index."""
+ q = '''query($qy:String!){ products(first:5, query:$qy){ edges{ node{''' + _PRODUCT_FIELDS + ''' } } } }'''
d = gql(q, {"qy": f"sku:{grs}"})
for e in d.get("data", {}).get("products", {}).get("edges", []):
n = e["node"]
- vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
- "price": x["node"]["price"], "position": x["node"]["position"],
- "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
- if any(v["sku"] == grs or v["sku"] == grs + "-Sample" for v in vs):
- return {"id": n["id"], "handle": n["handle"], "status": n["status"],
- "variants": vs, "has_image": bool(n["featuredImage"])}
+ p = _parse_node(n)
+ if any(v["sku"] == grs or v["sku"] == grs + "-Sample" for v in p["variants"]):
+ return p
return None
def find_grs_by_id(pid):
"""Re-read a product by its GID (read-your-writes consistent, unlike the SKU
search index which lags a fresh productCreate). Same shape as find_grs()."""
- q = '''query($id:ID!){ product(id:$id){
- id handle status featuredImage{ url }
- variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } } } }'''
+ q = '''query($id:ID!){ product(id:$id){''' + _PRODUCT_FIELDS + ''' } }'''
d = gql(q, {"id": pid})
n = (d.get("data") or {}).get("product")
if not n:
return None
- vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
- "price": x["node"]["price"], "position": x["node"]["position"],
- "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
- return {"id": n["id"], "handle": n["handle"], "status": n["status"],
- "variants": vs, "has_image": bool(n["featuredImage"])}
+ return _parse_node(n)
+
+def verify_image(pid, source_url, tries=8, delay=2.5):
+ """Bounded read-your-writes poll of the product's IMAGE media (by product id).
+
+ Shopify ingests attached media asynchronously (PENDING->PROCESSING->READY), so
+ the product's featuredImage/images read back empty for several seconds after
+ productCreateMedia — which previously made a freshly-created product fail the
+ step-2 image check and get held as DRAFT even though its media WAS attached and
+ the source URL was a validated HTTP 200.
+
+ Returns (ok, state, statuses):
+ ok=True -> media is present: READY, or still PROCESSING/UPLOADED after the
+ bounded window (accepted because the source URL was pre-validated
+ 200 and the media node exists — it will become READY).
+ ok=False -> NO media attached at all, or every attached image FAILED to
+ ingest = genuine NEEDS_IMAGE (e.g. the known dead-URL rows).
+ Bounded to ~tries*delay seconds (~20s)."""
+ last = None
+ for i in range(tries):
+ last = find_grs_by_id(pid)
+ final = (i == tries - 1)
+ if last:
+ sts = set(last["media_statuses"])
+ if last["featured"] or "READY" in sts:
+ return True, "READY", last["media_statuses"]
+ if last["has_media"]:
+ if sts and sts <= {"FAILED"}:
+ return False, "FAILED", last["media_statuses"] # ingest failed -> no image
+ if final:
+ # attached but still processing -> ACCEPT (URL was pre-validated 200)
+ return True, "PROCESSING", last["media_statuses"]
+ elif final:
+ return False, "NONE", last["media_statuses"] # nothing attached
+ if not final:
+ time.sleep(delay)
+ return False, "NONE", (last["media_statuses"] if last else [])
def verify_read(pid, grs, tries=6, delay=2):
"""Authoritative post-write re-read. Prefer by-ID (immediately consistent);
@@ -420,6 +477,16 @@ def process(row, apply):
img_status = http_status(row["image"])
image_ok = (img_status == 200)
plan["image_status"] = img_status
+ # For an already-existing product, an image that is ATTACHED but still PROCESSING
+ # counts as present (same rule the APPLY-path verify_image uses); only "no media
+ # attached / all FAILED" is a real no-image.
+ existing_img_ok = False
+ if existing:
+ _sts = set(existing.get("media_statuses") or [])
+ existing_img_ok = existing["featured"] or (existing["has_media"] and _sts != {"FAILED"})
+ plan["existing_image"] = {"featured": existing["featured"],
+ "has_media": existing["has_media"],
+ "statuses": existing["media_statuses"]}
try:
price = float(row["dw_price"]); cost = float(row["cost_yd"])
price_ok = abs(price - 3 * cost) <= 0.02
@@ -446,7 +513,7 @@ def process(row, apply):
# publish gate: image + width metafield required to go ACTIVE.
# width metafield is ALWAYS set by create/update. image gate = image_ok OR
# (update path where product already had an image).
- will_have_image = image_ok or (existing and existing["has_image"])
+ will_have_image = image_ok or existing_img_ok
can_publish = (skip is None) and bool(will_have_image)
plan["would_publish"] = can_publish
if skip:
@@ -479,14 +546,20 @@ def process(row, apply):
# productCreate and previously returned None here -> false VERIFY_DRAFT_FAILED).
chk = verify_read(pid, grs)
v_variants = bool(chk and len(chk["variants"]) >= 2)
- v_image = bool(chk and chk["has_image"])
v_price_ok = price_ok
- v_img_http = (http_status(row["image"]) == 200)
if not (v_variants and v_price_ok):
res["result"] = "VERIFY_DRAFT_FAILED"; res["detail"] = {"variants": v_variants, "price_ok": v_price_ok}
return res # leave DWPW untouched
+ # Image gate: treat the image as present when MEDIA IS ATTACHED to the product
+ # (a media node exists) AND the source URL was pre-validated 200 — even if the
+ # media is still PROCESSING (Shopify ingests async; featuredImage lags READY).
+ # Poll the product's media by id for a bounded window; a still-processing image
+ # is accepted. Genuinely NEEDS_IMAGE only when NO media is attached / all FAILED.
+ v_image, img_state, img_statuses = verify_image(pid, row["image"])
+ res["image_state"] = img_state; res["image_statuses"] = img_statuses
if not v_image:
- # no image -> cannot go ACTIVE; leave DRAFT + Needs-Image, do NOT archive DWPW
+ # no image attached (or ingest FAILED) -> cannot go ACTIVE; leave DRAFT +
+ # Needs-Image, do NOT archive DWPW.
res["result"] = "LEFT_DRAFT_NEEDS_IMAGE"; return res
# STEP 3 publish
publish_active(pid, grs, prev_status)
@@ -521,7 +594,8 @@ def main():
rows = json.load(open(args.batch))
if args.grs:
- rows = [r for r in rows if r["grs"] == args.grs]
+ _grs_set = set(s.strip() for s in args.grs.split(",") if s.strip())
+ rows = [r for r in rows if r["grs"] in _grs_set]
if args.limit:
rows = rows[:args.limit]
← 9e97f6a auto-data-snapshot: 2026-09-08T13:39:29 (1 data files) — scr
·
back to Designerwallcoverings
·
fix(dwpw-grs-migrate): --grs comma-list, drop blank metafiel 35ba74f →