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,919 @@
|
|
|
1
|
+
"""FotoWare API client - minimal read-only interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import Any, BinaryIO
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from fotoware_client.auth import OAuthAuthenticator
|
|
14
|
+
from fotoware_client.constants import (
|
|
15
|
+
ACCEPT_ASSET,
|
|
16
|
+
ACCEPT_ASSETLIST,
|
|
17
|
+
ACCEPT_COLLECTIONINFO,
|
|
18
|
+
ACCEPT_COLLECTIONLIST,
|
|
19
|
+
ACCEPT_DOWNLOAD,
|
|
20
|
+
ACCEPT_FULL_DESCRIPTOR,
|
|
21
|
+
ACCEPT_JSON,
|
|
22
|
+
DEFAULT_TIMEOUT,
|
|
23
|
+
)
|
|
24
|
+
from fotoware_client.credentials import load_credentials
|
|
25
|
+
from fotoware_client.exceptions import (
|
|
26
|
+
APIError,
|
|
27
|
+
ConfigurationError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
RetryExhaustedError,
|
|
31
|
+
)
|
|
32
|
+
from fotoware_client.limits import RequestCounters, check_query_size
|
|
33
|
+
from fotoware_client.models import Archive, Asset, SearchResult
|
|
34
|
+
from fotoware_client.retry import STANDARD_RETRY, RetryConfig, urllib3_retry
|
|
35
|
+
from fotoware_client.settings import ServerSettings
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FotoWareClient:
|
|
41
|
+
"""Minimal read-only client for FotoWare API.
|
|
42
|
+
|
|
43
|
+
One method call is one request to FotoWare, and what each cost is counted
|
|
44
|
+
(:meth:`cost_snapshot`); the only exceptions are the rendition downloads,
|
|
45
|
+
which poll until FotoWare has prepared the file. Nothing is batched or
|
|
46
|
+
asked again behind the caller's back, apart from the retries the
|
|
47
|
+
:class:`RetryConfig` allows.
|
|
48
|
+
|
|
49
|
+
Supports context manager protocol for proper resource cleanup.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
tenant_url: str,
|
|
55
|
+
client_id: str,
|
|
56
|
+
client_secret: str,
|
|
57
|
+
*,
|
|
58
|
+
retry_config: RetryConfig = STANDARD_RETRY,
|
|
59
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
60
|
+
settings: ServerSettings | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Initialize FotoWare client.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
tenant_url: Full URL to FotoWare tenant (e.g., 'https://fotoware.example.com')
|
|
66
|
+
client_id: OAuth client ID
|
|
67
|
+
client_secret: OAuth client secret
|
|
68
|
+
retry_config: Retry configuration for API requests
|
|
69
|
+
timeout: Default request timeout in seconds
|
|
70
|
+
settings: What the installation sets differently from FotoWeb's
|
|
71
|
+
defaults; see ServerSettings
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ConfigurationError: If tenant_url is invalid or credentials missing
|
|
75
|
+
"""
|
|
76
|
+
# Validate tenant URL
|
|
77
|
+
if not tenant_url:
|
|
78
|
+
raise ConfigurationError("tenant_url is required")
|
|
79
|
+
|
|
80
|
+
if not (tenant_url.startswith("http://") or tenant_url.startswith("https://")):
|
|
81
|
+
raise ConfigurationError("tenant_url must be absolute (include scheme)")
|
|
82
|
+
|
|
83
|
+
# Validate credentials
|
|
84
|
+
if not client_id or not client_secret:
|
|
85
|
+
raise ConfigurationError("client_id and client_secret are required")
|
|
86
|
+
|
|
87
|
+
# Normalize tenant URL (remove trailing slash)
|
|
88
|
+
self.tenant_url = tenant_url.rstrip("/")
|
|
89
|
+
self.timeout = timeout
|
|
90
|
+
self.retry_config = retry_config
|
|
91
|
+
self.settings = settings or ServerSettings()
|
|
92
|
+
self._counters = RequestCounters()
|
|
93
|
+
|
|
94
|
+
# Build OAuth token URL
|
|
95
|
+
token_url = urljoin(self.tenant_url + "/", self.settings.oauth_token_path.lstrip("/"))
|
|
96
|
+
|
|
97
|
+
# Initialize OAuth authenticator
|
|
98
|
+
self._auth = OAuthAuthenticator(
|
|
99
|
+
token_url=token_url,
|
|
100
|
+
client_id=client_id,
|
|
101
|
+
client_secret=client_secret,
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
retry_observer=self._observe_token_retry,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Initialize HTTP session with connection pool sized for parallel requests
|
|
107
|
+
self._session = requests.Session()
|
|
108
|
+
# Configure adapter with larger pool to support 15+ parallel workers
|
|
109
|
+
adapter = requests.adapters.HTTPAdapter(
|
|
110
|
+
pool_connections=20,
|
|
111
|
+
pool_maxsize=20,
|
|
112
|
+
max_retries=urllib3_retry(retry_config, self._counters.count_retry),
|
|
113
|
+
)
|
|
114
|
+
self._session.mount("http://", adapter)
|
|
115
|
+
self._session.mount("https://", adapter)
|
|
116
|
+
|
|
117
|
+
logger.debug("FotoWare client initialized for tenant: %s", self.tenant_url)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_env(cls, **kwargs: Any) -> FotoWareClient:
|
|
121
|
+
"""A client for the tenant the environment or the credentials file names.
|
|
122
|
+
|
|
123
|
+
Reads FOTOWARE_TENANT_URL, FOTOWARE_CLIENT_ID and FOTOWARE_CLIENT_SECRET,
|
|
124
|
+
or, where the environment lacks them, the credentials file; see
|
|
125
|
+
fotoware_client.credentials. Other arguments go to the constructor.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ConfigurationError: If neither has the credentials, or the file
|
|
129
|
+
may be read by others
|
|
130
|
+
"""
|
|
131
|
+
tenant_url, client_id, client_secret = load_credentials()
|
|
132
|
+
return cls(tenant_url, client_id, client_secret, **kwargs)
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> FotoWareClient:
|
|
135
|
+
"""Enter context manager."""
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
139
|
+
"""Exit context manager and cleanup resources."""
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
"""Close HTTP session and release resources."""
|
|
144
|
+
if self._session:
|
|
145
|
+
self._session.close()
|
|
146
|
+
logger.debug("FotoWare client closed")
|
|
147
|
+
|
|
148
|
+
def resolve(self, href: str) -> str:
|
|
149
|
+
"""*href* as a full URL: FotoWare answers with paths on the tenant.
|
|
150
|
+
|
|
151
|
+
Every call that takes an href from an answer accepts either form.
|
|
152
|
+
"""
|
|
153
|
+
return urljoin(self.tenant_url + "/", href)
|
|
154
|
+
|
|
155
|
+
def get_asset(self, asset_href: str) -> Asset:
|
|
156
|
+
"""One asset, as FotoWare describes it: metadata, renditions, previews.
|
|
157
|
+
|
|
158
|
+
One request. ``Asset.data`` holds the whole answer.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
NotFoundError: If there is no such asset
|
|
162
|
+
APIError: If the request fails
|
|
163
|
+
"""
|
|
164
|
+
response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_ASSET)
|
|
165
|
+
asset = Asset.from_json(response.json())
|
|
166
|
+
response.close()
|
|
167
|
+
return asset
|
|
168
|
+
|
|
169
|
+
def cost_snapshot(self) -> dict[str, object]:
|
|
170
|
+
"""What this client has asked of FotoWare so far; see RequestCounters."""
|
|
171
|
+
return self._counters.snapshot()
|
|
172
|
+
|
|
173
|
+
def _observe_token_retry(self, event: str, data: dict[str, object]) -> None:
|
|
174
|
+
if event == "retry":
|
|
175
|
+
delay = data.get("delay")
|
|
176
|
+
self._counters.count_retry(
|
|
177
|
+
None, str(data.get("where")), delay if isinstance(delay, float) else 0.0
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def validate_connection(self) -> None:
|
|
181
|
+
"""Validate OAuth credentials and API connectivity.
|
|
182
|
+
|
|
183
|
+
Makes a lightweight API call to /fotoweb/me to verify:
|
|
184
|
+
- OAuth token can be generated
|
|
185
|
+
- API endpoint is reachable
|
|
186
|
+
- Credentials are valid
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
AuthenticationError: If credentials are invalid (401)
|
|
190
|
+
NotFoundError: If API endpoint returns 404
|
|
191
|
+
APIError: If API is unreachable or returns other errors
|
|
192
|
+
"""
|
|
193
|
+
logger.debug("Validating FotoWare API connection")
|
|
194
|
+
me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
|
|
198
|
+
response.json() # Validate response is valid JSON
|
|
199
|
+
response.close()
|
|
200
|
+
logger.debug("FotoWare API connection validated successfully")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error("FotoWare API connection validation failed: %s", e)
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
def _get_auth_headers(self) -> dict[str, str]:
|
|
206
|
+
"""Get authorization headers with valid access token.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Dict with Authorization header
|
|
210
|
+
"""
|
|
211
|
+
token = self._auth.get_access_token()
|
|
212
|
+
return {"Authorization": f"Bearer {token}"}
|
|
213
|
+
|
|
214
|
+
def _request(
|
|
215
|
+
self,
|
|
216
|
+
method: str,
|
|
217
|
+
url: str,
|
|
218
|
+
*,
|
|
219
|
+
headers: dict[str, str] | None = None,
|
|
220
|
+
timeout: int | None = None,
|
|
221
|
+
**kwargs: Any,
|
|
222
|
+
) -> requests.Response:
|
|
223
|
+
"""Make HTTP request with authentication and retry logic.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
method: HTTP method (GET, POST, etc.)
|
|
227
|
+
url: Full URL to request
|
|
228
|
+
headers: Additional headers (merged with auth headers)
|
|
229
|
+
timeout: Request timeout in seconds (uses default if None)
|
|
230
|
+
**kwargs: Additional arguments passed to requests
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Response object
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
RateLimitError: A 429 that outlasted the retries.
|
|
237
|
+
RetryExhaustedError: A 502, 503 or 504, a connection error, a
|
|
238
|
+
timeout or an answer broken off that outlasted the retries.
|
|
239
|
+
NotFoundError: A 404.
|
|
240
|
+
APIError: Any other error status, which is not retried; a 500
|
|
241
|
+
among them.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
request_headers = self._get_auth_headers()
|
|
245
|
+
if headers:
|
|
246
|
+
request_headers.update(headers)
|
|
247
|
+
self._counters.count_request()
|
|
248
|
+
context = {"method": method, "url": url}
|
|
249
|
+
attempts = self.retry_config.max_attempts
|
|
250
|
+
for attempt in range(1, attempts + 1):
|
|
251
|
+
try:
|
|
252
|
+
response = self._session.request(
|
|
253
|
+
method=method,
|
|
254
|
+
url=url,
|
|
255
|
+
headers=request_headers,
|
|
256
|
+
timeout=timeout or self.timeout,
|
|
257
|
+
**kwargs,
|
|
258
|
+
)
|
|
259
|
+
break
|
|
260
|
+
# The body broke off after urllib3 had handed the response over.
|
|
261
|
+
# urllib3 retries only up to the headers, so this is repeated here.
|
|
262
|
+
except requests.exceptions.ChunkedEncodingError as e:
|
|
263
|
+
if attempt == attempts:
|
|
264
|
+
self._counters.count_error()
|
|
265
|
+
raise RetryExhaustedError(
|
|
266
|
+
f"FotoWare broke off the answer: {e}",
|
|
267
|
+
attempts=attempt,
|
|
268
|
+
last_error=e,
|
|
269
|
+
context=context,
|
|
270
|
+
) from e
|
|
271
|
+
delay = self.retry_config.delay(attempt)
|
|
272
|
+
self._counters.count_retry(None, type(e).__name__, delay)
|
|
273
|
+
time.sleep(delay)
|
|
274
|
+
# urllib3 has retried a connection error or a timeout as often as allowed.
|
|
275
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
|
276
|
+
self._counters.count_error()
|
|
277
|
+
raise RetryExhaustedError(
|
|
278
|
+
f"FotoWare unreachable: {e}",
|
|
279
|
+
attempts=attempts,
|
|
280
|
+
last_error=e,
|
|
281
|
+
context=context,
|
|
282
|
+
) from e
|
|
283
|
+
# Nothing was sent: a malformed URL, say.
|
|
284
|
+
except requests.RequestException as e:
|
|
285
|
+
self._counters.count_error()
|
|
286
|
+
raise APIError(f"Request not sent: {e}", context=context) from e
|
|
287
|
+
|
|
288
|
+
if response.status_code < 400:
|
|
289
|
+
return response
|
|
290
|
+
self._counters.count_error()
|
|
291
|
+
status_code = response.status_code
|
|
292
|
+
response_text = response.text
|
|
293
|
+
response.close()
|
|
294
|
+
if status_code == 429:
|
|
295
|
+
raise RateLimitError(
|
|
296
|
+
"Rate limit exceeded and retries exhausted",
|
|
297
|
+
context={**context, "attempts": self.retry_config.max_attempts},
|
|
298
|
+
)
|
|
299
|
+
if status_code == 404:
|
|
300
|
+
raise NotFoundError("HTTP 404: not found", context=context)
|
|
301
|
+
if status_code in self.retry_config.retry_on_status:
|
|
302
|
+
raise RetryExhaustedError(
|
|
303
|
+
f"HTTP {status_code} error",
|
|
304
|
+
attempts=self.retry_config.max_attempts,
|
|
305
|
+
context={**context, "status_code": status_code},
|
|
306
|
+
)
|
|
307
|
+
raise APIError(
|
|
308
|
+
f"HTTP {status_code} error",
|
|
309
|
+
status_code=status_code,
|
|
310
|
+
response_text=response_text,
|
|
311
|
+
context=context,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def list_archives(self) -> list[Archive]:
|
|
315
|
+
"""List all available archives.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
List of Archive instances
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
APIError: If request fails
|
|
322
|
+
"""
|
|
323
|
+
logger.debug("Listing archives")
|
|
324
|
+
|
|
325
|
+
# The API descriptor names the archives list, as a path on the tenant
|
|
326
|
+
me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
|
|
327
|
+
response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
|
|
328
|
+
archives_path = response.json().get("archives")
|
|
329
|
+
response.close()
|
|
330
|
+
if not isinstance(archives_path, str) or not archives_path:
|
|
331
|
+
raise APIError("No archives link found in API descriptor")
|
|
332
|
+
archives_href = urljoin(self.tenant_url + "/", archives_path)
|
|
333
|
+
|
|
334
|
+
# Fetch archives
|
|
335
|
+
response = self._request("GET", archives_href, headers=ACCEPT_COLLECTIONLIST)
|
|
336
|
+
archives_data = response.json()
|
|
337
|
+
response.close()
|
|
338
|
+
|
|
339
|
+
# Parse archives
|
|
340
|
+
archives = []
|
|
341
|
+
for archive_data in archives_data.get("data", []):
|
|
342
|
+
try:
|
|
343
|
+
archives.append(Archive.from_json(archive_data))
|
|
344
|
+
except (KeyError, TypeError) as e:
|
|
345
|
+
logger.warning("Skipping malformed archive: %s", e)
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
logger.debug("Found %d archives", len(archives))
|
|
349
|
+
return archives
|
|
350
|
+
|
|
351
|
+
def get_field_labels(self, archive_href: str) -> dict[str, str]:
|
|
352
|
+
"""Field id -> label, as the archive's metadata editor names them.
|
|
353
|
+
|
|
354
|
+
FotoWare keeps the field catalog itself: each archive links a metadata
|
|
355
|
+
editor whose regions list every field with its id, label and type. A
|
|
356
|
+
field shown in several regions appears once here.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
archive_href: Full URL to archive (from Archive.href)
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
APIError: If a request fails, the archive links no editor or the
|
|
363
|
+
editor lists no fields
|
|
364
|
+
KeyError: If the editor's answer has another shape
|
|
365
|
+
"""
|
|
366
|
+
response = self._request("GET", self.resolve(archive_href), headers=ACCEPT_COLLECTIONINFO)
|
|
367
|
+
archive = response.json()
|
|
368
|
+
response.close()
|
|
369
|
+
editor_href = (archive.get("metadataEditor") or {}).get("href")
|
|
370
|
+
if not editor_href:
|
|
371
|
+
raise APIError(f"Archive {archive_href} links no metadata editor")
|
|
372
|
+
|
|
373
|
+
editor_url = urljoin(self.tenant_url + "/", editor_href.lstrip("/"))
|
|
374
|
+
response = self._request("GET", editor_url, headers=ACCEPT_JSON)
|
|
375
|
+
editor = response.json()
|
|
376
|
+
response.close()
|
|
377
|
+
|
|
378
|
+
labels: dict[str, str] = {}
|
|
379
|
+
for region in editor["detailRegions"]:
|
|
380
|
+
for entry in region["fields"]:
|
|
381
|
+
field_def = entry["field"]
|
|
382
|
+
labels.setdefault(str(field_def["id"]), str(field_def["label"]))
|
|
383
|
+
if not labels:
|
|
384
|
+
raise APIError(f"The metadata editor of {archive_href} lists no fields")
|
|
385
|
+
return labels
|
|
386
|
+
|
|
387
|
+
def search_assets(self, archive_href: str, query: str) -> SearchResult:
|
|
388
|
+
"""One page of a search: at most 25 assets, and the link to the next page.
|
|
389
|
+
|
|
390
|
+
One request. The query is estimated first and refused when it is over
|
|
391
|
+
the size budget, because FotoWare would answer it with a 500 or, worse,
|
|
392
|
+
an empty list (see fotoware_client.limits).
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
archive_href: The archive, as Archive.href gives it (a path on the
|
|
396
|
+
tenant) or as a full URL
|
|
397
|
+
query: FotoWare search query (e.g., '631:12345' for EAN search)
|
|
398
|
+
|
|
399
|
+
Raises:
|
|
400
|
+
QueryTooLargeError: If the query is estimated over the budget;
|
|
401
|
+
nothing is sent
|
|
402
|
+
APIError: If the request fails
|
|
403
|
+
"""
|
|
404
|
+
check_query_size(query, self.settings.query_size_budget, self.settings.term_overhead)
|
|
405
|
+
search_url = self.resolve(archive_href).rstrip("/") + "/"
|
|
406
|
+
return self._search_page(search_url, params={"q": query})
|
|
407
|
+
|
|
408
|
+
def next_page(self, result: SearchResult) -> SearchResult | None:
|
|
409
|
+
"""The page after *result*, or None when it was the last. One request.
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
APIError: If the request fails
|
|
413
|
+
"""
|
|
414
|
+
if not result.next_href:
|
|
415
|
+
return None
|
|
416
|
+
return self._search_page(self.resolve(result.next_href))
|
|
417
|
+
|
|
418
|
+
def iter_search(self, archive_href: str, query: str) -> Iterator[SearchResult]:
|
|
419
|
+
"""Every page of a search, one request each, until the last.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
QueryTooLargeError: If the query is estimated over the budget
|
|
423
|
+
APIError: If a request fails
|
|
424
|
+
"""
|
|
425
|
+
page: SearchResult | None = self.search_assets(archive_href, query)
|
|
426
|
+
while page is not None:
|
|
427
|
+
yield page
|
|
428
|
+
page = self.next_page(page)
|
|
429
|
+
|
|
430
|
+
def _search_page(self, url: str, *, params: dict[str, str] | None = None) -> SearchResult:
|
|
431
|
+
response = self._request("GET", url, params=params, headers=ACCEPT_ASSETLIST)
|
|
432
|
+
result = SearchResult.from_json(response.json())
|
|
433
|
+
response.close()
|
|
434
|
+
self._counters.count_search_page(empty=not result.assets)
|
|
435
|
+
return result
|
|
436
|
+
|
|
437
|
+
def get_download_url(self, asset_href: str) -> str | None:
|
|
438
|
+
"""Get presigned download URL for an asset.
|
|
439
|
+
|
|
440
|
+
FotoWare may require two hops:
|
|
441
|
+
1. GET asset.href -> returns download info
|
|
442
|
+
2. Follow download link -> returns presigned URL
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
asset_href: Full URL to asset (from Asset.href)
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Presigned download URL, or None if not available
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
APIError: If request fails
|
|
452
|
+
"""
|
|
453
|
+
logger.debug("Getting download URL for: %s", asset_href)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
# Get asset with download info
|
|
457
|
+
response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_DOWNLOAD)
|
|
458
|
+
data = response.json()
|
|
459
|
+
response.close()
|
|
460
|
+
|
|
461
|
+
# Look for direct URL in attributes
|
|
462
|
+
if "attributes" in data and "downloadUrl" in data["attributes"]:
|
|
463
|
+
url: str = data["attributes"]["downloadUrl"]
|
|
464
|
+
return url
|
|
465
|
+
|
|
466
|
+
# Look for download link
|
|
467
|
+
for link in data.get("_links", {}).values():
|
|
468
|
+
if isinstance(link, dict) and link.get("rel") == "download":
|
|
469
|
+
download_href = link.get("href")
|
|
470
|
+
if download_href:
|
|
471
|
+
# Fetch presigned URL
|
|
472
|
+
response = self._request("GET", download_href, headers=ACCEPT_DOWNLOAD)
|
|
473
|
+
download_data = response.json()
|
|
474
|
+
response.close()
|
|
475
|
+
result_url: str | None = download_data.get("url") or download_data.get(
|
|
476
|
+
"href"
|
|
477
|
+
)
|
|
478
|
+
return result_url
|
|
479
|
+
|
|
480
|
+
logger.warning("No download URL found for asset: %s", asset_href)
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
except NotFoundError:
|
|
484
|
+
logger.warning("Asset not found: %s", asset_href)
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
def download_bytes(self, url: str) -> bytes:
|
|
488
|
+
"""Download asset binary data from URL.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
url: Full download URL (from get_download_url)
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Binary data
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
APIError: If download fails
|
|
498
|
+
"""
|
|
499
|
+
logger.debug("Downloading from: %s", url)
|
|
500
|
+
|
|
501
|
+
response = self._request("GET", url, timeout=self.settings.download_timeout)
|
|
502
|
+
data = response.content
|
|
503
|
+
response.close()
|
|
504
|
+
|
|
505
|
+
logger.debug("Downloaded %d bytes", len(data))
|
|
506
|
+
return data
|
|
507
|
+
|
|
508
|
+
def get_rendition_url(
|
|
509
|
+
self,
|
|
510
|
+
asset_href: str,
|
|
511
|
+
*,
|
|
512
|
+
timeout: int | None = None,
|
|
513
|
+
rendition: str | None = None,
|
|
514
|
+
) -> str:
|
|
515
|
+
"""Get download URL using FotoWare rendition API.
|
|
516
|
+
|
|
517
|
+
This method polls the rendition service until the file is ready and returns
|
|
518
|
+
the URL that can be used to download it. Use this when you need a URL
|
|
519
|
+
(e.g., for Shopware's upload_url_to_media) rather than immediate bytes.
|
|
520
|
+
|
|
521
|
+
Workflow:
|
|
522
|
+
1. Get asset metadata and find the original rendition
|
|
523
|
+
2. Submit rendition request to rendition service
|
|
524
|
+
3. Poll until ready (202 Accepted → 200 OK)
|
|
525
|
+
4. Return the ready download URL
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
asset_href: Full URL to asset (from Asset.href or search results)
|
|
529
|
+
timeout: Total timeout for polling (default: settings.download_timeout)
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
URL that can be used to download the original file
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
APIError: If rendition request fails or times out
|
|
536
|
+
NotFoundError: If asset or original rendition not found
|
|
537
|
+
|
|
538
|
+
Example:
|
|
539
|
+
>>> client = FotoWareClient(...)
|
|
540
|
+
>>> asset_href = "/fotoweb/archives/5000-Products/file.pdf.info"
|
|
541
|
+
>>> download_url = client.get_rendition_url(asset_href)
|
|
542
|
+
>>> # Later: download from that URL
|
|
543
|
+
"""
|
|
544
|
+
|
|
545
|
+
timeout = timeout or self.settings.download_timeout
|
|
546
|
+
start_time = time.time()
|
|
547
|
+
|
|
548
|
+
logger.debug("get_rendition_url: Starting for asset=%s", asset_href)
|
|
549
|
+
|
|
550
|
+
# Step 1: Get asset metadata with renditions
|
|
551
|
+
response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_ASSET)
|
|
552
|
+
asset_data = response.json()
|
|
553
|
+
response.close()
|
|
554
|
+
|
|
555
|
+
# Step 2: Find the rendition asked for, the original unless named
|
|
556
|
+
renditions = asset_data.get("renditions", [])
|
|
557
|
+
original_rendition = None
|
|
558
|
+
|
|
559
|
+
for candidate in renditions:
|
|
560
|
+
if not isinstance(candidate, dict):
|
|
561
|
+
continue
|
|
562
|
+
wanted = (
|
|
563
|
+
candidate.get("display_name") == rendition
|
|
564
|
+
if rendition is not None
|
|
565
|
+
else candidate.get("original") is True
|
|
566
|
+
or candidate.get("display_name") == self.settings.original_rendition_name
|
|
567
|
+
)
|
|
568
|
+
if wanted:
|
|
569
|
+
original_rendition = candidate
|
|
570
|
+
break
|
|
571
|
+
|
|
572
|
+
if not original_rendition:
|
|
573
|
+
raise APIError(
|
|
574
|
+
f"No {rendition or 'original'} rendition found for asset",
|
|
575
|
+
context={"asset_href": asset_href, "renditions_count": len(renditions)},
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
rendition_href = original_rendition.get("href")
|
|
579
|
+
if not rendition_href:
|
|
580
|
+
raise APIError(
|
|
581
|
+
"Original rendition has no href",
|
|
582
|
+
context={"asset_href": asset_href, "rendition": original_rendition},
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
logger.debug(
|
|
586
|
+
"get_rendition_url: Found original rendition href=%s", rendition_href[:80] + "..."
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# Step 3: Get rendition service URL
|
|
590
|
+
me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
|
|
591
|
+
response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
|
|
592
|
+
me_data = response.json()
|
|
593
|
+
response.close()
|
|
594
|
+
|
|
595
|
+
rendition_service_url = me_data.get("services", {}).get("rendition_request")
|
|
596
|
+
if not rendition_service_url:
|
|
597
|
+
raise APIError(
|
|
598
|
+
"Rendition service URL not found in API descriptor",
|
|
599
|
+
context={"me_url": me_url},
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Make URLs absolute
|
|
603
|
+
rendition_service_url = urljoin(self.tenant_url + "/", rendition_service_url.lstrip("/"))
|
|
604
|
+
rendition_href = urljoin(self.tenant_url + "/", rendition_href.lstrip("/"))
|
|
605
|
+
|
|
606
|
+
logger.debug("get_rendition_url: Rendition service URL=%s", rendition_service_url)
|
|
607
|
+
|
|
608
|
+
# Step 4: Submit rendition request
|
|
609
|
+
request_payload = {"href": rendition_href}
|
|
610
|
+
response = self._request(
|
|
611
|
+
"POST",
|
|
612
|
+
rendition_service_url,
|
|
613
|
+
json=request_payload,
|
|
614
|
+
headers={
|
|
615
|
+
"Content-Type": "application/vnd.fotoware.rendition-request+json",
|
|
616
|
+
"Accept": "application/vnd.fotoware.rendition-response+json",
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
# Get polling URL from Location header or response body
|
|
621
|
+
polling_url = response.headers.get("Location")
|
|
622
|
+
if not polling_url:
|
|
623
|
+
response_data = response.json()
|
|
624
|
+
response.close()
|
|
625
|
+
polling_url = response_data.get("href")
|
|
626
|
+
|
|
627
|
+
if not polling_url:
|
|
628
|
+
raise APIError(
|
|
629
|
+
"No polling URL returned from rendition request",
|
|
630
|
+
context={"asset_href": asset_href, "response_headers": dict(response.headers)},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
logger.debug("get_rendition_url: Polling URL=%s", polling_url)
|
|
634
|
+
|
|
635
|
+
# Step 5: Poll until ready (202 → 200) or timeout
|
|
636
|
+
poll_interval = self.settings.rendition_poll_interval
|
|
637
|
+
max_poll_interval = self.settings.rendition_poll_interval_max
|
|
638
|
+
|
|
639
|
+
while True:
|
|
640
|
+
elapsed = time.time() - start_time
|
|
641
|
+
if elapsed > timeout:
|
|
642
|
+
raise APIError(
|
|
643
|
+
f"Rendition polling timed out after {timeout}s",
|
|
644
|
+
context={"asset_href": asset_href, "polling_url": polling_url},
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Poll status. Streamed, so the finished file is not downloaded
|
|
648
|
+
# here and then again by the caller.
|
|
649
|
+
response = self._request("GET", polling_url, stream=True)
|
|
650
|
+
|
|
651
|
+
if response.status_code == 200:
|
|
652
|
+
# Download ready! Return the URL instead of downloading
|
|
653
|
+
response.close()
|
|
654
|
+
logger.debug(
|
|
655
|
+
"get_rendition_url: Ready after %.1fs, returning URL",
|
|
656
|
+
elapsed,
|
|
657
|
+
)
|
|
658
|
+
return polling_url
|
|
659
|
+
|
|
660
|
+
elif response.status_code == 202:
|
|
661
|
+
# Still processing - wait and retry
|
|
662
|
+
response.close()
|
|
663
|
+
logger.debug(
|
|
664
|
+
"get_rendition_url: Still processing (202), waiting %.1fs", poll_interval
|
|
665
|
+
)
|
|
666
|
+
time.sleep(poll_interval)
|
|
667
|
+
|
|
668
|
+
# Exponential backoff
|
|
669
|
+
poll_interval = min(poll_interval * 1.5, max_poll_interval)
|
|
670
|
+
|
|
671
|
+
else:
|
|
672
|
+
# Unexpected status
|
|
673
|
+
response.close()
|
|
674
|
+
raise APIError(
|
|
675
|
+
f"Unexpected status {response.status_code} while polling rendition",
|
|
676
|
+
status_code=response.status_code,
|
|
677
|
+
context={"asset_href": asset_href, "polling_url": polling_url},
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
def download_original(self, asset_href: str, *, timeout: int | None = None) -> bytes:
|
|
681
|
+
"""Download original file using FotoWare rendition API (atomic operation).
|
|
682
|
+
|
|
683
|
+
This is the recommended way to download original files. It handles the
|
|
684
|
+
complete rendition workflow:
|
|
685
|
+
1. Get asset metadata and find the original rendition
|
|
686
|
+
2. Submit rendition request to rendition service
|
|
687
|
+
3. Poll until ready (handles 202 Accepted → 200 OK)
|
|
688
|
+
4. Download and return bytes
|
|
689
|
+
|
|
690
|
+
The operation is atomic (no URL expiration issues) and handles all
|
|
691
|
+
FotoWare-specific complexity internally.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
asset_href: Full URL to asset (from Asset.href or search results)
|
|
695
|
+
timeout: Total timeout for the entire operation (default: settings.download_timeout)
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
Original file bytes
|
|
699
|
+
|
|
700
|
+
Raises:
|
|
701
|
+
APIError: If rendition request fails or times out
|
|
702
|
+
NotFoundError: If asset or original rendition not found
|
|
703
|
+
|
|
704
|
+
Example:
|
|
705
|
+
>>> client = FotoWareClient(...)
|
|
706
|
+
>>> asset_href = "/fotoweb/archives/5000-Products/file.jpg.info"
|
|
707
|
+
>>> pdf_bytes = client.download_original(asset_href)
|
|
708
|
+
"""
|
|
709
|
+
|
|
710
|
+
timeout = timeout or self.settings.download_timeout
|
|
711
|
+
start_time = time.time()
|
|
712
|
+
|
|
713
|
+
logger.debug("download_original: Starting for asset=%s", asset_href)
|
|
714
|
+
|
|
715
|
+
# Step 1: Get asset metadata with renditions
|
|
716
|
+
response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_ASSET)
|
|
717
|
+
asset_data = response.json()
|
|
718
|
+
response.close()
|
|
719
|
+
|
|
720
|
+
# Step 2: Find the original rendition
|
|
721
|
+
renditions = asset_data.get("renditions", [])
|
|
722
|
+
original_rendition = None
|
|
723
|
+
|
|
724
|
+
for rendition in renditions:
|
|
725
|
+
if not isinstance(rendition, dict):
|
|
726
|
+
continue
|
|
727
|
+
if (
|
|
728
|
+
rendition.get("original") is True
|
|
729
|
+
or rendition.get("display_name") == self.settings.original_rendition_name
|
|
730
|
+
):
|
|
731
|
+
original_rendition = rendition
|
|
732
|
+
break
|
|
733
|
+
|
|
734
|
+
if not original_rendition:
|
|
735
|
+
raise APIError(
|
|
736
|
+
"No original rendition found for asset",
|
|
737
|
+
context={"asset_href": asset_href, "renditions_count": len(renditions)},
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
rendition_href = original_rendition.get("href")
|
|
741
|
+
if not rendition_href:
|
|
742
|
+
raise APIError(
|
|
743
|
+
"Original rendition has no href",
|
|
744
|
+
context={"asset_href": asset_href, "rendition": original_rendition},
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
logger.debug(
|
|
748
|
+
"download_original: Found original rendition href=%s", rendition_href[:80] + "..."
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Step 3: Get rendition service URL
|
|
752
|
+
me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
|
|
753
|
+
response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
|
|
754
|
+
me_data = response.json()
|
|
755
|
+
response.close()
|
|
756
|
+
|
|
757
|
+
rendition_service_url = me_data.get("services", {}).get("rendition_request")
|
|
758
|
+
if not rendition_service_url:
|
|
759
|
+
raise APIError(
|
|
760
|
+
"Rendition service URL not found in API descriptor",
|
|
761
|
+
context={"me_url": me_url},
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Make rendition service URL absolute
|
|
765
|
+
rendition_service_url = urljoin(self.tenant_url + "/", rendition_service_url.lstrip("/"))
|
|
766
|
+
# Make rendition href absolute
|
|
767
|
+
rendition_href = urljoin(self.tenant_url + "/", rendition_href.lstrip("/"))
|
|
768
|
+
|
|
769
|
+
logger.debug("download_original: Rendition service URL=%s", rendition_service_url)
|
|
770
|
+
|
|
771
|
+
# Step 4: Submit rendition request
|
|
772
|
+
request_payload = {"href": rendition_href}
|
|
773
|
+
response = self._request(
|
|
774
|
+
"POST",
|
|
775
|
+
rendition_service_url,
|
|
776
|
+
json=request_payload,
|
|
777
|
+
headers={
|
|
778
|
+
"Content-Type": "application/vnd.fotoware.rendition-request+json",
|
|
779
|
+
"Accept": "application/vnd.fotoware.rendition-response+json",
|
|
780
|
+
},
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Get polling URL from Location header or response body
|
|
784
|
+
polling_url = response.headers.get("Location")
|
|
785
|
+
if not polling_url:
|
|
786
|
+
response_data = response.json()
|
|
787
|
+
response.close()
|
|
788
|
+
polling_url = response_data.get("href")
|
|
789
|
+
|
|
790
|
+
if not polling_url:
|
|
791
|
+
raise APIError(
|
|
792
|
+
"No polling URL returned from rendition request",
|
|
793
|
+
context={"asset_href": asset_href, "response_headers": dict(response.headers)},
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
logger.debug("download_original: Polling URL=%s", polling_url)
|
|
797
|
+
|
|
798
|
+
# Step 5: Poll until ready (202 → 200) or timeout
|
|
799
|
+
poll_interval = self.settings.rendition_poll_interval
|
|
800
|
+
max_poll_interval = self.settings.rendition_poll_interval_max
|
|
801
|
+
|
|
802
|
+
while True:
|
|
803
|
+
elapsed = time.time() - start_time
|
|
804
|
+
if elapsed > timeout:
|
|
805
|
+
raise APIError(
|
|
806
|
+
f"Rendition download timed out after {timeout}s",
|
|
807
|
+
context={"asset_href": asset_href, "polling_url": polling_url},
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Poll status; the finished file comes with the 200
|
|
811
|
+
response = self._request("GET", polling_url)
|
|
812
|
+
|
|
813
|
+
if response.status_code == 200:
|
|
814
|
+
# Download ready!
|
|
815
|
+
response.close()
|
|
816
|
+
logger.debug(
|
|
817
|
+
"download_original: Ready after %.1fs, downloading %d bytes",
|
|
818
|
+
elapsed,
|
|
819
|
+
len(response.content),
|
|
820
|
+
)
|
|
821
|
+
return response.content
|
|
822
|
+
|
|
823
|
+
elif response.status_code == 202:
|
|
824
|
+
# Still processing - wait and retry
|
|
825
|
+
response.close()
|
|
826
|
+
logger.debug(
|
|
827
|
+
"download_original: Still processing (202), waiting %.1fs", poll_interval
|
|
828
|
+
)
|
|
829
|
+
time.sleep(poll_interval)
|
|
830
|
+
|
|
831
|
+
# Exponential backoff
|
|
832
|
+
poll_interval = min(poll_interval * 1.5, max_poll_interval)
|
|
833
|
+
|
|
834
|
+
else:
|
|
835
|
+
# Unexpected status
|
|
836
|
+
response.close()
|
|
837
|
+
raise APIError(
|
|
838
|
+
f"Unexpected status {response.status_code} while polling rendition",
|
|
839
|
+
status_code=response.status_code,
|
|
840
|
+
context={"asset_href": asset_href, "polling_url": polling_url},
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def download_original_to_file(
|
|
844
|
+
self,
|
|
845
|
+
asset_href: str,
|
|
846
|
+
destination: BinaryIO,
|
|
847
|
+
*,
|
|
848
|
+
timeout: int | None = None,
|
|
849
|
+
chunk_size: int = 1024 * 1024,
|
|
850
|
+
) -> None:
|
|
851
|
+
"""Download an original rendition directly into a writable binary file.
|
|
852
|
+
|
|
853
|
+
This keeps authentication and rendition handling in the client layer
|
|
854
|
+
while avoiding a large in-memory `bytes` object in the caller.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
asset_href: Full URL to asset (from Asset.href or search results)
|
|
858
|
+
destination: Writable binary file-like object
|
|
859
|
+
timeout: Total timeout for the entire operation (default: settings.download_timeout)
|
|
860
|
+
chunk_size: Streaming chunk size in bytes
|
|
861
|
+
|
|
862
|
+
Raises:
|
|
863
|
+
APIError: If rendition request or download fails
|
|
864
|
+
NotFoundError: If asset or original rendition not found
|
|
865
|
+
"""
|
|
866
|
+
self._stream_to_file(
|
|
867
|
+
self.get_rendition_url(asset_href, timeout=timeout),
|
|
868
|
+
destination,
|
|
869
|
+
timeout=timeout,
|
|
870
|
+
chunk_size=chunk_size,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def download_rendition_to_file(
|
|
874
|
+
self,
|
|
875
|
+
asset_href: str,
|
|
876
|
+
destination: BinaryIO,
|
|
877
|
+
*,
|
|
878
|
+
rendition: str,
|
|
879
|
+
timeout: int | None = None,
|
|
880
|
+
chunk_size: int = 1024 * 1024,
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Download a named rendition, such as "Medium (JPG, 3000px)", directly
|
|
883
|
+
into a writable binary file.
|
|
884
|
+
|
|
885
|
+
Raises:
|
|
886
|
+
APIError: If the asset has no such rendition, or its request or
|
|
887
|
+
download fails
|
|
888
|
+
"""
|
|
889
|
+
self._stream_to_file(
|
|
890
|
+
self.get_rendition_url(asset_href, timeout=timeout, rendition=rendition),
|
|
891
|
+
destination,
|
|
892
|
+
timeout=timeout,
|
|
893
|
+
chunk_size=chunk_size,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
def _stream_to_file(
|
|
897
|
+
self,
|
|
898
|
+
download_url: str,
|
|
899
|
+
destination: BinaryIO,
|
|
900
|
+
*,
|
|
901
|
+
timeout: int | None,
|
|
902
|
+
chunk_size: int,
|
|
903
|
+
) -> None:
|
|
904
|
+
if chunk_size <= 0:
|
|
905
|
+
raise ValueError("chunk_size must be positive")
|
|
906
|
+
|
|
907
|
+
response = self._request(
|
|
908
|
+
"GET",
|
|
909
|
+
download_url,
|
|
910
|
+
timeout=timeout,
|
|
911
|
+
stream=True,
|
|
912
|
+
)
|
|
913
|
+
try:
|
|
914
|
+
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
915
|
+
if chunk:
|
|
916
|
+
destination.write(chunk)
|
|
917
|
+
destination.flush()
|
|
918
|
+
finally:
|
|
919
|
+
response.close()
|