← back to Reid Witlin Onboarding
rename_titles_fabric.py
130 lines
#!/usr/bin/env python3
"""
Reid Witlin / Architectural Fabrics — rewrite every ACTIVE product's title to
"{Pattern-or-Town} - {Colorway} Fabric" (Steve approved format 2026-09-03).
Rebuilds the title from STRUCTURED catalog data (pattern_name / virginia_town +
colorway derived from mfr_sku) rather than string-splitting the already-rendered
title, since some titles use town-based naming and others pattern-based, and
some patterns/colorways are multi-word.
Scope: only ACTIVE rwltd_catalog products (archived/discontinued items are
skipped — no point renaming something no longer customer-facing).
SAFETY: DRY_RUN=1 default. Title-only productUpdate, nothing else touched.
"""
import os, re, json, time, subprocess, urllib.request
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LIMIT = int(os.environ.get("LIMIT", "0")) or None
def _tok():
p = os.path.expanduser("~/Projects/secrets-manager/.env")
for line in open(p):
if line.startswith("SHOPIFY_ADMIN_TOKEN="):
return line.split("=", 1)[1].strip().strip('"')
raise SystemExit("SHOPIFY_ADMIN_TOKEN not found")
TOKEN = os.environ.get("AT") or _tok()
URL = "https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json"
def gql(q, v=None):
body = json.dumps({"query": q, "variables": v or {}}).encode()
req = urllib.request.Request(URL, body,
{"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
for a in range(8):
try:
d = json.load(urllib.request.urlopen(req, timeout=90))
if "errors" in d and any("THROTTLED" in str(e) for e in d["errors"]):
time.sleep(2 * (a + 1)); continue
return d
except Exception:
time.sleep(2 * (a + 1))
raise RuntimeError("gql failed")
def slugify(s):
return re.sub(r'-+', '-', re.sub(r'[^a-z0-9]+', '-', (s or '').lower())).strip('-')
def colorway_of(mfr_sku, pattern_name):
pat = slugify(pattern_name)
slug = slugify(mfr_sku)
if pat and slug.startswith(pat + '-'):
return slug[len(pat) + 1:]
return slug.rsplit('-', 1)[-1]
def psql_json(sql):
wrapped = f"SELECT COALESCE(json_agg(t),'[]') FROM ({sql}) t;"
r = subprocess.run(["psql", "host=/tmp dbname=dw_unified", "-tA", "-c", wrapped],
capture_output=True, text=True)
if r.returncode != 0:
raise SystemExit(f"psql failed: {r.stderr}")
return json.loads(r.stdout.strip() or "[]")
PRODUCT_STATUS_Q = "query($id: ID!) { product(id: $id) { status } }"
RENAME_M = """
mutation($input: ProductInput!) {
productUpdate(input: $input) { product { id title } userErrors { field message } }
}"""
def to_gid(raw):
return raw if raw.startswith("gid://") else f"gid://shopify/Product/{raw}"
def main():
rows = psql_json("""
SELECT mfr_sku, pattern_name, virginia_town, shopify_product_id
FROM rwltd_catalog
WHERE shopify_product_id IS NOT NULL AND shopify_product_id <> ''
ORDER BY mfr_sku
""")
if LIMIT:
rows = rows[:LIMIT]
candidates = []
for row in rows:
pat = row.get("pattern_name") or ""
town = (row.get("virginia_town") or "").strip()
mfr = row["mfr_sku"]
cw = colorway_of(mfr, pat).replace('-', ' ').title()
base = town or (pat.title() if pat.isupper() else pat)
if not base or not cw:
continue
new_title = f"{base} - {cw} Fabric"
candidates.append({"gid": to_gid(row["shopify_product_id"]), "new_title": new_title, "mfr_sku": mfr})
print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(candidates)} candidate rows "
f"(will skip any not currently ACTIVE)")
renamed, skipped_not_active, errs = 0, 0, []
for i, c in enumerate(candidates):
if DRY_RUN:
if i < 8:
print(f" {c['mfr_sku']:30s} -> {c['new_title']}")
renamed += 1
continue
st = gql(PRODUCT_STATUS_Q, {"id": c["gid"]})
status = ((st.get("data") or {}).get("product") or {}).get("status")
if status != "ACTIVE":
skipped_not_active += 1
continue
d = gql(RENAME_M, {"input": {"id": c["gid"], "title": c["new_title"]}})
r = (d.get("data") or {}).get("productUpdate") or {}
ue = r.get("userErrors") or []
if ue:
errs.append({"gid": c["gid"], "mfr_sku": c["mfr_sku"], "errors": ue[:2]})
else:
renamed += 1
if i % 50 == 0:
print(f" ...{i}/{len(candidates)} renamed={renamed} skipped_not_active={skipped_not_active} errs={len(errs)}")
time.sleep(0.3)
out = {"renamed": renamed, "skipped_not_active": skipped_not_active,
"errors": errs[:20], "total_candidates": len(candidates)}
json.dump(out, open("rename-titles-fabric-results.json", "w"), indent=2)
print(f"\nDONE. renamed={renamed} skipped_not_active={skipped_not_active} "
f"userErrors={len(errs)} ({'DRY-RUN — nothing written' if DRY_RUN else 'LIVE titles updated'})")
if errs:
print("sample errors:", json.dumps(errs[:3]))
if __name__ == "__main__":
main()