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 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)))