itaja 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.
itaja/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """Itaja for Python servers: declare what you sell, push it, mount one webhook route, react to events."""
2
+
3
+ from ._transport import DEFAULT_BASE_URL, mode_of_key
4
+ from ._version import SDK_VERSION
5
+ from .catalog import (
6
+ CreditPack,
7
+ Credits,
8
+ Feature,
9
+ Group,
10
+ License,
11
+ Plan,
12
+ credit_pack,
13
+ credits,
14
+ feature,
15
+ group,
16
+ license,
17
+ plan,
18
+ to_catalog_document,
19
+ )
20
+ from .client import Itaja, create_itaja
21
+ from .errors import (
22
+ ItajaAuthenticationError,
23
+ ItajaConfigError,
24
+ ItajaConflictError,
25
+ ItajaConnectionError,
26
+ ItajaError,
27
+ ItajaNotFoundError,
28
+ ItajaPermissionError,
29
+ ItajaRateLimitError,
30
+ ItajaServerError,
31
+ ItajaSignatureError,
32
+ ItajaValidationError,
33
+ )
34
+ from .handler import DedupeStore, MemoryDedupeStore, WebhookProcessor, WebhookResult
35
+ from .webhooks import EVENT_TYPES, Event, construct_event, sign_payload
36
+
37
+ __version__ = SDK_VERSION
38
+
39
+ __all__ = [
40
+ "DEFAULT_BASE_URL",
41
+ "EVENT_TYPES",
42
+ "SDK_VERSION",
43
+ "CreditPack",
44
+ "Credits",
45
+ "DedupeStore",
46
+ "Event",
47
+ "Feature",
48
+ "Group",
49
+ "Itaja",
50
+ "ItajaAuthenticationError",
51
+ "ItajaConfigError",
52
+ "ItajaConflictError",
53
+ "ItajaConnectionError",
54
+ "ItajaError",
55
+ "ItajaNotFoundError",
56
+ "ItajaPermissionError",
57
+ "ItajaRateLimitError",
58
+ "ItajaServerError",
59
+ "ItajaSignatureError",
60
+ "ItajaValidationError",
61
+ "License",
62
+ "MemoryDedupeStore",
63
+ "Plan",
64
+ "WebhookProcessor",
65
+ "WebhookResult",
66
+ "construct_event",
67
+ "create_itaja",
68
+ "credit_pack",
69
+ "credits",
70
+ "feature",
71
+ "group",
72
+ "license",
73
+ "mode_of_key",
74
+ "plan",
75
+ "sign_payload",
76
+ "to_catalog_document",
77
+ ]
itaja/_transport.py ADDED
@@ -0,0 +1,144 @@
1
+ """The HTTP layer: the ``{success, data}`` envelope unwrapped, typed errors, and
2
+ retries that can never charge twice.
3
+
4
+ Retry rule: only a call that is safe to send again is retried. The SDK puts an
5
+ ``Idempotency-Key`` on EVERY write (one per logical call, reused on each retry),
6
+ so the API answers a retry with the first response instead of running it again.
7
+ Retried: no response, ``5xx``, ``429`` and ``409 IDEMPOTENCY_KEY_IN_PROGRESS``.
8
+ ``Retry-After`` is honoured, with a ceiling.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import email.utils
14
+ import random
15
+ import time
16
+ import uuid
17
+ from collections.abc import Callable, Mapping
18
+ from typing import Any, Literal
19
+
20
+ import httpx
21
+
22
+ from ._version import SDK_VERSION
23
+ from .errors import ItajaConfigError, ItajaConnectionError, error_for_status
24
+
25
+ DEFAULT_BASE_URL = "https://api.getitaja.com/api/v1"
26
+ Method = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
27
+ _RETRYABLE_CONFLICT = "IDEMPOTENCY_KEY_IN_PROGRESS"
28
+ _MAX_WAIT = 30.0
29
+
30
+
31
+ def mode_of_key(api_key: str | None) -> Literal["sandbox", "live"]:
32
+ """The collection mode a key acts in: the prefix decides, never an option."""
33
+ return "sandbox" if (api_key or "").startswith("itjs_") else "live"
34
+
35
+
36
+ class Transport:
37
+ def __init__(
38
+ self,
39
+ *,
40
+ api_key: str,
41
+ base_url: str = DEFAULT_BASE_URL,
42
+ timeout: float = 30.0,
43
+ max_retries: int = 2,
44
+ http: httpx.Client | None = None,
45
+ sleep: Callable[[float], None] = time.sleep,
46
+ ) -> None:
47
+ self.api_key = api_key
48
+ self.base_url = base_url.rstrip("/")
49
+ self.max_retries = max_retries
50
+ self._sleep = sleep
51
+ self._owns_http = http is None
52
+ self._http = http or httpx.Client(timeout=timeout)
53
+
54
+ def close(self) -> None:
55
+ if self._owns_http:
56
+ self._http.close()
57
+
58
+ @property
59
+ def mode(self) -> Literal["sandbox", "live"]:
60
+ return mode_of_key(self.api_key)
61
+
62
+ def request(
63
+ self,
64
+ method: Method,
65
+ path: str,
66
+ *,
67
+ query: Mapping[str, Any] | None = None,
68
+ body: Any = None,
69
+ idempotency_key: str | None = None,
70
+ ) -> Any:
71
+ if not self.api_key:
72
+ raise ItajaConfigError("No Itaja API key. Set ITAJA_API_KEY, or pass api_key.", code="API_KEY_MISSING")
73
+ is_write = method != "GET"
74
+ key = (idempotency_key or str(uuid.uuid4())) if is_write else None
75
+ params = {k: v for k, v in (query or {}).items() if v is not None}
76
+ headers = {
77
+ "x-itj-key": self.api_key,
78
+ "x-itaja-client": f"itaja-python/{SDK_VERSION}",
79
+ "accept": "application/json",
80
+ }
81
+ if key is not None:
82
+ headers["Idempotency-Key"] = key
83
+ url = f"{self.base_url}/{path.lstrip('/')}"
84
+
85
+ attempt = 0
86
+ while True:
87
+ try:
88
+ response = self._http.request(method, url, params=params or None, json=body, headers=headers)
89
+ except httpx.TransportError as error:
90
+ if attempt < self.max_retries:
91
+ self._sleep(_backoff(attempt))
92
+ attempt += 1
93
+ continue
94
+ raise ItajaConnectionError(
95
+ f"Could not reach Itaja: {type(error).__name__}", code="CONNECTION_ERROR"
96
+ ) from None
97
+
98
+ try:
99
+ payload = response.json()
100
+ except ValueError:
101
+ payload = {}
102
+ if not isinstance(payload, dict):
103
+ payload = {}
104
+ status = response.status_code
105
+
106
+ if status < 300 and payload.get("success", True) is not False:
107
+ return payload.get("data", payload)
108
+
109
+ retry_after = _retry_after(response.headers.get("retry-after"))
110
+ retryable = status >= 500 or status == 429 or (status == 409 and payload.get("code") == _RETRYABLE_CONFLICT)
111
+ if retryable and attempt < self.max_retries:
112
+ self._sleep(min(retry_after, _MAX_WAIT) if retry_after is not None else _backoff(attempt))
113
+ attempt += 1
114
+ continue
115
+
116
+ details = payload.get("details")
117
+ message = payload.get("error")
118
+ code = payload.get("code")
119
+ raise error_for_status(
120
+ status,
121
+ message=message if isinstance(message, str) else f"Itaja answered HTTP {status}",
122
+ code=code if isinstance(code, str) else f"HTTP_{status}",
123
+ request_id=response.headers.get("x-request-id"),
124
+ details=details if isinstance(details, dict) else None,
125
+ retry_after_seconds=int(retry_after) if retry_after is not None else None,
126
+ )
127
+
128
+
129
+ def _backoff(attempt: int) -> float:
130
+ return float(min(0.5 * (2**attempt) + random.uniform(0, 0.25), _MAX_WAIT))
131
+
132
+
133
+ def _retry_after(value: str | None) -> float | None:
134
+ if not value:
135
+ return None
136
+ try:
137
+ seconds = float(value)
138
+ return seconds if seconds >= 0 else None
139
+ except ValueError:
140
+ pass
141
+ parsed = email.utils.parsedate_to_datetime(value) if value else None
142
+ if parsed is None:
143
+ return None
144
+ return max(0.0, parsed.timestamp() - time.time())
itaja/_version.py ADDED
@@ -0,0 +1 @@
1
+ SDK_VERSION = "0.1.0"
itaja/catalog.py ADDED
@@ -0,0 +1,388 @@
1
+ """The catalogue in code: one builder per way of selling, and ``to_catalog_document``
2
+ that turns what you declared into the canonical JSON ``PUT /v1/catalog`` takes
3
+ (``spec/catalog.schema.json``).
4
+
5
+ Every id is **yours** (``"free"``, ``"pro"``, ``"messages"``). Amounts are in
6
+ **major units** of their currency, as on every product route of the API
7
+ (``5000`` XOF, ``9.99`` EUR).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Iterable, Sequence
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Literal, TypedDict
16
+
17
+ from .errors import ItajaConfigError
18
+
19
+ Interval = Literal["week", "month", "quarter", "year"]
20
+ Reset = Literal["day", "week", "month", "year"]
21
+
22
+ INTERVALS: tuple[str, ...] = ("week", "month", "quarter", "year")
23
+ RESETS: tuple[str, ...] = ("day", "week", "month", "year")
24
+ _ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
25
+ _DURATION = re.compile(r"^(lifetime|days-\d+|from-first-activation-days-\d+)$")
26
+ _CURRENCY = re.compile(r"^[A-Za-z]{3}$")
27
+
28
+
29
+ class Money(TypedDict):
30
+ """``{"amount": 5000, "currency": "XOF"}``: major units, ISO 4217."""
31
+
32
+ amount: float
33
+ currency: str
34
+
35
+
36
+ class _PriceRequired(TypedDict):
37
+ amount: float
38
+ currency: str
39
+ interval: Interval
40
+
41
+
42
+ class Price(_PriceRequired, total=False):
43
+ """A recurring price: ``{"amount": 5000, "currency": "XOF", "interval": "month"}``."""
44
+
45
+ intervalCount: int
46
+
47
+
48
+ class Inclusion(TypedDict, total=False):
49
+ feature: str
50
+ limit: int
51
+ reset: Reset
52
+
53
+
54
+ def _error(message: str) -> ItajaConfigError:
55
+ return ItajaConfigError(message, code="CATALOG_DECLARATION_INVALID")
56
+
57
+
58
+ def _assert_id(value: str, what: str) -> None:
59
+ if not isinstance(value, str) or _ID.fullmatch(value) is None:
60
+ raise _error(
61
+ f'Invalid {what} id "{value}": 1 to 64 letters, digits, "_" or "-", starting with a letter or a digit.'
62
+ )
63
+
64
+
65
+ def _assert_money(price: Any, what: str) -> None:
66
+ if not isinstance(price, dict) or "amount" not in price or "currency" not in price:
67
+ raise _error(f"{what}: price needs an `amount` (major units) and a `currency`.")
68
+ amount = price["amount"]
69
+ if isinstance(amount, bool) or not isinstance(amount, (int, float)) or amount < 0:
70
+ raise _error(f"{what}: price.amount must be a non-negative number.")
71
+ if not isinstance(price["currency"], str) or _CURRENCY.fullmatch(price["currency"]) is None:
72
+ raise _error(f"{what}: `currency` must be an ISO 4217 code such as XOF or EUR.")
73
+
74
+
75
+ def _humanize(value: str) -> str:
76
+ words = re.sub(r"[_-]+", " ", value).strip()
77
+ return words[:1].upper() + words[1:]
78
+
79
+
80
+ # ── Features ────────────────────────────────────────────────────────────────
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Feature:
85
+ """A right. Call it to include it in a plan: ``export()`` or ``messages(limit=100, reset="month")``."""
86
+
87
+ id: str
88
+ type: Literal["boolean", "metered"]
89
+
90
+ def __post_init__(self) -> None:
91
+ _assert_id(self.id, "feature")
92
+
93
+ def __call__(self, limit: int | None = None, reset: Reset | None = None) -> Inclusion:
94
+ if self.type == "boolean":
95
+ if limit is not None or reset is not None:
96
+ raise _error(f'Feature "{self.id}" is boolean: include it as {self.id}(), without a limit.')
97
+ return {"feature": self.id}
98
+ if limit is None or isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
99
+ raise _error(f'Feature "{self.id}" is metered: include it with a whole, non-negative limit.')
100
+ if reset is not None and reset not in RESETS:
101
+ raise _error(f'Feature "{self.id}": reset must be one of {", ".join(RESETS)}.')
102
+ inclusion: Inclusion = {"feature": self.id, "limit": limit}
103
+ if reset is not None:
104
+ inclusion["reset"] = reset
105
+ return inclusion
106
+
107
+
108
+ def feature(id: str, type: Literal["boolean", "metered"]) -> Feature:
109
+ """``feature("export", "boolean")`` (access) or ``feature("messages", "metered")`` (a quota per period)."""
110
+ if type not in ("boolean", "metered"):
111
+ raise _error(f'Feature "{id}": type must be "boolean" or "metered".')
112
+ return Feature(id, type)
113
+
114
+
115
+ # ── Subscriptions ───────────────────────────────────────────────────────────
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Group:
120
+ """A subscription product: its plans exclude one another for a customer."""
121
+
122
+ id: str
123
+ name: str
124
+ description: str | None = None
125
+
126
+ def __post_init__(self) -> None:
127
+ _assert_id(self.id, "group")
128
+
129
+
130
+ def group(id: str, name: str, description: str | None = None) -> Group:
131
+ return Group(id, name, description)
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class Plan:
136
+ id: str
137
+ group: Group
138
+ name: str
139
+ description: str | None = None
140
+ price: Price | None = None
141
+ trial_days: int | None = None
142
+ default: bool = False
143
+ includes: tuple[Inclusion, ...] = field(default_factory=tuple)
144
+
145
+
146
+ def plan(
147
+ id: str,
148
+ group: Group | str,
149
+ *,
150
+ name: str | None = None,
151
+ description: str | None = None,
152
+ price: Price | None = None,
153
+ trial_days: int | None = None,
154
+ default: bool = False,
155
+ includes: Sequence[Inclusion] = (),
156
+ ) -> Plan:
157
+ """A plan of a group. The group's ``default`` plan is its free fallback and may omit ``price``."""
158
+ _assert_id(id, "plan")
159
+ g = group if isinstance(group, Group) else Group(group, _humanize(group))
160
+ if not default and price is None:
161
+ raise _error(f'Plan "{id}" has no price: only the group\'s default plan may be free.')
162
+ if price is not None:
163
+ _assert_money(price, f'Plan "{id}"')
164
+ if price.get("interval") not in INTERVALS:
165
+ raise _error(f'Plan "{id}": price.interval must be one of {", ".join(INTERVALS)}.')
166
+ return Plan(id, g, name or _humanize(id), description, price, trial_days, default, tuple(includes))
167
+
168
+
169
+ # ── Credits ─────────────────────────────────────────────────────────────────
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class Credits:
174
+ """A credit currency your app spends as the buyer uses it. Sell it with ``credit_pack``."""
175
+
176
+ id: str
177
+ name: str
178
+ description: str | None = None
179
+ #: What the buyer calls one credit (``Tokens``, ``Coins``).
180
+ coin_name: str | None = None
181
+
182
+ def __post_init__(self) -> None:
183
+ _assert_id(self.id, "credits")
184
+
185
+
186
+ def credits(id: str, name: str, *, description: str | None = None, coin_name: str | None = None) -> Credits:
187
+ return Credits(id, name, description, coin_name)
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class CreditPack:
192
+ id: str
193
+ of: Credits
194
+ credits: int
195
+ price: Money
196
+ label: str
197
+ description: str | None = None
198
+
199
+
200
+ def credit_pack(
201
+ id: str, of: Credits, credits: int, price: Money, *, label: str | None = None, description: str | None = None
202
+ ) -> CreditPack:
203
+ """A bundle of credits sold at a fixed price."""
204
+ _assert_id(id, "credit pack")
205
+ if isinstance(credits, bool) or not isinstance(credits, int) or credits < 1:
206
+ raise _error(f'Credit pack "{id}": credits must be a whole number of at least 1.')
207
+ _assert_money(price, f'Credit pack "{id}"')
208
+ return CreditPack(id, of, credits, price, label or f"{credits} {of.coin_name or of.name}", description)
209
+
210
+
211
+ # ── Licences ────────────────────────────────────────────────────────────────
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class License:
216
+ id: str
217
+ name: str
218
+ price: Money
219
+ #: Devices one key unlocks; ``None`` = unlimited.
220
+ devices: int | None
221
+ duration: str | None = None
222
+ description: str | None = None
223
+
224
+
225
+ def license(
226
+ id: str,
227
+ name: str,
228
+ *,
229
+ price: Money,
230
+ devices: int | None,
231
+ duration: str | None = None,
232
+ description: str | None = None,
233
+ ) -> License:
234
+ """A licence key that unlocks your software. ``duration``: ``"lifetime"``, ``"days-N"``,
235
+ ``"from-first-activation-days-N"``."""
236
+ _assert_id(id, "license")
237
+ _assert_money(price, f'License "{id}"')
238
+ if duration is not None and _DURATION.fullmatch(duration) is None:
239
+ raise _error(f'License "{id}": duration must be "lifetime", "days-N" or "from-first-activation-days-N".')
240
+ return License(id, name, price, devices, duration, description)
241
+
242
+
243
+ # ── The catalogue ───────────────────────────────────────────────────────────
244
+
245
+ CatalogItem = Feature | Plan | Credits | CreditPack | License
246
+
247
+
248
+ def _drop_none(values: dict[str, Any]) -> dict[str, Any]:
249
+ return {k: v for k, v in values.items() if v is not None}
250
+
251
+
252
+ def to_catalog_document(items: Iterable[CatalogItem]) -> dict[str, Any]:
253
+ """Turns a declaration into the canonical catalogue. Deterministic: the same declaration gives
254
+ the same JSON, in declaration order, in every language. Features are the ones you list."""
255
+ features: dict[str, dict[str, str]] = {}
256
+ groups: dict[str, tuple[Group, list[Plan]]] = {}
257
+ credit_products: dict[str, tuple[Credits, list[CreditPack]]] = {}
258
+ licenses: dict[str, License] = {}
259
+ order: list[tuple[str, str]] = []
260
+
261
+ def taken(product_id: str) -> bool:
262
+ return product_id in groups or product_id in credit_products or product_id in licenses
263
+
264
+ def duplicate(product_id: str) -> ItajaConfigError:
265
+ return _error(
266
+ f'Product id "{product_id}" is used twice in the catalogue '
267
+ "(groups, credits and licences share one id space)."
268
+ )
269
+
270
+ for item in items:
271
+ if isinstance(item, Feature):
272
+ known = features.get(item.id)
273
+ if known is not None and known["type"] != item.type:
274
+ raise _error(f'Feature "{item.id}" is declared twice with different types.')
275
+ features[item.id] = {"id": item.id, "type": item.type}
276
+ elif isinstance(item, Plan):
277
+ gid = item.group.id
278
+ if gid not in groups:
279
+ if taken(gid):
280
+ raise duplicate(gid)
281
+ groups[gid] = (item.group, [])
282
+ order.append(("group", gid))
283
+ elif groups[gid][0].name != item.group.name:
284
+ raise _error(f'Group "{gid}" is declared with two different names.')
285
+ plans = groups[gid][1]
286
+ if any(p.id == item.id for p in plans):
287
+ raise _error(f'Plan "{item.id}" is declared twice in group "{gid}".')
288
+ if item.default and any(p.default for p in plans):
289
+ raise _error(f'Group "{gid}" has two default plans; keep one.')
290
+ plans.append(item)
291
+ elif isinstance(item, (Credits, CreditPack)):
292
+ currency = item if isinstance(item, Credits) else item.of
293
+ if currency.id not in credit_products:
294
+ if taken(currency.id):
295
+ raise duplicate(currency.id)
296
+ credit_products[currency.id] = (currency, [])
297
+ order.append(("credits", currency.id))
298
+ if isinstance(item, CreditPack):
299
+ packs = credit_products[currency.id][1]
300
+ if any(p.id == item.id for p in packs):
301
+ raise _error(f'Credit pack "{item.id}" is declared twice in "{currency.id}".')
302
+ packs.append(item)
303
+ elif isinstance(item, License):
304
+ if taken(item.id):
305
+ raise duplicate(item.id)
306
+ licenses[item.id] = item
307
+ order.append(("license", item.id))
308
+ else:
309
+ raise _error("The catalogue accepts feature, plan, credits, credit_pack and license items only.")
310
+
311
+ products: list[dict[str, Any]] = []
312
+ for kind, product_id in order:
313
+ if kind == "group":
314
+ g, plans = groups[product_id]
315
+ for p in plans:
316
+ for inclusion in p.includes:
317
+ if inclusion["feature"] not in features:
318
+ raise _error(
319
+ f'Plan "{p.id}" includes feature "{inclusion["feature"]}", which the catalogue '
320
+ "does not declare. Add the feature to the catalogue."
321
+ )
322
+ products.append(
323
+ _drop_none(
324
+ {
325
+ "id": g.id,
326
+ "type": "subscription",
327
+ "name": g.name,
328
+ "description": g.description,
329
+ "plans": [
330
+ _drop_none(
331
+ {
332
+ "id": p.id,
333
+ "name": p.name,
334
+ "description": p.description,
335
+ "price": dict(p.price) if p.price is not None else None,
336
+ "trial": {"days": p.trial_days} if p.trial_days is not None else None,
337
+ "default": True if p.default else None,
338
+ "includes": [dict(i) for i in p.includes],
339
+ }
340
+ )
341
+ for p in plans
342
+ ],
343
+ }
344
+ )
345
+ )
346
+ elif kind == "credits":
347
+ c, packs = credit_products[product_id]
348
+ if not packs:
349
+ raise _error(f'Credits "{c.id}" has no pack: declare at least one credit_pack for it.')
350
+ products.append(
351
+ _drop_none(
352
+ {
353
+ "id": c.id,
354
+ "type": "credits",
355
+ "name": c.name,
356
+ "description": c.description,
357
+ "coinName": c.coin_name,
358
+ "packs": [
359
+ _drop_none(
360
+ {
361
+ "id": p.id,
362
+ "label": p.label,
363
+ "description": p.description,
364
+ "credits": p.credits,
365
+ "price": dict(p.price),
366
+ }
367
+ )
368
+ for p in packs
369
+ ],
370
+ }
371
+ )
372
+ )
373
+ else:
374
+ lic = licenses[product_id]
375
+ document = _drop_none(
376
+ {
377
+ "id": lic.id,
378
+ "name": lic.name,
379
+ "description": lic.description,
380
+ "price": dict(lic.price),
381
+ "duration": lic.duration,
382
+ "type": "license",
383
+ }
384
+ )
385
+ document["devices"] = lic.devices # null means unlimited: always sent
386
+ products.append(document)
387
+
388
+ return {"features": list(features.values()), "products": products}