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/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Markdown-for-Agents helpers for FastAPI (ASGI) and WSGI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .client import FetchResult, MarkdownForAgentsClient
|
|
12
|
+
from .middleware import MarkdownForAgentsMiddleware, add_md4a
|
|
13
|
+
from .store import FileStore, MemoryStore
|
|
14
|
+
from .wsgi import MarkdownForAgentsWSGIMiddleware
|
|
15
|
+
|
|
16
|
+
# Loaded on first use, so importing md4a.wsgi for a Flask application does not
|
|
17
|
+
# import FastAPI, Starlette or httpx.
|
|
18
|
+
_EXPORTS = {
|
|
19
|
+
"FetchResult": ".client",
|
|
20
|
+
"MarkdownForAgentsClient": ".client",
|
|
21
|
+
"MarkdownForAgentsMiddleware": ".middleware",
|
|
22
|
+
"add_md4a": ".middleware",
|
|
23
|
+
"FileStore": ".store",
|
|
24
|
+
"MemoryStore": ".store",
|
|
25
|
+
"MarkdownForAgentsWSGIMiddleware": ".wsgi",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FetchResult",
|
|
30
|
+
"FileStore",
|
|
31
|
+
"MarkdownForAgentsClient",
|
|
32
|
+
"MarkdownForAgentsMiddleware",
|
|
33
|
+
"MarkdownForAgentsWSGIMiddleware",
|
|
34
|
+
"MemoryStore",
|
|
35
|
+
"__version__",
|
|
36
|
+
"add_md4a",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Importing these eagerly used to make them attributes of the package, so
|
|
41
|
+
# ``md4a.store.MemoryStore`` keeps working after ``import md4a`` alone.
|
|
42
|
+
_SUBMODULES = frozenset({"client", "middleware", "store", "wsgi"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __getattr__(name: str) -> Any:
|
|
46
|
+
if name in _EXPORTS:
|
|
47
|
+
value = getattr(import_module(_EXPORTS[name], __name__), name)
|
|
48
|
+
globals()[name] = value
|
|
49
|
+
return value
|
|
50
|
+
if name in _SUBMODULES:
|
|
51
|
+
return import_module(f".{name}", __name__)
|
|
52
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def __dir__() -> list[str]:
|
|
56
|
+
"""Include lazy public exports in module discovery before first access."""
|
|
57
|
+
return sorted(set(globals()) | set(__all__))
|
md4a/_extras.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Clear errors for features that need the optional ``server`` dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
SERVER_EXTRA = frozenset({"anyio", "httpx", "uvicorn"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def require_server_extra(feature: str, exc: ImportError) -> ModuleNotFoundError:
|
|
9
|
+
"""Explain how to install a missing server dependency; re-raise anything else."""
|
|
10
|
+
if exc.name not in SERVER_EXTRA:
|
|
11
|
+
raise exc
|
|
12
|
+
return ModuleNotFoundError(
|
|
13
|
+
f"{feature} needs {exc.name}, which is part of the optional server dependencies. "
|
|
14
|
+
"Install them with: pip install 'md4a-fastapi[server]'",
|
|
15
|
+
name=exc.name,
|
|
16
|
+
)
|
md4a/_negotiation.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Negotiation rules shared by the ASGI and WSGI middleware.
|
|
2
|
+
|
|
3
|
+
Nothing here imports a web framework, so either middleware can use it alone.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from email.message import Message as EmailMessage
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
from markdownify import MarkdownConverter
|
|
15
|
+
|
|
16
|
+
# A tag name no page uses; markdownify calls convert_<name> for it.
|
|
17
|
+
_LITERAL_TAG = "md4aliteral"
|
|
18
|
+
|
|
19
|
+
# These describe the original HTML bytes and cannot be reused after conversion.
|
|
20
|
+
HTML_REPRESENTATION_HEADERS = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"content-encoding",
|
|
23
|
+
"content-location",
|
|
24
|
+
"content-range",
|
|
25
|
+
"accept-ranges",
|
|
26
|
+
"etag",
|
|
27
|
+
"last-modified",
|
|
28
|
+
"content-md5",
|
|
29
|
+
"digest",
|
|
30
|
+
"content-digest",
|
|
31
|
+
"repr-digest",
|
|
32
|
+
"transfer-encoding",
|
|
33
|
+
"trailer",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def accepts_markdown(value: str) -> bool:
|
|
41
|
+
"""Return true when text/markdown is an accepted media range (q=0 excluded)."""
|
|
42
|
+
for item in value.lower().split(","):
|
|
43
|
+
media, *parameters = (part.strip() for part in item.split(";"))
|
|
44
|
+
if media != "text/markdown":
|
|
45
|
+
continue
|
|
46
|
+
quality = next((p.partition("=")[2] for p in parameters if p.startswith("q=")), "1")
|
|
47
|
+
try:
|
|
48
|
+
return float(quality) > 0
|
|
49
|
+
except ValueError:
|
|
50
|
+
return False
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def declared_charset(content_type: str) -> str:
|
|
55
|
+
"""Charset named in a Content-Type value, or utf-8 when it names none."""
|
|
56
|
+
# Quote-aware: a ";" inside a quoted parameter value does not start a parameter.
|
|
57
|
+
message = EmailMessage()
|
|
58
|
+
message["content-type"] = content_type
|
|
59
|
+
return message.get_content_charset() or "utf-8"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def vary_with_accept(values: list[str]) -> str:
|
|
63
|
+
"""Merge the response's Vary field values and make sure Accept is among them."""
|
|
64
|
+
vary = [item.strip() for value in values for item in value.split(",")]
|
|
65
|
+
if "*" in vary:
|
|
66
|
+
return "*"
|
|
67
|
+
if "accept" not in {item.lower() for item in vary}:
|
|
68
|
+
vary.append("Accept")
|
|
69
|
+
return ", ".join(item for item in vary if item)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Matched elements whose Markdown only adds block structure around their content
|
|
73
|
+
# keep their tag; any other match (a link, an image, inline formatting, code) is
|
|
74
|
+
# replaced, so its own Markdown syntax cannot wrap the literal text.
|
|
75
|
+
_LITERAL_CONTAINERS = frozenset(
|
|
76
|
+
{
|
|
77
|
+
"address",
|
|
78
|
+
"article",
|
|
79
|
+
"aside",
|
|
80
|
+
"blockquote",
|
|
81
|
+
"caption",
|
|
82
|
+
"dd",
|
|
83
|
+
"div",
|
|
84
|
+
"dt",
|
|
85
|
+
"figcaption",
|
|
86
|
+
"footer",
|
|
87
|
+
"h1",
|
|
88
|
+
"h2",
|
|
89
|
+
"h3",
|
|
90
|
+
"h4",
|
|
91
|
+
"h5",
|
|
92
|
+
"h6",
|
|
93
|
+
"header",
|
|
94
|
+
"li",
|
|
95
|
+
"main",
|
|
96
|
+
"nav",
|
|
97
|
+
"p",
|
|
98
|
+
"section",
|
|
99
|
+
"td",
|
|
100
|
+
"th",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _code_span(text: str, *, in_table: bool = False) -> str:
|
|
106
|
+
"""Text as an inline code span that no Markdown in it can escape from."""
|
|
107
|
+
text = " ".join(text.split())
|
|
108
|
+
if not text:
|
|
109
|
+
return ""
|
|
110
|
+
if in_table:
|
|
111
|
+
# Table rows are split into cells before code spans are parsed, so a
|
|
112
|
+
# pipe must be escaped even inside one; GFM shows "\|" there as "|".
|
|
113
|
+
text = text.replace("|", "\\|")
|
|
114
|
+
# A fence longer than any backtick run in the text, padded with a space on
|
|
115
|
+
# each side so text that starts or ends with a backtick cannot join it.
|
|
116
|
+
fence = "`" * (max((len(run) for run in re.findall(r"`+", text)), default=0) + 1)
|
|
117
|
+
# The spaces outside the fences keep them from joining a backtick next to
|
|
118
|
+
# the span, including the fence of an adjacent literal span.
|
|
119
|
+
return f" {fence} {text} {fence} "
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _Converter(MarkdownConverter):
|
|
123
|
+
def convert_md4aliteral(self, el: Any, text: str, *args: Any, **kwargs: Any) -> str:
|
|
124
|
+
return _code_span(el.get_text(), in_table=el.find_parent(["td", "th"]) is not None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def html_to_markdown(
|
|
128
|
+
html: str,
|
|
129
|
+
*,
|
|
130
|
+
content_selector: str | None = None,
|
|
131
|
+
strip_selectors: Sequence[str] = (),
|
|
132
|
+
escape_misc: bool = False,
|
|
133
|
+
literal_selectors: Sequence[str] = (),
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Convert an HTML document to Markdown, optionally only part of it.
|
|
136
|
+
|
|
137
|
+
``strip_selectors`` are CSS selectors whose elements are removed first.
|
|
138
|
+
``content_selector`` is a CSS selector; its first match is what gets
|
|
139
|
+
converted. A page it does not match is converted whole, so a template
|
|
140
|
+
without that element still yields Markdown rather than nothing.
|
|
141
|
+
|
|
142
|
+
``escape_misc`` escapes Markdown punctuation in page text (``[``, ``]``,
|
|
143
|
+
``#``, ``>`` and the like) so it reads as text, not as links or headings.
|
|
144
|
+
``literal_selectors`` are CSS selectors whose elements' text is emitted as
|
|
145
|
+
an inline code span: shown as written, with nothing in it rendered as a
|
|
146
|
+
link, image, or autolink. Use it for untrusted text such as user comments,
|
|
147
|
+
together with ``escape_misc`` so a stray backtick in the page's own text
|
|
148
|
+
cannot open a code span that runs into the literal one.
|
|
149
|
+
"""
|
|
150
|
+
converter = _Converter(heading_style="ATX", escape_misc=escape_misc)
|
|
151
|
+
if content_selector is None and not strip_selectors and not literal_selectors:
|
|
152
|
+
return converter.convert(html)
|
|
153
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
154
|
+
for selector in strip_selectors:
|
|
155
|
+
for element in soup.select(selector):
|
|
156
|
+
element.decompose()
|
|
157
|
+
for selector in literal_selectors:
|
|
158
|
+
for element in soup.select(selector):
|
|
159
|
+
if element.parent is None:
|
|
160
|
+
continue # inside an element already made literal
|
|
161
|
+
literal = soup.new_tag(_LITERAL_TAG)
|
|
162
|
+
literal.string = element.get_text()
|
|
163
|
+
if element.name in _LITERAL_CONTAINERS:
|
|
164
|
+
element.clear()
|
|
165
|
+
element.append(literal)
|
|
166
|
+
else:
|
|
167
|
+
element.replace_with(literal)
|
|
168
|
+
content = soup.select_one(content_selector) if content_selector else None
|
|
169
|
+
return converter.convert(str(content if content is not None else soup))
|
md4a/app.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Query, Response
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from ._extras import require_server_extra
|
|
10
|
+
from .store import FileStore
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .client import MarkdownForAgentsClient
|
|
16
|
+
except ImportError as exc:
|
|
17
|
+
raise require_server_extra("The standalone md4a service", exc) from exc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app() -> FastAPI:
|
|
21
|
+
cache_dir = Path(os.getenv("MD4A_CACHE_DIR", ".md4a-cache"))
|
|
22
|
+
allowed = os.getenv("MD4A_ALLOWED_HOSTS")
|
|
23
|
+
allowed_hosts = (
|
|
24
|
+
{host.strip() for host in allowed.split(",") if host.strip()} if allowed else None
|
|
25
|
+
)
|
|
26
|
+
store = FileStore(cache_dir)
|
|
27
|
+
allow_private = os.getenv("MD4A_ALLOW_PRIVATE_ADDRESSES", "").strip().lower() in {
|
|
28
|
+
"1",
|
|
29
|
+
"true",
|
|
30
|
+
"yes",
|
|
31
|
+
}
|
|
32
|
+
client = MarkdownForAgentsClient(
|
|
33
|
+
allowed_hosts=allowed_hosts, allow_private_addresses=allow_private
|
|
34
|
+
)
|
|
35
|
+
app = FastAPI(title="md4a", version=__version__)
|
|
36
|
+
|
|
37
|
+
@app.get("/health")
|
|
38
|
+
async def health() -> dict[str, str]:
|
|
39
|
+
return {"status": "ok"}
|
|
40
|
+
|
|
41
|
+
@app.get("/fetch")
|
|
42
|
+
async def fetch(url: str = Query(..., description="Absolute page URL")) -> Response:
|
|
43
|
+
try:
|
|
44
|
+
result = await client.fetch(url)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
47
|
+
except httpx.HTTPStatusError as exc:
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=502,
|
|
50
|
+
detail=f"origin returned HTTP {exc.response.status_code}",
|
|
51
|
+
) from exc
|
|
52
|
+
except httpx.RequestError as exc:
|
|
53
|
+
raise HTTPException(status_code=502, detail=f"origin request failed: {exc}") from exc
|
|
54
|
+
if not result.available:
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=406,
|
|
57
|
+
detail=(
|
|
58
|
+
f"origin returned {result.content_type or 'no content type'}, not text/markdown"
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
assert result.markdown is not None
|
|
62
|
+
store.put(result.final_url, result.markdown)
|
|
63
|
+
return Response(
|
|
64
|
+
result.markdown,
|
|
65
|
+
media_type="text/markdown",
|
|
66
|
+
headers={"X-MD4A-Source": result.final_url},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return app
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app = create_app()
|
md4a/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ._extras import require_server_extra
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import httpx
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
from .client import MarkdownForAgentsClient
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
# Reported by main() so the md4a command prints one line, not a traceback.
|
|
17
|
+
_missing_extra: ModuleNotFoundError | None = require_server_extra("The md4a command", exc)
|
|
18
|
+
else:
|
|
19
|
+
_missing_extra = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parser() -> argparse.ArgumentParser:
|
|
23
|
+
result = argparse.ArgumentParser(prog="md4a", description="Markdown-for-Agents utility")
|
|
24
|
+
sub = result.add_subparsers(dest="command", required=True)
|
|
25
|
+
fetch = sub.add_parser("fetch", help="fetch and save a Markdown-for-Agents page")
|
|
26
|
+
fetch.add_argument("url")
|
|
27
|
+
fetch.add_argument("-o", "--output", help="output file (default: stdout)")
|
|
28
|
+
fetch.add_argument(
|
|
29
|
+
"--allowed-host",
|
|
30
|
+
action="append",
|
|
31
|
+
metavar="HOST",
|
|
32
|
+
help="only contact this host; repeatable (default: MD4A_ALLOWED_HOSTS, else unrestricted)",
|
|
33
|
+
)
|
|
34
|
+
fetch.add_argument(
|
|
35
|
+
"--allow-private-addresses",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="permit loopback, private, and other non-public destinations",
|
|
38
|
+
)
|
|
39
|
+
fetch.add_argument("--timeout", type=float, default=15.0, help="seconds (default: 15)")
|
|
40
|
+
fetch.add_argument(
|
|
41
|
+
"--max-bytes", type=int, default=5_000_000, help="response size limit (default: 5000000)"
|
|
42
|
+
)
|
|
43
|
+
serve = sub.add_parser("serve", help="run the standalone FastAPI service")
|
|
44
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
45
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _allowed_hosts(options: list[str] | None) -> set[str] | None:
|
|
50
|
+
if options:
|
|
51
|
+
return set(options)
|
|
52
|
+
allowed = os.getenv("MD4A_ALLOWED_HOSTS")
|
|
53
|
+
return {host.strip() for host in allowed.split(",") if host.strip()} if allowed else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _fetch(args: argparse.Namespace) -> int:
|
|
57
|
+
client = MarkdownForAgentsClient(
|
|
58
|
+
timeout=args.timeout,
|
|
59
|
+
max_bytes=args.max_bytes,
|
|
60
|
+
allowed_hosts=_allowed_hosts(args.allowed_host),
|
|
61
|
+
allow_private_addresses=args.allow_private_addresses,
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
result = await client.fetch(args.url)
|
|
65
|
+
except httpx.HTTPStatusError as exc:
|
|
66
|
+
print(f"md4a: origin returned HTTP {exc.response.status_code}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
except (ValueError, httpx.RequestError) as exc:
|
|
69
|
+
print(f"md4a: {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
if not result.available:
|
|
72
|
+
print(f"No Markdown-for-Agents response ({result.content_type or 'unknown content type'})")
|
|
73
|
+
return 2
|
|
74
|
+
assert result.markdown is not None
|
|
75
|
+
if args.output:
|
|
76
|
+
with open(args.output, "w", encoding="utf-8") as stream:
|
|
77
|
+
stream.write(result.markdown)
|
|
78
|
+
else:
|
|
79
|
+
print(result.markdown, end="")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str] | None = None) -> None:
|
|
84
|
+
args = parser().parse_args(argv)
|
|
85
|
+
if _missing_extra is not None:
|
|
86
|
+
raise SystemExit(f"md4a: {_missing_extra}")
|
|
87
|
+
if args.command == "serve":
|
|
88
|
+
uvicorn.run("md4a.app:app", host=args.host, port=args.port)
|
|
89
|
+
else:
|
|
90
|
+
raise SystemExit(asyncio.run(_fetch(args)))
|