checkoutmain-inventory-api 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ """CheckoutMain Inventory API client (Python, standard library only).
2
+
3
+ from checkoutmain_inventory import InventoryApi
4
+ api = InventoryApi(key=os.environ["LC_KEY"])
5
+ for item in api.items(updated_after=since):
6
+ upsert(item)
7
+
8
+ Retries 429/503 honouring Retry-After with jitter, pages through cursors,
9
+ sends API-Version when pinned, and raises InventoryApiError with the error
10
+ code, doc link and request id from the body.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import random
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from dataclasses import dataclass
22
+ from typing import Any, Callable, Dict, Iterator, List, Optional
23
+
24
+ __all__ = ["InventoryApi", "InventoryApiError", "LIVE_BASE_URL"]
25
+
26
+ LIVE_BASE_URL = "https://api.liquidculture.checkoutmain.co/v1"
27
+ USER_AGENT = "checkoutmain-inventory-api-py/1.0"
28
+
29
+
30
+ class InventoryApiError(Exception):
31
+ """An error body from the API: code, message, doc link, request id."""
32
+
33
+ def __init__(
34
+ self,
35
+ status: int,
36
+ code: str,
37
+ message: str,
38
+ doc: Optional[str] = None,
39
+ request_id: Optional[str] = None,
40
+ retry_after: Optional[int] = None,
41
+ param: Optional[str] = None,
42
+ scope: Optional[str] = None,
43
+ ) -> None:
44
+ super().__init__(f"{code} ({status}): {message}")
45
+ self.status = status
46
+ self.code = code
47
+ self.message = message
48
+ self.doc = doc
49
+ self.request_id = request_id
50
+ self.retry_after = retry_after
51
+ self.param = param
52
+ self.scope = scope
53
+
54
+
55
+ @dataclass
56
+ class _Response:
57
+ status: int
58
+ headers: Dict[str, str]
59
+ text: str
60
+
61
+
62
+ # A transport is (method, url, headers, body) -> _Response; the default uses urllib.
63
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], _Response]
64
+
65
+
66
+ def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes]) -> _Response:
67
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=30) as res: # noqa: S310 (https only by contract)
70
+ return _Response(res.status, {k.lower(): v for k, v in res.headers.items()}, res.read().decode("utf-8"))
71
+ except urllib.error.HTTPError as e:
72
+ return _Response(e.code, {k.lower(): v for k, v in e.headers.items()}, e.read().decode("utf-8"))
73
+
74
+
75
+ class InventoryApi:
76
+ def __init__(
77
+ self,
78
+ key: str,
79
+ base_url: str = LIVE_BASE_URL,
80
+ api_version: Optional[str] = None,
81
+ store: Optional[str] = None,
82
+ max_retries: int = 4,
83
+ transport: Optional[Transport] = None,
84
+ sleep: Callable[[float], None] = time.sleep,
85
+ ) -> None:
86
+ if not key:
87
+ raise ValueError("key is required")
88
+ self.key = key
89
+ self.base_url = base_url.rstrip("/")
90
+ self.api_version = api_version
91
+ self.store_id = store
92
+ self.max_retries = max_retries
93
+ self._transport = transport or _urllib_transport
94
+ self._sleep = sleep
95
+
96
+ # ── transport ──
97
+
98
+ def request(self, method: str, path: str, query: Optional[Dict[str, Any]] = None, body: Any = None) -> Any:
99
+ q = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
100
+ if self.store_id and "store" not in q:
101
+ q["store"] = self.store_id
102
+ for k, v in list(q.items()):
103
+ if isinstance(v, (list, tuple)):
104
+ q[k] = ",".join(str(x) for x in v)
105
+ elif isinstance(v, bool):
106
+ q[k] = "true" if v else "false"
107
+ url = f"{self.base_url}{path}"
108
+ if q:
109
+ url += "?" + urllib.parse.urlencode(q)
110
+ headers = {
111
+ "Authorization": f"Bearer {self.key}",
112
+ "Accept": "application/json",
113
+ "User-Agent": USER_AGENT,
114
+ }
115
+ if self.api_version:
116
+ headers["API-Version"] = self.api_version
117
+ data = None
118
+ if body is not None:
119
+ headers["Content-Type"] = "application/json"
120
+ data = json.dumps(body).encode("utf-8")
121
+ attempt = 0
122
+ while True:
123
+ res = self._transport(method, url, headers, data)
124
+ if 200 <= res.status < 300 or res.status == 304:
125
+ return json.loads(res.text) if res.text else None
126
+ err: Dict[str, Any] = {}
127
+ try:
128
+ err = json.loads(res.text).get("error", {}) or {}
129
+ except (ValueError, AttributeError):
130
+ err = {}
131
+ retry_after = res.headers.get("retry-after") or err.get("retry_after")
132
+ try:
133
+ retry_after_s: Optional[int] = int(retry_after) if retry_after is not None else None
134
+ except ValueError:
135
+ retry_after_s = None
136
+ if res.status in (429, 503) and attempt < self.max_retries:
137
+ attempt += 1
138
+ base = retry_after_s if retry_after_s is not None else 2**attempt
139
+ self._sleep(base + random.random() * 0.25)
140
+ continue
141
+ raise InventoryApiError(
142
+ res.status,
143
+ err.get("code", "http_error"),
144
+ err.get("message", f"HTTP {res.status}"),
145
+ err.get("doc"),
146
+ err.get("request_id") or res.headers.get("x-request-id"),
147
+ retry_after_s,
148
+ err.get("param"),
149
+ err.get("scope"),
150
+ )
151
+
152
+ def _walk(self, path: str, query: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]:
153
+ q = dict(query or {})
154
+ while True:
155
+ page = self.request("GET", path, q)
156
+ for row in page["data"]:
157
+ yield row
158
+ if not page.get("has_more") or not page.get("cursor"):
159
+ return
160
+ q["cursor"] = page["cursor"]
161
+
162
+ # ── endpoints ──
163
+
164
+ def store(self) -> Dict[str, Any]:
165
+ return self.request("GET", "/store")["data"]
166
+
167
+ def stores(self) -> List[Dict[str, Any]]:
168
+ return self.request("GET", "/stores")["data"]
169
+
170
+ def items_page(self, **query: Any) -> Dict[str, Any]:
171
+ return self.request("GET", "/catalog/items", query)
172
+
173
+ def items(self, **query: Any) -> Iterator[Dict[str, Any]]:
174
+ return self._walk("/catalog/items", query)
175
+
176
+ def item(self, guid: str) -> Dict[str, Any]:
177
+ return self.request("GET", f"/catalog/items/{urllib.parse.quote(guid, safe='')}")["data"]
178
+
179
+ def categories(self) -> List[Dict[str, Any]]:
180
+ return self.request("GET", "/catalog/categories")["data"]
181
+
182
+ def counts(self, **query: Any) -> Iterator[Dict[str, Any]]:
183
+ return self._walk("/inventory/counts", query)
184
+
185
+ def changes(self, **query: Any) -> Iterator[Dict[str, Any]]:
186
+ return self._walk("/inventory/changes", query)
187
+
188
+ def search(self, **body: Any) -> Dict[str, Any]:
189
+ return self.request("POST", "/catalog/search", None, body)
190
+
191
+ def export_catalog(self, fmt: str = "csv") -> str:
192
+ url = f"{self.base_url}/exports/catalog?" + urllib.parse.urlencode(
193
+ {k: v for k, v in {"format": fmt, "store": self.store_id}.items() if v}
194
+ )
195
+ res = self._transport("GET", url, {"Authorization": f"Bearer {self.key}", "User-Agent": USER_AGENT}, None)
196
+ if res.status != 200:
197
+ try:
198
+ err = json.loads(res.text).get("error", {}) or {}
199
+ except ValueError:
200
+ err = {}
201
+ raise InventoryApiError(res.status, err.get("code", "http_error"), err.get("message", f"HTTP {res.status}"), err.get("doc"), err.get("request_id"))
202
+ return res.text
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkoutmain-inventory-api
3
+ Version: 1.0.0
4
+ Summary: Client for the CheckoutMain Inventory API (LiquidCulture POS, High Spirits POS)
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+
9
+ # checkoutmain-inventory-api
10
+
11
+ Standard-library client for the CheckoutMain Inventory API: retries 429/503
12
+ honouring `Retry-After`, pages through cursors, pins `API-Version`, and raises
13
+ `InventoryApiError` with the error code, doc link and request id.
14
+
15
+ ```python
16
+ from checkoutmain_inventory import InventoryApi
17
+
18
+ api = InventoryApi(key=os.environ["LC_KEY"])
19
+ for item in api.items(updated_after="2026-10-15T00:00:00Z"):
20
+ upsert(item)
21
+ ```
22
+
23
+ Docs: https://checkoutmain.co/developers/liquidculture
@@ -0,0 +1,5 @@
1
+ checkoutmain_inventory/__init__.py,sha256=sBDfMSWrNhY08FQwuNvYq3q3BDEYF1GgCT4cb_wgGtY,7603
2
+ checkoutmain_inventory_api-1.0.0.dist-info/METADATA,sha256=ZlUGySdzIAnSWLqZSZti998cWyyYutPYaQEpN7HVSeQ,730
3
+ checkoutmain_inventory_api-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ checkoutmain_inventory_api-1.0.0.dist-info/top_level.txt,sha256=jXWHzV6zU2vAOCFa2iM6b3DM_HddKPaT4t9HIAOp_o8,23
5
+ checkoutmain_inventory_api-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ checkoutmain_inventory