bitgen-sdk 1.0.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.
- bitgen/__init__.py +18 -0
- bitgen/_http/__init__.py +1 -0
- bitgen/_http/base_url.py +42 -0
- bitgen/_http/client.py +131 -0
- bitgen/_http/timeout.py +21 -0
- bitgen/_http/transport.py +113 -0
- bitgen/_support/__init__.py +1 -0
- bitgen/_support/amount.py +42 -0
- bitgen/_support/asset_id.py +18 -0
- bitgen/_support/constants.py +19 -0
- bitgen/_support/path.py +17 -0
- bitgen/_support/reference.py +19 -0
- bitgen/_support/user_id.py +26 -0
- bitgen/_support/values.py +55 -0
- bitgen/client.py +109 -0
- bitgen/constants.py +36 -0
- bitgen/errors.py +35 -0
- bitgen/models/__init__.py +197 -0
- bitgen/models/_cast.py +161 -0
- bitgen/models/apikeys.py +136 -0
- bitgen/models/asset.py +205 -0
- bitgen/models/bank.py +104 -0
- bitgen/models/core.py +122 -0
- bitgen/models/custody.py +148 -0
- bitgen/models/customer.py +598 -0
- bitgen/models/history.py +44 -0
- bitgen/models/organization.py +42 -0
- bitgen/models/staking.py +229 -0
- bitgen/models/trading.py +137 -0
- bitgen/models/transaction.py +165 -0
- bitgen/models/webhooks.py +237 -0
- bitgen/page.py +42 -0
- bitgen/py.typed +0 -0
- bitgen/resources/__init__.py +27 -0
- bitgen/resources/apikeys.py +50 -0
- bitgen/resources/asset.py +37 -0
- bitgen/resources/bank.py +103 -0
- bitgen/resources/core.py +42 -0
- bitgen/resources/custody.py +86 -0
- bitgen/resources/customer.py +133 -0
- bitgen/resources/staking.py +129 -0
- bitgen/resources/trading.py +100 -0
- bitgen/resources/transaction.py +50 -0
- bitgen/resources/webhooks.py +237 -0
- bitgen/version.py +4 -0
- bitgen_sdk-1.0.0.dist-info/METADATA +66 -0
- bitgen_sdk-1.0.0.dist-info/RECORD +48 -0
- bitgen_sdk-1.0.0.dist-info/WHEEL +4 -0
bitgen/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Official Python SDK for the BITGEN API v4.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from bitgen import BitgenClient, Env
|
|
5
|
+
|
|
6
|
+
client = BitgenClient(scope="YOUR_SCOPE_UUID", apiKey="YOUR_API_KEY", env=Env.SANDBOX)
|
|
7
|
+
```
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from bitgen.client import BitgenClient
|
|
11
|
+
from bitgen.constants import Asset, Env
|
|
12
|
+
from bitgen.errors import BitgenError, UnexpectedAnswerError
|
|
13
|
+
from bitgen.page import Page
|
|
14
|
+
from bitgen.version import VERSION
|
|
15
|
+
|
|
16
|
+
__version__ = VERSION
|
|
17
|
+
|
|
18
|
+
__all__ = ["VERSION", "Asset", "BitgenClient", "BitgenError", "Env", "Page", "UnexpectedAnswerError"]
|
bitgen/_http/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The HTTP layer of the SDK — internal."""
|
bitgen/_http/base_url.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The base URL of the API for a configuration: `scheme://host[:port]`, no trailing slash, no version prefix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from bitgen._support import values
|
|
8
|
+
from bitgen.constants import Env
|
|
9
|
+
|
|
10
|
+
LOCALHOST_PORT = 3002
|
|
11
|
+
|
|
12
|
+
_HOSTS = {
|
|
13
|
+
Env.PRODUCTION: "https://api.bitgen.com",
|
|
14
|
+
Env.SANDBOX: "https://api.sandbox.bitgen.com",
|
|
15
|
+
Env.STAGING: "https://api.staging.btgn.dev",
|
|
16
|
+
}
|
|
17
|
+
# Letters, digits, dots, hyphens (and underscores of internal DNS names): a scheme, a port, a path, userinfo or a
|
|
18
|
+
# bracketed IPv6 are refused
|
|
19
|
+
_BARE_HOSTNAME = re.compile(r"[A-Za-z0-9._-]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve(env: object, host: object, port: object, isSsl: object) -> str:
|
|
23
|
+
"""`ValueError` when `env` is unknown, `host` is not a bare hostname or `port` is out of range; `TypeError` on a
|
|
24
|
+
wrong type. The values of `env` and `host` are never echoed."""
|
|
25
|
+
environment = values.ensure(env, Env.VALUES, "env")
|
|
26
|
+
if port is not None:
|
|
27
|
+
if isinstance(port, bool) or not isinstance(port, int):
|
|
28
|
+
raise TypeError("port must be an integer between 1 and 65535")
|
|
29
|
+
if port < 1 or port > 65535:
|
|
30
|
+
raise ValueError("port must be an integer between 1 and 65535")
|
|
31
|
+
if not isinstance(isSsl, bool):
|
|
32
|
+
raise TypeError("isSsl must be a boolean")
|
|
33
|
+
# Custom host (a container, a tunnel): bare hostname, scheme and port come from isSsl / port
|
|
34
|
+
if host is not None:
|
|
35
|
+
if not isinstance(host, str):
|
|
36
|
+
raise TypeError("host must be a string: a bare hostname (no scheme, port or path)")
|
|
37
|
+
if not _BARE_HOSTNAME.fullmatch(host):
|
|
38
|
+
raise ValueError("host must be a bare hostname (no scheme, port or path): use port and isSsl")
|
|
39
|
+
return f"{'https' if isSsl else 'http'}://{host}:{port if port is not None else 80}"
|
|
40
|
+
if environment == Env.LOCALHOST:
|
|
41
|
+
return f"http://localhost:{port if port is not None else LOCALHOST_PORT}"
|
|
42
|
+
return _HOSTS[environment]
|
bitgen/_http/client.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The HTTP layer of the SDK: URL and query building, headers, JSON bodies, and the mapping of every answer to a decoded
|
|
2
|
+
value or a `BitgenError`. The wire itself is the Transport."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from bitgen._http.transport import Response, Transport, TransportError
|
|
12
|
+
from bitgen.errors import BitgenError
|
|
13
|
+
|
|
14
|
+
_REDACTABLE_KEY = 8
|
|
15
|
+
"""Shortest key that is redacted from an error — a real key is far longer, a shorter one is a placeholder"""
|
|
16
|
+
|
|
17
|
+
Query = Mapping[str, str | int | float | bool | None]
|
|
18
|
+
"""Query string entries — `None` entries are skipped, booleans travel as `true` / `false`"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpClient:
|
|
22
|
+
"""`scope` is the organization uuid of the key — resources use it where the API expects the organization."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, transport: Transport, scope: str, apiKey: str, baseUrl: str, timeout: float, userAgent: str
|
|
26
|
+
) -> None:
|
|
27
|
+
self._transport = transport
|
|
28
|
+
self.scope = scope
|
|
29
|
+
self._apiKey = apiKey
|
|
30
|
+
self._baseUrl = baseUrl
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
self._headers = {
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"Accept": "application/json",
|
|
35
|
+
"User-Agent": userAgent,
|
|
36
|
+
"BITGEN-Scope": scope,
|
|
37
|
+
"Api-key": apiKey,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def get(self, path: str, query: Query | None = None) -> Any:
|
|
41
|
+
return self.request("GET", path, query)
|
|
42
|
+
|
|
43
|
+
def post(self, path: str, body: object = None) -> Any:
|
|
44
|
+
return self.request("POST", path, None, body)
|
|
45
|
+
|
|
46
|
+
def put(self, path: str, body: object = None) -> Any:
|
|
47
|
+
return self.request("PUT", path, None, body)
|
|
48
|
+
|
|
49
|
+
def patch(self, path: str, body: object = None) -> Any:
|
|
50
|
+
return self.request("PATCH", path, None, body)
|
|
51
|
+
|
|
52
|
+
def delete(self, path: str, body: object = None) -> Any:
|
|
53
|
+
return self.request("DELETE", path, None, body)
|
|
54
|
+
|
|
55
|
+
def request(self, method: str, path: str, query: Query | None = None, body: object = None) -> Any:
|
|
56
|
+
"""Sends the request and returns the decoded JSON answer — `None` on `204` or an empty body.
|
|
57
|
+
Anything outside 2xx, a 2xx that is not JSON, or no HTTP answer at all → `BitgenError`."""
|
|
58
|
+
url = self._baseUrl + path + _query_string(query)
|
|
59
|
+
encoded = None if body is None else _encode(body)
|
|
60
|
+
try:
|
|
61
|
+
response = self._transport.send(method, url, self._headers, encoded, self._timeout)
|
|
62
|
+
except TransportError as error:
|
|
63
|
+
raise BitgenError(0, error.code) from error
|
|
64
|
+
|
|
65
|
+
status = response.status
|
|
66
|
+
text = "" if status == 204 else response.body.decode("utf-8", errors="replace")
|
|
67
|
+
# v4 sends the real HTTP status: anything outside 2xx is an error, a redirect included
|
|
68
|
+
if status < 200 or status > 299:
|
|
69
|
+
code = _error_code(text)
|
|
70
|
+
raise BitgenError(status, self._redact(code if code is not None else _raw_body(text, response)))
|
|
71
|
+
if text.strip() == "":
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return _decode(text)
|
|
75
|
+
except ValueError as error:
|
|
76
|
+
# A 2xx that is not JSON is not an answer of the API (proxy page…): report it as an error
|
|
77
|
+
raise BitgenError(status, self._redact(_raw_body(text, response))) from error
|
|
78
|
+
|
|
79
|
+
def _redact(self, code: str) -> str:
|
|
80
|
+
"""A raw body that echoes the request (a debugging proxy page…) must not put the key in the error. A key shorter
|
|
81
|
+
than a credential can be (a placeholder of a test) is left alone: replacing its every occurrence would garble
|
|
82
|
+
the codes of the API (`unknown_asset` with the key `k`)."""
|
|
83
|
+
return code.replace(self._apiKey, "[redacted]") if len(self._apiKey) >= _REDACTABLE_KEY else code
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _query_string(query: Query | None) -> str:
|
|
87
|
+
if not query:
|
|
88
|
+
return ""
|
|
89
|
+
pairs: list[str] = []
|
|
90
|
+
for key, value in query.items():
|
|
91
|
+
if value is None:
|
|
92
|
+
continue
|
|
93
|
+
text = ("true" if value else "false") if isinstance(value, bool) else str(value)
|
|
94
|
+
pairs.append(f"{quote(key, safe='')}={quote(text, safe='')}")
|
|
95
|
+
return "?" + "&".join(pairs) if pairs else ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _encode(body: object) -> bytes:
|
|
99
|
+
"""Compact JSON, unicode and slashes kept as they are; `NaN` / `Infinity` are not JSON and are refused"""
|
|
100
|
+
return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _decode(text: str) -> Any:
|
|
104
|
+
"""Strict JSON: `NaN` / `Infinity`, which Python would accept, are not JSON"""
|
|
105
|
+
return json.loads(text, parse_constant=_not_json)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _not_json(constant: str) -> Any:
|
|
109
|
+
raise ValueError(f"{constant} is not JSON")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _error_code(text: str) -> str | None:
|
|
113
|
+
"""The stable `message` of an API error body `{ error, message, code }`, or `None` when the body is not one"""
|
|
114
|
+
try:
|
|
115
|
+
decoded = _decode(text)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return None
|
|
118
|
+
if not isinstance(decoded, dict):
|
|
119
|
+
return None
|
|
120
|
+
message = decoded.get("message")
|
|
121
|
+
if not isinstance(message, str) or message == "":
|
|
122
|
+
return None
|
|
123
|
+
return message
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _raw_body(text: str, response: Response) -> str:
|
|
127
|
+
"""Raw body, or the reason phrase, or the status as text — never empty"""
|
|
128
|
+
trimmed = text.strip()
|
|
129
|
+
if trimmed != "":
|
|
130
|
+
return trimmed
|
|
131
|
+
return response.reason if response.reason != "" else str(response.status)
|
bitgen/_http/timeout.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The request timeout: seconds in the configuration, validated once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
DEFAULT = 30
|
|
8
|
+
"""Default, in seconds"""
|
|
9
|
+
MAX = 2_147_483
|
|
10
|
+
"""Largest value, in seconds — the same bound as the other BITGEN SDKs (a 32-bit number of milliseconds)"""
|
|
11
|
+
|
|
12
|
+
_MESSAGE = f"timeout must be a number of seconds between 0 (no timeout) and {MAX}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve(seconds: object) -> float:
|
|
16
|
+
"""`0` = no timeout. `ValueError` when not a finite number between 0 and 2147483, `TypeError` when not a number."""
|
|
17
|
+
if isinstance(seconds, bool) or not isinstance(seconds, int | float):
|
|
18
|
+
raise TypeError(_MESSAGE)
|
|
19
|
+
if not math.isfinite(seconds) or seconds < 0 or seconds > MAX:
|
|
20
|
+
raise ValueError(_MESSAGE)
|
|
21
|
+
return float(seconds)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The wire: one HTTP request, one raw response. TLS verified, redirects never followed, one deadline for the whole
|
|
2
|
+
request."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import http.client
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Response:
|
|
18
|
+
"""What a Transport got back: the status, the reason phrase of the status line (may be empty) and the raw body."""
|
|
19
|
+
|
|
20
|
+
status: int
|
|
21
|
+
reason: str
|
|
22
|
+
body: bytes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransportError(Exception):
|
|
26
|
+
"""No HTTP response at all. `code` is `request_timeout` or `network_error`; the message is the operating system's
|
|
27
|
+
words for an OSError (DNS, refused, TLS, timed out), the class name alone for an http.client exception — never
|
|
28
|
+
anything the server sent, never the API key (which only travels in headers)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, code: str, message: str) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.code = code
|
|
33
|
+
|
|
34
|
+
def __reduce__(self) -> tuple[type[TransportError], tuple[str, str], dict[str, Any]]:
|
|
35
|
+
return (type(self), (self.code, str(self)), self.__dict__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Transport(Protocol):
|
|
39
|
+
"""Sends one HTTP request and returns the raw response. Never follows redirects."""
|
|
40
|
+
|
|
41
|
+
def send(self, method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: float) -> Response:
|
|
42
|
+
"""`body` is already serialized — `None` sends no body. `timeout` bounds the whole request, in seconds,
|
|
43
|
+
`0` = none. Raises `TransportError` when no HTTP response was received (timeout, DNS, connection, TLS…)."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpTransport:
|
|
48
|
+
"""The transport of the SDK: `http.client` from the standard library, TLS verified against the certificates of the
|
|
49
|
+
system, redirects never followed (a 3xx is answered as it is), and one deadline for the whole request — connection,
|
|
50
|
+
request, headers and body — not one per network operation: a timer shuts the socket down when it passes."""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._context = ssl.create_default_context()
|
|
54
|
+
|
|
55
|
+
def send(self, method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: float) -> Response:
|
|
56
|
+
parts = urlsplit(url)
|
|
57
|
+
host = parts.hostname or ""
|
|
58
|
+
target = parts.path + (f"?{parts.query}" if parts.query else "")
|
|
59
|
+
connection: http.client.HTTPConnection
|
|
60
|
+
if parts.scheme == "https":
|
|
61
|
+
connection = http.client.HTTPSConnection(host, parts.port, timeout=timeout or None, context=self._context)
|
|
62
|
+
else:
|
|
63
|
+
connection = http.client.HTTPConnection(host, parts.port, timeout=timeout or None)
|
|
64
|
+
# A body-less POST / PUT / PATCH / DELETE still announces `Content-Length: 0`, as the other SDKs do
|
|
65
|
+
payload = body if body is not None or method in ("GET", "HEAD") else b""
|
|
66
|
+
# The deadline of the whole request: when it passes, the timer shuts the socket down and whatever blocking
|
|
67
|
+
# read is in progress fails; `expired` tells such a failure from a genuine network error
|
|
68
|
+
expired = threading.Event()
|
|
69
|
+
timer = threading.Timer(timeout, _shutdown, (connection, expired)) if timeout > 0 else None
|
|
70
|
+
failure: TransportError
|
|
71
|
+
try:
|
|
72
|
+
if timer is not None:
|
|
73
|
+
timer.daemon = True
|
|
74
|
+
timer.start()
|
|
75
|
+
try:
|
|
76
|
+
connection.request(method, target, body=payload, headers=headers)
|
|
77
|
+
response = connection.getresponse()
|
|
78
|
+
data = response.read()
|
|
79
|
+
if expired.is_set():
|
|
80
|
+
raise TimeoutError("the request deadline has passed")
|
|
81
|
+
return Response(response.status, response.reason, data)
|
|
82
|
+
except (OSError, http.client.HTTPException) as error:
|
|
83
|
+
# a socket timeout is a TimeoutError (an OSError); the timer's shutdown ends in an OSError or an
|
|
84
|
+
# incomplete answer (an HTTPException)
|
|
85
|
+
code = "request_timeout" if isinstance(error, TimeoutError) or expired.is_set() else "network_error"
|
|
86
|
+
failure = _translate(code, error)
|
|
87
|
+
finally:
|
|
88
|
+
if timer is not None:
|
|
89
|
+
timer.cancel()
|
|
90
|
+
connection.close()
|
|
91
|
+
# raised outside the `except`: an http.client exception is neither the cause nor the context of the failure
|
|
92
|
+
raise failure
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _translate(code: str, error: Exception) -> TransportError:
|
|
96
|
+
"""The transport error to raise. An OSError (DNS, refused, TLS, timed out) is kept as the cause, with the operating
|
|
97
|
+
system's words; an http.client exception is dropped and only named — a `BadStatusLine` carries the first line the
|
|
98
|
+
server sent, and a server that reflects the request would put the key in it"""
|
|
99
|
+
if isinstance(error, http.client.HTTPException):
|
|
100
|
+
return TransportError(code, type(error).__name__)
|
|
101
|
+
failure = TransportError(code, f"{type(error).__name__}: {error}")
|
|
102
|
+
failure.__cause__ = error
|
|
103
|
+
return failure
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _shutdown(connection: http.client.HTTPConnection, expired: threading.Event) -> None:
|
|
107
|
+
"""The deadline has passed: wake whatever blocking read is in progress on the socket"""
|
|
108
|
+
expired.set()
|
|
109
|
+
sock = connection.sock
|
|
110
|
+
if sock is None:
|
|
111
|
+
return
|
|
112
|
+
with contextlib.suppress(OSError):
|
|
113
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Argument validation, shared by the client and the resources — internal."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Amounts always reach the API as strings: a string is sent as is (trimmed), an int, a finite float or a `Decimal` ≥ 0
|
|
2
|
+
in its plain decimal form. Nothing is ever rounded or reformatted — send crypto amounts (up to 18 decimals) as
|
|
3
|
+
strings."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
_MESSAGE = "amount must be a non-empty string or a finite number >= 0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize(value: object) -> str:
|
|
14
|
+
"""`ValueError` on an empty string, a negative or non-finite number, or a float that Python would write in exponent
|
|
15
|
+
notation (`1e-08`, `1e+21`: pass it as a decimal string); `TypeError` on anything that is not a string, an int, a
|
|
16
|
+
float or a `Decimal` (a bool included)."""
|
|
17
|
+
if isinstance(value, str):
|
|
18
|
+
trimmed = value.strip()
|
|
19
|
+
if trimmed == "":
|
|
20
|
+
raise ValueError(_MESSAGE)
|
|
21
|
+
return trimmed
|
|
22
|
+
if isinstance(value, bool) or not isinstance(value, int | float | Decimal):
|
|
23
|
+
raise TypeError(_MESSAGE)
|
|
24
|
+
if isinstance(value, int):
|
|
25
|
+
if value < 0:
|
|
26
|
+
raise ValueError(_MESSAGE)
|
|
27
|
+
return str(int(value))
|
|
28
|
+
if isinstance(value, Decimal):
|
|
29
|
+
if not value.is_finite() or value < 0:
|
|
30
|
+
raise ValueError(_MESSAGE)
|
|
31
|
+
# plain notation, never `1E-8`; the sign of a negative zero is dropped
|
|
32
|
+
return "0" if value == 0 else format(value, "f")
|
|
33
|
+
if not math.isfinite(value) or value < 0:
|
|
34
|
+
raise ValueError(_MESSAGE)
|
|
35
|
+
if value == 0:
|
|
36
|
+
return "0"
|
|
37
|
+
# shortest round-trip representation, as `repr` writes it (exponent notation below 1e-4 and from 1e16);
|
|
38
|
+
# `float(value)` so that a subclass (`numpy.float64`) does not write its own name
|
|
39
|
+
text = repr(float(value))
|
|
40
|
+
if "e" in text or "E" in text:
|
|
41
|
+
raise ValueError(f"amount {text} would be sent in exponent notation: pass it as a decimal string")
|
|
42
|
+
return text.removesuffix(".0")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""An asset, as accepted by every method expecting one: its uuid or ISO code as a string (`Asset.ETH`, `"eth"`, a uuid —
|
|
2
|
+
any case, the API normalizes it), or an `Asset` / `AssetRef` model — the SDK then sends its uuid."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from bitgen.models.asset import Asset, AssetRef
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve(asset: object) -> str:
|
|
10
|
+
"""`ValueError` on a model without a non-empty `uuid`, `TypeError` on anything that is not a string, an `Asset` or
|
|
11
|
+
an `AssetRef` — both before any request."""
|
|
12
|
+
if isinstance(asset, str):
|
|
13
|
+
return asset
|
|
14
|
+
if not isinstance(asset, Asset | AssetRef) or not isinstance(asset.uuid, str):
|
|
15
|
+
raise TypeError("asset must be a uuid or an ISO code string, or an Asset / AssetRef model")
|
|
16
|
+
if asset.uuid.strip() == "":
|
|
17
|
+
raise ValueError("asset must be a uuid or an ISO code, or a model with a non-empty uuid")
|
|
18
|
+
return asset.uuid
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The metaclass of the constant classes of the SDK (`Env`, `Asset`, `Locale`…): frozen and never instantiated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConstantsMeta(type):
|
|
9
|
+
"""A class of string constants: `Env.SANDBOX` is `"sandbox"`, `Env.VALUES` lists every value in the order of the
|
|
10
|
+
API contract. The class is frozen (no value can be replaced or removed) and is not instantiated."""
|
|
11
|
+
|
|
12
|
+
def __setattr__(cls, name: str, value: object) -> None:
|
|
13
|
+
raise AttributeError(f"{cls.__name__} is a constant class: its values cannot be changed")
|
|
14
|
+
|
|
15
|
+
def __delattr__(cls, name: str) -> None:
|
|
16
|
+
raise AttributeError(f"{cls.__name__} is a constant class: its values cannot be removed")
|
|
17
|
+
|
|
18
|
+
def __call__(cls, *args: object, **kwargs: object) -> NoReturn:
|
|
19
|
+
raise TypeError(f"{cls.__name__} is a constant class: it is not instantiated")
|
bitgen/_support/path.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Path segments (`/asset/{asset}`, `/account/{user}`…): validated and encoded before they reach a URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def segment(value: object, name: str) -> str:
|
|
9
|
+
"""`ValueError` on an empty value or on `.` / `..` (which the URL would normalize away), `TypeError` on a
|
|
10
|
+
non-string."""
|
|
11
|
+
if not isinstance(value, str):
|
|
12
|
+
raise TypeError(f"{name} must be a string")
|
|
13
|
+
if value.strip() == "":
|
|
14
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
15
|
+
if value in (".", ".."):
|
|
16
|
+
raise ValueError(f'{name} must not be "." or ".."')
|
|
17
|
+
return quote(value, safe="")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""A reference to an object the SDK returns — an order, a movement, a position, a connector… — given as its uuid or as
|
|
2
|
+
the model itself: the SDK then sends the model's uuid."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve(value: object, model: type | tuple[type, ...], name: str) -> str:
|
|
8
|
+
"""The uuid to send. A string is sent as is; a model of the expected class is its `uuid`. An empty string, or a
|
|
9
|
+
model without a non-empty `uuid`, is a `ValueError`; any other object a `TypeError` — both before any request."""
|
|
10
|
+
if isinstance(value, str):
|
|
11
|
+
uuid = value
|
|
12
|
+
elif isinstance(value, model) and isinstance(getattr(value, "uuid", None), str):
|
|
13
|
+
uuid = getattr(value, "uuid") # noqa: B009 - the model is checked above, its attribute is not typed here
|
|
14
|
+
else:
|
|
15
|
+
names = " / ".join(cls.__name__ for cls in (model if isinstance(model, tuple) else (model,)))
|
|
16
|
+
raise TypeError(f"{name} must be a uuid string, or a {names} model")
|
|
17
|
+
if uuid.strip() == "":
|
|
18
|
+
raise ValueError(f"{name} must be a non-empty uuid, or a model with a non-empty uuid")
|
|
19
|
+
return uuid
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""A customer, as accepted by every method expecting one: their uuid (or an email, where the API resolves it), or one of
|
|
2
|
+
the models that carry a customer's uuid — the `Created` of `client.customer.create()`, a `Customer`, an `Account`, the
|
|
3
|
+
`user` of an `Order`, the `owner` of a `Transaction` or of a `StakingMovement`."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from bitgen.models.customer import Account, Created, Customer, OrderUser, UserSummary
|
|
8
|
+
|
|
9
|
+
_MODELS = (Created, Customer, Account, UserSummary, OrderUser)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve(user: object) -> str:
|
|
13
|
+
"""The uuid (or email) to send. A string is sent as is; a model is its `uuid`. An empty string, or a model without
|
|
14
|
+
a non-empty `uuid`, is a `ValueError`; any other object — a `Wallet`, a plain object with a `uuid` — a `TypeError`:
|
|
15
|
+
both before any request."""
|
|
16
|
+
if isinstance(user, str):
|
|
17
|
+
value = user
|
|
18
|
+
elif isinstance(user, _MODELS) and isinstance(user.uuid, str):
|
|
19
|
+
value = user.uuid
|
|
20
|
+
else:
|
|
21
|
+
raise TypeError(
|
|
22
|
+
"user must be a uuid or email string, or a Created / Customer / Account / UserSummary / OrderUser model"
|
|
23
|
+
)
|
|
24
|
+
if value.strip() == "":
|
|
25
|
+
raise ValueError("user must be a non-empty uuid or email, or a model with a non-empty uuid")
|
|
26
|
+
return value
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The single validation of the constant lists of the SDK (`Env.VALUES`, `Locale.VALUES`…)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure(value: object, values: Sequence[str], name: str) -> str:
|
|
9
|
+
"""Returns `value` when it is one of `values`. A value outside the list is a caller's mistake, refused before any
|
|
10
|
+
request — the value itself is never echoed: `ValueError("locale must be FR or EN")`; not a string → `TypeError`."""
|
|
11
|
+
if not isinstance(value, str):
|
|
12
|
+
raise TypeError(f"{name} must be a string, one of {_join(values)}")
|
|
13
|
+
if value not in values:
|
|
14
|
+
raise ValueError(f"{name} must be {_join(values)}")
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _join(values: Sequence[str]) -> str:
|
|
19
|
+
"""`A, B or C`"""
|
|
20
|
+
if len(values) <= 1:
|
|
21
|
+
return "".join(values)
|
|
22
|
+
return ", ".join(values[:-1]) + " or " + values[-1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def string(value: object, name: str) -> str:
|
|
26
|
+
"""A required string argument"""
|
|
27
|
+
if not isinstance(value, str):
|
|
28
|
+
raise TypeError(f"{name} must be a string")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def optional_string(value: object, name: str) -> str | None:
|
|
33
|
+
"""`None` (not sent) or a string"""
|
|
34
|
+
if value is not None and not isinstance(value, str):
|
|
35
|
+
raise TypeError(f"{name} must be a string")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def optional_int(value: object, name: str) -> int | None:
|
|
40
|
+
"""`None` (not sent) or an integer — a boolean is not one"""
|
|
41
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
|
|
42
|
+
raise TypeError(f"{name} must be an integer")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def optional_bool(value: object, name: str) -> bool | None:
|
|
47
|
+
"""`None` (not sent) or a boolean"""
|
|
48
|
+
if value is not None and not isinstance(value, bool):
|
|
49
|
+
raise TypeError(f"{name} must be a boolean")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def optional_choice(value: object, values: Sequence[str], name: str) -> str | None:
|
|
54
|
+
"""`None` (not sent) or one of `values`"""
|
|
55
|
+
return None if value is None else ensure(value, values, name)
|
bitgen/client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The entry point of the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from bitgen._http import base_url
|
|
8
|
+
from bitgen._http import timeout as timeout_
|
|
9
|
+
from bitgen._http.client import HttpClient
|
|
10
|
+
from bitgen._http.transport import HttpTransport
|
|
11
|
+
from bitgen.constants import Env
|
|
12
|
+
from bitgen.resources.apikeys import ApikeysResource
|
|
13
|
+
from bitgen.resources.asset import AssetResource
|
|
14
|
+
from bitgen.resources.bank import BankResource
|
|
15
|
+
from bitgen.resources.core import CoreResource
|
|
16
|
+
from bitgen.resources.custody import CustodyResource
|
|
17
|
+
from bitgen.resources.customer import CustomerResource
|
|
18
|
+
from bitgen.resources.staking import StakingResource
|
|
19
|
+
from bitgen.resources.trading import TradingResource
|
|
20
|
+
from bitgen.resources.transaction import TransactionResource
|
|
21
|
+
from bitgen.resources.webhooks import WebhooksResource
|
|
22
|
+
from bitgen.version import VERSION
|
|
23
|
+
|
|
24
|
+
_PRINTABLE_ASCII = re.compile(r"[\x20-\x7E]+")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BitgenClient:
|
|
28
|
+
"""One instance per API key; the resources hang off it (`client.customer`, `client.bank`, `client.custody`,
|
|
29
|
+
`client.trading`, `client.transaction`, `client.staking`, `client.core`, `client.webhooks`, `client.apikeys`,
|
|
30
|
+
`client.asset`). An invalid configuration raises a `ValueError` (or a `TypeError` on a wrong type) here, before
|
|
31
|
+
any request is sent.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
client = BitgenClient(scope="YOUR_SCOPE_UUID", apiKey="YOUR_API_KEY", env=Env.SANDBOX)
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
customer: CustomerResource
|
|
39
|
+
"""The customers of the organization: creation, listing, accounts"""
|
|
40
|
+
bank: BankResource
|
|
41
|
+
"""The EUR account of each customer"""
|
|
42
|
+
custody: CustodyResource
|
|
43
|
+
"""The crypto wallets of each customer, per asset: deposit addresses, balances, on-chain withdrawals"""
|
|
44
|
+
trading: TradingResource
|
|
45
|
+
"""Purchases and sales of crypto for a customer, through the exchange of the platform"""
|
|
46
|
+
transaction: TransactionResource
|
|
47
|
+
"""The journal of the fiat and crypto movements of the organization, read-only"""
|
|
48
|
+
staking: StakingResource
|
|
49
|
+
"""Staking positions of the customers: providers, movements, rewards, portfolio"""
|
|
50
|
+
core: CoreResource
|
|
51
|
+
"""The catalogue of the connectors of the platform, read-only"""
|
|
52
|
+
webhooks: WebhooksResource
|
|
53
|
+
"""The webhooks of the organization: endpoint and secret, subscriptions, delivery logs, catalogue, `verify`"""
|
|
54
|
+
apikeys: ApikeysResource
|
|
55
|
+
"""The API keys of the organization and the journal of their calls, read-only"""
|
|
56
|
+
asset: AssetResource
|
|
57
|
+
"""The catalogue of assets, tickers and EUR price histories"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
scope: str,
|
|
63
|
+
apiKey: str,
|
|
64
|
+
env: str = Env.PRODUCTION,
|
|
65
|
+
host: str | None = None,
|
|
66
|
+
port: int | None = None,
|
|
67
|
+
isSsl: bool = True,
|
|
68
|
+
timeout: int | float = timeout_.DEFAULT,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""
|
|
71
|
+
:param scope: uuid of the organization that owns the key (`BITGEN-Scope` header)
|
|
72
|
+
:param apiKey: the raw key (`Api-key` header)
|
|
73
|
+
:param env: target environment, one of the `Env` constants — `Env.PRODUCTION` by default
|
|
74
|
+
:param host: custom hostname (bare: no scheme, port or path), used instead of `env`
|
|
75
|
+
:param port: port — with `host` (default 80) or `Env.LOCALHOST` (default 3002)
|
|
76
|
+
:param isSsl: `https` (default) or `http`, with `host`
|
|
77
|
+
:param timeout: request timeout in seconds, `30` by default, `0` = none
|
|
78
|
+
"""
|
|
79
|
+
_require_header_value(scope, "scope")
|
|
80
|
+
_require_header_value(apiKey, "apiKey")
|
|
81
|
+
self._http = HttpClient(
|
|
82
|
+
HttpTransport(),
|
|
83
|
+
scope,
|
|
84
|
+
apiKey,
|
|
85
|
+
base_url.resolve(env, host, port, isSsl),
|
|
86
|
+
timeout_.resolve(timeout),
|
|
87
|
+
f"bitgen-sdk-python/{VERSION}",
|
|
88
|
+
)
|
|
89
|
+
self.customer = CustomerResource(self._http)
|
|
90
|
+
self.bank = BankResource(self._http)
|
|
91
|
+
self.custody = CustodyResource(self._http)
|
|
92
|
+
self.trading = TradingResource(self._http)
|
|
93
|
+
self.transaction = TransactionResource(self._http)
|
|
94
|
+
core = CoreResource(self._http) # built first: the staking providers are read from the core catalogue
|
|
95
|
+
self.staking = StakingResource(self._http, core)
|
|
96
|
+
self.core = core
|
|
97
|
+
self.webhooks = WebhooksResource(self._http)
|
|
98
|
+
self.apikeys = ApikeysResource(self._http)
|
|
99
|
+
self.asset = AssetResource(self._http)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _require_header_value(value: object, name: str) -> None:
|
|
103
|
+
"""Non-empty printable ASCII (a header value) — the value itself is never echoed"""
|
|
104
|
+
if not isinstance(value, str):
|
|
105
|
+
raise TypeError(f"{name} must be a non-empty string")
|
|
106
|
+
if value == "":
|
|
107
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
108
|
+
if not _PRINTABLE_ASCII.fullmatch(value):
|
|
109
|
+
raise ValueError(f"{name} contains invalid characters (printable ASCII expected)")
|