[object Object]

← back to Designerwallcoverings

TK-11323: importer stamps 8/8 whole-bolt rule + canonical The Naturals Collection tag; token probe falls back to a live token

5776161e0b3169097c70757072b4a1c244a6f063 · 2026-09-23 12:21:11 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LAC9zUVqzfo6MsW5y7tAN

Files touched

Diff

commit 5776161e0b3169097c70757072b4a1c244a6f063
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 12:21:11 2026 -0700

    TK-11323: importer stamps 8/8 whole-bolt rule + canonical The Naturals Collection tag; token probe falls back to a live token
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011LAC9zUVqzfo6MsW5y7tAN
---
 scripts/dwpw-grs-migrate.py | 101 ++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 93 insertions(+), 8 deletions(-)

diff --git a/scripts/dwpw-grs-migrate.py b/scripts/dwpw-grs-migrate.py
index 281823b..a8eb714 100644
--- a/scripts/dwpw-grs-migrate.py
+++ b/scripts/dwpw-grs-migrate.py
@@ -77,7 +77,15 @@ _WEIGHT_SELLABLE_LB = resolve_weight_lb({"sku": "sellable", "title": "Per Yard"}
                                         {"product_type": PRODUCT_TYPE})
 _WEIGHT_SAMPLE_LB = resolve_weight_lb({"sku": "x-Sample", "title": "Sample"},
                                       {"product_type": PRODUCT_TYPE})
-COLLECTION = "TWIL Naturals"
+# TK-11323 (2026-09-23): canonical line tag is "The Naturals Collection" — the Sep-9
+# rename TARGET. The legacy "TWIL Naturals" hardcoded here was the source of the
+# 104-product tag drift (every daily run re-stamped the retired tag).
+COLLECTION = "The Naturals Collection"
+# TK-11323 whole-bolt rule (Steve directive): a PRICED Per-Yard grasscloth variant is
+# sold in 8-yard bolts — min 8 / step 8 — stamped as variant metafields at create AND
+# update so a new import can never ship 1-yard-buyable again (the recurring forward-leak).
+QTY_RULE_MIN = "8"
+QTY_RULE_UNITS = "8"
 WIDTH_METAFIELD = '36" Wide (trim to 34")'
 DESC_TPL = ("{name} is a natural woven grasscloth wallcovering by Fentucci, with a "
             "handcrafted natural-fiber texture that brings organic warmth and understated "
@@ -85,14 +93,60 @@ DESC_TPL = ("{name} is a natural woven grasscloth wallcovering by Fentucci, with
             "spaces, from feature walls to refined hospitality settings.")
 
 # ---------------------------------------------------------------- token / http
-def load_token():
-    tk = os.environ.get("SHOPIFY_FULL_ACCESS_TOKEN")
-    if tk:
-        return tk.strip()
+def _read_key(name):
+    v = os.environ.get(name)
+    if v:
+        return v.strip()
     for line in open(SECRETS):
-        if line.startswith("SHOPIFY_FULL_ACCESS_TOKEN="):
-            return line.split("=", 1)[1].strip()
-    raise RuntimeError("SHOPIFY_FULL_ACCESS_TOKEN not found (env or secrets .env)")
+        if line.startswith(name + "="):
+            return line.split("=", 1)[1].strip().strip('"').strip("'")
+    return None
+
+REQUIRED_SCOPES = {"write_products", "write_inventory", "write_publications"}
+
+def _token_authenticates(tk):
+    """One cheap read: proves AUTH and SCOPE (auth alone would pass a token that then
+    403s on every write — the original bug in new clothes). Returns (ok, why).
+    401/403 => dead/blocked token. Network/5xx => raise (unmeasured, never a false pass)."""
+    body = json.dumps({"query": "{ shop { name } currentAppInstallation { accessScopes { handle } } }"}).encode()
+    req = urllib.request.Request(
+        f"https://{DOMAIN}/admin/api/{API}/graphql.json", data=body, method="POST",
+        headers={"X-Shopify-Access-Token": tk, "Content-Type": "application/json"})
+    try:
+        with urllib.request.urlopen(req, timeout=30) as r:
+            out = json.loads(r.read())
+    except urllib.error.HTTPError as e:
+        if e.code in (401, 403):
+            return False, f"HTTP {e.code} {(e.read() or b'')[:80]!r}"
+        raise
+    data = out.get("data") or {}
+    if not data.get("shop"):
+        return False, f"no shop in response: {json.dumps(out)[:120]}"
+    have = {s["handle"] for s in (data.get("currentAppInstallation") or {}).get("accessScopes", [])}
+    missing = REQUIRED_SCOPES - have
+    if missing:
+        return False, f"authenticates but lacks scopes {sorted(missing)}"
+    return True, "ok"
+
+def load_token():
+    """TK-11323 (2026-09-23): prefer SHOPIFY_FULL_ACCESS_TOKEN, fall back to
+    SHOPIFY_ADMIN_TOKEN — but only ever to a token that AUTHENTICATES. On 2026-09-22
+    the FULL token (…2ea5) was revoked and this job 401'd on all 207 rows while its
+    daily digest still read 'published=0 email=no' (a false-quiet). A token that
+    fails the probe is skipped loudly; if none authenticates the job fails LOUD."""
+    tried = []
+    for name in ("SHOPIFY_FULL_ACCESS_TOKEN", "SHOPIFY_ADMIN_TOKEN"):
+        tk = _read_key(name)
+        if not tk:
+            tried.append(f"{name}=absent")
+            continue
+        ok, why = _token_authenticates(tk)
+        if ok:
+            if tried:
+                print(f"[token] {'; '.join(tried)} -> using {name} (…{tk[-4:]})", file=sys.stderr)
+            return tk
+        tried.append(f"{name}=…{tk[-4:]} rejected({why})")
+    raise RuntimeError("NO Shopify token authenticates with required scopes: " + "; ".join(tried))
 
 TOKEN = load_token()
 
@@ -110,6 +164,11 @@ def gql(query, variables=None, _tries=0):
         if e.code in (429, 502, 503) and _tries < 5:
             time.sleep(2 * (_tries + 1))
             return gql(query, variables, _tries + 1)
+        if e.code == 401:
+            # TK-11323: token died mid-run. This is never a per-row error — abort the run
+            # with a distinct exit code so launchd/daily.sh see a FAILURE, not 'published=0'.
+            print("FATAL: Shopify token rejected (401) mid-run — aborting; nothing further written", file=sys.stderr)
+            sys.exit(3)
         raise
     except urllib.error.URLError:
         if _tries < 5:
@@ -461,6 +520,7 @@ def create_grs(row, image_ok, res):
                                                            "unit": "POUNDS"}}}}]})
     ue = d["data"]["productVariantsBulkUpdate"]["userErrors"]
     if ue: raise RuntimeError(f"variant update {row['grs']}: {ue}")
