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.
@@ -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
+ ]
@@ -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")