python-fotoware-client 0.2.3__tar.gz → 0.3.0__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 (33) hide show
  1. {python_fotoware_client-0.2.3/src/python_fotoware_client.egg-info → python_fotoware_client-0.3.0}/PKG-INFO +23 -12
  2. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/README.md +22 -11
  3. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/pyproject.toml +1 -1
  4. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/__init__.py +1 -1
  5. python_fotoware_client-0.3.0/src/fotoware_client/client.py +406 -0
  6. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/constants.py +0 -1
  7. python_fotoware_client-0.3.0/src/fotoware_client/extensions.py +181 -0
  8. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/retry.py +12 -2
  9. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0/src/python_fotoware_client.egg-info}/PKG-INFO +23 -12
  10. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/SOURCES.txt +2 -0
  11. python_fotoware_client-0.3.0/tests/test_client.py +305 -0
  12. python_fotoware_client-0.3.0/tests/test_extensions.py +213 -0
  13. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_retry.py +19 -4
  14. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_settings.py +3 -27
  15. python_fotoware_client-0.2.3/src/fotoware_client/client.py +0 -963
  16. python_fotoware_client-0.2.3/tests/test_client.py +0 -624
  17. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/LICENSE +0 -0
  18. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/setup.cfg +0 -0
  19. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/auth.py +0 -0
  20. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/credentials.py +0 -0
  21. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/exceptions.py +0 -0
  22. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/limits.py +0 -0
  23. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/models.py +0 -0
  24. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/py.typed +0 -0
  25. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/settings.py +0 -0
  26. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
  27. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/requires.txt +0 -0
  28. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
  29. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_auth.py +0 -0
  30. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_credentials.py +0 -0
  31. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_exceptions.py +0 -0
  32. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_limits.py +0 -0
  33. {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-fotoware-client
3
- Version: 0.2.3
3
+ Version: 0.3.0
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
@@ -33,18 +33,24 @@ their trademarks.
33
33
 
34
34
  ## What it does
35
35
 
36
- - **One call is one request.** `search_assets` returns one page; `next_page`
37
- and `iter_search` follow `paging.next`. Nothing is batched or asked again
38
- behind the caller's back. The only exception are the rendition downloads,
39
- which poll until FotoWare has prepared the file.
36
+ - **One method per API call, one request each.** `FotoWareClient` mirrors
37
+ FotoWare's REST API: `get_api_descriptor`, `get_archives`, `get_archive`,
38
+ `get_metadata_editor`, `search` (one page), `get_page`, `get_asset`,
39
+ `request_rendition`, `get_rendition_task`. Nothing is batched or asked again
40
+ behind the caller's back.
41
+ - **Extensions for what takes several calls.** `fotoware_client.extensions`
42
+ builds on those methods alone: `list_archives`, `field_labels`, `iter_search`
43
+ over every page, and `Rendition` for a file from request to download.
40
44
  - **Refuses a query FotoWare would answer wrongly.** Over FotoWare's size limit
41
45
  a search comes back as a 500 or, worse, as an empty list that looks exactly
42
- like "no hits". `search_assets` estimates the query first and raises
46
+ like "no hits". `search` estimates the query first and raises
43
47
  `QueryTooLargeError` over the budget, without sending it.
44
48
  `estimate_query_size` is public, so callers can batch to stay under it.
45
49
  - **Retries what is transient.** urllib3's `Retry` on the session retries 429
46
50
  (honouring `Retry-After`) and 502/503/504, twice by default. A 500 is not
47
51
  retried: it was always caused by the query.
52
+ The waits double from 1 s and carry jitter, so parallel callers do not
53
+ all ask again at the same moment; a `Retry-After` is kept as sent.
48
54
  - **Counts the cost.** `cost_snapshot()` reports requests, retries, errors,
49
55
  search pages and empty search pages.
50
56
  - OAuth client credentials with automatic token refresh; context manager.
@@ -111,20 +117,25 @@ shell history.
111
117
 
112
118
  ```python
113
119
  from fotoware_client import FotoWareClient
120
+ from fotoware_client.extensions import Rendition, iter_search, list_archives
114
121
 
115
122
  with FotoWareClient.from_env() as client:
116
- archive = next(
117
- a for a in client.list_archives() if a.href.startswith("/fotoweb/archives/5000-")
118
- )
123
+ archive = next(a for a in list_archives(client) if a.href.startswith("/fotoweb/archives/5000-"))
119
124
  query = "(631:4000000000001 OR 631:4000000000002) AND (dt:image OR dt:graphic)"
120
- for page in client.iter_search(archive.href, query):
125
+ for page in iter_search(client, archive.href, query):
121
126
  for asset in page.assets:
122
127
  print(asset.filename)
128
+
129
+ Rendition(client, asset.href).save("original.jpg")
130
+ data = Rendition(client, asset.href, "Medium (JPG, 3000px)").read()
123
131
  print(client.cost_snapshot())
124
132
  ```
125
133
 
126
- Other calls: `get_asset`, `get_field_labels`, `get_rendition_url`, `download_original`,
127
- `download_original_to_file`, `download_rendition_to_file`, `download_bytes`.
134
+ `Rendition(client, href, name=None)` asks FotoWare to prepare the original, or
135
+ the rendition with that display name, polls the task, and downloads the file
136
+ in the poll that finds it ready: `.save(path)`, `.read()`, `.write_to(file)`,
137
+ or `.url()` for the prepared address alone. A task FotoWare has already
138
+ cleaned up (410 Gone) is requested anew, once.
128
139
 
129
140
  ## Errors
130
141
 
@@ -12,18 +12,24 @@ their trademarks.
12
12
 
13
13
  ## What it does
14
14
 
15
- - **One call is one request.** `search_assets` returns one page; `next_page`
16
- and `iter_search` follow `paging.next`. Nothing is batched or asked again
17
- behind the caller's back. The only exception are the rendition downloads,
18
- which poll until FotoWare has prepared the file.
15
+ - **One method per API call, one request each.** `FotoWareClient` mirrors
16
+ FotoWare's REST API: `get_api_descriptor`, `get_archives`, `get_archive`,
17
+ `get_metadata_editor`, `search` (one page), `get_page`, `get_asset`,
18
+ `request_rendition`, `get_rendition_task`. Nothing is batched or asked again
19
+ behind the caller's back.
20
+ - **Extensions for what takes several calls.** `fotoware_client.extensions`
21
+ builds on those methods alone: `list_archives`, `field_labels`, `iter_search`
22
+ over every page, and `Rendition` for a file from request to download.
19
23
  - **Refuses a query FotoWare would answer wrongly.** Over FotoWare's size limit
20
24
  a search comes back as a 500 or, worse, as an empty list that looks exactly
21
- like "no hits". `search_assets` estimates the query first and raises
25
+ like "no hits". `search` estimates the query first and raises
22
26
  `QueryTooLargeError` over the budget, without sending it.
23
27
  `estimate_query_size` is public, so callers can batch to stay under it.
24
28
  - **Retries what is transient.** urllib3's `Retry` on the session retries 429
25
29
  (honouring `Retry-After`) and 502/503/504, twice by default. A 500 is not
26
30
  retried: it was always caused by the query.
31
+ The waits double from 1 s and carry jitter, so parallel callers do not
32
+ all ask again at the same moment; a `Retry-After` is kept as sent.
27
33
  - **Counts the cost.** `cost_snapshot()` reports requests, retries, errors,
28
34
  search pages and empty search pages.
29
35
  - OAuth client credentials with automatic token refresh; context manager.
@@ -90,20 +96,25 @@ shell history.
90
96
 
91
97
  ```python
92
98
  from fotoware_client import FotoWareClient
99
+ from fotoware_client.extensions import Rendition, iter_search, list_archives
93
100
 
94
101
  with FotoWareClient.from_env() as client:
95
- archive = next(
96
- a for a in client.list_archives() if a.href.startswith("/fotoweb/archives/5000-")
97
- )
102
+ archive = next(a for a in list_archives(client) if a.href.startswith("/fotoweb/archives/5000-"))
98
103
  query = "(631:4000000000001 OR 631:4000000000002) AND (dt:image OR dt:graphic)"
99
- for page in client.iter_search(archive.href, query):
104
+ for page in iter_search(client, archive.href, query):
100
105
  for asset in page.assets:
101
106
  print(asset.filename)
107
+
108
+ Rendition(client, asset.href).save("original.jpg")
109
+ data = Rendition(client, asset.href, "Medium (JPG, 3000px)").read()
102
110
  print(client.cost_snapshot())
103
111
  ```
104
112
 
105
- Other calls: `get_asset`, `get_field_labels`, `get_rendition_url`, `download_original`,
106
- `download_original_to_file`, `download_rendition_to_file`, `download_bytes`.
113
+ `Rendition(client, href, name=None)` asks FotoWare to prepare the original, or
114
+ the rendition with that display name, polls the task, and downloads the file
115
+ in the poll that finds it ready: `.save(path)`, `.read()`, `.write_to(file)`,
116
+ or `.url()` for the prepared address alone. A task FotoWare has already
117
+ cleaned up (410 Gone) is requested anew, once.
107
118
 
108
119
  ## Errors
109
120
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-fotoware-client"
7
- version = "0.2.3"
7
+ version = "0.3.0"
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.3"
26
+ __version__ = "0.3.0"
27
27
 
28
28
  __all__ = [
29
29
  # Client
@@ -0,0 +1,406 @@
1
+ """FotoWare API client - minimal read-only interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any, BinaryIO
8
+ from urllib.parse import urljoin
9
+
10
+ import requests
11
+
12
+ from fotoware_client.auth import OAuthAuthenticator
13
+ from fotoware_client.constants import (
14
+ ACCEPT_ASSET,
15
+ ACCEPT_ASSETLIST,
16
+ ACCEPT_COLLECTIONINFO,
17
+ ACCEPT_COLLECTIONLIST,
18
+ ACCEPT_FULL_DESCRIPTOR,
19
+ ACCEPT_JSON,
20
+ DEFAULT_TIMEOUT,
21
+ )
22
+ from fotoware_client.credentials import load_credentials
23
+ from fotoware_client.exceptions import (
24
+ APIError,
25
+ ConfigurationError,
26
+ NotFoundError,
27
+ RateLimitError,
28
+ RetryExhaustedError,
29
+ )
30
+ from fotoware_client.limits import RequestCounters, check_query_size
31
+ from fotoware_client.models import Archive, Asset, SearchResult
32
+ from fotoware_client.retry import STANDARD_RETRY, RetryConfig, urllib3_retry
33
+ from fotoware_client.settings import ServerSettings
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class FotoWareClient:
39
+ """The FotoWare API, read-only: one method per API call, one request each.
40
+
41
+ Nothing is batched, combined or asked again behind the caller's back,
42
+ apart from the retries the :class:`RetryConfig` allows, and what each
43
+ request cost is counted (:meth:`cost_snapshot`). Sequences of calls --
44
+ every page of a search, a rendition from request to file, an archive's
45
+ field labels -- are in :mod:`fotoware_client.extensions`.
46
+
47
+ Supports context manager protocol for proper resource cleanup.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ tenant_url: str,
53
+ client_id: str,
54
+ client_secret: str,
55
+ *,
56
+ retry_config: RetryConfig = STANDARD_RETRY,
57
+ timeout: int = DEFAULT_TIMEOUT,
58
+ settings: ServerSettings | None = None,
59
+ ) -> None:
60
+ """Initialize FotoWare client.
61
+
62
+ Args:
63
+ tenant_url: Full URL to FotoWare tenant (e.g., 'https://fotoware.example.com')
64
+ client_id: OAuth client ID
65
+ client_secret: OAuth client secret
66
+ retry_config: Retry configuration for API requests
67
+ timeout: Default request timeout in seconds
68
+ settings: What the installation sets differently from FotoWeb's
69
+ defaults; see ServerSettings
70
+
71
+ Raises:
72
+ ConfigurationError: If tenant_url is invalid or credentials missing
73
+ """
74
+ # Validate tenant URL
75
+ if not tenant_url:
76
+ raise ConfigurationError("tenant_url is required")
77
+
78
+ if not (tenant_url.startswith("http://") or tenant_url.startswith("https://")):
79
+ raise ConfigurationError("tenant_url must be absolute (include scheme)")
80
+
81
+ # Validate credentials
82
+ if not client_id or not client_secret:
83
+ raise ConfigurationError("client_id and client_secret are required")
84
+
85
+ # Normalize tenant URL (remove trailing slash)
86
+ self.tenant_url = tenant_url.rstrip("/")
87
+ self.timeout = timeout
88
+ self.retry_config = retry_config
89
+ self.settings = settings or ServerSettings()
90
+ self._counters = RequestCounters()
91
+
92
+ # Build OAuth token URL
93
+ token_url = urljoin(self.tenant_url + "/", self.settings.oauth_token_path.lstrip("/"))
94
+
95
+ # Initialize OAuth authenticator
96
+ self._auth = OAuthAuthenticator(
97
+ token_url=token_url,
98
+ client_id=client_id,
99
+ client_secret=client_secret,
100
+ timeout=timeout,
101
+ retry_observer=self._observe_token_retry,
102
+ )
103
+
104
+ # Initialize HTTP session with connection pool sized for parallel requests
105
+ self._session = requests.Session()
106
+ # Configure adapter with larger pool to support 15+ parallel workers
107
+ adapter = requests.adapters.HTTPAdapter(
108
+ pool_connections=20,
109
+ pool_maxsize=20,
110
+ max_retries=urllib3_retry(retry_config, self._counters.count_retry),
111
+ )
112
+ self._session.mount("http://", adapter)
113
+ self._session.mount("https://", adapter)
114
+
115
+ logger.debug("FotoWare client initialized for tenant: %s", self.tenant_url)
116
+
117
+ @classmethod
118
+ def from_env(cls, **kwargs: Any) -> FotoWareClient:
119
+ """A client for the tenant the environment or the credentials file names.
120
+
121
+ Reads FOTOWARE_TENANT_URL, FOTOWARE_CLIENT_ID and FOTOWARE_CLIENT_SECRET,
122
+ or, where the environment lacks them, the credentials file; see
123
+ fotoware_client.credentials. Other arguments go to the constructor.
124
+
125
+ Raises:
126
+ ConfigurationError: If neither has the credentials, or the file
127
+ may be read by others
128
+ """
129
+ tenant_url, client_id, client_secret = load_credentials()
130
+ return cls(tenant_url, client_id, client_secret, **kwargs)
131
+
132
+ def __enter__(self) -> FotoWareClient:
133
+ """Enter context manager."""
134
+ return self
135
+
136
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
137
+ """Exit context manager and cleanup resources."""
138
+ self.close()
139
+
140
+ def close(self) -> None:
141
+ """Close HTTP session and release resources."""
142
+ if self._session:
143
+ self._session.close()
144
+ logger.debug("FotoWare client closed")
145
+
146
+ def resolve(self, href: str) -> str:
147
+ """*href* as a full URL: FotoWare answers with paths on the tenant.
148
+
149
+ Every call that takes an href from an answer accepts either form.
150
+ """
151
+ return urljoin(self.tenant_url + "/", href)
152
+
153
+ def cost_snapshot(self) -> dict[str, object]:
154
+ """What this client has asked of FotoWare so far; see RequestCounters."""
155
+ return self._counters.snapshot()
156
+
157
+ def _observe_token_retry(self, event: str, data: dict[str, object]) -> None:
158
+ if event == "retry":
159
+ delay = data.get("delay")
160
+ self._counters.count_retry(
161
+ None, str(data.get("where")), delay if isinstance(delay, float) else 0.0
162
+ )
163
+
164
+ def _get_auth_headers(self) -> dict[str, str]:
165
+ """Get authorization headers with valid access token.
166
+
167
+ Returns:
168
+ Dict with Authorization header
169
+ """
170
+ token = self._auth.get_access_token()
171
+ return {"Authorization": f"Bearer {token}"}
172
+
173
+ def _get_json(self, url: str, **kwargs: Any) -> Any:
174
+ """GET *url* and return its JSON; the response is closed either way.
175
+
176
+ Raises:
177
+ requests.JSONDecodeError: If the answer is no JSON
178
+ The errors of _request
179
+ """
180
+ response = self._request("GET", url, **kwargs)
181
+ try:
182
+ return response.json()
183
+ finally:
184
+ response.close()
185
+
186
+ def _request(
187
+ self,
188
+ method: str,
189
+ url: str,
190
+ *,
191
+ headers: dict[str, str] | None = None,
192
+ timeout: int | None = None,
193
+ **kwargs: Any,
194
+ ) -> requests.Response:
195
+ """Make HTTP request with authentication and retry logic.
196
+
197
+ Args:
198
+ method: HTTP method (GET, POST, etc.)
199
+ url: Full URL to request
200
+ headers: Additional headers (merged with auth headers)
201
+ timeout: Request timeout in seconds (uses default if None)
202
+ **kwargs: Additional arguments passed to requests
203
+
204
+ Returns:
205
+ Response object
206
+
207
+ Raises:
208
+ RateLimitError: A 429 that outlasted the retries.
209
+ RetryExhaustedError: A 502, 503 or 504, a connection error, a
210
+ timeout or an answer broken off that outlasted the retries.
211
+ NotFoundError: A 404.
212
+ APIError: Any other error status, which is not retried; a 500
213
+ among them.
214
+ """
215
+
216
+ request_headers = self._get_auth_headers()
217
+ if headers:
218
+ request_headers.update(headers)
219
+ self._counters.count_request()
220
+ context = {"method": method, "url": url}
221
+ attempts = self.retry_config.max_attempts
222
+ for attempt in range(1, attempts + 1):
223
+ try:
224
+ response = self._session.request(
225
+ method=method,
226
+ url=url,
227
+ headers=request_headers,
228
+ timeout=timeout or self.timeout,
229
+ **kwargs,
230
+ )
231
+ break
232
+ # The body broke off after urllib3 had handed the response over.
233
+ # urllib3 retries only up to the headers, so this is repeated here.
234
+ except requests.exceptions.ChunkedEncodingError as e:
235
+ if attempt == attempts:
236
+ self._counters.count_error()
237
+ raise RetryExhaustedError(
238
+ f"FotoWare broke off the answer: {e}",
239
+ attempts=attempt,
240
+ last_error=e,
241
+ context=context,
242
+ ) from e
243
+ delay = self.retry_config.delay(attempt)
244
+ self._counters.count_retry(None, type(e).__name__, delay)
245
+ time.sleep(delay)
246
+ # urllib3 has retried a connection error or a timeout as often as allowed.
247
+ except (requests.ConnectionError, requests.Timeout) as e:
248
+ self._counters.count_error()
249
+ raise RetryExhaustedError(
250
+ f"FotoWare unreachable: {e}",
251
+ attempts=attempts,
252
+ last_error=e,
253
+ context=context,
254
+ ) from e
255
+ # Nothing was sent: a malformed URL, say.
256
+ except requests.RequestException as e:
257
+ self._counters.count_error()
258
+ raise APIError(f"Request not sent: {e}", context=context) from e
259
+
260
+ if response.status_code < 400:
261
+ return response
262
+ self._counters.count_error()
263
+ status_code = response.status_code
264
+ response_text = response.text
265
+ response.close()
266
+ if status_code == 429:
267
+ raise RateLimitError(
268
+ "Rate limit exceeded and retries exhausted",
269
+ context={**context, "attempts": self.retry_config.max_attempts},
270
+ )
271
+ if status_code == 404:
272
+ raise NotFoundError("HTTP 404: not found", context=context)
273
+ if status_code in self.retry_config.retry_on_status:
274
+ raise RetryExhaustedError(
275
+ f"HTTP {status_code} error",
276
+ attempts=self.retry_config.max_attempts,
277
+ context={**context, "status_code": status_code},
278
+ )
279
+ raise APIError(
280
+ f"HTTP {status_code} error",
281
+ status_code=status_code,
282
+ response_text=response_text,
283
+ context=context,
284
+ )
285
+
286
+ # --- The API: one method per FotoWare call --------------------------------
287
+
288
+ def get_api_descriptor(self) -> dict[str, Any]:
289
+ """The API descriptor (``/fotoweb/me``): the archives' and services' addresses."""
290
+ descriptor: dict[str, Any] = self._get_json(
291
+ self.resolve(self.settings.api_descriptor_path), headers=ACCEPT_FULL_DESCRIPTOR
292
+ )
293
+ return descriptor
294
+
295
+ def get_archives(self, archives_href: str) -> list[Archive]:
296
+ """The archives listed at *archives_href* (the descriptor's ``archives``)."""
297
+ data = self._get_json(self.resolve(archives_href), headers=ACCEPT_COLLECTIONLIST)
298
+ archives = []
299
+ for archive_data in data.get("data", []):
300
+ try:
301
+ archives.append(Archive.from_json(archive_data))
302
+ except (KeyError, TypeError) as e:
303
+ logger.warning("Skipping malformed archive: %s", e)
304
+ return archives
305
+
306
+ def get_archive(self, archive_href: str) -> dict[str, Any]:
307
+ """One archive's description, which links its metadata editor."""
308
+ archive: dict[str, Any] = self._get_json(
309
+ self.resolve(archive_href), headers=ACCEPT_COLLECTIONINFO
310
+ )
311
+ return archive
312
+
313
+ def get_metadata_editor(self, editor_href: str) -> dict[str, Any]:
314
+ """A metadata editor: its regions list every field with id, label and type."""
315
+ editor: dict[str, Any] = self._get_json(self.resolve(editor_href), headers=ACCEPT_JSON)
316
+ return editor
317
+
318
+ def search(self, archive_href: str, query: str) -> SearchResult:
319
+ """One page of a search and the link to the next.
320
+
321
+ The query is estimated first and refused when it is over the size
322
+ budget, because FotoWare would answer it with a 500 or, worse, an
323
+ empty list (see fotoware_client.limits).
324
+
325
+ Raises:
326
+ QueryTooLargeError: If the query is estimated over the budget;
327
+ nothing is sent
328
+ """
329
+ check_query_size(query, self.settings.query_size_budget, self.settings.term_overhead)
330
+ search_url = self.resolve(archive_href).rstrip("/") + "/"
331
+ return self._search_page(search_url, params={"q": query})
332
+
333
+ def get_page(self, page_href: str) -> SearchResult:
334
+ """The page a search answer's ``next_href`` points to."""
335
+ return self._search_page(self.resolve(page_href))
336
+
337
+ def get_asset(self, asset_href: str) -> Asset:
338
+ """One asset: metadata, renditions, previews. ``Asset.data`` holds the answer."""
339
+ return Asset.from_json(self._get_json(self.resolve(asset_href), headers=ACCEPT_ASSET))
340
+
341
+ def request_rendition(self, service_href: str, rendition_href: str) -> str:
342
+ """Ask FotoWare to prepare a rendition; the address of its background task.
343
+
344
+ *service_href* is the descriptor's ``services.rendition_request``,
345
+ *rendition_href* one of an asset's ``renditions``.
346
+ """
347
+ response = self._request(
348
+ "POST",
349
+ self.resolve(service_href),
350
+ json={"href": self.resolve(rendition_href)},
351
+ headers={
352
+ "Content-Type": "application/vnd.fotoware.rendition-request+json",
353
+ "Accept": "application/vnd.fotoware.rendition-response+json",
354
+ },
355
+ )
356
+ try:
357
+ task_href = response.headers.get("Location") or response.json().get("href")
358
+ finally:
359
+ response.close()
360
+ if not task_href:
361
+ raise APIError(
362
+ "The rendition request named no task",
363
+ context={"rendition_href": rendition_href},
364
+ )
365
+ return self.resolve(task_href)
366
+
367
+ def get_rendition_task(
368
+ self,
369
+ task_href: str,
370
+ destination: BinaryIO | None = None,
371
+ *,
372
+ chunk_size: int = 1024 * 1024,
373
+ ) -> bool:
374
+ """Ask a rendition task once; True when the file is ready.
375
+
376
+ While FotoWare prepares the file it answers 202, and this returns
377
+ False. Once it is ready it answers 200 with the file, which is written
378
+ to *destination* when one is given. A task FotoWare has cleaned up
379
+ answers 410 Gone; a new rendition request is the only remedy.
380
+
381
+ Raises:
382
+ APIError: 410 when the task is gone, or another failure
383
+ """
384
+ if chunk_size <= 0:
385
+ raise ValueError("chunk_size must be positive")
386
+ response = self._request(
387
+ "GET", self.resolve(task_href), timeout=self.settings.download_timeout, stream=True
388
+ )
389
+ try:
390
+ if response.status_code == 202:
391
+ return False
392
+ if destination is not None:
393
+ for chunk in response.iter_content(chunk_size=chunk_size):
394
+ if chunk:
395
+ destination.write(chunk)
396
+ destination.flush()
397
+ return True
398
+ finally:
399
+ response.close()
400
+
401
+ def _search_page(self, url: str, *, params: dict[str, str] | None = None) -> SearchResult:
402
+ result = SearchResult.from_json(
403
+ self._get_json(url, params=params, headers=ACCEPT_ASSETLIST)
404
+ )
405
+ self._counters.count_search_page(empty=not result.assets)
406
+ return result
@@ -7,7 +7,6 @@ ACCEPT_COLLECTIONLIST = {"Accept": "application/vnd.fotoware.collectionlist+json
7
7
  ACCEPT_COLLECTIONINFO = {"Accept": "application/vnd.fotoware.collectioninfo+json"}
8
8
  ACCEPT_ASSETLIST = {"Accept": "application/vnd.fotoware.assetlist+json"}
9
9
  ACCEPT_ASSET = {"Accept": "application/vnd.fotoware.asset+json"}
10
- ACCEPT_DOWNLOAD = {"Accept": "application/vnd.fotoware.download+json"}
11
10
 
12
11
  # Request timeouts (in seconds)
13
12
  DEFAULT_TIMEOUT = 30