python-fotoware-client 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {python_fotoware_client-0.2.1/src/python_fotoware_client.egg-info → python_fotoware_client-0.2.3}/PKG-INFO +1 -1
  2. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/pyproject.toml +1 -1
  3. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/__init__.py +1 -1
  4. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/client.py +103 -59
  5. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3/src/python_fotoware_client.egg-info}/PKG-INFO +1 -1
  6. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_client.py +116 -2
  7. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/LICENSE +0 -0
  8. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/README.md +0 -0
  9. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/setup.cfg +0 -0
  10. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/auth.py +0 -0
  11. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/constants.py +0 -0
  12. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/credentials.py +0 -0
  13. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/exceptions.py +0 -0
  14. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/limits.py +0 -0
  15. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/models.py +0 -0
  16. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/py.typed +0 -0
  17. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/retry.py +0 -0
  18. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/settings.py +0 -0
  19. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/SOURCES.txt +0 -0
  20. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
  21. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/requires.txt +0 -0
  22. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
  23. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_auth.py +0 -0
  24. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_credentials.py +0 -0
  25. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_exceptions.py +0 -0
  26. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_limits.py +0 -0
  27. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_models.py +0 -0
  28. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_retry.py +0 -0
  29. {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fotoware-client
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: A thin, honest client for the FotoWare API: one call, one request; refuses queries FotoWare would answer wrongly; counts what each request costs
5
5
  Author: Michael Hoppe
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-fotoware-client"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "A thin, honest client for the FotoWare API: one call, one request; refuses queries FotoWare would answer wrongly; counts what each request costs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14,<3.15"
@@ -23,7 +23,7 @@ from fotoware_client.models import Archive, Asset, SearchResult
23
23
  from fotoware_client.retry import NO_RETRY, STANDARD_RETRY, RetryConfig
24
24
  from fotoware_client.settings import ServerSettings
25
25
 
26
- __version__ = "0.2.1"
26
+ __version__ = "0.2.3"
27
27
 
28
28
  __all__ = [
29
29
  # Client
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import time
7
- from collections.abc import Iterator
7
+ from collections.abc import Callable, Iterator
8
8
  from typing import Any, BinaryIO
9
9
  from urllib.parse import urljoin
10
10
 
@@ -161,9 +161,7 @@ class FotoWareClient:
161
161
  NotFoundError: If there is no such asset
162
162
  APIError: If the request fails
163
163
  """
164
- response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_ASSET)
165
- asset = Asset.from_json(response.json())
166
- response.close()
164
+ asset = Asset.from_json(self._get_json(self.resolve(asset_href), headers=ACCEPT_ASSET))
167
165
  return asset
168
166
 
169
167
  def cost_snapshot(self) -> dict[str, object]:
@@ -194,9 +192,9 @@ class FotoWareClient:
194
192
  me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
195
193
 
196
194
  try:
197
- response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
198
- response.json() # Validate response is valid JSON
199
- response.close()
195
+ self._get_json(
196
+ me_url, headers=ACCEPT_FULL_DESCRIPTOR
197
+ ) # Validate response is valid JSON
200
198
  logger.debug("FotoWare API connection validated successfully")
201
199
  except Exception as e:
202
200
  logger.error("FotoWare API connection validation failed: %s", e)
@@ -211,6 +209,19 @@ class FotoWareClient:
211
209
  token = self._auth.get_access_token()
212
210
  return {"Authorization": f"Bearer {token}"}
213
211
 
212
+ def _get_json(self, url: str, **kwargs: Any) -> Any:
213
+ """GET *url* and return its JSON; the response is closed either way.
214
+
215
+ Raises:
216
+ requests.JSONDecodeError: If the answer is no JSON
217
+ The errors of _request
218
+ """
219
+ response = self._request("GET", url, **kwargs)
220
+ try:
221
+ return response.json()
222
+ finally:
223
+ response.close()
224
+
214
225
  def _request(
215
226
  self,
216
227
  method: str,
@@ -324,17 +335,13 @@ class FotoWareClient:
324
335
 
325
336
  # The API descriptor names the archives list, as a path on the tenant
326
337
  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()
338
+ archives_path = self._get_json(me_url, headers=ACCEPT_FULL_DESCRIPTOR).get("archives")
330
339
  if not isinstance(archives_path, str) or not archives_path:
331
340
  raise APIError("No archives link found in API descriptor")
332
341
  archives_href = urljoin(self.tenant_url + "/", archives_path)
333
342
 
334
343
  # Fetch archives
335
- response = self._request("GET", archives_href, headers=ACCEPT_COLLECTIONLIST)
336
- archives_data = response.json()
337
- response.close()
344
+ archives_data = self._get_json(archives_href, headers=ACCEPT_COLLECTIONLIST)
338
345
 
339
346
  # Parse archives
340
347
  archives = []
@@ -363,17 +370,13 @@ class FotoWareClient:
363
370
  editor lists no fields
364
371
  KeyError: If the editor's answer has another shape
365
372
  """
366
- response = self._request("GET", self.resolve(archive_href), headers=ACCEPT_COLLECTIONINFO)
367
- archive = response.json()
368
- response.close()
373
+ archive = self._get_json(self.resolve(archive_href), headers=ACCEPT_COLLECTIONINFO)
369
374
  editor_href = (archive.get("metadataEditor") or {}).get("href")
370
375
  if not editor_href:
371
376
  raise APIError(f"Archive {archive_href} links no metadata editor")
372
377
 
373
378
  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()
379
+ editor = self._get_json(editor_url, headers=ACCEPT_JSON)
377
380
 
378
381
  labels: dict[str, str] = {}
379
382
  for region in editor["detailRegions"]:
@@ -428,9 +431,9 @@ class FotoWareClient:
428
431
  page = self.next_page(page)
429
432
 
430
433
  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
+ result = SearchResult.from_json(
435
+ self._get_json(url, params=params, headers=ACCEPT_ASSETLIST)
436
+ )
434
437
  self._counters.count_search_page(empty=not result.assets)
435
438
  return result
436
439
 
@@ -454,9 +457,7 @@ class FotoWareClient:
454
457
 
455
458
  try:
456
459
  # 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
+ data = self._get_json(self.resolve(asset_href), headers=ACCEPT_DOWNLOAD)
460
461
 
461
462
  # Look for direct URL in attributes
462
463
  if "attributes" in data and "downloadUrl" in data["attributes"]:
@@ -469,9 +470,7 @@ class FotoWareClient:
469
470
  download_href = link.get("href")
470
471
  if download_href:
471
472
  # Fetch presigned URL
472
- response = self._request("GET", download_href, headers=ACCEPT_DOWNLOAD)
473
- download_data = response.json()
474
- response.close()
473
+ download_data = self._get_json(download_href, headers=ACCEPT_DOWNLOAD)
475
474
  result_url: str | None = download_data.get("url") or download_data.get(
476
475
  "href"
477
476
  )
@@ -511,6 +510,57 @@ class FotoWareClient:
511
510
  *,
512
511
  timeout: int | None = None,
513
512
  rendition: str | None = None,
513
+ ) -> str:
514
+ """The URL a prepared rendition can be downloaded from, the original's
515
+ unless *rendition* names another.
516
+
517
+ FotoWare prepares the file in a background task and serves it under
518
+ that task's address for a limited time; download it promptly.
519
+
520
+ Raises:
521
+ APIError: If the asset has no such rendition, or a request fails
522
+ """
523
+ return self._renewing_gone_rendition(
524
+ lambda: self._get_rendition_url_once(asset_href, timeout=timeout, rendition=rendition)
525
+ )
526
+
527
+ def download_original(self, asset_href: str, *, timeout: int | None = None) -> bytes:
528
+ """The original file of an asset, in memory.
529
+
530
+ Raises:
531
+ APIError: If the request or download fails
532
+ """
533
+ return self._renewing_gone_rendition(
534
+ lambda: self._download_original_once(asset_href, timeout=timeout)
535
+ )
536
+
537
+ def _renewing_gone_rendition[T](self, attempt: Callable[[], T]) -> T:
538
+ """Run a rendition *attempt*; if FotoWare says its result is gone, once more.
539
+
540
+ A rendition is prepared in a background task, and FotoWare answers
541
+ 410 Gone once that task's result has been cleaned up. The address is
542
+ dead then; asking it again is pointless, but a new rendition request
543
+ starts a new task. Seen on 2026-10-24 for a few renditions in a hundred
544
+ while FotoWare was otherwise healthy. The repeat counts as a retry.
545
+
546
+ A 410 arrives before any byte is written, so a destination file is
547
+ still untouched when the attempt is repeated.
548
+ """
549
+ try:
550
+ return attempt()
551
+ except APIError as exc:
552
+ if exc.status_code != 410:
553
+ raise
554
+ logger.info("Rendition gone (410); requesting it anew: %s", exc)
555
+ self._counters.count_retry(410, None, 0.0)
556
+ return attempt()
557
+
558
+ def _get_rendition_url_once(
559
+ self,
560
+ asset_href: str,
561
+ *,
562
+ timeout: int | None = None,
563
+ rendition: str | None = None,
514
564
  ) -> str:
515
565
  """Get download URL using FotoWare rendition API.
516
566
 
@@ -548,9 +598,7 @@ class FotoWareClient:
548
598
  logger.debug("get_rendition_url: Starting for asset=%s", asset_href)
549
599
 
550
600
  # 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()
601
+ asset_data = self._get_json(self.resolve(asset_href), headers=ACCEPT_ASSET)
554
602
 
555
603
  # Step 2: Find the rendition asked for, the original unless named
556
604
  renditions = asset_data.get("renditions", [])
@@ -588,9 +636,7 @@ class FotoWareClient:
588
636
 
589
637
  # Step 3: Get rendition service URL
590
638
  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()
639
+ me_data = self._get_json(me_url, headers=ACCEPT_FULL_DESCRIPTOR)
594
640
 
595
641
  rendition_service_url = me_data.get("services", {}).get("rendition_request")
596
642
  if not rendition_service_url:
@@ -618,11 +664,10 @@ class FotoWareClient:
618
664
  )
619
665
 
620
666
  # 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()
667
+ try:
668
+ polling_url = response.headers.get("Location") or response.json().get("href")
669
+ finally:
624
670
  response.close()
625
- polling_url = response_data.get("href")
626
671
 
627
672
  if not polling_url:
628
673
  raise APIError(
@@ -677,7 +722,7 @@ class FotoWareClient:
677
722
  context={"asset_href": asset_href, "polling_url": polling_url},
678
723
  )
679
724
 
680
- def download_original(self, asset_href: str, *, timeout: int | None = None) -> bytes:
725
+ def _download_original_once(self, asset_href: str, *, timeout: int | None = None) -> bytes:
681
726
  """Download original file using FotoWare rendition API (atomic operation).
682
727
 
683
728
  This is the recommended way to download original files. It handles the
@@ -713,9 +758,7 @@ class FotoWareClient:
713
758
  logger.debug("download_original: Starting for asset=%s", asset_href)
714
759
 
715
760
  # 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()
761
+ asset_data = self._get_json(self.resolve(asset_href), headers=ACCEPT_ASSET)
719
762
 
720
763
  # Step 2: Find the original rendition
721
764
  renditions = asset_data.get("renditions", [])
@@ -750,9 +793,7 @@ class FotoWareClient:
750
793
 
751
794
  # Step 3: Get rendition service URL
752
795
  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()
796
+ me_data = self._get_json(me_url, headers=ACCEPT_FULL_DESCRIPTOR)
756
797
 
757
798
  rendition_service_url = me_data.get("services", {}).get("rendition_request")
758
799
  if not rendition_service_url:
@@ -781,11 +822,10 @@ class FotoWareClient:
781
822
  )
