PyEVP 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.
Files changed (50) hide show
  1. pyevp/__init__.py +47 -0
  2. pyevp/__main__.py +28 -0
  3. pyevp/_email.py +65 -0
  4. pyevp/_httpsig.py +284 -0
  5. pyevp/_jose.py +145 -0
  6. pyevp/_sf.py +406 -0
  7. pyevp/adapters/__init__.py +4 -0
  8. pyevp/adapters/_doh.py +92 -0
  9. pyevp/adapters/_fetch.py +86 -0
  10. pyevp/adapters/_http.py +38 -0
  11. pyevp/adapters/dnspython.py +79 -0
  12. pyevp/adapters/doh.py +130 -0
  13. pyevp/adapters/httpx.py +147 -0
  14. pyevp/adapters/urllib.py +170 -0
  15. pyevp/cache.py +83 -0
  16. pyevp/cli/__init__.py +348 -0
  17. pyevp/contrib/__init__.py +4 -0
  18. pyevp/contrib/django/__init__.py +306 -0
  19. pyevp/contrib/django/apps.py +17 -0
  20. pyevp/contrib/django/issuer.py +314 -0
  21. pyevp/contrib/django/migrations/0001_initial.py +17 -0
  22. pyevp/contrib/django/migrations/__init__.py +0 -0
  23. pyevp/contrib/django/models.py +14 -0
  24. pyevp/contrib/django/templatetags/__init__.py +0 -0
  25. pyevp/contrib/django/templatetags/pyevp.py +32 -0
  26. pyevp/core.py +321 -0
  27. pyevp/diagnostics.py +182 -0
  28. pyevp/discovery.py +144 -0
  29. pyevp/errors.py +80 -0
  30. pyevp/issuer/__init__.py +39 -0
  31. pyevp/issuer/core.py +413 -0
  32. pyevp/issuer/errors.py +99 -0
  33. pyevp/issuer/fedcm.py +44 -0
  34. pyevp/issuer/keys.py +140 -0
  35. pyevp/issuer/profile.py +96 -0
  36. pyevp/nonce.py +25 -0
  37. pyevp/observability.py +89 -0
  38. pyevp/ports.py +54 -0
  39. pyevp/profile.py +153 -0
  40. pyevp/py.typed +0 -0
  41. pyevp/replay.py +69 -0
  42. pyevp/testing.py +343 -0
  43. pyevp/token.py +135 -0
  44. pyevp/types.py +37 -0
  45. pyevp/verifier.py +486 -0
  46. pyevp-0.1.0.dist-info/METADATA +171 -0
  47. pyevp-0.1.0.dist-info/RECORD +50 -0
  48. pyevp-0.1.0.dist-info/WHEEL +4 -0
  49. pyevp-0.1.0.dist-info/entry_points.txt +3 -0
  50. pyevp-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,79 @@
