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