python-fotoware-client 0.2.0__tar.gz → 0.2.2__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.0/src/python_fotoware_client.egg-info → python_fotoware_client-0.2.2}/PKG-INFO +2 -2
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/README.md +1 -1
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/pyproject.toml +1 -1
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/__init__.py +1 -1
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/client.py +36 -47
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2/src/python_fotoware_client.egg-info}/PKG-INFO +2 -2
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_client.py +35 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/LICENSE +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/setup.cfg +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/auth.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/constants.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/credentials.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/exceptions.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/limits.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/models.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/py.typed +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/retry.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/settings.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/SOURCES.txt +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/requires.txt +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_auth.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_credentials.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_exceptions.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_limits.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_models.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_retry.py +0 -0
- {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/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.2
|
|
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
|
|
@@ -157,4 +157,4 @@ Pre-commit hooks: `pip install pre-commit && pre-commit install`.
|
|
|
157
157
|
|
|
158
158
|
## License
|
|
159
159
|
|
|
160
|
-
MIT, see [LICENSE](LICENSE).
|
|
160
|
+
MIT, see [LICENSE](https://github.com/hoppefamily/python-fotoware-client/blob/main/LICENSE).
|
|
@@ -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.2"
|
|
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.0 → python_fotoware_client-0.2.2}/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.2"
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
29
|
# Client
|
|
@@ -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
|
)
|
|
@@ -548,9 +547,7 @@ class FotoWareClient:
|
|
|
548
547
|
logger.debug("get_rendition_url: Starting for asset=%s", asset_href)
|
|
549
548
|
|
|
550
549
|
# Step 1: Get asset metadata with renditions
|
|
551
|
-
|
|
552
|
-
asset_data = response.json()
|
|
553
|
-
response.close()
|
|
550
|
+
asset_data = self._get_json(self.resolve(asset_href), headers=ACCEPT_ASSET)
|
|
554
551
|
|
|
555
552
|
# Step 2: Find the rendition asked for, the original unless named
|
|
556
553
|
renditions = asset_data.get("renditions", [])
|
|
@@ -588,9 +585,7 @@ class FotoWareClient:
|
|
|
588
585
|
|
|
589
586
|
# Step 3: Get rendition service URL
|
|
590
587
|
me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
|
|
591
|
-
|
|
592
|
-
me_data = response.json()
|
|
593
|
-
response.close()
|
|
588
|
+
me_data = self._get_json(me_url, headers=ACCEPT_FULL_DESCRIPTOR)
|
|
594
589
|
|
|
595
590
|
rendition_service_url = me_data.get("services", {}).get("rendition_request")
|
|
596
591
|
if not rendition_service_url:
|
|
@@ -618,11 +613,10 @@ class FotoWareClient:
|
|
|
618
613
|
)
|
|
619
614
|
|
|
620
615
|
# Get polling URL from Location header or response body
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
616
|
+
try:
|
|
617
|
+
polling_url = response.headers.get("Location") or response.json().get("href")
|
|
618
|
+
finally:
|
|
624
619
|
response.close()
|
|
625
|
-
polling_url = response_data.get("href")
|
|
626
620
|
|
|
627
621
|
if not polling_url:
|
|
628
622
|
raise APIError(
|
|
@@ -713,9 +707,7 @@ class FotoWareClient:
|
|
|
713
707
|
logger.debug("download_original: Starting for asset=%s", asset_href)
|
|
714
708
|
|
|
715
709
|
# Step 1: Get asset metadata with renditions
|
|
716
|
-
|
|
717
|
-
asset_data = response.json()
|
|
718
|
-
response.close()
|
|
710
|
+
asset_data = self._get_json(self.resolve(asset_href), headers=ACCEPT_ASSET)
|
|
719
711
|
|
|
720
712
|
# Step 2: Find the original rendition
|
|
721
713
|
renditions = asset_data.get("renditions", [])
|
|
@@ -750,9 +742,7 @@ class FotoWareClient:
|
|
|
750
742
|
|
|
751
743
|
# Step 3: Get rendition service URL
|
|
752
744
|
me_url = urljoin(self.tenant_url + "/", self.settings.api_descriptor_path.lstrip("/"))
|
|
753
|
-
|
|
754
|
-
me_data = response.json()
|
|
755
|
-
response.close()
|
|
745
|
+
me_data = self._get_json(me_url, headers=ACCEPT_FULL_DESCRIPTOR)
|
|
756
746
|
|
|
757
747
|
rendition_service_url = me_data.get("services", {}).get("rendition_request")
|
|
758
748
|
if not rendition_service_url:
|
|
@@ -781,11 +771,10 @@ class FotoWareClient:
|
|
|
781
771
|
)
|
|
782
772
|
|
|
783
773
|
# Get polling URL from Location header or response body
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
774
|
+
try:
|
|
775
|
+
polling_url = response.headers.get("Location") or response.json().get("href")
|
|
776
|
+
finally:
|
|
787
777
|
response.close()
|
|
788
|
-
polling_url = response_data.get("href")
|
|
789
778
|
|
|
790
779
|
if not polling_url:
|
|
791
780
|
raise APIError(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-fotoware-client
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -157,4 +157,4 @@ Pre-commit hooks: `pip install pre-commit && pre-commit install`.
|
|
|
157
157
|
|
|
158
158
|
## License
|
|
159
159
|
|
|
160
|
-
MIT, see [LICENSE](LICENSE).
|
|
160
|
+
MIT, see [LICENSE](https://github.com/hoppefamily/python-fotoware-client/blob/main/LICENSE).
|
|
@@ -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
|
|
@@ -508,3 +509,37 @@ 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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/constants.py
RENAMED
|
File without changes
|
{python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/credentials.py
RENAMED
|
File without changes
|
{python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/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.0 → python_fotoware_client-0.2.2}/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
|