"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false), distinct from WooAuthError (a 401 from the store). """ import base64 import json import urllib.error import urllib.request from typing import Any, Callable from .crypto import DecryptError, decrypt class WooError(Exception): """Base for all Woo client failures.""" class WooCredentialsMissing(WooError): """Woo base URL or credentials are not configured (fail-closed).""" class WooAuthError(WooError): """The store rejected the credentials (401).""" class WooNotFoundError(WooError): pass class WooServerError(WooError): pass class WooConnectionError(WooError): """Store unreachable (DNS, TLS, timeout).""" def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]): req = urllib.request.Request(url, method=method, data=data) for k, v in headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=15) as r: return r.getcode(), dict(r.headers.items()), r.read() except urllib.error.HTTPError as e: body = e.read() try: parsed = json.loads(body) except (ValueError, json.JSONDecodeError): parsed = {} msg = parsed.get("message") if isinstance(parsed, dict) else None if e.code == 401: raise WooAuthError(msg or "authentication rejected (401)") from e if e.code == 404: raise WooNotFoundError(msg or "not found (404)") from e raise WooServerError(f"server error {e.code}: {msg}") from e except (urllib.error.URLError, OSError, TimeoutError) as e: raise WooConnectionError(f"store unreachable: {e}") from e class WooClient: def __init__(self, username: str, password: str, base_url: str, request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None: if not base_url or not base_url.startswith("https://"): raise WooCredentialsMissing("WOO_BASE_URL must be https:// (SETTINGS-10)") self.base_url = base_url.rstrip("/") self._request = request self._auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") @classmethod def from_settings(cls, base_url: str, username: str | None, encrypted_password: str | None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> "WooClient": """Build from Settings values; raise WooCredentialsMissing if incomplete (fail-closed).""" if not username: raise WooCredentialsMissing("woo_wp_username not set (SETTINGS-15)") if not encrypted_password: raise WooCredentialsMissing("woo_app_password not configured (SETTINGS-15)") try: password = decrypt(encrypted_password) except DecryptError as e: raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from e if request is None: request = _default_request return cls(username, password, base_url, request) def _get(self, path: str) -> tuple[int, dict, bytes]: url = f"{self.base_url}/wp-json/wc/v3{path}" return self._request( url, "GET", None, {"Authorization": f"Basic {self._auth}", "Accept": "application/json"}, ) def _fetch_all(self, path: str) -> list[dict[str, Any]]: page = 1 results: list[dict[str, Any]] = [] while True: code, headers, body = self._get(f"{path}?per_page=100&page={page}") if code != 200: raise WooServerError(f"expected 200, got {code}") results.extend(json.loads(body)) total_pages = int(headers.get("X-WP-TotalPages") or headers.get("x-wp-totalpages") or 1) if page >= total_pages: return results page += 1 def get_categories(self) -> list[dict[str, Any]]: return self._fetch_all("/products/categories") def get_tags(self) -> list[dict[str, Any]]: return self._fetch_all("/products/tags") def get_attributes(self) -> list[dict[str, Any]]: return self._fetch_all("/products/attributes") def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]: return self._fetch_all(f"/products/attributes/{attr_id}/terms") def upload_media(self, png_bytes: bytes, filename: str) -> int: """POST a PNG to WordPress core media; return the attachment id.""" boundary = "----slabuploader-media-boundary" body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' "Content-Type: image/png\r\n\r\n" ).encode("utf-8") + png_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8") url = f"{self.base_url}/wp-json/wp/v2/media" code, _headers, resp = self._request( url, "POST", body, { "Authorization": f"Basic {self._auth}", "Content-Type": f"multipart/form-data; boundary={boundary}", }, ) if code not in (200, 201): raise WooServerError(f"media upload returned {code}") return int(json.loads(resp)["id"])