1
+ """TXT resolvers backed by dnspython (``pip install pyevp[dns]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dns.asyncresolver
6
+ import dns.flags
7
+ import dns.resolver
8
+ from dns.resolver import Answer
9
+
10
+ __all__ = ["AsyncDnsPythonResolver", "DnsPythonResolver", "DnssecError"]
11
+
12
+ _EMPTY = (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer)
13
+
14
+
15
+ class DnssecError(Exception):
16
+ """The answer was not marked authenticated (AD) by the resolver."""
17
+
18
+
19
+ def _configure(resolver: dns.resolver.BaseResolver, require_dnssec: bool) -> None:
20
+ if require_dnssec:
21
+ resolver.use_edns(0, dns.flags.DO, 1232)
22
+ # ``None`` means dnspython's default query flags, which set RD; keep it.
23
+ base = dns.flags.RD if resolver.flags is None else resolver.flags
24
+ resolver.flags = base | dns.flags.AD
25
+
26
+
27
+ def _records(answer: Answer, name: str, require_dnssec: bool) -> list[str]:
28
+ if require_dnssec and not (answer.response.flags & dns.flags.AD):
29
+ raise DnssecError(f"TXT answer for {name} is not DNSSEC-authenticated")
30
+ return [b"".join(rdata.strings).decode("utf-8", "replace") for rdata in answer]
31
+
32
+
33
+ class DnsPythonResolver:
34
+ """Synchronous resolver.
35
+
36
+ With ``require_dnssec=True`` the answer must carry the AD flag, which is only
37
+ meaningful when the configured upstream resolver validates DNSSEC and the
38
+ path to it is trusted (e.g. a local validating resolver).
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ resolver: dns.resolver.Resolver | None = None,
44
+ *,
45
+ require_dnssec: bool = False,
46
+ lifetime: float = 5.0,
47
+ ) -> None:
48
+ self._resolver = resolver or dns.resolver.Resolver()
49
+ self._resolver.lifetime = lifetime
50
+ self._require_dnssec = require_dnssec
51
+ _configure(self._resolver, require_dnssec)
52
+
53
+ def resolve_txt(self, name: str) -> list[str]:
54
+ try:
55
+ answer = self._resolver.resolve(name, "TXT")
56
+ except _EMPTY:
57
+ return []
58
+ return _records(answer, name, self._require_dnssec)
59
+
60
+
61
+ class AsyncDnsPythonResolver:
62
+ def __init__(
63
+ self,
64
+ resolver: dns.asyncresolver.Resolver | None = None,
65
+ *,
66
+ require_dnssec: bool = False,
67
+ lifetime: float = 5.0,
68
+ ) -> None:
69
+ self._resolver = resolver or dns.asyncresolver.Resolver()
70
+ self._resolver.lifetime = lifetime
71
+ self._require_dnssec = require_dnssec
72
+ _configure(self._resolver, require_dnssec)
73
+
74
+ async def resolve_txt(self, name: str) -> list[str]:
75
+ try:
76
+ answer = await self._resolver.resolve(name, "TXT")
77
+ except _EMPTY:
78
+ return []
79
+ return _records(answer, name, self._require_dnssec)
pyevp/adapters/doh.py ADDED
@@ -0,0 +1,130 @@
1
+ """TXT resolvers over DNS-over-HTTPS JSON APIs (``pip install pyevp[httpx2]`` or ``pyevp[httpx]``).
2
+
3
+ Useful where plain DNS is unavailable or untrusted (serverless platforms,
4
+ locked-down networks). Only an HTTP client is needed; dnspython is not.
5
+
6
+ ``require_dnssec`` checks the resolver's ``AD`` flag, which only means that the
7
+ DoH provider validated the answer: you are trusting that provider over TLS.
8
+ Unsigned zones (gmail.com, for example) never pass this check.
9
+
10
+ If you already use ``dnspython[doh]``, an RFC 8484 resolver is also possible::
11
+
12
+ resolver = dns.resolver.Resolver(configure=False)
13
+ resolver.nameservers = ["https://cloudflare-dns.com/dns-query"]
14
+ DnsPythonResolver(resolver)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from types import TracebackType
20
+ from typing import TYPE_CHECKING, Self
21
+
22
+ from pyevp.adapters._doh import (
23
+ CLOUDFLARE,
24
+ GOOGLE,
25
+ DnssecError,
26
+ DohError,
27
+ )
28
+ from pyevp.adapters._doh import HEADERS as _HEADERS
29
+ from pyevp.adapters._doh import params as _params
30
+ from pyevp.adapters._doh import records as _records
31
+ from pyevp.adapters._http import http
32
+
33
+ if TYPE_CHECKING:
34
+ from pyevp.adapters._http import AsyncClient, Client, Response
35
+
36
+ __all__ = [
37
+ "CLOUDFLARE",
38
+ "GOOGLE",
39
+ "AsyncDohResolver",
40
+ "DnssecError",
41
+ "DohError",
42
+ "DohResolver",
43
+ ]
44
+
45
+
46
+ def _check(response: Response, name: str) -> object:
47
+ if response.status_code != 200:
48
+ raise DohError(f"DoH lookup of {name} returned HTTP {response.status_code}")
49
+ try:
50
+ return response.json()
51
+ except (ValueError, RecursionError) as exc:
52
+ raise DohError(f"DoH lookup of {name} did not return JSON") from exc
53
+
54
+
55
+ class DohResolver:
56
+ """Synchronous DoH TXT resolver (Google by default; pass ``endpoint=CLOUDFLARE`` etc.)."""
57
+
58
+ def __init__(
59
+ self,
60
+ endpoint: str = GOOGLE,
61
+ *,
62
+ client: Client | None = None,
63
+ require_dnssec: bool = False,
64
+ timeout: float = 5.0,
65
+ ) -> None:
66
+ self.endpoint = endpoint
67
+ self._owns_client = client is None
68
+ self._client: Client = client or http.Client(timeout=timeout, follow_redirects=False)
69
+ self._require_dnssec = require_dnssec
70
+
71
+ def resolve_txt(self, name: str) -> list[str]:
72
+ response = self._client.get(
73
+ self.endpoint, params=_params(name), headers=_HEADERS, follow_redirects=False
74
+ )
75
+ return _records(_check(response, name), name, self._require_dnssec)
76
+
77
+ def close(self) -> None:
78
+ """Close the client this resolver created; a client passed in stays open."""
79
+ if self._owns_client:
80
+ self._client.close()
81
+
82
+ def __enter__(self) -> Self:
83
+ return self
84
+
85
+ def __exit__(
86
+ self,
87
+ exc_type: type[BaseException] | None,
88
+ exc: BaseException | None,
89
+ tb: TracebackType | None,
90
+ ) -> None:
91
+ self.close()
92
+
93
+
94
+ class AsyncDohResolver:
95
+ def __init__(
96
+ self,
97
+ endpoint: str = GOOGLE,
98
+ *,
99
+ client: AsyncClient | None = None,
100
+ require_dnssec: bool = False,
101
+ timeout: float = 5.0,
102
+ ) -> None:
103
+ self.endpoint = endpoint
104
+ self._owns_client = client is None
105
+ self._client: AsyncClient = client or http.AsyncClient(
106
+ timeout=timeout, follow_redirects=False
107
+ )
108
+ self._require_dnssec = require_dnssec
109
+
110
+ async def resolve_txt(self, name: str) -> list[str]:
111
+ response = await self._client.get(
112
+ self.endpoint, params=_params(name), headers=_HEADERS, follow_redirects=False
113
+ )
114
+ return _records(_check(response, name), name, self._require_dnssec)
115
+
116
+ async def aclose(self) -> None:
117
+ """Close the client this resolver created; a client passed in stays open."""
118
+ if self._owns_client:
119
+ await self._client.aclose()
120
+
121
+ async def __aenter__(self) -> Self:
122
+ return self
123
+
124
+ async def __aexit__(
125
+ self,
126
+ exc_type: type[BaseException] | None,
127
+ exc: BaseException | None,
128
+ tb: TracebackType | None,
129
+ ) -> None:
130
+ await self.aclose()
@@ -0,0 +1,147 @@
1
+ """JSON fetchers backed by httpx2 or httpx (``pip install pyevp[httpx2]`` / ``pyevp[httpx]``).
2
+
3
+ httpx2 is used for default clients when it is installed; a client from either
4
+ library can be passed in explicitly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import socket
10
+ from types import TracebackType
11
+ from typing import TYPE_CHECKING, Self
12
+
13
+ import anyio
14
+
15
+ from pyevp.adapters import _fetch
16
+ from pyevp.adapters._fetch import HEADERS as _HEADERS
17
+ from pyevp.adapters._fetch import (
18
+ MAX_DOCUMENT_BYTES,
19
+ AsyncResolveHost,
20
+ FetchError,
21
+ ResolveHost,
22
+ )
23
+ from pyevp.adapters._fetch import decode as _decode
24
+ from pyevp.adapters._http import http
25
+
26
+ if TYPE_CHECKING:
27
+ from pyevp.adapters._http import AsyncClient, Client, Response
28
+
29
+ __all__ = ["AsyncHttpxFetcher", "FetchError", "HttpxFetcher"]
30
+
31
+
32
+ def _check(response: Response) -> None:
33
+ if response.status_code != 200:
34
+ raise FetchError(f"GET {response.request.url} returned HTTP {response.status_code}")
35
+ if response.headers.get("Content-Encoding", "identity").strip().lower() != "identity":
36
+ raise FetchError(f"GET {response.request.url} returned a compressed response")
37
+
38
+
39
+ async def _anyio_resolve_host(host: str) -> list[str]:
40
+ infos = await anyio.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
41
+ return [str(info[4][0]) for info in infos]
42
+
43
+
44
+ class HttpxFetcher:
45
+ """Synchronous fetcher.
46
+
47
+ Redirects are not followed, compressed responses are refused and bodies are size-capped.
48
+ Before each request the host is resolved and refused unless every address is globally
49
+ routable; pass ``require_global_addresses=False`` to reach an issuer on a private network
50
+ (local development) or when an egress proxy enforces that policy and resolves names
51
+ itself. ``resolve_host`` replaces the system resolver used for that check.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ client: Client | None = None,
57
+ *,
58
+ timeout: float = 5.0,
59
+ require_global_addresses: bool = True,
60
+ resolve_host: ResolveHost = _fetch.system_resolve_host,
61
+ ) -> None:
62
+ self._owns_client = client is None
63
+ self._client: Client = client or http.Client(timeout=timeout, follow_redirects=False)
64
+ self._require_global = require_global_addresses
65
+ self._resolve_host = resolve_host
66
+
67
+ def fetch_json(self, url: str) -> object:
68
+ if self._require_global:
69
+ _fetch.require_global(url, self._resolve_host)
70
+ with self._client.stream("GET", url, headers=_HEADERS, follow_redirects=False) as response:
71
+ _check(response)
72
+ body = bytearray()
73
+ for chunk in response.iter_raw():
74
+ body += chunk
75
+ if len(body) > MAX_DOCUMENT_BYTES:
76
+ raise FetchError(f"GET {url} response is too large")
77
+ return _decode(bytes(body), url)
78
+
79
+ def close(self) -> None:
80
+ """Close the client this fetcher created; a client passed in stays open."""
81
+ if self._owns_client:
82
+ self._client.close()
83
+
84
+ def __enter__(self) -> Self:
85
+ return self
86
+
87
+ def __exit__(
88
+ self,
89
+ exc_type: type[BaseException] | None,
90
+ exc: BaseException | None,
91
+ tb: TracebackType | None,
92
+ ) -> None:
93
+ self.close()
94
+
95
+
96
+ class AsyncHttpxFetcher:
97
+ """Asynchronous counterpart of :class:`HttpxFetcher` (asyncio or trio)."""
98
+
99
+ def __init__(
100
+ self,
101
+ client: AsyncClient | None = None,
102
+ *,
103
+ timeout: float = 5.0,
104
+ require_global_addresses: bool = True,
105
+ resolve_host: AsyncResolveHost = _anyio_resolve_host,
106
+ ) -> None:
107
+ self._owns_client = client is None
108
+ self._client: AsyncClient = client or http.AsyncClient(
109
+ timeout=timeout, follow_redirects=False
110
+ )
111
+ self._require_global = require_global_addresses
112
+ self._resolve_host = resolve_host
113
+
114
+ async def fetch_json(self, url: str) -> object:
115
+ if self._require_global:
116
+ host = _fetch.host_of(url)
117
+ try:
118
+ addresses = list(await self._resolve_host(host))
119
+ except OSError as exc:
120
+ raise FetchError(f"GET {url}: cannot resolve {host}: {exc}") from exc
121
+ _fetch.check_global_addresses(url, host, addresses)
122
+ async with self._client.stream(
123
+ "GET", url, headers=_HEADERS, follow_redirects=False
124
+ ) as response:
125
+ _check(response)
126
+ body = bytearray()
127
+ async for chunk in response.aiter_raw():
128
+ body += chunk
129
+ if len(body) > MAX_DOCUMENT_BYTES:
130
+ raise FetchError(f"GET {url} response is too large")
131
+ return _decode(bytes(body), url)
132
+
133
+ async def aclose(self) -> None:
134
+ """Close the client this fetcher created; a client passed in stays open."""
135
+ if self._owns_client:
136
+ await self._client.aclose()
137
+
138
+ async def __aenter__(self) -> Self:
139
+ return self
140
+
141
+ async def __aexit__(
142
+ self,
143
+ exc_type: type[BaseException] | None,
144
+ exc: BaseException | None,
145
+ tb: TracebackType | None,
146
+ ) -> None:
147
+ await self.aclose()
@@ -0,0 +1,170 @@
1
+ """A JSON fetcher and a DoH TXT resolver using only the standard library.
2
+
3
+ Nothing beyond PyEVP's core dependencies is needed, which suits applications
4
+ that already pick their own HTTP stack and do not want httpx or dnspython::
5
+
6
+ from pyevp import Verifier
7
+ from pyevp.adapters.urllib import UrllibDohResolver, UrllibFetcher
8
+
9
+ verifier = Verifier(audience=..., resolver=UrllibDohResolver(), fetcher=UrllibFetcher())
10
+
11
+ Both are synchronous; async applications should use the httpx adapters.
12
+ Like those, they never follow redirects, refuse compressed responses and
13
+ cap body sizes. Proxies from the environment (``HTTPS_PROXY``) are honoured
14
+ as usual for urllib; pass ``handlers`` to configure proxies or TLS differently::
15
+
16
+ UrllibFetcher(handlers=[urllib.request.HTTPSHandler(context=my_ssl_context)])
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import http.client
22
+ import urllib.error
23
+ import urllib.request
24
+ from collections.abc import Mapping, Sequence
25
+ from email.message import Message
26
+ from typing import IO
27
+ from urllib.parse import urlencode, urlsplit, urlunsplit
28
+
29
+ from pyevp.adapters import _fetch
30
+ from pyevp.adapters._doh import CLOUDFLARE, GOOGLE, DnssecError, DohError, params, records
31
+ from pyevp.adapters._doh import HEADERS as _DOH_HEADERS
32
+ from pyevp.adapters._fetch import FetchError
33
+
34
+ # The DoH names are re-exported because pyevp.adapters.doh needs httpx.
35
+ __all__ = [
36
+ "CLOUDFLARE",
37
+ "GOOGLE",
38
+ "DnssecError",
39
+ "DohError",
40
+ "FetchError",
41
+ "UrllibDohResolver",
42
+ "UrllibFetcher",
43
+ ]
44
+
45
+
46
+ class _NoRedirects(urllib.request.HTTPRedirectHandler):
47
+ """Turn every redirect into an ``HTTPError`` instead of following it."""
48
+
49
+ def redirect_request(self, *args: object, **kwargs: object) -> None:
50
+ return None
51
+
52
+
53
+ def _opener(handlers: Sequence[urllib.request.BaseHandler]) -> urllib.request.OpenerDirector:
54
+ # Our handler replaces urllib's default redirect handler; drop any the caller passed.
55
+ kept = [h for h in handlers if not isinstance(h, urllib.request.HTTPRedirectHandler)]
56
+ return urllib.request.build_opener(*kept, _NoRedirects())
57
+
58
+
59
+ def _get(
60
+ opener: urllib.request.OpenerDirector,
61
+ url: str,
62
+ headers: Mapping[str, str],
63
+ timeout: float,
64
+ error: type[Exception],
65
+ ) -> bytes:
66
+ """GET ``url`` and return the raw body of a 200, uncompressed response."""
67
+ request = urllib.request.Request(url, headers=dict(headers), method="GET")
68
+ try:
69
+ with opener.open(request, timeout=timeout) as response:
70
+ return _read(response, response.status, response.headers, url, error)
71
+ except urllib.error.HTTPError as exc:
72
+ exc.close()
73
+ raise error(f"GET {url} returned HTTP {exc.code}") from None
74
+ # HTTPException covers IncompleteRead from truncated chunked bodies.
75
+ except (urllib.error.URLError, OSError, http.client.HTTPException) as exc:
76
+ raise error(f"GET {url} failed: {exc}") from exc
77
+
78
+
79
+ def _read(
80
+ response: IO[bytes], status: int, headers: Message, url: str, error: type[Exception]
81
+ ) -> bytes:
82
+ if status != 200:
83
+ raise error(f"GET {url} returned HTTP {status}")
84
+ if headers.get("Content-Encoding", "identity").strip().lower() != "identity":
85
+ raise error(f"GET {url} returned a compressed response")
86
+ declared = _content_length(headers)
87
+ if declared is not None and declared > _fetch.MAX_DOCUMENT_BYTES:
88
+ raise error(f"GET {url} response is too large")
89
+ body = response.read(_fetch.MAX_DOCUMENT_BYTES + 1)
90
+ if len(body) > _fetch.MAX_DOCUMENT_BYTES:
91
+ raise error(f"GET {url} response is too large")
92
+ # read(n) returns short when the server closes early; httpx would raise here.
93
+ if declared is not None and len(body) != declared:
94
+ raise error(f"GET {url} response is incomplete")
95
+ return body
96
+
97
+
98
+ def _content_length(headers: Message) -> int | None:
99
+ value = headers.get("Content-Length")
100
+ if value is None or headers.get("Transfer-Encoding"):
101
+ return None
102
+ try:
103
+ length = int(value)
104
+ except ValueError:
105
+ return None
106
+ return length if length >= 0 else None
107
+
108
+
109
+ def _with_query(endpoint: str, extra: Mapping[str, str]) -> str:
110
+ """Append ``extra`` to whatever query ``endpoint`` already has, as httpx's ``params`` do."""
111
+ parts = urlsplit(endpoint)
112
+ query = "&".join(q for q in (parts.query, urlencode(extra)) if q)
113
+ return urlunsplit(parts._replace(query=query))
114
+
115
+
116
+ class UrllibFetcher:
117
+ """Synchronous :class:`~pyevp.JsonFetcher` built on :mod:`urllib.request`.
118
+
119
+ Like :class:`~pyevp.adapters.httpx.HttpxFetcher`, it refuses hosts that do not resolve
120
+ exclusively to globally routable addresses unless ``require_global_addresses=False``.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ timeout: float = 5.0,
127
+ handlers: Sequence[urllib.request.BaseHandler] = (),
128
+ require_global_addresses: bool = True,
129
+ resolve_host: _fetch.ResolveHost = _fetch.system_resolve_host,
130
+ ) -> None:
131
+ self._timeout = timeout
132
+ self._opener = _opener(handlers)
133
+ self._require_global = require_global_addresses
134
+ self._resolve_host = resolve_host
135
+
136
+ def fetch_json(self, url: str) -> object:
137
+ if self._require_global:
138
+ _fetch.require_global(url, self._resolve_host)
139
+ body = _get(self._opener, url, _fetch.HEADERS, self._timeout, FetchError)
140
+ return _fetch.decode(body, url)
141
+
142
+
143
+ class UrllibDohResolver:
144
+ """Synchronous DoH TXT resolver (Google by default; pass ``CLOUDFLARE`` etc.).
145
+
146
+ ``require_dnssec=True`` checks the provider's ``AD`` flag, so it means trusting that
147
+ provider over TLS; unsigned zones (gmail.com, for example) never pass it.
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ endpoint: str = GOOGLE,
153
+ *,
154
+ require_dnssec: bool = False,
155
+ timeout: float = 5.0,
156
+ handlers: Sequence[urllib.request.BaseHandler] = (),
157
+ ) -> None:
158
+ self.endpoint = endpoint
159
+ self._require_dnssec = require_dnssec
160
+ self._timeout = timeout
161
+ self._opener = _opener(handlers)
162
+
163
+ def resolve_txt(self, name: str) -> list[str]:
164
+ url = _with_query(self.endpoint, params(name))
165
+ body = _get(self._opener, url, _DOH_HEADERS, self._timeout, DohError)
166
+ try:
167
+ document = _fetch.decode(body, url)
168
+ except FetchError as exc:
169
+ raise DohError(f"DoH lookup of {name} did not return JSON") from exc
170
+ return records(document, name, self._require_dnssec)
pyevp/cache.py ADDED
@@ -0,0 +1,83 @@
1
+ """Caching of issuer metadata and key sets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Protocol
9
+
10
+ from pyevp.ports import Clock, system_clock
11
+
12
+ __all__ = ["AsyncCache", "Cache", "CacheEntry", "InMemoryCache", "NullCache"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CacheEntry:
17
+ value: object
18
+ stored_at: datetime
19
+
20
+
21
+ class Cache(Protocol):
22
+ """Key/value store for decoded JSON documents.
23
+
24
+ Implementations may be backed by Django's cache, Redis, … ; they are called
25
+ from both sync and async drivers so must not block for long.
26
+ """
27
+
28
+ def get(self, key: str) -> CacheEntry | None: ...
29
+
30
+ def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None: ...
31
+
32
+
33
+ class AsyncCache(Protocol):
34
+ """:class:`Cache` with coroutine methods, for stores that must not block the event loop.
35
+
36
+ :class:`~pyevp.AsyncVerifier` accepts either kind.
37
+ """
38
+
39
+ async def get(self, key: str) -> CacheEntry | None: ...
40
+
41
+ async def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None: ...
42
+
43
+
44
+ class NullCache:
45
+ """A cache that stores nothing: every verification fetches metadata and keys."""
46
+
47
+ def get(self, key: str) -> CacheEntry | None:
48
+ return None
49
+
50
+ def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None:
51
+ return None
52
+
53
+
54
+ class InMemoryCache:
55
+ """Process-local TTL cache (not shared between workers).
56
+
57
+ Safe to share between threads, e.g. one ``Verifier`` used by a threaded server.
58
+ """
59
+
60
+ def __init__(self, *, clock: Clock = system_clock, max_entries: int = 256) -> None:
61
+ self._clock = clock
62
+ self._max_entries = max_entries
63
+ self._data: dict[str, tuple[datetime, CacheEntry]] = {}
64
+ self._lock = threading.Lock()
65
+
66
+ def get(self, key: str) -> CacheEntry | None:
67
+ now = self._clock() # outside the lock: the clock is user code
68
+ with self._lock:
69
+ item = self._data.get(key)
70
+ if item is None:
71
+ return None
72
+ expires_at, entry = item
73
+ if now >= expires_at:
74
+ del self._data[key]
75
+ return None
76
+ return entry
77
+
78
+ def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None:
79
+ expires_at = self._clock() + ttl
80
+ with self._lock:
81
+ if len(self._data) >= self._max_entries and key not in self._data:
82
+ del self._data[min(self._data, key=lambda k: self._data[k][0])]
83
+ self._data[key] = (expires_at, entry)