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,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()