← back to Elitis Price 2026
exec/lib/shopify.py
85 lines
#!/usr/bin/env python3
"""Shared Shopify Admin API (2024-10) client for the Elitis reprice job.
Reads token from the DW repo .env. All calls are $0 (Shopify Admin API is free).
"""
import os, json, time, urllib.request, urllib.error, sys
API_VERSION = "2024-10"
def _load_env():
dom = os.environ.get("SHOPIFY_STORE_DOMAIN")
tok = os.environ.get("SHOPIFY_ADMIN_ACCESS_TOKEN")
if dom and tok:
return dom, tok
envp = os.path.expanduser("~/Projects/Designer-Wallcoverings/.env")
with open(envp) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
v = v.strip().strip('"').strip("'")
if k == "SHOPIFY_STORE_DOMAIN":
dom = v
elif k == "SHOPIFY_ADMIN_ACCESS_TOKEN":
tok = v
if not dom or not tok:
sys.exit("Missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_ACCESS_TOKEN")
return dom, tok
DOMAIN, TOKEN = _load_env()
GQL_URL = f"https://{DOMAIN}/admin/api/{API_VERSION}/graphql.json"
def gql(query, variables=None, retries=5):
body = json.dumps({"query": query, "variables": variables or {}}).encode()
req = urllib.request.Request(GQL_URL, data=body, method="POST", headers={
"X-Shopify-Access-Token": TOKEN,
"Content-Type": "application/json",
})
last = None
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
except urllib.error.HTTPError as e:
last = f"HTTP {e.code}: {e.read().decode()[:300]}"
if e.code in (429, 500, 502, 503):
time.sleep(2 * (attempt + 1)); continue
raise RuntimeError(last)
except urllib.error.URLError as e:
last = str(e); time.sleep(2 * (attempt + 1)); continue
if "errors" in data and data["errors"]:
raise RuntimeError("GQL errors: " + json.dumps(data["errors"])[:500])
# Handle throttle inside userErrors-free responses
ext = data.get("extensions", {}).get("cost", {}).get("throttleStatus", {})
avail = ext.get("currentlyAvailable")
if avail is not None and avail < 200:
time.sleep(1.5)
return data["data"]
raise RuntimeError(f"gql failed after {retries}: {last}")
def find_product_by_title(title):
"""Exact title match (title is unique per Elitis product). Returns node or None."""
q = """query($q:String!){ products(first:10, query:$q){ edges{ node{
id legacyResourceId title status handle vendor productType tags
options{name values}
variants(first:30){ edges{ node{
id title sku price inventoryPolicy inventoryItem{ id tracked }
selectedOptions{name value} } } }
metafields(first:50){ edges{ node{ namespace key value type } } }
} } } }"""
# Escape quotes/backslashes for the Shopify search-query string literal
safe = title.replace("\\", "\\\\").replace('"', '\\"')
data = gql(q, {"q": f'title:"{safe}"'})
edges = data["products"]["edges"]
for e in edges:
if e["node"]["title"] == title:
return e["node"]
# fall back: single result even if not exact
return edges[0]["node"] if len(edges) == 1 else None
if __name__ == "__main__":
# smoke test
n = find_product_by_title(sys.argv[1] if len(sys.argv) > 1 else "Lin Expérience Minimaliste | Elitis")
print(json.dumps(n, indent=2, ensure_ascii=False) if n else "NOT FOUND")