comatype 0.0.1__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.
comatype/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Comatype runtime: provides the client `c` and server `s` used by generated code (comatype_gen)."""
2
+
3
+ from . import scalars
4
+ from .cache import CacheSpec, MemoryCache
5
+ from .client import Client, c
6
+ from .cookie import Cookie, CookieDef, CookieJar, serialize_set_cookie
7
+ from .endpoint import ComatypeWrap, Endpoint, NoArg, Status
8
+ from .errors import ComatypeError, ComatypeValidationError, UnexpectedStatusError
9
+ from .raw import CtxT, Headers, RawRequest, RawResponse
10
+ from .server import Server, s
11
+
12
+ __all__ = [
13
+ "CacheSpec",
14
+ "MemoryCache",
15
+ "Client",
16
+ "ComatypeError",
17
+ "ComatypeValidationError",
18
+ "ComatypeWrap",
19
+ "Cookie",
20
+ "CookieDef",
21
+ "CookieJar",
22
+ "CtxT",
23
+ "Endpoint",
24
+ "Headers",
25
+ "NoArg",
26
+ "RawRequest",
27
+ "RawResponse",
28
+ "Server",
29
+ "Status",
30
+ "UnexpectedStatusError",
31
+ "c",
32
+ "s",
33
+ "scalars",
34
+ "serialize_set_cookie",
35
+ ]
comatype/cache.py ADDED
@@ -0,0 +1,118 @@
1
+ """Cache (`cache` in `.comat`): an in-memory TTL cache and the encoding used to store entries in cookies. Applies to GET / QUERY only.。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import time
7
+ from collections import OrderedDict
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Literal
11
+
12
+ from .raw import Headers, RawResponse
13
+
14
+ # Name prefix of cache cookies. The client does not send cookies with this prefix back to the server.
15
+ CACHE_COOKIE_PREFIX = "comatype_cache_"
16
+ # Maximum length of one cookie value. Larger responses are not cached in a cookie.
17
+ CACHE_COOKIE_MAX = 3800
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CacheSpec:
22
+ """The `cache` setting. mode is off (never cache) / memory (keep in memory for ttl) / cookie (keep in a cookie for ttl; client only)."""
23
+
24
+ mode: Literal["off", "memory", "cookie"]
25
+ ttl_ms: int = 0
26
+
27
+ @property
28
+ def memory(self) -> bool:
29
+ return self.mode == "memory" and self.ttl_ms > 0
30
+
31
+ @property
32
+ def cookie(self) -> bool:
33
+ return self.mode == "cookie" and self.ttl_ms > 0
34
+
35
+
36
+ def _clone(r: RawResponse) -> RawResponse:
37
+ return RawResponse(status=r.status, headers=Headers(dict(r.headers.items())), body=r.body)
38
+
39
+
40
+ class MemoryCache:
41
+ """In-memory cache with a ttl (expired entries are dropped on lookup; over the size limit, the oldest are dropped first)."""
42
+
43
+ def __init__(self, max_entries: int = 1000, now: Callable[[], float] = time.monotonic) -> None:
44
+ self._max = max_entries
45
+ self._now = now
46
+ self._d: OrderedDict[str, tuple[RawResponse, float]] = OrderedDict()
47
+
48
+ def get(self, key: str) -> RawResponse | None:
49
+ e = self._d.get(key)
50
+ if e is None:
51
+ return None
52
+ if e[1] <= self._now():
53
+ del self._d[key]
54
+ return None
55
+ return _clone(e[0])
56
+
57
+ def set(self, key: str, res: RawResponse, ttl_ms: int) -> None:
58
+ self._d.pop(key, None)
59
+ self._d[key] = (_clone(res), self._now() + ttl_ms / 1000)
60
+ while len(self._d) > self._max:
61
+ self._d.popitem(last=False)
62
+
63
+ def clear(self) -> None:
64
+ self._d.clear()
65
+
66
+ def __len__(self) -> int:
67
+ return len(self._d)
68
+
69
+
70
+ def cacheable(res: RawResponse) -> bool:
71
+ """Whether a response may be cached (2xx, no Set-Cookie)."""
72
+ return 200 <= res.status < 300 and not res.set_cookies
73
+
74
+
75
+ def client_cache_key(method: str, url: str, body: bytes, declared: tuple[str, ...], headers: Mapping[str, str]) -> str:
76
+ """Client cache key: method, URL (path and query) and body, plus the request headers declared in `.comat`.
77
+ Headers added by wrappers or init (Authorization etc.) are not included."""
78
+ lower = {k.lower(): v for k, v in headers.items()}
79
+ hs = "\n".join(f"{n}:{lower.get(n, '')}" for n in sorted(h.lower() for h in declared))
80
+ return f"{method}\n{url}\n{hs}\n\n{body.decode('utf-8', 'surrogateescape')}"
81
+
82
+
83
+ def server_cache_key(method: str, target: str, headers: Mapping[str, str], body: bytes) -> str:
84
+ """Server cache key: an exact match of the request (method, path, query, all headers, body)."""
85
+ hs = "\n".join(f"{k.lower()}:{v}" for k, v in sorted(headers.items(), key=lambda kv: kv[0].lower()))
86
+ return f"{method}\n{target}\n{hs}\n\n{body.decode('utf-8', 'surrogateescape')}"
87
+
88
+
89
+ def cache_cookie_name(key: str) -> str:
90
+ """Build a cookie name from the key (FNV-1a, 64-bit)."""
91
+ h = 0xCBF29CE484222325
92
+ for b in key.encode("utf-8", "surrogateescape"):
93
+ h = ((h ^ b) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
94
+ return f"{CACHE_COOKIE_PREFIX}{h:016x}"
95
+
96
+
97
+ def encode_cache_cookie(res: RawResponse) -> str | None:
98
+ """Encode a response as a cookie value (base64url of `status\\nname\\tvalue\\n...\\n\\nbody`). None if too large."""
99
+ head = f"{res.status}\n" + "".join(f"{k}\t{v}\n" for k, v in res.headers.items())
100
+ v = base64.urlsafe_b64encode(head.encode() + b"\n" + res.body).decode().rstrip("=")
101
+ return v if len(v) <= CACHE_COOKIE_MAX else None
102
+
103
+
104
+ def decode_cache_cookie(value: str) -> RawResponse | None:
105
+ try:
106
+ raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
107
+ head, sep, body = raw.partition(b"\n\n")
108
+ if not sep:
109
+ return None
110
+ lines = head.decode().split("\n")
111
+ out = RawResponse(status=int(lines[0]), body=body)
112
+ for ln in lines[1:]:
113
+ k, tab, v = ln.partition("\t")
114
+ if tab:
115
+ out.headers[k] = v
116
+ return out
117
+ except Exception: # noqa: BLE001
118
+ return None