fulkruma 0.1.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.
fulkruma/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Official Python SDK for Fulkruma.
2
+
3
+ Mirrors the Node SDK (`@forjio/fulkruma-node`) API surface — HMAC-signed
4
+ requests, 14 resource namespaces, optional `X-Fulkruma-On-Behalf-Of`
5
+ platform-admin scoping, plus webhook signature verification.
6
+ """
7
+
8
+ from .client import FulkrumaClient
9
+ from .errors import FulkrumaError
10
+ from .webhooks import verify_webhook
11
+
12
+ __all__ = [
13
+ "FulkrumaClient",
14
+ "FulkrumaError",
15
+ "verify_webhook",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
fulkruma/client.py ADDED
@@ -0,0 +1,237 @@
1
+ """High-level Fulkruma HMAC client.
2
+
3
+ Mirrors `FulkrumaClient` from the Node SDK 1:1:
4
+
5
+ - ``Authorization: Fulkruma-HMAC-SHA256 keyId=…, scope=*, signature=…``
6
+ - ``X-Fulkruma-Timestamp: <unix>``
7
+ - ``X-Fulkruma-On-Behalf-Of: <accountId>`` for platform-admin keys
8
+ - ``Idempotency-Key`` on every mutation that needs replay safety
9
+ - Standard ``{ data, error, meta }`` envelope unwrapping
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import time
18
+ import uuid
19
+ from typing import Any, Dict, Optional
20
+ from urllib.parse import urlencode
21
+
22
+ import httpx
23
+
24
+ from .errors import FulkrumaError
25
+ from .resources import (
26
+ AddressesResources,
27
+ AdminResources,
28
+ ApiKeysResources,
29
+ AuditLogResources,
30
+ BillingResources,
31
+ DeliveriesResources,
32
+ IntegrationsResources,
33
+ LicensesResources,
34
+ ProductsResources,
35
+ ShipmentsResources,
36
+ ShippingResources,
37
+ StatsResources,
38
+ StockResources,
39
+ WarehousesResources,
40
+ WebhooksResources,
41
+ )
42
+
43
+
44
+ class FulkrumaClient:
45
+ """HMAC-signed client for the Fulkruma REST API.
46
+
47
+ Parameters
48
+ ----------
49
+ key_id:
50
+ HMAC access key id, e.g. ``AKIAFULK...``.
51
+ secret:
52
+ HMAC secret.
53
+ base_url:
54
+ Default ``https://fulkruma.com``. Trailing slashes are stripped.
55
+ on_behalf_of:
56
+ Optional merchant ``accountId`` — forwarded as
57
+ ``X-Fulkruma-On-Behalf-Of``. Only allowed when ``key_id`` holds
58
+ the ``fulkruma:platform:admin`` scope.
59
+ timeout_ms:
60
+ Per-request timeout in milliseconds. Default 30 000.
61
+ http:
62
+ Inject a pre-configured ``httpx.Client`` (e.g. with a custom
63
+ transport for tests). Caller owns its lifecycle.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ key_id: str,
70
+ secret: str,
71
+ base_url: str = "https://fulkruma.com",
72
+ on_behalf_of: Optional[str] = None,
73
+ timeout_ms: int = 30_000,
74
+ http: Optional[httpx.Client] = None,
75
+ ) -> None:
76
+ if not key_id or not secret:
77
+ raise ValueError("FulkrumaClient: key_id and secret are required")
78
+ self.key_id = key_id
79
+ self._secret = secret
80
+ self.base_url = base_url.rstrip("/")
81
+ self._default_obo = on_behalf_of
82
+ self.timeout_ms = timeout_ms
83
+ self._http = http or httpx.Client(timeout=timeout_ms / 1000.0)
84
+ self._owns_http = http is None
85
+
86
+ # Resource namespaces — match the Node SDK property names.
87
+ self.products = ProductsResources(self)
88
+ self.warehouses = WarehousesResources(self)
89
+ self.stock = StockResources(self)
90
+ self.addresses = AddressesResources(self)
91
+ self.shipments = ShipmentsResources(self)
92
+ self.shipping = ShippingResources(self)
93
+ self.licenses = LicensesResources(self)
94
+ self.deliveries = DeliveriesResources(self)
95
+ self.api_keys = ApiKeysResources(self)
96
+ self.audit_log = AuditLogResources(self)
97
+ self.billing = BillingResources(self)
98
+ self.integrations = IntegrationsResources(self)
99
+ self.stats = StatsResources(self)
100
+ self.webhooks = WebhooksResources(self)
101
+ self.admin = AdminResources(self)
102
+
103
+ # ─── Lifecycle ───────────────────────────────────────────────────────
104
+
105
+ def close(self) -> None:
106
+ if self._owns_http:
107
+ self._http.close()
108
+
109
+ def __enter__(self) -> "FulkrumaClient":
110
+ return self
111
+
112
+ def __exit__(self, *_: Any) -> None:
113
+ self.close()
114
+
115
+ def for_merchant(self, account_id: str) -> "FulkrumaClient":
116
+ """Clone scoped to a specific merchant. For platform-admin keys."""
117
+ return FulkrumaClient(
118
+ key_id=self.key_id,
119
+ secret=self._secret,
120
+ base_url=self.base_url,
121
+ on_behalf_of=account_id,
122
+ timeout_ms=self.timeout_ms,
123
+ )
124
+
125
+ # ─── Internal helpers ────────────────────────────────────────────────
126
+
127
+ @staticmethod
128
+ def _qs(params: Dict[str, Any]) -> str:
129
+ entries = [(k, v) for k, v in params.items() if v is not None]
130
+ if not entries:
131
+ return ""
132
+ # str(bool) → "True"/"False"; Node SDK emits the JS coercion ("true"/"false")
133
+ norm = []
134
+ for k, v in entries:
135
+ if isinstance(v, bool):
136
+ norm.append((k, "true" if v else "false"))
137
+ else:
138
+ norm.append((k, str(v)))
139
+ return "?" + urlencode(norm)
140
+
141
+ @staticmethod
142
+ def _gen_idem() -> str:
143
+ return f"idem_{uuid.uuid4()}"
144
+
145
+ def _sign(
146
+ self,
147
+ *,
148
+ method: str,
149
+ path: str,
150
+ body: Optional[str],
151
+ idempotency_key: Optional[str],
152
+ ) -> Dict[str, str]:
153
+ ts = str(int(time.time()))
154
+ body_hash = hashlib.sha256((body or "").encode("utf-8")).hexdigest()
155
+ idem = f"\n{idempotency_key}" if idempotency_key else ""
156
+ string_to_sign = f"{method.upper()}\n{path}\n{ts}\n{body_hash}{idem}"
157
+ signature = hmac.new(
158
+ self._secret.encode("utf-8"),
159
+ string_to_sign.encode("utf-8"),
160
+ hashlib.sha256,
161
+ ).hexdigest()
162
+ return {"signature": signature, "timestamp": ts}
163
+
164
+ # ─── Low-level request ───────────────────────────────────────────────
165
+
166
+ def request(
167
+ self,
168
+ method: str,
169
+ path: str,
170
+ *,
171
+ body: Any = None,
172
+ idempotency_key: Optional[str] = None,
173
+ on_behalf_of: Optional[str] = None,
174
+ ) -> Any:
175
+ body_json = json.dumps(body, separators=(",", ":")) if body is not None else None
176
+ signed = self._sign(
177
+ method=method, path=path, body=body_json, idempotency_key=idempotency_key,
178
+ )
179
+
180
+ headers: Dict[str, str] = {
181
+ "Accept": "application/json",
182
+ "Authorization": (
183
+ f"Fulkruma-HMAC-SHA256 keyId={self.key_id}, scope=*, "
184
+ f"signature={signed['signature']}"
185
+ ),
186
+ "X-Fulkruma-Timestamp": signed["timestamp"],
187
+ }
188
+ if body_json is not None:
189
+ headers["Content-Type"] = "application/json"
190
+ if idempotency_key:
191
+ headers["Idempotency-Key"] = idempotency_key
192
+ effective_obo = on_behalf_of if on_behalf_of is not None else self._default_obo
193
+ if effective_obo:
194
+ headers["X-Fulkruma-On-Behalf-Of"] = effective_obo
195
+
196
+ try:
197
+ res = self._http.request(
198
+ method.upper(),
199
+ f"{self.base_url}{path}",
200
+ headers=headers,
201
+ content=body_json,
202
+ )
203
+ except httpx.TimeoutException as exc:
204
+ raise FulkrumaError(
205
+ 0, "timeout", f"Fulkruma request timed out after {self.timeout_ms}ms",
206
+ ) from exc
207
+ except httpx.HTTPError as exc:
208
+ raise FulkrumaError(0, "network_error", str(exc)) from exc
209
+
210
+ text = res.text
211
+ try:
212
+ env = json.loads(text) if text else {}
213
+ except ValueError:
214
+ raise FulkrumaError(
215
+ res.status_code, "invalid_response", f"Non-JSON response: {text[:200]}",
216
+ ) from None
217
+
218
+ request_id = None
219
+ if isinstance(env, dict):
220
+ meta = env.get("meta")
221
+ if isinstance(meta, dict):
222
+ request_id = meta.get("requestId")
223
+ err = env.get("error")
224
+ else:
225
+ err = None
226
+
227
+ if res.status_code >= 400 or err:
228
+ if not err:
229
+ err = {"code": "unknown", "message": f"HTTP {res.status_code}"}
230
+ raise FulkrumaError(
231
+ res.status_code,
232
+ err.get("code", "unknown"),
233
+ err.get("message", f"HTTP {res.status_code}"),
234
+ request_id,
235
+ )
236
+
237
+ return env.get("data") if isinstance(env, dict) else env
fulkruma/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Typed error class for the Fulkruma SDK.
2
+
3
+ Mirrors `FulkrumaError` from the Node SDK: every non-2xx response and
4
+ every enveloped `{ error: { code, message } }` payload raises this with
5
+ the upstream code preserved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+
13
+ class FulkrumaError(Exception):
14
+ """Raised on any non-success response from a Fulkruma endpoint."""
15
+
16
+ def __init__(
17
+ self,
18
+ status: int,
19
+ code: str,
20
+ message: str,
21
+ request_id: Optional[str] = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.code = code
26
+ self.message = message
27
+ self.request_id = request_id
28
+
29
+ def __repr__(self) -> str:
30
+ return (
31
+ f"FulkrumaError(status={self.status}, code={self.code!r}, "
32
+ f"message={self.message!r}, request_id={self.request_id!r})"
33
+ )
fulkruma/resources.py ADDED
@@ -0,0 +1,351 @@
1
+ """Resource namespaces for FulkrumaClient — Python parity with fulkruma-node.
2
+
3
+ Each builder takes the parent FulkrumaClient and exposes methods that map
4
+ 1:1 to backend REST routes. Every method accepts a keyword-only
5
+ ``on_behalf_of`` for per-call merchant override (no-op unless the key
6
+ holds the ``fulkruma:platform:admin`` scope).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
12
+
13
+ if TYPE_CHECKING:
14
+ from .client import FulkrumaClient
15
+
16
+
17
+ class _Namespace:
18
+ def __init__(self, client: "FulkrumaClient") -> None:
19
+ self._c = client
20
+
21
+
22
+ class ProductsResources(_Namespace):
23
+ def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
24
+ return self._c.request(
25
+ "POST", "/api/v1/products", body=body,
26
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
27
+ )
28
+
29
+ def get(self, product_id: str, *, on_behalf_of: Optional[str] = None):
30
+ return self._c.request("GET", f"/api/v1/products/{product_id}", on_behalf_of=on_behalf_of)
31
+
32
+ def list(self, *, archived: Optional[bool] = None, on_behalf_of: Optional[str] = None):
33
+ return self._c.request(
34
+ "GET", "/api/v1/products" + self._c._qs({"archived": archived}),
35
+ on_behalf_of=on_behalf_of,
36
+ )
37
+
38
+ def update(self, product_id: str, patch: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
39
+ return self._c.request(
40
+ "PATCH", f"/api/v1/products/{product_id}", body=patch, on_behalf_of=on_behalf_of,
41
+ )
42
+
43
+ def archive(self, product_id: str, *, on_behalf_of: Optional[str] = None):
44
+ return self._c.request(
45
+ "DELETE", f"/api/v1/products/{product_id}", on_behalf_of=on_behalf_of,
46
+ )
47
+
48
+ def add_variant(self, product_id: str, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
49
+ return self._c.request(
50
+ "POST", f"/api/v1/products/{product_id}/variants",
51
+ body=body, on_behalf_of=on_behalf_of,
52
+ )
53
+
54
+ def update_variant(
55
+ self, product_id: str, variant_id: str, patch: Dict[str, Any],
56
+ *, on_behalf_of: Optional[str] = None,
57
+ ):
58
+ return self._c.request(
59
+ "PATCH", f"/api/v1/products/{product_id}/variants/{variant_id}",
60
+ body=patch, on_behalf_of=on_behalf_of,
61
+ )
62
+
63
+ def archive_variant(self, product_id: str, variant_id: str, *, on_behalf_of: Optional[str] = None):
64
+ return self._c.request(
65
+ "DELETE", f"/api/v1/products/{product_id}/variants/{variant_id}",
66
+ on_behalf_of=on_behalf_of,
67
+ )
68
+
69
+
70
+ class WarehousesResources(_Namespace):
71
+ def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
72
+ return self._c.request("POST", "/api/v1/warehouses", body=body, on_behalf_of=on_behalf_of)
73
+
74
+ def list(self, *, on_behalf_of: Optional[str] = None):
75
+ return self._c.request("GET", "/api/v1/warehouses", on_behalf_of=on_behalf_of)
76
+
77
+ def update(self, warehouse_id: str, patch: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
78
+ return self._c.request(
79
+ "PATCH", f"/api/v1/warehouses/{warehouse_id}", body=patch, on_behalf_of=on_behalf_of,
80
+ )
81
+
82
+ def archive(self, warehouse_id: str, *, on_behalf_of: Optional[str] = None):
83
+ return self._c.request(
84
+ "DELETE", f"/api/v1/warehouses/{warehouse_id}", on_behalf_of=on_behalf_of,
85
+ )
86
+
87
+
88
+ class StockResources(_Namespace):
89
+ def levels(self, *, variant_id: Optional[str] = None, on_behalf_of: Optional[str] = None):
90
+ return self._c.request(
91
+ "GET", "/api/v1/stock/levels" + self._c._qs({"variant_id": variant_id}),
92
+ on_behalf_of=on_behalf_of,
93
+ )
94
+
95
+ def movements(self, *, variant_id: Optional[str] = None, on_behalf_of: Optional[str] = None):
96
+ return self._c.request(
97
+ "GET", "/api/v1/stock/movements" + self._c._qs({"variant_id": variant_id}),
98
+ on_behalf_of=on_behalf_of,
99
+ )
100
+
101
+ def reservations(self, *, on_behalf_of: Optional[str] = None):
102
+ return self._c.request("GET", "/api/v1/stock/reservations", on_behalf_of=on_behalf_of)
103
+
104
+ def adjust(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
105
+ return self._c.request(
106
+ "POST", "/api/v1/stock/adjust", body=body,
107
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
108
+ )
109
+
110
+
111
+ class AddressesResources(_Namespace):
112
+ def list(self, *, customer_id: Optional[str] = None, on_behalf_of: Optional[str] = None):
113
+ return self._c.request(
114
+ "GET", "/api/v1/addresses" + self._c._qs({"customer_id": customer_id}),
115
+ on_behalf_of=on_behalf_of,
116
+ )
117
+
118
+ def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
119
+ return self._c.request("POST", "/api/v1/addresses", body=body, on_behalf_of=on_behalf_of)
120
+
121
+ def delete(self, address_id: str, *, on_behalf_of: Optional[str] = None):
122
+ return self._c.request(
123
+ "DELETE", f"/api/v1/addresses/{address_id}", on_behalf_of=on_behalf_of,
124
+ )
125
+
126
+
127
+ class ShipmentsResources(_Namespace):
128
+ def list(self, *, status: Optional[str] = None, on_behalf_of: Optional[str] = None):
129
+ return self._c.request(
130
+ "GET", "/api/v1/shipments" + self._c._qs({"status": status}),
131
+ on_behalf_of=on_behalf_of,
132
+ )
133
+
134
+ def get(self, shipment_id: str, *, on_behalf_of: Optional[str] = None):
135
+ return self._c.request("GET", f"/api/v1/shipments/{shipment_id}", on_behalf_of=on_behalf_of)
136
+
137
+ def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
138
+ return self._c.request(
139
+ "POST", "/api/v1/shipments", body=body,
140
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
141
+ )
142
+
143
+
144
+ class ShippingResources(_Namespace):
145
+ def couriers(self, *, on_behalf_of: Optional[str] = None):
146
+ return self._c.request("GET", "/api/v1/shipping/couriers", on_behalf_of=on_behalf_of)
147
+
148
+ def origin(self, *, on_behalf_of: Optional[str] = None):
149
+ return self._c.request("GET", "/api/v1/shipping/origin", on_behalf_of=on_behalf_of)
150
+
151
+ def set_origin(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
152
+ return self._c.request(
153
+ "PATCH", "/api/v1/shipping/origin", body=body, on_behalf_of=on_behalf_of,
154
+ )
155
+
156
+ def rates(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
157
+ return self._c.request(
158
+ "POST", "/api/v1/shipping/rates", body=body, on_behalf_of=on_behalf_of,
159
+ )
160
+
161
+
162
+ class LicensesResources(_Namespace):
163
+ def list(self, *, on_behalf_of: Optional[str] = None):
164
+ return self._c.request("GET", "/api/v1/licenses", on_behalf_of=on_behalf_of)
165
+
166
+ def issue(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
167
+ return self._c.request(
168
+ "POST", "/api/v1/licenses", body=body,
169
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
170
+ )
171
+
172
+ def revoke(self, license_id: str, *, on_behalf_of: Optional[str] = None):
173
+ return self._c.request(
174
+ "POST", f"/api/v1/licenses/{license_id}/revoke",
175
+ body={}, on_behalf_of=on_behalf_of,
176
+ )
177
+
178
+ def activate(self, body: Dict[str, Any]):
179
+ """Public unauthenticated endpoint — buyers' apps call this."""
180
+ return self._c.request("POST", "/api/v1/licenses/activate", body=body)
181
+
182
+ def deactivate(self, body: Dict[str, Any]):
183
+ """Public unauthenticated — release a previously-activated instance."""
184
+ return self._c.request("POST", "/api/v1/licenses/deactivate", body=body)
185
+
186
+ def validate(self, *, key: str, product_id: Optional[str] = None):
187
+ """Public unauthenticated — software pings this on launch."""
188
+ return self._c.request(
189
+ "GET", "/api/v1/licenses/validate" + self._c._qs({"key": key, "productId": product_id}),
190
+ )
191
+
192
+
193
+ class DeliveriesResources(_Namespace):
194
+ def list(self, *, on_behalf_of: Optional[str] = None):
195
+ return self._c.request("GET", "/api/v1/deliveries", on_behalf_of=on_behalf_of)
196
+
197
+ def get(self, delivery_id: str, *, on_behalf_of: Optional[str] = None):
198
+ return self._c.request(
199
+ "GET", f"/api/v1/deliveries/{delivery_id}", on_behalf_of=on_behalf_of,
200
+ )
201
+
202
+ def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
203
+ return self._c.request(
204
+ "POST", "/api/v1/deliveries", body=body,
205
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
206
+ )
207
+
208
+
209
+ class ApiKeysResources(_Namespace):
210
+ def list(self, *, on_behalf_of: Optional[str] = None):
211
+ return self._c.request("GET", "/api/v1/api-keys", on_behalf_of=on_behalf_of)
212
+
213
+ def create(self, body: Optional[Dict[str, Any]] = None, *, on_behalf_of: Optional[str] = None):
214
+ return self._c.request(
215
+ "POST", "/api/v1/api-keys", body=body or {},
216
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
217
+ )
218
+
219
+ def revoke(self, key_id: str, *, on_behalf_of: Optional[str] = None):
220
+ return self._c.request(
221
+ "POST", f"/api/v1/api-keys/{key_id}/revoke",
222
+ body={}, on_behalf_of=on_behalf_of,
223
+ )
224
+
225
+
226
+ class AuditLogResources(_Namespace):
227
+ def list(
228
+ self,
229
+ *,
230
+ limit: Optional[int] = None,
231
+ cursor: Optional[str] = None,
232
+ since: Optional[str] = None,
233
+ event_type: Optional[str] = None,
234
+ on_behalf_of: Optional[str] = None,
235
+ ):
236
+ return self._c.request(
237
+ "GET",
238
+ "/api/v1/audit-log" + self._c._qs({
239
+ "limit": limit,
240
+ "cursor": cursor,
241
+ "since": since,
242
+ "eventType": event_type,
243
+ }),
244
+ on_behalf_of=on_behalf_of,
245
+ )
246
+
247
+
248
+ class BillingResources(_Namespace):
249
+ def plans(self, *, on_behalf_of: Optional[str] = None):
250
+ return self._c.request("GET", "/api/v1/billing/plans", on_behalf_of=on_behalf_of)
251
+
252
+ def current_plan(self, *, on_behalf_of: Optional[str] = None):
253
+ return self._c.request("GET", "/api/v1/billing/plan", on_behalf_of=on_behalf_of)
254
+
255
+ def subscription(self, *, on_behalf_of: Optional[str] = None):
256
+ return self._c.request("GET", "/api/v1/billing/subscription", on_behalf_of=on_behalf_of)
257
+
258
+ def usage(self, *, on_behalf_of: Optional[str] = None):
259
+ return self._c.request("GET", "/api/v1/billing/usage", on_behalf_of=on_behalf_of)
260
+
261
+ def invoices(
262
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None,
263
+ on_behalf_of: Optional[str] = None,
264
+ ):
265
+ return self._c.request(
266
+ "GET",
267
+ "/api/v1/billing/invoices" + self._c._qs({"limit": limit, "cursor": cursor}),
268
+ on_behalf_of=on_behalf_of,
269
+ )
270
+
271
+ def checkout(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
272
+ return self._c.request(
273
+ "POST", "/api/v1/billing/checkout", body=body, on_behalf_of=on_behalf_of,
274
+ )
275
+
276
+ def cancel(self, *, on_behalf_of: Optional[str] = None):
277
+ return self._c.request(
278
+ "POST", "/api/v1/billing/cancel", body={}, on_behalf_of=on_behalf_of,
279
+ )
280
+
281
+
282
+ class IntegrationsResources(_Namespace):
283
+ def status(self, *, on_behalf_of: Optional[str] = None):
284
+ return self._c.request("GET", "/api/v1/integrations/status", on_behalf_of=on_behalf_of)
285
+
286
+
287
+ class StatsResources(_Namespace):
288
+ def overview(self, *, on_behalf_of: Optional[str] = None):
289
+ return self._c.request("GET", "/api/v1/stats/overview", on_behalf_of=on_behalf_of)
290
+
291
+
292
+ class WebhooksResources(_Namespace):
293
+ def list_endpoints(self, *, on_behalf_of: Optional[str] = None):
294
+ return self._c.request(
295
+ "GET", "/api/v1/webhooks/endpoints", on_behalf_of=on_behalf_of,
296
+ )
297
+
298
+ def create_endpoint(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
299
+ return self._c.request(
300
+ "POST", "/api/v1/webhooks/endpoints", body=body,
301
+ idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
302
+ )
303
+
304
+ def update_endpoint(self, endpoint_id: str, patch: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
305
+ return self._c.request(
306
+ "PATCH", f"/api/v1/webhooks/endpoints/{endpoint_id}",
307
+ body=patch, on_behalf_of=on_behalf_of,
308
+ )
309
+
310
+ def delete_endpoint(self, endpoint_id: str, *, on_behalf_of: Optional[str] = None):
311
+ return self._c.request(
312
+ "DELETE", f"/api/v1/webhooks/endpoints/{endpoint_id}",
313
+ on_behalf_of=on_behalf_of,
314
+ )
315
+
316
+ def list_events(
317
+ self,
318
+ *,
319
+ limit: Optional[int] = None,
320
+ cursor: Optional[str] = None,
321
+ type: Optional[str] = None,
322
+ on_behalf_of: Optional[str] = None,
323
+ ):
324
+ return self._c.request(
325
+ "GET",
326
+ "/api/v1/webhooks/events"
327
+ + self._c._qs({"limit": limit, "cursor": cursor, "type": type}),
328
+ on_behalf_of=on_behalf_of,
329
+ )
330
+
331
+
332
+ class AdminResources(_Namespace):
333
+ def provision_workspace(self, body: Dict[str, Any]):
334
+ """Idempotent — refuses to re-route a workspace already provisioned
335
+ by a different partner. Mirrors the Node SDK key `ws_<acc>_<partner>`."""
336
+ account_id = body.get("accountId", "unknown")
337
+ partner = body.get("partner", "unknown")
338
+ return self._c.request(
339
+ "POST", "/api/v1/admin/workspaces", body=body,
340
+ idempotency_key=f"ws_{account_id}_{partner}",
341
+ )
342
+
343
+ def get_workspace(self, account_id: str):
344
+ return self._c.request("GET", f"/api/v1/admin/workspaces/{account_id}")
345
+
346
+ def partner_usage(self, *, partner: str, from_: str, to: str):
347
+ return self._c.request(
348
+ "GET",
349
+ "/api/v1/admin/partner/usage"
350
+ + self._c._qs({"partner": partner, "from": from_, "to": to}),
351
+ )
fulkruma/webhooks.py ADDED
@@ -0,0 +1,112 @@
1
+ """HMAC-SHA256 webhook signature verifier for Fulkruma.
2
+
3
+ Fulkruma signs every webhook delivery with::
4
+
5
+ Fulkruma-Signature: t=<unix_seconds>,v1=<hex>
6
+
7
+ where ``<hex> = HMAC-SHA256(secret, f"{t}.{rawBody}")``. The *raw* body
8
+ bytes are what got signed — re-serialising parsed JSON will produce
9
+ different whitespace and the signature will never match.
10
+
11
+ Flask example::
12
+
13
+ from flask import Flask, request, abort
14
+ from fulkruma import verify_webhook, FulkrumaError
15
+
16
+ app = Flask(__name__)
17
+
18
+ @app.post("/webhooks/fulkruma")
19
+ def fulkruma_hook():
20
+ raw = request.get_data()
21
+ sig = request.headers.get("Fulkruma-Signature")
22
+ try:
23
+ event = verify_webhook(raw_body=raw, signature=sig, secret=SECRET)
24
+ except FulkrumaError:
25
+ abort(400)
26
+ # event["type"], event["data"], ...
27
+ return "", 204
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import hmac
34
+ import json
35
+ import time
36
+ from typing import Any, Dict, Optional, Union
37
+
38
+ from .errors import FulkrumaError
39
+
40
+
41
+ def verify_webhook(
42
+ *,
43
+ raw_body: Union[bytes, str],
44
+ signature: Optional[str],
45
+ secret: str,
46
+ tolerance_sec: int = 300,
47
+ now: Optional[float] = None,
48
+ ) -> Dict[str, Any]:
49
+ """Verify and parse an inbound Fulkruma webhook.
50
+
51
+ Returns the decoded event envelope on success. Raises
52
+ :class:`FulkrumaError` on any failure (missing header, drift, bad
53
+ signature, malformed JSON).
54
+
55
+ Parameters
56
+ ----------
57
+ raw_body:
58
+ The unparsed request body as received over the wire. ``str`` is
59
+ encoded as UTF-8 before hashing.
60
+ signature:
61
+ The value of the ``Fulkruma-Signature`` header.
62
+ secret:
63
+ The endpoint's shared signing secret.
64
+ tolerance_sec:
65
+ Maximum drift in seconds — defaults to 300 (5 min), matching the
66
+ Node SDK and Stripe/GitHub conventions.
67
+ now:
68
+ Inject a clock for tests; seconds since epoch.
69
+ """
70
+ if not signature:
71
+ raise FulkrumaError(0, "missing_signature", "missing Fulkruma-Signature header")
72
+
73
+ parts: Dict[str, str] = {}
74
+ for segment in signature.split(","):
75
+ segment = segment.strip()
76
+ if not segment or "=" not in segment:
77
+ continue
78
+ k, _, v = segment.partition("=")
79
+ parts[k.strip()] = v.strip()
80
+
81
+ ts = parts.get("t")
82
+ v1 = parts.get("v1")
83
+ if not ts or not v1:
84
+ raise FulkrumaError(0, "malformed_signature", "malformed signature header")
85
+
86
+ try:
87
+ ts_num = int(ts)
88
+ except ValueError:
89
+ raise FulkrumaError(0, "malformed_signature", "non-numeric timestamp") from None
90
+
91
+ current = now if now is not None else time.time()
92
+ drift = abs(int(current) - ts_num)
93
+ if drift > tolerance_sec:
94
+ raise FulkrumaError(
95
+ 0, "signature_expired", f"signature timestamp {drift}s out of tolerance",
96
+ )
97
+
98
+ body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
99
+ expected = hmac.new(
100
+ secret.encode("utf-8"),
101
+ f"{ts}.".encode("utf-8") + body_bytes,
102
+ hashlib.sha256,
103
+ ).hexdigest()
104
+
105
+ if not hmac.compare_digest(expected, v1):
106
+ raise FulkrumaError(0, "bad_signature", "bad signature")
107
+
108
+ body_str = body_bytes.decode("utf-8")
109
+ try:
110
+ return json.loads(body_str)
111
+ except ValueError:
112
+ raise FulkrumaError(0, "invalid_body", "webhook body is not valid JSON") from None
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: fulkruma
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Fulkruma — stock + warehouses + shipping + licenses + deliveries. HMAC-signed requests with full parity to the Node SDK.
5
+ Project-URL: Homepage, https://fulkruma.com
6
+ Project-URL: Source, https://github.com/hachimi-cat/saas-fulkruma/tree/master/sdk/python
7
+ Project-URL: Issues, https://github.com/hachimi-cat/saas-fulkruma/issues
8
+ Author-email: Forjio <hello@fulkruma.com>
9
+ License: MIT
10
+ Keywords: biteship,forjio,fulkruma,licenses,shipping,stock,warehouses
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: httpx>=0.27.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: respx>=0.21; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # fulkruma (Python)
25
+
26
+ Official Python SDK for [Fulkruma](https://fulkruma.com) — stock,
27
+ warehouses, shipping, licenses, deliveries. Mirrors the Node SDK
28
+ (`@forjio/fulkruma-node`) API surface 1:1.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install fulkruma
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ from fulkruma import FulkrumaClient
40
+
41
+ client = FulkrumaClient(
42
+ key_id="AKIAFULK...",
43
+ secret="sk_...",
44
+ base_url="https://fulkruma.com", # default
45
+ )
46
+
47
+ # Create a warehouse
48
+ wh = client.warehouses.create({"name": "Jakarta DC", "city": "Jakarta"})
49
+
50
+ # Adjust stock
51
+ client.stock.adjust({
52
+ "variantId": "var_1",
53
+ "warehouseId": wh["warehouse"]["id"],
54
+ "delta": 50,
55
+ "reason": "initial_stock",
56
+ })
57
+
58
+ # List shipments
59
+ shipments = client.shipments.list(status="in_transit")
60
+ ```
61
+
62
+ ## Platform-admin scope
63
+
64
+ Keys with the `fulkruma:platform:admin` scope can operate against any
65
+ merchant via the `X-Fulkruma-On-Behalf-Of` header. Either pass it on the
66
+ client:
67
+
68
+ ```python
69
+ admin = FulkrumaClient(key_id="...", secret="...", on_behalf_of="acc_merchant")
70
+ ```
71
+
72
+ …or scope a single call:
73
+
74
+ ```python
75
+ client.shipments.list(on_behalf_of="acc_merchant")
76
+ ```
77
+
78
+ `for_merchant(account_id)` returns a cloned client locked to one merchant.
79
+
80
+ ## Webhook verification
81
+
82
+ ```python
83
+ from fulkruma import verify_webhook
84
+
85
+ # Flask
86
+ @app.post("/webhooks/fulkruma")
87
+ def hook():
88
+ raw = request.get_data() # bytes — verify the raw body, NOT parsed JSON
89
+ sig = request.headers.get("Fulkruma-Signature", "")
90
+ event = verify_webhook(raw_body=raw, signature=sig, secret=os.environ["WHSEC"])
91
+ # event["type"], event["data"], etc.
92
+ return "", 204
93
+ ```
94
+
95
+ ## Resource namespaces
96
+
97
+ | Namespace | Methods |
98
+ |---|---|
99
+ | `products` | `create`, `get`, `list`, `update`, `archive`, `add_variant`, `update_variant`, `archive_variant` |
100
+ | `warehouses` | `create`, `list`, `update`, `archive` |
101
+ | `stock` | `levels`, `movements`, `reservations`, `adjust` |
102
+ | `addresses` | `list`, `create`, `delete` |
103
+ | `shipments` | `list`, `get`, `create` |
104
+ | `shipping` | `couriers`, `origin`, `set_origin`, `rates` |
105
+ | `licenses` | `list`, `issue`, `revoke`, `activate`, `deactivate`, `validate` |
106
+ | `deliveries` | `list`, `get`, `create` |
107
+ | `api_keys` | `list`, `create`, `revoke` |
108
+ | `audit_log` | `list` |
109
+ | `billing` | `plans`, `current_plan`, `subscription`, `usage`, `invoices`, `checkout`, `cancel` |
110
+ | `integrations` | `status` |
111
+ | `stats` | `overview` |
112
+ | `webhooks` | `list_endpoints`, `create_endpoint`, `update_endpoint`, `delete_endpoint`, `list_events` |
113
+ | `admin` | `provision_workspace`, `get_workspace`, `partner_usage` |
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,8 @@
1
+ fulkruma/__init__.py,sha256=Guxw2mV-CqMCK8-60aviAH4me-mustCiK5wl7j7Ts_8,457
2
+ fulkruma/client.py,sha256=oFI22gnppq02yl37yVhAYhqW0-FIVNiZ9K7IOE7aO_A,8083
3
+ fulkruma/errors.py,sha256=WDRtb-Eswr-gu9TVFkDdJ0eWJETj0-SWxCfAVwBeXRo,901
4
+ fulkruma/resources.py,sha256=Za7BPszBoUTekkkiwchBnwf1dJbXpfTwXvES3jrppfA,13967
5
+ fulkruma/webhooks.py,sha256=B0LXIOAi-7BPKoksKbvZjQ7heCMmSLOnk4czhYwTQkU,3405
6
+ fulkruma-0.1.0.dist-info/METADATA,sha256=074i602FgnYPKoKIRFMtrk4kj2JFZ7WIyr05UoNbIO4,3601
7
+ fulkruma-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ fulkruma-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any