md4a-fastapi 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.
md4a/client.py ADDED
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import socket
5
+ import urllib.request
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from types import TracebackType
9
+ from urllib.parse import urlparse
10
+
11
+ from . import __version__
12
+ from ._extras import require_server_extra
13
+
14
+ try:
15
+ import anyio
16
+ import httpx
17
+ except ImportError as exc:
18
+ raise require_server_extra("md4a.client", exc) from exc
19
+
20
+ MARKDOWN_ACCEPT = "text/markdown"
21
+
22
+ _NAT64 = ipaddress.ip_network("64:ff9b::/96")
23
+ _IPV6_GLOBAL_UNICAST = ipaddress.ip_network("2000::/3")
24
+ _ADDRESSES = "md4a.addresses"
25
+ _PROXY = "md4a.proxy"
26
+
27
+
28
+ def _is_public(value: str) -> bool:
29
+ """Return true for a globally routable unicast address."""
30
+ address = ipaddress.ip_address(value)
31
+ if isinstance(address, ipaddress.IPv6Address):
32
+ # Judge addresses that carry an IPv4 destination by that destination.
33
+ embedded = address.ipv4_mapped or address.sixtofour
34
+ if embedded is None and address.teredo is not None:
35
+ embedded = address.teredo[1]
36
+ if embedded is None and address in _NAT64:
37
+ embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
38
+ if embedded is not None:
39
+ address = embedded
40
+ elif address not in _IPV6_GLOBAL_UNICAST:
41
+ # ipaddress calls some ranges outside it global, such as site-local fec0::/10.
42
+ return False
43
+ return address.is_global and not address.is_multicast
44
+
45
+
46
+ def _literal_address(host: str) -> str | None:
47
+ """Return the address a host spells, or None when it is a name to resolve."""
48
+ try:
49
+ return str(ipaddress.ip_address(host))
50
+ except ValueError:
51
+ pass
52
+ try:
53
+ # Forms such as 127.1, 2130706433, and 0x7f000001 are addresses to libc.
54
+ return socket.inet_ntoa(socket.inet_aton(host))
55
+ except (OSError, UnicodeError):
56
+ return None
57
+
58
+
59
+ def _exclusion_matches(pattern: httpx.URL, url: httpx.URL) -> bool:
60
+ if pattern.scheme != "all" and pattern.scheme != url.scheme:
61
+ return False
62
+ if pattern.port is not None and pattern.port != url.port:
63
+ return False
64
+ host = pattern.host
65
+ if host in ("", "*"):
66
+ return True
67
+ if host.startswith("*."):
68
+ return url.host.endswith(host[1:]) and len(url.host) > len(host) - 1
69
+ if host.startswith("*"):
70
+ return url.host == host[1:] or url.host.endswith(f".{host[1:]}")
71
+ return url.host == host
72
+
73
+
74
+ def _environment_proxy(url: httpx.URL) -> str | None:
75
+ """Return the environment proxy httpx would send this URL through, if any.
76
+
77
+ md4a routes requests itself, so it has to make this decision; it follows
78
+ httpx's reading of the proxy variables (httpx._utils.get_environment_proxies).
79
+ """
80
+ settings = urllib.request.getproxies()
81
+ rules: dict[str, str | None] = {}
82
+ for scheme in ("http", "https", "all"):
83
+ proxy = settings.get(scheme)
84
+ if proxy:
85
+ rules[f"{scheme}://"] = proxy if "://" in proxy else f"http://{proxy}"
86
+ for entry in (item.strip() for item in settings.get("no", "").split(",")):
87
+ if entry == "*":
88
+ return None
89
+ if not entry:
90
+ continue
91
+ address = entry.split("/")[0]
92
+ if "://" in entry:
93
+ rules[entry] = None
94
+ elif _parses(ipaddress.IPv4Address, address) or entry.lower() == "localhost":
95
+ rules[f"all://{entry}"] = None
96
+ elif _parses(ipaddress.IPv6Address, address):
97
+ rules[f"all://[{entry}]"] = None
98
+ else:
99
+ rules[f"all://*{entry}"] = None
100
+
101
+ chosen: tuple[tuple[int, int, int], str | None] | None = None
102
+ for key, proxy in rules.items():
103
+ try:
104
+ pattern = httpx.URL(key)
105
+ except httpx.InvalidURL:
106
+ continue
107
+ if not _exclusion_matches(pattern, url):
108
+ continue
109
+ # The most specific rule wins: a port, then the longer host, then a scheme.
110
+ priority = (
111
+ pattern.port is None,
112
+ -len("" if pattern.host == "*" else pattern.host),
113
+ -len("" if pattern.scheme == "all" else pattern.scheme),
114
+ )
115
+ if chosen is None or priority < chosen[0]:
116
+ chosen = (priority, proxy)
117
+ return chosen[1] if chosen else None
118
+
119
+
120
+ def _parses(kind: type, value: str) -> bool:
121
+ try:
122
+ kind(value)
123
+ except ValueError:
124
+ return False
125
+ return True
126
+
127
+
128
+ class _RoutingTransport(httpx.AsyncBaseTransport):
129
+ """Send each request where md4a decided it goes: a proxy, or a checked address.
130
+
131
+ Direct requests connect to the addresses that were validated instead of
132
+ resolving the name again. Only the connection target changes: the hostname is
133
+ kept for Host, SNI, and certificate verification, and the client still sees the
134
+ original URL, so redirects and cookies stay scoped to the hostname.
135
+
136
+ Each hostname gets its own direct transport. A connection pool only sees the
137
+ pinned address, so sharing one would let a hostname reuse a connection whose
138
+ certificate was verified for a different hostname at the same address.
139
+ """
140
+
141
+ def __init__(self, direct_transport: Callable[[], httpx.AsyncBaseTransport]) -> None:
142
+ self._direct_transport = direct_transport
143
+ self._direct: dict[str, httpx.AsyncBaseTransport] = {}
144
+ self._proxies: dict[str, httpx.AsyncBaseTransport] = {}
145
+ self._entered: dict[int, httpx.AsyncBaseTransport] = {}
146
+
147
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
148
+ proxy = request.extensions.get(_PROXY)
149
+ if proxy is not None:
150
+ if proxy not in self._proxies:
151
+ self._proxies[proxy] = await self._enter(httpx.AsyncHTTPTransport(proxy=proxy))
152
+ return await self._proxies[proxy].handle_async_request(request)
153
+ addresses = request.extensions.get(_ADDRESSES)
154
+ if not addresses:
155
+ raise ValueError("refusing to send a request whose destination was not checked")
156
+ hostname = request.url.raw_host.decode("ascii")
157
+ if hostname not in self._direct:
158
+ self._direct[hostname] = await self._enter(self._direct_transport())
159
+ direct = self._direct[hostname]
160
+ extensions = {**request.extensions, "sni_hostname": hostname}
161
+ for index, address in enumerate(addresses):
162
+ pinned = httpx.Request(
163
+ request.method,
164
+ request.url.copy_with(host=address),
165
+ headers=request.headers,
166
+ stream=request.stream,
167
+ extensions=extensions,
168
+ )
169
+ try:
170
+ return await direct.handle_async_request(pinned)
171
+ except (httpx.ConnectError, httpx.ConnectTimeout):
172
+ # Nothing was sent, so the next validated address can be tried.
173
+ if index == len(addresses) - 1:
174
+ raise
175
+ raise AssertionError("unreachable")
176
+
177
+ async def _enter(self, transport: httpx.AsyncBaseTransport) -> httpx.AsyncBaseTransport:
178
+ # A supplied transport is handed out for every hostname; enter it once.
179
+ if id(transport) not in self._entered:
180
+ self._entered[id(transport)] = transport
181
+ await transport.__aenter__()
182
+ return transport
183
+
184
+ async def __aexit__(
185
+ self,
186
+ exc_type: type[BaseException] | None = None,
187
+ exc_value: BaseException | None = None,
188
+ traceback: TracebackType | None = None,
189
+ ) -> None:
190
+ entered, self._entered = self._entered, {}
191
+ for transport in entered.values():
192
+ await transport.__aexit__(exc_type, exc_value, traceback)
193
+
194
+ async def aclose(self) -> None:
195
+ await self.__aexit__()
196
+
197
+
198
+ @dataclass(frozen=True, slots=True)
199
+ class FetchResult:
200
+ requested_url: str
201
+ final_url: str
202
+ status_code: int
203
+ content_type: str
204
+ markdown: str | None
205
+
206
+ @property
207
+ def available(self) -> bool:
208
+ return self.markdown is not None
209
+
210
+
211
+ class MarkdownForAgentsClient:
212
+ """Probe URLs using the Markdown-for-Agents content negotiation header."""
213
+
214
+ def __init__(
215
+ self,
216
+ *,
217
+ timeout: float = 15.0,
218
+ max_bytes: int = 5_000_000,
219
+ allowed_hosts: set[str] | None = None,
220
+ allow_private_addresses: bool = False,
221
+ transport: httpx.AsyncBaseTransport | None = None,
222
+ ) -> None:
223
+ self.timeout = timeout
224
+ self.max_bytes = max_bytes
225
+ self.allowed_hosts = allowed_hosts
226
+ self.allow_private_addresses = allow_private_addresses
227
+ self.transport = transport
228
+
229
+ def _validate_url(self, url: str) -> None:
230
+ parsed = urlparse(url)
231
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
232
+ raise ValueError("url must be an absolute http(s) URL")
233
+ if self.allowed_hosts is not None and parsed.hostname not in self.allowed_hosts:
234
+ raise ValueError(f"host is not allowed: {parsed.hostname}")
235
+
236
+ def _direct_transport(self) -> httpx.AsyncBaseTransport:
237
+ """Return the transport for one hostname's direct connections."""
238
+ return self.transport or httpx.AsyncHTTPTransport()
239
+
240
+ def _proxy_for(self, url: httpx.URL) -> str | None:
241
+ """Return the environment proxy this URL goes through, if any.
242
+
243
+ An explicitly supplied transport is never proxied.
244
+ """
245
+ return None if self.transport is not None else _environment_proxy(url)
246
+
247
+ async def _route(self, url: str) -> dict[str, object]:
248
+ """Refuse a non-public destination; return how the request must be sent."""
249
+ target = httpx.URL(url)
250
+ host = target.raw_host.decode("ascii")
251
+ literal = _literal_address(host)
252
+ if literal is not None:
253
+ addresses = [literal]
254
+ else:
255
+ proxy = self._proxy_for(target)
256
+ if proxy is not None:
257
+ # The proxy resolves the name, so there is no address to check or pin.
258
+ return {_PROXY: proxy}
259
+ try:
260
+ with anyio.fail_after(self.timeout):
261
+ found = await anyio.getaddrinfo(host, None, type=socket.SOCK_STREAM)
262
+ except (OSError, TimeoutError) as exc:
263
+ raise httpx.ConnectError(f"could not resolve host: {host}") from exc
264
+ addresses = list(dict.fromkeys(str(item[4][0]) for item in found))
265
+ if not addresses or not all(_is_public(address) for address in addresses):
266
+ raise ValueError(f"host is not a public address: {host}")
267
+ proxy = self._proxy_for(target)
268
+ return {_PROXY: proxy} if proxy is not None else {_ADDRESSES: addresses}
269
+
270
+ async def fetch(self, url: str) -> FetchResult:
271
+ self._validate_url(url)
272
+ async with httpx.AsyncClient(
273
+ follow_redirects=False,
274
+ timeout=self.timeout,
275
+ # With the check off, httpx routes and resolves as it normally would.
276
+ transport=(
277
+ self.transport
278
+ if self.allow_private_addresses
279
+ else _RoutingTransport(self._direct_transport)
280
+ ),
281
+ headers={"Accept": MARKDOWN_ACCEPT, "User-Agent": f"md4a/{__version__}"},
282
+ ) as client:
283
+ current_url = url
284
+ for _ in range(11):
285
+ self._validate_url(current_url)
286
+ route = {} if self.allow_private_addresses else await self._route(current_url)
287
+ request = client.build_request("GET", current_url, extensions=route)
288
+ response = await client.send(request, stream=True)
289
+ if response.is_redirect:
290
+ redirect = response.next_request
291
+ await response.aclose()
292
+ if redirect is None:
293
+ raise ValueError("redirect response did not include a usable location")
294
+ current_url = str(redirect.url)
295
+ continue
296
+
297
+ try:
298
+ response.raise_for_status()
299
+ chunks: list[bytes] = []
300
+ size = 0
301
+ async for chunk in response.aiter_bytes():
302
+ size += len(chunk)
303
+ if size > self.max_bytes:
304
+ raise ValueError(f"response exceeds {self.max_bytes} bytes")
305
+ chunks.append(chunk)
306
+
307
+ content_type = response.headers.get("content-type", "")
308
+ media_type = content_type.partition(";")[0].strip().lower()
309
+ body = b"".join(chunks)
310
+ markdown = (
311
+ body.decode(response.encoding or "utf-8")
312
+ if media_type == MARKDOWN_ACCEPT
313
+ else None
314
+ )
315
+ return FetchResult(
316
+ requested_url=url,
317
+ final_url=str(response.url),
318
+ status_code=response.status_code,
319
+ content_type=content_type,
320
+ markdown=markdown,
321
+ )
322
+ finally:
323
+ await response.aclose()
324
+ raise ValueError("too many redirects")
md4a/middleware.py ADDED
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Sequence
4
+
5
+ from fastapi import FastAPI
6
+ from starlette.datastructures import Headers, MutableHeaders
7
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
8
+
9
+ from ._negotiation import (
10
+ HTML_REPRESENTATION_HEADERS,
11
+ MARKDOWN_CONTENT_TYPE,
12
+ accepts_markdown,
13
+ declared_charset,
14
+ html_to_markdown,
15
+ vary_with_accept,
16
+ )
17
+ from .store import MemoryStore, Store
18
+
19
+ MarkdownProvider = Callable[[str], str | None]
20
+
21
+ # ASGI header names are bytes.
22
+ _HTML_REPRESENTATION_HEADERS = {name.encode() for name in HTML_REPRESENTATION_HEADERS}
23
+
24
+
25
+ def _declared_length(headers: Headers) -> int:
26
+ try:
27
+ return int(headers.get("content-length", "0"))
28
+ except ValueError:
29
+ return 0
30
+
31
+
32
+ def _declared_charset(headers: Headers) -> str:
33
+ return declared_charset(headers.get("content-type", ""))
34
+
35
+
36
+ class MarkdownForAgentsMiddleware:
37
+ """Serve Markdown when a GET request explicitly accepts text/markdown.
38
+
39
+ Cached/provider content wins. Otherwise the normal FastAPI route executes and
40
+ an HTML response is converted, cached by request path, and returned as Markdown.
41
+ Non-GET and non-Markdown requests pass through unchanged.
42
+
43
+ Only successful HTML and Markdown responses are buffered, up to max_html_bytes.
44
+ Other responses, and bodies over that limit, are streamed through unconverted
45
+ and uncached.
46
+
47
+ Cached content is served until the store drops it; give the store a ttl to
48
+ refresh it, or pass cache_responses=False to stop writing responses to the store.
49
+
50
+ Conversion preserves response headers except metadata tied to the HTML bytes.
51
+ Stores contain only content: security/auth middleware must wrap this middleware
52
+ to run on cache hits and provider responses as well as converted responses.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ app: ASGIApp,
58
+ *,
59
+ store: Store | None = None,
60
+ provider: MarkdownProvider | None = None,
61
+ convert_html: bool = True,
62
+ max_html_bytes: int = 5_000_000,
63
+ cache_responses: bool = True,
64
+ content_selector: str | None = None,
65
+ strip_selectors: Sequence[str] = (),
66
+ escape_misc: bool = False,
67
+ literal_selectors: Sequence[str] = (),
68
+ ) -> None:
69
+ self.app = app
70
+ self.store = store or MemoryStore()
71
+ self.provider = provider
72
+ self.convert_html = convert_html
73
+ self.max_html_bytes = max_html_bytes
74
+ self.cache_responses = cache_responses
75
+ self.content_selector = content_selector
76
+ self.strip_selectors = tuple(strip_selectors)
77
+ self.escape_misc = escape_misc
78
+ self.literal_selectors = tuple(literal_selectors)
79
+
80
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
81
+ if scope["type"] != "http" or scope["method"] != "GET":
82
+ await self.app(scope, receive, send)
83
+ return
84
+ request_headers = Headers(scope=scope)
85
+ if not accepts_markdown(request_headers.get("accept", "")):
86
+ await self.app(scope, receive, send)
87
+ return
88
+
89
+ key = scope.get("raw_path", scope["path"].encode()).decode()
90
+ if scope.get("query_string"):
91
+ key += "?" + scope["query_string"].decode()
92
+ content = self.store.get(key)
93
+ if content is None and self.provider is not None:
94
+ content = self.provider(key)
95
+ if content is not None:
96
+ await self._send_markdown(send, content)
97
+ return
98
+
99
+ start: Message | None = None
100
+ media_type = ""
101
+ body_parts: list[bytes] = []
102
+ buffered = 0
103
+ passthrough = False
104
+
105
+ async def release() -> None:
106
+ """Give up on conversion: forward what is held and stop buffering."""
107
+ nonlocal passthrough
108
+ assert start is not None
109
+ passthrough = True
110
+ await send(start)
111
+ if body_parts:
112
+ body = b"".join(body_parts)
113
+ body_parts.clear()
114
+ await send({"type": "http.response.body", "body": body, "more_body": True})
115
+
116
+ async def capture(message: Message) -> None:
117
+ nonlocal start, media_type, buffered, passthrough
118
+ if message["type"] == "http.response.start":
119
+ start = message
120
+ response_headers = Headers(raw=start["headers"])
121
+ media_type = response_headers.get("content-type", "").partition(";")[0].lower()
122
+ convertible = 200 <= start["status"] < 300 and (
123
+ media_type == "text/markdown"
124
+ or (self.convert_html and media_type == "text/html")
125
+ )
126
+ # Forward all messages, including trailers, when the representation
127
+ # cannot be converted. Only buffer bodies that may be converted or
128
+ # cached, and never compressed, partial, or oversized content.
129
+ passthrough = (
130
+ not convertible
131
+ or bool(start.get("trailers", False))
132
+ or any(
133
+ coding.strip().lower() != "identity"
134
+ for value in response_headers.getlist("content-encoding")
135
+ for coding in value.split(",")
136
+ )
137
+ or start["status"] == 206
138
+ or "content-range" in response_headers
139
+ or _declared_length(response_headers) > self.max_html_bytes
140
+ )
141
+ if passthrough:
142
+ await send(message)
143
+ elif passthrough or start is None:
144
+ # Extension messages may precede the start (for example debug).
145
+ await send(message)
146
+ elif message["type"] == "http.response.body":
147
+ body = message.get("body", b"")
148
+ if buffered + len(body) > self.max_html_bytes:
149
+ await release()
150
+ await send(message)
151
+ return
152
+ buffered += len(body)
153
+ body_parts.append(body)
154
+ if not message.get("more_body", False):
155
+ await finish()
156
+ else:
157
+ # Not a body this middleware can hold (for example pathsend).
158
+ await release()
159
+ await send(message)
160
+
161
+ async def finish() -> None:
162
+ assert start is not None
163
+ body = b"".join(body_parts)
164
+ try:
165
+ text = body.decode(_declared_charset(Headers(raw=start["headers"])))
166
+ # Some codecs decode to lone surrogates, which cannot be served or stored.
167
+ text.encode("utf-8")
168
+ except (LookupError, UnicodeError):
169
+ text = None
170
+ if text is None or media_type == "text/markdown":
171
+ # Undecodable content is returned as the application sent it, uncached.
172
+ if text is not None and self.cache_responses:
173
+ self.store.put(key, text)
174
+ await send(start)
175
+ await send({"type": "http.response.body", "body": body})
176
+ return
177
+ converted = html_to_markdown(
178
+ text,
179
+ content_selector=self.content_selector,
180
+ strip_selectors=self.strip_selectors,
181
+ escape_misc=self.escape_misc,
182
+ literal_selectors=self.literal_selectors,
183
+ )
184
+ if self.cache_responses:
185
+ self.store.put(key, converted)
186
+ await self._send_markdown(
187
+ send, converted, status=start["status"], raw_headers=start["headers"]
188
+ )
189
+
190
+ await self.app(scope, receive, capture)
191
+
192
+ @staticmethod
193
+ async def _send_markdown(
194
+ send: Send,
195
+ content: str,
196
+ status: int = 200,
197
+ *,
198
+ raw_headers: list[tuple[bytes, bytes]] | None = None,
199
+ ) -> None:
200
+ body = content.encode("utf-8")
201
+ # Copy raw pairs so repeated fields (especially Set-Cookie) survive and
202
+ # neither the original response nor its mutable headers are modified.
203
+ message: Message = {
204
+ "type": "http.response.start",
205
+ "status": status,
206
+ "headers": [
207
+ (name, value)
208
+ for name, value in (raw_headers or [])
209
+ if name.lower() not in _HTML_REPRESENTATION_HEADERS
210
+ ],
211
+ }
212
+ headers = MutableHeaders(scope=message)
213
+ headers["content-type"] = MARKDOWN_CONTENT_TYPE
214
+ headers["content-length"] = str(len(body))
215
+ headers["vary"] = vary_with_accept(headers.getlist("vary"))
216
+ await send(message)
217
+ await send({"type": "http.response.body", "body": body})
218
+
219
+
220
+ def add_md4a(
221
+ app: FastAPI,
222
+ *,
223
+ store: Store | None = None,
224
+ provider: MarkdownProvider | None = None,
225
+ convert_html: bool = True,
226
+ max_html_bytes: int = 5_000_000,
227
+ cache_responses: bool = True,
228
+ content_selector: str | None = None,
229
+ strip_selectors: Sequence[str] = (),
230
+ escape_misc: bool = False,
231
+ literal_selectors: Sequence[str] = (),
232
+ ) -> None:
233
+ """Add Markdown-for-Agents negotiation to an existing FastAPI app."""
234
+ app.add_middleware(
235
+ MarkdownForAgentsMiddleware,
236
+ store=store,
237
+ provider=provider,
238
+ convert_html=convert_html,
239
+ max_html_bytes=max_html_bytes,
240
+ cache_responses=cache_responses,
241
+ content_selector=content_selector,
242
+ strip_selectors=strip_selectors,
243
+ escape_misc=escape_misc,
244
+ literal_selectors=literal_selectors,
245
+ )
md4a/py.typed ADDED
@@ -0,0 +1 @@
1
+
md4a/store.py ADDED
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import string
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+
11
+ class Store(Protocol):
12
+ def get(self, key: str) -> str | None: ...
13
+ def put(self, key: str, content: str) -> None: ...
14
+
15
+
16
+ def _validated_ttl(ttl: float | None) -> float | None:
17
+ if ttl is not None and ttl <= 0:
18
+ raise ValueError("ttl must be greater than 0, or None to never expire")
19
+ return ttl
20
+
21
+
22
+ class MemoryStore:
23
+ """Process-local buffer store.
24
+
25
+ Entries older than ``ttl`` seconds are treated as missing. With ``max_entries``
26
+ set, storing beyond the limit drops expired entries, then the oldest stored.
27
+ """
28
+
29
+ def __init__(self, *, ttl: float | None = None, max_entries: int | None = None) -> None:
30
+ if max_entries is not None and max_entries < 1:
31
+ raise ValueError("max_entries must be at least 1, or None for no limit")
32
+ self.ttl = _validated_ttl(ttl)
33
+ self.max_entries = max_entries
34
+ self.data: dict[str, str] = {}
35
+ self._stored_at: dict[str, float] = {}
36
+
37
+ def _expired(self, key: str) -> bool:
38
+ return self.ttl is not None and time.monotonic() - self._stored_at[key] >= self.ttl
39
+
40
+ def get(self, key: str) -> str | None:
41
+ if key in self.data and self._expired(key):
42
+ self.delete(key)
43
+ return self.data.get(key)
44
+
45
+ def put(self, key: str, content: str) -> None:
46
+ # Remove first so a re-stored key becomes the newest entry.
47
+ self.delete(key)
48
+ if self.max_entries is not None and len(self.data) >= self.max_entries:
49
+ for stale in [item for item in self.data if self._expired(item)]:
50
+ self.delete(stale)
51
+ while len(self.data) >= self.max_entries:
52
+ self.delete(next(iter(self.data)))
53
+ self.data[key] = content
54
+ self._stored_at[key] = time.monotonic()
55
+
56
+ def delete(self, key: str) -> None:
57
+ self.data.pop(key, None)
58
+ self._stored_at.pop(key, None)
59
+
60
+ def clear(self) -> None:
61
+ self.data.clear()
62
+ self._stored_at.clear()
63
+
64
+
65
+ class FileStore:
66
+ """Filesystem store with hashed filenames and a human-readable index.
67
+
68
+ Entries whose file is older than ``ttl`` seconds are removed when read.
69
+ """
70
+
71
+ def __init__(self, directory: str | Path, *, ttl: float | None = None) -> None:
72
+ self.ttl = _validated_ttl(ttl)
73
+ self.directory = Path(directory)
74
+ self.directory.mkdir(parents=True, exist_ok=True)
75
+
76
+ def _path(self, key: str) -> Path:
77
+ digest = hashlib.sha256(key.encode()).hexdigest()
78
+ return self.directory / f"{digest}.md"
79
+
80
+ def _read_index(self) -> dict[str, str]:
81
+ try:
82
+ index = json.loads((self.directory / "index.json").read_text(encoding="utf-8"))
83
+ except (FileNotFoundError, json.JSONDecodeError):
84
+ return {}
85
+ return index if isinstance(index, dict) else {}
86
+
87
+ def _write_index(self, index: dict[str, str]) -> None:
88
+ (self.directory / "index.json").write_text(
89
+ json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8"
90
+ )
91
+
92
+ def get(self, key: str) -> str | None:
93
+ path = self._path(key)
94
+ try:
95
+ if self.ttl is not None and time.time() - path.stat().st_mtime >= self.ttl:
96
+ self.delete(key)
97
+ return None
98
+ return path.read_text(encoding="utf-8")
99
+ except FileNotFoundError:
100
+ return None
101
+
102
+ def put(self, key: str, content: str) -> None:
103
+ path = self._path(key)
104
+ temporary = path.with_suffix(".tmp")
105
+ temporary.write_text(content, encoding="utf-8")
106
+ temporary.replace(path)
107
+ index = self._read_index()
108
+ index[key] = path.name
109
+ self._write_index(index)
110
+
111
+ def delete(self, key: str) -> None:
112
+ self._path(key).unlink(missing_ok=True)
113
+ index = self._read_index()
114
+ if index.pop(key, None) is not None:
115
+ self._write_index(index)
116
+
117
+ def clear(self) -> None:
118
+ # Only remove files this store names: a SHA-256 hex digest plus ".md".
119
+ for path in self.directory.glob("*.md"):
120
+ if len(path.stem) == 64 and set(path.stem) <= set(string.hexdigits):
121
+ path.unlink(missing_ok=True)
122
+ self._write_index({})