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 +77 -0
- itaja/_transport.py +144 -0
- itaja/_version.py +1 -0
- itaja/catalog.py +388 -0
- itaja/cli.py +267 -0
- itaja/client.py +459 -0
- itaja/errors.py +108 -0
- itaja/handler.py +160 -0
- itaja/integrations/__init__.py +4 -0
- itaja/integrations/django.py +27 -0
- itaja/integrations/flask.py +22 -0
- itaja/integrations/starlette.py +40 -0
- itaja/py.typed +0 -0
- itaja/webhooks.py +162 -0
- itaja-0.1.0.dist-info/METADATA +377 -0
- itaja-0.1.0.dist-info/RECORD +19 -0
- itaja-0.1.0.dist-info/WHEEL +4 -0
- itaja-0.1.0.dist-info/entry_points.txt +2 -0
- itaja-0.1.0.dist-info/licenses/LICENSE +21 -0
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}
|