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.
Files changed (48) hide show
  1. bitgen/__init__.py +18 -0
  2. bitgen/_http/__init__.py +1 -0
  3. bitgen/_http/base_url.py +42 -0
  4. bitgen/_http/client.py +131 -0
  5. bitgen/_http/timeout.py +21 -0
  6. bitgen/_http/transport.py +113 -0
  7. bitgen/_support/__init__.py +1 -0
  8. bitgen/_support/amount.py +42 -0
  9. bitgen/_support/asset_id.py +18 -0
  10. bitgen/_support/constants.py +19 -0
  11. bitgen/_support/path.py +17 -0
  12. bitgen/_support/reference.py +19 -0
  13. bitgen/_support/user_id.py +26 -0
  14. bitgen/_support/values.py +55 -0
  15. bitgen/client.py +109 -0
  16. bitgen/constants.py +36 -0
  17. bitgen/errors.py +35 -0
  18. bitgen/models/__init__.py +197 -0
  19. bitgen/models/_cast.py +161 -0
  20. bitgen/models/apikeys.py +136 -0
  21. bitgen/models/asset.py +205 -0
  22. bitgen/models/bank.py +104 -0
  23. bitgen/models/core.py +122 -0
  24. bitgen/models/custody.py +148 -0
  25. bitgen/models/customer.py +598 -0
  26. bitgen/models/history.py +44 -0
  27. bitgen/models/organization.py +42 -0
  28. bitgen/models/staking.py +229 -0
  29. bitgen/models/trading.py +137 -0
  30. bitgen/models/transaction.py +165 -0
  31. bitgen/models/webhooks.py +237 -0
  32. bitgen/page.py +42 -0
  33. bitgen/py.typed +0 -0
  34. bitgen/resources/__init__.py +27 -0
  35. bitgen/resources/apikeys.py +50 -0
  36. bitgen/resources/asset.py +37 -0
  37. bitgen/resources/bank.py +103 -0
  38. bitgen/resources/core.py +42 -0
  39. bitgen/resources/custody.py +86 -0
  40. bitgen/resources/customer.py +133 -0
  41. bitgen/resources/staking.py +129 -0
  42. bitgen/resources/trading.py +100 -0
  43. bitgen/resources/transaction.py +50 -0
  44. bitgen/resources/webhooks.py +237 -0
  45. bitgen/version.py +4 -0
  46. bitgen_sdk-1.0.0.dist-info/METADATA +66 -0
  47. bitgen_sdk-1.0.0.dist-info/RECORD +48 -0
  48. 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"]
@@ -0,0 +1 @@
1
+ """The HTTP layer of the SDK — internal."""
@@ -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)
@@ -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")
@@ -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)")