heleket-sdk 0.3.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.
@@ -0,0 +1,72 @@
1
+ """Heleket Python SDK.
2
+
3
+ A reference implementation of the `Heleket <https://heleket.com>`_
4
+ cryptocurrency payment API. Covers payments, payouts, balance, services,
5
+ exchange rates, and webhook verification.
6
+
7
+ Public entry points::
8
+
9
+ from heleket_sdk import HeleketPayment, HeleketPayout
10
+ from heleket_sdk import ClientOptions
11
+ from heleket_sdk import WebhookVerifier
12
+ from heleket_sdk import PaymentStatus, PayoutStatus
13
+ from heleket_sdk import HeleketError, ApiError, ValidationError, HttpError, SignatureError
14
+
15
+ See ``docs/`` for the full reference.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from ._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, ClientOptions
21
+ from ._debug import DebugEntry, DebugSink, stderr_sink
22
+ from ._errors import (
23
+ ApiError,
24
+ HeleketError,
25
+ HttpError,
26
+ SignatureError,
27
+ ValidationError,
28
+ )
29
+ from ._signature import constant_time_equal, sign
30
+ from ._status import AmlLinkStatus, CourseSource, PaymentStatus, PayoutStatus
31
+ from ._transport import RequestsTransport, Transport, TransportResponse
32
+ from ._version import __version__
33
+ from .payment import HeleketPayment
34
+ from .payout import HeleketPayout
35
+ from .webhook import WebhookPayload, WebhookVerifier
36
+
37
+ __all__ = [
38
+ # Clients
39
+ "HeleketPayment",
40
+ "HeleketPayout",
41
+ # Configuration
42
+ "ClientOptions",
43
+ "DEFAULT_BASE_URL",
44
+ "DEFAULT_TIMEOUT_SECONDS",
45
+ # Webhook
46
+ "WebhookVerifier",
47
+ "WebhookPayload",
48
+ # Enums
49
+ "PaymentStatus",
50
+ "PayoutStatus",
51
+ "AmlLinkStatus",
52
+ "CourseSource",
53
+ # Errors
54
+ "HeleketError",
55
+ "ApiError",
56
+ "ValidationError",
57
+ "HttpError",
58
+ "SignatureError",
59
+ # Transport
60
+ "Transport",
61
+ "TransportResponse",
62
+ "RequestsTransport",
63
+ # Debug
64
+ "DebugEntry",
65
+ "DebugSink",
66
+ "stderr_sink",
67
+ # Low-level signature
68
+ "sign",
69
+ "constant_time_equal",
70
+ # Metadata
71
+ "__version__",
72
+ ]
@@ -0,0 +1,157 @@
1
+ """Shared HTTP plumbing for :class:`HeleketPayment` and :class:`HeleketPayout`.
2
+
3
+ Encodes the request, signs it, dispatches via the configured ``Transport``,
4
+ dumps to the debug sink when enabled, and translates the response into either
5
+ a typed result or an exception.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from ._config import ClientOptions
16
+ from ._debug import DebugEntry
17
+ from ._errors import ApiError, HttpError, ValidationError
18
+ from ._signature import sign
19
+ from ._transport import RequestsTransport, Transport
20
+ from ._version import __version__
21
+
22
+ __all__ = ["BaseClient"]
23
+
24
+ _USER_AGENT = f"heleket-python-sdk/{__version__}"
25
+
26
+
27
+ class BaseClient:
28
+ """Common base for the payment and payout clients.
29
+
30
+ Carries the merchant UUID, API key, and the resolved :class:`ClientOptions`.
31
+ Subclasses call :meth:`_post` with the path and request body.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ merchant_id: str,
38
+ api_key: str,
39
+ options: ClientOptions | None = None,
40
+ ) -> None:
41
+ if not merchant_id or not merchant_id.strip():
42
+ raise ValueError("merchant_id must not be empty")
43
+ if not api_key or not api_key.strip():
44
+ raise ValueError("api_key must not be empty")
45
+
46
+ self._merchant_id = merchant_id.strip()
47
+ self._api_key = api_key.strip()
48
+ self._options = options if options is not None else ClientOptions()
49
+ self._transport: Transport = self._options.transport or RequestsTransport()
50
+
51
+ # ------------------------------------------------------------------ #
52
+ # internal helpers
53
+ # ------------------------------------------------------------------ #
54
+
55
+ def _post(self, path: str, params: Any = None) -> Any:
56
+ """Send a signed POST request and return the decoded ``result`` field."""
57
+ return self._send("POST", path, self._encode_body(params))
58
+
59
+ def _get(self, path: str) -> Any:
60
+ """Send a signed GET request and return the decoded ``result`` field.
61
+
62
+ For read-only endpoints that carry all their input in the path (e.g.
63
+ exchange rates). There is no request body, so the signed bytes are the
64
+ empty string — exactly like a POST with no params: ``sign = md5(api_key)``.
65
+ """
66
+ return self._send("GET", path, "")
67
+
68
+ def _send(self, method: str, path: str, body: str) -> Any:
69
+ """Sign the exact body bytes, attach the auth headers, dispatch via the
70
+ transport, and translate the response.
71
+
72
+ Shared by :meth:`_post` and :meth:`_get` so the signing and error
73
+ handling live in exactly one place.
74
+ """
75
+ signature = sign(body, self._api_key)
76
+ url = self._options.base_url + path
77
+
78
+ headers = {
79
+ "merchant": self._merchant_id,
80
+ "sign": signature,
81
+ "Content-Type": "application/json",
82
+ "User-Agent": _USER_AGENT,
83
+ }
84
+
85
+ if self._options.debug:
86
+ self._options.logger(DebugEntry.request(method, url, body))
87
+
88
+ try:
89
+ response = self._transport.round_trip(method, url, headers, body, self._options.timeout)
90
+ except HttpError:
91
+ raise
92
+ except Exception as exc: # noqa: BLE001 — wrap unexpected failures
93
+ raise HttpError(f"transport failed: {exc}") from exc
94
+
95
+ if self._options.debug:
96
+ self._options.logger(DebugEntry.response(response.status_code, response.body))
97
+
98
+ return self._parse_response(response.status_code, response.body)
99
+
100
+ @staticmethod
101
+ def _encode_body(params: Any) -> str:
102
+ """Encode params to JSON, returning ``""`` for null/empty payloads."""
103
+ if params is None:
104
+ return ""
105
+ if isinstance(params, BaseModel):
106
+ # by_alias=True so snake_case wire names beat the Pydantic field
107
+ # names; exclude_none=True so optional fields don't serialise.
108
+ data = params.model_dump(by_alias=True, exclude_none=True)
109
+ elif isinstance(params, dict):
110
+ data = {k: v for k, v in params.items() if v is not None}
111
+ else:
112
+ # Last-ditch — let json.dumps try.
113
+ data = params
114
+
115
+ if not data and not isinstance(data, int | float | bool):
116
+ return ""
117
+ return json.dumps(data, separators=(",", ":"))
118
+
119
+ @staticmethod
120
+ def _parse_response(status_code: int, body: str) -> Any:
121
+ envelope: dict[str, Any] | None
122
+ if body:
123
+ try:
124
+ envelope = json.loads(body)
125
+ if not isinstance(envelope, dict):
126
+ envelope = None
127
+ except json.JSONDecodeError:
128
+ envelope = None
129
+ else:
130
+ envelope = None
131
+
132
+ if status_code == 422:
133
+ fields: dict[str, list[str]] = {}
134
+ if envelope and isinstance(envelope.get("errors"), dict):
135
+ for key, value in envelope["errors"].items():
136
+ if isinstance(value, list):
137
+ fields[key] = [str(v) for v in value]
138
+ else:
139
+ fields[key] = [str(value)]
140
+ summary = _summarise_fields(fields)
141
+ raise ValidationError(f"validation failed: {summary}", status_code, fields, body)
142
+
143
+ if envelope is None:
144
+ raise ApiError(f"non-JSON response (HTTP {status_code})", status_code, body)
145
+
146
+ state = envelope.get("state")
147
+ if state != 0:
148
+ message = envelope.get("message", f"api error (state={state})")
149
+ raise ApiError(str(message), status_code, body)
150
+
151
+ return envelope.get("result")
152
+
153
+
154
+ def _summarise_fields(fields: dict[str, list[str]]) -> str:
155
+ if not fields:
156
+ return "(no field details)"
157
+ return "; ".join(f"{k} ({', '.join(v)})" for k, v in fields.items())
heleket_sdk/_cli.py ADDED
@@ -0,0 +1,137 @@
1
+ """``heleket-webhook-inspect`` — verify a captured webhook payload.
2
+
3
+ Usage::
4
+
5
+ # Preferred: pass the API key via env var so it doesn't land in shell
6
+ # history or appear in `ps` output.
7
+ export HELEKET_API_KEY=$HELEKET_PAYMENT_KEY
8
+ echo '{"type":"payment",...}' | heleket-webhook-inspect
9
+
10
+ # Or pass it explicitly (less safe — recorded in shell history).
11
+ heleket-webhook-inspect --key=$KEY --file=webhook.json
12
+ heleket-webhook-inspect --key=$KEY --type=payout --file=webhook.json
13
+
14
+ Exit codes:
15
+
16
+ * ``0`` — payload valid, signature verified
17
+ * ``1`` — payload could not be parsed
18
+ * ``2`` — signature does not match
19
+ * ``3`` — missing arguments
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from ._errors import SignatureError
32
+ from .webhook import WebhookVerifier
33
+
34
+ _COLOR_RESET = "\x1b[0m"
35
+ _COLOR_RED = "\x1b[31m"
36
+ _COLOR_GREEN = "\x1b[32m"
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ parser = argparse.ArgumentParser(
41
+ prog="heleket-webhook-inspect",
42
+ description="Verify a captured Heleket webhook payload.",
43
+ )
44
+ parser.add_argument(
45
+ "--key",
46
+ required=False,
47
+ help=(
48
+ "API key used to sign the webhook. Falls back to the HELEKET_API_KEY "
49
+ "env var if omitted (preferred — keeps the key out of shell history)."
50
+ ),
51
+ )
52
+ parser.add_argument("--file", help="Path to a webhook JSON file. Defaults to stdin.")
53
+ parser.add_argument("--type", help="Expected payload type (payment | payout | wallet)")
54
+ args = parser.parse_args(argv)
55
+
56
+ api_key = args.key or os.environ.get("HELEKET_API_KEY", "")
57
+ if not api_key:
58
+ sys.stderr.write(
59
+ "Usage: heleket-webhook-inspect [--key=<api-key>] "
60
+ "[--file=<path>] [--type=payment|payout]\n"
61
+ "Set HELEKET_API_KEY in the environment to avoid passing the key on "
62
+ "the command line.\n"
63
+ )
64
+ return 3
65
+
66
+ body = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
67
+
68
+ # Trim trailing newline added by shells / editors — Heleket signs the exact
69
+ # bytes it sends, which never have a trailing newline.
70
+ body = body.rstrip("\r\n")
71
+
72
+ if not body:
73
+ sys.stderr.write("No input received. Pipe a JSON payload to stdin or pass --file=<path>.\n")
74
+ return 1
75
+
76
+ try:
77
+ parsed: dict[str, Any] = json.loads(body)
78
+ except json.JSONDecodeError as error:
79
+ sys.stderr.write(f"Input is not valid JSON: {error}\n")
80
+ return 1
81
+
82
+ print("Heleket webhook inspector")
83
+ print("-" * 40)
84
+ for field in ("type", "uuid", "order_id", "status", "amount", "network", "txid"):
85
+ _print_field(field, parsed.get(field))
86
+
87
+ if "is_final" in parsed:
88
+ value = parsed["is_final"]
89
+ text = "yes" if value is True else "no" if value is False else str(value)
90
+ print(f" {'is_final':<10} {text}")
91
+ else:
92
+ print(f" {'is_final':<10} (missing)")
93
+
94
+ sign_value = parsed.get("sign")
95
+ if isinstance(sign_value, str):
96
+ truncated = sign_value if len(sign_value) <= 16 else sign_value[:16] + "…"
97
+ print(f" {'sign':<10} {truncated}")
98
+ else:
99
+ print(f" {'sign':<10} (missing)")
100
+
101
+ if args.type and parsed.get("type") and args.type != parsed["type"]:
102
+ sys.stderr.write(f"\nWarning: --type={args.type} but payload type is {parsed['type']}\n")
103
+
104
+ try:
105
+ WebhookVerifier(api_key).verify_raw(body)
106
+ except SignatureError as error:
107
+ print()
108
+ print(f"signature: {_COLOR_RED}INVALID{_COLOR_RESET} — {error.reason}")
109
+ print("Hints:")
110
+ print(" - Are you using the same API key that was configured when the webhook was sent?")
111
+ print(
112
+ " - Payment webhooks sign with the payment key; "
113
+ "payout webhooks sign with the payout key."
114
+ )
115
+ print(
116
+ " - If you re-serialised the payload before passing it here, "
117
+ "the slash escaping may have been lost."
118
+ )
119
+ return 2
120
+
121
+ print()
122
+ print(f"signature: {_COLOR_GREEN}valid{_COLOR_RESET}")
123
+ return 0
124
+
125
+
126
+ def _print_field(name: str, value: Any) -> None:
127
+ label = f" {name:<10}"
128
+ if value is None:
129
+ print(f"{label} (missing)")
130
+ elif isinstance(value, str):
131
+ print(f"{label} {value}")
132
+ else:
133
+ print(f"{label} {json.dumps(value)}")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ sys.exit(main())
heleket_sdk/_config.py ADDED
@@ -0,0 +1,43 @@
1
+ """Client options for :class:`HeleketPayment` / :class:`HeleketPayout`.
2
+
3
+ :class:`ClientOptions` is a plain dataclass — not a Pydantic model — because
4
+ it can hold non-serialisable values like a ``DebugSink`` callable or a
5
+ custom transport.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ._debug import DebugSink, stderr_sink
14
+
15
+ if TYPE_CHECKING:
16
+ from ._transport import Transport
17
+
18
+ __all__ = ["ClientOptions", "DEFAULT_BASE_URL", "DEFAULT_TIMEOUT_SECONDS"]
19
+
20
+ DEFAULT_BASE_URL = "https://api.heleket.com"
21
+ DEFAULT_TIMEOUT_SECONDS = 30.0
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class ClientOptions:
26
+ """Optional settings passed to ``HeleketPayment`` / ``HeleketPayout``.
27
+
28
+ ``transport`` defaults to a fresh :class:`RequestsTransport` lazily, so
29
+ constructing ``ClientOptions()`` doesn't open a TCP socket pool.
30
+ """
31
+
32
+ base_url: str = DEFAULT_BASE_URL
33
+ timeout: float = DEFAULT_TIMEOUT_SECONDS
34
+ debug: bool = False
35
+ logger: DebugSink = field(default=stderr_sink)
36
+ transport: Transport | None = None
37
+
38
+ def __post_init__(self) -> None:
39
+ # Normalise the base URL (drop trailing slashes) so callers can pass
40
+ # either form interchangeably.
41
+ self.base_url = self.base_url.rstrip("/")
42
+ if self.timeout <= 0:
43
+ raise ValueError("timeout must be > 0")
heleket_sdk/_debug.py ADDED
@@ -0,0 +1,63 @@
1
+ """Debug logging for the Heleket SDK.
2
+
3
+ A :class:`DebugSink` receives :class:`DebugEntry` objects when the client is
4
+ constructed with ``debug=True``. The default sink writes one line per entry
5
+ to ``stderr``; plug in your own to route to ``logging``, ``structlog``, or
6
+ anywhere else.
7
+
8
+ **Security:** entries never carry the API key or the ``sign`` header.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from typing import Literal
17
+
18
+ __all__ = ["DebugEntry", "DebugSink", "stderr_sink"]
19
+
20
+ EntryKind = Literal["request", "response", "error"]
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class DebugEntry:
25
+ """A single debug event.
26
+
27
+ Use the ``request``/``response``/``error`` classmethods rather than the
28
+ constructor when calling from your own code.
29
+ """
30
+
31
+ kind: EntryKind
32
+ method: str | None = None
33
+ url: str | None = None
34
+ body: str | None = None
35
+ status: int | None = None
36
+ message: str | None = None
37
+
38
+ @classmethod
39
+ def request(cls, method: str, url: str, body: str) -> DebugEntry:
40
+ return cls(kind="request", method=method, url=url, body=body)
41
+
42
+ @classmethod
43
+ def response(cls, status: int, body: str) -> DebugEntry:
44
+ return cls(kind="response", status=status, body=body)
45
+
46
+ @classmethod
47
+ def error(cls, message: str) -> DebugEntry:
48
+ return cls(kind="error", message=message)
49
+
50
+
51
+ DebugSink = Callable[[DebugEntry], None]
52
+
53
+
54
+ def stderr_sink(entry: DebugEntry) -> None:
55
+ """Default :class:`DebugSink`. Writes a one-line summary per entry to ``stderr``."""
56
+ if entry.kind == "request":
57
+ body = entry.body if entry.body else "(empty body)"
58
+ print(f"[heleket][→ {entry.method} {entry.url}] {body}", file=sys.stderr)
59
+ elif entry.kind == "response":
60
+ body = entry.body if entry.body else "(empty body)"
61
+ print(f"[heleket][← {entry.status}] {body}", file=sys.stderr)
62
+ elif entry.kind == "error":
63
+ print(f"[heleket][error] {entry.message}", file=sys.stderr)
heleket_sdk/_errors.py ADDED
@@ -0,0 +1,67 @@
1
+ """Exception hierarchy for the Heleket SDK.
2
+
3
+ All exceptions extend :class:`HeleketError`, which is a plain ``Exception`` —
4
+ they're unchecked in the Python sense. Catch the specific subclass you care
5
+ about, or :class:`HeleketError` to handle the SDK's whole surface uniformly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = [
11
+ "HeleketError",
12
+ "ApiError",
13
+ "ValidationError",
14
+ "HttpError",
15
+ "SignatureError",
16
+ ]
17
+
18
+
19
+ class HeleketError(Exception):
20
+ """Base class for every exception raised by the SDK."""
21
+
22
+
23
+ class ApiError(HeleketError):
24
+ """Raised when Heleket responds with ``state != 0`` (business error).
25
+
26
+ Carries the HTTP status and raw response body so callers can log or
27
+ surface the exact server reply.
28
+ """
29
+
30
+ def __init__(self, message: str, http_status: int = 0, raw_body: str = "") -> None:
31
+ super().__init__(message)
32
+ self.http_status = http_status
33
+ self.raw_body = raw_body
34
+
35
+
36
+ class ValidationError(ApiError):
37
+ """Raised on HTTP 422. Carries a ``fields`` dict mapping field → messages."""
38
+
39
+ def __init__(
40
+ self,
41
+ message: str,
42
+ http_status: int,
43
+ fields: dict[str, list[str]],
44
+ raw_body: str = "",
45
+ ) -> None:
46
+ super().__init__(message, http_status, raw_body)
47
+ self.fields = fields
48
+
49
+
50
+ class HttpError(HeleketError):
51
+ """Raised when the HTTP transport itself fails.
52
+
53
+ DNS, TCP, TLS, timeout, broken pipe. The API was never reached, or no
54
+ response was received.
55
+ """
56
+
57
+
58
+ class SignatureError(HeleketError):
59
+ """Raised by :class:`WebhookVerifier` when the signature does not match.
60
+
61
+ Treat as a hard security failure — never trust the payload contents in
62
+ this case.
63
+ """
64
+
65
+ def __init__(self, reason: str) -> None:
66
+ super().__init__(f"heleket signature error: {reason}")
67
+ self.reason = reason
@@ -0,0 +1,46 @@
1
+ """Heleket request and webhook signature.
2
+
3
+ Formula (per https://doc.heleket.com/general/request-format):
4
+
5
+ sign = md5( base64(json_body) + api_key )
6
+
7
+ For requests with no parameters, pass an empty bytes object — base64-encoding it
8
+ yields the empty string, so the hash collapses to ``md5(api_key)``.
9
+
10
+ The body argument MUST be the exact bytes that will be sent over the wire.
11
+ The same routine is used to produce outgoing request signatures and to verify
12
+ incoming webhook signatures.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import hmac
20
+
21
+ __all__ = ["sign", "constant_time_equal"]
22
+
23
+
24
+ def sign(body: bytes | str, api_key: str) -> str:
25
+ """Compute the Heleket signature for the given body and API key.
26
+
27
+ Both ``bytes`` and ``str`` bodies are accepted. Strings are UTF-8 encoded.
28
+ """
29
+ if isinstance(body, str):
30
+ body = body.encode("utf-8")
31
+ encoded = base64.b64encode(body).decode("ascii")
32
+ return hashlib.md5((encoded + api_key).encode("utf-8")).hexdigest() # noqa: S324
33
+
34
+
35
+ def constant_time_equal(expected: str | None, actual: str | None) -> bool:
36
+ """Constant-time equality check for hex signatures.
37
+
38
+ Returns ``False`` if either side is ``None`` or the strings differ in
39
+ length. Use this in webhook verification — equality with ``==`` would
40
+ leak timing information.
41
+ """
42
+ if expected is None or actual is None:
43
+ return False
44
+ if len(expected) != len(actual):
45
+ return False
46
+ return hmac.compare_digest(expected, actual)