commerceforai 0.1.0__tar.gz

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,3 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: commerceforai
3
+ Version: 0.1.0
4
+ Summary: Commerce for apps built by AI agents: products, inventory, Stripe checkout, orders, shipping, returns, subscriptions and gift cards.
5
+ Project-URL: Homepage, https://commerceforai.com
6
+ Project-URL: Documentation, https://commerceforai.com/docs
7
+ Project-URL: Repository, https://github.com/leeshadle/commerceforai-js
8
+ Project-URL: Agent guide, https://commerceforai.com/llms.txt
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,checkout,commerce,ecommerce,inventory,orders,stripe
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # commerceforai (Python)
18
+
19
+ Products, inventory, Stripe checkout, orders, shipping, returns, subscriptions and gift cards, on the merchant's own Stripe account. No dependencies.
20
+
21
+ ```bash
22
+ pip install commerceforai
23
+ ```
24
+
25
+ ```python
26
+ from commerceforai import Commerce, CommerceError, create_test_store
27
+
28
+ store = create_test_store("My shop") # a free test store, no sign-up; keep store["api_key"]
29
+ commerce = Commerce(store["api_key"]) # or Commerce() with COMMERCEFORAI_API_KEY set
30
+
31
+ product = commerce.products.create(name="Trail Tee", price=3600, inventory=25)
32
+ variant = product["variants"][0]["id"]
33
+
34
+ checkout = commerce.checkouts.create(items=[{"variant_id": variant, "quantity": 1}], success_url="https://example.com/thanks")
35
+ print(checkout["url"]) # send the customer here
36
+
37
+ # Test stores can run the whole purchase without a browser:
38
+ order = commerce.store.simulate_order(items=[{"variant_id": variant, "quantity": 1}])["order"]
39
+ commerce.orders.fulfill(order["id"], carrier="USPS", tracking_number="9400100000000000000000")
40
+
41
+ try:
42
+ commerce.orders.refund(order["id"], amount=500)
43
+ except CommerceError as e:
44
+ print(e.type, e.message) # e.g. permission_error when the key has a refund cap
45
+ ```
46
+
47
+ Responses are plain dicts and lists. Money is an integer in cents. Errors raise `CommerceError` with `type`, `message` and extra fields such as `param` or `available`.
48
+
49
+ Resources: `products`, `variants`, `inventory`, `checkouts`, `orders`, `returns`, `collections`, `discounts`, `customers`, `draft_orders`, `gift_cards`, `subscriptions`, `store`, `events`, `webhooks`, `reports`. Anything else: `commerce.request("GET", "/v1/...")` ([OpenAPI](https://api.commerceforai.com/openapi.json)).
50
+
51
+ Webhooks (Flask example):
52
+
53
+ ```python
54
+ from commerceforai import verify_webhook
55
+
56
+ event = verify_webhook(request.get_data(), request.headers, os.environ["COMMERCEFORAI_WEBHOOK_SECRET"])
57
+ if event["type"] == "order.paid": ...
58
+ ```
59
+
60
+ Docs: https://commerceforai.com/docs · Agent guide: https://commerceforai.com/llms.txt · MIT licensed.
@@ -0,0 +1,44 @@
1
+ # commerceforai (Python)
2
+
3
+ Products, inventory, Stripe checkout, orders, shipping, returns, subscriptions and gift cards, on the merchant's own Stripe account. No dependencies.
4
+
5
+ ```bash
6
+ pip install commerceforai
7
+ ```
8
+
9
+ ```python
10
+ from commerceforai import Commerce, CommerceError, create_test_store
11
+
12
+ store = create_test_store("My shop") # a free test store, no sign-up; keep store["api_key"]
13
+ commerce = Commerce(store["api_key"]) # or Commerce() with COMMERCEFORAI_API_KEY set
14
+
15
+ product = commerce.products.create(name="Trail Tee", price=3600, inventory=25)
16
+ variant = product["variants"][0]["id"]
17
+
18
+ checkout = commerce.checkouts.create(items=[{"variant_id": variant, "quantity": 1}], success_url="https://example.com/thanks")
19
+ print(checkout["url"]) # send the customer here
20
+
21
+ # Test stores can run the whole purchase without a browser:
22
+ order = commerce.store.simulate_order(items=[{"variant_id": variant, "quantity": 1}])["order"]
23
+ commerce.orders.fulfill(order["id"], carrier="USPS", tracking_number="9400100000000000000000")
24
+
25
+ try:
26
+ commerce.orders.refund(order["id"], amount=500)
27
+ except CommerceError as e:
28
+ print(e.type, e.message) # e.g. permission_error when the key has a refund cap
29
+ ```
30
+
31
+ Responses are plain dicts and lists. Money is an integer in cents. Errors raise `CommerceError` with `type`, `message` and extra fields such as `param` or `available`.
32
+
33
+ Resources: `products`, `variants`, `inventory`, `checkouts`, `orders`, `returns`, `collections`, `discounts`, `customers`, `draft_orders`, `gift_cards`, `subscriptions`, `store`, `events`, `webhooks`, `reports`. Anything else: `commerce.request("GET", "/v1/...")` ([OpenAPI](https://api.commerceforai.com/openapi.json)).
34
+
35
+ Webhooks (Flask example):
36
+
37
+ ```python
38
+ from commerceforai import verify_webhook
39
+
40
+ event = verify_webhook(request.get_data(), request.headers, os.environ["COMMERCEFORAI_WEBHOOK_SECRET"])
41
+ if event["type"] == "order.paid": ...
42
+ ```
43
+
44
+ Docs: https://commerceforai.com/docs · Agent guide: https://commerceforai.com/llms.txt · MIT licensed.
@@ -0,0 +1,375 @@
1
+ """CommerceForAI for Python: products, inventory, Stripe checkout, orders, shipping, returns, subscriptions and
2
+ gift cards on the merchant's own Stripe account.
3
+
4
+ from commerceforai import Commerce, create_test_store
5
+
6
+ store = create_test_store("My shop") # free test store, no sign-up
7
+ commerce = Commerce(store["api_key"]) # or Commerce() with COMMERCEFORAI_API_KEY set
8
+ product = commerce.products.create(name="Trail Tee", price=3600, inventory=25)
9
+ checkout = commerce.checkouts.create(items=[{"variant_id": product["variants"][0]["id"], "quantity": 1}],
10
+ success_url="https://example.com/thanks")
11
+ print(checkout["url"])
12
+
13
+ Every method returns the API's JSON as plain dicts and lists. Money is an integer in cents. API errors raise
14
+ CommerceError, which carries `type`, `message` and any extra fields (for example `param`, `available`, `limit`).
15
+ No dependencies: standard library only. Agent guide: https://commerceforai.com/llms.txt
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import os
24
+ import time
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ import uuid
29
+ from typing import Any, Mapping, Optional
30
+
31
+ __version__ = "0.1.0"
32
+ __all__ = ["Commerce", "CommerceError", "create_test_store", "verify_webhook"]
33
+
34
+ DEFAULT_BASE_URL = "https://api.commerceforai.com"
35
+
36
+
37
+ class CommerceError(Exception):
38
+ """An error returned by the API. `type` is e.g. invalid_request, out_of_stock, permission_error, not_found."""
39
+
40
+ def __init__(self, type: str, message: str, status: int = 0, **extra: Any):
41
+ super().__init__(message)
42
+ self.type, self.message, self.status, self.extra = type, message, status, extra
43
+
44
+ def __getattr__(self, name: str) -> Any: # error.param, error.available, error.limit ...
45
+ try:
46
+ return self.__dict__["extra"][name]
47
+ except KeyError:
48
+ raise AttributeError(name) from None
49
+
50
+ def __str__(self) -> str:
51
+ return f"[{self.type}] {self.message}"
52
+
53
+
54
+ def _send(method: str, url: str, headers: dict, body: Any = None, timeout: float = 30.0, retries: int = 2) -> Any:
55
+ data = None if body is None else json.dumps(body).encode()
56
+ for attempt in range(retries + 1):
57
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
58
+ try:
59
+ with urllib.request.urlopen(req, timeout=timeout) as res:
60
+ text = res.read().decode()
61
+ return json.loads(text) if text else None
62
+ except urllib.error.HTTPError as e:
63
+ text = e.read().decode(errors="replace")
64
+ e.close()
65
+ # Safe to retry: GETs, and POSTs because each carries an Idempotency-Key.
66
+ if e.code in (429, 500, 502, 503, 504) and attempt < retries:
67
+ time.sleep(min(float(e.headers.get("Retry-After") or 0.5 * 2 ** attempt), 10))
68
+ continue
69
+ try:
70
+ err = json.loads(text).get("error") or {}
71
+ except ValueError:
72
+ err = {}
73
+ extra = {k: v for k, v in err.items() if k not in ("type", "message")}
74
+ raise CommerceError(err.get("type", "api_error"), err.get("message", text[:300] or f"HTTP {e.code}"), e.code, **extra) from None
75
+ except urllib.error.URLError as e:
76
+ if attempt < retries:
77
+ time.sleep(0.5 * 2 ** attempt)
78
+ continue
79
+ raise CommerceError("connection_error", f"Couldn't reach {url}: {e.reason}") from None
80
+
81
+
82
+ def create_test_store(name: Optional[str] = None, base_url: str = DEFAULT_BASE_URL) -> dict:
83
+ """Create a free test-mode store with no sign-up. Returns { store_id, api_key, claim_url, ... }.
84
+ Give claim_url to the person who owns the project; keep api_key in COMMERCEFORAI_API_KEY."""
85
+ return _send("POST", f"{base_url.rstrip('/')}/sandbox/stores", {"content-type": "application/json", "user-agent": f"commerceforai-python/{__version__}"},
86
+ {"name": name} if name else {})
87
+
88
+
89
+ def verify_webhook(payload: str | bytes, headers: Mapping[str, str], secret: str, tolerance_seconds: int = 300) -> dict:
90
+ """Check a webhook's signature and return the event. Pass the raw request body, not parsed JSON.
91
+ Raises ValueError if the signature is missing, wrong or too old."""
92
+ get = lambda k: headers.get(k) or headers.get(k.title()) or headers.get(k.upper()) # noqa: E731
93
+ msg_id, ts, sig_header = get("webhook-id"), get("webhook-timestamp"), get("webhook-signature")
94
+ if not (msg_id and ts and sig_header):
95
+ raise ValueError("Missing webhook-id, webhook-timestamp or webhook-signature header")
96
+ if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance_seconds:
97
+ raise ValueError("Webhook timestamp outside tolerance")
98
+ body = payload.decode() if isinstance(payload, bytes) else payload
99
+ key = base64.b64decode(secret.removeprefix("whsec_"))
100
+ expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.{body}".encode(), hashlib.sha256).digest()).decode()
101
+ for part in sig_header.split(" "):
102
+ version, _, sig = part.partition(",")
103
+ if version == "v1" and hmac.compare_digest(sig, expected):
104
+ return json.loads(body)
105
+ raise ValueError("Invalid webhook signature")
106
+
107
+
108
+ class _Resource:
109
+ def __init__(self, client: "Commerce"):
110
+ self._c = client
111
+
112
+
113
+ def _q(value: Any) -> str:
114
+ return urllib.parse.quote(str(value), safe="")
115
+
116
+
117
+ class _Products(_Resource):
118
+ def create(self, **params: Any) -> dict:
119
+ """name plus price (and inventory, sku), or name plus variants=[{name, price, inventory?, sku?, recurring?}]."""
120
+ return self._c.request("POST", "/v1/products", params)
121
+
122
+ def create_many(self, products: list) -> dict:
123
+ """Up to 100 at once; each succeeds or fails on its own."""
124
+ return self._c.request("POST", "/v1/products/bulk", {"products": products})
125
+
126
+ def list(self, **query: Any) -> dict:
127
+ return self._c.request("GET", "/v1/products", query=query)
128
+
129
+ def get(self, id: str) -> dict:
130
+ return self._c.request("GET", f"/v1/products/{_q(id)}")
131
+
132
+ def update(self, id: str, **params: Any) -> dict:
133
+ return self._c.request("PATCH", f"/v1/products/{_q(id)}", params)
134
+
135
+ def archive(self, id: str) -> dict:
136
+ return self._c.request("DELETE", f"/v1/products/{_q(id)}")
137
+
138
+ def add_variant(self, id: str, **params: Any) -> dict:
139
+ return self._c.request("POST", f"/v1/products/{_q(id)}/variants", params)
140
+
141
+
142
+ class _Variants(_Resource):
143
+ def update(self, id: str, **params: Any) -> dict:
144
+ return self._c.request("PATCH", f"/v1/variants/{_q(id)}", params)
145
+
146
+
147
+ class _Inventory(_Resource):
148
+ def list(self, **query: Any) -> dict:
149
+ return self._c.request("GET", "/v1/inventory", query=query)
150
+
151
+ def adjust(self, variant_id: str, by: int, reason: Optional[str] = None) -> dict:
152
+ """Add (or with a negative number, remove) units."""
153
+ return self._c.request("POST", f"/v1/variants/{_q(variant_id)}/inventory", {"adjust": by, **({"reason": reason} if reason else {})})
154
+
155
+ def set(self, variant_id: str, on_hand: int, reason: Optional[str] = None) -> dict:
156
+ return self._c.request("POST", f"/v1/variants/{_q(variant_id)}/inventory", {"set": on_hand, **({"reason": reason} if reason else {})})
157
+
158
+
159
+ class _Checkouts(_Resource):
160
+ def create(self, **params: Any) -> dict:
161
+ """items=[{variant_id, quantity}], success_url, plus optional customer_email, discount_code, cancel_url.
162
+ Send the customer to the returned `url`. Stock is held until they pay or the checkout expires."""
163
+ return self._c.request("POST", "/v1/checkouts", params)
164
+
165
+ def get(self, id: str) -> dict:
166
+ return self._c.request("GET", f"/v1/checkouts/{_q(id)}")
167
+
168
+
169
+ class _Orders(_Resource):
170
+ def list(self, **query: Any) -> dict:
171
+ return self._c.request("GET", "/v1/orders", query=query)
172
+
173
+ def get(self, id: str | int) -> dict:
174
+ """By id or order number."""
175
+ return self._c.request("GET", f"/v1/orders/{_q(id)}")
176
+
177
+ def update(self, id: str | int, **params: Any) -> dict:
178
+ return self._c.request("PATCH", f"/v1/orders/{_q(id)}", params)
179
+
180
+ def fulfill(self, id: str | int, **params: Any) -> dict:
181
+ """carrier, tracking_number, tracking_url; items=[...] ships part of the order."""
182
+ return self._c.request("POST", f"/v1/orders/{_q(id)}/fulfill", params)
183
+
184
+ def refund(self, id: str | int, **params: Any) -> dict:
185
+ """amount in cents (default: everything left), restock, reason."""
186
+ return self._c.request("POST", f"/v1/orders/{_q(id)}/refund", params)
187
+
188
+ def edit(self, id: str | int, **params: Any) -> dict:
189
+ """Before it ships: replace, remove, add, shipping_address, email. dry_run=True previews the refund."""
190
+ return self._c.request("POST", f"/v1/orders/{_q(id)}/edit", params)
191
+
192
+ def shipping_rates(self, id: str | int, **params: Any) -> dict:
193
+ return self._c.request("POST", f"/v1/orders/{_q(id)}/shipping-rates", params)
194
+
195
+ def buy_label(self, id: str | int, **params: Any) -> dict:
196
+ return self._c.request("POST", f"/v1/orders/{_q(id)}/labels", params)
197
+
198
+
199
+ class _Returns(_Resource):
200
+ def create(self, order_id: str | int, **params: Any) -> dict:
201
+ return self._c.request("POST", f"/v1/orders/{_q(order_id)}/returns", params)
202
+
203
+ def list(self, **query: Any) -> dict:
204
+ return self._c.request("GET", "/v1/returns", query=query)
205
+
206
+ def get(self, id: str) -> dict:
207
+ return self._c.request("GET", f"/v1/returns/{_q(id)}")
208
+
209
+ def approve(self, id: str, **params: Any) -> dict:
210
+ return self._c.request("POST", f"/v1/returns/{_q(id)}/approve", params)
211
+
212
+ def reject(self, id: str, **params: Any) -> dict:
213
+ return self._c.request("POST", f"/v1/returns/{_q(id)}/reject", params)
214
+
215
+ def receive(self, id: str, **params: Any) -> dict:
216
+ return self._c.request("POST", f"/v1/returns/{_q(id)}/receive", params)
217
+
218
+
219
+ class _Crud(_Resource):
220
+ """list / create / get / update for a collection-style endpoint."""
221
+ path = ""
222
+
223
+ def list(self, **query: Any) -> dict:
224
+ return self._c.request("GET", self.path, query=query)
225
+
226
+ def create(self, **params: Any) -> dict:
227
+ return self._c.request("POST", self.path, params)
228
+
229
+ def get(self, id: str | int) -> dict:
230
+ return self._c.request("GET", f"{self.path}/{_q(id)}")
231
+
232
+ def update(self, id: str | int, **params: Any) -> dict:
233
+ return self._c.request("PATCH", f"{self.path}/{_q(id)}", params)
234
+
235
+
236
+ class _Collections(_Crud):
237
+ path = "/v1/collections"
238
+
239
+ def delete(self, id: str) -> dict:
240
+ return self._c.request("DELETE", f"{self.path}/{_q(id)}")
241
+
242
+
243
+ class _Discounts(_Crud):
244
+ path = "/v1/discounts"
245
+
246
+ def delete(self, id: str) -> dict:
247
+ return self._c.request("DELETE", f"{self.path}/{_q(id)}")
248
+
249
+
250
+ class _DraftOrders(_Crud):
251
+ path = "/v1/draft_orders"
252
+
253
+ def send_invoice(self, id: str | int, **params: Any) -> dict:
254
+ return self._c.request("POST", f"{self.path}/{_q(id)}/send_invoice", params)
255
+
256
+ def cancel(self, id: str | int) -> dict:
257
+ return self._c.request("POST", f"{self.path}/{_q(id)}/cancel", {})
258
+
259
+
260
+ class _GiftCards(_Crud):
261
+ path = "/v1/gift_cards"
262
+
263
+ def send(self, id: str, **params: Any) -> dict:
264
+ return self._c.request("POST", f"{self.path}/{_q(id)}/send", params)
265
+
266
+
267
+ class _Subscriptions(_Resource):
268
+ def list(self, **query: Any) -> dict:
269
+ return self._c.request("GET", "/v1/subscriptions", query=query)
270
+
271
+ def get(self, id: str) -> dict:
272
+ return self._c.request("GET", f"/v1/subscriptions/{_q(id)}")
273
+
274
+ def cancel(self, id: str, at_period_end: bool = True) -> dict:
275
+ return self._c.request("POST", f"/v1/subscriptions/{_q(id)}/cancel", {"at_period_end": at_period_end})
276
+
277
+ def resume(self, id: str) -> dict:
278
+ return self._c.request("POST", f"/v1/subscriptions/{_q(id)}/resume", {})
279
+
280
+ def simulate_renewal(self, id: str) -> dict:
281
+ """Test stores: run the next renewal now and get the order it creates."""
282
+ return self._c.request("POST", f"/v1/subscriptions/{_q(id)}/simulate_renewal", {})
283
+
284
+
285
+ class _Customers(_Resource):
286
+ def list(self, **query: Any) -> dict:
287
+ return self._c.request("GET", "/v1/customers", query=query)
288
+
289
+ def get(self, email: str) -> dict:
290
+ return self._c.request("GET", f"/v1/customers/{_q(email)}")
291
+
292
+ def update(self, email: str, **params: Any) -> dict:
293
+ """tax_exempt=True marks the customer exempt from sales tax on checkouts created with their email."""
294
+ return self._c.request("PATCH", f"/v1/customers/{_q(email)}", params)
295
+
296
+
297
+ class _Store(_Resource):
298
+ def get(self) -> dict:
299
+ return self._c.request("GET", "/v1/store")
300
+
301
+ def update(self, **params: Any) -> dict:
302
+ return self._c.request("PATCH", "/v1/store", params)
303
+
304
+ def setup_status(self) -> dict:
305
+ """What's left before the store can take real orders, each with the exact fix. Call this first."""
306
+ return self._c.request("GET", "/v1/setup_status")
307
+
308
+ def simulate_order(self, **params: Any) -> dict:
309
+ """Test stores: run a full purchase without a browser. items=[{variant_id, quantity}], fulfill=True to ship it."""
310
+ return self._c.request("POST", "/v1/simulate/order", params)
311
+
312
+ def go_live_link(self) -> dict:
313
+ """A link for the owner: sign in, copy the store to live mode, connect Stripe."""
314
+ return self._c.request("POST", "/v1/go_live_links", {})
315
+
316
+ def configure_checkout_shipping(self, **params: Any) -> dict:
317
+ """countries=["US"], rates=[{name, amount}], free_over=7500"""
318
+ return self._c.request("PUT", "/v1/store/checkout-shipping", params)
319
+
320
+ def usage(self) -> dict:
321
+ return self._c.request("GET", "/v1/store/usage")
322
+
323
+
324
+ class _Events(_Resource):
325
+ def list(self, **query: Any) -> dict:
326
+ """after=<event id>, type="order.*", limit. Poll this instead of webhooks if that's simpler."""
327
+ return self._c.request("GET", "/v1/events", query=query)
328
+
329
+
330
+ class _Webhooks(_Resource):
331
+ def create(self, url: str, events: Optional[list] = None) -> dict:
332
+ """Returns the endpoint with its signing `secret` (shown once)."""
333
+ return self._c.request("POST", "/v1/webhook-endpoints", {"url": url, **({"events": events} if events else {})})
334
+
335
+ def list(self) -> dict:
336
+ return self._c.request("GET", "/v1/webhook-endpoints")
337
+
338
+ def remove(self, id: str) -> dict:
339
+ return self._c.request("DELETE", f"/v1/webhook-endpoints/{_q(id)}")
340
+
341
+ verify = staticmethod(verify_webhook)
342
+
343
+
344
+ class _Reports(_Resource):
345
+ def sales(self, days: int = 30) -> dict:
346
+ return self._c.request("GET", "/v1/reports/sales", query={"days": days})
347
+
348
+ def tax(self, days: int = 365) -> dict:
349
+ return self._c.request("GET", "/v1/reports/tax", query={"days": days})
350
+
351
+
352
+ class Commerce:
353
+ """A store's API client. `api_key` defaults to the COMMERCEFORAI_API_KEY environment variable."""
354
+
355
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 30.0, max_retries: int = 2):
356
+ self.api_key = api_key or os.environ.get("COMMERCEFORAI_API_KEY")
357
+ if not self.api_key:
358
+ raise ValueError("No API key. Pass one, set COMMERCEFORAI_API_KEY, or call commerceforai.create_test_store() for a free test store.")
359
+ self.base_url = (base_url or os.environ.get("COMMERCEFORAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
360
+ self.timeout, self.max_retries = timeout, max_retries
361
+ self.products, self.variants, self.inventory = _Products(self), _Variants(self), _Inventory(self)
362
+ self.checkouts, self.orders, self.returns = _Checkouts(self), _Orders(self), _Returns(self)
363
+ self.collections, self.discounts, self.customers = _Collections(self), _Discounts(self), _Customers(self)
364
+ self.draft_orders, self.gift_cards, self.subscriptions = _DraftOrders(self), _GiftCards(self), _Subscriptions(self)
365
+ self.store, self.events, self.webhooks, self.reports = _Store(self), _Events(self), _Webhooks(self), _Reports(self)
366
+
367
+ def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, idempotency_key: Optional[str] = None) -> Any:
368
+ """Call any endpoint (see https://api.commerceforai.com/openapi.json). POSTs get an Idempotency-Key, so retries are safe."""
369
+ qs = urllib.parse.urlencode({k: ("true" if v is True else "false" if v is False else v) for k, v in (query or {}).items() if v is not None})
370
+ headers = {"authorization": f"Bearer {self.api_key}", "content-type": "application/json", "user-agent": f"commerceforai-python/{__version__}"}
371
+ if method == "POST":
372
+ headers["idempotency-key"] = idempotency_key or str(uuid.uuid4())
373
+ if body is not None:
374
+ body = {k: v for k, v in body.items() if v is not None} if isinstance(body, dict) else body
375
+ return _send(method, f"{self.base_url}{path}{'?' + qs if qs else ''}", headers, body if method != "GET" else None, self.timeout, self.max_retries)
File without changes
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "commerceforai"
7
+ version = "0.1.0"
8
+ description = "Commerce for apps built by AI agents: products, inventory, Stripe checkout, orders, shipping, returns, subscriptions and gift cards."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ keywords = ["commerce", "ecommerce", "stripe", "checkout", "orders", "inventory", "ai", "agents"]
13
+ classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent", "Typing :: Typed"]
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Homepage = "https://commerceforai.com"
18
+ Documentation = "https://commerceforai.com/docs"
19
+ Repository = "https://github.com/leeshadle/commerceforai-js"
20
+ "Agent guide" = "https://commerceforai.com/llms.txt"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["commerceforai"]
@@ -0,0 +1,51 @@
1
+ """Runs against the real API with a throwaway test store (no credentials needed): python -m unittest discover -s tests"""
2
+ import base64, hashlib, hmac, json, time, unittest
3
+
4
+ from commerceforai import Commerce, CommerceError, create_test_store, verify_webhook
5
+
6
+
7
+ class LiveTest(unittest.TestCase):
8
+ @classmethod
9
+ def setUpClass(cls):
10
+ cls.store = create_test_store("Python SDK test")
11
+ cls.commerce = Commerce(cls.store["api_key"])
12
+
13
+ def test_sell_and_ship(self):
14
+ c = self.commerce
15
+ product = c.products.create(name="Trail Tee", variants=[{"name": "M", "price": 3600, "inventory": 5}, {"name": "L", "price": 3600, "inventory": 5}])
16
+ m, l = (v["id"] for v in product["variants"])
17
+ self.assertEqual(c.products.list(limit=10)["data"][0]["id"], product["id"])
18
+ checkout = c.checkouts.create(items=[{"variant_id": m, "quantity": 1}], success_url="https://example.com/thanks")
19
+ self.assertTrue(checkout["url"].startswith("https://checkout.stripe.com/"))
20
+ order = c.store.simulate_order(items=[{"variant_id": m, "quantity": 2}])["order"]
21
+ self.assertEqual(order["status"], "paid")
22
+ preview = c.orders.edit(order["id"], replace=[{"variant_id": m, "with_variant_id": l, "quantity": 1}], dry_run=True)
23
+ self.assertEqual(preview["refund"], 0)
24
+ shipped = c.orders.fulfill(order["number"], carrier="USPS", tracking_number="PY123")
25
+ self.assertEqual(shipped["status"], "fulfilled")
26
+ self.assertIn("/track/", shipped["tracking_page_url"])
27
+ self.assertTrue(any(e["type"] == "order.fulfilled" for e in c.events.list(type="order.*")["data"]))
28
+
29
+ def test_errors_carry_details(self):
30
+ with self.assertRaises(CommerceError) as ctx:
31
+ self.commerce.products.create(nme="Typo", price=100)
32
+ self.assertEqual(ctx.exception.type, "invalid_request")
33
+ self.assertIn("name", ctx.exception.message)
34
+ with self.assertRaises(CommerceError) as ctx:
35
+ self.commerce.orders.get("ord_missing")
36
+ self.assertEqual((ctx.exception.type, ctx.exception.status), ("not_found", 404))
37
+
38
+ def test_verify_webhook(self):
39
+ secret = "whsec_" + base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
40
+ body, ts = json.dumps({"type": "order.paid", "data": {"number": 1001}}), str(int(time.time()))
41
+ sig = base64.b64encode(hmac.new(base64.b64decode(secret[6:]), f"msg_1.{ts}.{body}".encode(), hashlib.sha256).digest()).decode()
42
+ headers = {"webhook-id": "msg_1", "webhook-timestamp": ts, "webhook-signature": f"v1,{sig}"}
43
+ self.assertEqual(verify_webhook(body, headers, secret)["type"], "order.paid")
44
+ with self.assertRaises(ValueError):
45
+ verify_webhook(body + " ", headers, secret)
46
+ with self.assertRaises(ValueError):
47
+ verify_webhook(body, {**headers, "webhook-timestamp": "1"}, secret)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()