cinetpay-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.
Files changed (41) hide show
  1. cinetpay/__init__.py +175 -0
  2. cinetpay/api/__init__.py +17 -0
  3. cinetpay/api/async_balance.py +70 -0
  4. cinetpay/api/async_payment.py +118 -0
  5. cinetpay/api/async_transfer.py +118 -0
  6. cinetpay/api/balance.py +70 -0
  7. cinetpay/api/payment.py +118 -0
  8. cinetpay/api/transfer.py +118 -0
  9. cinetpay/async_client.py +290 -0
  10. cinetpay/auth/__init__.py +11 -0
  11. cinetpay/auth/async_authenticator.py +119 -0
  12. cinetpay/auth/authenticator.py +140 -0
  13. cinetpay/auth/token_store.py +72 -0
  14. cinetpay/client.py +256 -0
  15. cinetpay/constants/__init__.py +15 -0
  16. cinetpay/constants/channels.py +12 -0
  17. cinetpay/constants/currencies.py +14 -0
  18. cinetpay/constants/payment_methods.py +43 -0
  19. cinetpay/constants/statuses.py +44 -0
  20. cinetpay/errors/__init__.py +14 -0
  21. cinetpay/errors/api_error.py +45 -0
  22. cinetpay/errors/auth_error.py +12 -0
  23. cinetpay/errors/base.py +18 -0
  24. cinetpay/errors/network_errors.py +29 -0
  25. cinetpay/http/__init__.py +9 -0
  26. cinetpay/http/async_http_client.py +173 -0
  27. cinetpay/http/http_client.py +173 -0
  28. cinetpay/logger.py +70 -0
  29. cinetpay/py.typed +0 -0
  30. cinetpay/types/__init__.py +59 -0
  31. cinetpay/types/balance.py +35 -0
  32. cinetpay/types/config.py +139 -0
  33. cinetpay/types/payment.py +210 -0
  34. cinetpay/types/transfer.py +163 -0
  35. cinetpay/types/webhook.py +59 -0
  36. cinetpay/validation.py +163 -0
  37. cinetpay/webhooks/__init__.py +8 -0
  38. cinetpay/webhooks/notification.py +79 -0
  39. cinetpay_python-0.1.0.dist-info/METADATA +376 -0
  40. cinetpay_python-0.1.0.dist-info/RECORD +41 -0
  41. cinetpay_python-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,118 @@