782
823
 
783
824
  # 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()
825
+ try:
826
+ polling_url = response.headers.get("Location") or response.json().get("href")
827
+ finally:
787
828
  response.close()
788
- polling_url = response_data.get("href")
789
829
 
790
830
  if not polling_url:
791
831
  raise APIError(
@@ -863,11 +903,13 @@ class FotoWareClient:
863
903
  APIError: If rendition request or download fails
864
904
  NotFoundError: If asset or original rendition not found
865
905
  """
866
- self._stream_to_file(
867
- self.get_rendition_url(asset_href, timeout=timeout),
868
- destination,
869
- timeout=timeout,
870
- chunk_size=chunk_size,
906
+ self._renewing_gone_rendition(
907
+ lambda: self._stream_to_file(
908
+ self._get_rendition_url_once(asset_href, timeout=timeout),
909
+ destination,
910
+ timeout=timeout,
911
+ chunk_size=chunk_size,
912
+ )
871
913
  )
872
914
 
873
915
  def download_rendition_to_file(
@@ -886,11 +928,13 @@ class FotoWareClient:
886
928
  APIError: If the asset has no such rendition, or its request or
887
929
  download fails
888
930
  """
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,
931
+ self._renewing_gone_rendition(
932
+ lambda: self._stream_to_file(
933
+ self._get_rendition_url_once(asset_href, timeout=timeout, rendition=rendition),
934
+ destination,
935
+ timeout=timeout,
936
+ chunk_size=chunk_size,
937
+ )
894
938
  )
895
939
 
896
940
  def _stream_to_file(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fotoware-client
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: A thin, honest client for the FotoWare API: one call, one request; refuses queries FotoWare would answer wrongly; counts what each request costs
5
5
  Author: Michael Hoppe
6
6
  License-Expression: MIT
@@ -4,6 +4,7 @@ import io
4
4
  from unittest.mock import Mock, patch
5
5
 
6
6
  import pytest
7
+ import requests
7
8
 
8
9
  from fotoware_client.client import FotoWareClient
9
10
  from fotoware_client.exceptions import APIError, ConfigurationError
@@ -416,7 +417,7 @@ class TestFotoWareClientDownloadOriginalToFile:
416
417
  with (
417
418
  patch.object(
418
419
  client,
419
- "get_rendition_url",
420
+ "_get_rendition_url_once",
420
421
  return_value="https://fotoware.example.com/renditions/file.bin",
421
422
  ) as mock_get_rendition_url,
422
423
  patch.object(
@@ -460,7 +461,7 @@ class TestFotoWareClientDownloadOriginalToFile:
460
461
  with (
461
462
  patch.object(
462
463
  client,
463
- "get_rendition_url",
464
+ "_get_rendition_url_once",
464
465
  return_value="https://fotoware.example.com/renditions/medium.jpg",
465
466
  ) as mock_get_rendition_url,
466
467
  patch.object(client, "_request", return_value=response),
@@ -508,3 +509,116 @@ class TestFotoWareClientRenditionChoice:
508
509
  pytest.raises(APIError, match="No Large rendition"),
509
510
  ):
510
511
  client.get_rendition_url("https://fotoware.example.com/asset/1.info", rendition="Large")
512
+
513
+
514
+ class TestResponsesAreClosed:
515
+ """Every response goes back to the pool, whatever its body holds."""
516
+
517
+ @patch("fotoware_client.client.OAuthAuthenticator")
518
+ def test_an_unreadable_answer_is_closed(self, mock_auth: Mock) -> None:
519
+ client = FotoWareClient("https://fotoware.example.com", "id", "secret")
520
+ response = Mock()
521
+ response.json.side_effect = requests.JSONDecodeError("invalid json", "", 0)
522
+
523
+ with patch.object(client, "_request", return_value=response):
524
+ with pytest.raises(requests.JSONDecodeError):
525
+ client.get_asset("/a/1.jpg.info")
526
+
527
+ response.close.assert_called_once()
528
+
529
+ @patch("fotoware_client.client.OAuthAuthenticator")
530
+ def test_a_rendition_request_answered_with_a_location_is_closed(self, mock_auth: Mock) -> None:
531
+ client = FotoWareClient("https://fotoware.example.com", "id", "secret")
532
+ asset = Mock()
533
+ asset.json.return_value = {"renditions": [{"original": True, "href": "/r/1"}]}
534
+ descriptor = Mock()
535
+ descriptor.json.return_value = {
536
+ "services": {"rendition_request": "/fotoweb/services/renditions"}
537
+ }
538
+ requested = Mock(status_code=202, headers={"Location": "/fotoweb/poll/1"})
539
+ ready = Mock(status_code=200)
540
+
541
+ with patch.object(client, "_request", side_effect=[asset, descriptor, requested, ready]):
542
+ client.get_rendition_url("/a/1.jpg.info")
543
+
544
+ requested.close.assert_called_once()
545
+ requested.json.assert_not_called()
546
+
547
+
548
+ class TestGoneRenditions:
549
+ """A rendition whose background task is gone (410) is requested anew, once."""
550
+
551
+ @staticmethod
552
+ def _gone() -> APIError:
553
+ return APIError("HTTP 410 error", status_code=410)
554
+
555
+ @patch("fotoware_client.client.OAuthAuthenticator")
556
+ def test_a_gone_rendition_is_requested_anew(self, mock_auth: Mock) -> None:
557
+ client = FotoWareClient("https://fotoware.example.com", "id", "secret")
558
+ destination = io.BytesIO()
559
+
560
+ def stream(url: str, dest: io.BytesIO, **_: object) -> None:
561
+ if url.endswith("/task-1/file.jpg"):
562
+ raise self._gone()
563
+ dest.write(b"jpeg")
564
+
565
+ with (
566
+ patch.object(
567
+ client,
568
+ "_get_rendition_url_once",
569
+ side_effect=["https://f/task-1/file.jpg", "https://f/task-2/file.jpg"],
570
+ ) as rendition_url,
571
+ patch.object(client, "_stream_to_file", side_effect=stream),
572
+ ):
573
+ client.download_rendition_to_file(
574
+ "/a/1.jpg.info", destination, rendition="Medium (JPG, 3000px)"
575
+ )
576
+
577
+ assert rendition_url.call_count == 2
578
+ assert destination.getvalue() == b"jpeg"
579
+ assert client.cost_snapshot()["retries_by_status"] == {"410": 1}
580
+
581
+ @patch("fotoware_client.client.OAuthAuthenticator")
582
+ def test_a_rendition_gone_twice_is_an_error(self, mock_auth: Mock) -> None:
583
+ client = FotoWareClient("https://fotoware.example.com", "id", "secret")
584
+
585
+ with (
586
+ patch.object(client, "_get_rendition_url_once", return_value="https://f/t/x.jpg"),
587
+ patch.object(client, "_stream_to_file", side_effect=self._gone()) as stream,
588
+ ):
589
+ with pytest.raises(APIError) as exc_info:
590
+ client.download_original_to_file("/a/1.jpg.info", io.BytesIO())
591
+
592
+ assert exc_info.value.status_code == 410
593
+ assert stream.call_count == 2
594
+
595
+ @patch("fotoware_client.client.OAuthAuthenticator")
596
+ def test_other_errors_are_not_repeated(self, mock_auth: Mock) -> None:
597
+ client = FotoWareClient("https://fotoware.example.com", "id", "secret")
598
+
599
+ with (
600
+ patch.object(client, "_get_rendition_url_once", return_value="https://f/t/x.jpg"),
601
+ patch.object(
602
+ client,
603
+ "_stream_to_file",
604
+ side_effect=APIError("HTTP 403 error", status_code=403),
605
+ ) as stream,
606
+ ):
607
+ with pytest.raises(APIError):
608
+ client.download_original_to_file("/a/1.jpg.info", io.BytesIO())
609
+
610
+ assert stream.call_count == 1
611
+
612
+ @patch("fotoware_client.client.OAuthAuthenticator")
613
+ def test_a_rendition_gone_while_polling_is_requested_anew(self, mock_auth: Mock) -> None:
614
+ client = FotoWareClient("https://fotoware.example.com", "id", "secret")
615
+
616
+ with patch.object(
617
+ client,
618
+ "_get_rendition_url_once",
619
+ side_effect=[self._gone(), "https://f/task-2/file.jpg"],
620
+ ):
621
+ assert client.get_rendition_url("/a/1.jpg.info") == "https://f/task-2/file.jpg"
622
+
623
+ with patch.object(client, "_download_original_once", side_effect=[self._gone(), b"bytes"]):
624
+ assert client.download_original("/a/1.jpg.info") == b"bytes"