dataapp_crawler 0.1.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.
- dataapp_crawler/__init__.py +87 -0
- dataapp_crawler/__main__.py +35 -0
- dataapp_crawler/_logging.py +148 -0
- dataapp_crawler/_network_diagnostics.py +193 -0
- dataapp_crawler/browser_tools/__init__.py +23 -0
- dataapp_crawler/browser_tools/_common.py +66 -0
- dataapp_crawler/browser_tools/dom.py +49 -0
- dataapp_crawler/browser_tools/lists.py +125 -0
- dataapp_crawler/browser_tools/navigation.py +243 -0
- dataapp_crawler/browser_tools/readiness.py +42 -0
- dataapp_crawler/captcha.py +912 -0
- dataapp_crawler/checkpoint.py +487 -0
- dataapp_crawler/cloudflare.py +523 -0
- dataapp_crawler/context.py +231 -0
- dataapp_crawler/cookies.py +207 -0
- dataapp_crawler/crawler.py +1392 -0
- dataapp_crawler/errors.py +184 -0
- dataapp_crawler/http.py +223 -0
- dataapp_crawler/images.py +267 -0
- dataapp_crawler/local_auth.py +77 -0
- dataapp_crawler/models.py +285 -0
- dataapp_crawler/pagination.py +172 -0
- dataapp_crawler/parsing.py +13 -0
- dataapp_crawler/providers/__init__.py +20 -0
- dataapp_crawler/providers/browser.py +292 -0
- dataapp_crawler/providers/camoufox.py +184 -0
- dataapp_crawler/providers/dataos.py +174 -0
- dataapp_crawler/providers/pool.py +451 -0
- dataapp_crawler/providers/proxy.py +209 -0
- dataapp_crawler/py.typed +0 -0
- dataapp_crawler/routing.py +67 -0
- dataapp_crawler/scheduling.py +425 -0
- dataapp_crawler/sessions.py +735 -0
- dataapp_crawler-0.1.0.dist-info/METADATA +211 -0
- dataapp_crawler-0.1.0.dist-info/RECORD +36 -0
- dataapp_crawler-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Versioned, bounded crawler state for Runtime-managed persistence.
|
|
2
|
+
|
|
3
|
+
Checkpoint documents contain target URLs and developer-owned request data, so every
|
|
4
|
+
export must be stored as a private artifact. Request headers and bodies require the
|
|
5
|
+
additional ``include_sensitive=True`` opt-in. The SDK only models state; storing the
|
|
6
|
+
returned document is a Runtime responsibility.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Iterable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from itertools import islice
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .errors import ConfigurationError
|
|
21
|
+
from .models import Request, Stats
|
|
22
|
+
|
|
23
|
+
_SCHEMA = "dataapp_crawler-checkpoint"
|
|
24
|
+
_VERSION = 1
|
|
25
|
+
_MAX_REQUESTS = 10_000
|
|
26
|
+
_MAX_KEYS = 20_000
|
|
27
|
+
_MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
|
|
28
|
+
_MAX_STRING_LENGTH = 4 * 1024 * 1024
|
|
29
|
+
_MAX_JSON_DEPTH = 32
|
|
30
|
+
_MAX_JSON_NODES = 100_000
|
|
31
|
+
|
|
32
|
+
_REQUEST_FIELDS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"url",
|
|
35
|
+
"method",
|
|
36
|
+
"headers",
|
|
37
|
+
"params",
|
|
38
|
+
"content",
|
|
39
|
+
"json",
|
|
40
|
+
"label",
|
|
41
|
+
"user_data",
|
|
42
|
+
"key",
|
|
43
|
+
"replayable",
|
|
44
|
+
"mode",
|
|
45
|
+
"site_key",
|
|
46
|
+
"session_key",
|
|
47
|
+
"deduplicate",
|
|
48
|
+
"wait_until",
|
|
49
|
+
"snapshot",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
_STATS_FIELDS = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"requests",
|
|
55
|
+
"pages",
|
|
56
|
+
"captured",
|
|
57
|
+
"results",
|
|
58
|
+
"retries",
|
|
59
|
+
"duplicates",
|
|
60
|
+
"failed",
|
|
61
|
+
"stop_reason",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
_DOCUMENT_FIELDS = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"schema",
|
|
67
|
+
"version",
|
|
68
|
+
"pending",
|
|
69
|
+
"inflight",
|
|
70
|
+
"dedup_keys",
|
|
71
|
+
"seen_keys",
|
|
72
|
+
"stats",
|
|
73
|
+
"elapsed_seconds",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class CheckpointStats:
|
|
80
|
+
"""Immutable counters consumed before the checkpoint was captured."""
|
|
81
|
+
|
|
82
|
+
requests: int = 0
|
|
83
|
+
pages: int = 0
|
|
84
|
+
captured: int = 0
|
|
85
|
+
results: int = 0
|
|
86
|
+
retries: int = 0
|
|
87
|
+
duplicates: int = 0
|
|
88
|
+
failed: int = 0
|
|
89
|
+
|
|
90
|
+
def to_stats(self) -> Stats:
|
|
91
|
+
return Stats(
|
|
92
|
+
requests=self.requests,
|
|
93
|
+
pages=self.pages,
|
|
94
|
+
captured=self.captured,
|
|
95
|
+
results=self.results,
|
|
96
|
+
retries=self.retries,
|
|
97
|
+
duplicates=self.duplicates,
|
|
98
|
+
failed=self.failed,
|
|
99
|
+
stop_reason=None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CrawlCheckpoint:
|
|
104
|
+
"""An immutable in-memory checkpoint with at-least-once inflight recovery."""
|
|
105
|
+
|
|
106
|
+
__slots__ = (
|
|
107
|
+
"_pending_records",
|
|
108
|
+
"_inflight_records",
|
|
109
|
+
"_dedup_keys",
|
|
110
|
+
"_seen_keys",
|
|
111
|
+
"_stats",
|
|
112
|
+
"_elapsed_seconds",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
pending_records: tuple[str, ...],
|
|
119
|
+
inflight_records: tuple[str, ...],
|
|
120
|
+
dedup_keys: frozenset[str],
|
|
121
|
+
seen_keys: frozenset[str],
|
|
122
|
+
stats: CheckpointStats,
|
|
123
|
+
elapsed_seconds: float,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._pending_records = pending_records
|
|
126
|
+
self._inflight_records = inflight_records
|
|
127
|
+
self._dedup_keys = dedup_keys
|
|
128
|
+
self._seen_keys = seen_keys
|
|
129
|
+
self._stats = stats
|
|
130
|
+
self._elapsed_seconds = elapsed_seconds
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return (
|
|
134
|
+
"CrawlCheckpoint("
|
|
135
|
+
f"pending={len(self._pending_records)}, inflight={len(self._inflight_records)}, "
|
|
136
|
+
f"dedup_keys={len(self._dedup_keys)}, seen_keys={len(self._seen_keys)}, "
|
|
137
|
+
f"elapsed_seconds={self._elapsed_seconds!r})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def capture(
|
|
142
|
+
cls,
|
|
143
|
+
*,
|
|
144
|
+
pending: Iterable[Request],
|
|
145
|
+
inflight: Iterable[Request],
|
|
146
|
+
dedup_keys: Iterable[str],
|
|
147
|
+
seen_keys: Iterable[str],
|
|
148
|
+
stats: Stats,
|
|
149
|
+
elapsed_seconds: float,
|
|
150
|
+
) -> CrawlCheckpoint:
|
|
151
|
+
pending_records = _bounded_request_records(pending, _MAX_REQUESTS)
|
|
152
|
+
inflight_records = _bounded_request_records(inflight, _MAX_REQUESTS - len(pending_records))
|
|
153
|
+
for request in (_request_from_record(record) for record in inflight_records):
|
|
154
|
+
if request.replayable is False or (
|
|
155
|
+
request.method not in {"GET", "HEAD"} and request.replayable is not True
|
|
156
|
+
):
|
|
157
|
+
raise ConfigurationError(
|
|
158
|
+
"Inflight requests must use GET/HEAD or explicitly set replayable=True"
|
|
159
|
+
)
|
|
160
|
+
checkpoint_stats = _checkpoint_stats(stats)
|
|
161
|
+
elapsed = _nonnegative_finite(elapsed_seconds, "elapsed_seconds")
|
|
162
|
+
dedup = _keys(
|
|
163
|
+
set(_keys(dedup_keys, "dedup_keys"))
|
|
164
|
+
| {
|
|
165
|
+
request.unique_key
|
|
166
|
+
for record in (*pending_records, *inflight_records)
|
|
167
|
+
if (request := _request_from_record(record)).deduplicate
|
|
168
|
+
},
|
|
169
|
+
"dedup_keys",
|
|
170
|
+
)
|
|
171
|
+
seen = _keys(seen_keys, "seen_keys")
|
|
172
|
+
return cls(
|
|
173
|
+
pending_records=pending_records,
|
|
174
|
+
inflight_records=inflight_records,
|
|
175
|
+
dedup_keys=dedup,
|
|
176
|
+
seen_keys=seen,
|
|
177
|
+
stats=checkpoint_stats,
|
|
178
|
+
elapsed_seconds=elapsed,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def pending_requests(self) -> tuple[Request, ...]:
|
|
183
|
+
return tuple(_request_from_record(record) for record in self._pending_records)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def inflight_requests(self) -> tuple[Request, ...]:
|
|
187
|
+
return tuple(_request_from_record(record) for record in self._inflight_records)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def requeued_requests(self) -> tuple[Request, ...]:
|
|
191
|
+
"""Return inflight first: restored work is intentionally at-least-once."""
|
|
192
|
+
return self.inflight_requests + self.pending_requests
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def dedup_keys(self) -> frozenset[str]:
|
|
196
|
+
return self._dedup_keys
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def seen_keys(self) -> frozenset[str]:
|
|
200
|
+
return self._seen_keys
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def stats(self) -> Stats:
|
|
204
|
+
"""Return a fresh mutable Stats value with the prior stop reason cleared."""
|
|
205
|
+
return self._stats.to_stats()
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def elapsed(self) -> float:
|
|
209
|
+
return self._elapsed_seconds
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def elapsed_seconds(self) -> float:
|
|
213
|
+
return self._elapsed_seconds
|
|
214
|
+
|
|
215
|
+
def to_dict(self, *, include_sensitive: bool = False) -> dict[str, Any]:
|
|
216
|
+
_validate_sensitive_opt_in(include_sensitive)
|
|
217
|
+
requests = [
|
|
218
|
+
*(_request_from_record(record) for record in self._pending_records),
|
|
219
|
+
*(_request_from_record(record) for record in self._inflight_records),
|
|
220
|
+
]
|
|
221
|
+
if not include_sensitive and any(
|
|
222
|
+
_has_sensitive_request_state(request) for request in requests
|
|
223
|
+
):
|
|
224
|
+
raise ConfigurationError(
|
|
225
|
+
"Checkpoint contains request headers or body data; "
|
|
226
|
+
"pass include_sensitive=True and store the artifact privately"
|
|
227
|
+
)
|
|
228
|
+
document = {
|
|
229
|
+
"schema": _SCHEMA,
|
|
230
|
+
"version": _VERSION,
|
|
231
|
+
"pending": [_load_record(record) for record in self._pending_records],
|
|
232
|
+
"inflight": [_load_record(record) for record in self._inflight_records],
|
|
233
|
+
"dedup_keys": sorted(self._dedup_keys),
|
|
234
|
+
"seen_keys": sorted(self._seen_keys),
|
|
235
|
+
"stats": {**self._stats.__dict__, "stop_reason": None},
|
|
236
|
+
"elapsed_seconds": self._elapsed_seconds,
|
|
237
|
+
}
|
|
238
|
+
_document_size(document)
|
|
239
|
+
return document
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def from_dict(
|
|
243
|
+
cls, document: Mapping[str, Any], *, include_sensitive: bool = False
|
|
244
|
+
) -> CrawlCheckpoint:
|
|
245
|
+
if not isinstance(document, Mapping):
|
|
246
|
+
raise ConfigurationError("Checkpoint must be a JSON object")
|
|
247
|
+
copied = _json_copy(dict(document), "checkpoint")
|
|
248
|
+
_document_size(copied)
|
|
249
|
+
_exact_fields(copied, _DOCUMENT_FIELDS, "checkpoint")
|
|
250
|
+
if copied["schema"] != _SCHEMA:
|
|
251
|
+
raise ConfigurationError("Unknown checkpoint schema")
|
|
252
|
+
if type(copied["version"]) is not int or copied["version"] != _VERSION:
|
|
253
|
+
raise ConfigurationError("Unsupported checkpoint version")
|
|
254
|
+
pending = _request_list(copied["pending"], "pending")
|
|
255
|
+
inflight = _request_list(copied["inflight"], "inflight")
|
|
256
|
+
_validate_sensitive_opt_in(include_sensitive)
|
|
257
|
+
if not include_sensitive and any(
|
|
258
|
+
_has_sensitive_request_state(request) for request in (*pending, *inflight)
|
|
259
|
+
):
|
|
260
|
+
raise ConfigurationError(
|
|
261
|
+
"Checkpoint contains request headers or body data; "
|
|
262
|
+
"pass include_sensitive=True and store the artifact privately"
|
|
263
|
+
)
|
|
264
|
+
return cls.capture(
|
|
265
|
+
pending=pending,
|
|
266
|
+
inflight=inflight,
|
|
267
|
+
dedup_keys=_key_list(copied["dedup_keys"], "dedup_keys"),
|
|
268
|
+
seen_keys=_key_list(copied["seen_keys"], "seen_keys"),
|
|
269
|
+
stats=_stats_from_document(copied["stats"]),
|
|
270
|
+
elapsed_seconds=copied["elapsed_seconds"],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _validate_sensitive_opt_in(include_sensitive: bool) -> None:
|
|
275
|
+
if not isinstance(include_sensitive, bool):
|
|
276
|
+
raise ConfigurationError("include_sensitive must be a boolean")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _has_sensitive_request_state(request: Request) -> bool:
|
|
280
|
+
return (
|
|
281
|
+
bool(request.headers) or request.content not in {None, "", b""} or request.json is not None
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _request_record(request: Request) -> str:
|
|
286
|
+
if not isinstance(request, Request):
|
|
287
|
+
raise ConfigurationError("Checkpoint queues must contain Request values")
|
|
288
|
+
content: Any
|
|
289
|
+
if isinstance(request.content, bytes):
|
|
290
|
+
content = {"encoding": "base64", "data": base64.b64encode(request.content).decode("ascii")}
|
|
291
|
+
elif request.content is None or isinstance(request.content, str):
|
|
292
|
+
content = request.content
|
|
293
|
+
else:
|
|
294
|
+
raise ConfigurationError("Request content must be bytes, text or None")
|
|
295
|
+
value = {
|
|
296
|
+
"url": request.url,
|
|
297
|
+
"method": request.method,
|
|
298
|
+
"headers": request.headers,
|
|
299
|
+
"params": request.params,
|
|
300
|
+
"content": content,
|
|
301
|
+
"json": request.json,
|
|
302
|
+
"label": request.label,
|
|
303
|
+
"user_data": request.user_data,
|
|
304
|
+
"key": request.key,
|
|
305
|
+
"replayable": request.replayable,
|
|
306
|
+
"mode": request.mode,
|
|
307
|
+
"site_key": request.site_key,
|
|
308
|
+
"session_key": request.session_key,
|
|
309
|
+
"deduplicate": request.deduplicate,
|
|
310
|
+
"wait_until": request.wait_until,
|
|
311
|
+
"snapshot": request.snapshot,
|
|
312
|
+
}
|
|
313
|
+
copied = _json_copy(value, "request")
|
|
314
|
+
return json.dumps(copied, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _request_from_record(record: str) -> Request:
|
|
318
|
+
value = _load_record(record)
|
|
319
|
+
_exact_fields(value, _REQUEST_FIELDS, "request")
|
|
320
|
+
for name in ("url", "method", "label", "mode", "wait_until"):
|
|
321
|
+
if not isinstance(value[name], str):
|
|
322
|
+
raise ConfigurationError(f"request.{name} must be a string")
|
|
323
|
+
for name in ("key", "site_key", "session_key"):
|
|
324
|
+
if value[name] is not None and not isinstance(value[name], str):
|
|
325
|
+
raise ConfigurationError(f"request.{name} must be a string or null")
|
|
326
|
+
if value["replayable"] is not None and not isinstance(value["replayable"], bool):
|
|
327
|
+
raise ConfigurationError("request.replayable must be a boolean or null")
|
|
328
|
+
for name in ("deduplicate", "snapshot"):
|
|
329
|
+
if not isinstance(value[name], bool):
|
|
330
|
+
raise ConfigurationError(f"request.{name} must be a boolean")
|
|
331
|
+
if not isinstance(value["headers"], dict) or any(
|
|
332
|
+
not isinstance(key, str) or not isinstance(item, str)
|
|
333
|
+
for key, item in value["headers"].items()
|
|
334
|
+
):
|
|
335
|
+
raise ConfigurationError("request.headers must map strings to strings")
|
|
336
|
+
if value["params"] is not None and not isinstance(value["params"], dict):
|
|
337
|
+
raise ConfigurationError("request.params must be an object or null")
|
|
338
|
+
if not isinstance(value["user_data"], dict):
|
|
339
|
+
raise ConfigurationError("request.user_data must be an object")
|
|
340
|
+
content = value["content"]
|
|
341
|
+
if isinstance(content, dict):
|
|
342
|
+
_exact_fields(content, frozenset({"encoding", "data"}), "request content")
|
|
343
|
+
if content["encoding"] != "base64" or not isinstance(content["data"], str):
|
|
344
|
+
raise ConfigurationError("Invalid encoded request content")
|
|
345
|
+
try:
|
|
346
|
+
content = base64.b64decode(content["data"], validate=True)
|
|
347
|
+
except (ValueError, binascii.Error):
|
|
348
|
+
raise ConfigurationError("Invalid base64 request content") from None
|
|
349
|
+
elif content is not None and not isinstance(content, str):
|
|
350
|
+
raise ConfigurationError("Request content must be encoded bytes, text or None")
|
|
351
|
+
try:
|
|
352
|
+
return Request(**{**value, "content": content})
|
|
353
|
+
except (TypeError, ValueError, ConfigurationError) as error:
|
|
354
|
+
if isinstance(error, ConfigurationError):
|
|
355
|
+
raise
|
|
356
|
+
raise ConfigurationError("Invalid request in checkpoint") from None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _request_list(value: Any, name: str) -> tuple[Request, ...]:
|
|
360
|
+
if not isinstance(value, list):
|
|
361
|
+
raise ConfigurationError(f"{name} must be a JSON array")
|
|
362
|
+
if len(value) > _MAX_REQUESTS:
|
|
363
|
+
raise ConfigurationError("Checkpoint contains too many requests")
|
|
364
|
+
requests: list[Request] = []
|
|
365
|
+
for item in value:
|
|
366
|
+
if not isinstance(item, dict):
|
|
367
|
+
raise ConfigurationError(f"{name} entries must be JSON objects")
|
|
368
|
+
requests.append(_request_from_record(json.dumps(item, separators=(",", ":"))))
|
|
369
|
+
return tuple(requests)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _bounded_request_records(values: Iterable[Request], limit: int) -> tuple[str, ...]:
|
|
373
|
+
records = tuple(_request_record(request) for request in islice(values, limit + 1))
|
|
374
|
+
if len(records) > limit:
|
|
375
|
+
raise ConfigurationError("Checkpoint contains too many requests")
|
|
376
|
+
return records
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _checkpoint_stats(stats: Stats) -> CheckpointStats:
|
|
380
|
+
if not isinstance(stats, Stats):
|
|
381
|
+
raise ConfigurationError("stats must be a Stats value")
|
|
382
|
+
values = {}
|
|
383
|
+
for name in CheckpointStats.__dataclass_fields__:
|
|
384
|
+
value = getattr(stats, name)
|
|
385
|
+
if type(value) is not int or value < 0:
|
|
386
|
+
raise ConfigurationError(f"stats.{name} must be a nonnegative integer")
|
|
387
|
+
values[name] = value
|
|
388
|
+
return CheckpointStats(**values)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _stats_from_document(value: Any) -> Stats:
|
|
392
|
+
if not isinstance(value, dict):
|
|
393
|
+
raise ConfigurationError("stats must be a JSON object")
|
|
394
|
+
_exact_fields(value, _STATS_FIELDS, "stats")
|
|
395
|
+
if value["stop_reason"] is not None:
|
|
396
|
+
raise ConfigurationError("Restored checkpoint stop_reason must be null")
|
|
397
|
+
fields = {name: value[name] for name in CheckpointStats.__dataclass_fields__}
|
|
398
|
+
for name, item in fields.items():
|
|
399
|
+
if type(item) is not int or item < 0:
|
|
400
|
+
raise ConfigurationError(f"stats.{name} must be a nonnegative integer")
|
|
401
|
+
return Stats(**fields, stop_reason=None)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _keys(values: Iterable[str], name: str) -> frozenset[str]:
|
|
405
|
+
collected: list[str] = []
|
|
406
|
+
for value in islice(values, _MAX_KEYS + 1):
|
|
407
|
+
if not isinstance(value, str) or not value or len(value) > _MAX_STRING_LENGTH:
|
|
408
|
+
raise ConfigurationError(f"{name} must contain bounded non-empty strings")
|
|
409
|
+
collected.append(value)
|
|
410
|
+
if len(collected) > _MAX_KEYS:
|
|
411
|
+
raise ConfigurationError(f"{name} contains too many values")
|
|
412
|
+
return frozenset(collected)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _key_list(value: Any, name: str) -> tuple[str, ...]:
|
|
416
|
+
if not isinstance(value, list):
|
|
417
|
+
raise ConfigurationError(f"{name} must be a JSON array")
|
|
418
|
+
if len(value) != len(set(item for item in value if isinstance(item, str))):
|
|
419
|
+
raise ConfigurationError(f"{name} must not contain duplicate values")
|
|
420
|
+
return tuple(_keys(value, name))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _nonnegative_finite(value: Any, name: str) -> float:
|
|
424
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
425
|
+
raise ConfigurationError(f"{name} must be a finite nonnegative number")
|
|
426
|
+
result = float(value)
|
|
427
|
+
if not math.isfinite(result) or result < 0:
|
|
428
|
+
raise ConfigurationError(f"{name} must be a finite nonnegative number")
|
|
429
|
+
return result
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _json_copy(value: Any, name: str) -> Any:
|
|
433
|
+
nodes = 0
|
|
434
|
+
|
|
435
|
+
def validate(item: Any, depth: int) -> None:
|
|
436
|
+
nonlocal nodes
|
|
437
|
+
nodes += 1
|
|
438
|
+
if nodes > _MAX_JSON_NODES or depth > _MAX_JSON_DEPTH:
|
|
439
|
+
raise ConfigurationError(f"{name} exceeds checkpoint JSON limits")
|
|
440
|
+
if item is None or isinstance(item, (bool, int)):
|
|
441
|
+
return
|
|
442
|
+
if isinstance(item, float):
|
|
443
|
+
if not math.isfinite(item):
|
|
444
|
+
raise ConfigurationError(f"{name} contains a non-finite number")
|
|
445
|
+
return
|
|
446
|
+
if isinstance(item, str):
|
|
447
|
+
if len(item) > _MAX_STRING_LENGTH:
|
|
448
|
+
raise ConfigurationError(f"{name} contains an oversized string")
|
|
449
|
+
return
|
|
450
|
+
if isinstance(item, list):
|
|
451
|
+
for child in item:
|
|
452
|
+
validate(child, depth + 1)
|
|
453
|
+
return
|
|
454
|
+
if isinstance(item, dict):
|
|
455
|
+
for key, child in item.items():
|
|
456
|
+
if not isinstance(key, str) or len(key) > _MAX_STRING_LENGTH:
|
|
457
|
+
raise ConfigurationError(f"{name} object keys must be bounded strings")
|
|
458
|
+
validate(child, depth + 1)
|
|
459
|
+
return
|
|
460
|
+
raise ConfigurationError(f"{name} contains a non-JSON value")
|
|
461
|
+
|
|
462
|
+
validate(value, 0)
|
|
463
|
+
try:
|
|
464
|
+
return json.loads(json.dumps(value, allow_nan=False))
|
|
465
|
+
except (TypeError, ValueError):
|
|
466
|
+
raise ConfigurationError(f"{name} must be strict JSON") from None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _load_record(record: str) -> dict[str, Any]:
|
|
470
|
+
value = json.loads(record)
|
|
471
|
+
if not isinstance(value, dict):
|
|
472
|
+
raise ConfigurationError("Invalid internal checkpoint request")
|
|
473
|
+
return value
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _document_size(document: Any) -> None:
|
|
477
|
+
try:
|
|
478
|
+
encoded = json.dumps(document, allow_nan=False, separators=(",", ":")).encode()
|
|
479
|
+
except (TypeError, ValueError):
|
|
480
|
+
raise ConfigurationError("Checkpoint must be strict JSON") from None
|
|
481
|
+
if len(encoded) > _MAX_DOCUMENT_BYTES:
|
|
482
|
+
raise ConfigurationError("Checkpoint exceeds the maximum document size")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _exact_fields(value: Mapping[str, Any], expected: frozenset[str], name: str) -> None:
|
|
486
|
+
if set(value) != expected:
|
|
487
|
+
raise ConfigurationError(f"{name} fields do not match the checkpoint schema")
|