nomba 0.1.1__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.
nomba/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Official Python SDK for the Nomba API.
2
+
3
+ from nomba import Nomba
4
+
5
+ nomba = Nomba() # reads NOMBA_CLIENT_ID, NOMBA_CLIENT_SECRET, NOMBA_ACCOUNT_ID
6
+ balance = nomba.accounts.get_balance()
7
+ """
8
+
9
+ from . import types, webhooks
10
+ from ._client import AsyncNomba, Nomba
11
+ from ._enums import TransactionStatus, WebhookEventType
12
+ from ._version import __version__
13
+ from .errors import (
14
+ APIConnectionError,
15
+ ApiError,
16
+ APITimeoutError,
17
+ AuthenticationError,
18
+ ConfigurationError,
19
+ NombaError,
20
+ NotFoundError,
21
+ PermissionDeniedError,
22
+ RateLimitError,
23
+ Reference,
24
+ ServerError,
25
+ ValidationError,
26
+ WebhookVerificationError,
27
+ )
28
+ from .tokens import AsyncTokenStore, InMemoryTokenStore, Token, TokenStore
29
+
30
+ __all__ = [
31
+ "APIConnectionError",
32
+ "APITimeoutError",
33
+ "ApiError",
34
+ "AsyncNomba",
35
+ "AsyncTokenStore",
36
+ "AuthenticationError",
37
+ "ConfigurationError",
38
+ "InMemoryTokenStore",
39
+ "Nomba",
40
+ "NombaError",
41
+ "NotFoundError",
42
+ "PermissionDeniedError",
43
+ "RateLimitError",
44
+ "Reference",
45
+ "ServerError",
46
+ "Token",
47
+ "TokenStore",
48
+ "TransactionStatus",
49
+ "ValidationError",
50
+ "WebhookEventType",
51
+ "WebhookVerificationError",
52
+ "__version__",
53
+ "types",
54
+ "webhooks",
55
+ ]
nomba/_client.py ADDED
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import threading
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from datetime import datetime
9
+ from types import TracebackType
10
+ from typing import Any, TypeVar, cast
11
+
12
+ import httpx
13
+
14
+ from . import _core
15
+ from ._config import Config, resolve_config
16
+ from ._core import AccessToken, Flow, Received, Request, Send, Sleep
17
+ from .resources.accounts import Accounts, AsyncAccounts
18
+ from .resources.auth import AsyncAuth, Auth
19
+ from .resources.bills import AsyncBills, Bills
20
+ from .resources.checkout import AsyncCheckout, Checkout
21
+ from .resources.direct_debits import AsyncDirectDebits, DirectDebits
22
+ from .resources.global_collection import AsyncGlobalCollection, GlobalCollection
23
+ from .resources.global_payout import AsyncGlobalPayout, GlobalPayout
24
+ from .resources.terminals import AsyncTerminals, Terminals
25
+ from .resources.transactions import AsyncTransactions, Transactions
26
+ from .resources.transfers import AsyncTransfers, Transfers
27
+ from .resources.virtual_accounts import AsyncVirtualAccounts, VirtualAccounts
28
+ from .tokens import AsyncTokenStore, InMemoryTokenStore, Token, TokenStore
29
+ from .webhooks import Webhooks
30
+
31
+ __all__ = ["AsyncNomba", "Nomba"]
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ def _repr(name: str, config: Config) -> str:
37
+ return (
38
+ f"{name}(environment={config.environment!r}, base_url={config.base_url!r}, "
39
+ f"account_id={config.account_id!r}, client_id={config.client_id!r}, client_secret='***')"
40
+ )
41
+
42
+
43
+ class Nomba:
44
+ """Synchronous Nomba API client.
45
+
46
+ Credentials default to the ``NOMBA_CLIENT_ID``, ``NOMBA_CLIENT_SECRET`` and
47
+ ``NOMBA_ACCOUNT_ID`` environment variables. The environment defaults to
48
+ ``"sandbox"``; pass ``environment="production"`` to move real money.
49
+ No network call happens until the first API call.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ client_id: str | None = None,
56
+ client_secret: str | None = None,
57
+ account_id: str | None = None,
58
+ environment: str | None = None,
59
+ base_url: str | None = None,
60
+ timeout: float | None = None,
61
+ max_retries: int | None = None,
62
+ token_store: TokenStore | None = None,
63
+ http_client: httpx.Client | None = None,
64
+ ) -> None:
65
+ self._config = resolve_config(
66
+ client_id=client_id,
67
+ client_secret=client_secret,
68
+ account_id=account_id,
69
+ environment=environment,
70
+ base_url=base_url,
71
+ timeout=timeout,
72
+ max_retries=max_retries,
73
+ )
74
+ self._store: TokenStore = token_store if token_store is not None else InMemoryTokenStore()
75
+ self._owns_http = http_client is None
76
+ self._http = http_client if http_client is not None else httpx.Client(follow_redirects=False)
77
+ self._token_lock = threading.Lock()
78
+ self._sleep: Callable[[float], None] = time.sleep
79
+ self._monotonic: Callable[[], float] = time.monotonic
80
+ self._now: Callable[[], datetime] = _core.utcnow
81
+
82
+ self.auth = Auth(self)
83
+ self.accounts = Accounts(self)
84
+ self.virtual_accounts = VirtualAccounts(self)
85
+ self.checkout = Checkout(self)
86
+ self.transfers = Transfers(self)
87
+ self.transactions = Transactions(self)
88
+ self.terminals = Terminals(self)
89
+ self.direct_debits = DirectDebits(self)
90
+ self.bills = Bills(self)
91
+ self.global_payout = GlobalPayout(self)
92
+ self.global_collection = GlobalCollection(self)
93
+ self.webhooks = Webhooks()
94
+
95
+ @property
96
+ def environment(self) -> str:
97
+ return self._config.environment
98
+
99
+ @property
100
+ def base_url(self) -> str:
101
+ return self._config.base_url
102
+
103
+ def __repr__(self) -> str:
104
+ return _repr("Nomba", self._config)
105
+
106
+ # -- lifecycle
107
+
108
+ def close(self) -> None:
109
+ """Close the underlying HTTP client (unless you passed your own)."""
110
+ if self._owns_http:
111
+ self._http.close()
112
+
113
+ def __enter__(self) -> Nomba:
114
+ return self
115
+
116
+ def __exit__(
117
+ self,
118
+ exc_type: type[BaseException] | None,
119
+ exc: BaseException | None,
120
+ tb: TracebackType | None,
121
+ ) -> None:
122
+ self.close()
123
+
124
+ # -- driver
125
+
126
+ def _request(self, req: Request[T]) -> T:
127
+ return cast(T, self._run(_core.api_flow(req, self._config)))
128
+
129
+ def _run(self, flow: Flow[Any]) -> Any:
130
+ value: Any = None
131
+ error: BaseException | None = None
132
+ while True:
133
+ try:
134
+ action = flow.throw(error) if error is not None else flow.send(value)
135
+ except StopIteration as stop:
136
+ return stop.value
137
+ value, error = None, None
138
+ try:
139
+ value = self._perform(action)
140
+ except Exception as exc: # handed back to the flow
141
+ error = exc
142
+
143
+ def _perform(self, action: Any) -> Any:
144
+ if isinstance(action, Send):
145
+ return self._send(action)
146
+ if isinstance(action, Sleep):
147
+ self._sleep(action.seconds)
148
+ return None
149
+ if isinstance(action, AccessToken):
150
+ return self._access_token(action.stale)
151
+ raise TypeError(f"unknown action {action!r}") # pragma: no cover
152
+
153
+ def _send(self, action: Send) -> Received:
154
+ # Stream the body so the total time per attempt can be enforced, on top
155
+ # of httpx's per-phase timeouts.
156
+ reader = _core.BodyReader(action, self._monotonic)
157
+ response = self._http.send(action.request, stream=True, follow_redirects=False)
158
+ try:
159
+ reader.check()
160
+ for chunk in response.iter_bytes():
161
+ reader.add(chunk)
162
+ finally:
163
+ response.close()
164
+ return reader.received(response)
165
+
166
+ def _access_token(self, stale: str | None) -> str:
167
+ # Fast path: a usable cached token needs no lock.
168
+ cached = _core.usable_token(self._store.get(), stale, self._now())
169
+ if cached is not None:
170
+ return cached
171
+ # Single-flight: concurrent callers wait here, re-check, and reuse the
172
+ # token the first caller obtained.
173
+ with self._token_lock:
174
+ token = self._store.get()
175
+ if stale is not None and token is not None and token.access_token == stale:
176
+ self._store.set(None)
177
+ token = None
178
+ decision = _core.token_decision(token, self._now())
179
+ if decision == "valid" and token is not None:
180
+ return token.access_token
181
+ current = token if decision == "refresh" else None
182
+ new: Token = self._run(_core.obtain_token_flow(self._config, current, self._now))
183
+ self._store.set(new)
184
+ return new.access_token
185
+
186
+ def _revoke(self) -> Any:
187
+ with self._token_lock:
188
+ token = self._store.get()
189
+ if token is None:
190
+ return None
191
+ try:
192
+ return self._request(_core.revoke_request(self._config, token))
193
+ finally:
194
+ with self._token_lock:
195
+ self._store.set(None)
196
+
197
+
198
+ class AsyncNomba:
199
+ """Asynchronous Nomba API client. Same surface as :class:`Nomba`, with ``await``."""
200
+
201
+ def __init__(
202
+ self,
203
+ *,
204
+ client_id: str | None = None,
205
+ client_secret: str | None = None,
206
+ account_id: str | None = None,
207
+ environment: str | None = None,
208
+ base_url: str | None = None,
209
+ timeout: float | None = None,
210
+ max_retries: int | None = None,
211
+ token_store: AsyncTokenStore | TokenStore | None = None,
212
+ http_client: httpx.AsyncClient | None = None,
213
+ ) -> None:
214
+ self._config = resolve_config(
215
+ client_id=client_id,
216
+ client_secret=client_secret,
217
+ account_id=account_id,
218
+ environment=environment,
219
+ base_url=base_url,
220
+ timeout=timeout,
221
+ max_retries=max_retries,
222
+ )
223
+ self._store: AsyncTokenStore | TokenStore = token_store if token_store is not None else InMemoryTokenStore()
224
+ self._owns_http = http_client is None
225
+ self._http = http_client if http_client is not None else httpx.AsyncClient(follow_redirects=False)
226
+ self._token_lock: asyncio.Lock | None = None
227
+ self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
228
+ self._monotonic: Callable[[], float] = time.monotonic
229
+ self._now: Callable[[], datetime] = _core.utcnow
230
+
231
+ self.auth = AsyncAuth(self)
232
+ self.accounts = AsyncAccounts(self)
233
+ self.virtual_accounts = AsyncVirtualAccounts(self)
234
+ self.checkout = AsyncCheckout(self)
235
+ self.transfers = AsyncTransfers(self)
236
+ self.transactions = AsyncTransactions(self)
237
+ self.terminals = AsyncTerminals(self)
238
+ self.direct_debits = AsyncDirectDebits(self)
239
+ self.bills = AsyncBills(self)
240
+ self.global_payout = AsyncGlobalPayout(self)
241
+ self.global_collection = AsyncGlobalCollection(self)
242
+ self.webhooks = Webhooks()
243
+
244
+ @property
245
+ def environment(self) -> str:
246
+ return self._config.environment
247
+
248
+ @property
249
+ def base_url(self) -> str:
250
+ return self._config.base_url
251
+
252
+ def __repr__(self) -> str:
253
+ return _repr("AsyncNomba", self._config)
254
+
255
+ # -- lifecycle
256
+
257
+ async def aclose(self) -> None:
258
+ """Close the underlying HTTP client (unless you passed your own)."""
259
+ if self._owns_http:
260
+ await self._http.aclose()
261
+
262
+ async def close(self) -> None:
263
+ """Alias of :meth:`aclose`."""
264
+ await self.aclose()
265
+
266
+ async def __aenter__(self) -> AsyncNomba:
267
+ return self
268
+
269
+ async def __aexit__(
270
+ self,
271
+ exc_type: type[BaseException] | None,
272
+ exc: BaseException | None,
273
+ tb: TracebackType | None,
274
+ ) -> None:
275
+ await self.aclose()
276
+
277
+ # -- driver
278
+
279
+ def _lock(self) -> asyncio.Lock:
280
+ if self._token_lock is None:
281
+ self._token_lock = asyncio.Lock()
282
+ return self._token_lock
283
+
284
+ async def _request(self, req: Request[T]) -> T:
285
+ return cast(T, await self._run(_core.api_flow(req, self._config)))
286
+
287
+ async def _run(self, flow: Flow[Any]) -> Any:
288
+ value: Any = None
289
+ error: BaseException | None = None
290
+ while True:
291
+ try:
292
+ action = flow.throw(error) if error is not None else flow.send(value)
293
+ except StopIteration as stop:
294
+ return stop.value
295
+ value, error = None, None
296
+ try:
297
+ value = await self._perform(action)
298
+ except Exception as exc: # handed back to the flow
299
+ error = exc
300
+
301
+ async def _perform(self, action: Any) -> Any:
302
+ if isinstance(action, Send):
303
+ return await self._send(action)
304
+ if isinstance(action, Sleep):
305
+ await self._sleep(action.seconds)
306
+ return None
307
+ if isinstance(action, AccessToken):
308
+ return await self._access_token(action.stale)
309
+ raise TypeError(f"unknown action {action!r}") # pragma: no cover
310
+
311
+ async def _send(self, action: Send) -> Received:
312
+ reader = _core.BodyReader(action, self._monotonic)
313
+ response = await self._http.send(action.request, stream=True, follow_redirects=False)
314
+ try:
315
+ reader.check()
316
+ async for chunk in response.aiter_bytes():
317
+ reader.add(chunk)
318
+ finally:
319
+ await response.aclose()
320
+ return reader.received(response)
321
+
322
+ async def _store_get(self) -> Token | None:
323
+ result: Any = self._store.get()
324
+ if inspect.isawaitable(result):
325
+ result = await result
326
+ return cast("Token | None", result)
327
+
328
+ async def _store_set(self, token: Token | None) -> None:
329
+ result: Any = self._store.set(token)
330
+ if inspect.isawaitable(result):
331
+ await result
332
+
333
+ async def _access_token(self, stale: str | None) -> str:
334
+ cached = _core.usable_token(await self._store_get(), stale, self._now())
335
+ if cached is not None:
336
+ return cached
337
+ async with self._lock():
338
+ token = await self._store_get()
339
+ if stale is not None and token is not None and token.access_token == stale:
340
+ await self._store_set(None)
341
+ token = None
342
+ decision = _core.token_decision(token, self._now())
343
+ if decision == "valid" and token is not None:
344
+ return token.access_token
345
+ current = token if decision == "refresh" else None
346
+ new: Token = await self._run(_core.obtain_token_flow(self._config, current, self._now))
347
+ await self._store_set(new)
348
+ return new.access_token
349
+
350
+ async def _revoke(self) -> Any:
351
+ async with self._lock():
352
+ token = await self._store_get()
353
+ if token is None:
354
+ return None
355
+ try:
356
+ return await self._request(_core.revoke_request(self._config, token))
357
+ finally:
358
+ async with self._lock():
359
+ await self._store_set(None)
nomba/_config.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from urllib.parse import urlsplit
6
+
7
+ from .errors import ConfigurationError
8
+
9
+ ENVIRONMENTS = {
10
+ "sandbox": "https://sandbox.nomba.com",
11
+ "production": "https://api.nomba.com",
12
+ }
13
+ DEFAULT_TIMEOUT = 30.0
14
+ DEFAULT_MAX_RETRIES = 2
15
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Config:
20
+ client_id: str
21
+ client_secret: str = field(repr=False)
22
+ account_id: str
23
+ environment: str
24
+ base_url: str
25
+ timeout: float
26
+ max_retries: int
27
+
28
+ @property
29
+ def is_sandbox(self) -> bool:
30
+ return self.environment == "sandbox"
31
+
32
+ def __repr__(self) -> str:
33
+ return (
34
+ f"Config(client_id={self.client_id!r}, client_secret='***', account_id={self.account_id!r}, "
35
+ f"environment={self.environment!r}, base_url={self.base_url!r}, timeout={self.timeout!r}, "
36
+ f"max_retries={self.max_retries!r})"
37
+ )
38
+
39
+
40
+ def _pick(value: str | None, env: str) -> str | None:
41
+ if value is not None:
42
+ return value
43
+ from_env = os.environ.get(env)
44
+ return from_env if from_env else None
45
+
46
+
47
+ def _validate_base_url(url: str) -> str:
48
+ parts = urlsplit(url)
49
+ host = (parts.hostname or "").lower()
50
+ if not host:
51
+ raise ConfigurationError(f"base_url {url!r} is not an absolute URL")
52
+ if not (parts.scheme == "https" or (parts.scheme == "http" and host in _LOCAL_HOSTS)):
53
+ raise ConfigurationError("base_url must use https:// (http:// is allowed only for localhost or 127.0.0.1)")
54
+ if parts.query or parts.fragment:
55
+ raise ConfigurationError("base_url must not contain a query string or fragment")
56
+ if parts.username or parts.password:
57
+ raise ConfigurationError("base_url must not contain credentials")
58
+ return url.rstrip("/")
59
+
60
+
61
+ def resolve_config(
62
+ *,
63
+ client_id: str | None,
64
+ client_secret: str | None,
65
+ account_id: str | None,
66
+ environment: str | None,
67
+ base_url: str | None,
68
+ timeout: float | None,
69
+ max_retries: int | None,
70
+ ) -> Config:
71
+ cid = _pick(client_id, "NOMBA_CLIENT_ID")
72
+ secret = _pick(client_secret, "NOMBA_CLIENT_SECRET")
73
+ acct = _pick(account_id, "NOMBA_ACCOUNT_ID")
74
+ missing = [
75
+ f"{opt} (or env {env})"
76
+ for opt, env, val in (
77
+ ("client_id", "NOMBA_CLIENT_ID", cid),
78
+ ("client_secret", "NOMBA_CLIENT_SECRET", secret),
79
+ ("account_id", "NOMBA_ACCOUNT_ID", acct),
80
+ )
81
+ if not val
82
+ ]
83
+ if missing or cid is None or secret is None or acct is None:
84
+ raise ConfigurationError("Missing Nomba credentials: " + ", ".join(missing))
85
+
86
+ env_name = _pick(environment, "NOMBA_ENVIRONMENT") or "sandbox"
87
+ if env_name not in ENVIRONMENTS:
88
+ raise ConfigurationError(f"environment must be 'sandbox' or 'production', got {env_name!r}")
89
+
90
+ url = _validate_base_url(base_url) if base_url is not None else ENVIRONMENTS[env_name]
91
+
92
+ t = DEFAULT_TIMEOUT if timeout is None else timeout
93
+ if isinstance(t, bool) or not isinstance(t, (int, float)) or t <= 0:
94
+ raise ConfigurationError("timeout must be a positive number of seconds")
95
+ r = DEFAULT_MAX_RETRIES if max_retries is None else max_retries
96
+ if isinstance(r, bool) or not isinstance(r, int) or r < 0:
97
+ raise ConfigurationError("max_retries must be a non-negative integer")
98
+
99
+ return Config(
100
+ client_id=cid,
101
+ client_secret=secret,
102
+ account_id=acct,
103
+ environment=env_name,
104
+ base_url=url,
105
+ timeout=float(t),
106
+ max_retries=r,
107
+ )