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/__init__.py +57 -0
- md4a/_extras.py +16 -0
- md4a/_negotiation.py +169 -0
- md4a/app.py +72 -0
- md4a/cli.py +90 -0
- md4a/client.py +324 -0
- md4a/middleware.py +245 -0
- md4a/py.typed +1 -0
- md4a/store.py +122 -0
- md4a/wsgi.py +292 -0
- md4a_fastapi-0.1.0.dist-info/METADATA +351 -0
- md4a_fastapi-0.1.0.dist-info/RECORD +15 -0
- md4a_fastapi-0.1.0.dist-info/WHEEL +4 -0
- md4a_fastapi-0.1.0.dist-info/entry_points.txt +2 -0
- md4a_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
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({})
|