python-fotoware-client 0.2.0__py3-none-any.whl
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.
- fotoware_client/__init__.py +62 -0
- fotoware_client/auth.py +302 -0
- fotoware_client/client.py +919 -0
- fotoware_client/constants.py +13 -0
- fotoware_client/credentials.py +59 -0
- fotoware_client/exceptions.py +110 -0
- fotoware_client/limits.py +133 -0
- fotoware_client/models.py +190 -0
- fotoware_client/py.typed +0 -0
- fotoware_client/retry.py +166 -0
- fotoware_client/settings.py +54 -0
- python_fotoware_client-0.2.0.dist-info/METADATA +160 -0
- python_fotoware_client-0.2.0.dist-info/RECORD +16 -0
- python_fotoware_client-0.2.0.dist-info/WHEEL +5 -0
- python_fotoware_client-0.2.0.dist-info/licenses/LICENSE +21 -0
- python_fotoware_client-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Constants for FotoWare API client."""
|
|
2
|
+
|
|
3
|
+
# FotoWare API Accept headers for different content types
|
|
4
|
+
ACCEPT_JSON = {"Accept": "application/json"}
|
|
5
|
+
ACCEPT_FULL_DESCRIPTOR = {"Accept": "application/vnd.fotoware.full-api-descriptor+json"}
|
|
6
|
+
ACCEPT_COLLECTIONLIST = {"Accept": "application/vnd.fotoware.collectionlist+json"}
|
|
7
|
+
ACCEPT_COLLECTIONINFO = {"Accept": "application/vnd.fotoware.collectioninfo+json"}
|
|
8
|
+
ACCEPT_ASSETLIST = {"Accept": "application/vnd.fotoware.assetlist+json"}
|
|
9
|
+
ACCEPT_ASSET = {"Accept": "application/vnd.fotoware.asset+json"}
|
|
10
|
+
ACCEPT_DOWNLOAD = {"Accept": "application/vnd.fotoware.download+json"}
|
|
11
|
+
|
|
12
|
+
# Request timeouts (in seconds)
|
|
13
|
+
DEFAULT_TIMEOUT = 30
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Where a FotoWare client's credentials come from, when the caller does not pass them.
|
|
2
|
+
|
|
3
|
+
The environment first: FOTOWARE_TENANT_URL, FOTOWARE_CLIENT_ID and
|
|
4
|
+
FOTOWARE_CLIENT_SECRET. Where it lacks them, the credentials file,
|
|
5
|
+
``$XDG_CONFIG_HOME/fotoware/credentials`` (``~/.config/fotoware/credentials``),
|
|
6
|
+
one ``NAME=value`` per line. The file is refused when others may read it.
|
|
7
|
+
|
|
8
|
+
Never from a command line: a secret there lands in the process list and the
|
|
9
|
+
shell history.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from fotoware_client.exceptions import ConfigurationError
|
|
18
|
+
|
|
19
|
+
ENV_TENANT_URL = "FOTOWARE_TENANT_URL"
|
|
20
|
+
ENV_CLIENT_ID = "FOTOWARE_CLIENT_ID"
|
|
21
|
+
ENV_CLIENT_SECRET = "FOTOWARE_CLIENT_SECRET"
|
|
22
|
+
_NAMES = (ENV_TENANT_URL, ENV_CLIENT_ID, ENV_CLIENT_SECRET)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def credentials_file() -> Path:
|
|
26
|
+
"""The credentials file's place."""
|
|
27
|
+
config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
28
|
+
return Path(config_home) / "fotoware" / "credentials"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_credentials() -> tuple[str, str, str]:
|
|
32
|
+
"""Tenant URL, client id and client secret.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ConfigurationError: If neither the environment nor the file has all
|
|
36
|
+
three, or the file may be read by others
|
|
37
|
+
"""
|
|
38
|
+
values = {name: os.environ.get(name, "") for name in _NAMES}
|
|
39
|
+
if not all(values.values()):
|
|
40
|
+
values = _read_file(credentials_file())
|
|
41
|
+
return values[ENV_TENANT_URL], values[ENV_CLIENT_ID], values[ENV_CLIENT_SECRET]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_file(path: Path) -> dict[str, str]:
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
f"No FotoWare credentials: set {', '.join(_NAMES)} or create {path}"
|
|
48
|
+
)
|
|
49
|
+
if path.stat().st_mode & 0o077:
|
|
50
|
+
raise ConfigurationError(f"{path} may be read by others; run: chmod 600 {path}")
|
|
51
|
+
values: dict[str, str] = {}
|
|
52
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
53
|
+
key, sep, value = line.strip().partition("=")
|
|
54
|
+
if sep and not key.startswith("#"):
|
|
55
|
+
values[key.strip()] = value.strip().strip('"')
|
|
56
|
+
missing = [name for name in _NAMES if not values.get(name)]
|
|
57
|
+
if missing:
|
|
58
|
+
raise ConfigurationError(f"{path} lacks {', '.join(missing)}")
|
|
59
|
+
return values
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Exception hierarchy for FotoWare client.
|
|
2
|
+
|
|
3
|
+
All exceptions inherit from FotoWareError for easy catching.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FotoWareError(Exception):
|
|
12
|
+
"""Base exception for all FotoWare client errors."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, *, context: dict[str, Any] | None = None) -> None:
|
|
15
|
+
"""Initialize error with message and optional context.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
message: Human-readable error message
|
|
19
|
+
context: Optional dict with additional error context
|
|
20
|
+
"""
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.context = context or {}
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
if self.context:
|
|
27
|
+
ctx_str = ", ".join(f"{k}={v}" for k, v in self.context.items())
|
|
28
|
+
return f"{self.message} ({ctx_str})"
|
|
29
|
+
return self.message
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationError(FotoWareError):
|
|
33
|
+
"""OAuth authentication failed."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RateLimitError(FotoWareError):
|
|
39
|
+
"""API rate limit exceeded (HTTP 429)."""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotFoundError(FotoWareError):
|
|
45
|
+
"""Resource not found (HTTP 404)."""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APIError(FotoWareError):
|
|
51
|
+
"""HTTP error from FotoWare API."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
message: str,
|
|
56
|
+
*,
|
|
57
|
+
status_code: int | None = None,
|
|
58
|
+
response_text: str | None = None,
|
|
59
|
+
context: dict[str, Any] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Initialize API error with HTTP details.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
message: Human-readable error message
|
|
65
|
+
status_code: HTTP status code
|
|
66
|
+
response_text: Response body text
|
|
67
|
+
context: Optional dict with additional error context
|
|
68
|
+
"""
|
|
69
|
+
ctx = context or {}
|
|
70
|
+
if status_code:
|
|
71
|
+
ctx["status_code"] = status_code
|
|
72
|
+
if response_text:
|
|
73
|
+
ctx["response_text"] = response_text[:200] # Truncate to 200 chars
|
|
74
|
+
super().__init__(message, context=ctx)
|
|
75
|
+
self.status_code = status_code
|
|
76
|
+
self.response_text = response_text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConfigurationError(FotoWareError):
|
|
80
|
+
"""Invalid client configuration."""
|
|
81
|
+
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RetryExhaustedError(FotoWareError):
|
|
86
|
+
"""All retry attempts failed."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
message: str,
|
|
91
|
+
*,
|
|
92
|
+
attempts: int,
|
|
93
|
+
last_error: Exception | None = None,
|
|
94
|
+
context: dict[str, Any] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Initialize retry exhausted error.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
message: Human-readable error message
|
|
100
|
+
attempts: Number of attempts made
|
|
101
|
+
last_error: The last exception that occurred
|
|
102
|
+
context: Optional dict with additional error context
|
|
103
|
+
"""
|
|
104
|
+
ctx = context or {}
|
|
105
|
+
ctx["attempts"] = attempts
|
|
106
|
+
if last_error:
|
|
107
|
+
ctx["last_error"] = str(last_error)
|
|
108
|
+
super().__init__(message, context=ctx)
|
|
109
|
+
self.attempts = attempts
|
|
110
|
+
self.last_error = last_error
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""What a FotoWare search may ask, and what it cost.
|
|
2
|
+
|
|
3
|
+
Measured on 2026-10-23 against a FotoWeb 8.1.1223.0 tenant. Check these again
|
|
4
|
+
when the server version changes; an installation that differs passes its own
|
|
5
|
+
values in fotoware_client.ServerSettings.
|
|
6
|
+
|
|
7
|
+
- **The answers came in pages of 25**, and ``pageSize`` was ignored. FotoWare
|
|
8
|
+
guarantees no page size ("do not assume the number of elements per page"),
|
|
9
|
+
so the client counts nothing and follows ``paging.next`` until it ends.
|
|
10
|
+
- **The cost is per answer, not per asset.** An answer with hits took about
|
|
11
|
+
0.4 s one after another and about 1 s at 15 in parallel, whether it held 1
|
|
12
|
+
asset or 25. An empty answer took 130-220 ms.
|
|
13
|
+
- **The search has a size limit, and over it FotoWare answers wrongly.** The
|
|
14
|
+
limit applies to FotoWare's internal translation of the query, about 2,000
|
|
15
|
+
characters ("search query string is limited to 2000 characters" in its
|
|
16
|
+
documentation). Over it the answer is HTTP 500 or, worse, HTTP 200 with an
|
|
17
|
+
empty list, which looks exactly like "no hits". Nothing in the answer tells
|
|
18
|
+
them apart, so the only remedy is not to send such a query:
|
|
19
|
+
:func:`estimate_query_size` estimates it and the client refuses a query over
|
|
20
|
+
:data:`QUERY_SIZE_BUDGET` with :class:`QueryTooLargeError`.
|
|
21
|
+
|
|
22
|
+
The estimate: every term costs the length of its value plus
|
|
23
|
+
:data:`TERM_OVERHEAD`. With 13-digit EANs 40 terms worked and 42 did not, with
|
|
24
|
+
2-digit values 52 and 56; both edges sit at about 2,000 by this count. Other
|
|
25
|
+
query forms (wildcards, ranges, full text) were not measured.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
import threading
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
from fotoware_client.exceptions import FotoWareError
|
|
35
|
+
|
|
36
|
+
TERM_OVERHEAD = 33
|
|
37
|
+
"""What a term adds to the translated query besides its value (measured default)."""
|
|
38
|
+
|
|
39
|
+
QUERY_SIZE_LIMIT = 2000
|
|
40
|
+
"""Where FotoWare starts to answer wrongly, by :func:`estimate_query_size`."""
|
|
41
|
+
|
|
42
|
+
QUERY_SIZE_BUDGET = 1500
|
|
43
|
+
"""The largest estimate the client sends, kept well below the limit."""
|
|
44
|
+
|
|
45
|
+
_OPERATORS = frozenset({"AND", "OR", "NOT"})
|
|
46
|
+
_TOKEN = re.compile(r"[^\s()]+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_query_size(query: str, term_overhead: int = TERM_OVERHEAD) -> int:
|
|
50
|
+
"""The size FotoWare's translation of *query* will have, estimated.
|
|
51
|
+
|
|
52
|
+
Each term counts the length of its value (after ``field:``, if it has one)
|
|
53
|
+
plus *term_overhead*. Operators, brackets and spaces count nothing.
|
|
54
|
+
"""
|
|
55
|
+
size = 0
|
|
56
|
+
for token in _TOKEN.findall(query):
|
|
57
|
+
if token in _OPERATORS:
|
|
58
|
+
continue
|
|
59
|
+
_, _, value = token.rpartition(":")
|
|
60
|
+
size += len(value) + term_overhead
|
|
61
|
+
return size
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QueryTooLargeError(FotoWareError):
|
|
65
|
+
"""A search the client refuses to send, because FotoWare would answer it wrongly."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, estimated_size: int, budget: int) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
"Search query too large for FotoWare: an answer to it may be empty or a 500 "
|
|
70
|
+
"although assets match. Split it into smaller queries.",
|
|
71
|
+
context={"estimated_size": estimated_size, "budget": budget},
|
|
72
|
+
)
|
|
73
|
+
self.estimated_size = estimated_size
|
|
74
|
+
self.budget = budget
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_query_size(
|
|
78
|
+
query: str, budget: int = QUERY_SIZE_BUDGET, term_overhead: int = TERM_OVERHEAD
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Raise :class:`QueryTooLargeError` when *query* is estimated over *budget*."""
|
|
81
|
+
size = estimate_query_size(query, term_overhead)
|
|
82
|
+
if size > budget:
|
|
83
|
+
raise QueryTooLargeError(estimated_size=size, budget=budget)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RequestCounters:
|
|
88
|
+
"""What the client has asked of FotoWare so far. Thread-safe.
|
|
89
|
+
|
|
90
|
+
``requests`` counts calls, ``retries`` the repeats on top of them, so the
|
|
91
|
+
HTTP requests sent are their sum. ``search_pages`` and ``empty_search_pages``
|
|
92
|
+
are the search answers among the requests; an empty one is cheap, a full or
|
|
93
|
+
partial one costs the same.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
requests: int = 0
|
|
97
|
+
retries: int = 0
|
|
98
|
+
errors: int = 0
|
|
99
|
+
search_pages: int = 0
|
|
100
|
+
empty_search_pages: int = 0
|
|
101
|
+
retries_by_status: dict[str, int] = field(default_factory=dict)
|
|
102
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
|
103
|
+
|
|
104
|
+
def count_request(self) -> None:
|
|
105
|
+
with self._lock:
|
|
106
|
+
self.requests += 1
|
|
107
|
+
|
|
108
|
+
def count_error(self) -> None:
|
|
109
|
+
with self._lock:
|
|
110
|
+
self.errors += 1
|
|
111
|
+
|
|
112
|
+
def count_search_page(self, *, empty: bool) -> None:
|
|
113
|
+
with self._lock:
|
|
114
|
+
self.search_pages += 1
|
|
115
|
+
if empty:
|
|
116
|
+
self.empty_search_pages += 1
|
|
117
|
+
|
|
118
|
+
def count_retry(self, status: int | None, error: str | None, delay: float) -> None:
|
|
119
|
+
key = str(status) if status is not None else (error or "network")
|
|
120
|
+
with self._lock:
|
|
121
|
+
self.retries += 1
|
|
122
|
+
self.retries_by_status[key] = self.retries_by_status.get(key, 0) + 1
|
|
123
|
+
|
|
124
|
+
def snapshot(self) -> dict[str, object]:
|
|
125
|
+
with self._lock:
|
|
126
|
+
return {
|
|
127
|
+
"requests": self.requests,
|
|
128
|
+
"retries": self.retries,
|
|
129
|
+
"errors": self.errors,
|
|
130
|
+
"search_pages": self.search_pages,
|
|
131
|
+
"empty_search_pages": self.empty_search_pages,
|
|
132
|
+
"retries_by_status": dict(self.retries_by_status),
|
|
133
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Data models for FotoWare API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Archive:
|
|
11
|
+
"""Represents a FotoWare archive.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
href: Full URL to the archive
|
|
15
|
+
name: Human-readable archive name
|
|
16
|
+
data: Complete raw JSON data from API
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
href: str
|
|
20
|
+
name: str
|
|
21
|
+
data: dict[str, Any] = field(repr=False)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_json(cls, data: dict[str, Any]) -> Archive:
|
|
25
|
+
"""Create Archive from API JSON response.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
data: Raw JSON data from FotoWare API
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Archive instance
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
KeyError: If required fields are missing
|
|
35
|
+
"""
|
|
36
|
+
return cls(
|
|
37
|
+
href=data["href"],
|
|
38
|
+
name=data.get("name", data.get("displayName", "Unknown")),
|
|
39
|
+
data=data,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Asset:
|
|
45
|
+
"""Represents a FotoWare asset (image, document, etc.).
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
href: Full URL to the asset
|
|
49
|
+
filename: Asset filename
|
|
50
|
+
fields: Metadata fields (dict mapping field IDs to values)
|
|
51
|
+
mimetype: MIME type (e.g., 'image/jpeg', 'application/pdf')
|
|
52
|
+
data: Complete raw JSON data from API
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
href: str
|
|
56
|
+
filename: str
|
|
57
|
+
fields: dict[str, Any]
|
|
58
|
+
mimetype: str | None
|
|
59
|
+
data: dict[str, Any] = field(repr=False)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_json(cls, data: dict[str, Any]) -> Asset:
|
|
63
|
+
"""Create Asset from API JSON response.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
data: Raw JSON data from FotoWare API
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Asset instance
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
KeyError: If required fields are missing
|
|
73
|
+
"""
|
|
74
|
+
# Extract filename from metadata
|
|
75
|
+
filename = data.get("filename", "unknown")
|
|
76
|
+
|
|
77
|
+
# Extract and flatten metadata fields
|
|
78
|
+
fields: dict[str, Any] = {}
|
|
79
|
+
metadata = data.get("metadata", {})
|
|
80
|
+
|
|
81
|
+
# Handle both direct field values and nested structures
|
|
82
|
+
for field_id, field_data in metadata.items():
|
|
83
|
+
if isinstance(field_data, dict):
|
|
84
|
+
# Extract value from nested structure
|
|
85
|
+
value = field_data.get("value")
|
|
86
|
+
if value is not None:
|
|
87
|
+
fields[field_id] = value
|
|
88
|
+
else:
|
|
89
|
+
# Direct value
|
|
90
|
+
fields[field_id] = field_data
|
|
91
|
+
|
|
92
|
+
# Get MIME type
|
|
93
|
+
mimetype = data.get("mimetype") or data.get("attributes", {}).get("mimetype")
|
|
94
|
+
|
|
95
|
+
return cls(
|
|
96
|
+
href=data["href"],
|
|
97
|
+
filename=filename,
|
|
98
|
+
fields=fields,
|
|
99
|
+
mimetype=mimetype,
|
|
100
|
+
data=data,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def get_field(self, field_id: str, default: Any = None) -> Any:
|
|
104
|
+
"""Get metadata field value by field ID.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
field_id: FotoWare field ID (e.g., '631' for EAN)
|
|
108
|
+
default: Default value if field not found
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Field value or default
|
|
112
|
+
"""
|
|
113
|
+
return self.fields.get(field_id, default)
|
|
114
|
+
|
|
115
|
+
def get_field_values(self, field_id: str) -> list[Any]:
|
|
116
|
+
"""Get metadata field values as list (handles multi-value fields).
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
field_id: FotoWare field ID
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of values (empty list if field not found)
|
|
123
|
+
"""
|
|
124
|
+
value = self.fields.get(field_id)
|
|
125
|
+
if value is None:
|
|
126
|
+
return []
|
|
127
|
+
if isinstance(value, list):
|
|
128
|
+
return value
|
|
129
|
+
return [value]
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def doctype(self) -> str | None:
|
|
133
|
+
"""Get asset doctype (e.g., 'image', 'document', 'graphic').
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Doctype string or None if not present
|
|
137
|
+
"""
|
|
138
|
+
return self.data.get("doctype")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class SearchResult:
|
|
143
|
+
"""Represents paginated search results from FotoWare.
|
|
144
|
+
|
|
145
|
+
Attributes:
|
|
146
|
+
assets: List of Asset instances
|
|
147
|
+
total_count: Total number of matching assets
|
|
148
|
+
next_href: URL to fetch next page (None if last page)
|
|
149
|
+
data: Complete raw JSON data from API
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
assets: list[Asset]
|
|
153
|
+
total_count: int
|
|
154
|
+
next_href: str | None
|
|
155
|
+
data: dict[str, Any] = field(repr=False)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_json(cls, data: dict[str, Any]) -> SearchResult:
|
|
159
|
+
"""Create SearchResult from API JSON response.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
data: Raw JSON data from FotoWare API
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
SearchResult instance
|
|
166
|
+
"""
|
|
167
|
+
# Parse assets from data
|
|
168
|
+
assets = []
|
|
169
|
+
for asset_data in data.get("data", []):
|
|
170
|
+
try:
|
|
171
|
+
assets.append(Asset.from_json(asset_data))
|
|
172
|
+
except KeyError, TypeError:
|
|
173
|
+
# Skip malformed assets
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Get total count
|
|
177
|
+
paging = data.get("paging") or {}
|
|
178
|
+
total_count = paging.get("totalAssets", len(assets))
|
|
179
|
+
|
|
180
|
+
# Get next page link from paging object
|
|
181
|
+
next_href = paging.get("next") or None
|
|
182
|
+
if next_href == "": # Empty string means no next page
|
|
183
|
+
next_href = None
|
|
184
|
+
|
|
185
|
+
return cls(
|
|
186
|
+
assets=assets,
|
|
187
|
+
total_count=total_count,
|
|
188
|
+
next_href=next_href,
|
|
189
|
+
data=data,
|
|
190
|
+
)
|
fotoware_client/py.typed
ADDED
|
File without changes
|
fotoware_client/retry.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Retry policy for FotoWare requests, run by urllib3.
|
|
2
|
+
|
|
3
|
+
:class:`RetryConfig` says what is retried and how long to wait; urllib3's
|
|
4
|
+
``Retry``, mounted on the client's session, does the retrying. That is the
|
|
5
|
+
standard for ``requests``, and it honours ``Retry-After``. :class:`ObservedRetry`
|
|
6
|
+
adds a callback per retry, because a retry that succeeds is otherwise invisible
|
|
7
|
+
to the caller and to the cost counters.
|
|
8
|
+
|
|
9
|
+
**500 is not retried.** Measured on FotoWeb 8.1.1223.0 (2026-10-23): a 500
|
|
10
|
+
never appeared below the query size limit and always reappeared above it, so a
|
|
11
|
+
repeat only costs time. 429 and 502/503/504 are transient and are retried.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Any, Self
|
|
21
|
+
|
|
22
|
+
from urllib3.connectionpool import ConnectionPool
|
|
23
|
+
from urllib3.exceptions import InvalidHeader
|
|
24
|
+
from urllib3.response import BaseHTTPResponse
|
|
25
|
+
from urllib3.util.retry import Retry
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RetryConfig:
|
|
32
|
+
"""What is retried, and how long to wait in between.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
max_attempts: Total attempts, the first one included. 1 means no retry.
|
|
36
|
+
base_delay: Wait before the first retry, in seconds; doubled for each
|
|
37
|
+
further one.
|
|
38
|
+
max_delay: Longest wait between two attempts, in seconds.
|
|
39
|
+
retry_on_status: Status codes retried. 500 deliberately not, see above.
|
|
40
|
+
max_retry_after: Longest ``Retry-After`` honoured, in seconds; a longer
|
|
41
|
+
one is cut to this.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
max_attempts: int = 3
|
|
45
|
+
base_delay: float = 1.0
|
|
46
|
+
max_delay: float = 30.0
|
|
47
|
+
retry_on_status: frozenset[int] = field(default_factory=lambda: frozenset({429, 502, 503, 504}))
|
|
48
|
+
max_retry_after: float = 60.0
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if self.max_attempts < 1:
|
|
52
|
+
raise ValueError(f"max_attempts must be >= 1, got {self.max_attempts}")
|
|
53
|
+
if self.base_delay < 0 or self.max_delay < 0 or self.max_retry_after < 0:
|
|
54
|
+
raise ValueError("delays must be >= 0")
|
|
55
|
+
|
|
56
|
+
def delay(self, retry_number: int) -> float:
|
|
57
|
+
"""The wait before retry *retry_number* (1 for the first)."""
|
|
58
|
+
return float(min(self.max_delay, self.base_delay * 2 ** (retry_number - 1)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
STANDARD_RETRY = RetryConfig()
|
|
62
|
+
NO_RETRY = RetryConfig(max_attempts=1)
|
|
63
|
+
|
|
64
|
+
# Called once per retry, before the wait: the status that caused it (None for a
|
|
65
|
+
# network error), the error's type name (None for a status), and the wait in
|
|
66
|
+
# seconds.
|
|
67
|
+
RetryListener = Callable[[int | None, str | None, float], None]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ObservedRetry(Retry):
|
|
71
|
+
"""urllib3's ``Retry`` with a :class:`RetryConfig`'s waits and a callback.
|
|
72
|
+
|
|
73
|
+
A ``Retry-After`` the server sends takes precedence over the backoff, up to
|
|
74
|
+
``config.max_retry_after``; one that cannot be read is logged and the
|
|
75
|
+
backoff used instead. Only ``config.retry_on_status`` is retried: urllib3
|
|
76
|
+
would also retry a 413, 429 or 503 whenever it carries a ``Retry-After``,
|
|
77
|
+
whatever the status list says; that is switched off.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
RETRY_AFTER_STATUS_CODES = frozenset()
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*args: Any,
|
|
85
|
+
config: RetryConfig,
|
|
86
|
+
on_retry: RetryListener | None = None,
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> None:
|
|
89
|
+
super().__init__(*args, **kwargs)
|
|
90
|
+
self.config = config
|
|
91
|
+
self.on_retry = on_retry
|
|
92
|
+
# Fixed when the retry is decided, so the wait reported and the wait
|
|
93
|
+
# slept are the same number.
|
|
94
|
+
self.planned_delay: float | None = None
|
|
95
|
+
|
|
96
|
+
def new(self, **kw: Any) -> Self:
|
|
97
|
+
kw.setdefault("config", self.config)
|
|
98
|
+
kw.setdefault("on_retry", self.on_retry)
|
|
99
|
+
return super().new(**kw)
|
|
100
|
+
|
|
101
|
+
def get_retry_after(self, response: BaseHTTPResponse) -> float | None:
|
|
102
|
+
# urllib3 raises on a header it cannot parse, which would turn a 503
|
|
103
|
+
# into a network error. increment() logs it; here it is just absent.
|
|
104
|
+
try:
|
|
105
|
+
return super().get_retry_after(response)
|
|
106
|
+
except InvalidHeader:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def get_backoff_time(self) -> float:
|
|
110
|
+
if self.planned_delay is not None:
|
|
111
|
+
return self.planned_delay
|
|
112
|
+
return self.config.delay(len(self.history))
|
|
113
|
+
|
|
114
|
+
def increment(
|
|
115
|
+
self,
|
|
116
|
+
method: str | None = None,
|
|
117
|
+
url: str | None = None,
|
|
118
|
+
response: BaseHTTPResponse | None = None,
|
|
119
|
+
error: Exception | None = None,
|
|
120
|
+
_pool: ConnectionPool | None = None,
|
|
121
|
+
_stacktrace: TracebackType | None = None,
|
|
122
|
+
) -> Self:
|
|
123
|
+
# Raises MaxRetryError when the budget is spent; nothing to report then.
|
|
124
|
+
retried = super().increment(method, url, response, error, _pool, _stacktrace)
|
|
125
|
+
retry_after = retried.get_retry_after(response) if response is not None else None
|
|
126
|
+
if retry_after is None and response is not None and response.headers.get("Retry-After"):
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Unreadable Retry-After %r, backing off instead",
|
|
129
|
+
response.headers.get("Retry-After"),
|
|
130
|
+
)
|
|
131
|
+
delay = retry_after if retry_after is not None else self.config.delay(len(retried.history))
|
|
132
|
+
# Also for a Retry-After of 0, which urllib3 treats as "not slept" and
|
|
133
|
+
# falls back to the backoff for.
|
|
134
|
+
retried.planned_delay = delay
|
|
135
|
+
if self.on_retry is not None:
|
|
136
|
+
self.on_retry(
|
|
137
|
+
response.status if response is not None else None,
|
|
138
|
+
type(error).__name__ if error is not None else None,
|
|
139
|
+
delay,
|
|
140
|
+
)
|
|
141
|
+
return retried
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def urllib3_retry(config: RetryConfig, on_retry: RetryListener | None = None) -> ObservedRetry:
|
|
145
|
+
"""The urllib3 policy a :class:`RetryConfig` describes.
|
|
146
|
+
|
|
147
|
+
Every method is retried: the client only reads, and its one POST asks for a
|
|
148
|
+
rendition, which FotoWare prepares again without harm. Once the retries are
|
|
149
|
+
spent the last response is returned, not raised, so the client maps its
|
|
150
|
+
status like any other.
|
|
151
|
+
"""
|
|
152
|
+
retries = config.max_attempts - 1
|
|
153
|
+
return ObservedRetry(
|
|
154
|
+
total=retries,
|
|
155
|
+
connect=retries,
|
|
156
|
+
read=retries,
|
|
157
|
+
status=retries,
|
|
158
|
+
other=retries,
|
|
159
|
+
allowed_methods=None,
|
|
160
|
+
status_forcelist=config.retry_on_status,
|
|
161
|
+
respect_retry_after_header=True,
|
|
162
|
+
retry_after_max=config.max_retry_after,
|
|
163
|
+
raise_on_status=False,
|
|
164
|
+
config=config,
|
|
165
|
+
on_retry=on_retry,
|
|
166
|
+
)
|