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 @@
|
|
|
1
|
+
checkoutmain_inventory
|