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 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")