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.
- {python_fotoware_client-0.2.1/src/python_fotoware_client.egg-info → python_fotoware_client-0.2.3}/PKG-INFO +1 -1
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/pyproject.toml +1 -1
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/__init__.py +1 -1
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/client.py +103 -59
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3/src/python_fotoware_client.egg-info}/PKG-INFO +1 -1
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_client.py +116 -2
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/LICENSE +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/README.md +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/setup.cfg +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/auth.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/constants.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/credentials.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/exceptions.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/limits.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/models.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/py.typed +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/retry.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/settings.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/SOURCES.txt +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/requires.txt +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_auth.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_credentials.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_exceptions.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_limits.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_models.py +0 -0
- {python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/tests/test_retry.py +0 -0
- {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.
|
|
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.
|
|
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"
|
{python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/__init__.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
response
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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.
|
|
867
|
-
self.
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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.
|
|
890
|
-
self.
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/constants.py
RENAMED
|
File without changes
|
{python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/credentials.py
RENAMED
|
File without changes
|
{python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fotoware_client-0.2.1 → python_fotoware_client-0.2.3}/src/fotoware_client/settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|