nomba-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class NombaError(Exception):
7
+ """Base exception for all Nomba SDK errors."""
8
+
9
+
10
+ class NombaAPIError(NombaError):
11
+ """Raised when the Nomba API returns a non-success response."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status_code: int | None = None,
18
+ code: str | None = None,
19
+ response_body: Any = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+ self.code = code
24
+ self.response_body = response_body
25
+
26
+ def __str__(self) -> str: # pragma: no cover - cosmetic
27
+ parts = [super().__str__()]
28
+ if self.status_code is not None:
29
+ parts.append(f"(status={self.status_code})")
30
+ if self.code is not None:
31
+ parts.append(f"(code={self.code})")
32
+ return " ".join(parts)
33
+
34
+
35
+ class NombaAuthError(NombaAPIError):
36
+ """Raised when authentication with Nomba fails."""
37
+
38
+
39
+ class NombaValidationError(NombaError):
40
+ """
41
+ Raised locally (before any network call) when a request body is missing
42
+ required nested fields per Nomba's own OpenAPI spec. This catches
43
+ mistakes in nested objects (e.g. a missing field inside `order={...}`)
44
+ that the generated method's flat signature can't enforce on its own.
45
+ """
46
+
47
+ def __init__(self, message: str, *, missing: list[str] | None = None) -> None:
48
+ super().__init__(message)
49
+ self.missing = missing or []
@@ -0,0 +1,3 @@
1
+ from .card_payment import AsyncCardPaymentFlow, CardPaymentFlow, CardPaymentStep
2
+
3
+ __all__ = ["CardPaymentFlow", "AsyncCardPaymentFlow", "CardPaymentStep"]
@@ -0,0 +1,204 @@
1
+ """
2
+ Guided helper for Nomba's card-payment flow.
3
+
4
+ Nomba's card checkout is a multi-step sequence that otherwise requires
5
+ reading their docs to understand:
6
+
7
+ 1. Create an order -> orderReference
8
+ 2. Submit card details -> responseCode tells you what's next:
9
+ "00" = done, payment completed
10
+ "T0" = OTP required, call submit_otp()
11
+ "S0" = 3D Secure required, redirect the user using secureAuthenticationData
12
+ 3. (if "T0") submit_otp(otp) -> or resend_otp() if it didn't arrive
13
+ 4. confirm() -> final transaction status/details
14
+
15
+ This module wraps that sequence in a small stateful object so callers don't
16
+ need to track orderReference/transactionId by hand or look up what each
17
+ responseCode means.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ if TYPE_CHECKING:
25
+ from ..resources.charge import AsyncCharge, Charge
26
+ from .. import models as _models
27
+ # Response codes Nomba documents on the card-details submission response.
28
+ RESPONSE_CODE_SUCCESS = "00"
29
+ RESPONSE_CODE_OTP_REQUIRED = "T0"
30
+ RESPONSE_CODE_3DS_REQUIRED = "S0"
31
+
32
+
33
+ @dataclass
34
+ class CardPaymentStep:
35
+ """Outcome of a single step in the card-payment flow."""
36
+
37
+ raw: _models.SubmitCustomerCardDetailsResponse | _models.SubmitCustomerPaymentOtpResponse | dict[str, Any]
38
+ response_code: str | None
39
+ status: Any
40
+ message: str | None
41
+ transaction_id: str | None
42
+ requires_otp: bool
43
+ requires_3ds: bool
44
+ secure_authentication_data: dict[str, Any] | None
45
+ completed: bool
46
+
47
+
48
+ def _interpret(raw: _models.SubmitCustomerCardDetailsResponse | _models.SubmitCustomerPaymentOtpResponse, transaction_id_fallback: str | None = None) -> CardPaymentStep:
49
+ data = raw.get("data", raw) if isinstance(raw.get("data"), dict) else raw
50
+ response_code = data.get("responseCode")
51
+ transaction_id = data.get("transactionId") or transaction_id_fallback
52
+ return CardPaymentStep(
53
+ raw=raw,
54
+ response_code=response_code,
55
+ status=data.get("status"),
56
+ message=data.get("message"),
57
+ transaction_id=transaction_id,
58
+ requires_otp=response_code == RESPONSE_CODE_OTP_REQUIRED,
59
+ requires_3ds=response_code == RESPONSE_CODE_3DS_REQUIRED,
60
+ secure_authentication_data=data.get("secureAuthenticationData"),
61
+ completed=response_code == RESPONSE_CODE_SUCCESS,
62
+ )
63
+
64
+
65
+ class CardPaymentFlow:
66
+ """
67
+ Stateful, guided wrapper around Nomba's card-payment + OTP sequence.
68
+
69
+ Example:
70
+ from nomba import Nomba
71
+ from nomba.flows import CardPaymentFlow
72
+
73
+ nomba = Nomba(...)
74
+ order = nomba.checkout.create_an_online_checkout_order(
75
+ order={"orderReference": "order-001", "amount": "1000", ...}
76
+ )
77
+ order_ref = order["data"]["orderReference"]
78
+
79
+ flow = CardPaymentFlow(nomba.charge, order_reference=order_ref)
80
+ step = flow.submit_card(card_details="...", key="")
81
+
82
+ if step.requires_otp:
83
+ step = flow.submit_otp(otp="123456")
84
+ elif step.requires_3ds:
85
+ # redirect the user using step.secure_authentication_data
86
+ ...
87
+
88
+ if step.completed:
89
+ result = flow.confirm()
90
+ """
91
+
92
+ def __init__(self, charge: "Charge", *, order_reference: str) -> None:
93
+ self._charge = charge
94
+ self.order_reference = order_reference
95
+ self.transaction_id: str | None = None
96
+
97
+ def submit_card(
98
+ self,
99
+ *,
100
+ card_details: str,
101
+ key: str = "",
102
+ save_card: bool | None = None,
103
+ device_information: object | None = None,
104
+ ) -> CardPaymentStep:
105
+ raw = self._charge.submit_customer_card_details(
106
+ card_details=card_details,
107
+ key=key,
108
+ order_reference=self.order_reference,
109
+ save_card=save_card,
110
+ device_information=device_information,
111
+ )
112
+ step = _interpret(raw)
113
+ self.transaction_id = step.transaction_id
114
+ return step
115
+
116
+ def submit_otp(self, otp: str) -> CardPaymentStep:
117
+ if not self.transaction_id:
118
+ raise ValueError(
119
+ "No transaction_id on this flow yet — call submit_card() first."
120
+ )
121
+ raw = self._charge.submit_customer_payment_otp(
122
+ otp=otp,
123
+ order_reference=self.order_reference,
124
+ transaction_id=self.transaction_id,
125
+ )
126
+ return _interpret(raw, transaction_id_fallback=self.transaction_id)
127
+
128
+ def resend_otp(self) -> _models.ResendCustomerPaymentOtpResponse:
129
+ return self._charge.resend_customer_payment_otp(
130
+ order_reference=self.order_reference
131
+ )
132
+
133
+ def confirm(self) -> _models.FetchCheckoutTransactionDetailsResponse:
134
+ return self._charge.fetch_checkout_transaction_details(
135
+ order_reference=self.order_reference
136
+ )
137
+
138
+ def cancel(self, *, force: bool = False) -> _models.CancelCheckoutTransactionResponse:
139
+ if not self.transaction_id:
140
+ raise ValueError(
141
+ "No transaction_id on this flow yet — call submit_card() first."
142
+ )
143
+ return self._charge.cancel_checkout_transaction(
144
+ transaction_id=self.transaction_id, force_cancel=force
145
+ )
146
+
147
+
148
+ class AsyncCardPaymentFlow:
149
+ """Async version of `CardPaymentFlow` — same steps, all coroutines."""
150
+
151
+ def __init__(self, charge: "AsyncCharge", *, order_reference: str) -> None:
152
+ self._charge = charge
153
+ self.order_reference = order_reference
154
+ self.transaction_id: str | None = None
155
+
156
+ async def submit_card(
157
+ self,
158
+ *,
159
+ card_details: str,
160
+ key: str = "",
161
+ save_card: bool | None = None,
162
+ device_information: object | None = None,
163
+ ) -> CardPaymentStep:
164
+ raw = await self._charge.submit_customer_card_details(
165
+ card_details=card_details,
166
+ key=key,
167
+ order_reference=self.order_reference,
168
+ save_card=save_card,
169
+ device_information=device_information,
170
+ )
171
+ step = _interpret(raw)
172
+ self.transaction_id = step.transaction_id
173
+ return step
174
+
175
+ async def submit_otp(self, otp: str) -> CardPaymentStep:
176
+ if not self.transaction_id:
177
+ raise ValueError(
178
+ "No transaction_id on this flow yet — call submit_card() first."
179
+ )
180
+ raw = await self._charge.submit_customer_payment_otp(
181
+ otp=otp,
182
+ order_reference=self.order_reference,
183
+ transaction_id=self.transaction_id,
184
+ )
185
+ return _interpret(raw, transaction_id_fallback=self.transaction_id)
186
+
187
+ async def resend_otp(self)-> _models.ResendCustomerPaymentOtpResponse:
188
+ return await self._charge.resend_customer_payment_otp(
189
+ order_reference=self.order_reference
190
+ )
191
+
192
+ async def confirm(self)-> _models.FetchCheckoutTransactionDetailsResponse:
193
+ return await self._charge.fetch_checkout_transaction_details(
194
+ order_reference=self.order_reference
195
+ )
196
+
197
+ async def cancel(self, *, force: bool = False)-> _models.CancelCheckoutTransactionResponse:
198
+ if not self.transaction_id:
199
+ raise ValueError(
200
+ "No transaction_id on this flow yet — call submit_card() first."
201
+ )
202
+ return await self._charge.cancel_checkout_transaction(
203
+ transaction_id=self.transaction_id, force_cancel=force
204
+ )
nomba_python/http.py ADDED
@@ -0,0 +1,418 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import threading
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .exceptions import NombaAPIError, NombaAuthError
12
+
13
+ LIVE_BASE_URL = "https://api.nomba.com"
14
+ SANDBOX_BASE_URL = "https://sandbox.nomba.com"
15
+
16
+ # Status codes worth retrying: rate limit + transient server-side errors.
17
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
18
+
19
+
20
+ def _compute_backoff(attempt: int, retry_after: str | None, backoff_factor: float) -> float:
21
+ if retry_after:
22
+ try:
23
+ return float(retry_after)
24
+ except ValueError:
25
+ pass
26
+ # exponential backoff with jitter: backoff_factor * 2^attempt, +/- 20%
27
+ base = backoff_factor * (2 ** attempt)
28
+ jitter = base * random.uniform(-0.2, 0.2)
29
+ return max(0.0, base + jitter)
30
+
31
+
32
+ class NombaClient:
33
+ """
34
+ Low-level HTTP client for the Nomba API.
35
+
36
+ Handles OAuth2 client-credentials authentication, token caching/refresh
37
+ (with a lock so concurrent requests don't race to re-fetch a token),
38
+ the `accountId` header that most endpoints require, and automatic
39
+ retry-with-backoff for 429/5xx responses.
40
+
41
+ Example:
42
+ client = NombaClient(
43
+ client_id="...",
44
+ client_secret="...",
45
+ account_id="...",
46
+ )
47
+ account = client.get(f"/v1/accounts/virtual/{account_ref}")
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ client_id: str,
53
+ client_secret: str,
54
+ account_id: str,
55
+ *,
56
+ sandbox: bool = False,
57
+ timeout: float = 30.0,
58
+ max_retries: int = 3,
59
+ backoff_factor: float = 0.5,
60
+ ) -> None:
61
+ self.client_id = client_id
62
+ self.client_secret = client_secret
63
+ self.account_id = account_id
64
+ self.base_url = SANDBOX_BASE_URL if sandbox else LIVE_BASE_URL
65
+ self.max_retries = max_retries
66
+ self.backoff_factor = backoff_factor
67
+
68
+ self._access_token: str | None = None
69
+ self._token_expires_at: float = 0.0
70
+ self._token_lock = threading.Lock()
71
+
72
+ self._http = httpx.Client(base_url=self.base_url, timeout=timeout)
73
+
74
+ # -- auth -----------------------------------------------------------
75
+
76
+ def _fetch_token_locked(self) -> None:
77
+ try:
78
+ response = self._http.post(
79
+ "/v1/auth/token/issue",
80
+ headers={
81
+ "Content-Type": "application/json",
82
+ "accountId": self.account_id,
83
+ },
84
+ json={
85
+ "grant_type": "client_credentials",
86
+ "client_id": self.client_id,
87
+ "client_secret": self.client_secret,
88
+ },
89
+ )
90
+ except httpx.HTTPError as exc:
91
+ raise NombaAuthError(f"Failed to reach Nomba auth endpoint: {exc}") from exc
92
+
93
+ if response.status_code >= 400:
94
+ raise NombaAuthError(
95
+ "Failed to obtain access token",
96
+ status_code=response.status_code,
97
+ response_body=_safe_json(response),
98
+ )
99
+
100
+ body = _safe_json(response) or {}
101
+ data = body.get("data", body)
102
+ token = data.get("access_token")
103
+ if not token:
104
+ raise NombaAuthError(
105
+ "Nomba auth response did not include an access_token",
106
+ response_body=body,
107
+ )
108
+
109
+ expires_in = data.get("expires_in", 3600)
110
+ self._access_token = token
111
+ # refresh a little early to avoid edge-of-expiry failures
112
+ self._token_expires_at = time.monotonic() + max(int(expires_in) - 60, 0)
113
+
114
+ def _ensure_token(self) -> str:
115
+ # fast path: token already valid, no lock needed
116
+ if self._access_token is not None and time.monotonic() < self._token_expires_at:
117
+ return self._access_token
118
+ # slow path: only one thread should actually fetch; others wait then
119
+ # re-check (the lock serializes them, avoiding a fetch stampede).
120
+ with self._token_lock:
121
+ if self._access_token is None or time.monotonic() >= self._token_expires_at:
122
+ self._fetch_token_locked()
123
+ assert self._access_token is not None
124
+ return self._access_token
125
+
126
+ def _invalidate_token(self) -> None:
127
+ with self._token_lock:
128
+ self._access_token = None
129
+
130
+ # -- request helpers --------------------------------------------------
131
+
132
+ def request(
133
+ self,
134
+ method: str,
135
+ path: str,
136
+ *,
137
+ json: dict[str, Any] | None = None,
138
+ params: dict[str, Any] | None = None,
139
+ extra_headers: dict[str, str] | None = None,
140
+ _attempt: int = 0,
141
+ _retry_on_auth_failure: bool = True,
142
+ ) -> dict[str, Any]:
143
+ token = self._ensure_token()
144
+ headers = {
145
+ "Authorization": f"Bearer {token}",
146
+ "accountId": self.account_id,
147
+ "Content-Type": "application/json",
148
+ }
149
+ if extra_headers:
150
+ headers.update(extra_headers)
151
+
152
+ try:
153
+ response = self._http.request(
154
+ method, path, json=json, params=params, headers=headers
155
+ )
156
+ except httpx.HTTPError as exc:
157
+ raise NombaAPIError(f"Request to {path} failed: {exc}") from exc
158
+
159
+ if response.status_code == 401 and _retry_on_auth_failure:
160
+ # token may have been invalidated server-side; force refresh once
161
+ self._invalidate_token()
162
+ return self.request(
163
+ method,
164
+ path,
165
+ json=json,
166
+ params=params,
167
+ extra_headers=extra_headers,
168
+ _attempt=_attempt,
169
+ _retry_on_auth_failure=False,
170
+ )
171
+
172
+ if response.status_code in RETRYABLE_STATUS_CODES and _attempt < self.max_retries:
173
+ delay = _compute_backoff(
174
+ _attempt, response.headers.get("Retry-After"), self.backoff_factor
175
+ )
176
+ time.sleep(delay)
177
+ return self.request(
178
+ method,
179
+ path,
180
+ json=json,
181
+ params=params,
182
+ extra_headers=extra_headers,
183
+ _attempt=_attempt + 1,
184
+ _retry_on_auth_failure=_retry_on_auth_failure,
185
+ )
186
+
187
+ body = _safe_json(response)
188
+
189
+ if response.status_code >= 400:
190
+ message = "Nomba API request failed"
191
+ code = None
192
+ if isinstance(body, dict):
193
+ message = body.get("description", message)
194
+ code = body.get("code")
195
+ raise NombaAPIError(
196
+ message,
197
+ status_code=response.status_code,
198
+ code=code,
199
+ response_body=body,
200
+ )
201
+
202
+ return body if isinstance(body, dict) else {"data": body}
203
+
204
+ def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
205
+ return self.request("GET", path, **kwargs)
206
+
207
+ def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
208
+ return self.request("POST", path, **kwargs)
209
+
210
+ def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
211
+ return self.request("PUT", path, **kwargs)
212
+
213
+ def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
214
+ return self.request("DELETE", path, **kwargs)
215
+
216
+ def close(self) -> None:
217
+ self._http.close()
218
+
219
+ def __enter__(self) -> "NombaClient":
220
+ return self
221
+
222
+ def __exit__(self, *exc_info: Any) -> None:
223
+ self.close()
224
+
225
+
226
+ def _safe_json(response: httpx.Response) -> Any:
227
+ try:
228
+ return response.json()
229
+ except ValueError:
230
+ return None
231
+
232
+
233
+ class AsyncNombaClient:
234
+ """
235
+ Async low-level HTTP client for the Nomba API (httpx.AsyncClient based).
236
+
237
+ Mirrors NombaClient's behavior: OAuth2 client-credentials auth, token
238
+ caching/refresh guarded by an asyncio.Lock, the `accountId` header, and
239
+ automatic retry-with-backoff for 429/5xx responses.
240
+
241
+ Example:
242
+ client = AsyncNombaClient(
243
+ client_id="...",
244
+ client_secret="...",
245
+ account_id="...",
246
+ )
247
+ account = await client.get(f"/v1/accounts/virtual/{account_ref}")
248
+ """
249
+
250
+ def __init__(
251
+ self,
252
+ client_id: str,
253
+ client_secret: str,
254
+ account_id: str,
255
+ *,
256
+ sandbox: bool = False,
257
+ timeout: float = 30.0,
258
+ max_retries: int = 3,
259
+ backoff_factor: float = 0.5,
260
+ ) -> None:
261
+ self.client_id = client_id
262
+ self.client_secret = client_secret
263
+ self.account_id = account_id
264
+ self.base_url = SANDBOX_BASE_URL if sandbox else LIVE_BASE_URL
265
+ self.max_retries = max_retries
266
+ self.backoff_factor = backoff_factor
267
+
268
+ self._access_token: str | None = None
269
+ self._token_expires_at: float = 0.0
270
+ self._token_lock = asyncio.Lock()
271
+
272
+ self._http = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
273
+
274
+ # -- auth -----------------------------------------------------------
275
+
276
+ async def _fetch_token_locked(self) -> None:
277
+ try:
278
+ response = await self._http.post(
279
+ "/v1/auth/token/issue",
280
+ headers={
281
+ "Content-Type": "application/json",
282
+ "accountId": self.account_id,
283
+ },
284
+ json={
285
+ "grant_type": "client_credentials",
286
+ "client_id": self.client_id,
287
+ "client_secret": self.client_secret,
288
+ },
289
+ )
290
+ except httpx.HTTPError as exc:
291
+ raise NombaAuthError(f"Failed to reach Nomba auth endpoint: {exc}") from exc
292
+
293
+ if response.status_code >= 400:
294
+ raise NombaAuthError(
295
+ "Failed to obtain access token",
296
+ status_code=response.status_code,
297
+ response_body=_safe_json(response),
298
+ )
299
+
300
+ body = _safe_json(response) or {}
301
+ data = body.get("data", body)
302
+ token = data.get("access_token")
303
+ if not token:
304
+ raise NombaAuthError(
305
+ "Nomba auth response did not include an access_token",
306
+ response_body=body,
307
+ )
308
+
309
+ expires_in = data.get("expires_in", 3600)
310
+ self._access_token = token
311
+ self._token_expires_at = time.monotonic() + max(int(expires_in) - 60, 0)
312
+
313
+ async def _ensure_token(self) -> str:
314
+ if self._access_token is not None and time.monotonic() < self._token_expires_at:
315
+ return self._access_token
316
+ async with self._token_lock:
317
+ if self._access_token is None or time.monotonic() >= self._token_expires_at:
318
+ await self._fetch_token_locked()
319
+ assert self._access_token is not None
320
+ return self._access_token
321
+
322
+ async def _invalidate_token(self) -> None:
323
+ async with self._token_lock:
324
+ self._access_token = None
325
+
326
+ # -- request helpers --------------------------------------------------
327
+
328
+ async def request(
329
+ self,
330
+ method: str,
331
+ path: str,
332
+ *,
333
+ json: dict[str, Any] | None = None,
334
+ params: dict[str, Any] | None = None,
335
+ extra_headers: dict[str, str] | None = None,
336
+ _attempt: int = 0,
337
+ _retry_on_auth_failure: bool = True,
338
+ ) -> dict[str, Any]:
339
+ token = await self._ensure_token()
340
+ headers = {
341
+ "Authorization": f"Bearer {token}",
342
+ "accountId": self.account_id,
343
+ "Content-Type": "application/json",
344
+ }
345
+ if extra_headers:
346
+ headers.update(extra_headers)
347
+
348
+ try:
349
+ response = await self._http.request(
350
+ method, path, json=json, params=params, headers=headers
351
+ )
352
+ except httpx.HTTPError as exc:
353
+ raise NombaAPIError(f"Request to {path} failed: {exc}") from exc
354
+
355
+ if response.status_code == 401 and _retry_on_auth_failure:
356
+ await self._invalidate_token()
357
+ return await self.request(
358
+ method,
359
+ path,
360
+ json=json,
361
+ params=params,
362
+ extra_headers=extra_headers,
363
+ _attempt=_attempt,
364
+ _retry_on_auth_failure=False,
365
+ )
366
+
367
+ if response.status_code in RETRYABLE_STATUS_CODES and _attempt < self.max_retries:
368
+ delay = _compute_backoff(
369
+ _attempt, response.headers.get("Retry-After"), self.backoff_factor
370
+ )
371
+ await asyncio.sleep(delay)
372
+ return await self.request(
373
+ method,
374
+ path,
375
+ json=json,
376
+ params=params,
377
+ extra_headers=extra_headers,
378
+ _attempt=_attempt + 1,
379
+ _retry_on_auth_failure=_retry_on_auth_failure,
380
+ )
381
+
382
+ body = _safe_json(response)
383
+
384
+ if response.status_code >= 400:
385
+ message = "Nomba API request failed"
386
+ code = None
387
+ if isinstance(body, dict):
388
+ message = body.get("description", message)
389
+ code = body.get("code")
390
+ raise NombaAPIError(
391
+ message,
392
+ status_code=response.status_code,
393
+ code=code,
394
+ response_body=body,
395
+ )
396
+
397
+ return body if isinstance(body, dict) else {"data": body}
398
+
399
+ async def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
400
+ return await self.request("GET", path, **kwargs)
401
+
402
+ async def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
403
+ return await self.request("POST", path, **kwargs)
404
+
405
+ async def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
406
+ return await self.request("PUT", path, **kwargs)
407
+
408
+ async def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
409
+ return await self.request("DELETE", path, **kwargs)
410
+
411
+ async def close(self) -> None:
412
+ await self._http.aclose()
413
+
414
+ async def __aenter__(self) -> "AsyncNombaClient":
415
+ return self
416
+
417
+ async def __aexit__(self, *exc_info: Any) -> None:
418
+ await self.close()