fulkruma 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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
@@ -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,94 @@
1
+ # fulkruma (Python)
2
+
3
+ Official Python SDK for [Fulkruma](https://fulkruma.com) — stock,
4
+ warehouses, shipping, licenses, deliveries. Mirrors the Node SDK
5
+ (`@forjio/fulkruma-node`) API surface 1:1.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install fulkruma
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from fulkruma import FulkrumaClient
17
+
18
+ client = FulkrumaClient(
19
+ key_id="AKIAFULK...",
20
+ secret="sk_...",
21
+ base_url="https://fulkruma.com", # default
22
+ )
23
+
24
+ # Create a warehouse
25
+ wh = client.warehouses.create({"name": "Jakarta DC", "city": "Jakarta"})
26
+
27
+ # Adjust stock
28
+ client.stock.adjust({
29
+ "variantId": "var_1",
30
+ "warehouseId": wh["warehouse"]["id"],
31
+ "delta": 50,
32
+ "reason": "initial_stock",
33
+ })
34
+
35
+ # List shipments
36
+ shipments = client.shipments.list(status="in_transit")
37
+ ```
38
+
39
+ ## Platform-admin scope
40
+
41
+ Keys with the `fulkruma:platform:admin` scope can operate against any
42
+ merchant via the `X-Fulkruma-On-Behalf-Of` header. Either pass it on the
43
+ client:
44
+
45
+ ```python
46
+ admin = FulkrumaClient(key_id="...", secret="...", on_behalf_of="acc_merchant")
47
+ ```
48
+
49
+ …or scope a single call:
50
+
51
+ ```python
52
+ client.shipments.list(on_behalf_of="acc_merchant")
53
+ ```
54
+
55
+ `for_merchant(account_id)` returns a cloned client locked to one merchant.
56
+
57
+ ## Webhook verification
58
+
59
+ ```python
60
+ from fulkruma import verify_webhook
61
+
62
+ # Flask
63
+ @app.post("/webhooks/fulkruma")
64
+ def hook():
65
+ raw = request.get_data() # bytes — verify the raw body, NOT parsed JSON
66
+ sig = request.headers.get("Fulkruma-Signature", "")
67
+ event = verify_webhook(raw_body=raw, signature=sig, secret=os.environ["WHSEC"])
68
+ # event["type"], event["data"], etc.
69
+ return "", 204
70
+ ```
71
+
72
+ ## Resource namespaces
73
+
74
+ | Namespace | Methods |
75
+ |---|---|
76
+ | `products` | `create`, `get`, `list`, `update`, `archive`, `add_variant`, `update_variant`, `archive_variant` |
77
+ | `warehouses` | `create`, `list`, `update`, `archive` |
78
+ | `stock` | `levels`, `movements`, `reservations`, `adjust` |
79
+ | `addresses` | `list`, `create`, `delete` |
80
+ | `shipments` | `list`, `get`, `create` |
81
+ | `shipping` | `couriers`, `origin`, `set_origin`, `rates` |
82
+ | `licenses` | `list`, `issue`, `revoke`, `activate`, `deactivate`, `validate` |
83
+ | `deliveries` | `list`, `get`, `create` |
84
+ | `api_keys` | `list`, `create`, `revoke` |
85
+ | `audit_log` | `list` |
86
+ | `billing` | `plans`, `current_plan`, `subscription`, `usage`, `invoices`, `checkout`, `cancel` |
87
+ | `integrations` | `status` |
88
+ | `stats` | `overview` |
89
+ | `webhooks` | `list_endpoints`, `create_endpoint`, `update_endpoint`, `delete_endpoint`, `list_events` |
90
+ | `admin` | `provision_workspace`, `get_workspace`, `partner_usage` |
91
+
92
+ ## License
93
+
94
+ MIT
@@ -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"
@@ -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
@@ -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
+ )