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,231 @@
1
+ """Cooperative cancellation and bounded execution, independent of platform internals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import math
8
+ import time
9
+ from collections.abc import Awaitable, Callable, Iterable
10
+ from contextvars import ContextVar, Token
11
+ from fnmatch import fnmatchcase
12
+ from itertools import islice
13
+ from typing import Any, Literal, TypeVar
14
+ from urllib.parse import urldefrag, urljoin
15
+
16
+ from .errors import ConfigurationError, CrawlCancelled, LimitReached
17
+ from .models import Limits, Request, Stats, origin
18
+
19
+ T = TypeVar("T")
20
+
21
+ _CURRENT_SESSION: ContextVar[tuple[Any, Any] | None] = ContextVar(
22
+ "dataapp_crawler_current_session", default=None
23
+ )
24
+
25
+
26
+ class CrawlContext:
27
+ def __init__(
28
+ self, limits: Limits, *, run_id: str | None = None, attempt_id: str | None = None
29
+ ) -> None:
30
+ self.limits = limits
31
+ self.run_id = run_id
32
+ self.attempt_id = attempt_id
33
+ self.stats = Stats()
34
+ self.cancelled = asyncio.Event()
35
+ self._started: float | None = None
36
+ self._enqueue: Callable[[Request], bool] | None = None
37
+ self._seen: set[str] = set()
38
+
39
+ @property
40
+ def session(self) -> Any | None:
41
+ """Safe metadata and health controls for the current exclusive pool lease."""
42
+ current = _CURRENT_SESSION.get()
43
+ if current is None or current[0] is not self:
44
+ return None
45
+ session = current[1]
46
+ return session if session.active else None
47
+
48
+ def _set_session(self, session: Any) -> Token[tuple[Any, Any] | None]:
49
+ return _CURRENT_SESSION.set((self, session))
50
+
51
+ def _reset_session(self, token: Token[tuple[Any, Any] | None]) -> None:
52
+ _CURRENT_SESSION.reset(token)
53
+
54
+ def start(self) -> None:
55
+ if self._started is None:
56
+ self._started = time.monotonic()
57
+
58
+ @property
59
+ def remaining(self) -> float:
60
+ self.start()
61
+ assert self._started is not None
62
+ return max(0, self.limits.max_duration - (time.monotonic() - self._started))
63
+
64
+ def check(self) -> None:
65
+ if self.cancelled.is_set():
66
+ raise CrawlCancelled("Acquisition cancelled")
67
+ if self.remaining <= 0:
68
+ self.stats.stop_reason = "max_duration"
69
+ raise LimitReached("Acquisition duration limit reached")
70
+
71
+ def cancel(self, reason: str = "cancelled") -> None:
72
+ self.stats.stop_reason = reason
73
+ self.cancelled.set()
74
+
75
+ def stop(self) -> None:
76
+ self.cancel("developer_stop")
77
+
78
+ def enqueue(self, request: Request | str) -> bool:
79
+ """Add follow-up work; false means duplicate. Full queues raise QueueFull."""
80
+ self.check()
81
+ if self._enqueue is None:
82
+ raise RuntimeError("enqueue is only available during crawl")
83
+ return self._enqueue(Request(request) if isinstance(request, str) else request)
84
+
85
+ def enqueue_links(
86
+ self,
87
+ links: Iterable[str],
88
+ *,
89
+ base_url: str,
90
+ label: str = "",
91
+ mode: Literal["http", "browser"] = "http",
92
+ same_origin: bool = True,
93
+ include: Iterable[str] = (),
94
+ exclude: Iterable[str] = (),
95
+ max_links: int = 100,
96
+ ) -> int:
97
+ """Resolve and enqueue bounded links from a developer's DOM/JSON/Page parser.
98
+
99
+ Patterns use case-sensitive URL globs. Inspect at most ``max_links``
100
+ candidates, strip fragments, and use the crawl queue's existing deduplication
101
+ and limits. Cross-origin links require an explicit ``same_origin=False``.
102
+ """
103
+ self.check()
104
+ if self._enqueue is None:
105
+ raise RuntimeError("enqueue_links is only available during crawl")
106
+ if isinstance(max_links, bool) or not isinstance(max_links, int) or max_links < 1:
107
+ raise ConfigurationError("max_links must be a positive integer")
108
+ if mode not in {"http", "browser"} or not isinstance(same_origin, bool):
109
+ raise ConfigurationError("Invalid link acquisition mode or origin policy")
110
+ source_origin = origin(base_url)
111
+ included, excluded = tuple(include), tuple(exclude)
112
+ added = 0
113
+ for href in islice(links, max_links):
114
+ self.check()
115
+ if not isinstance(href, str) or not href.strip() or href.lstrip().startswith("#"):
116
+ continue
117
+ try:
118
+ url = urldefrag(urljoin(base_url, href.strip()))[0]
119
+ target_origin = origin(url)
120
+ except (ConfigurationError, ValueError):
121
+ continue
122
+ if same_origin and target_origin != source_origin:
123
+ continue
124
+ if included and not any(fnmatchcase(url, pattern) for pattern in included):
125
+ continue
126
+ if any(fnmatchcase(url, pattern) for pattern in excluded):
127
+ continue
128
+ added += self.enqueue(Request(url, label=label, mode=mode))
129
+ return added
130
+
131
+ def seen(self, key: str) -> bool:
132
+ """Return whether a record/cursor key was already seen, then remember it."""
133
+ present = key in self._seen
134
+ if not present:
135
+ if len(self._seen) >= self.limits.max_results + self.limits.max_requests:
136
+ raise LimitReached("Seen-key memory limit reached")
137
+ self._seen.add(key)
138
+ return present
139
+
140
+ def reserve_request(self) -> None:
141
+ self.check()
142
+ if self.stats.requests >= self.limits.max_requests:
143
+ self.stats.stop_reason = "max_requests"
144
+ raise LimitReached("Request limit reached")
145
+ self.stats.requests += 1
146
+
147
+ async def run(self, awaitable: Awaitable[T], *, timeout: float | None = None) -> T:
148
+ """Run one operation within cancellation and time budgets.
149
+
150
+ ``timeout`` bounds this operation while the remaining ``max_duration``
151
+ budget still bounds the whole crawl. An operation timeout raises the
152
+ standard :class:`TimeoutError`; exhausting the crawl budget raises
153
+ :class:`LimitReached`. The owned task is cancelled and joined on every
154
+ timeout or cancellation path.
155
+ """
156
+ try:
157
+ if timeout is not None and (
158
+ isinstance(timeout, bool)
159
+ or not isinstance(timeout, (int, float))
160
+ or not math.isfinite(timeout)
161
+ or timeout <= 0
162
+ ):
163
+ raise ConfigurationError("timeout must be a finite positive number or None")
164
+ self.check()
165
+ except BaseException:
166
+ # Calling an async function creates a coroutine before this method can
167
+ # validate its keyword arguments. Close that unstarted coroutine so a
168
+ # configuration error never produces an unawaited-coroutine warning.
169
+ if inspect.iscoroutine(awaitable):
170
+ awaitable.close()
171
+ else:
172
+ rejected_task = asyncio.ensure_future(awaitable)
173
+ if not rejected_task.done():
174
+ rejected_task.cancel()
175
+ await asyncio.gather(rejected_task, return_exceptions=True)
176
+ raise
177
+
178
+ remaining = self.remaining
179
+ operation_timeout = float(timeout) if timeout is not None else None
180
+ deadline = min(remaining, operation_timeout) if operation_timeout is not None else remaining
181
+ operation_deadline_first = operation_timeout is not None and operation_timeout < remaining
182
+ task = asyncio.ensure_future(awaitable)
183
+ abort = asyncio.create_task(self.cancelled.wait())
184
+ try:
185
+ done, _ = await asyncio.wait(
186
+ {task, abort}, timeout=deadline, return_when=asyncio.FIRST_COMPLETED
187
+ )
188
+ if abort in done:
189
+ raise CrawlCancelled("Acquisition cancelled")
190
+ if task in done:
191
+ return task.result()
192
+ if operation_deadline_first:
193
+ raise TimeoutError("Operation timeout reached")
194
+ self.stats.stop_reason = "max_duration"
195
+ raise LimitReached("Acquisition duration limit reached")
196
+ finally:
197
+ for pending_task in (task, abort):
198
+ if not pending_task.done():
199
+ pending_task.cancel()
200
+ await asyncio.gather(task, abort, return_exceptions=True)
201
+
202
+ async def sleep(self, delay: float) -> None:
203
+ await self.run(asyncio.sleep(max(0, delay)))
204
+
205
+
206
+ class SiteGate:
207
+ """Start-rate and concurrency limits shared by all requests/retries to one site."""
208
+
209
+ def __init__(self, context: CrawlContext) -> None:
210
+ self.context = context
211
+ self.semaphore = asyncio.Semaphore(context.limits.per_site_concurrency)
212
+ self._lock = asyncio.Lock()
213
+ self._next_start = 0.0
214
+
215
+ def defer(self, delay: float) -> None:
216
+ self._next_start = max(self._next_start, time.monotonic() + delay)
217
+
218
+ async def __aenter__(self) -> SiteGate:
219
+ await self.context.run(self.semaphore.acquire())
220
+ try:
221
+ async with self._lock:
222
+ while self._next_start > time.monotonic():
223
+ await self.context.sleep(self._next_start - time.monotonic())
224
+ self._next_start = time.monotonic() + self.context.limits.min_interval
225
+ except BaseException:
226
+ self.semaphore.release()
227
+ raise
228
+ return self
229
+
230
+ async def __aexit__(self, *exc: Any) -> None:
231
+ self.semaphore.release()
@@ -0,0 +1,207 @@
1
+ """Cookie conversion and reconciliation for opt-in cross-engine sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.cookiejar
6
+ import time
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from .errors import ConfigurationError
12
+
13
+ CookieKey = tuple[str, str, str]
14
+
15
+
16
+ def _same_site(value: Any) -> str | None:
17
+ if not isinstance(value, str):
18
+ return None
19
+ normalized = value.lower()
20
+ return {"strict": "Strict", "lax": "Lax", "none": "None"}.get(normalized)
21
+
22
+
23
+ def _rest_value(cookie: http.cookiejar.Cookie, name: str) -> Any:
24
+ for key, value in getattr(cookie, "_rest", {}).items():
25
+ if key.lower() == name.lower():
26
+ return value
27
+ return None
28
+
29
+
30
+ def _has_rest(cookie: http.cookiejar.Cookie, name: str) -> bool:
31
+ return any(key.lower() == name.lower() for key in getattr(cookie, "_rest", {}))
32
+
33
+
34
+ def _as_bool(value: Any) -> bool:
35
+ if isinstance(value, str):
36
+ return value.lower() not in {"", "0", "false", "no"}
37
+ return bool(value)
38
+
39
+
40
+ def _is_partitioned_record(cookie: dict[str, Any]) -> bool:
41
+ return "partitionKey" in cookie or cookie.get("partitioned") is not None
42
+
43
+
44
+ def _normalized(cookie: dict[str, Any]) -> dict[str, Any] | None:
45
+ if _is_partitioned_record(cookie):
46
+ raise ConfigurationError("Partitioned cookies cannot be shared across engines")
47
+ name = cookie.get("name")
48
+ domain = cookie.get("domain")
49
+ if not isinstance(name, str) or not name or not isinstance(domain, str) or not domain:
50
+ return None
51
+ expires = cookie.get("expires")
52
+ if not isinstance(expires, (int, float)) or expires < 0:
53
+ expires = None
54
+ if expires is not None and expires <= time.time():
55
+ return None
56
+ host_only = cookie.get("hostOnly")
57
+ if not isinstance(host_only, bool):
58
+ host_only = not domain.startswith(".")
59
+ if host_only:
60
+ domain = domain.lstrip(".")
61
+ elif not domain.startswith("."):
62
+ domain = f".{domain}"
63
+ result: dict[str, Any] = {
64
+ "name": name,
65
+ "value": str(cookie.get("value", "")),
66
+ "domain": domain.lower(),
67
+ "path": cookie.get("path") or "/",
68
+ "hostOnly": host_only,
69
+ "secure": bool(cookie.get("secure", False)),
70
+ "httpOnly": bool(cookie.get("httpOnly", False)),
71
+ "expires": expires,
72
+ }
73
+ same_site = _same_site(cookie.get("sameSite"))
74
+ if same_site is not None:
75
+ result["sameSite"] = same_site
76
+ return result
77
+
78
+
79
+ def cookies_from_jar(jar: http.cookiejar.CookieJar) -> list[dict[str, Any]]:
80
+ """Export current cookies without exposing HTTP headers or other identity state."""
81
+ jar.clear_expired_cookies()
82
+ exported: list[dict[str, Any]] = []
83
+ for cookie in jar:
84
+ if _has_rest(cookie, "Partitioned") or _has_rest(cookie, "partitionKey"):
85
+ raise ConfigurationError("Partitioned cookies cannot be shared across engines")
86
+ normalized = _normalized(
87
+ {
88
+ "name": cookie.name,
89
+ "value": cookie.value,
90
+ "domain": cookie.domain,
91
+ "path": cookie.path,
92
+ "hostOnly": not cookie.domain_specified,
93
+ "secure": cookie.secure,
94
+ "httpOnly": _as_bool(_rest_value(cookie, "HttpOnly")),
95
+ "sameSite": _rest_value(cookie, "SameSite"),
96
+ "expires": cookie.expires,
97
+ }
98
+ )
99
+ if normalized is not None:
100
+ exported.append(normalized)
101
+ return exported
102
+
103
+
104
+ def is_partitioned_jar_cookie(cookie: http.cookiejar.Cookie) -> bool:
105
+ return _has_rest(cookie, "Partitioned") or _has_rest(cookie, "partitionKey")
106
+
107
+
108
+ def validate_shareable_cookies(cookies: Iterable[dict[str, Any]]) -> None:
109
+ """Fail before a bridge can silently downgrade partitioned browser state."""
110
+ for cookie in cookies:
111
+ _normalized(cookie)
112
+
113
+
114
+ def cookie_to_jar(cookie: dict[str, Any]) -> http.cookiejar.Cookie | None:
115
+ normalized = _normalized(cookie)
116
+ if normalized is None:
117
+ return None
118
+ rest: dict[str, Any] = {}
119
+ if normalized["httpOnly"]:
120
+ rest["HttpOnly"] = True
121
+ if "sameSite" in normalized:
122
+ rest["SameSite"] = normalized["sameSite"]
123
+ domain = normalized["domain"]
124
+ host_only = normalized["hostOnly"]
125
+ expires = normalized["expires"]
126
+ return http.cookiejar.Cookie(
127
+ version=0,
128
+ name=normalized["name"],
129
+ value=normalized["value"],
130
+ port=None,
131
+ port_specified=False,
132
+ domain=domain,
133
+ domain_specified=not host_only,
134
+ domain_initial_dot=domain.startswith("."),
135
+ path=normalized["path"],
136
+ path_specified=True,
137
+ secure=normalized["secure"],
138
+ expires=expires,
139
+ discard=expires is None,
140
+ comment=None,
141
+ comment_url=None,
142
+ rest=rest,
143
+ rfc2109=False,
144
+ )
145
+
146
+
147
+ def replace_jar(
148
+ jar: http.cookiejar.CookieJar,
149
+ cookies: Iterable[dict[str, Any]],
150
+ *,
151
+ ignore_partitioned: bool = False,
152
+ ) -> None:
153
+ jar.clear()
154
+ for item in cookies:
155
+ if ignore_partitioned and _is_partitioned_record(item):
156
+ continue
157
+ cookie = cookie_to_jar(item)
158
+ if cookie is not None:
159
+ jar.set_cookie(cookie)
160
+
161
+
162
+ def cookies_for_browser(cookies: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
163
+ result: list[dict[str, Any]] = []
164
+ for item in cookies:
165
+ cookie = _normalized(item)
166
+ if cookie is None:
167
+ continue
168
+ browser_cookie: dict[str, Any] = {
169
+ "name": cookie["name"],
170
+ "value": cookie["value"],
171
+ "domain": cookie["domain"],
172
+ "path": cookie["path"],
173
+ "secure": cookie["secure"],
174
+ "httpOnly": cookie["httpOnly"],
175
+ "expires": cookie["expires"] if cookie["expires"] is not None else -1,
176
+ }
177
+ if "sameSite" in cookie:
178
+ browser_cookie["sameSite"] = cookie["sameSite"]
179
+ result.append(browser_cookie)
180
+ return result
181
+
182
+
183
+ @dataclass
184
+ class CookieState:
185
+ """A session-local canonical snapshot; callers serialize access through the lease."""
186
+
187
+ _cookies: dict[CookieKey, dict[str, Any]] = field(default_factory=dict, repr=False)
188
+
189
+ @classmethod
190
+ def from_jar(cls, jar: http.cookiejar.CookieJar) -> CookieState:
191
+ state = cls()
192
+ state.replace(cookies_from_jar(jar))
193
+ return state
194
+
195
+ def replace(self, cookies: Iterable[dict[str, Any]]) -> None:
196
+ replacement: dict[CookieKey, dict[str, Any]] = {}
197
+ for item in cookies:
198
+ cookie = _normalized(item)
199
+ if cookie is None:
200
+ continue
201
+ key = (cookie["domain"], cookie["path"], cookie["name"])
202
+ replacement[key] = cookie
203
+ self._cookies = replacement
204
+
205
+ def snapshot(self) -> list[dict[str, Any]]:
206
+ self.replace(self._cookies.values())
207
+ return [dict(self._cookies[key]) for key in sorted(self._cookies)]