[object Object]

← back to Designer Wallcoverings

auto-save: 2026-06-24T15:27:54 (2 files) — vendor-scrapers/china-seas-refresh shopify/duplicate-theme.py

cb3bd9c1b6755c6a2e1294c6281b5e03b9d79d96 · 2026-06-24 15:28:00 -0700 · Steve Abrams

Files touched

Diff

commit cb3bd9c1b6755c6a2e1294c6281b5e03b9d79d96
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 15:28:00 2026 -0700

    auto-save: 2026-06-24T15:27:54 (2 files) — vendor-scrapers/china-seas-refresh shopify/duplicate-theme.py
---
 shopify/duplicate-theme.py | 95 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 95 insertions(+)

diff --git a/shopify/duplicate-theme.py b/shopify/duplicate-theme.py
new file mode 100644
index 00000000..6474dff7
--- /dev/null
+++ b/shopify/duplicate-theme.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Duplicate a Shopify theme by copying every asset into a new unpublished theme.
+
+Usage:
+  SHOPIFY_THEME_TOKEN=... python3 duplicate-theme.py <SRC_THEME_ID> "<NEW NAME>"
+
+Creates an empty unpublished theme, then GET/PUT-copies each asset (text via
+`value`, binary via `attachment`) with 429 backoff. Live theme is never touched.
+Reversible: delete the new theme to undo. $0 (theme API is unmetered).
+"""
+import sys, os, json, time, urllib.request, urllib.parse, urllib.error
+
+STORE = "designer-laboratory-sandbox.myshopify.com"
+API = "2024-10"
+
+def tok():
+    t = os.environ.get("SHOPIFY_THEME_TOKEN") or os.environ.get("THEME_TOKEN")
+    if not t:
+        sec = os.path.expanduser("~/Projects/secrets-manager/.env")
+        for line in open(sec):
+            if line.startswith("SHOPIFY_THEME_TOKEN="):
+                t = line.split("=", 1)[1].strip().strip('"').strip("'"); break
+    if not t:
+        sys.exit("No theme token (SHOPIFY_THEME_TOKEN).")
+    return t
+
+H = {"X-Shopify-Access-Token": tok(), "Content-Type": "application/json"}
+
+def req(method, path, body=None):
+    url = f"https://{STORE}/admin/api/{API}/{path}"
+    data = json.dumps(body).encode() if body is not None else None
+    for attempt in range(6):
+        try:
+            r = urllib.request.urlopen(urllib.request.Request(url, data=data, method=method, headers=H))
+            return json.load(r)
+        except urllib.error.HTTPError as e:
+            if e.code == 429:  # rate limited — back off
+                time.sleep(1.5 * (attempt + 1)); continue
+            raise
+    raise SystemExit(f"giving up after retries: {method} {path}")
+
+def main():
+    if len(sys.argv) < 3:
+        sys.exit('Usage: python3 duplicate-theme.py <SRC_THEME_ID> "<NEW NAME>"')
+    src_id, new_name = sys.argv[1], sys.argv[2]
+
+    themes = req("GET", "themes.json")["themes"]
+    src = next((t for t in themes if str(t["id"]) == str(src_id)), None)
+    if not src:
+        sys.exit(f"source theme {src_id} not found")
+    if any(t["name"] == new_name for t in themes):
+        sys.exit(f'a theme named "{new_name}" already exists — aborting')
+    print(f'source: {src["id"]} "{src["name"]}" (role={src["role"]})')
+
+    # 1) create the empty unpublished theme
+    new = req("POST", "themes.json", {"theme": {"name": new_name, "role": "unpublished"}})["theme"]
+    dst_id = new["id"]
+    print(f'created: {dst_id} "{new["name"]}" (role={new["role"]})')
+
+    # 2) enumerate source assets, copy each
+    assets = req("GET", f"themes/{src_id}/assets.json")["assets"]
+    keys = [a["key"] for a in assets]
+    print(f"copying {len(keys)} assets...")
+    ok = skip = fail = 0
+    for i, key in enumerate(keys, 1):
+        q = urllib.parse.urlencode({"asset[key]": key})
+        try:
+            a = req("GET", f"themes/{src_id}/assets.json?{q}")["asset"]
+        except urllib.error.HTTPError as e:
+            print(f"  [{i}/{len(keys)}] GET FAIL {key}: {e.code}"); fail += 1; continue
+        payload = {"key": key}
+        if a.get("value") is not None:
+            payload["value"] = a["value"]
+        elif a.get("attachment") is not None:
+            payload["attachment"] = a["attachment"]
+        else:
+            skip += 1; continue  # nothing to write (e.g. a redirect-only asset)
+        try:
+            req("PUT", f"themes/{dst_id}/assets.json", {"asset": payload}); ok += 1
+        except urllib.error.HTTPError as e:
+            print(f"  [{i}/{len(keys)}] PUT FAIL {key}: {e.code} {e.read().decode()[:120]}"); fail += 1
+        if i % 40 == 0:
+            print(f"  ...{i}/{len(keys)} (ok={ok} skip={skip} fail={fail})")
+        time.sleep(0.25)  # stay under REST rate limit
+
+    print(f"\nDONE  ok={ok} skip={skip} fail={fail}")
+    # 3) sanity: confirm asset count parity
+    dst_assets = req("GET", f"themes/{dst_id}/assets.json")["assets"]
+    print(f"source assets={len(keys)}  new-theme assets={len(dst_assets)}")
+    print(f'\nnew theme id: {dst_id} "{new_name}" (unpublished)')
+    print(f"PREVIEW any product: https://www.designerwallcoverings.com/?preview_theme_id={dst_id}")
+    print(f"delete to undo:  DELETE /admin/api/{API}/themes/{dst_id}.json")
+
+if __name__ == "__main__":
+    main()

← 008d22cf patcher: avoid slash in LIVE label so backup path is valid  ·  back to Designer Wallcoverings  ·  Add reusable Shopify theme duplication script (asset-copy + 75e798ac →