python-fotoware-client 0.2.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.
- fotoware_client/__init__.py +62 -0
- fotoware_client/auth.py +302 -0
- fotoware_client/client.py +919 -0
- fotoware_client/constants.py +13 -0
- fotoware_client/credentials.py +59 -0
- fotoware_client/exceptions.py +110 -0
- fotoware_client/limits.py +133 -0
- fotoware_client/models.py +190 -0
- fotoware_client/py.typed +0 -0
- fotoware_client/retry.py +166 -0
- fotoware_client/settings.py +54 -0
- python_fotoware_client-0.2.0.dist-info/METADATA +160 -0
- python_fotoware_client-0.2.0.dist-info/RECORD +16 -0
- python_fotoware_client-0.2.0.dist-info/WHEEL +5 -0
- python_fotoware_client-0.2.0.dist-info/licenses/LICENSE +21 -0
- python_fotoware_client-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""FotoWare API Client - Layer 1: Pure API."""
|
|
2
|
+
|
|
3
|
+
from fotoware_client.auth import OAuthAuthenticator, OAuthToken
|
|
4
|
+
from fotoware_client.client import FotoWareClient
|
|
5
|
+
from fotoware_client.constants import DEFAULT_TIMEOUT
|
|
6
|
+
from fotoware_client.credentials import credentials_file, load_credentials
|
|
7
|
+
from fotoware_client.exceptions import (
|
|
8
|
+
APIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
FotoWareError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
RetryExhaustedError,
|
|
15
|
+
)
|
|
16
|
+
from fotoware_client.limits import (
|
|
17
|
+
QUERY_SIZE_BUDGET,
|
|
18
|
+
QUERY_SIZE_LIMIT,
|
|
19
|
+
QueryTooLargeError,
|
|
20
|
+
estimate_query_size,
|
|
21
|
+
)
|
|
22
|
+
from fotoware_client.models import Archive, Asset, SearchResult
|
|
23
|
+
from fotoware_client.retry import NO_RETRY, STANDARD_RETRY, RetryConfig
|
|
24
|
+
from fotoware_client.settings import ServerSettings
|
|
25
|
+
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# Client
|
|
30
|
+
"FotoWareClient",
|
|
31
|
+
# Models
|
|
32
|
+
"Archive",
|
|
33
|
+
"Asset",
|
|
34
|
+
"SearchResult",
|
|
35
|
+
# Auth
|
|
36
|
+
"OAuthAuthenticator",
|
|
37
|
+
"OAuthToken",
|
|
38
|
+
# Exceptions
|
|
39
|
+
"FotoWareError",
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"APIError",
|
|
44
|
+
"ConfigurationError",
|
|
45
|
+
"RetryExhaustedError",
|
|
46
|
+
"QueryTooLargeError",
|
|
47
|
+
# Installation
|
|
48
|
+
"ServerSettings",
|
|
49
|
+
# Credentials
|
|
50
|
+
"credentials_file",
|
|
51
|
+
"load_credentials",
|
|
52
|
+
# Limits
|
|
53
|
+
"QUERY_SIZE_BUDGET",
|
|
54
|
+
"QUERY_SIZE_LIMIT",
|
|
55
|
+
"estimate_query_size",
|
|
56
|
+
# Retry
|
|
57
|
+
"RetryConfig",
|
|
58
|
+
"STANDARD_RETRY",
|
|
59
|
+
"NO_RETRY",
|
|
60
|
+
# Constants
|
|
61
|
+
"DEFAULT_TIMEOUT",
|
|
62
|
+
]
|
fotoware_client/auth.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""OAuth 2.0 authentication for FotoWare API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from urllib3.util.ssl_ import create_urllib3_context
|
|
14
|
+
|
|
15
|
+
from fotoware_client.exceptions import AuthenticationError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class OAuthToken:
|
|
22
|
+
"""OAuth 2.0 access token with expiration tracking.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
access_token: Bearer token for API requests
|
|
26
|
+
token_type: Token type (usually 'Bearer')
|
|
27
|
+
expires_at: Unix timestamp when token expires
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
access_token: str
|
|
31
|
+
token_type: str
|
|
32
|
+
expires_at: float
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_expired(self) -> bool:
|
|
36
|
+
"""Check if token is expired or will expire in next 30 seconds."""
|
|
37
|
+
return time.time() >= (self.expires_at - 30)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _failure_kind(exc: BaseException) -> str:
|
|
41
|
+
"""Name the failure the way the log should carry it."""
|
|
42
|
+
if isinstance(exc, requests.exceptions.HTTPError):
|
|
43
|
+
return "server_error"
|
|
44
|
+
if isinstance(exc, requests.exceptions.Timeout):
|
|
45
|
+
return "timeout"
|
|
46
|
+
return "network_error"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_transient(exc: BaseException) -> bool:
|
|
50
|
+
"""Whether waiting could plausibly change the answer.
|
|
51
|
+
|
|
52
|
+
Connection problems, TLS trouble and timeouts: yes. FotoWare answering 5xx or
|
|
53
|
+
429: yes as well -- the credentials are fine, the far end is not.
|
|
54
|
+
|
|
55
|
+
A 400 or a 401: no. Seven rounds of backoff would turn a clear answer into
|
|
56
|
+
a ninety second wait and then the same answer, and the run would report a
|
|
57
|
+
network problem where the real one is a credential.
|
|
58
|
+
|
|
59
|
+
The read timeout is why this exists. `ConnectTimeout` is a
|
|
60
|
+
`ConnectionError` and was always retried; `ReadTimeout` is not, so FotoWare
|
|
61
|
+
accepting the connection and then staying silent for 30 seconds aborted an
|
|
62
|
+
otherwise healthy run on the first attempt.
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(
|
|
65
|
+
exc,
|
|
66
|
+
(
|
|
67
|
+
ConnectionResetError,
|
|
68
|
+
requests.exceptions.ConnectionError,
|
|
69
|
+
requests.exceptions.SSLError,
|
|
70
|
+
requests.exceptions.Timeout,
|
|
71
|
+
),
|
|
72
|
+
):
|
|
73
|
+
return True
|
|
74
|
+
if isinstance(exc, requests.exceptions.HTTPError):
|
|
75
|
+
status = getattr(exc.response, "status_code", None)
|
|
76
|
+
return status is not None and (status >= 500 or status == 429)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OAuthAuthenticator:
|
|
81
|
+
"""Thread-safe OAuth 2.0 authenticator for FotoWare API.
|
|
82
|
+
|
|
83
|
+
Handles token acquisition and automatic refresh when expired.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
token_url: str,
|
|
89
|
+
client_id: str,
|
|
90
|
+
client_secret: str,
|
|
91
|
+
*,
|
|
92
|
+
timeout: int = 30,
|
|
93
|
+
retry_observer: Callable[[str, dict[str, object]], None] | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Initialize OAuth authenticator.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
token_url: Full URL to OAuth token endpoint
|
|
99
|
+
client_id: OAuth client ID
|
|
100
|
+
client_secret: OAuth client secret
|
|
101
|
+
timeout: Request timeout in seconds
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
AuthenticationError: If initial token acquisition fails
|
|
105
|
+
"""
|
|
106
|
+
self.token_url = token_url
|
|
107
|
+
self.client_id = client_id
|
|
108
|
+
self.client_secret = client_secret
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
self._retry_observer = retry_observer
|
|
111
|
+
|
|
112
|
+
self._token: OAuthToken | None = None
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
|
|
115
|
+
# Create session with TLS 1.2 for compatibility with older OpenSSL
|
|
116
|
+
self._session = self._create_session()
|
|
117
|
+
|
|
118
|
+
# Acquire initial token
|
|
119
|
+
self._refresh_token()
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
self._session.close()
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> OAuthAuthenticator:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
128
|
+
self.close()
|
|
129
|
+
|
|
130
|
+
def _create_session(self) -> requests.Session:
|
|
131
|
+
"""Create requests Session with a custom SSL context.
|
|
132
|
+
|
|
133
|
+
This keeps Requests' default TLS negotiation (including TLS 1.3 when
|
|
134
|
+
available) but disables ALPN to avoid HTTP/2 negotiation issues.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Configured requests Session
|
|
138
|
+
"""
|
|
139
|
+
from requests.adapters import HTTPAdapter
|
|
140
|
+
|
|
141
|
+
class DefaultTLSNoALPNAdapter(HTTPAdapter):
|
|
142
|
+
"""HTTPAdapter that keeps default TLS but disables ALPN."""
|
|
143
|
+
|
|
144
|
+
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
|
145
|
+
ctx = create_urllib3_context()
|
|
146
|
+
# Disable ALPN to avoid HTTP/2 negotiation issues
|
|
147
|
+
ctx.set_alpn_protocols([])
|
|
148
|
+
|
|
149
|
+
pool_kwargs["ssl_context"] = ctx
|
|
150
|
+
return super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
|
151
|
+
|
|
152
|
+
session = requests.Session()
|
|
153
|
+
session.mount("https://", DefaultTLSNoALPNAdapter())
|
|
154
|
+
return session
|
|
155
|
+
|
|
156
|
+
def get_access_token(self) -> str:
|
|
157
|
+
"""Get valid access token, refreshing if needed.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Valid access token
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
AuthenticationError: If token refresh fails
|
|
164
|
+
"""
|
|
165
|
+
with self._lock:
|
|
166
|
+
if self._token is None or self._token.is_expired:
|
|
167
|
+
self._refresh_token()
|
|
168
|
+
|
|
169
|
+
if self._token is None:
|
|
170
|
+
raise AuthenticationError("Failed to acquire access token")
|
|
171
|
+
|
|
172
|
+
return self._token.access_token
|
|
173
|
+
|
|
174
|
+
def _refresh_token(self) -> None:
|
|
175
|
+
"""Refresh OAuth token (must be called with lock held).
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
AuthenticationError: If token refresh fails
|
|
179
|
+
"""
|
|
180
|
+
logger.debug("Refreshing OAuth token")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
# Prepare request data
|
|
184
|
+
data = {
|
|
185
|
+
"grant_type": "client_credentials",
|
|
186
|
+
"client_id": self.client_id,
|
|
187
|
+
"client_secret": self.client_secret,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Small delay to mimic initialization timing that made TLS handshakes stable
|
|
191
|
+
time.sleep(0.2)
|
|
192
|
+
|
|
193
|
+
# Retry on connection resets (TLS handshake flakiness)
|
|
194
|
+
# This is expected to be extremely rare and mostly CI-only. Prefer waiting
|
|
195
|
+
# a bit longer over failing an otherwise healthy import.
|
|
196
|
+
backoff_delays = [1, 2, 4, 8, 16, 32, 32]
|
|
197
|
+
response = None
|
|
198
|
+
for attempt, delay in enumerate(backoff_delays, start=1):
|
|
199
|
+
try:
|
|
200
|
+
response = self._session.post(
|
|
201
|
+
self.token_url,
|
|
202
|
+
data=data,
|
|
203
|
+
timeout=self.timeout,
|
|
204
|
+
)
|
|
205
|
+
response.raise_for_status()
|
|
206
|
+
break
|
|
207
|
+
except (
|
|
208
|
+
ConnectionResetError,
|
|
209
|
+
requests.exceptions.RequestException,
|
|
210
|
+
) as e:
|
|
211
|
+
if not _is_transient(e):
|
|
212
|
+
raise
|
|
213
|
+
kind = _failure_kind(e)
|
|
214
|
+
payload = {
|
|
215
|
+
"where": kind,
|
|
216
|
+
"operation": "oauth_token",
|
|
217
|
+
"url": self.token_url,
|
|
218
|
+
"attempt": attempt,
|
|
219
|
+
"max_attempts": len(backoff_delays),
|
|
220
|
+
"error": str(e),
|
|
221
|
+
}
|
|
222
|
+
if attempt == len(backoff_delays):
|
|
223
|
+
if self._retry_observer is not None:
|
|
224
|
+
self._retry_observer(
|
|
225
|
+
"retry_exhausted",
|
|
226
|
+
{
|
|
227
|
+
**payload,
|
|
228
|
+
"failure_kind": kind,
|
|
229
|
+
"attempts": attempt,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
raise AuthenticationError(
|
|
233
|
+
f"OAuth token request failed after retries: {e} "
|
|
234
|
+
f"(token_url={self.token_url}, client_id={self.client_id})"
|
|
235
|
+
) from e
|
|
236
|
+
jitter = random.uniform(0, 0.5)
|
|
237
|
+
retry_delay = delay + jitter
|
|
238
|
+
if self._retry_observer is not None:
|
|
239
|
+
self._retry_observer(
|
|
240
|
+
"retry",
|
|
241
|
+
{
|
|
242
|
+
**payload,
|
|
243
|
+
"delay": retry_delay,
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
logger.debug(
|
|
247
|
+
"OAuth token retry scheduled (attempt %d/%d), retrying in %.2fs",
|
|
248
|
+
attempt,
|
|
249
|
+
len(backoff_delays),
|
|
250
|
+
retry_delay,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Reset the connection pool to avoid reusing any bad state.
|
|
254
|
+
try:
|
|
255
|
+
self._session.close()
|
|
256
|
+
finally:
|
|
257
|
+
self._session = self._create_session()
|
|
258
|
+
|
|
259
|
+
time.sleep(retry_delay)
|
|
260
|
+
assert response is not None # for type checkers
|
|
261
|
+
|
|
262
|
+
data = response.json()
|
|
263
|
+
|
|
264
|
+
# Extract token data
|
|
265
|
+
access_token = data.get("access_token")
|
|
266
|
+
if not access_token:
|
|
267
|
+
raise AuthenticationError("No access_token in OAuth response")
|
|
268
|
+
|
|
269
|
+
token_type = data.get("token_type", "Bearer")
|
|
270
|
+
expires_in = data.get("expires_in", 3600) # Default 1 hour
|
|
271
|
+
|
|
272
|
+
# Calculate expiration time
|
|
273
|
+
expires_at = time.time() + expires_in
|
|
274
|
+
|
|
275
|
+
self._token = OAuthToken(
|
|
276
|
+
access_token=access_token,
|
|
277
|
+
token_type=token_type,
|
|
278
|
+
expires_at=expires_at,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
logger.debug("OAuth token refreshed successfully (expires in %ds)", expires_in)
|
|
282
|
+
|
|
283
|
+
except requests.RequestException as e:
|
|
284
|
+
error_msg = f"OAuth token request failed: {e}"
|
|
285
|
+
logger.error(error_msg)
|
|
286
|
+
raise AuthenticationError(
|
|
287
|
+
error_msg,
|
|
288
|
+
context={"token_url": self.token_url, "client_id": self.client_id},
|
|
289
|
+
) from e
|
|
290
|
+
except (KeyError, ValueError) as e:
|
|
291
|
+
error_msg = f"Invalid OAuth response: {e}"
|
|
292
|
+
logger.error(error_msg)
|
|
293
|
+
raise AuthenticationError(
|
|
294
|
+
error_msg,
|
|
295
|
+
context={"token_url": self.token_url},
|
|
296
|
+
) from e
|
|
297
|
+
|
|
298
|
+
def invalidate(self) -> None:
|
|
299
|
+
"""Invalidate current token to force refresh on next request."""
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._token = None
|
|
302
|
+
logger.debug("OAuth token invalidated")
|