prismadata 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PrismaData
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: prismadata
3
+ Version: 0.1.0
4
+ Summary: Python client for the PrismaData location intelligence API
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: geolocation,geocoding,data-science,brazil,location-intelligence
8
+ Author: PrismaData
9
+ Author-email: contato@prismadata.io
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Provides-Extra: all
25
+ Provides-Extra: cache
26
+ Provides-Extra: pandas
27
+ Provides-Extra: progress
28
+ Provides-Extra: sklearn
29
+ Requires-Dist: diskcache (>=5.0) ; extra == "cache" or extra == "all"
30
+ Requires-Dist: httpx (>=0.27,<0.28)
31
+ Requires-Dist: pandas (>=1.5) ; extra == "pandas" or extra == "sklearn" or extra == "all"
32
+ Requires-Dist: scikit-learn (>=1.0) ; extra == "sklearn" or extra == "all"
33
+ Requires-Dist: tenacity (>=9.0,<10.0)
34
+ Requires-Dist: tqdm (>=4.0) ; extra == "progress" or extra == "all"
35
+ Project-URL: Bug Tracker, https://github.com/prismadata/python-client/issues
36
+ Project-URL: Changelog, https://github.com/prismadata/python-client/blob/main/CHANGELOG.md
37
+ Project-URL: Homepage, https://prismadata.io
38
+ Project-URL: Repository, https://github.com/prismadata/python-client
39
+ Description-Content-Type: text/markdown
40
+
41
+ # prismadata
42
+
43
+ Python client for the [PrismaData](https://prismadata.io) location intelligence API.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install prismadata
49
+ ```
50
+
51
+ With optional extras:
52
+
53
+ ```bash
54
+ pip install prismadata[pandas] # DataFrame enrichment
55
+ pip install prismadata[sklearn] # scikit-learn transformer
56
+ pip install prismadata[all] # everything (pandas, sklearn, cache, progress bars)
57
+ ```
58
+
59
+ ## Quick Start
60
+
61
+ ```python
62
+ from prismadata import Client
63
+
64
+ client = Client(api_key="your-api-key")
65
+
66
+ # Geocode an address
67
+ result = client.geocode(full_address="Av Paulista 1000, Sao Paulo")
68
+ print(result["prismadata__geocoder__latitude"], result["prismadata__geocoder__longitude"])
69
+
70
+ # Query slum proximity
71
+ slum = client.slum(lat=-23.56, lng=-46.65)
72
+ print(slum["prismadata__favela__distancia_m"])
73
+
74
+ # Calculate a route
75
+ route = client.route([(-23.56, -46.65), (-23.57, -46.66)])
76
+ print(route["prismadata__routing_route__distancia_m"])
77
+ ```
78
+
79
+ ## Authentication
80
+
81
+ The client supports two authentication methods:
82
+
83
+ ```python
84
+ # Using API key
85
+ client = Client(api_key="your-api-key")
86
+
87
+ # Using username and password
88
+ client = Client(username="your-user", password="your-pass")
89
+ ```
90
+
91
+ Credentials can also be provided via environment variables:
92
+
93
+ ```bash
94
+ export PRISMADATA_APIKEY="your-api-key"
95
+ # or
96
+ export PRISMADATA_USERNAME="your-user"
97
+ export PRISMADATA_PASSWORD="your-pass"
98
+ ```
99
+
100
+ ```python
101
+ # Picks up credentials from environment automatically
102
+ client = Client()
103
+ ```
104
+
105
+ Credential resolution order: explicit `api_key` > explicit `username`/`password` > `PRISMADATA_APIKEY` env var > `PRISMADATA_USERNAME`+`PRISMADATA_PASSWORD` env vars.
106
+
107
+ ## DataFrame Enrichment
108
+
109
+ ```python
110
+ import pandas as pd
111
+ from prismadata import Client
112
+
113
+ client = Client(api_key="your-api-key")
114
+
115
+ df = pd.DataFrame({
116
+ "lat": [-23.56, -23.57, -23.58],
117
+ "lng": [-46.65, -46.66, -46.67],
118
+ })
119
+
120
+ enriched = client.enrich(df, services=["slum", "income_static", "infosc"])
121
+ print(enriched.columns.tolist())
122
+ # ['lat', 'lng', 'prismadata__favela__distancia_m', ..., 'prismadata__personal_income_static__percentil_br', ...]
123
+
124
+ # Use clean_columns=True for shorter column names
125
+ client = Client(api_key="your-api-key", clean_columns=True)
126
+ enriched = client.enrich(df, services=["slum"])
127
+ # Column names: 'favela_distancia_m', 'favela_nome', ...
128
+ ```
129
+
130
+ ## scikit-learn Pipeline
131
+
132
+ ```python
133
+ from sklearn.pipeline import Pipeline
134
+ from sklearn.ensemble import RandomForestClassifier
135
+ from prismadata.sklearn import PrismaDataTransformer
136
+
137
+ pipe = Pipeline([
138
+ ("enrich", PrismaDataTransformer(
139
+ api_key="your-api-key",
140
+ services=["slum", "income_static"],
141
+ )),
142
+ ("model", RandomForestClassifier()),
143
+ ])
144
+
145
+ pipe.fit(X_train, y_train)
146
+ ```
147
+
148
+ ## Async Client
149
+
150
+ All methods are available asynchronously via `AsyncClient`:
151
+
152
+ ```python
153
+ from prismadata import AsyncClient
154
+
155
+ async with await AsyncClient.create(api_key="your-api-key") as client:
156
+ result = await client.slum(lat=-23.56, lng=-46.65)
157
+ print(result)
158
+
159
+ # Batch and enrichment work the same way
160
+ enriched = await client.enrich(df, services=["slum", "income_static"])
161
+ ```
162
+
163
+ ## Error Handling
164
+
165
+ ```python
166
+ from prismadata import Client
167
+ from prismadata.exceptions import (
168
+ AuthenticationError,
169
+ BatchError,
170
+ RateLimitError,
171
+ PrismaDataError,
172
+ )
173
+
174
+ try:
175
+ result = client.slum_batch(large_point_dict)
176
+ except BatchError as e:
177
+ # Some chunks succeeded, some failed
178
+ print(f"Got {len(e.partial_results)} results, {len(e.failed_keys)} failed")
179
+ for key, value in e.partial_results.items():
180
+ process(key, value) # use what succeeded
181
+ retry(e.failed_keys) # retry what failed
182
+ except RateLimitError:
183
+ print("Rate limit exceeded, wait and retry")
184
+ except AuthenticationError:
185
+ print("Invalid credentials")
186
+ except PrismaDataError as e:
187
+ print(f"API error {e.status_code}: {e}")
188
+ ```
189
+
190
+ ## Available Methods
191
+
192
+ ### Geocoding
193
+ - `client.geocode(full_address=..., zipcode=..., city=..., state=...)` - Address to coordinates
194
+ - `client.reverse_geocode(lat, lng)` - Coordinates to address
195
+
196
+ ### Location Services
197
+ - `client.slum(lat, lng)` - Nearest slum/favela proximity
198
+ - `client.prison(lat, lng)` - Nearest prison proximity
199
+ - `client.border(lat, lng)` - Border proximity
200
+ - `client.infosc(lat, lng)` - Census sector info
201
+ - `client.income_static(lat, lng)` - Income percentiles
202
+ - `client.income_pdf(lat, lng, gender=..., age=...)` - Detailed income statistics
203
+
204
+ ### Routing
205
+ - `client.route(points, profile="car")` - Route between points
206
+ - `client.isochrone(lat, lng, time_limit=600, profile="car")` - Reachable area
207
+
208
+ ### Address Validation
209
+ - `client.compare_address(lat, lng, full_address=...)` - Compare address with coordinates
210
+ - `client.validate_address(locations, addresses)` - Validate against location history
211
+ - `client.cluster_locations(locations)` - Cluster location history
212
+
213
+ ### Credit
214
+ - `client.precatory(cpf_cnpj=...)` - Credit summary (precatorios/RPVs)
215
+ - `client.precatory_detail(cpf_cnpj=...)` - Detailed credit list
216
+
217
+ ### Batch Operations
218
+ - `client.slum_batch(points)` - Batch slum queries
219
+ - `client.prison_batch(points)` - Batch prison queries
220
+ - `client.border_batch(points)` - Batch border queries
221
+ - `client.infosc_batch(points)` - Batch census sector queries
222
+ - `client.route_batch(items, profile="car")` - Batch routing
223
+ - `client.isochrone_batch(items, profile="car")` - Batch isochrones
224
+
225
+ ### Aggregator
226
+ - `client.aggregate(lat, lng, services=[...])` - Multiple services in one call
227
+ - `client.aggregate_batch(points, services=[...])` - Batch aggregation
228
+ - `client.geocode_aggregate(full_address=..., services=[...])` - Geocode + aggregate
229
+
230
+ ## Configuration
231
+
232
+ ```python
233
+ client = Client(
234
+ api_key="your-key",
235
+ timeout=30, # Request timeout (seconds)
236
+ cache=True, # Enable disk cache (requires diskcache)
237
+ cache_ttl=86400, # Cache TTL (seconds)
238
+ clean_columns=False, # Keep 'prismadata__' prefix (default)
239
+ show_progress=True, # Show tqdm progress bars
240
+ app_name="my-app", # Sent as X-App header on every request
241
+ )
242
+ ```
243
+
244
+ ## License
245
+
246
+ MIT
247
+
@@ -0,0 +1,206 @@
1
+ # prismadata
2
+
3
+ Python client for the [PrismaData](https://prismadata.io) location intelligence API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install prismadata
9
+ ```
10
+
11
+ With optional extras:
12
+
13
+ ```bash
14
+ pip install prismadata[pandas] # DataFrame enrichment
15
+ pip install prismadata[sklearn] # scikit-learn transformer
16
+ pip install prismadata[all] # everything (pandas, sklearn, cache, progress bars)
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ from prismadata import Client
23
+
24
+ client = Client(api_key="your-api-key")
25
+
26
+ # Geocode an address
27
+ result = client.geocode(full_address="Av Paulista 1000, Sao Paulo")
28
+ print(result["prismadata__geocoder__latitude"], result["prismadata__geocoder__longitude"])
29
+
30
+ # Query slum proximity
31
+ slum = client.slum(lat=-23.56, lng=-46.65)
32
+ print(slum["prismadata__favela__distancia_m"])
33
+
34
+ # Calculate a route
35
+ route = client.route([(-23.56, -46.65), (-23.57, -46.66)])
36
+ print(route["prismadata__routing_route__distancia_m"])
37
+ ```
38
+
39
+ ## Authentication
40
+
41
+ The client supports two authentication methods:
42
+
43
+ ```python
44
+ # Using API key
45
+ client = Client(api_key="your-api-key")
46
+
47
+ # Using username and password
48
+ client = Client(username="your-user", password="your-pass")
49
+ ```
50
+
51
+ Credentials can also be provided via environment variables:
52
+
53
+ ```bash
54
+ export PRISMADATA_APIKEY="your-api-key"
55
+ # or
56
+ export PRISMADATA_USERNAME="your-user"
57
+ export PRISMADATA_PASSWORD="your-pass"
58
+ ```
59
+
60
+ ```python
61
+ # Picks up credentials from environment automatically
62
+ client = Client()
63
+ ```
64
+
65
+ Credential resolution order: explicit `api_key` > explicit `username`/`password` > `PRISMADATA_APIKEY` env var > `PRISMADATA_USERNAME`+`PRISMADATA_PASSWORD` env vars.
66
+
67
+ ## DataFrame Enrichment
68
+
69
+ ```python
70
+ import pandas as pd
71
+ from prismadata import Client
72
+
73
+ client = Client(api_key="your-api-key")
74
+
75
+ df = pd.DataFrame({
76
+ "lat": [-23.56, -23.57, -23.58],
77
+ "lng": [-46.65, -46.66, -46.67],
78
+ })
79
+
80
+ enriched = client.enrich(df, services=["slum", "income_static", "infosc"])
81
+ print(enriched.columns.tolist())
82
+ # ['lat', 'lng', 'prismadata__favela__distancia_m', ..., 'prismadata__personal_income_static__percentil_br', ...]
83
+
84
+ # Use clean_columns=True for shorter column names
85
+ client = Client(api_key="your-api-key", clean_columns=True)
86
+ enriched = client.enrich(df, services=["slum"])
87
+ # Column names: 'favela_distancia_m', 'favela_nome', ...
88
+ ```
89
+
90
+ ## scikit-learn Pipeline
91
+
92
+ ```python
93
+ from sklearn.pipeline import Pipeline
94
+ from sklearn.ensemble import RandomForestClassifier
95
+ from prismadata.sklearn import PrismaDataTransformer
96
+
97
+ pipe = Pipeline([
98
+ ("enrich", PrismaDataTransformer(
99
+ api_key="your-api-key",
100
+ services=["slum", "income_static"],
101
+ )),
102
+ ("model", RandomForestClassifier()),
103
+ ])
104
+
105
+ pipe.fit(X_train, y_train)
106
+ ```
107
+
108
+ ## Async Client
109
+
110
+ All methods are available asynchronously via `AsyncClient`:
111
+
112
+ ```python
113
+ from prismadata import AsyncClient
114
+
115
+ async with await AsyncClient.create(api_key="your-api-key") as client:
116
+ result = await client.slum(lat=-23.56, lng=-46.65)
117
+ print(result)
118
+
119
+ # Batch and enrichment work the same way
120
+ enriched = await client.enrich(df, services=["slum", "income_static"])
121
+ ```
122
+
123
+ ## Error Handling
124
+
125
+ ```python
126
+ from prismadata import Client
127
+ from prismadata.exceptions import (
128
+ AuthenticationError,
129
+ BatchError,
130
+ RateLimitError,
131
+ PrismaDataError,
132
+ )
133
+
134
+ try:
135
+ result = client.slum_batch(large_point_dict)
136
+ except BatchError as e:
137
+ # Some chunks succeeded, some failed
138
+ print(f"Got {len(e.partial_results)} results, {len(e.failed_keys)} failed")
139
+ for key, value in e.partial_results.items():
140
+ process(key, value) # use what succeeded
141
+ retry(e.failed_keys) # retry what failed
142
+ except RateLimitError:
143
+ print("Rate limit exceeded, wait and retry")
144
+ except AuthenticationError:
145
+ print("Invalid credentials")
146
+ except PrismaDataError as e:
147
+ print(f"API error {e.status_code}: {e}")
148
+ ```
149
+
150
+ ## Available Methods
151
+
152
+ ### Geocoding
153
+ - `client.geocode(full_address=..., zipcode=..., city=..., state=...)` - Address to coordinates
154
+ - `client.reverse_geocode(lat, lng)` - Coordinates to address
155
+
156
+ ### Location Services
157
+ - `client.slum(lat, lng)` - Nearest slum/favela proximity
158
+ - `client.prison(lat, lng)` - Nearest prison proximity
159
+ - `client.border(lat, lng)` - Border proximity
160
+ - `client.infosc(lat, lng)` - Census sector info
161
+ - `client.income_static(lat, lng)` - Income percentiles
162
+ - `client.income_pdf(lat, lng, gender=..., age=...)` - Detailed income statistics
163
+
164
+ ### Routing
165
+ - `client.route(points, profile="car")` - Route between points
166
+ - `client.isochrone(lat, lng, time_limit=600, profile="car")` - Reachable area
167
+
168
+ ### Address Validation
169
+ - `client.compare_address(lat, lng, full_address=...)` - Compare address with coordinates
170
+ - `client.validate_address(locations, addresses)` - Validate against location history
171
+ - `client.cluster_locations(locations)` - Cluster location history
172
+
173
+ ### Credit
174
+ - `client.precatory(cpf_cnpj=...)` - Credit summary (precatorios/RPVs)
175
+ - `client.precatory_detail(cpf_cnpj=...)` - Detailed credit list
176
+
177
+ ### Batch Operations
178
+ - `client.slum_batch(points)` - Batch slum queries
179
+ - `client.prison_batch(points)` - Batch prison queries
180
+ - `client.border_batch(points)` - Batch border queries
181
+ - `client.infosc_batch(points)` - Batch census sector queries
182
+ - `client.route_batch(items, profile="car")` - Batch routing
183
+ - `client.isochrone_batch(items, profile="car")` - Batch isochrones
184
+
185
+ ### Aggregator
186
+ - `client.aggregate(lat, lng, services=[...])` - Multiple services in one call
187
+ - `client.aggregate_batch(points, services=[...])` - Batch aggregation
188
+ - `client.geocode_aggregate(full_address=..., services=[...])` - Geocode + aggregate
189
+
190
+ ## Configuration
191
+
192
+ ```python
193
+ client = Client(
194
+ api_key="your-key",
195
+ timeout=30, # Request timeout (seconds)
196
+ cache=True, # Enable disk cache (requires diskcache)
197
+ cache_ttl=86400, # Cache TTL (seconds)
198
+ clean_columns=False, # Keep 'prismadata__' prefix (default)
199
+ show_progress=True, # Show tqdm progress bars
200
+ app_name="my-app", # Sent as X-App header on every request
201
+ )
202
+ ```
203
+
204
+ ## License
205
+
206
+ MIT
@@ -0,0 +1,69 @@
1
+ """PrismaData - Python client for location intelligence API.
2
+
3
+ Usage::
4
+
5
+ from prismadata import Client
6
+
7
+ client = Client(api_key="your-key")
8
+ result = client.geocode(full_address="Av Paulista 1000, Sao Paulo")
9
+
10
+ Async usage::
11
+
12
+ from prismadata import AsyncClient
13
+
14
+ async with await AsyncClient.create(api_key="your-key") as client:
15
+ result = await client.geocode(full_address="Av Paulista 1000, Sao Paulo")
16
+ """
17
+
18
+ from ._types import (
19
+ BorderResult,
20
+ GeocodeResult,
21
+ IncomePdfResult,
22
+ IncomeStaticResult,
23
+ InfoscResult,
24
+ IsochroneResult,
25
+ PrecatoryResult,
26
+ PrisonResult,
27
+ ReverseGeocodeResult,
28
+ RouteResult,
29
+ RoutingBatchResult,
30
+ SlumResult,
31
+ UserInfo,
32
+ )
33
+ from .async_client import AsyncClient
34
+ from .client import Client
35
+ from .exceptions import (
36
+ AuthenticationError,
37
+ BatchError,
38
+ PrismaDataError,
39
+ QuotaExhaustedError,
40
+ RateLimitError,
41
+ ValidationError,
42
+ )
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "AsyncClient",
48
+ "Client",
49
+ "__version__",
50
+ "AuthenticationError",
51
+ "BatchError",
52
+ "PrismaDataError",
53
+ "QuotaExhaustedError",
54
+ "RateLimitError",
55
+ "ValidationError",
56
+ "BorderResult",
57
+ "GeocodeResult",
58
+ "IncomePdfResult",
59
+ "IncomeStaticResult",
60
+ "InfoscResult",
61
+ "IsochroneResult",
62
+ "PrecatoryResult",
63
+ "PrisonResult",
64
+ "ReverseGeocodeResult",
65
+ "RouteResult",
66
+ "RoutingBatchResult",
67
+ "SlumResult",
68
+ "UserInfo",
69
+ ]
@@ -0,0 +1,112 @@
1
+ """Async JWT authentication manager for the PrismaData API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ._auth import _decode_jwt_claims
13
+ from ._constants import TOKEN_RENEW_MARGIN
14
+ from .exceptions import AuthenticationError
15
+
16
+ logger = logging.getLogger("prismadata.async_auth")
17
+
18
+
19
+ class AsyncAuthManager:
20
+ """Manages JWT token lifecycle asynchronously."""
21
+
22
+ def __init__(
23
+ self,
24
+ base_url: str,
25
+ timeout: int,
26
+ *,
27
+ api_key: str | None = None,
28
+ username: str | None = None,
29
+ password: str | None = None,
30
+ ) -> None:
31
+ self._base_url = base_url
32
+ self._timeout = timeout
33
+ self._api_key = api_key
34
+ self._username = username
35
+ self._password = password
36
+ self._token: str | None = None
37
+ self._token_exp: float = 0.0
38
+ self._rate_limit: float | None = None
39
+ self._sandbox: bool = False
40
+ self._lock = asyncio.Lock()
41
+ self._http_client: httpx.AsyncClient | None = None
42
+
43
+ def set_http_client(self, client: httpx.AsyncClient) -> None:
44
+ """Attach a shared httpx.AsyncClient for token refresh requests."""
45
+ self._http_client = client
46
+
47
+ @property
48
+ def rate_limit(self) -> float | None:
49
+ return self._rate_limit
50
+
51
+ @property
52
+ def is_sandbox(self) -> bool:
53
+ return self._sandbox
54
+
55
+ async def authenticate(self) -> None:
56
+ """Force token fetch immediately."""
57
+ async with self._lock:
58
+ await self._fetch_token()
59
+
60
+ async def ensure_valid_token(self) -> None:
61
+ """Ensure a valid token is available, refreshing if needed."""
62
+ if self._token and time.time() < (self._token_exp - TOKEN_RENEW_MARGIN):
63
+ return
64
+ async with self._lock:
65
+ if self._token and time.time() < (self._token_exp - TOKEN_RENEW_MARGIN):
66
+ return
67
+ await self._fetch_token()
68
+
69
+ def get_headers(self) -> dict[str, str]:
70
+ """Return auth headers (sync — call ensure_valid_token first)."""
71
+ if self._api_key:
72
+ return {"X-Apikey": self._api_key}
73
+ return {"Authorization": f"Bearer {self._token}"}
74
+
75
+ async def _try_refresh_claims(self) -> None:
76
+ """Best-effort claim refresh in API key mode."""
77
+ try:
78
+ await self.ensure_valid_token()
79
+ except Exception as exc:
80
+ logger.warning("Claim refresh failed (API key mode): %s", exc)
81
+
82
+ async def _fetch_token(self) -> None:
83
+ mode = "api_key" if self._api_key else "credentials"
84
+ logger.debug("Fetching token (mode=%s)", mode)
85
+
86
+ if self._api_key:
87
+ data = {"api_key": self._api_key}
88
+ else:
89
+ data = {"username": self._username, "password": self._password}
90
+
91
+ url = f"{self._base_url}/auth/token"
92
+ try:
93
+ if self._http_client is not None:
94
+ resp = await self._http_client.post(url, data=data)
95
+ else:
96
+ async with httpx.AsyncClient(timeout=self._timeout) as http:
97
+ resp = await http.post(url, data=data)
98
+ except httpx.HTTPError as exc:
99
+ raise AuthenticationError(f"Failed to connect to auth endpoint: {exc}") from exc
100
+
101
+ if resp.status_code in (401, 403):
102
+ raise AuthenticationError(f"Authentication failed: {resp.text}")
103
+ resp.raise_for_status()
104
+
105
+ body = resp.json()
106
+ self._token = body["access_token"]
107
+ claims = _decode_jwt_claims(self._token)
108
+ self._token_exp = claims.get("exp", time.time() + 3600)
109
+ self._rate_limit = claims.get("rate_limit")
110
+ self._sandbox = claims.get("sandbox", False)
111
+ expires_in = self._token_exp - time.time()
112
+ logger.debug("Token obtained, expires in %.0fs, rate_limit=%s", expires_in, self._rate_limit)