← 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
A shopify/duplicate-theme.py
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 →