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 +55 -0
- nomba/_client.py +359 -0
- nomba/_config.py +107 -0
- nomba/_core.py +550 -0
- nomba/_enums.py +45 -0
- nomba/_version.py +1 -0
- nomba/errors.py +154 -0
- nomba/py.typed +0 -0
- nomba/resources/__init__.py +1 -0
- nomba/resources/_base.py +78 -0
- nomba/resources/accounts.py +149 -0
- nomba/resources/auth.py +21 -0
- nomba/resources/bills.py +148 -0
- nomba/resources/checkout.py +285 -0
- nomba/resources/direct_debits.py +98 -0
- nomba/resources/global_collection.py +45 -0
- nomba/resources/global_payout.py +171 -0
- nomba/resources/terminals.py +43 -0
- nomba/resources/transactions.py +282 -0
- nomba/resources/transfers.py +65 -0
- nomba/resources/virtual_accounts.py +106 -0
- nomba/tokens.py +106 -0
- nomba/types/__init__.py +410 -0
- nomba/types/accounts.py +48 -0
- nomba/types/auth.py +10 -0
- nomba/types/bills.py +116 -0
- nomba/types/checkout.py +258 -0
- nomba/types/direct_debits.py +121 -0
- nomba/types/global_collection.py +41 -0
- nomba/types/global_payout.py +155 -0
- nomba/types/shared.py +47 -0
- nomba/types/terminals.py +29 -0
- nomba/types/transactions.py +66 -0
- nomba/types/transfers.py +65 -0
- nomba/types/virtual_accounts.py +77 -0
- nomba/types/webhooks.py +16 -0
- nomba/webhooks.py +197 -0
- nomba-0.1.1.dist-info/METADATA +391 -0
- nomba-0.1.1.dist-info/RECORD +41 -0
- nomba-0.1.1.dist-info/WHEEL +4 -0
- nomba-0.1.1.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|