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.
- politeclient/__init__.py +47 -0
- politeclient/cache.py +360 -0
- politeclient/client.py +448 -0
- politeclient/decorator.py +43 -0
- politeclient/exceptions.py +44 -0
- politeclient/logging_utils.py +90 -0
- politeclient/pagination.py +126 -0
- politeclient/py.typed +0 -0
- politeclient/ratelimit.py +131 -0
- politeclient/retry.py +118 -0
- politeclient-0.1.0.dist-info/METADATA +273 -0
- politeclient-0.1.0.dist-info/RECORD +14 -0
- politeclient-0.1.0.dist-info/WHEEL +4 -0
- politeclient-0.1.0.dist-info/licenses/LICENSE +21 -0
politeclient/__init__.py
ADDED
|
@@ -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
|