← 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
M scripts/dwpw-grs-migrate.py
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 →