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.
Files changed (29) hide show
  1. {python_fotoware_client-0.2.0/src/python_fotoware_client.egg-info → python_fotoware_client-0.2.2}/PKG-INFO +2 -2
  2. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/README.md +1 -1
  3. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/pyproject.toml +1 -1
  4. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/__init__.py +1 -1
  5. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/client.py +36 -47
  6. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2/src/python_fotoware_client.egg-info}/PKG-INFO +2 -2
  7. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_client.py +35 -0
  8. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/LICENSE +0 -0
  9. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/setup.cfg +0 -0
  10. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/auth.py +0 -0
  11. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/constants.py +0 -0
  12. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/credentials.py +0 -0
  13. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/exceptions.py +0 -0
  14. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/limits.py +0 -0
  15. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/models.py +0 -0
  16. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/py.typed +0 -0
  17. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/retry.py +0 -0
  18. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/fotoware_client/settings.py +0 -0
  19. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/SOURCES.txt +0 -0
  20. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
  21. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/requires.txt +0 -0
  22. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
  23. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_auth.py +0 -0
  24. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_credentials.py +0 -0
  25. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_exceptions.py +0 -0
  26. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_limits.py +0 -0
  27. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_models.py +0 -0
  28. {python_fotoware_client-0.2.0 → python_fotoware_client-0.2.2}/tests/test_retry.py +0 -0
  29. {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.0
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).
@@ -136,4 +136,4 @@ Pre-commit hooks: `pip install pre-commit && pre-commit install`.
136
136
 
137
137
  ## License
138
138
 
139
- MIT, see [LICENSE](LICENSE).
139
+ 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.0"
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"
@@ -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.0"
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
- 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
  )
@@ -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
- response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_ASSET)
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
- response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
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
- polling_url = response.headers.get("Location")
622
- if not polling_url:
623
- response_data = response.json()
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
- response = self._request("GET", self.resolve(asset_href), headers=ACCEPT_ASSET)
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
- response = self._request("GET", me_url, headers=ACCEPT_FULL_DESCRIPTOR)
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
- polling_url = response.headers.get("Location")
785
- if not polling_url:
786
- response_data = response.json()
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.0
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()