python-fotoware-client 0.2.2__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.2/src/python_fotoware_client.egg-info → python_fotoware_client-0.2.3}/PKG-INFO +1 -1
  2. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/pyproject.toml +1 -1
  3. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/__init__.py +1 -1
  4. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/client.py +67 -12
  5. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3/src/python_fotoware_client.egg-info}/PKG-INFO +1 -1
  6. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_client.py +81 -2
  7. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/LICENSE +0 -0
  8. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/README.md +0 -0
  9. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/setup.cfg +0 -0
  10. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/auth.py +0 -0
  11. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/constants.py +0 -0
  12. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/credentials.py +0 -0
  13. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/exceptions.py +0 -0
  14. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/limits.py +0 -0
  15. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/models.py +0 -0
  16. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/py.typed +0 -0
  17. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/retry.py +0 -0
  18. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/fotoware_client/settings.py +0 -0
  19. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/SOURCES.txt +0 -0
  20. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
  21. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/requires.txt +0 -0
  22. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
  23. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_auth.py +0 -0
  24. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_credentials.py +0 -0
  25. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_exceptions.py +0 -0
  26. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_limits.py +0 -0
  27. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_models.py +0 -0
  28. {python_fotoware_client-0.2.2 → python_fotoware_client-0.2.3}/tests/test_retry.py +0 -0
  29. {python_fotoware_client-0.2.2 → 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.2
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.2"
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.2"
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
 
@@ -510,6 +510,57 @@ class FotoWareClient:
510
510
  *,
511
511
  timeout: int | None = None,
512
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,
513
564
  ) -> str:
514
565
  """Get download URL using FotoWare rendition API.
515
566
 
@@ -671,7 +722,7 @@ class FotoWareClient:
671
722
  context={"asset_href": asset_href, "polling_url": polling_url},
672
723
  )
673
724
 
674
- 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:
675
726
  """Download original file using FotoWare rendition API (atomic operation).
676
727
 
677
728
  This is the recommended way to download original files. It handles the
@@ -852,11 +903,13 @@ class FotoWareClient:
852
903
  APIError: If rendition request or download fails
853
904
  NotFoundError: If asset or original rendition not found
854
905
  """
855
- self._stream_to_file(
856
- self.get_rendition_url(asset_href, timeout=timeout),
857
- destination,
858
- timeout=timeout,
859
- 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
+ )
860
913
  )
861
914
 
862
915
  def download_rendition_to_file(
@@ -875,11 +928,13 @@ class FotoWareClient:
875
928
  APIError: If the asset has no such rendition, or its request or
876
929
  download fails
877
930
  """
878
- self._stream_to_file(
879
- self.get_rendition_url(asset_href, timeout=timeout, rendition=rendition),
880
- destination,
881
- timeout=timeout,
882
- 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
+ )
883
938
  )
884
939
 
885
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.2
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
@@ -417,7 +417,7 @@ class TestFotoWareClientDownloadOriginalToFile:
417
417
  with (
418
418
  patch.object(
419
419
  client,
420
- "get_rendition_url",
420
+ "_get_rendition_url_once",
421
421
  return_value="https://fotoware.example.com/renditions/file.bin",
422
422
  ) as mock_get_rendition_url,
423
423
  patch.object(
@@ -461,7 +461,7 @@ class TestFotoWareClientDownloadOriginalToFile:
461
461
  with (
462
462
  patch.object(
463
463
  client,
464
- "get_rendition_url",
464
+ "_get_rendition_url_once",
465
465
  return_value="https://fotoware.example.com/renditions/medium.jpg",
466
466
  ) as mock_get_rendition_url,
467
467
  patch.object(client, "_request", return_value=response),
@@ -543,3 +543,82 @@ class TestResponsesAreClosed:
543
543
 
544
544
  requested.close.assert_called_once()
545
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"