+    res["qty_rule_8_8"] = _set_qty_rule(default_variant, row["grs"], row["dw_price"])  # TK-11323
     # Sample variant
     q = '''mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
       productVariantsBulkCreate(productId:$pid, variants:$vars){ userErrors{ field message } } }'''
@@ -506,6 +566,7 @@ def update_grs(row, existing, image_ok, res):
                                                                "unit": "POUNDS"}}}}]})
         ue = d["data"]["productVariantsBulkUpdate"]["userErrors"]
         if ue: raise RuntimeError(f"variant update {row['grs']}: {ue}")
+        res["qty_rule_8_8"] = _set_qty_rule(sell["id"], row["grs"], row["dw_price"])  # TK-11323
     # ensure Sample variant exists
     if not any(v["sku"] == row["grs"] + "-Sample" for v in existing["variants"]):
         q = '''mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
@@ -542,6 +603,30 @@ def _set_metafields(pid, row):
     ue = d["data"]["metafieldsSet"]["userErrors"]
     if ue: raise RuntimeError(f"metafieldsSet {row['grs']}: {ue}")
 
+def _set_qty_rule(variant_gid, grs, price):
+    """TK-11323: stamp the 8/8 whole-bolt rule on the PRICED Per-Yard variant.
+    Skipped (returns False) when price <= 0 — a $0 quote-only variant must NOT get an
+    8-minimum (the Sep-14 finding that ruled build-drafts.py the wrong target)."""
+    try:
+        p = float(price)
+    except (TypeError, ValueError):
+        p = 0.0
+    if not (p > 0):          # also rejects NaN
+        print(f"[qty-rule] {grs}: price={price!r} -> no 8/8 stamp (quote-only/unpriced)", file=sys.stderr)
+        return False
+    mfs = [
+      {"ownerId": variant_gid, "namespace": "custom", "key": "v_prod_quantity_order_min",
+       "type": "single_line_text_field", "value": QTY_RULE_MIN},
+      {"ownerId": variant_gid, "namespace": "custom", "key": "v_prods_quantity_order_units",
+       "type": "single_line_text_field", "value": QTY_RULE_UNITS},
+    ]
+    q = '''mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){
+      userErrors{ field message } } }'''
+    d = gql(q, {"mf": mfs})
+    ue = d["data"]["metafieldsSet"]["userErrors"]
+    if ue: raise RuntimeError(f"qty-rule metafieldsSet {grs}: {ue}")
+    return True
+
 def _attach_image(pid, url):
     q = '''mutation($pid:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$pid,media:$media){
       media{ ... on MediaImage { id } status } mediaUserErrors{ field message } } }'''

← 33f8b4f TK-11552: 2026-09-23 rescue — back up 8 imminent info@ draft  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-23T12:42:16 (1 data files) — scr d4c017e →