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.
- {python_fotoware_client-0.2.3/src/python_fotoware_client.egg-info → python_fotoware_client-0.3.0}/PKG-INFO +23 -12
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/README.md +22 -11
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/pyproject.toml +1 -1
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/__init__.py +1 -1
- python_fotoware_client-0.3.0/src/fotoware_client/client.py +406 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/constants.py +0 -1
- python_fotoware_client-0.3.0/src/fotoware_client/extensions.py +181 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/retry.py +12 -2
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0/src/python_fotoware_client.egg-info}/PKG-INFO +23 -12
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/SOURCES.txt +2 -0
- python_fotoware_client-0.3.0/tests/test_client.py +305 -0
- python_fotoware_client-0.3.0/tests/test_extensions.py +213 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_retry.py +19 -4
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_settings.py +3 -27
- python_fotoware_client-0.2.3/src/fotoware_client/client.py +0 -963
- python_fotoware_client-0.2.3/tests/test_client.py +0 -624
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/LICENSE +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/setup.cfg +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/auth.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/credentials.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/exceptions.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/limits.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/models.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/py.typed +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/settings.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/dependency_links.txt +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/requires.txt +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/python_fotoware_client.egg-info/top_level.txt +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_auth.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_credentials.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_exceptions.py +0 -0
- {python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/tests/test_limits.py +0 -0
- {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.
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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". `
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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". `
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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.
|
|
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"
|
{python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/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.
|
|
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
|
{python_fotoware_client-0.2.3 → python_fotoware_client-0.3.0}/src/fotoware_client/constants.py
RENAMED
|
@@ -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
|