eps-sdk 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.
eps_sdk/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Backend-only Python SDK for Eko Platform Services (EPS) APIs.
2
+
3
+ from eps_sdk import EpsClient
4
+
5
+ client = EpsClient(
6
+ developer_key="...",
7
+ access_key="...",
8
+ environment="sandbox",
9
+ initiator_id="9962981729",
10
+ )
11
+ sender = client.call("dmt-get-sender", {"customer_id": "9123456789"})
12
+
13
+ Never instantiate this in anything a browser can reach — `access_key` signs
14
+ every request and must stay server-side.
15
+ """
16
+
17
+ from .client import (
18
+ MULTIPART_JSON_FIELD,
19
+ EpsClient,
20
+ EpsError,
21
+ EpsHttpError,
22
+ Target,
23
+ sign_secret_key,
24
+ )
25
+
26
+ __all__ = [
27
+ "EpsClient",
28
+ "EpsError",
29
+ "EpsHttpError",
30
+ "MULTIPART_JSON_FIELD",
31
+ "Target",
32
+ "sign_secret_key",
33
+ ]
eps_sdk/client.py ADDED
@@ -0,0 +1,412 @@
1
+ """Backend-only Python client for Eko Platform Services (EPS).
2
+
3
+ Port of ``packages/sdk-js/src/client.ts`` and ``packages/sdk-php/src/EpsClient.php``.
4
+ The signing algorithm, validation rules and error message formats are fixed by
5
+ ``docs/sdk-golden-vector.md`` — every SDK language must agree byte for byte.
6
+
7
+ Standard library only: no runtime dependencies to keep up to date.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import math
17
+ import mimetypes
18
+ import os
19
+ import re
20
+ import secrets
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple
28
+
29
+ __all__ = [
30
+ "EpsClient",
31
+ "EpsError",
32
+ "EpsHttpError",
33
+ "MULTIPART_JSON_FIELD",
34
+ "Target",
35
+ "sign_secret_key",
36
+ ]
37
+
38
+ #: Name of the single form field carrying every non-file value as one JSON
39
+ #: object. Eko's upload APIs do not take a form field per parameter. Mirrors
40
+ #: ``MULTIPART_JSON_FIELD`` in the website's ``src/lib/data/api-specs-common.ts``.
41
+ MULTIPART_JSON_FIELD = "form-data"
42
+
43
+ _DEFAULT_TIMEOUT = 30.0
44
+
45
+ _NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$")
46
+ _INTEGER_RE = re.compile(r"^-?\d+$")
47
+
48
+
49
+ class EpsError(Exception):
50
+ """Client-side failure: unknown slug, missing param, bad type, bad config."""
51
+
52
+
53
+ class EpsHttpError(EpsError):
54
+ """Non-2xx response from EPS.
55
+
56
+ The decoded envelope (when the body was JSON) is kept on ``body`` so callers
57
+ can inspect it, but this is raised rather than returned: an auth or infra
58
+ failure must never be mistaken for a successful call.
59
+ """
60
+
61
+ def __init__(self, status: int, url: str, body: Any, raw: bytes) -> None:
62
+ super().__init__(f"EPS request to {url} failed with HTTP {status}.")
63
+ self.status = status
64
+ self.url = url
65
+ self.body = body
66
+ self.raw = raw
67
+
68
+
69
+ def sign_secret_key(access_key: str, timestamp: str) -> str:
70
+ """secret-key = base64(HMAC-SHA256(timestamp, base64(access_key))).
71
+
72
+ The HMAC key is the base64 *string's* bytes — the encoded text, not the
73
+ decoded key. See docs/sdk-golden-vector.md.
74
+ """
75
+ encoded_key = base64.b64encode(access_key.encode("utf-8"))
76
+ digest = hmac.new(encoded_key, timestamp.encode("utf-8"), hashlib.sha256).digest()
77
+ return base64.b64encode(digest).decode("ascii")
78
+
79
+
80
+ def _is_real_number(value: Any) -> bool:
81
+ """True for a number, excluding ``bool`` (a Python ``int`` subclass)."""
82
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
83
+
84
+
85
+ def _is_file_value(value: Any) -> bool:
86
+ """A readable local file path, or an in-memory ``(filename, bytes)`` pair.
87
+
88
+ Paths must exist, matching the PHP SDK: a typo'd path is caught before the
89
+ request is signed rather than at read time.
90
+ """
91
+ if isinstance(value, tuple):
92
+ return (
93
+ len(value) == 2
94
+ and isinstance(value[0], str)
95
+ and isinstance(value[1], (bytes, bytearray))
96
+ )
97
+ if isinstance(value, (str, os.PathLike)):
98
+ try:
99
+ return Path(os.fspath(value)).is_file()
100
+ except (OSError, ValueError):
101
+ return False
102
+ return False
103
+
104
+
105
+ def _matches_type(spec_type: str, value: Any) -> bool:
106
+ """Lenient, coercion-aware type check against a spec type.
107
+
108
+ Only present values are checked (presence is enforced separately). Unknown
109
+ types pass. The wire sends everything as strings, so numeric/boolean strings
110
+ are accepted.
111
+ """
112
+ if spec_type == "string":
113
+ # Strings and numbers (which coerce cleanly); not booleans/objects.
114
+ return isinstance(value, str) or _is_real_number(value)
115
+ if spec_type == "file":
116
+ # A local file path (read by the SDK) or an ``(filename, bytes)`` pair.
117
+ return _is_file_value(value)
118
+ if spec_type == "number":
119
+ if _is_real_number(value):
120
+ return math.isfinite(value)
121
+ return isinstance(value, str) and _NUMBER_RE.match(value) is not None
122
+ if spec_type == "integer":
123
+ if isinstance(value, bool):
124
+ return False
125
+ if isinstance(value, int):
126
+ return True
127
+ # Matches JS `Number.isInteger`: a whole float counts as an integer.
128
+ if isinstance(value, float):
129
+ return math.isfinite(value) and value.is_integer()
130
+ return isinstance(value, str) and _INTEGER_RE.match(value) is not None
131
+ if spec_type == "boolean":
132
+ return isinstance(value, bool) or value in ("true", "false")
133
+ return True # unknown/unsupported spec type -> not enforced
134
+
135
+
136
+ def _to_wire_str(value: Any) -> str:
137
+ """Stringify a value for a URL path token or query param.
138
+
139
+ Matches JavaScript ``String(value)`` so the four SDKs put identical bytes on
140
+ the wire: lowercase booleans, no trailing ``.0`` on whole floats.
141
+ """
142
+ if isinstance(value, bool):
143
+ return "true" if value else "false"
144
+ if value is None:
145
+ return "null"
146
+ if isinstance(value, float) and value.is_integer() and math.isfinite(value):
147
+ return str(int(value))
148
+ return str(value)
149
+
150
+
151
+ def _load_surface() -> Dict[str, Any]:
152
+ """Load the baked ``sdk-surface.json`` asset.
153
+
154
+ Two locations, in order: next to the installed package (the wheel puts it
155
+ there via ``force-include``), then the monorepo's ``data/`` directory for a
156
+ source checkout. A missing file means the package was built incorrectly.
157
+ """
158
+ here = Path(__file__).resolve().parent
159
+ candidates = (
160
+ here / "data" / "sdk-surface.json", # installed wheel
161
+ here.parent.parent / "data" / "sdk-surface.json", # monorepo src layout
162
+ )
163
+ for path in candidates:
164
+ if path.is_file():
165
+ surface = json.loads(path.read_text(encoding="utf-8"))
166
+ if not isinstance(surface, dict) or "environments" not in surface:
167
+ raise EpsError(f"EPS SDK surface at {path} is invalid or corrupt.")
168
+ return surface
169
+ raise EpsError(
170
+ f"EPS SDK surface not found at {candidates[0]}. The package is built "
171
+ "incorrectly (run `npm run build` to bake it)."
172
+ )
173
+
174
+
175
+ _SURFACE = _load_surface()
176
+
177
+
178
+ @dataclass
179
+ class Target:
180
+ """The resolved wire target for one call — everything but the sending."""
181
+
182
+ method: str
183
+ url: str
184
+ body: Optional[bytes]
185
+ headers: Dict[str, str]
186
+ multipart: bool
187
+
188
+
189
+ def _encode_multipart(
190
+ values: Mapping[str, Any], file_params: Iterable[str]
191
+ ) -> Tuple[bytes, str]:
192
+ """Build a multipart/form-data body.
193
+
194
+ One ``form-data`` part holds every non-file value as JSON, followed by a part
195
+ per upload — the order the API documents. ``None`` values are dropped (a form
196
+ field has no null encoding); nulls nested inside a value survive the JSON.
197
+ """
198
+ file_params = set(file_params)
199
+ payload: Dict[str, Any] = {}
200
+ uploads: list[Tuple[str, str, bytes]] = []
201
+ for name, value in values.items():
202
+ if value is None:
203
+ continue
204
+ if name in file_params:
205
+ if isinstance(value, tuple):
206
+ filename, content = value[0], bytes(value[1])
207
+ else:
208
+ path = Path(os.fspath(value))
209
+ filename, content = path.name, path.read_bytes()
210
+ uploads.append((name, filename, content))
211
+ else:
212
+ payload[name] = value
213
+
214
+ boundary = f"----EpsSdkBoundary{secrets.token_hex(16)}"
215
+ crlf = b"\r\n"
216
+ chunks: list[bytes] = []
217
+
218
+ def part_header(header: str) -> None:
219
+ chunks.append(f"--{boundary}".encode() + crlf)
220
+ chunks.append(header.encode() + crlf + crlf)
221
+
222
+ part_header(f'Content-Disposition: form-data; name="{MULTIPART_JSON_FIELD}"')
223
+ chunks.append(_dump_json(payload) + crlf)
224
+ for name, filename, content in uploads:
225
+ mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
226
+ chunks.append(f"--{boundary}".encode() + crlf)
227
+ chunks.append(
228
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode()
229
+ + crlf
230
+ )
231
+ chunks.append(f"Content-Type: {mime}".encode() + crlf + crlf)
232
+ chunks.append(bytes(content) + crlf)
233
+ chunks.append(f"--{boundary}--".encode() + crlf)
234
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
235
+
236
+
237
+ @dataclass
238
+ class EpsClient:
239
+ """Signed EPS API client. Backend-only — never ship ``access_key`` to a browser.
240
+
241
+ ``initiator_id`` / ``user_code`` are near-constant per developer, so they are
242
+ set once here and injected into every call; pass either in a call's ``params``
243
+ to override (including an explicit ``None`` to clear one).
244
+ """
245
+
246
+ developer_key: str
247
+ access_key: str
248
+ environment: str
249
+ initiator_id: Optional[str] = None
250
+ user_code: Optional[str] = None
251
+ timeout: float = _DEFAULT_TIMEOUT
252
+ #: Test-only clock injection (milliseconds since the epoch).
253
+ now: Callable[[], int] = field(
254
+ default_factory=lambda: (lambda: int(time.time() * 1000))
255
+ )
256
+ base_url: str = field(init=False)
257
+
258
+ def __post_init__(self) -> None:
259
+ for env in _SURFACE["environments"]:
260
+ if env["id"] == self.environment:
261
+ self.base_url = env["baseUrl"]
262
+ break
263
+ else:
264
+ raise EpsError(f'Unknown environment "{self.environment}".')
265
+
266
+ def _endpoint(self, slug: str) -> Dict[str, Any]:
267
+ for endpoint in _SURFACE["endpoints"]:
268
+ if endpoint["slug"] == slug:
269
+ return endpoint
270
+ raise EpsError(f'Unknown endpoint slug "{slug}".')
271
+
272
+ def build_headers(self, multipart: bool = False) -> Dict[str, str]:
273
+ """Signed auth headers. Multipart callers get no ``content-type`` here —
274
+ the boundary-carrying value is set when the body is encoded."""
275
+ timestamp = str(self.now())
276
+ headers = {
277
+ "developer_key": self.developer_key,
278
+ "secret-key": sign_secret_key(self.access_key, timestamp),
279
+ "secret-key-timestamp": timestamp,
280
+ }
281
+ if not multipart:
282
+ headers["content-type"] = "application/json"
283
+ return headers
284
+
285
+ def resolve_target(
286
+ self, slug: str, params: Optional[Mapping[str, Any]] = None
287
+ ) -> Target:
288
+ """Resolve a slug + params into the signed wire target.
289
+
290
+ Raises :class:`EpsError` on an unknown slug, a missing required param or a
291
+ type mismatch — before anything is signed or sent.
292
+ """
293
+ endpoint = self._endpoint(slug)
294
+
295
+ # Client-level defaults first; an explicit per-call value wins, including
296
+ # an explicit None that clears one.
297
+ merged: Dict[str, Any] = {}
298
+ if self.initiator_id is not None:
299
+ merged["initiator_id"] = self.initiator_id
300
+ if self.user_code is not None:
301
+ merged["user_code"] = self.user_code
302
+ merged.update(params or {})
303
+
304
+ # Spec-driven guard: every requiredParam must be present and non-null
305
+ # before we sign and send.
306
+ missing = [p for p in endpoint["requiredParams"] if merged.get(p) is None]
307
+ if missing:
308
+ raise EpsError(
309
+ f'Missing required params for "{slug}": {", ".join(missing)}.'
310
+ )
311
+
312
+ # Type guard: every provided param known to the spec must match its type.
313
+ # Unknown params (not in the surface) pass through untouched.
314
+ bad_types = [
315
+ f'{p["name"]} (expected {p["type"]})'
316
+ for p in endpoint["params"]
317
+ if merged.get(p["name"]) is not None
318
+ and not _matches_type(p["type"], merged[p["name"]])
319
+ ]
320
+ if bad_types:
321
+ raise EpsError(
322
+ f'Invalid param types for "{slug}": {", ".join(bad_types)}.'
323
+ )
324
+
325
+ # A `type: "file"` param flips the whole request to multipart/form-data.
326
+ file_params = {p["name"] for p in endpoint["params"] if p["type"] == "file"}
327
+ multipart = bool(file_params)
328
+
329
+ # Path params (e.g. {customer_id}) fill the URL; the rest become the query
330
+ # string on GET, a multipart body when the endpoint has file uploads, or
331
+ # the JSON body on every other method.
332
+ path = endpoint["path"]
333
+ rest: Dict[str, Any] = {}
334
+ for key, value in merged.items():
335
+ token = "{" + key + "}"
336
+ if token in path:
337
+ path = path.replace(token, urllib.parse.quote(_to_wire_str(value), safe=""))
338
+ else:
339
+ rest[key] = value
340
+
341
+ url = f"{self.base_url}{path}"
342
+ headers = self.build_headers(multipart)
343
+ body: Optional[bytes] = None
344
+ if endpoint["method"] == "GET":
345
+ query = urllib.parse.urlencode(
346
+ [(k, _to_wire_str(v)) for k, v in rest.items()]
347
+ )
348
+ if query:
349
+ url += ("&" if "?" in url else "?") + query
350
+ elif multipart:
351
+ body, content_type = _encode_multipart(rest, file_params)
352
+ headers["content-type"] = content_type
353
+ else:
354
+ body = _dump_json(rest)
355
+
356
+ return Target(
357
+ method=endpoint["method"],
358
+ url=url,
359
+ body=body,
360
+ headers=headers,
361
+ multipart=multipart,
362
+ )
363
+
364
+ def call(self, slug: str, params: Optional[Mapping[str, Any]] = None) -> Any:
365
+ """Sign and send one endpoint call, returning the decoded response envelope.
366
+
367
+ Raises :class:`EpsError` on invalid input, :class:`EpsHttpError` on a
368
+ non-2xx response, and :class:`urllib.error.URLError` on a transport
369
+ failure. A body that is not JSON is an error, never a silent ``{}``.
370
+ """
371
+ target = self.resolve_target(slug, params)
372
+ request = urllib.request.Request(
373
+ target.url, data=target.body, method=target.method
374
+ )
375
+ for name, value in target.headers.items():
376
+ request.add_header(name, value)
377
+ try:
378
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
379
+ raw = response.read()
380
+ status = response.status
381
+ except urllib.error.HTTPError as exc: # non-2xx: body still worth decoding
382
+ raw = exc.read()
383
+ raise EpsHttpError(
384
+ exc.code, target.url, _decode_json_or_none(raw), raw
385
+ ) from exc
386
+ if not 200 <= status < 300:
387
+ raise EpsHttpError(status, target.url, _decode_json_or_none(raw), raw)
388
+ try:
389
+ return json.loads(raw)
390
+ except ValueError as exc:
391
+ raise EpsError(
392
+ f"EPS response from {target.url} was not valid JSON: {raw[:200]!r}"
393
+ ) from exc
394
+
395
+
396
+ def _dump_json(payload: Mapping[str, Any]) -> bytes:
397
+ """Compact JSON, matching ``JSON.stringify`` byte for byte.
398
+
399
+ A value the encoder cannot handle raises here rather than silently blanking
400
+ the payload — the request would otherwise fail far from its cause.
401
+ """
402
+ try:
403
+ return json.dumps(payload, separators=(",", ":")).encode("utf-8")
404
+ except (TypeError, ValueError) as exc:
405
+ raise EpsError(f"Params could not be JSON-encoded: {exc}") from exc
406
+
407
+
408
+ def _decode_json_or_none(raw: bytes) -> Any:
409
+ try:
410
+ return json.loads(raw)
411
+ except ValueError:
412
+ return None