decdn 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.
- decdn/__init__.py +129 -0
- decdn/_core.py +324 -0
- decdn/_hex.py +23 -0
- decdn/_http.py +88 -0
- decdn/_mime.py +68 -0
- decdn/_verify.py +119 -0
- decdn/_version.py +1 -0
- decdn/base32.py +54 -0
- decdn/capability.py +109 -0
- decdn/client.py +425 -0
- decdn/errors.py +74 -0
- decdn/hashing.py +28 -0
- decdn/local.py +32 -0
- decdn/loopback.py +146 -0
- decdn/manifest.py +130 -0
- decdn/pointer.py +351 -0
- decdn/py.typed +0 -0
- decdn/response.py +221 -0
- decdn/uri.py +181 -0
- decdn-0.1.0.dist-info/METADATA +349 -0
- decdn-0.1.0.dist-info/RECORD +24 -0
- decdn-0.1.0.dist-info/WHEEL +4 -0
- decdn-0.1.0.dist-info/licenses/LICENSE-APACHE +201 -0
- decdn-0.1.0.dist-info/licenses/LICENSE-MIT +19 -0
decdn/__init__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Python client for deCDN over a decdn gateway."""
|
|
2
|
+
|
|
3
|
+
from decdn._version import __version__
|
|
4
|
+
from decdn.base32 import (
|
|
5
|
+
base32_to_hash,
|
|
6
|
+
hash_to_base32,
|
|
7
|
+
)
|
|
8
|
+
from decdn.capability import (
|
|
9
|
+
Capability,
|
|
10
|
+
parse_capability,
|
|
11
|
+
)
|
|
12
|
+
from decdn.client import (
|
|
13
|
+
AsyncClient,
|
|
14
|
+
ByteRange,
|
|
15
|
+
Client,
|
|
16
|
+
Context,
|
|
17
|
+
HeadInfo,
|
|
18
|
+
Resolved,
|
|
19
|
+
VerifyMode,
|
|
20
|
+
)
|
|
21
|
+
from decdn.errors import (
|
|
22
|
+
DecdnError,
|
|
23
|
+
ErrorCode,
|
|
24
|
+
)
|
|
25
|
+
from decdn.hashing import (
|
|
26
|
+
Blake3Hasher,
|
|
27
|
+
blake3_hex,
|
|
28
|
+
create_blake3,
|
|
29
|
+
)
|
|
30
|
+
from decdn.loopback import (
|
|
31
|
+
AsyncLoopbackApi,
|
|
32
|
+
Delegate,
|
|
33
|
+
LoopbackApi,
|
|
34
|
+
RegisteredCapability,
|
|
35
|
+
Registration,
|
|
36
|
+
)
|
|
37
|
+
from decdn.manifest import (
|
|
38
|
+
MAX_MANIFEST_BYTES,
|
|
39
|
+
BundleManifest,
|
|
40
|
+
ManifestChunk,
|
|
41
|
+
ManifestEntry,
|
|
42
|
+
parse_manifest,
|
|
43
|
+
)
|
|
44
|
+
from decdn.pointer import (
|
|
45
|
+
AsyncPointerApi,
|
|
46
|
+
Pointer,
|
|
47
|
+
PointerApi,
|
|
48
|
+
PointerDomain,
|
|
49
|
+
PointerSet,
|
|
50
|
+
PointerSigner,
|
|
51
|
+
PointerTypedData,
|
|
52
|
+
TypedDataSigner,
|
|
53
|
+
pointer_signing_hash,
|
|
54
|
+
pointer_typed_data,
|
|
55
|
+
recover_pointer_signer,
|
|
56
|
+
sign_pointer,
|
|
57
|
+
)
|
|
58
|
+
from decdn.response import (
|
|
59
|
+
AsyncResponse,
|
|
60
|
+
ContentRange,
|
|
61
|
+
Response,
|
|
62
|
+
)
|
|
63
|
+
from decdn.uri import (
|
|
64
|
+
SCHEME,
|
|
65
|
+
DecdnUri,
|
|
66
|
+
UriKind,
|
|
67
|
+
format_uri,
|
|
68
|
+
parse_uri,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"__version__",
|
|
73
|
+
# base32
|
|
74
|
+
"base32_to_hash",
|
|
75
|
+
"hash_to_base32",
|
|
76
|
+
# capability
|
|
77
|
+
"Capability",
|
|
78
|
+
"parse_capability",
|
|
79
|
+
# client
|
|
80
|
+
"AsyncClient",
|
|
81
|
+
"ByteRange",
|
|
82
|
+
"Client",
|
|
83
|
+
"Context",
|
|
84
|
+
"HeadInfo",
|
|
85
|
+
"Resolved",
|
|
86
|
+
"VerifyMode",
|
|
87
|
+
# errors
|
|
88
|
+
"DecdnError",
|
|
89
|
+
"ErrorCode",
|
|
90
|
+
# hashing
|
|
91
|
+
"Blake3Hasher",
|
|
92
|
+
"blake3_hex",
|
|
93
|
+
"create_blake3",
|
|
94
|
+
# loopback
|
|
95
|
+
"AsyncLoopbackApi",
|
|
96
|
+
"Delegate",
|
|
97
|
+
"LoopbackApi",
|
|
98
|
+
"RegisteredCapability",
|
|
99
|
+
"Registration",
|
|
100
|
+
# manifest
|
|
101
|
+
"MAX_MANIFEST_BYTES",
|
|
102
|
+
"BundleManifest",
|
|
103
|
+
"ManifestChunk",
|
|
104
|
+
"ManifestEntry",
|
|
105
|
+
"parse_manifest",
|
|
106
|
+
# pointer
|
|
107
|
+
"AsyncPointerApi",
|
|
108
|
+
"Pointer",
|
|
109
|
+
"PointerApi",
|
|
110
|
+
"PointerDomain",
|
|
111
|
+
"PointerSet",
|
|
112
|
+
"PointerSigner",
|
|
113
|
+
"PointerTypedData",
|
|
114
|
+
"TypedDataSigner",
|
|
115
|
+
"pointer_signing_hash",
|
|
116
|
+
"pointer_typed_data",
|
|
117
|
+
"recover_pointer_signer",
|
|
118
|
+
"sign_pointer",
|
|
119
|
+
# response
|
|
120
|
+
"AsyncResponse",
|
|
121
|
+
"ContentRange",
|
|
122
|
+
"Response",
|
|
123
|
+
# uri
|
|
124
|
+
"SCHEME",
|
|
125
|
+
"DecdnUri",
|
|
126
|
+
"UriKind",
|
|
127
|
+
"format_uri",
|
|
128
|
+
"parse_uri",
|
|
129
|
+
]
|
decdn/_core.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The sans-I/O core of both clients: context selection, URLs, headers, the request plan of `get`,
|
|
2
|
+
and what an answer's status and headers mean for its body.
|
|
3
|
+
|
|
4
|
+
Nothing here touches the network. `Client` and `AsyncClient` parse, plan with this core, send,
|
|
5
|
+
and hand the answer back to it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from decdn._hex import is_hash
|
|
17
|
+
from decdn._mime import rewritten, site_type
|
|
18
|
+
from decdn._verify import GuardOptions
|
|
19
|
+
from decdn.base32 import hash_to_base32
|
|
20
|
+
from decdn.errors import DecdnError
|
|
21
|
+
from decdn.manifest import BundleManifest, ManifestEntry
|
|
22
|
+
from decdn.pointer import PointerDomain
|
|
23
|
+
from decdn.uri import SCHEME, DecdnUri, UriKind, format_uri, parse_uri
|
|
24
|
+
|
|
25
|
+
Context = Literal["public", "loopback"]
|
|
26
|
+
"""Where the client reaches the gateway: a public gateway, or the local one with its token."""
|
|
27
|
+
|
|
28
|
+
VerifyMode = Literal["auto"] | bool
|
|
29
|
+
"""`"auto"` verifies what can be verified; `True` refuses what cannot; `False` verifies nothing."""
|
|
30
|
+
|
|
31
|
+
Verified = bool | Literal["skipped"]
|
|
32
|
+
|
|
33
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
34
|
+
_ETAG = re.compile(r'"?([0-9a-fA-F]{64})"?')
|
|
35
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "[::1]"})
|
|
36
|
+
_RETRYABLE = frozenset({"PAYMENT_REQUIRED", "POOL_UNFUNDED", "RATE_LIMITED"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ByteRange:
|
|
41
|
+
"""A byte window: `length` bytes from `offset`, or to the end without `length`."""
|
|
42
|
+
|
|
43
|
+
offset: int
|
|
44
|
+
length: int | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class HeadInfo:
|
|
49
|
+
"""What `head` knows about content without fetching it."""
|
|
50
|
+
|
|
51
|
+
size: int | None = None
|
|
52
|
+
hash: str | None = None
|
|
53
|
+
content_type: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Resolved:
|
|
58
|
+
"""What a URI names, once its manifest is read."""
|
|
59
|
+
|
|
60
|
+
kind: UriKind
|
|
61
|
+
# The URI's hash: of the blob, or of the bundle manifest.
|
|
62
|
+
hash: str
|
|
63
|
+
# The URI on the gateway's bare host.
|
|
64
|
+
bare_url: str
|
|
65
|
+
# The manifest entry an entry URI names.
|
|
66
|
+
entry: ManifestEntry | None = None
|
|
67
|
+
# The bundle or entry on its site origin.
|
|
68
|
+
site_url: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Public through `decdn.client`, which re-exports them: reprs, docs and pickles name it.
|
|
72
|
+
for _cls in (ByteRange, HeadInfo, Resolved):
|
|
73
|
+
_cls.__module__ = "decdn.client"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Plan:
|
|
78
|
+
"""One request `get` sends, and what its body must hash to."""
|
|
79
|
+
|
|
80
|
+
url: str
|
|
81
|
+
hash: str | None
|
|
82
|
+
length: int | None
|
|
83
|
+
verified: Verified
|
|
84
|
+
# The entry by its hash, unrewritten: where a verified entry comes from when its site answers
|
|
85
|
+
# with a type the gateway rewrites.
|
|
86
|
+
blob_url: str | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def config(message: str) -> DecdnError:
|
|
90
|
+
return DecdnError("CONFIG", message)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _digits(value: str | None) -> int | None:
|
|
94
|
+
"""Return `value` as an int when it is ASCII digits only, else `None`."""
|
|
95
|
+
return int(value) if value is not None and _DIGITS.fullmatch(value) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse(uri: str | DecdnUri) -> DecdnUri:
|
|
99
|
+
"""Return `uri` parsed; a `DecdnUri` as is."""
|
|
100
|
+
return parse_uri(uri) if isinstance(uri, str) else uri
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def tail(uri: DecdnUri) -> str:
|
|
104
|
+
"""Return `uri` without `decdn:` and the hash: `[/<path>][?ns=<id>]`, canonically encoded."""
|
|
105
|
+
return format_uri(uri)[len(SCHEME) + 64 :]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def entry_of(manifest: BundleManifest, uri: DecdnUri) -> ManifestEntry:
|
|
109
|
+
"""Return the manifest entry `uri` names; `NOT_FOUND` when the bundle has none."""
|
|
110
|
+
entry = manifest.entry(uri.path or "")
|
|
111
|
+
if entry is None:
|
|
112
|
+
named = json.dumps(uri.path, ensure_ascii=False)
|
|
113
|
+
raise DecdnError("NOT_FOUND", f"bundle {uri.hash} has no entry {named}")
|
|
114
|
+
return entry
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def retry_wait(error: DecdnError, retry: bool | float) -> int | None:
|
|
118
|
+
"""Return the seconds to wait before the one retry `retry` asks for, or `None` for no retry.
|
|
119
|
+
|
|
120
|
+
A float caps the wait: a longer `Retry-After` is not waited out.
|
|
121
|
+
"""
|
|
122
|
+
if retry is False or error.code not in _RETRYABLE or error.retry_after_secs is None:
|
|
123
|
+
return None
|
|
124
|
+
if not isinstance(retry, bool) and error.retry_after_secs > retry:
|
|
125
|
+
return None
|
|
126
|
+
return error.retry_after_secs
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Core:
|
|
130
|
+
"""A client's configuration, its manifest cache, and everything it decides without I/O."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
gateway_url: str,
|
|
135
|
+
*,
|
|
136
|
+
token: str | None,
|
|
137
|
+
host: str | None,
|
|
138
|
+
pointer: PointerDomain | None,
|
|
139
|
+
verify: VerifyMode,
|
|
140
|
+
manifest_cache_size: int,
|
|
141
|
+
) -> None:
|
|
142
|
+
try:
|
|
143
|
+
gateway = httpx.URL(gateway_url)
|
|
144
|
+
except httpx.InvalidURL as e:
|
|
145
|
+
raise config(f"not a gateway URL: {gateway_url}") from e
|
|
146
|
+
port = gateway.port
|
|
147
|
+
if gateway.scheme == "" or gateway.raw_host == b"" or (port is not None and not 0 <= port <= 65535):
|
|
148
|
+
raise config(f"not a gateway URL: {gateway_url}")
|
|
149
|
+
hostname = gateway.raw_host.decode("ascii")
|
|
150
|
+
self.context: Context = "loopback" if token is not None or hostname in _LOOPBACK_HOSTS else "public"
|
|
151
|
+
if token is not None and (host is None or host == ""):
|
|
152
|
+
raise config(
|
|
153
|
+
"a loopback token needs `host`: the host whose capability pays, sent as x-decdn-host"
|
|
154
|
+
)
|
|
155
|
+
self.scheme = gateway.scheme
|
|
156
|
+
self.port = port
|
|
157
|
+
bracketed = f"[{hostname}]" if ":" in hostname else hostname
|
|
158
|
+
# `host[:port]` of the gateway, the port only when it is not the scheme's default.
|
|
159
|
+
self.authority = bracketed if port is None else f"{bracketed}:{port}"
|
|
160
|
+
self.origin = f"{self.scheme}://{self.authority}"
|
|
161
|
+
self.token = token
|
|
162
|
+
self.host = host
|
|
163
|
+
self.pointer = pointer
|
|
164
|
+
self.verify: VerifyMode = verify
|
|
165
|
+
self.cache_size = manifest_cache_size
|
|
166
|
+
self.manifests: OrderedDict[str, BundleManifest] = OrderedDict()
|
|
167
|
+
|
|
168
|
+
def cache_get(self, hash: str) -> BundleManifest | None:
|
|
169
|
+
"""Return the cached manifest of `hash`, now the most recently used, or `None`."""
|
|
170
|
+
manifest = self.manifests.get(hash)
|
|
171
|
+
if manifest is not None:
|
|
172
|
+
self.manifests.move_to_end(hash)
|
|
173
|
+
return manifest
|
|
174
|
+
|
|
175
|
+
def cache_put(self, hash: str, manifest: BundleManifest) -> None:
|
|
176
|
+
"""Cache `manifest` by `hash`, evicting the least recently used past the cache size."""
|
|
177
|
+
self.manifests[hash] = manifest
|
|
178
|
+
self.manifests.move_to_end(hash)
|
|
179
|
+
while len(self.manifests) > self.cache_size:
|
|
180
|
+
self.manifests.popitem(last=False)
|
|
181
|
+
|
|
182
|
+
def auth_headers(self) -> dict[str, str]:
|
|
183
|
+
"""Return the headers every bare-host request carries."""
|
|
184
|
+
if self.token is None or self.host is None:
|
|
185
|
+
return {}
|
|
186
|
+
return {"x-decdn-token": self.token, "x-decdn-host": self.host}
|
|
187
|
+
|
|
188
|
+
def is_bare(self, url: str) -> bool:
|
|
189
|
+
"""Return whether `url` is on the gateway's bare host."""
|
|
190
|
+
return url.startswith(f"{self.origin}/")
|
|
191
|
+
|
|
192
|
+
def bare_url(self, uri: DecdnUri) -> str:
|
|
193
|
+
"""Return the URI on the bare host."""
|
|
194
|
+
return f"{self.origin}/{uri.hash}{tail(uri)}"
|
|
195
|
+
|
|
196
|
+
def site_url(self, uri: str | DecdnUri) -> str:
|
|
197
|
+
"""Return the site URL of a bundle or an entry."""
|
|
198
|
+
u = parse(uri)
|
|
199
|
+
if u.kind == "blob":
|
|
200
|
+
raise config("a blob has no site: only a bundle or an entry does")
|
|
201
|
+
label = hash_to_base32(u.hash)
|
|
202
|
+
host = label if u.namespace is None else f"{u.namespace}.{label}"
|
|
203
|
+
path = tail(DecdnUri(hash=u.hash, kind=u.kind, path=u.path))
|
|
204
|
+
if self.context == "loopback":
|
|
205
|
+
port = self.port if self.port is not None else 443 if self.scheme == "https" else 80
|
|
206
|
+
return f"{self.scheme}://{host}.localhost:{port}{path}"
|
|
207
|
+
return f"{self.scheme}://{host}.{self.authority}{path}"
|
|
208
|
+
|
|
209
|
+
def namespace_url(self, namespace: int) -> str:
|
|
210
|
+
"""Return the host a namespace's pointer serves at on a public gateway: `https://<ns>.<host>/`."""
|
|
211
|
+
if isinstance(namespace, bool) or not isinstance(namespace, int):
|
|
212
|
+
raise config(f"a namespace id is an integer: got {namespace!r}")
|
|
213
|
+
if namespace <= 0:
|
|
214
|
+
raise config("a namespace id is at least 1")
|
|
215
|
+
if self.context == "loopback":
|
|
216
|
+
raise config("a loopback gateway holds no namespace pointers")
|
|
217
|
+
return f"{self.scheme}://{namespace}.{self.authority}/"
|
|
218
|
+
|
|
219
|
+
def manifest_hash(self, hash_or_uri: str | DecdnUri | None) -> str:
|
|
220
|
+
"""Return the hash a manifest call names."""
|
|
221
|
+
if hash_or_uri is None:
|
|
222
|
+
raise config("manifest needs a hash outside a site page")
|
|
223
|
+
if isinstance(hash_or_uri, str) and is_hash(hash_or_uri):
|
|
224
|
+
return hash_or_uri.lower()
|
|
225
|
+
return parse(hash_or_uri).hash
|
|
226
|
+
|
|
227
|
+
def entry_blob_url(self, uri: DecdnUri, entry: ManifestEntry) -> str:
|
|
228
|
+
"""Return an entry by its own hash, as a blob: the bytes the manifest names, unrewritten."""
|
|
229
|
+
return self.bare_url(DecdnUri(hash=entry.hash, kind="blob", namespace=uri.namespace))
|
|
230
|
+
|
|
231
|
+
def entry_url(self, uri: DecdnUri, entry: ManifestEntry | None) -> str:
|
|
232
|
+
"""Return where an entry comes from, given its manifest entry when verifying."""
|
|
233
|
+
if entry is not None and rewritten(uri.path or "") and self.context != "loopback":
|
|
234
|
+
return self.entry_blob_url(uri, entry)
|
|
235
|
+
return self.bare_url(uri) if self.context == "loopback" else self.site_url(uri)
|
|
236
|
+
|
|
237
|
+
def needs_entry(self, uri: DecdnUri, mode: VerifyMode, ranged: bool) -> bool:
|
|
238
|
+
"""Return whether planning `uri` needs its manifest entry: a verified whole entry does."""
|
|
239
|
+
return uri.kind == "entry" and mode is not False and not ranged
|
|
240
|
+
|
|
241
|
+
def plan(self, uri: DecdnUri, mode: VerifyMode, ranged: bool, entry: ManifestEntry | None) -> Plan:
|
|
242
|
+
"""Return the request `get` sends for `uri`.
|
|
243
|
+
|
|
244
|
+
`entry` is the manifest entry, given exactly when `needs_entry` is true. Raises `CONFIG` for
|
|
245
|
+
what cannot be honoured as asked.
|
|
246
|
+
"""
|
|
247
|
+
off: Verified = False if mode is False else "skipped"
|
|
248
|
+
|
|
249
|
+
def unverifiable(why: str) -> None:
|
|
250
|
+
if mode is True:
|
|
251
|
+
raise config(f"verify: true, but {why}")
|
|
252
|
+
|
|
253
|
+
if uri.kind == "blob":
|
|
254
|
+
# The gateway serves a blob whole: nothing declares its size, so a range would come
|
|
255
|
+
# back as every byte, or as an unverifiable window.
|
|
256
|
+
if ranged:
|
|
257
|
+
raise config("a blob has no declared size to range: fetch it whole, or range a bundle entry")
|
|
258
|
+
if mode is False:
|
|
259
|
+
return Plan(url=self.bare_url(uri), hash=None, length=None, verified=False, blob_url=None)
|
|
260
|
+
return Plan(url=self.bare_url(uri), hash=uri.hash, length=None, verified=True, blob_url=None)
|
|
261
|
+
if uri.kind == "bundle":
|
|
262
|
+
unverifiable("a bundle's index is a page the gateway rewrites")
|
|
263
|
+
url = self.bare_url(uri) if self.context == "loopback" else self.site_url(uri)
|
|
264
|
+
return Plan(url=url, hash=None, length=None, verified=off, blob_url=None)
|
|
265
|
+
if mode is False or ranged:
|
|
266
|
+
if ranged and mode is True:
|
|
267
|
+
raise config("verify: true, but a range cannot be verified")
|
|
268
|
+
return Plan(url=self.entry_url(uri, None), hash=None, length=None, verified=off, blob_url=None)
|
|
269
|
+
if entry is None:
|
|
270
|
+
raise ValueError("a verified entry is planned with its manifest entry")
|
|
271
|
+
url = self.entry_url(uri, entry)
|
|
272
|
+
blob_url = self.entry_blob_url(uri, entry)
|
|
273
|
+
return Plan(
|
|
274
|
+
url=url,
|
|
275
|
+
hash=entry.hash,
|
|
276
|
+
length=entry.size,
|
|
277
|
+
verified=True,
|
|
278
|
+
blob_url=blob_url if url != blob_url and self.context != "loopback" else None,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def range_header(self, r: ByteRange) -> str:
|
|
282
|
+
"""Return the `Range` header of `r`; `CONFIG` unless the offset is at least 0 and the length 1."""
|
|
283
|
+
if (
|
|
284
|
+
isinstance(r.offset, bool)
|
|
285
|
+
or not isinstance(r.offset, int)
|
|
286
|
+
or r.offset < 0
|
|
287
|
+
or (r.length is not None and (isinstance(r.length, bool) or not isinstance(r.length, int)))
|
|
288
|
+
or (r.length is not None and r.length < 1)
|
|
289
|
+
):
|
|
290
|
+
raise config("a range is a non-negative offset and a positive length")
|
|
291
|
+
end = "" if r.length is None else str(r.offset + r.length - 1)
|
|
292
|
+
return f"bytes={r.offset}-{end}"
|
|
293
|
+
|
|
294
|
+
def interpret(
|
|
295
|
+
self, plan: Plan, status: int, headers: httpx.Headers, ranged: ByteRange | None
|
|
296
|
+
) -> tuple[Verified, GuardOptions]:
|
|
297
|
+
"""Return what the answer to `plan` is: its `verified`, and the bounds its body is guarded by."""
|
|
298
|
+
# Only a window the caller asked for goes unhashed: whatever status the gateway answers a
|
|
299
|
+
# whole-body request with, its bytes must hash.
|
|
300
|
+
whole = ranged is None or status == 200
|
|
301
|
+
verified: Verified = "skipped" if plan.verified is True and not whole else plan.verified
|
|
302
|
+
hash_ = plan.hash if verified is True and plan.hash is not None else None
|
|
303
|
+
# The manifest's size bounds a whole entry both ways, and a window holds at most what was
|
|
304
|
+
# asked for. Content-Length bounds a body from below only: a body decoded on the way can
|
|
305
|
+
# run past it.
|
|
306
|
+
if whole and plan.length is not None:
|
|
307
|
+
return verified, GuardOptions(hash=hash_, length=plan.length, max=plan.length)
|
|
308
|
+
length = _digits(headers.get("content-length"))
|
|
309
|
+
max_ = ranged.length if not whole and ranged is not None and ranged.length is not None else None
|
|
310
|
+
return verified, GuardOptions(hash=hash_, length=length, max=max_)
|
|
311
|
+
|
|
312
|
+
def head_from_headers(self, headers: httpx.Headers) -> HeadInfo:
|
|
313
|
+
"""Return what a public `HEAD` answer says: its size, hash (ETag) and type."""
|
|
314
|
+
size = _digits(headers.get("content-length"))
|
|
315
|
+
etag = _ETAG.fullmatch(headers.get("etag", ""))
|
|
316
|
+
return HeadInfo(
|
|
317
|
+
size=size,
|
|
318
|
+
hash=etag.group(1).lower() if etag is not None else None,
|
|
319
|
+
content_type=headers.get("content-type"),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def head_from_entry(self, entry: ManifestEntry) -> HeadInfo:
|
|
323
|
+
"""Return what the manifest says of an entry."""
|
|
324
|
+
return HeadInfo(size=entry.size, hash=entry.hash, content_type=site_type(entry.path))
|
decdn/_hex.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Hex encoding helpers shared across the SDK."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_HEX_RE = re.compile(r"[0-9a-fA-F]*")
|
|
6
|
+
_HASH_RE = re.compile(r"[0-9a-fA-F]{64}")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_hex(data: bytes) -> str:
|
|
10
|
+
"""Return the lowercase hex spelling of `data`."""
|
|
11
|
+
return data.hex()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def from_hex(text: str) -> bytes | None:
|
|
15
|
+
"""Return the bytes of an even-length hex string, or `None` if it is not hex."""
|
|
16
|
+
if len(text) % 2 != 0 or _HEX_RE.fullmatch(text) is None:
|
|
17
|
+
return None
|
|
18
|
+
return bytes.fromhex(text)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_hash(text: str) -> bool:
|
|
22
|
+
"""Return whether `text` is a BLAKE3 hash spelled as 64 hex characters."""
|
|
23
|
+
return _HASH_RE.fullmatch(text) is not None
|
decdn/_http.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Gateway answers as errors: every non-2xx answer becomes a `DecdnError` with the fields its
|
|
2
|
+
contract carries (Retry-After, the `Link: <decdn:...>; rel="alternate"` fallback, the 402/503
|
|
3
|
+
JSON body, the 416 size, the pointer 409 `seq`)."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from decdn.errors import DecdnError, ErrorCode
|
|
12
|
+
|
|
13
|
+
CODES: dict[int, ErrorCode] = {
|
|
14
|
+
400: "BAD_URI",
|
|
15
|
+
401: "UNAUTHORIZED",
|
|
16
|
+
402: "PAYMENT_REQUIRED",
|
|
17
|
+
403: "FORBIDDEN",
|
|
18
|
+
404: "NOT_FOUND",
|
|
19
|
+
409: "CONFLICT",
|
|
20
|
+
413: "PAYLOAD_TOO_LARGE",
|
|
21
|
+
416: "RANGE_NOT_SATISFIABLE",
|
|
22
|
+
422: "UNPROCESSABLE",
|
|
23
|
+
429: "RATE_LIMITED",
|
|
24
|
+
502: "UPSTREAM",
|
|
25
|
+
503: "POOL_UNFUNDED",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_LINK = re.compile(r'<(decdn:[^>]+)>\s*;\s*rel="?alternate"?')
|
|
29
|
+
_SEQ = re.compile(r'"seq"\s*:\s*([0-9]+)')
|
|
30
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
31
|
+
_RANGE_SIZE = re.compile(r"bytes \*/([0-9]+)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_success(status: int) -> bool:
|
|
35
|
+
"""Return whether `status` is a 2xx answer."""
|
|
36
|
+
return 200 <= status <= 299
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def network_error(url: str, exc: Exception) -> DecdnError:
|
|
40
|
+
"""Return the `NETWORK` error for a failure to reach the gateway at `url`.
|
|
41
|
+
|
|
42
|
+
The caller raises it `from exc`.
|
|
43
|
+
"""
|
|
44
|
+
u = httpx.URL(url)
|
|
45
|
+
port = f":{u.port}" if u.port is not None else ""
|
|
46
|
+
return DecdnError("NETWORK", f"cannot reach {u.scheme}://{u.host}{port}: {exc}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def error_of(status: int, headers: httpx.Headers, body: str) -> DecdnError:
|
|
50
|
+
"""Return the error for a gateway answer that is not a success.
|
|
51
|
+
|
|
52
|
+
`body` is the answer's text, read by the caller.
|
|
53
|
+
"""
|
|
54
|
+
code: ErrorCode = CODES.get(status, "HTTP")
|
|
55
|
+
message = body.strip()
|
|
56
|
+
details: dict[str, Any] = {"status": status}
|
|
57
|
+
if message != "":
|
|
58
|
+
details["body"] = message
|
|
59
|
+
retry_after = headers.get("retry-after")
|
|
60
|
+
if retry_after is not None and _DIGITS.fullmatch(retry_after.strip()):
|
|
61
|
+
details["retry_after_secs"] = int(retry_after.strip())
|
|
62
|
+
link = _LINK.search(headers.get("link") or "")
|
|
63
|
+
if link is not None:
|
|
64
|
+
details["fallback_uri"] = link.group(1)
|
|
65
|
+
if headers.get("content-type", "").startswith("application/json"):
|
|
66
|
+
try:
|
|
67
|
+
parsed = json.loads(body)
|
|
68
|
+
except ValueError:
|
|
69
|
+
# Not JSON after all: the text stands.
|
|
70
|
+
parsed = None
|
|
71
|
+
if isinstance(parsed, dict):
|
|
72
|
+
if isinstance(parsed.get("error"), str):
|
|
73
|
+
message = parsed["error"]
|
|
74
|
+
if isinstance(parsed.get("uri"), str):
|
|
75
|
+
details["fallback_uri"] = parsed["uri"]
|
|
76
|
+
secs = parsed.get("retry_after_secs")
|
|
77
|
+
if isinstance(secs, int) and not isinstance(secs, bool) and secs >= 0:
|
|
78
|
+
details["retry_after_secs"] = secs
|
|
79
|
+
if parsed is not None:
|
|
80
|
+
# A u64 `seq` is read from its digits, as the gateway wrote it.
|
|
81
|
+
seq = _SEQ.search(body)
|
|
82
|
+
if seq is not None:
|
|
83
|
+
details["seq"] = int(seq.group(1))
|
|
84
|
+
if status == 416:
|
|
85
|
+
size = _RANGE_SIZE.fullmatch(headers.get("content-range", ""))
|
|
86
|
+
if size is not None:
|
|
87
|
+
details["size"] = int(size.group(1))
|
|
88
|
+
return DecdnError(code, message or f"gateway answered {status}", **details)
|
decdn/_mime.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""MIME rules for site entries, mirroring the TypeScript SDK's `mime.ts`.
|
|
2
|
+
|
|
3
|
+
What the gateway does with an entry by the extension of its name: whether a
|
|
4
|
+
site rewrites it as it streams (the gateway rewrites the types mime_guess
|
|
5
|
+
maps these extensions to:
|
|
6
|
+
https://github.com/decdn/decdn/blob/main/crates/cli/src/commands/gateway/rewrite.rs),
|
|
7
|
+
and the type a site serves it as, for the common web types. When it
|
|
8
|
+
verifies off loopback, the client fetches these entries by their hash up
|
|
9
|
+
front, and fetches by hash again any other entry a site answers with a
|
|
10
|
+
type it rewrites.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Extensions a site serves as `text/html` or `text/css`, rewritten as they stream.
|
|
16
|
+
_REWRITTEN_RE = re.compile(r"\.(html?|hxt|shtml|css)\Z", re.IGNORECASE)
|
|
17
|
+
_EXTENSION_RE = re.compile(r"\.([^./]+)\Z")
|
|
18
|
+
|
|
19
|
+
_TYPES: dict[str, str] = {
|
|
20
|
+
"html": "text/html",
|
|
21
|
+
"htm": "text/html",
|
|
22
|
+
"css": "text/css",
|
|
23
|
+
"js": "text/javascript",
|
|
24
|
+
"mjs": "text/javascript",
|
|
25
|
+
"json": "application/json",
|
|
26
|
+
"txt": "text/plain",
|
|
27
|
+
"md": "text/markdown",
|
|
28
|
+
"csv": "text/csv",
|
|
29
|
+
"xml": "text/xml",
|
|
30
|
+
"svg": "image/svg+xml",
|
|
31
|
+
"png": "image/png",
|
|
32
|
+
"jpg": "image/jpeg",
|
|
33
|
+
"jpeg": "image/jpeg",
|
|
34
|
+
"gif": "image/gif",
|
|
35
|
+
"webp": "image/webp",
|
|
36
|
+
"avif": "image/avif",
|
|
37
|
+
"ico": "image/x-icon",
|
|
38
|
+
"wasm": "application/wasm",
|
|
39
|
+
"pdf": "application/pdf",
|
|
40
|
+
"zip": "application/zip",
|
|
41
|
+
"gz": "application/gzip",
|
|
42
|
+
"tar": "application/x-tar",
|
|
43
|
+
"mp4": "video/mp4",
|
|
44
|
+
"webm": "video/webm",
|
|
45
|
+
"mp3": "audio/mpeg",
|
|
46
|
+
"ogg": "audio/ogg",
|
|
47
|
+
"woff": "font/woff",
|
|
48
|
+
"woff2": "font/woff2",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rewritten(path: str) -> bool:
|
|
53
|
+
"""Return whether a site rewrites the entry at `path`, so its bytes need not hash to the entry hash."""
|
|
54
|
+
return _REWRITTEN_RE.search(path) is not None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def site_type(path: str) -> str:
|
|
58
|
+
"""Return the type a site serves the entry at `path` as: text in UTF-8, bytes for an unknown extension."""
|
|
59
|
+
match = _EXTENSION_RE.search(path)
|
|
60
|
+
ext = match.group(1).lower() if match is not None else ""
|
|
61
|
+
content_type = _TYPES.get(ext, "application/octet-stream")
|
|
62
|
+
return f"{content_type}; charset=utf-8" if content_type.startswith("text/") else content_type
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_rewritten_type(content_type: str | None) -> bool:
|
|
66
|
+
"""Return whether a response content type is one a site rewrites: `text/html` or `text/css`."""
|
|
67
|
+
essence = (content_type or "").split(";")[0].strip().lower()
|
|
68
|
+
return essence in ("text/html", "text/css")
|