← back to Designer Wallcoverings
Normalize all 1422 GRS grasscloth widths to 36" (kill 52/54" vinyl default)
d183fe68a437f7946bdae00769892fd19070a341 · 2026-07-06 17:17:18 -0700 · Steve
Files touched
A shopify/scripts/fix-grs-width.py
Diff
commit d183fe68a437f7946bdae00769892fd19070a341
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 17:17:18 2026 -0700
Normalize all 1422 GRS grasscloth widths to 36" (kill 52/54" vinyl default)
---
shopify/scripts/fix-grs-width.py | 115 +++++++++++++++++++++++++++++++++++++++
1 file changed, 115 insertions(+)
diff --git a/shopify/scripts/fix-grs-width.py b/shopify/scripts/fix-grs-width.py
new file mode 100644
index 00000000..1c469485
--- /dev/null
+++ b/shopify/scripts/fix-grs-width.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""
+Normalize width metafields for ALL Phillipe Romano GRS (grasscloth) products.
+
+Hard rule (Steve): GRS = 36" material ONLY. A block of ~217 GRS SKUs carried a
+bad commercial-vinyl default ("52/54\"" / "54 Inches"); ~269 were blank; ~936
+were correct-36" but spelled 15+ ways. This rewrites the three width metafields
+the storefront reads (global.width, dwc.width, custom.width) to ONE canonical
+string on every GRS product.
+
+Customer-facing store write. Steve authorized scope=all-1422, value=canonical.
+
+Usage:
+ fix-grs-width.py --canary grs-20311 # one product, live
+ fix-grs-width.py --limit 1 # first product only
+ fix-grs-width.py # full sweep (all GRS-*)
+"""
+import os, sys, json, time, urllib.request, urllib.error
+
+CANONICAL = '36" Wide (trim to 34")'
+NAMESPACES = [("global", "width"), ("dwc", "width"), ("custom", "width")]
+MFTYPE = "single_line_text_field"
+
+STORE = os.environ.get("SHOPIFY_STORE_DOMAIN") or "designer-laboratory-sandbox.myshopify.com"
+TOKEN = (os.environ.get("SHOPIFY_ADMIN_API_TOKEN") or os.environ.get("SHOPIFY_ADMIN_TOKEN")
+ or os.environ.get("SHOPIFY_ACCESS_TOKEN") or os.environ.get("SHOPIFY_API_TOKEN")
+ or os.environ.get("SHOPIFY_ADMIN_API_ACCESS_TOKEN"))
+API = "2024-10"
+
+def gql(query, variables=None, retries=6):
+ body = json.dumps({"query": query, "variables": variables or {}}).encode()
+ for attempt in range(retries):
+ req = urllib.request.Request(
+ f"https://{STORE}/admin/api/{API}/graphql.json", data=body,
+ headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+ try:
+ d = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code in (429, 502, 503):
+ time.sleep(2 * (attempt + 1)); continue
+ raise
+ errs = d.get("errors")
+ throttled = errs and any(
+ (e.get("extensions", {}) or {}).get("code") == "THROTTLED" for e in errs)
+ if throttled:
+ time.sleep(2 * (attempt + 1)); continue
+ return d
+ raise RuntimeError("exhausted retries")
+
+def collect_gids(only=None):
+ q = '''query($c:String){ products(first:250, query:"sku:GRS-*", after:$c){
+ pageInfo{hasNextPage endCursor} nodes{ id handle } } }'''
+ cur, out = None, []
+ while True:
+ d = gql(q, {"c": cur})
+ p = d["data"]["products"]
+ for n in p["nodes"]:
+ if only and n["handle"] not in only:
+ continue
+ out.append((n["id"], n["handle"]))
+ if p["pageInfo"]["hasNextPage"]:
+ cur = p["pageInfo"]["endCursor"]
+ else:
+ break
+ return out
+
+SET = '''mutation($mf:[MetafieldsSetInput!]!){
+ metafieldsSet(metafields:$mf){
+ metafields{ id } userErrors{ field message } } }'''
+
+def set_batch(products):
+ """products: list of (gid, handle). Up to 8 per call (8*3=24 metafields<=25)."""
+ mf = []
+ for gid, _ in products:
+ for ns, key in NAMESPACES:
+ mf.append({"ownerId": gid, "namespace": ns, "key": key,
+ "type": MFTYPE, "value": CANONICAL})
+ d = gql(SET, {"mf": mf})
+ ue = d.get("data", {}).get("metafieldsSet", {}).get("userErrors") or []
+ if d.get("errors"):
+ ue += [{"message": str(d["errors"])}]
+ return ue
+
+def main():
+ if not TOKEN:
+ print("ERROR: no Shopify admin token in env"); sys.exit(1)
+ args = sys.argv[1:]
+ only = None; limit = None
+ if "--canary" in args:
+ only = {args[args.index("--canary") + 1]}
+ if "--limit" in args:
+ limit = int(args[args.index("--limit") + 1])
+ print(f"store={STORE} token=...{TOKEN[-4:]} canonical={CANONICAL!r}")
+ print("collecting GRS-* product ids ...")
+ gids = collect_gids(only=only)
+ if limit:
+ gids = gids[:limit]
+ print(f"targets: {len(gids)} products x 3 metafields = {len(gids)*3} writes")
+ ok = 0; errs = 0
+ B = 8
+ for i in range(0, len(gids), B):
+ batch = gids[i:i + B]
+ ue = set_batch(batch)
+ if ue:
+ errs += len(batch)
+ print(f" [{i+len(batch)}/{len(gids)}] userErrors: {ue[:3]}")
+ else:
+ ok += len(batch)
+ if (i // B) % 5 == 0 or len(gids) <= B:
+ print(f" [{i+len(batch)}/{len(gids)}] ok ({batch[0][1]}..{batch[-1][1]})")
+ time.sleep(0.4) # gentle on the cost-based throttle
+ print(f"\nDONE. products written ok={ok} errored={errs}")
+
+if __name__ == "__main__":
+ main()
← 783b84d8 chore: version bump (session close) — WallQuest→PR naturals
·
back to Designer Wallcoverings
·
Guard GRS grasscloth width to 36" in command54 push (never i ec3bf17e →