1
+ """Synchronous transfer API — create transfers and check status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ from cinetpay.auth.authenticator import Authenticator
9
+ from cinetpay.errors.api_error import ApiError
10
+ from cinetpay.http.http_client import HttpClient
11
+ from cinetpay.types.transfer import (
12
+ TransferRequest,
13
+ TransferResponse,
14
+ TransferStatus,
15
+ serialize_transfer_request,
16
+ to_transfer_response,
17
+ to_transfer_status,
18
+ )
19
+ from cinetpay.validation import validate_transfer_request
20
+
21
+
22
+ class TransferApi:
23
+ """CinetPay money transfer API.
24
+
25
+ Accessible via ``client.transfer``.
26
+
27
+ Example::
28
+
29
+ transfer = client.transfer.create(request, "CI")
30
+ status = client.transfer.get_status(transfer.transaction_id, "CI")
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ http_client: HttpClient,
36
+ authenticators: dict[str, Authenticator],
37
+ ) -> None:
38
+ self._http_client = http_client
39
+ self._authenticators = authenticators
40
+
41
+ def create(
42
+ self,
43
+ request: TransferRequest,
44
+ country: str,
45
+ ) -> TransferResponse:
46
+ """Create a money transfer to a mobile money number.
47
+
48
+ Args:
49
+ request: Transfer parameters (amount, currency, phone, operator, etc.).
50
+ country: ISO country code (e.g. ``"CI"``, ``"SN"``).
51
+
52
+ Returns:
53
+ Response containing initial status and transaction identifiers.
54
+
55
+ Raises:
56
+ ApiError: If the API returns an error (e.g. INSUFFICIENT_BALANCE).
57
+ ValidationError: If a request field is invalid.
58
+ TypeError: If the country is not configured.
59
+ """
60
+ validate_transfer_request(request)
61
+ auth = self._resolve_authenticator(country)
62
+ body = serialize_transfer_request(request)
63
+
64
+ try:
65
+ token = auth.get_token()
66
+ raw: dict[str, Any] = self._http_client.post("/v1/transfer", body, token)
67
+ return to_transfer_response(raw)
68
+ except ApiError as exc:
69
+ if _is_token_expired(exc):
70
+ token = auth.force_refresh()
71
+ raw = self._http_client.post("/v1/transfer", body, token)
72
+ return to_transfer_response(raw)
73
+ raise
74
+
75
+ def get_status(
76
+ self,
77
+ transaction_id: str,
78
+ country: str,
79
+ ) -> TransferStatus:
80
+ """Retrieve the current status of a transfer.
81
+
82
+ Args:
83
+ transaction_id: CinetPay transaction ID or ``merchant_transaction_id``.
84
+ country: ISO country code (e.g. ``"CI"``, ``"SN"``).
85
+
86
+ Returns:
87
+ Transfer status with recipient information.
88
+
89
+ Raises:
90
+ ApiError: If the transaction is not found (NOT_FOUND).
91
+ """
92
+ auth = self._resolve_authenticator(country)
93
+ path = f"/v1/transfer/{quote(transaction_id, safe='')}"
94
+
95
+ try:
96
+ token = auth.get_token()
97
+ raw: dict[str, Any] = self._http_client.get(path, token)
98
+ return to_transfer_status(raw)
99
+ except ApiError as exc:
100
+ if _is_token_expired(exc):
101
+ token = auth.force_refresh()
102
+ raw = self._http_client.get(path, token)
103
+ return to_transfer_status(raw)
104
+ raise
105
+
106
+ def _resolve_authenticator(self, country: str) -> Authenticator:
107
+ key = country.upper()
108
+ auth = self._authenticators.get(key)
109
+ if auth is None:
110
+ available = ", ".join(self._authenticators.keys())
111
+ raise TypeError(
112
+ f'No credentials configured for country "{key}". Available: {available}'
113
+ )
114
+ return auth
115
+
116
+
117
+ def _is_token_expired(error: ApiError) -> bool:
118
+ return error.api_code == 1003 or error.api_status == "EXPIRED_TOKEN"
@@ -0,0 +1,290 @@
1
+ """Asynchronous CinetPay client — async entry point of the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse
6
+
7
+ from cinetpay.api.async_balance import AsyncBalanceApi
8
+ from cinetpay.api.async_payment import AsyncPaymentApi
9
+ from cinetpay.api.async_transfer import AsyncTransferApi
10
+ from cinetpay.auth.async_authenticator import AsyncAuthenticator
11
+ from cinetpay.auth.token_store import MemoryTokenStore
12
+ from cinetpay.http.async_http_client import AsyncHttpClient
13
+ from cinetpay.logger import LoggerProtocol, NoopLogger, StandardLibLogger
14
+ from cinetpay.types.config import (
15
+ API_KEY_PREFIX_LIVE,
16
+ API_KEY_PREFIX_TEST,
17
+ AsyncTokenStoreProtocol,
18
+ ClientConfig,
19
+ CountryCredentials,
20
+ DEFAULT_BASE_URL,
21
+ DEFAULT_TIMEOUT,
22
+ DEFAULT_TOKEN_TTL,
23
+ PRODUCTION_BASE_URL,
24
+ )
25
+
26
+ # Hostnames allowed for the base URL (SSRF protection)
27
+ _ALLOWED_HOSTS = frozenset({
28
+ "api.cinetpay.net", # Sandbox
29
+ "api.cinetpay.co", # Production
30
+ "localhost",
31
+ "127.0.0.1",
32
+ })
33
+
34
+
35
+ class _AsyncMemoryTokenStore:
36
+ """Async adapter around the sync :class:`MemoryTokenStore`."""
37
+
38
+ def __init__(self) -> None:
39
+ self._store = MemoryTokenStore()
40
+
41
+ async def get(self, key: str) -> str | None:
42
+ """Retrieve a token from the in-memory cache.
43
+
44
+ Args:
45
+ key: Cache key (e.g. ``cinetpay_token_ci``).
46
+
47
+ Returns:
48
+ The token string, or ``None`` if absent or expired.
49
+ """
50
+ return self._store.get(key)
51
+
52
+ async def set(self, key: str, value: str, ttl_seconds: int) -> None:
53
+ """Store a token with a time-to-live.
54
+
55
+ Args:
56
+ key: Cache key.
57
+ value: JWT token string.
58
+ ttl_seconds: Time-to-live in seconds.
59
+ """
60
+ self._store.set(key, value, ttl_seconds)
61
+
62
+ async def delete(self, key: str) -> None:
63
+ """Remove a token from the in-memory cache.
64
+
65
+ Args:
66
+ key: Cache key to delete.
67
+ """
68
+ self._store.delete(key)
69
+
70
+
71
+ class AsyncCinetPayClient:
72
+ """Asynchronous CinetPay SDK client.
73
+
74
+ Equivalent of :class:`~cinetpay.client.CinetPayClient` for async contexts
75
+ (FastAPI, aiohttp, etc.).
76
+
77
+ Example::
78
+
79
+ from cinetpay import AsyncCinetPayClient, ClientConfig, CountryCredentials
80
+
81
+ async with AsyncCinetPayClient(ClientConfig(
82
+ credentials={
83
+ "CI": CountryCredentials(api_key="sk_test_...", api_password="..."),
84
+ },
85
+ )) as client:
86
+ balance = await client.balance.get("CI")
87
+
88
+ Attributes:
89
+ payment: Async web payment API (initialize, get_status).
90
+ transfer: Async money transfer API (create, get_status).
91
+ balance: Async merchant balance API (get).
92
+ """
93
+
94
+ payment: AsyncPaymentApi
95
+ """Async web payment API -- initialization and status check."""
96
+
97
+ transfer: AsyncTransferApi
98
+ """Async money transfer API -- send and check status."""
99
+
100
+ balance: AsyncBalanceApi
101
+ """Async merchant account balance API."""
102
+
103
+ def __init__(self, config: ClientConfig) -> None:
104
+ credentials = config.credentials
105
+ if not credentials:
106
+ raise TypeError(
107
+ "At least one country credential must be provided in config.credentials"
108
+ )
109
+
110
+ token_ttl = config.token_ttl if config.token_ttl is not None else DEFAULT_TOKEN_TTL
111
+ timeout = config.timeout if config.timeout is not None else DEFAULT_TIMEOUT
112
+
113
+ # For async, we need an async-compatible token store
114
+ async_token_store: AsyncTokenStoreProtocol
115
+ if config.token_store is not None and isinstance(config.token_store, AsyncTokenStoreProtocol):
116
+ async_token_store = config.token_store # type: ignore[assignment]
117
+ else:
118
+ async_token_store = _AsyncMemoryTokenStore()
119
+
120
+ # Resolve logger
121
+ logger: LoggerProtocol
122
+ if config.logger is not None:
123
+ logger = config.logger
124
+ elif config.debug:
125
+ logger = StandardLibLogger()
126
+ else:
127
+ logger = NoopLogger()
128
+
129
+ # Detect environment
130
+ entries = list(credentials.items())
131
+ detected_env = _detect_environment(entries, logger)
132
+
133
+ # Resolve base URL
134
+ if config.base_url is not None:
135
+ base_url = config.base_url
136
+ elif detected_env == "live":
137
+ base_url = PRODUCTION_BASE_URL
138
+ else:
139
+ base_url = DEFAULT_BASE_URL
140
+
141
+ # Validate coherence
142
+ _validate_key_url_coherence(detected_env, base_url, logger)
143
+
144
+ # Enforce HTTPS
145
+ parsed = urlparse(base_url)
146
+ if parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1"):
147
+ raise TypeError(
148
+ "base_url must use HTTPS for security. "
149
+ "Use https:// or localhost for development."
150
+ )
151
+
152
+ # SSRF protection
153
+ if parsed.hostname and not any(
154
+ parsed.hostname == h or parsed.hostname.endswith(f".{h}")
155
+ for h in _ALLOWED_HOSTS
156
+ ):
157
+ logger.warn(
158
+ f'base_url hostname "{parsed.hostname}" is not a known CinetPay domain. '
159
+ f"Expected: {', '.join(sorted(_ALLOWED_HOSTS))}. Proceeding anyway."
160
+ )
161
+
162
+ if not parsed.scheme or not parsed.hostname:
163
+ raise TypeError(f'base_url "{base_url}" is not a valid URL.')
164
+
165
+ # Build async HTTP client and authenticators
166
+ http_client = AsyncHttpClient(base_url, timeout, logger)
167
+
168
+ self._authenticators: dict[str, AsyncAuthenticator] = {}
169
+ for country, creds in entries:
170
+ key = country.upper()
171
+ self._authenticators[key] = AsyncAuthenticator(
172
+ http_client, key, creds, token_ttl, async_token_store, logger,
173
+ )
174
+
175
+ self.payment = AsyncPaymentApi(http_client, self._authenticators)
176
+ self.transfer = AsyncTransferApi(http_client, self._authenticators)
177
+ self.balance = AsyncBalanceApi(http_client, self._authenticators)
178
+
179
+ self._http_client = http_client
180
+ self._logger = logger
181
+
182
+ logger.debug("AsyncCinetPayClient initialized", {
183
+ "countries": self.countries(),
184
+ "base_url": base_url,
185
+ "token_ttl": token_ttl,
186
+ "timeout": timeout,
187
+ })
188
+
189
+ def countries(self) -> list[str]:
190
+ """Return the list of configured country codes."""
191
+ return list(self._authenticators.keys())
192
+
193
+ async def revoke_token(self, country: str) -> None:
194
+ """Revoke the cached JWT token for a country.
195
+
196
+ Args:
197
+ country: ISO country code (e.g. ``"CI"``).
198
+
199
+ Raises:
200
+ TypeError: If the country is not configured.
201
+ """
202
+ key = country.upper()
203
+ auth = self._authenticators.get(key)
204
+ if auth is None:
205
+ raise TypeError(
206
+ f'No credentials configured for country "{key}". '
207
+ f"Available: {', '.join(self.countries())}"
208
+ )
209
+ await auth.clear_cache()
210
+
211
+ async def revoke_all_tokens(self) -> None:
212
+ """Revoke all cached JWT tokens for all configured countries."""
213
+ for auth in self._authenticators.values():
214
+ await auth.clear_cache()
215
+
216
+ async def close(self) -> None:
217
+ """Close the underlying async HTTP client and release resources."""
218
+ await self._http_client.close()
219
+
220
+ async def __aenter__(self) -> AsyncCinetPayClient:
221
+ return self
222
+
223
+ async def __aexit__(self, *args: object) -> None:
224
+ await self.close()
225
+
226
+ def __repr__(self) -> str:
227
+ """Prevent credential leakage via repr()."""
228
+ return f"AsyncCinetPayClient(countries={self.countries()!r})"
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Private helpers (shared logic with sync client)
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ def _detect_environment(
237
+ entries: list[tuple[str, CountryCredentials]],
238
+ logger: LoggerProtocol,
239
+ ) -> str:
240
+ """Detect test/live/mixed/unknown from API key prefixes."""
241
+ envs: set[str] = set()
242
+ for country, creds in entries:
243
+ if creds.api_key.startswith(API_KEY_PREFIX_TEST):
244
+ envs.add("test")
245
+ elif creds.api_key.startswith(API_KEY_PREFIX_LIVE):
246
+ envs.add("live")
247
+ else:
248
+ logger.warn(
249
+ f'API key for country {country.upper()} does not start with '
250
+ f'"{API_KEY_PREFIX_TEST}" or "{API_KEY_PREFIX_LIVE}". '
251
+ f"Expected format: sk_test_... (sandbox) or sk_live_... (production)."
252
+ )
253
+ envs.add("unknown")
254
+
255
+ if "test" in envs and "live" in envs:
256
+ logger.error(
257
+ "MIXED ENVIRONMENTS: some credentials use sk_test_ (sandbox) and "
258
+ "others use sk_live_ (production). This will cause authentication "
259
+ "failures. Use the same environment for all countries."
260
+ )
261
+ return "mixed"
262
+
263
+ if "live" in envs:
264
+ return "live"
265
+ if "test" in envs:
266
+ return "test"
267
+ return "unknown"
268
+
269
+
270
+ def _validate_key_url_coherence(
271
+ detected_env: str,
272
+ base_url: str,
273
+ logger: LoggerProtocol,
274
+ ) -> None:
275
+ """Warn if API key type and base URL are mismatched."""
276
+ is_sandbox_url = "cinetpay.net" in base_url
277
+ is_production_url = "cinetpay.co" in base_url
278
+
279
+ if detected_env == "live" and is_sandbox_url:
280
+ logger.error(
281
+ f"ENVIRONMENT MISMATCH: production keys (sk_live_) are used with "
282
+ f'sandbox URL ({base_url}). Use base_url="{PRODUCTION_BASE_URL}" '
283
+ f"for production, or remove base_url to auto-detect."
284
+ )
285
+ if detected_env == "test" and is_production_url:
286
+ logger.error(
287
+ f"ENVIRONMENT MISMATCH: sandbox keys (sk_test_) are used with "
288
+ f'production URL ({base_url}). Use base_url="{DEFAULT_BASE_URL}" '
289
+ f"for sandbox, or remove base_url to auto-detect."
290
+ )
@@ -0,0 +1,11 @@
1
+ """Authentication module — token store, sync and async authenticators."""
2
+
3
+ from cinetpay.auth.authenticator import Authenticator
4
+ from cinetpay.auth.async_authenticator import AsyncAuthenticator
5
+ from cinetpay.auth.token_store import MemoryTokenStore
6
+
7
+ __all__ = [
8
+ "AsyncAuthenticator",
9
+ "Authenticator",
10
+ "MemoryTokenStore",
11
+ ]
@@ -0,0 +1,119 @@
1
+ """Asynchronous JWT authenticator with asyncio.Lock stampede guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from cinetpay.errors.auth_error import AuthenticationError
9
+ from cinetpay.http.async_http_client import AsyncHttpClient
10
+ from cinetpay.logger import LoggerProtocol
11
+ from cinetpay.types.config import AsyncTokenStoreProtocol, CountryCredentials
12
+
13
+
14
+ class AsyncAuthenticator:
15
+ """Async JWT authenticator for a single country.
16
+
17
+ Equivalent of :class:`~cinetpay.auth.authenticator.Authenticator` but uses
18
+ :class:`asyncio.Lock` for the stampede guard and ``await`` for all I/O.
19
+
20
+ .. note::
21
+ Internal — used by :class:`~cinetpay.async_client.AsyncCinetPayClient`.
22
+ Do not instantiate directly.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ http_client: AsyncHttpClient,
28
+ country: str,
29
+ credentials: CountryCredentials,
30
+ token_ttl: int,
31
+ token_store: AsyncTokenStoreProtocol,
32
+ logger: LoggerProtocol,
33
+ ) -> None:
34
+ self._http_client = http_client
35
+ self._country = country
36
+ self._token_ttl = token_ttl
37
+ self._token_store = token_store
38
+ self._logger = logger
39
+ self._cache_key = f"cinetpay_token_{country.lower()}"
40
+
41
+ # Name-mangled to prevent accidental leakage
42
+ self.__api_key = credentials.api_key
43
+ self.__api_password = credentials.api_password
44
+
45
+ # Stampede guard
46
+ self._lock = asyncio.Lock()
47
+
48
+ async def get_token(self) -> str:
49
+ """Return a valid JWT token (from cache or freshly obtained).
50
+
51
+ Uses an :class:`asyncio.Lock` to prevent token stampede.
52
+
53
+ Returns:
54
+ JWT Bearer token.
55
+
56
+ Raises:
57
+ AuthenticationError: If the API does not return a token.
58
+ """
59
+ cached = await self._token_store.get(self._cache_key)
60
+ if cached is not None:
61
+ self._logger.debug(f"Token cache hit for {self._country}")
62
+ return cached
63
+
64
+ self._logger.debug(f"Token cache miss for {self._country}, authenticating...")
65
+
66
+ async with self._lock:
67
+ # Double-check after acquiring the lock
68
+ cached = await self._token_store.get(self._cache_key)
69
+ if cached is not None:
70
+ self._logger.debug(f"Token cache hit for {self._country} (after lock)")
71
+ return cached
72
+ return await self._authenticate()
73
+
74
+ async def force_refresh(self) -> str:
75
+ """Force token renewal by clearing cache then re-authenticating.
76
+
77
+ Returns:
78
+ New JWT token.
79
+
80
+ Raises:
81
+ AuthenticationError: If the API does not return a token.
82
+ """
83
+ self._logger.warn(f"Force refreshing token for {self._country} (expired or invalid)")
84
+ await self._token_store.delete(self._cache_key)
85
+ return await self._authenticate()
86
+
87
+ async def clear_cache(self) -> None:
88
+ """Delete the cached token for this country."""
89
+ await self._token_store.delete(self._cache_key)
90
+
91
+ def __repr__(self) -> str:
92
+ """Prevent credential leakage in logs and stack traces."""
93
+ return f"AsyncAuthenticator(country={self._country!r}, cache_key={self._cache_key!r})"
94
+
95
+ async def _authenticate(self) -> str:
96
+ """Call ``POST /v1/oauth/login`` and cache the returned token."""
97
+ try:
98
+ data: dict[str, Any] = await self._http_client.post(
99
+ "/v1/oauth/login",
100
+ {
101
+ "api_key": self.__api_key,
102
+ "api_password": self.__api_password,
103
+ },
104
+ )
105
+ except Exception:
106
+ raise AuthenticationError(
107
+ f"Authentication failed for country {self._country}"
108
+ ) from None
109
+
110
+ access_token = data.get("access_token")
111
+ if not access_token:
112
+ raise AuthenticationError(
113
+ f"Authentication failed for country {self._country}: "
114
+ "no access_token in response"
115
+ )
116
+
117
+ await self._token_store.set(self._cache_key, access_token, self._token_ttl)
118
+ self._logger.debug(f"Token cached for {self._country} (TTL: {self._token_ttl}s)")
119
+ return access_token
@@ -0,0 +1,140 @@
1
+ """Synchronous JWT authenticator with stampede guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Any
7
+
8
+ from cinetpay.errors.auth_error import AuthenticationError
9
+ from cinetpay.http.http_client import HttpClient
10
+ from cinetpay.logger import LoggerProtocol
11
+ from cinetpay.types.config import CountryCredentials, TokenStoreProtocol
12
+
13
+
14
+ class Authenticator:
15
+ """Manages JWT authentication for a single country.
16
+
17
+ Each CinetPay country has its own credentials (``api_key`` / ``api_password``).
18
+ The Authenticator obtains a JWT via ``POST /v1/oauth/login``, caches it in the
19
+ :class:`~cinetpay.types.config.TokenStoreProtocol`, and auto-renews on expiry.
20
+
21
+ Credentials are stored in name-mangled attributes and never exposed via
22
+ ``repr()``, ``str()``, or ``vars()``.
23
+
24
+ .. note::
25
+ Internal — used by :class:`~cinetpay.client.CinetPayClient`.
26
+ Do not instantiate directly.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ http_client: HttpClient,
32
+ country: str,
33
+ credentials: CountryCredentials,
34
+ token_ttl: int,
35
+ token_store: TokenStoreProtocol,
36
+ logger: LoggerProtocol,
37
+ ) -> None:
38
+ self._http_client = http_client
39
+ self._country = country
40
+ self._token_ttl = token_ttl
41
+ self._token_store = token_store
42
+ self._logger = logger
43
+ self._cache_key = f"cinetpay_token_{country.lower()}"
44
+
45
+ # Name-mangled to prevent accidental leakage
46
+ self.__api_key = credentials.api_key
47
+ self.__api_password = credentials.api_password
48
+
49
+ # Stampede guard: only one thread authenticates at a time
50
+ self._lock = threading.Lock()
51
+
52
+ def get_token(self) -> str:
53
+ """Return a valid JWT token (from cache or freshly obtained).
54
+
55
+ Uses a :class:`threading.Lock` to prevent token stampede: if multiple
56
+ threads call this concurrently with an empty cache, only one HTTP call
57
+ is made.
58
+
59
+ Returns:
60
+ JWT Bearer token.
61
+
62
+ Raises:
63
+ AuthenticationError: If the API does not return a token.
64
+ """
65
+ cached = self._token_store.get(self._cache_key)
66
+ if cached is not None:
67
+ self._logger.debug(f"Token cache hit for {self._country}")
68
+ return cached
69
+
70
+ self._logger.debug(f"Token cache miss for {self._country}, authenticating...")
71
+
72
+ with self._lock:
73
+ # Double-check after acquiring the lock (another thread may have filled the cache)
74
+ cached = self._token_store.get(self._cache_key)
75
+ if cached is not None:
76
+ self._logger.debug(f"Token cache hit for {self._country} (after lock)")
77
+ return cached
78
+ return self._authenticate()
79
+
80
+ def force_refresh(self) -> str:
81
+ """Force token renewal by clearing the cache then re-authenticating.
82
+
83
+ Called automatically by API classes when the API returns
84
+ ``EXPIRED_TOKEN`` (code 1003).
85
+
86
+ Returns:
87
+ New JWT token.
88
+
89
+ Raises:
90
+ AuthenticationError: If the API does not return a token.
91
+ """
92
+ self._logger.warn(f"Force refreshing token for {self._country} (expired or invalid)")
93
+ self._token_store.delete(self._cache_key)
94
+ return self._authenticate()
95
+
96
+ def clear_cache(self) -> None:
97
+ """Delete the cached token for this country.
98
+
99
+ Useful to force re-authentication on the next API call.
100
+ """
101
+ self._token_store.delete(self._cache_key)
102
+
103
+ def __repr__(self) -> str:
104
+ """Prevent credential leakage in logs and stack traces."""
105
+ return f"Authenticator(country={self._country!r}, cache_key={self._cache_key!r})"
106
+
107
+ def _authenticate(self) -> str:
108
+ """Call ``POST /v1/oauth/login`` and cache the returned token.
109
+
110
+ Returns:
111
+ JWT token.
112
+
113
+ Raises:
114
+ AuthenticationError: If the response is missing ``access_token``
115
+ or the HTTP call fails.
116
+ """
117
+ try:
118
+ data: dict[str, Any] = self._http_client.post(
119
+ "/v1/oauth/login",
120
+ {
121
+ "api_key": self.__api_key,
122
+ "api_password": self.__api_password,
123
+ },
124
+ )
125
+ except Exception:
126
+ # Sanitize to prevent credential leakage in stack traces
127
+ raise AuthenticationError(
128
+ f"Authentication failed for country {self._country}"
129
+ ) from None
130
+
131
+ access_token = data.get("access_token")
132
+ if not access_token:
133
+ raise AuthenticationError(
134
+ f"Authentication failed for country {self._country}: "
135
+ "no access_token in response"
136
+ )
137
+
138
+ self._token_store.set(self._cache_key, access_token, self._token_ttl)
139
+ self._logger.debug(f"Token cached for {self._country} (TTL: {self._token_ttl}s)")
140
+ return access_token