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 +33 -0
- eps_sdk/client.py +412 -0
- eps_sdk/data/sdk-surface.json +3924 -0
- eps_sdk-0.1.0.dist-info/METADATA +92 -0
- eps_sdk-0.1.0.dist-info/RECORD +7 -0
- eps_sdk-0.1.0.dist-info/WHEEL +4 -0
- eps_sdk-0.1.0.dist-info/licenses/LICENSE +31 -0
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
|