politeclient 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,47 @@
1
+ """politeclient — a polite, careful, well-behaved HTTP client for Python.
2
+
3
+ A thin, well-behaved wrapper around ``requests`` that bundles everything people
4
+ forget when calling APIs: retries with backoff + jitter, ``Retry-After``
5
+ support, a per-host rate-limit governor, sane default headers, an optional disk
6
+ cache, pagination helpers, sensible timeouts and structured logging.
7
+
8
+ from politeclient import PoliteClient, RateLimit, RetryPolicy
9
+
10
+ with PoliteClient(rate_limit=RateLimit(rate=5)) as client:
11
+ data = client.get("https://api.example.com/things").json()
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ from .cache import CachedResponse, DiskCache
19
+ from .client import DEFAULT_USER_AGENT, PoliteClient
20
+ from .decorator import polite
21
+ from .exceptions import (
22
+ PoliteError,
23
+ RateLimitConfigError,
24
+ RetryBudgetExceeded,
25
+ )
26
+ from .pagination import dig, paginate_cursor, paginate_offset
27
+ from .ratelimit import RateLimit, TokenBucket
28
+ from .retry import RetryPolicy, parse_retry_after
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "PoliteClient",
33
+ "RateLimit",
34
+ "RetryPolicy",
35
+ "TokenBucket",
36
+ "DiskCache",
37
+ "CachedResponse",
38
+ "polite",
39
+ "paginate_offset",
40
+ "paginate_cursor",
41
+ "dig",
42
+ "parse_retry_after",
43
+ "PoliteError",
44
+ "RetryBudgetExceeded",
45
+ "RateLimitConfigError",
46
+ "DEFAULT_USER_AGENT",
47
+ ]
politeclient/cache.py ADDED
@@ -0,0 +1,360 @@
1
+ """Optional on-disk cache for GET responses.
2
+
3
+ A dead-simple, dependency-free file cache keyed by request identity. It only
4
+ ever stores successful, cacheable GET responses and honours a TTL. This is *not*
5
+ a full HTTP caching layer (no ETag/Last-Modified revalidation) — it is the "I'm
6
+ iterating on a scraper and don't want to hammer the API every run" cache, which
7
+ is exactly the cache people hand-roll badly.
8
+
9
+ Because entries are plain JSON files in a directory the caller picks, the cache
10
+ is deliberately conservative about what it is willing to write and to reuse:
11
+
12
+ * only an **allowlist** of response headers reaches disk
13
+ (:data:`CACHEABLE_RESPONSE_HEADERS`) — ``Set-Cookie``, ``Authorization`` and
14
+ every other credential-bearing header is dropped;
15
+ * a response with ``Cache-Control: no-store`` is not written at all
16
+ (RFC 9111 §5.2.2.5 applies that directive to private caches too);
17
+ * a response with a non-empty ``Vary`` is not written either, because this cache
18
+ keys on method + URL + params and therefore cannot tell two variants apart
19
+ (RFC 9111 §4.1);
20
+ * the server's declared freshness (``Cache-Control: max-age``, ``Expires``) is
21
+ an **upper bound** on the local TTL — the shorter of the two wins.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import re
31
+ import tempfile
32
+ import time
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any, Dict, FrozenSet, Mapping, Optional
36
+
37
+ from .retry import parse_retry_after
38
+
39
+
40
+ #: Response headers that may be persisted. This is an allowlist on purpose: a
41
+ #: header nobody thought about is discarded rather than written to disk. In
42
+ #: particular ``Set-Cookie``, ``Authorization``, ``Proxy-Authorization`` and
43
+ #: ``WWW-Authenticate`` never reach a cache file.
44
+ #: Request headers that identify *who* is asking. A response fetched with any of
45
+ #: these is treated as unshareable: the cache key is built from method, URL and
46
+ #: params only, so two callers with different credentials would otherwise collide
47
+ #: on the same entry and one could be served the other's personalised response.
48
+ #: Servers are supposed to mark such responses ``no-store`` or ``Vary``, but many
49
+ #: do not, so the safe default is not to cache them at all.
50
+ CREDENTIAL_REQUEST_HEADERS: FrozenSet[str] = frozenset(
51
+ {"authorization", "cookie", "proxy-authorization", "www-authenticate"}
52
+ )
53
+
54
+
55
+ def carries_credentials(headers: Optional[Mapping[str, Any]]) -> bool:
56
+ """True if *headers* identify a specific caller.
57
+
58
+ Used to skip the cache entirely for authenticated requests. Matching is
59
+ case-insensitive, as HTTP header names are.
60
+ """
61
+ if not headers:
62
+ return False
63
+ return any(str(name).lower() in CREDENTIAL_REQUEST_HEADERS for name in headers)
64
+
65
+
66
+ CACHEABLE_RESPONSE_HEADERS: FrozenSet[str] = frozenset(
67
+ {
68
+ "content-type",
69
+ "content-encoding",
70
+ "etag",
71
+ "last-modified",
72
+ "date",
73
+ "vary",
74
+ }
75
+ )
76
+
77
+
78
+ def filter_response_headers(headers: Mapping[str, str]) -> Dict[str, str]:
79
+ """Return only the headers this cache is willing to persist.
80
+
81
+ Matching is case-insensitive; the original spelling of a kept header is
82
+ preserved.
83
+ """
84
+ return {
85
+ name: value
86
+ for name, value in headers.items()
87
+ if name.lower() in CACHEABLE_RESPONSE_HEADERS
88
+ }
89
+
90
+
91
+ def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
92
+ """Case-insensitive header lookup (``headers`` may be a plain ``dict``)."""
93
+ target = name.lower()
94
+ for key, value in headers.items():
95
+ if key.lower() == target:
96
+ return value
97
+ return None
98
+
99
+
100
+ def parse_cache_control(headers: Mapping[str, str]) -> Dict[str, str]:
101
+ """Parse ``Cache-Control`` into ``{directive: value}`` (values may be "")."""
102
+ raw = _header(headers, "Cache-Control")
103
+ if not raw:
104
+ return {}
105
+ directives: Dict[str, str] = {}
106
+ for part in raw.split(","):
107
+ part = part.strip()
108
+ if not part:
109
+ continue
110
+ name, _, value = part.partition("=")
111
+ directives[name.strip().lower()] = value.strip().strip('"')
112
+ return directives
113
+
114
+
115
+ def _expires_in(value: str) -> float:
116
+ """Seconds of freshness left according to an ``Expires`` header value.
117
+
118
+ ``Expires`` only takes the HTTP-date form. RFC 9111 §5.3 says an invalid
119
+ value (the common ``Expires: 0``, for instance) means *already expired*, so
120
+ anything we cannot read as a date yields ``0.0``.
121
+ """
122
+ value = value.strip()
123
+ if not value or value.lstrip("+-").isdigit():
124
+ return 0.0
125
+ seconds = parse_retry_after(value) # same HTTP-date parsing as Retry-After
126
+ return 0.0 if seconds is None else seconds
127
+
128
+
129
+ def server_freshness(headers: Mapping[str, str]) -> Optional[float]:
130
+ """How long the *server* says this response stays fresh, in seconds.
131
+
132
+ ``Cache-Control: max-age`` wins over ``Expires`` (RFC 9111 §4.2.1), and an
133
+ ``Age`` header is subtracted from it so a response that was already sitting
134
+ in an upstream cache does not get a full lifetime here. Returns ``None`` when
135
+ the server declared nothing, in which case the local TTL is all we have.
136
+ """
137
+ directives = parse_cache_control(headers)
138
+ if "no-cache" in directives:
139
+ # We cannot revalidate, so "don't reuse without revalidating" can only
140
+ # mean "never reuse".
141
+ return 0.0
142
+ if "max-age" in directives:
143
+ try:
144
+ lifetime = float(int(directives["max-age"]))
145
+ except ValueError:
146
+ lifetime = None
147
+ if lifetime is not None:
148
+ age = 0.0
149
+ raw_age = _header(headers, "Age")
150
+ if raw_age:
151
+ try:
152
+ age = max(0.0, float(int(raw_age.strip())))
153
+ except ValueError:
154
+ age = 0.0
155
+ return max(0.0, lifetime - age)
156
+ expires = _header(headers, "Expires")
157
+ if expires is not None:
158
+ return _expires_in(expires)
159
+ return None
160
+
161
+
162
+ def is_storable(headers: Mapping[str, str]) -> Optional[str]:
163
+ """Return ``None`` if the response may be stored, else why it may not.
164
+
165
+ Two refusals, both about correctness rather than taste:
166
+
167
+ * ``Cache-Control: no-store`` — RFC 9111 §5.2.2.5 binds private caches too.
168
+ * a non-empty ``Vary`` — this cache keys on method + URL + params only, so it
169
+ has no way to tell one variant from another. Storing such a response and
170
+ replaying it for a different request is exactly what RFC 9111 §4.1
171
+ forbids, and without revalidation the only safe move is not to store it.
172
+ """
173
+ if "no-store" in parse_cache_control(headers):
174
+ return "no-store"
175
+ vary = _header(headers, "Vary")
176
+ if vary is not None and vary.strip():
177
+ return "vary"
178
+ return None
179
+
180
+
181
+ #: Every entry file is named ``<sha256 hex digest>.json`` (see
182
+ #: :meth:`DiskCache.make_key`); :meth:`DiskCache.clear` only removes those.
183
+ _ENTRY_STEM = re.compile(r"[0-9a-f]{64}")
184
+
185
+
186
+ @dataclass
187
+ class CachedResponse:
188
+ """A response reconstructed from the cache."""
189
+
190
+ status_code: int
191
+ headers: Dict[str, str]
192
+ content: bytes
193
+ url: str
194
+ created_at: float
195
+
196
+ @property
197
+ def text(self) -> str:
198
+ return self.content.decode("utf-8", errors="replace")
199
+
200
+ def json(self) -> Any:
201
+ return json.loads(self.content)
202
+
203
+ @property
204
+ def age(self) -> float:
205
+ return time.time() - self.created_at
206
+
207
+
208
+ class DiskCache:
209
+ """Content-addressed cache stored as one JSON file per entry.
210
+
211
+ Entries are unencrypted JSON in ``directory``; the caller owns that path and
212
+ its permissions. Only the headers in :data:`CACHEABLE_RESPONSE_HEADERS` are
213
+ written — see the module docstring for what is refused and why.
214
+
215
+ Args:
216
+ directory: Where to store entries (created if missing).
217
+ ttl: Default time-to-live in seconds. ``None`` means never expire. The
218
+ server's own ``max-age``/``Expires`` can only make it shorter.
219
+ """
220
+
221
+ def __init__(self, directory: str | os.PathLike[str], *, ttl: Optional[float] = 3600.0) -> None:
222
+ self.directory = Path(directory).expanduser()
223
+ self.directory.mkdir(parents=True, exist_ok=True)
224
+ self.ttl = ttl
225
+
226
+ @staticmethod
227
+ def make_key(method: str, url: str, params: Optional[Mapping[str, Any]] = None) -> str:
228
+ """A stable cache key for a request.
229
+
230
+ Params are sorted so ``?a=1&b=2`` and ``?b=2&a=1`` share an entry.
231
+
232
+ Request headers are deliberately *not* part of the key: keeping them out
233
+ means no credential ever reaches a cache filename, and it is why a
234
+ response that varies by header is refused at write time instead.
235
+ """
236
+ canonical = {
237
+ "method": method.upper(),
238
+ "url": url,
239
+ "params": sorted((str(k), str(v)) for k, v in (params or {}).items()),
240
+ }
241
+ blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
242
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
243
+
244
+ def _path_for(self, key: str) -> Path:
245
+ return self.directory / f"{key}.json"
246
+
247
+ def get(self, key: str, *, ttl: Optional[float] = None) -> Optional[CachedResponse]:
248
+ """Return a cached response, or ``None`` on miss/expiry.
249
+
250
+ An expired entry is deleted eagerly so the directory self-cleans. The
251
+ effective TTL is the *smaller* of the local TTL and whatever freshness
252
+ the server declared when the entry was stored.
253
+ """
254
+ path = self._path_for(key)
255
+ try:
256
+ raw = path.read_text(encoding="utf-8")
257
+ except (FileNotFoundError, OSError):
258
+ return None
259
+ try:
260
+ data = json.loads(raw)
261
+ except json.JSONDecodeError:
262
+ # Corrupt entry — treat as a miss and remove it.
263
+ self._safe_unlink(path)
264
+ return None
265
+
266
+ headers = dict(data.get("headers", {}))
267
+ # Belt and braces: entries with a Vary are refused at write time, so one
268
+ # here was written by hand or by an older version. Serving it could hand
269
+ # back the wrong variant, and we cannot revalidate — drop it.
270
+ vary = _header(headers, "Vary")
271
+ if vary is not None and vary.strip():
272
+ self._safe_unlink(path)
273
+ return None
274
+
275
+ effective_ttl = ttl if ttl is not None else self.ttl
276
+ server_ttl = data.get("server_ttl")
277
+ if server_ttl is not None:
278
+ server_ttl = float(server_ttl)
279
+ effective_ttl = (
280
+ server_ttl if effective_ttl is None else min(effective_ttl, server_ttl)
281
+ )
282
+
283
+ created_at = float(data.get("created_at", 0.0))
284
+ # ``>=`` rather than ``>``: an entry is fresh while its age is *below*
285
+ # the TTL, so a TTL of zero (``max-age=0``, ``no-cache``, a past or
286
+ # invalid ``Expires``) always misses — even when the read lands in the
287
+ # same clock tick as the write, which is reachable on platforms whose
288
+ # ``time.time()`` is coarse.
289
+ if effective_ttl is not None and (time.time() - created_at) >= effective_ttl:
290
+ self._safe_unlink(path)
291
+ return None
292
+
293
+ return CachedResponse(
294
+ status_code=int(data["status_code"]),
295
+ headers=headers,
296
+ content=base64.b64decode(data["content"]),
297
+ url=data.get("url", ""),
298
+ created_at=created_at,
299
+ )
300
+
301
+ def set(
302
+ self,
303
+ key: str,
304
+ *,
305
+ status_code: int,
306
+ headers: Mapping[str, str],
307
+ content: bytes,
308
+ url: str,
309
+ ) -> bool:
310
+ """Store a response atomically (write-to-temp then rename).
311
+
312
+ Returns ``True`` if the entry was written, ``False`` if the response was
313
+ refused (``no-store`` or a non-empty ``Vary`` — see :func:`is_storable`).
314
+ Only allowlisted headers are persisted; ``headers`` should be the full
315
+ response headers so the directives above can be read from them.
316
+ """
317
+ if is_storable(headers) is not None:
318
+ return False
319
+
320
+ payload = {
321
+ "status_code": status_code,
322
+ "headers": filter_response_headers(headers),
323
+ "content": base64.b64encode(content).decode("ascii"),
324
+ "url": url,
325
+ "created_at": time.time(),
326
+ # Upper bound on freshness declared by the server, if any.
327
+ "server_ttl": server_freshness(headers),
328
+ }
329
+ path = self._path_for(key)
330
+ # Atomic write: never leave a half-written entry that a reader could see.
331
+ fd, tmp = tempfile.mkstemp(dir=self.directory, suffix=".tmp")
332
+ try:
333
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
334
+ json.dump(payload, fh, separators=(",", ":"))
335
+ os.replace(tmp, path)
336
+ except BaseException:
337
+ self._safe_unlink(Path(tmp))
338
+ raise
339
+ return True
340
+
341
+ def clear(self) -> int:
342
+ """Delete every cache entry. Returns the number removed.
343
+
344
+ Only files named like an entry — ``<64 hex chars>.json``, the shape
345
+ :meth:`make_key` always produces — are touched. Anything else that
346
+ happens to live in the directory is left alone.
347
+ """
348
+ removed = 0
349
+ for path in self.directory.glob("*.json"):
350
+ if _ENTRY_STEM.fullmatch(path.stem) and self._safe_unlink(path):
351
+ removed += 1
352
+ return removed
353
+
354
+ @staticmethod
355
+ def _safe_unlink(path: Path) -> bool:
356
+ try:
357
+ path.unlink()
358
+ return True
359
+ except OSError:
360
+ return False