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.
@@ -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")