archivist-py 1.0.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.
- archivist/__init__.py +100 -0
- archivist/core/__init__.py +52 -0
- archivist/core/_http.py +261 -0
- archivist/core/_urls.py +80 -0
- archivist/core/errors.py +124 -0
- archivist/core/models.py +83 -0
- archivist/core/protocols.py +46 -0
- archivist/py.typed +0 -0
- archivist/services/__init__.py +14 -0
- archivist/services/archive_today/__init__.py +19 -0
- archivist/services/archive_today/_common.py +364 -0
- archivist/services/archive_today/async_client.py +182 -0
- archivist/services/archive_today/client.py +178 -0
- archivist/services/archive_today/models.py +111 -0
- archivist/services/internet_archive/__init__.py +47 -0
- archivist/services/internet_archive/_common.py +586 -0
- archivist/services/internet_archive/async_client.py +569 -0
- archivist/services/internet_archive/client.py +537 -0
- archivist/services/internet_archive/models.py +389 -0
- archivist_py-1.0.0.dist-info/METADATA +120 -0
- archivist_py-1.0.0.dist-info/RECORD +23 -0
- archivist_py-1.0.0.dist-info/WHEEL +4 -0
- archivist_py-1.0.0.dist-info/licenses/LICENSE +661 -0
archivist/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Preserve the web and retrieve archives with a typed Python API."""
|
|
2
|
+
|
|
3
|
+
from archivist.core import (
|
|
4
|
+
Archiver,
|
|
5
|
+
ArchiveRecord,
|
|
6
|
+
ArchivistError,
|
|
7
|
+
AsyncArchiver,
|
|
8
|
+
AsyncSearcher,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
CaptureFailedError,
|
|
11
|
+
CaptureJob,
|
|
12
|
+
InvalidOptionError,
|
|
13
|
+
InvalidServiceResponseError,
|
|
14
|
+
InvalidTargetURLError,
|
|
15
|
+
InvalidURLError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
OptionCombinationError,
|
|
18
|
+
PagedSearchResult,
|
|
19
|
+
PollingTimeoutError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
Searcher,
|
|
22
|
+
ServiceError,
|
|
23
|
+
ServiceStatus,
|
|
24
|
+
TLSVerificationError,
|
|
25
|
+
)
|
|
26
|
+
from archivist.services.archive_today import (
|
|
27
|
+
ArchiveTodayClient,
|
|
28
|
+
ArchiveTodayMemento,
|
|
29
|
+
ArchiveTodayRecentCapture,
|
|
30
|
+
ArchiveTodayRecentFeed,
|
|
31
|
+
ArchiveTodayTimeMap,
|
|
32
|
+
AsyncArchiveTodayClient,
|
|
33
|
+
)
|
|
34
|
+
from archivist.services.internet_archive import (
|
|
35
|
+
AsyncInternetArchiveClient,
|
|
36
|
+
InternetArchiveAccount,
|
|
37
|
+
InternetArchiveApiKey,
|
|
38
|
+
InternetArchiveAvailability,
|
|
39
|
+
InternetArchiveCaptureJob,
|
|
40
|
+
InternetArchiveCaptureStatus,
|
|
41
|
+
InternetArchiveCdxRecord,
|
|
42
|
+
InternetArchiveCdxResult,
|
|
43
|
+
InternetArchiveClient,
|
|
44
|
+
InternetArchiveCookies,
|
|
45
|
+
InternetArchiveFailedStatus,
|
|
46
|
+
InternetArchivePendingStatus,
|
|
47
|
+
InternetArchiveSaveOptions,
|
|
48
|
+
InternetArchiveSuccessStatus,
|
|
49
|
+
InternetArchiveSystemStatus,
|
|
50
|
+
InternetArchiveUserStatus,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "1.0.0"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"ArchiveRecord",
|
|
57
|
+
"ArchiveTodayClient",
|
|
58
|
+
"ArchiveTodayMemento",
|
|
59
|
+
"ArchiveTodayRecentCapture",
|
|
60
|
+
"ArchiveTodayRecentFeed",
|
|
61
|
+
"ArchiveTodayTimeMap",
|
|
62
|
+
"Archiver",
|
|
63
|
+
"ArchivistError",
|
|
64
|
+
"AsyncArchiveTodayClient",
|
|
65
|
+
"AsyncArchiver",
|
|
66
|
+
"AsyncInternetArchiveClient",
|
|
67
|
+
"AsyncSearcher",
|
|
68
|
+
"AuthenticationError",
|
|
69
|
+
"CaptureFailedError",
|
|
70
|
+
"CaptureJob",
|
|
71
|
+
"InternetArchiveAccount",
|
|
72
|
+
"InternetArchiveApiKey",
|
|
73
|
+
"InternetArchiveAvailability",
|
|
74
|
+
"InternetArchiveCaptureJob",
|
|
75
|
+
"InternetArchiveCaptureStatus",
|
|
76
|
+
"InternetArchiveCdxRecord",
|
|
77
|
+
"InternetArchiveCdxResult",
|
|
78
|
+
"InternetArchiveClient",
|
|
79
|
+
"InternetArchiveCookies",
|
|
80
|
+
"InternetArchiveFailedStatus",
|
|
81
|
+
"InternetArchivePendingStatus",
|
|
82
|
+
"InternetArchiveSaveOptions",
|
|
83
|
+
"InternetArchiveSuccessStatus",
|
|
84
|
+
"InternetArchiveSystemStatus",
|
|
85
|
+
"InternetArchiveUserStatus",
|
|
86
|
+
"InvalidOptionError",
|
|
87
|
+
"InvalidServiceResponseError",
|
|
88
|
+
"InvalidTargetURLError",
|
|
89
|
+
"InvalidURLError",
|
|
90
|
+
"NetworkError",
|
|
91
|
+
"OptionCombinationError",
|
|
92
|
+
"PagedSearchResult",
|
|
93
|
+
"PollingTimeoutError",
|
|
94
|
+
"RateLimitError",
|
|
95
|
+
"Searcher",
|
|
96
|
+
"ServiceError",
|
|
97
|
+
"ServiceStatus",
|
|
98
|
+
"TLSVerificationError",
|
|
99
|
+
"__version__",
|
|
100
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared models, protocols, and exceptions."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from archivist.core.errors import (
|
|
6
|
+
ArchivistError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
CaptureFailedError,
|
|
9
|
+
InvalidOptionError,
|
|
10
|
+
InvalidServiceResponseError,
|
|
11
|
+
InvalidTargetURLError,
|
|
12
|
+
InvalidURLError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
OptionCombinationError,
|
|
15
|
+
PollingTimeoutError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ServiceError,
|
|
18
|
+
TLSVerificationError,
|
|
19
|
+
)
|
|
20
|
+
from archivist.core.models import (
|
|
21
|
+
ArchiveRecord,
|
|
22
|
+
CaptureJob,
|
|
23
|
+
PagedSearchResult,
|
|
24
|
+
ServiceStatus,
|
|
25
|
+
)
|
|
26
|
+
from archivist.core.protocols import Archiver, AsyncArchiver, AsyncSearcher, Searcher
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ArchiveRecord",
|
|
32
|
+
"Archiver",
|
|
33
|
+
"ArchivistError",
|
|
34
|
+
"AsyncArchiver",
|
|
35
|
+
"AsyncSearcher",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"CaptureFailedError",
|
|
38
|
+
"CaptureJob",
|
|
39
|
+
"InvalidOptionError",
|
|
40
|
+
"InvalidServiceResponseError",
|
|
41
|
+
"InvalidTargetURLError",
|
|
42
|
+
"InvalidURLError",
|
|
43
|
+
"NetworkError",
|
|
44
|
+
"OptionCombinationError",
|
|
45
|
+
"PagedSearchResult",
|
|
46
|
+
"PollingTimeoutError",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"Searcher",
|
|
49
|
+
"ServiceError",
|
|
50
|
+
"ServiceStatus",
|
|
51
|
+
"TLSVerificationError",
|
|
52
|
+
]
|
archivist/core/_http.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Shared HTTP response and exception handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from inspect import isawaitable
|
|
11
|
+
from typing import Any, Protocol, cast
|
|
12
|
+
|
|
13
|
+
import niquests
|
|
14
|
+
|
|
15
|
+
from archivist.core.errors import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
InvalidServiceResponseError,
|
|
18
|
+
NetworkError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
TLSVerificationError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_HTTP_ERROR_STATUS = 400
|
|
26
|
+
_HTTP_RATE_LIMIT_STATUS = 429
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResponseLike(Protocol):
|
|
30
|
+
status_code: int
|
|
31
|
+
headers: Mapping[str, str]
|
|
32
|
+
text: str
|
|
33
|
+
url: str
|
|
34
|
+
|
|
35
|
+
def json(self) -> object:
|
|
36
|
+
"""Decode the response as JSON."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncJsonResponseLike(Protocol):
|
|
41
|
+
"""Expose fields consumed while decoding an asynchronous JSON response."""
|
|
42
|
+
|
|
43
|
+
status_code: int
|
|
44
|
+
|
|
45
|
+
def json(self) -> object:
|
|
46
|
+
"""Decode the response as JSON, possibly returning an awaitable."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncTextResponseLike(Protocol):
|
|
51
|
+
"""Expose fields consumed while decoding an asynchronous text response."""
|
|
52
|
+
|
|
53
|
+
status_code: int
|
|
54
|
+
text: object
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_retry_after(
|
|
58
|
+
headers: Mapping[str, str],
|
|
59
|
+
) -> tuple[float | datetime | None, str | None]:
|
|
60
|
+
"""Parse Retry-After while preserving an unrecognized raw value."""
|
|
61
|
+
raw = headers.get("Retry-After") or headers.get("retry-after")
|
|
62
|
+
if raw is None:
|
|
63
|
+
return None, None
|
|
64
|
+
value = raw.strip()
|
|
65
|
+
try:
|
|
66
|
+
seconds = float(value)
|
|
67
|
+
except ValueError:
|
|
68
|
+
try:
|
|
69
|
+
date = parsedate_to_datetime(value)
|
|
70
|
+
except (TypeError, ValueError, OverflowError):
|
|
71
|
+
return None, raw
|
|
72
|
+
if date.tzinfo is None:
|
|
73
|
+
date = date.replace(tzinfo=UTC)
|
|
74
|
+
return date.astimezone(UTC), raw
|
|
75
|
+
if not math.isfinite(seconds):
|
|
76
|
+
return None, raw
|
|
77
|
+
return max(0.0, seconds), raw
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def response_json(response: ResponseLike, *, service: str) -> object:
|
|
81
|
+
"""Decode JSON or raise a body-safe service response error."""
|
|
82
|
+
try:
|
|
83
|
+
return response.json()
|
|
84
|
+
except niquests.exceptions.JSONDecodeError:
|
|
85
|
+
error = None
|
|
86
|
+
except (niquests.exceptions.RequestException, OSError) as exc:
|
|
87
|
+
error = translate_request_error(exc, service=service)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
error = None
|
|
90
|
+
if error is not None:
|
|
91
|
+
raise error from None
|
|
92
|
+
logger.warning("%s returned invalid JSON", service)
|
|
93
|
+
raise InvalidServiceResponseError(
|
|
94
|
+
f"{service} returned invalid JSON",
|
|
95
|
+
service=service,
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
) from None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def response_mapping(response: ResponseLike, *, service: str) -> Mapping[str, Any]:
|
|
101
|
+
"""Decode a JSON object response."""
|
|
102
|
+
value = response_json(response, service=service)
|
|
103
|
+
if not isinstance(value, Mapping):
|
|
104
|
+
logger.warning("%s returned JSON with an unexpected shape", service)
|
|
105
|
+
raise InvalidServiceResponseError(
|
|
106
|
+
f"{service} returned JSON with an unexpected shape",
|
|
107
|
+
service=service,
|
|
108
|
+
status_code=response.status_code,
|
|
109
|
+
)
|
|
110
|
+
return cast("Mapping[str, Any]", value)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def async_response_json(response: object, *, service: str) -> object:
|
|
114
|
+
"""Decode either a regular or multiplexed niquests async response."""
|
|
115
|
+
typed = cast("AsyncJsonResponseLike", response)
|
|
116
|
+
try:
|
|
117
|
+
value = typed.json()
|
|
118
|
+
if isawaitable(value):
|
|
119
|
+
value = await value
|
|
120
|
+
return value
|
|
121
|
+
except niquests.exceptions.JSONDecodeError:
|
|
122
|
+
error = None
|
|
123
|
+
except (niquests.exceptions.RequestException, OSError) as exc:
|
|
124
|
+
error = translate_request_error(exc, service=service)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
error = None
|
|
127
|
+
if error is not None:
|
|
128
|
+
raise error from None
|
|
129
|
+
logger.warning("%s returned invalid JSON", service)
|
|
130
|
+
raise InvalidServiceResponseError(
|
|
131
|
+
f"{service} returned invalid JSON",
|
|
132
|
+
service=service,
|
|
133
|
+
status_code=typed.status_code,
|
|
134
|
+
) from None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def async_response_mapping(
|
|
138
|
+
response: object, *, service: str
|
|
139
|
+
) -> Mapping[str, Any]:
|
|
140
|
+
"""Decode an async JSON object response."""
|
|
141
|
+
value = await async_response_json(response, service=service)
|
|
142
|
+
if not isinstance(value, Mapping):
|
|
143
|
+
logger.warning("%s returned JSON with an unexpected shape", service)
|
|
144
|
+
raise InvalidServiceResponseError(
|
|
145
|
+
f"{service} returned JSON with an unexpected shape",
|
|
146
|
+
service=service,
|
|
147
|
+
status_code=cast("AsyncJsonResponseLike", response).status_code,
|
|
148
|
+
)
|
|
149
|
+
return cast("Mapping[str, Any]", value)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def response_text(response: ResponseLike, *, service: str) -> str:
|
|
153
|
+
"""Read a regular response body and require decoded text."""
|
|
154
|
+
try:
|
|
155
|
+
value = response.text
|
|
156
|
+
except (niquests.exceptions.RequestException, OSError) as exc:
|
|
157
|
+
error = translate_request_error(exc, service=service)
|
|
158
|
+
else:
|
|
159
|
+
error = None
|
|
160
|
+
if error is not None:
|
|
161
|
+
raise error from None
|
|
162
|
+
if not isinstance(value, str):
|
|
163
|
+
logger.warning("%s returned a non-text response", service)
|
|
164
|
+
raise InvalidServiceResponseError(
|
|
165
|
+
f"{service} returned a non-text response",
|
|
166
|
+
service=service,
|
|
167
|
+
status_code=response.status_code,
|
|
168
|
+
)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def async_response_text(response: object, *, service: str) -> str:
|
|
173
|
+
"""Read text from either niquests async response representation."""
|
|
174
|
+
typed = cast("AsyncTextResponseLike", response)
|
|
175
|
+
try:
|
|
176
|
+
value = typed.text
|
|
177
|
+
if isawaitable(value):
|
|
178
|
+
value = await value
|
|
179
|
+
except (niquests.exceptions.RequestException, OSError) as exc:
|
|
180
|
+
error = translate_request_error(exc, service=service)
|
|
181
|
+
else:
|
|
182
|
+
error = None
|
|
183
|
+
if error is not None:
|
|
184
|
+
raise error from None
|
|
185
|
+
if not isinstance(value, str):
|
|
186
|
+
logger.warning("%s returned a non-text response", service)
|
|
187
|
+
raise InvalidServiceResponseError(
|
|
188
|
+
f"{service} returned a non-text response",
|
|
189
|
+
service=service,
|
|
190
|
+
status_code=typed.status_code,
|
|
191
|
+
)
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def raise_for_common_status(response: ResponseLike, *, service: str) -> None:
|
|
196
|
+
"""Translate common HTTP failures without retaining response bodies."""
|
|
197
|
+
status_code = response.status_code
|
|
198
|
+
if status_code in {401, 403}:
|
|
199
|
+
logger.warning(
|
|
200
|
+
"%s rejected request credentials with HTTP %s", service, status_code
|
|
201
|
+
)
|
|
202
|
+
raise AuthenticationError(
|
|
203
|
+
f"{service} rejected the request credentials",
|
|
204
|
+
service=service,
|
|
205
|
+
status_code=status_code,
|
|
206
|
+
)
|
|
207
|
+
if status_code == _HTTP_RATE_LIMIT_STATUS:
|
|
208
|
+
retry_after, raw = parse_retry_after(response.headers)
|
|
209
|
+
logger.warning("%s rate limit reached", service)
|
|
210
|
+
raise RateLimitError(
|
|
211
|
+
f"{service} rate limit reached",
|
|
212
|
+
service=service,
|
|
213
|
+
status_code=status_code,
|
|
214
|
+
retry_after=retry_after,
|
|
215
|
+
retry_after_raw=raw,
|
|
216
|
+
)
|
|
217
|
+
if status_code >= _HTTP_ERROR_STATUS:
|
|
218
|
+
logger.warning("%s returned HTTP %s", service, status_code)
|
|
219
|
+
raise InvalidServiceResponseError(
|
|
220
|
+
f"{service} returned HTTP {status_code}",
|
|
221
|
+
service=service,
|
|
222
|
+
status_code=status_code,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def translate_request_error(exc: BaseException, *, service: str) -> NetworkError:
|
|
227
|
+
"""Convert a transport exception without copying its potentially sensitive text."""
|
|
228
|
+
type_name = type(exc).__name__.lower()
|
|
229
|
+
if "ssl" in type_name or "tls" in type_name:
|
|
230
|
+
return TLSVerificationError(
|
|
231
|
+
f"TLS request to {service} failed",
|
|
232
|
+
service=service,
|
|
233
|
+
cause_type=type(exc).__name__,
|
|
234
|
+
)
|
|
235
|
+
return NetworkError(
|
|
236
|
+
f"network request to {service} failed",
|
|
237
|
+
service=service,
|
|
238
|
+
cause_type=type(exc).__name__,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_SECRET_KEYS = frozenset(
|
|
243
|
+
{
|
|
244
|
+
"authorization",
|
|
245
|
+
"cookie",
|
|
246
|
+
"capture_cookie",
|
|
247
|
+
"password",
|
|
248
|
+
"target_password",
|
|
249
|
+
"logged-in-sig",
|
|
250
|
+
"logged-in-user",
|
|
251
|
+
"secret_key",
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def redact_mapping(values: Mapping[str, object]) -> dict[str, object]:
|
|
257
|
+
"""Return a shallow, log-safe copy of request metadata."""
|
|
258
|
+
return {
|
|
259
|
+
key: "<redacted>" if key.lower() in _SECRET_KEYS else value
|
|
260
|
+
for key, value in values.items()
|
|
261
|
+
}
|
archivist/core/_urls.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""URL validation and safe log rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
from archivist.core.errors import InvalidTargetURLError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_ASCII_CONTROL_LIMIT = 32
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _split_http_url(url: str, *, label: str) -> SplitResult:
|
|
16
|
+
if not isinstance(url, str) or not url:
|
|
17
|
+
raise InvalidTargetURLError(f"{label} must be a non-empty string")
|
|
18
|
+
if any(
|
|
19
|
+
character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT
|
|
20
|
+
for character in url
|
|
21
|
+
):
|
|
22
|
+
raise InvalidTargetURLError(
|
|
23
|
+
f"{label} cannot contain whitespace or control characters"
|
|
24
|
+
)
|
|
25
|
+
try:
|
|
26
|
+
parsed = urlsplit(url)
|
|
27
|
+
_ = parsed.port
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
raise InvalidTargetURLError(f"{label} is not a valid URL") from exc
|
|
30
|
+
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
|
31
|
+
raise InvalidTargetURLError(f"{label} must be an absolute HTTP or HTTPS URL")
|
|
32
|
+
return parsed
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_target_url(url: str) -> str:
|
|
36
|
+
"""Validate an archive target without changing its wire representation."""
|
|
37
|
+
_split_http_url(url, label="target URL")
|
|
38
|
+
return url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_cdx_query(query: str) -> str:
|
|
42
|
+
"""Validate an absolute URL or wildcard expression accepted by CDX."""
|
|
43
|
+
if not isinstance(query, str) or not query:
|
|
44
|
+
raise InvalidTargetURLError("CDX query must be a non-empty string")
|
|
45
|
+
if any(
|
|
46
|
+
character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT
|
|
47
|
+
for character in query
|
|
48
|
+
):
|
|
49
|
+
raise InvalidTargetURLError(
|
|
50
|
+
"CDX query cannot contain whitespace or control characters"
|
|
51
|
+
)
|
|
52
|
+
if "://" in query:
|
|
53
|
+
validate_target_url(query)
|
|
54
|
+
return query
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_service_url(url: str) -> str:
|
|
58
|
+
"""Validate and normalize a configurable archive service base URL."""
|
|
59
|
+
parsed = _split_http_url(url, label="service URL")
|
|
60
|
+
if parsed.username is not None or parsed.password is not None:
|
|
61
|
+
raise InvalidTargetURLError("service URL cannot contain user information")
|
|
62
|
+
if "?" in url or "#" in url:
|
|
63
|
+
raise InvalidTargetURLError("service URL cannot contain a query or fragment")
|
|
64
|
+
return url.rstrip("/")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sanitize_url_for_log(url: str) -> str:
|
|
68
|
+
"""Remove user information, query parameters, and fragments from a URL."""
|
|
69
|
+
try:
|
|
70
|
+
parsed = urlsplit(url)
|
|
71
|
+
hostname = parsed.hostname
|
|
72
|
+
if not parsed.scheme or not hostname:
|
|
73
|
+
return "<invalid-url>"
|
|
74
|
+
if ":" in hostname and not hostname.startswith("["):
|
|
75
|
+
hostname = f"[{hostname}]"
|
|
76
|
+
port = parsed.port
|
|
77
|
+
netloc = f"{hostname}:{port}" if port is not None else hostname
|
|
78
|
+
return urlunsplit((parsed.scheme, netloc, parsed.path or "/", "", ""))
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
return "<invalid-url>"
|
archivist/core/errors.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Exceptions raised by Archivist clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArchivistError(Exception):
|
|
12
|
+
"""Base class for all package exceptions."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidTargetURLError(ArchivistError, ValueError):
|
|
16
|
+
"""The target is not an absolute HTTP or HTTPS URL."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
InvalidURLError = InvalidTargetURLError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidOptionError(ArchivistError, ValueError):
|
|
23
|
+
"""A client option has an invalid value."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OptionCombinationError(InvalidOptionError):
|
|
27
|
+
"""Two or more otherwise valid options cannot be used together."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ServiceError(ArchivistError):
|
|
31
|
+
"""An archive service request failed."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
service: str | None = None,
|
|
38
|
+
status_code: int | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize the error with service response context."""
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.service = service
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NetworkError(ServiceError):
|
|
47
|
+
"""A request failed before a usable HTTP response was received."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
message: str,
|
|
52
|
+
*,
|
|
53
|
+
service: str | None = None,
|
|
54
|
+
status_code: int | None = None,
|
|
55
|
+
cause_type: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Initialize the error with transport failure context."""
|
|
58
|
+
super().__init__(message, service=service, status_code=status_code)
|
|
59
|
+
self.cause_type = cause_type
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TLSVerificationError(NetworkError):
|
|
63
|
+
"""TLS negotiation or certificate verification failed."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthenticationError(ServiceError):
|
|
67
|
+
"""A service rejected or requires the supplied credentials."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RateLimitError(ServiceError):
|
|
71
|
+
"""A service refused a request because a rate limit was reached."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
message: str,
|
|
76
|
+
*,
|
|
77
|
+
service: str | None = None,
|
|
78
|
+
status_code: int | None = None,
|
|
79
|
+
retry_after: float | datetime | None = None,
|
|
80
|
+
retry_after_raw: str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Initialize the error with retry timing information."""
|
|
83
|
+
super().__init__(message, service=service, status_code=status_code)
|
|
84
|
+
self.retry_after = retry_after
|
|
85
|
+
self.retry_after_raw = retry_after_raw
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class InvalidServiceResponseError(ServiceError):
|
|
89
|
+
"""A service returned an undocumented or malformed response."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CaptureFailedError(ServiceError):
|
|
93
|
+
"""A service accepted a capture job but later reported failure."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
message: str,
|
|
98
|
+
*,
|
|
99
|
+
service: str | None = None,
|
|
100
|
+
status_code: int | None = None,
|
|
101
|
+
job_id: str | None = None,
|
|
102
|
+
service_code: str | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Initialize the error with failed capture details."""
|
|
105
|
+
super().__init__(message, service=service, status_code=status_code)
|
|
106
|
+
self.job_id = job_id
|
|
107
|
+
self.service_code = service_code
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PollingTimeoutError(ServiceError, TimeoutError):
|
|
111
|
+
"""A capture did not reach a terminal state before its deadline."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
message: str,
|
|
116
|
+
*,
|
|
117
|
+
service: str | None = None,
|
|
118
|
+
job_id: str | None = None,
|
|
119
|
+
timeout: float | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Initialize the error with polling deadline details."""
|
|
122
|
+
super().__init__(message, service=service)
|
|
123
|
+
self.job_id = job_id
|
|
124
|
+
self.timeout = timeout
|
archivist/core/models.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Service-independent Archivist models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _as_utc(value: datetime | None) -> datetime | None:
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
18
|
+
raise ValueError("timestamps must include timezone information")
|
|
19
|
+
return value.astimezone(UTC)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class ArchiveRecord:
|
|
24
|
+
"""A capture returned by an archive service."""
|
|
25
|
+
|
|
26
|
+
service: str
|
|
27
|
+
archive_url: str = field(repr=False)
|
|
28
|
+
original_url: str | None = field(default=None, repr=False)
|
|
29
|
+
archived_at: datetime | None = None
|
|
30
|
+
capture_id: str | None = None
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
"""Normalize the archive timestamp to UTC."""
|
|
34
|
+
object.__setattr__(self, "archived_at", _as_utc(self.archived_at))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ItemT = TypeVar("ItemT")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class PagedSearchResult(Generic[ItemT]):
|
|
42
|
+
"""One page of service search results."""
|
|
43
|
+
|
|
44
|
+
items: tuple[ItemT, ...]
|
|
45
|
+
page: int = 1
|
|
46
|
+
page_count: int = 1
|
|
47
|
+
total_count: int | None = None
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
"""Validate pagination metadata."""
|
|
51
|
+
if self.page < 1:
|
|
52
|
+
raise ValueError("page must be at least 1")
|
|
53
|
+
if self.page_count < 1:
|
|
54
|
+
raise ValueError("page_count must be at least 1")
|
|
55
|
+
if self.total_count is not None and self.total_count < 0:
|
|
56
|
+
raise ValueError("total_count cannot be negative")
|
|
57
|
+
|
|
58
|
+
def __iter__(self) -> Iterator[ItemT]:
|
|
59
|
+
"""Iterate over the items on this page."""
|
|
60
|
+
return iter(self.items)
|
|
61
|
+
|
|
62
|
+
def __len__(self) -> int:
|
|
63
|
+
"""Return the number of items on this page."""
|
|
64
|
+
return len(self.items)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class CaptureJob:
|
|
69
|
+
"""An accepted asynchronous capture job."""
|
|
70
|
+
|
|
71
|
+
service: str
|
|
72
|
+
job_id: str
|
|
73
|
+
target_url: str = field(repr=False)
|
|
74
|
+
message: str | None = field(default=None, repr=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class ServiceStatus:
|
|
79
|
+
"""A service health summary."""
|
|
80
|
+
|
|
81
|
+
service: str
|
|
82
|
+
status: str
|
|
83
|
+
message: str | None = None
|