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.
- commerceforai-0.1.0/.gitignore +3 -0
- commerceforai-0.1.0/PKG-INFO +60 -0
- commerceforai-0.1.0/README.md +44 -0
- commerceforai-0.1.0/commerceforai/__init__.py +375 -0
- commerceforai-0.1.0/commerceforai/py.typed +0 -0
- commerceforai-0.1.0/pyproject.toml +23 -0
- commerceforai-0.1.0/tests/test_live.py +51 -0
|
@@ -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()
|