politefetch 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.
- politefetch/__init__.py +49 -0
- politefetch/adapters/__init__.py +15 -0
- politefetch/adapters/base.py +751 -0
- politefetch/adapters/decoding.py +88 -0
- politefetch/adapters/httpx.py +204 -0
- politefetch/adapters/requests.py +156 -0
- politefetch/adapters/urllib.py +118 -0
- politefetch/checks.py +256 -0
- politefetch/clock.py +61 -0
- politefetch/errors.py +352 -0
- politefetch/gate.py +466 -0
- politefetch/policy.py +295 -0
- politefetch/py.typed +0 -0
- politefetch/skill/__init__.py +213 -0
- politefetch/skill/__main__.py +3 -0
- politefetch/skill/politefetch/SKILL.md +351 -0
- politefetch/skill/politefetch/references/api.md +1014 -0
- politefetch/status.py +125 -0
- politefetch/store/__init__.py +41 -0
- politefetch/store/base.py +549 -0
- politefetch/store/memory.py +273 -0
- politefetch/store/postgres.py +753 -0
- politefetch/store/sqlite.py +671 -0
- politefetch/testing/__init__.py +3 -0
- politefetch/testing/barrier.py +282 -0
- politefetch/testing/counter.py +69 -0
- politefetch/testing/plugin.py +70 -0
- politefetch/testing/processes.py +122 -0
- politefetch/testing/simulator.py +431 -0
- politefetch-0.1.0.dist-info/METADATA +361 -0
- politefetch-0.1.0.dist-info/RECORD +34 -0
- politefetch-0.1.0.dist-info/WHEEL +4 -0
- politefetch-0.1.0.dist-info/entry_points.txt +2 -0
- politefetch-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""What every adapter shares: the fetch cycle, the result, the failure shapes.
|
|
2
|
+
|
|
3
|
+
An adapter only knows how to send one GET with its client (`Transport`),
|
|
4
|
+
hand back the body as transferred, piece by piece, and name that client's
|
|
5
|
+
errors. Everything else happens here, the same way for every client:
|
|
6
|
+
|
|
7
|
+
1. the gate admits the request or raises `Refused`: nothing is sent;
|
|
8
|
+
2. the request goes out once, with the conditional headers the gate
|
|
9
|
+
remembers, a `User-Agent` and `Accept-Encoding: gzip, deflate`;
|
|
10
|
+
redirects are not followed, because each would be another request to
|
|
11
|
+
the source;
|
|
12
|
+
3. the body is read piece by piece, within a deadline for the whole request
|
|
13
|
+
and a size limit (`max_bytes`, and what is left of the host's byte
|
|
14
|
+
budget), then decoded here, so wire and decoded bytes count the same in
|
|
15
|
+
every client and a compressed stream cut short is caught;
|
|
16
|
+
4. no answer, an HTTP error status, or an answer past its deadline raises
|
|
17
|
+
`FetchFailed`;
|
|
18
|
+
5. a 2xx body goes through the content checks of `politefetch.checks`
|
|
19
|
+
(length, HTML, JSON, data time, freshness, stuck source) and any
|
|
20
|
+
`ContentError` is raised; a 304 is checked against the data time last
|
|
21
|
+
seen, because "not modified" does not mean "fresh";
|
|
22
|
+
6. the outcome is recorded with its bytes before anything is returned.
|
|
23
|
+
Stale or stuck data came from a source that answered well: it is
|
|
24
|
+
recorded as an answer, and `StaleData` or `StuckSource` carries the
|
|
25
|
+
result.
|
|
26
|
+
|
|
27
|
+
The deadline is checked when the head arrives and after each piece of the
|
|
28
|
+
body: a request lasts at most the deadline plus one connect and one read
|
|
29
|
+
timeout. A source that drips the head itself is bounded only by the
|
|
30
|
+
client's read timeout on each piece of it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import socket
|
|
35
|
+
import ssl
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
38
|
+
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import datetime, timedelta
|
|
41
|
+
from enum import StrEnum
|
|
42
|
+
from importlib import metadata
|
|
43
|
+
from types import TracebackType
|
|
44
|
+
from typing import Any, Literal, Protocol, Self
|
|
45
|
+
from urllib.parse import urljoin
|
|
46
|
+
|
|
47
|
+
from politefetch.adapters.decoding import ACCEPT_ENCODING, decode_body
|
|
48
|
+
from politefetch.checks import (
|
|
49
|
+
Expect,
|
|
50
|
+
check_advancing,
|
|
51
|
+
check_body,
|
|
52
|
+
check_fresh,
|
|
53
|
+
check_length,
|
|
54
|
+
check_not_ahead,
|
|
55
|
+
check_redirect,
|
|
56
|
+
read_data_time,
|
|
57
|
+
)
|
|
58
|
+
from politefetch.errors import (
|
|
59
|
+
BodyTooLarge,
|
|
60
|
+
ContentError,
|
|
61
|
+
FetchFailed,
|
|
62
|
+
SourceNotReady,
|
|
63
|
+
StaleData,
|
|
64
|
+
StuckSource,
|
|
65
|
+
UnexpectedContent,
|
|
66
|
+
)
|
|
67
|
+
from politefetch.gate import Attempt, Gate, host_of
|
|
68
|
+
from politefetch.policy import parse_duration, parse_size
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"DEFAULT_DEADLINE",
|
|
72
|
+
"DEFAULT_MAX_AHEAD",
|
|
73
|
+
"AsyncFetcher",
|
|
74
|
+
"AsyncRawResponse",
|
|
75
|
+
"AsyncTransport",
|
|
76
|
+
"FailureShape",
|
|
77
|
+
"FetchResult",
|
|
78
|
+
"Fetcher",
|
|
79
|
+
"IncompleteBody",
|
|
80
|
+
"RawResponse",
|
|
81
|
+
"Timer",
|
|
82
|
+
"Transport",
|
|
83
|
+
"TransportError",
|
|
84
|
+
"classify",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FailureShape(StrEnum):
|
|
89
|
+
"""How a request failed without a usable HTTP answer. Stored: do not
|
|
90
|
+
rename."""
|
|
91
|
+
|
|
92
|
+
CONNECT_TIMEOUT = "connect_timeout"
|
|
93
|
+
READ_TIMEOUT = "read_timeout"
|
|
94
|
+
REFUSED = "refused"
|
|
95
|
+
RESET = "reset"
|
|
96
|
+
TLS = "tls"
|
|
97
|
+
DNS = "dns"
|
|
98
|
+
CONNECTION = "connection"
|
|
99
|
+
"""Any other failure to connect or to read the answer."""
|
|
100
|
+
HTTP = "http"
|
|
101
|
+
"""An HTTP error status (1xx, 4xx, 5xx)."""
|
|
102
|
+
DEADLINE = "deadline"
|
|
103
|
+
"""A good answer still arriving when the fetch's deadline was up."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
Phase = Literal["connect", "read"]
|
|
107
|
+
Timeout = float | tuple[float, float]
|
|
108
|
+
Timer = Callable[[], float]
|
|
109
|
+
"""Seconds on a clock that never jumps back, such as `time.monotonic`."""
|
|
110
|
+
|
|
111
|
+
DEFAULT_DEADLINE = timedelta(minutes=5)
|
|
112
|
+
"""How long a whole request may last by default, body included."""
|
|
113
|
+
|
|
114
|
+
DEFAULT_MAX_AHEAD = timedelta(minutes=15)
|
|
115
|
+
"""How far ahead of now a data time may be by default: room for a clock a
|
|
116
|
+
little ahead, not for a wrong field."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _chain(error: BaseException) -> Iterator[BaseException]:
|
|
120
|
+
"""The error and everything behind it: causes, contexts, `reason`
|
|
121
|
+
attributes and exceptions passed as arguments."""
|
|
122
|
+
seen: set[int] = set()
|
|
123
|
+
queue: list[BaseException] = [error]
|
|
124
|
+
while queue:
|
|
125
|
+
current = queue.pop(0)
|
|
126
|
+
if id(current) in seen:
|
|
127
|
+
continue
|
|
128
|
+
seen.add(id(current))
|
|
129
|
+
yield current
|
|
130
|
+
behind = [
|
|
131
|
+
current.__cause__,
|
|
132
|
+
current.__context__,
|
|
133
|
+
getattr(current, "reason", None),
|
|
134
|
+
]
|
|
135
|
+
behind += [arg for arg in current.args if isinstance(arg, BaseException)]
|
|
136
|
+
queue += [b for b in behind if isinstance(b, BaseException)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def classify(error: BaseException, *, phase: Phase) -> FailureShape:
|
|
140
|
+
"""Name a low-level failure by what is found behind it. A timeout counts
|
|
141
|
+
as a connect timeout or a read timeout according to `phase`."""
|
|
142
|
+
for found in _chain(error):
|
|
143
|
+
if isinstance(found, ssl.SSLError | ssl.CertificateError):
|
|
144
|
+
return FailureShape.TLS
|
|
145
|
+
if isinstance(found, socket.gaierror):
|
|
146
|
+
return FailureShape.DNS
|
|
147
|
+
if isinstance(found, ConnectionRefusedError):
|
|
148
|
+
return FailureShape.REFUSED
|
|
149
|
+
if isinstance(
|
|
150
|
+
found, ConnectionResetError | BrokenPipeError | ConnectionAbortedError
|
|
151
|
+
):
|
|
152
|
+
return FailureShape.RESET
|
|
153
|
+
if isinstance(found, TimeoutError):
|
|
154
|
+
return (
|
|
155
|
+
FailureShape.CONNECT_TIMEOUT
|
|
156
|
+
if phase == "connect"
|
|
157
|
+
else FailureShape.READ_TIMEOUT
|
|
158
|
+
)
|
|
159
|
+
return FailureShape.CONNECTION
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- what a transport hands back ------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class RawResponse:
|
|
167
|
+
"""One HTTP answer as a transport receives it. Header names are
|
|
168
|
+
lowercase. `chunks` yields the body as transferred, piece by piece as it
|
|
169
|
+
arrives: without the transfer framing, but not decoded (a
|
|
170
|
+
Content-Encoding stays, the core undoes it)."""
|
|
171
|
+
|
|
172
|
+
status: int
|
|
173
|
+
headers: Mapping[str, str]
|
|
174
|
+
chunks: Iterator[bytes]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class AsyncRawResponse:
|
|
179
|
+
"""`RawResponse` for an asynchronous transport."""
|
|
180
|
+
|
|
181
|
+
status: int
|
|
182
|
+
headers: Mapping[str, str]
|
|
183
|
+
chunks: AsyncIterator[bytes]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TransportError(Exception):
|
|
187
|
+
"""No usable answer: raised by a transport, while connecting or while
|
|
188
|
+
reading the body, and turned into `FetchFailed`."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, shape: FailureShape) -> None:
|
|
191
|
+
super().__init__(shape)
|
|
192
|
+
self.shape = shape
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class IncompleteBody(Exception):
|
|
196
|
+
"""The body broke off before its end: raised by a transport while the
|
|
197
|
+
body is read, and turned into `SourceNotReady`."""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Transport(Protocol):
|
|
201
|
+
"""Sends one GET, without following redirects, retrying or decoding.
|
|
202
|
+
|
|
203
|
+
`send` returns a context manager: the connection stays open while the
|
|
204
|
+
core reads `chunks`, and closes when the block ends, also when the core
|
|
205
|
+
stops reading early. Failures raise `TransportError` or `IncompleteBody`.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def send(
|
|
209
|
+
self, url: str, headers: Mapping[str, str], timeout: tuple[float, float]
|
|
210
|
+
) -> AbstractContextManager[RawResponse]: ...
|
|
211
|
+
|
|
212
|
+
def close(self) -> None: ...
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AsyncTransport(Protocol):
|
|
216
|
+
def send(
|
|
217
|
+
self, url: str, headers: Mapping[str, str], timeout: tuple[float, float]
|
|
218
|
+
) -> AbstractAsyncContextManager[AsyncRawResponse]: ...
|
|
219
|
+
|
|
220
|
+
async def aclose(self) -> None: ...
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- what a fetch hands back ----------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True, slots=True)
|
|
227
|
+
class FetchResult:
|
|
228
|
+
"""A fetch that passed every check.
|
|
229
|
+
|
|
230
|
+
`content` is the parsed JSON, the decoded text or the bytes, according to
|
|
231
|
+
`expect`; `None` when the source answered 304 (`not_modified`). Then
|
|
232
|
+
`data_time` is the latest data time seen for the URL, if any. `body` is
|
|
233
|
+
decoded; `wire_bytes` counts it as transferred.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
url: str
|
|
237
|
+
status: int
|
|
238
|
+
headers: Mapping[str, str]
|
|
239
|
+
body: bytes
|
|
240
|
+
content: Any
|
|
241
|
+
not_modified: bool
|
|
242
|
+
data_time: datetime | None
|
|
243
|
+
wire_bytes: int
|
|
244
|
+
decoded_bytes: int
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _user_agent() -> str:
|
|
248
|
+
try:
|
|
249
|
+
return f"politefetch/{metadata.version('politefetch')}"
|
|
250
|
+
except metadata.PackageNotFoundError: # pragma: no cover - running from a checkout
|
|
251
|
+
return "politefetch"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
USER_AGENT = _user_agent()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _timeouts(timeout: Timeout) -> tuple[float, float]:
|
|
258
|
+
connect, read = (timeout, timeout) if isinstance(timeout, int | float) else timeout
|
|
259
|
+
for value in (connect, read):
|
|
260
|
+
if not 0 < value < float("inf"):
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"timeouts must be positive and finite, got {timeout!r}: a request "
|
|
263
|
+
"that never ends keeps the host blocked until reconciliation"
|
|
264
|
+
)
|
|
265
|
+
return float(connect), float(read)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _deadline(
|
|
269
|
+
deadline: timedelta | str, timeout: tuple[float, float], gate: Gate
|
|
270
|
+
) -> timedelta:
|
|
271
|
+
value = parse_duration(deadline)
|
|
272
|
+
if value <= timedelta(0):
|
|
273
|
+
raise ValueError(f"deadline must be positive, got {value}")
|
|
274
|
+
longest = value + timedelta(seconds=timeout[0] + timeout[1])
|
|
275
|
+
if longest >= gate.reconcile_after:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"deadline {value} plus one connect and one read timeout ({longest}) "
|
|
278
|
+
f"must end before the gate's reconcile_after ({gate.reconcile_after}): "
|
|
279
|
+
"a request still running then is closed as abandoned, and the host "
|
|
280
|
+
"can be asked again while it runs"
|
|
281
|
+
)
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class _Options:
|
|
287
|
+
expect: Expect
|
|
288
|
+
data_time: Callable[[Any], datetime] | None
|
|
289
|
+
max_age: timedelta | None
|
|
290
|
+
max_ahead: timedelta
|
|
291
|
+
stuck_after: int | timedelta | None
|
|
292
|
+
allow_empty: bool
|
|
293
|
+
max_bytes: int | None
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def build(
|
|
297
|
+
cls,
|
|
298
|
+
expect: Expect,
|
|
299
|
+
data_time: Callable[[Any], datetime] | None,
|
|
300
|
+
max_age: timedelta | str | None,
|
|
301
|
+
max_ahead: timedelta | str,
|
|
302
|
+
stuck_after: int | timedelta | str | None,
|
|
303
|
+
allow_empty: bool,
|
|
304
|
+
max_bytes: int | str | None,
|
|
305
|
+
) -> "_Options":
|
|
306
|
+
if (max_age is not None or stuck_after is not None) and data_time is None:
|
|
307
|
+
raise ValueError("max_age and stuck_after need a data_time extractor")
|
|
308
|
+
stuck: int | timedelta | None
|
|
309
|
+
if isinstance(stuck_after, int):
|
|
310
|
+
if stuck_after < 2:
|
|
311
|
+
raise ValueError(f"stuck_after must be at least 2, got {stuck_after}")
|
|
312
|
+
stuck = stuck_after
|
|
313
|
+
elif stuck_after is not None:
|
|
314
|
+
stuck = parse_duration(stuck_after)
|
|
315
|
+
if stuck <= timedelta(0):
|
|
316
|
+
raise ValueError(f"stuck_after must be positive, got {stuck}")
|
|
317
|
+
else:
|
|
318
|
+
stuck = None
|
|
319
|
+
age = None if max_age is None else parse_duration(max_age)
|
|
320
|
+
ahead = parse_duration(max_ahead)
|
|
321
|
+
size = None if max_bytes is None else parse_size(max_bytes)
|
|
322
|
+
return cls(expect, data_time, age, ahead, stuck, allow_empty, size)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _request_headers(
|
|
326
|
+
attempt: Attempt, user_agent: str, headers: Mapping[str, str] | None
|
|
327
|
+
) -> dict[str, str]:
|
|
328
|
+
"""Ours, then the caller's, then the conditional ones: a name given
|
|
329
|
+
again, in any case, replaces the earlier one."""
|
|
330
|
+
ours = {"User-Agent": user_agent, "Accept-Encoding": ACCEPT_ENCODING}
|
|
331
|
+
merged: dict[str, tuple[str, str]] = {}
|
|
332
|
+
for given in (ours, headers or {}, attempt.conditional_headers):
|
|
333
|
+
for name, value in given.items():
|
|
334
|
+
merged[name.lower()] = (name, value)
|
|
335
|
+
return dict(merged.values())
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _content_length(headers: Mapping[str, str]) -> int | None:
|
|
339
|
+
value = headers.get("content-length", "").strip()
|
|
340
|
+
return int(value) if value.isascii() and value.isdigit() else None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --- reading the body -----------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class _Reading:
|
|
347
|
+
"""One answer read piece by piece, until its end, the deadline or the
|
|
348
|
+
size limit, whichever comes first."""
|
|
349
|
+
|
|
350
|
+
def __init__(
|
|
351
|
+
self, timer: Timer, deadline: timedelta, limit: tuple[int, str] | None
|
|
352
|
+
) -> None:
|
|
353
|
+
self._timer = timer
|
|
354
|
+
self._ends = timer() + deadline.total_seconds()
|
|
355
|
+
self.limit = limit
|
|
356
|
+
"""The most wire bytes it may read, and why."""
|
|
357
|
+
self.status: int | None = None
|
|
358
|
+
self.headers: Mapping[str, str] = {}
|
|
359
|
+
self.pieces: list[bytes] = []
|
|
360
|
+
self.wire = 0
|
|
361
|
+
self.cut: FailureShape | Literal["too_large"] | None = None
|
|
362
|
+
|
|
363
|
+
def start(self, status: int, headers: Mapping[str, str]) -> bool:
|
|
364
|
+
"""Note the head; `False` if the body must not be read."""
|
|
365
|
+
self.status, self.headers = status, headers
|
|
366
|
+
return self._in_time()
|
|
367
|
+
|
|
368
|
+
def take(self, chunk: bytes) -> bool:
|
|
369
|
+
"""Keep a piece of the body; `False` if reading must stop."""
|
|
370
|
+
self.pieces.append(chunk)
|
|
371
|
+
self.wire += len(chunk)
|
|
372
|
+
if self.limit is not None and self.wire > self.limit[0]:
|
|
373
|
+
self.cut = "too_large"
|
|
374
|
+
return False
|
|
375
|
+
return self._in_time()
|
|
376
|
+
|
|
377
|
+
def _in_time(self) -> bool:
|
|
378
|
+
if self._timer() > self._ends:
|
|
379
|
+
self.cut = FailureShape.DEADLINE
|
|
380
|
+
return False
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _limit(attempt: Attempt, max_bytes: int | None) -> tuple[int, str] | None:
|
|
385
|
+
limits = []
|
|
386
|
+
if max_bytes is not None:
|
|
387
|
+
limits.append((max_bytes, f"max_bytes {max_bytes}"))
|
|
388
|
+
if attempt.bytes_left is not None:
|
|
389
|
+
left = attempt.bytes_left
|
|
390
|
+
limits.append((left, f"the {left} bytes left in the host's byte budget"))
|
|
391
|
+
return min(limits) if limits else None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _read(
|
|
395
|
+
transport: Transport,
|
|
396
|
+
url: str,
|
|
397
|
+
headers: Mapping[str, str],
|
|
398
|
+
timeout: tuple[float, float],
|
|
399
|
+
reading: _Reading,
|
|
400
|
+
) -> None:
|
|
401
|
+
with transport.send(url, headers, timeout) as response:
|
|
402
|
+
if reading.start(response.status, response.headers):
|
|
403
|
+
for chunk in response.chunks:
|
|
404
|
+
if not reading.take(chunk):
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def _aread(
|
|
409
|
+
transport: AsyncTransport,
|
|
410
|
+
url: str,
|
|
411
|
+
headers: Mapping[str, str],
|
|
412
|
+
timeout: tuple[float, float],
|
|
413
|
+
reading: _Reading,
|
|
414
|
+
) -> None:
|
|
415
|
+
async with transport.send(url, headers, timeout) as response:
|
|
416
|
+
if reading.start(response.status, response.headers):
|
|
417
|
+
async for chunk in response.chunks:
|
|
418
|
+
if not reading.take(chunk):
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _lost(
|
|
423
|
+
attempt: Attempt, url: str, reading: _Reading, error: TransportError
|
|
424
|
+
) -> FetchFailed:
|
|
425
|
+
"""No answer, or one that broke off: what had arrived still counts."""
|
|
426
|
+
started = reading.status is not None
|
|
427
|
+
attempt.failure(
|
|
428
|
+
error.shape,
|
|
429
|
+
status=reading.status,
|
|
430
|
+
headers=reading.headers,
|
|
431
|
+
wire_bytes=reading.wire if started else None,
|
|
432
|
+
)
|
|
433
|
+
return FetchFailed(url, error.shape, reading.status)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _incomplete(attempt: Attempt, url: str, reading: _Reading) -> SourceNotReady:
|
|
437
|
+
error = SourceNotReady("body broke off before its end", url=url)
|
|
438
|
+
attempt.failure(
|
|
439
|
+
error.shape,
|
|
440
|
+
status=reading.status,
|
|
441
|
+
headers=reading.headers,
|
|
442
|
+
wire_bytes=reading.wire,
|
|
443
|
+
)
|
|
444
|
+
return error
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# --- judging the answer ---------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _judge_time(
|
|
451
|
+
gate: Gate, url: str, data_time: datetime, options: _Options
|
|
452
|
+
) -> StaleData | StuckSource | None:
|
|
453
|
+
"""Note the data time and tell whether the data is stale or stuck. Not
|
|
454
|
+
raised here: the answer is recorded as good first."""
|
|
455
|
+
record = gate.note_data_time(url, data_time)
|
|
456
|
+
now = gate.clock.now()
|
|
457
|
+
try:
|
|
458
|
+
if options.max_age is not None:
|
|
459
|
+
check_fresh(data_time, now=now, max_age=options.max_age, url=url)
|
|
460
|
+
if options.stuck_after is not None:
|
|
461
|
+
check_advancing(record, stuck_after=options.stuck_after, now=now, url=url)
|
|
462
|
+
except (StaleData, StuckSource) as late:
|
|
463
|
+
return late
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _decoded_size(
|
|
468
|
+
raw: bytes, headers: Mapping[str, str], options: _Options
|
|
469
|
+
) -> int | None:
|
|
470
|
+
"""The decoded size of a body nobody will use, if it decodes."""
|
|
471
|
+
try:
|
|
472
|
+
encoding = headers.get("content-encoding")
|
|
473
|
+
return len(decode_body(raw, encoding, max_bytes=options.max_bytes))
|
|
474
|
+
except ContentError:
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _finish(
|
|
479
|
+
gate: Gate, attempt: Attempt, url: str, reading: _Reading, options: _Options
|
|
480
|
+
) -> FetchResult:
|
|
481
|
+
"""Check one answer, record its outcome, and return it or raise."""
|
|
482
|
+
if reading.status is None:
|
|
483
|
+
raise RuntimeError("the transport ended without an answer")
|
|
484
|
+
status, headers, wire = reading.status, reading.headers, reading.wire
|
|
485
|
+
raw = b"".join(reading.pieces)
|
|
486
|
+
|
|
487
|
+
def reject(error: ContentError, decoded: int | None) -> ContentError:
|
|
488
|
+
attempt.failure(
|
|
489
|
+
error.shape,
|
|
490
|
+
status=status,
|
|
491
|
+
headers=headers,
|
|
492
|
+
wire_bytes=wire,
|
|
493
|
+
decoded_bytes=decoded,
|
|
494
|
+
)
|
|
495
|
+
return error
|
|
496
|
+
|
|
497
|
+
if status == 304:
|
|
498
|
+
size = _decoded_size(raw, headers, options) or 0
|
|
499
|
+
latest = None
|
|
500
|
+
judged = None
|
|
501
|
+
if options.data_time is not None:
|
|
502
|
+
record = gate.store.data_time_record(url)
|
|
503
|
+
if record is not None:
|
|
504
|
+
latest = record.latest
|
|
505
|
+
judged = _judge_time(gate, url, latest, options)
|
|
506
|
+
attempt.response(304, headers=headers, wire_bytes=wire, decoded_bytes=size)
|
|
507
|
+
result = FetchResult(url, 304, headers, b"", None, True, latest, wire, size)
|
|
508
|
+
return _deliver(result, judged)
|
|
509
|
+
if not 200 <= status < 300:
|
|
510
|
+
# The status is the failure: a body cut short changes nothing.
|
|
511
|
+
decoded_size = _decoded_size(raw, headers, options)
|
|
512
|
+
if 300 <= status < 400:
|
|
513
|
+
location = headers.get("location")
|
|
514
|
+
try:
|
|
515
|
+
if location:
|
|
516
|
+
check_redirect(url, urljoin(url, location))
|
|
517
|
+
raise UnexpectedContent(
|
|
518
|
+
f"redirect to {location!r} not followed: ask for that URL", url=url
|
|
519
|
+
)
|
|
520
|
+
except UnexpectedContent as error:
|
|
521
|
+
raise reject(error, decoded_size) from None
|
|
522
|
+
attempt.response(
|
|
523
|
+
status, headers=headers, wire_bytes=wire, decoded_bytes=decoded_size
|
|
524
|
+
)
|
|
525
|
+
raise FetchFailed(url, FailureShape.HTTP, status)
|
|
526
|
+
if reading.cut is FailureShape.DEADLINE:
|
|
527
|
+
attempt.failure(
|
|
528
|
+
FailureShape.DEADLINE, status=status, headers=headers, wire_bytes=wire
|
|
529
|
+
)
|
|
530
|
+
raise FetchFailed(url, FailureShape.DEADLINE, status)
|
|
531
|
+
if reading.cut == "too_large" and reading.limit is not None:
|
|
532
|
+
too_large = BodyTooLarge(f"body larger than {reading.limit[1]}", url=url)
|
|
533
|
+
raise reject(too_large, None)
|
|
534
|
+
decoded: int | None = None
|
|
535
|
+
try:
|
|
536
|
+
if status != 204:
|
|
537
|
+
check_length(
|
|
538
|
+
content_length=_content_length(headers), received=wire, url=url
|
|
539
|
+
)
|
|
540
|
+
body = decode_body(
|
|
541
|
+
raw, headers.get("content-encoding"), max_bytes=options.max_bytes, url=url
|
|
542
|
+
)
|
|
543
|
+
decoded = len(body)
|
|
544
|
+
content = check_body(
|
|
545
|
+
body,
|
|
546
|
+
expect=options.expect,
|
|
547
|
+
content_type=headers.get("content-type"),
|
|
548
|
+
allow_empty=options.allow_empty or status == 204,
|
|
549
|
+
url=url,
|
|
550
|
+
)
|
|
551
|
+
data_time = None
|
|
552
|
+
judged = None
|
|
553
|
+
if options.data_time is not None:
|
|
554
|
+
data_time = read_data_time(content, options.data_time, url=url)
|
|
555
|
+
now = gate.clock.now()
|
|
556
|
+
check_not_ahead(data_time, now=now, max_ahead=options.max_ahead, url=url)
|
|
557
|
+
judged = _judge_time(gate, url, data_time, options)
|
|
558
|
+
except ContentError as error:
|
|
559
|
+
raise reject(error, decoded) from None
|
|
560
|
+
attempt.response(status, headers=headers, wire_bytes=wire, decoded_bytes=decoded)
|
|
561
|
+
result = FetchResult(
|
|
562
|
+
url, status, headers, body, content, False, data_time, wire, len(body)
|
|
563
|
+
)
|
|
564
|
+
return _deliver(result, judged)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _deliver(
|
|
568
|
+
result: FetchResult, judged: StaleData | StuckSource | None
|
|
569
|
+
) -> FetchResult:
|
|
570
|
+
"""Return the result, or raise the stale or stuck error that carries it."""
|
|
571
|
+
if judged is None:
|
|
572
|
+
return result
|
|
573
|
+
judged.result = result
|
|
574
|
+
raise judged
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _note_freeze(gate: Gate, url: str, failed: FetchFailed) -> None:
|
|
578
|
+
"""If the answer left the host frozen, say so on the error, and how to
|
|
579
|
+
lift it."""
|
|
580
|
+
if failed.status is None:
|
|
581
|
+
return
|
|
582
|
+
host = host_of(url)
|
|
583
|
+
now = gate.clock.now()
|
|
584
|
+
freeze = gate.store.host_state(host, now).freeze
|
|
585
|
+
if freeze is None or not freeze.active(now):
|
|
586
|
+
return
|
|
587
|
+
lift = f"gate.unfreeze({host!r}, by=...)"
|
|
588
|
+
if freeze.until is None:
|
|
589
|
+
note = f"the host is now frozen until a person lifts it: {lift}"
|
|
590
|
+
else:
|
|
591
|
+
until = freeze.until.isoformat()
|
|
592
|
+
note = f"the host is now frozen until {until}; {lift} lifts it sooner"
|
|
593
|
+
failed.note = note
|
|
594
|
+
failed.args = (failed.url, failed.shape, failed.status, note)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# --- the fetchers ---------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class Fetcher:
|
|
601
|
+
"""Fetch through the gate with a synchronous transport."""
|
|
602
|
+
|
|
603
|
+
def __init__(
|
|
604
|
+
self,
|
|
605
|
+
gate: Gate,
|
|
606
|
+
transport: Transport,
|
|
607
|
+
*,
|
|
608
|
+
timeout: Timeout = (10.0, 30.0),
|
|
609
|
+
deadline: timedelta | str = DEFAULT_DEADLINE,
|
|
610
|
+
user_agent: str = USER_AGENT,
|
|
611
|
+
timer: Timer = time.monotonic,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""`timeout` is one number or (connect, read), in seconds; both must
|
|
614
|
+
be finite. `deadline` bounds the whole request, body included; with
|
|
615
|
+
the two timeouts it must end before `gate.reconcile_after`. `timer`
|
|
616
|
+
measures it."""
|
|
617
|
+
self.gate = gate
|
|
618
|
+
self.transport = transport
|
|
619
|
+
self.timeout = _timeouts(timeout)
|
|
620
|
+
self.deadline = _deadline(deadline, self.timeout, gate)
|
|
621
|
+
self.user_agent = user_agent
|
|
622
|
+
self.timer = timer
|
|
623
|
+
|
|
624
|
+
def get(
|
|
625
|
+
self,
|
|
626
|
+
url: str,
|
|
627
|
+
*,
|
|
628
|
+
expect: Expect = "json",
|
|
629
|
+
data_time: Callable[[Any], datetime] | None = None,
|
|
630
|
+
max_age: timedelta | str | None = None,
|
|
631
|
+
max_ahead: timedelta | str = DEFAULT_MAX_AHEAD,
|
|
632
|
+
stuck_after: int | timedelta | str | None = None,
|
|
633
|
+
headers: Mapping[str, str] | None = None,
|
|
634
|
+
allow_empty: bool = False,
|
|
635
|
+
max_bytes: int | str | None = None,
|
|
636
|
+
) -> FetchResult:
|
|
637
|
+
"""Fetch `url` once, if the gate allows it, and check what comes back.
|
|
638
|
+
|
|
639
|
+
`data_time` reads the data time from the parsed content. A data time
|
|
640
|
+
more than `max_ahead` after now is `UnexpectedContent`, and is not
|
|
641
|
+
kept. `max_age` flags data older than that (`StaleData`);
|
|
642
|
+
`stuck_after` flags a data time that has not advanced for that many
|
|
643
|
+
reads, or for that long (`StuckSource`). Both are recorded as
|
|
644
|
+
answers and carry the result. `max_bytes` bounds the body, as
|
|
645
|
+
transferred and as decoded; reading also stops at what is left of the
|
|
646
|
+
host's byte budget. `headers` are sent too, and replace ours of the
|
|
647
|
+
same name. Raises `Refused`, `FetchFailed` (whose message says if the
|
|
648
|
+
answer froze the host, and how to lift it) or a `ContentError`.
|
|
649
|
+
"""
|
|
650
|
+
options = _Options.build(
|
|
651
|
+
expect, data_time, max_age, max_ahead, stuck_after, allow_empty, max_bytes
|
|
652
|
+
)
|
|
653
|
+
try:
|
|
654
|
+
with self.gate.attempt(url) as attempt:
|
|
655
|
+
sent = _request_headers(attempt, self.user_agent, headers)
|
|
656
|
+
limit = _limit(attempt, options.max_bytes)
|
|
657
|
+
reading = _Reading(self.timer, self.deadline, limit)
|
|
658
|
+
try:
|
|
659
|
+
_read(self.transport, url, sent, self.timeout, reading)
|
|
660
|
+
except TransportError as error:
|
|
661
|
+
raise _lost(attempt, url, reading, error) from error
|
|
662
|
+
except IncompleteBody as incomplete:
|
|
663
|
+
raise _incomplete(attempt, url, reading) from incomplete
|
|
664
|
+
return _finish(self.gate, attempt, url, reading, options)
|
|
665
|
+
except FetchFailed as failed:
|
|
666
|
+
_note_freeze(self.gate, url, failed)
|
|
667
|
+
raise
|
|
668
|
+
|
|
669
|
+
def close(self) -> None:
|
|
670
|
+
self.transport.close()
|
|
671
|
+
|
|
672
|
+
def __enter__(self) -> Self:
|
|
673
|
+
return self
|
|
674
|
+
|
|
675
|
+
def __exit__(
|
|
676
|
+
self,
|
|
677
|
+
exc_type: type[BaseException] | None,
|
|
678
|
+
exc: BaseException | None,
|
|
679
|
+
tb: TracebackType | None,
|
|
680
|
+
) -> None:
|
|
681
|
+
self.close()
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class AsyncFetcher:
|
|
685
|
+
"""Fetch through the gate with an asynchronous transport. Same meaning
|
|
686
|
+
as `Fetcher`; store work runs in a worker thread."""
|
|
687
|
+
|
|
688
|
+
def __init__(
|
|
689
|
+
self,
|
|
690
|
+
gate: Gate,
|
|
691
|
+
transport: AsyncTransport,
|
|
692
|
+
*,
|
|
693
|
+
timeout: Timeout = (10.0, 30.0),
|
|
694
|
+
deadline: timedelta | str = DEFAULT_DEADLINE,
|
|
695
|
+
user_agent: str = USER_AGENT,
|
|
696
|
+
timer: Timer = time.monotonic,
|
|
697
|
+
) -> None:
|
|
698
|
+
self.gate = gate
|
|
699
|
+
self.transport = transport
|
|
700
|
+
self.timeout = _timeouts(timeout)
|
|
701
|
+
self.deadline = _deadline(deadline, self.timeout, gate)
|
|
702
|
+
self.user_agent = user_agent
|
|
703
|
+
self.timer = timer
|
|
704
|
+
|
|
705
|
+
async def get(
|
|
706
|
+
self,
|
|
707
|
+
url: str,
|
|
708
|
+
*,
|
|
709
|
+
expect: Expect = "json",
|
|
710
|
+
data_time: Callable[[Any], datetime] | None = None,
|
|
711
|
+
max_age: timedelta | str | None = None,
|
|
712
|
+
max_ahead: timedelta | str = DEFAULT_MAX_AHEAD,
|
|
713
|
+
stuck_after: int | timedelta | str | None = None,
|
|
714
|
+
headers: Mapping[str, str] | None = None,
|
|
715
|
+
allow_empty: bool = False,
|
|
716
|
+
max_bytes: int | str | None = None,
|
|
717
|
+
) -> FetchResult:
|
|
718
|
+
options = _Options.build(
|
|
719
|
+
expect, data_time, max_age, max_ahead, stuck_after, allow_empty, max_bytes
|
|
720
|
+
)
|
|
721
|
+
try:
|
|
722
|
+
async with self.gate.attempt(url) as attempt:
|
|
723
|
+
sent = _request_headers(attempt, self.user_agent, headers)
|
|
724
|
+
limit = _limit(attempt, options.max_bytes)
|
|
725
|
+
reading = _Reading(self.timer, self.deadline, limit)
|
|
726
|
+
try:
|
|
727
|
+
await _aread(self.transport, url, sent, self.timeout, reading)
|
|
728
|
+
except TransportError as error:
|
|
729
|
+
raise _lost(attempt, url, reading, error) from error
|
|
730
|
+
except IncompleteBody as incomplete:
|
|
731
|
+
raise _incomplete(attempt, url, reading) from incomplete
|
|
732
|
+
return await asyncio.to_thread(
|
|
733
|
+
_finish, self.gate, attempt, url, reading, options
|
|
734
|
+
)
|
|
735
|
+
except FetchFailed as failed:
|
|
736
|
+
await asyncio.to_thread(_note_freeze, self.gate, url, failed)
|
|
737
|
+
raise
|
|
738
|
+
|
|
739
|
+
async def aclose(self) -> None:
|
|
740
|
+
await self.transport.aclose()
|
|
741
|
+
|
|
742
|
+
async def __aenter__(self) -> Self:
|
|
743
|
+
return self
|
|
744
|
+
|
|
745
|
+
async def __aexit__(
|
|
746
|
+
self,
|
|
747
|
+
exc_type: type[BaseException] | None,
|
|
748
|
+
exc: BaseException | None,
|
|
749
|
+
tb: TracebackType | None,
|
|
750
|
+
) -> None:
|
|
751
|
+
await self.aclose()
|