md4a-fastapi 0.1.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ .env
2
+ .venv/
3
+ .md4a-cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+ uv.lock
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ All notable changes to `md4a-fastapi` are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/). Until 1.0, minor versions may change
6
+ the API.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-17
11
+
12
+ First release.
13
+
14
+ ### Added
15
+
16
+ - `MarkdownForAgentsMiddleware` and `add_md4a` for FastAPI and other ASGI apps:
17
+ a `GET` request that accepts `text/markdown` gets a successful HTML response
18
+ converted to Markdown, cached by path and query string; everything else passes
19
+ through unchanged.
20
+ - `MarkdownForAgentsWSGIMiddleware` for Flask and other WSGI apps, with the same
21
+ options and rules. Importing it does not import FastAPI, Starlette, or httpx.
22
+ - A synchronous `provider` hook for hand-written Markdown, served ahead of conversion.
23
+ - `MemoryStore` and `FileStore`, with `ttl`, `delete`, and `clear`, and
24
+ `max_entries` for `MemoryStore`. `cache_responses=False` stops writing
25
+ responses to the store.
26
+ - `content_selector` and `strip_selectors` to convert only the page content.
27
+ - `escape_misc` and `literal_selectors` (both off by default) to keep untrusted
28
+ text, such as user comments, from becoming links or images in the Markdown.
29
+ - `MarkdownForAgentsClient` for remote discovery: sends `Accept: text/markdown`,
30
+ accepts only `text/markdown` responses, and checks schemes, redirects, host
31
+ allowlists, timeouts, response size, and non-public destination addresses.
32
+ - A standalone FastAPI service (`/health`, `/fetch`) and the `md4a fetch` and
33
+ `md4a serve` commands.
34
+
35
+ ### Behavior
36
+
37
+ - Converted responses keep application headers, including repeated `Set-Cookie`;
38
+ md4a recalculates the content type and length, merges `Accept` into `Vary`, and
39
+ drops validators, digests, and range metadata that described the HTML bytes.
40
+ - Only successful HTML and Markdown bodies are buffered, up to `max_html_bytes`
41
+ (5 MB by default). Other media types and compressed, partial, oversized, and
42
+ (ASGI) trailer-bearing responses pass through unconverted and uncached. A
43
+ streamed HTML or Markdown body is held until it ends or reaches the limit, so
44
+ conversion does not preserve chunk timing.
45
+ - Bodies are decoded with the charset declared in `Content-Type`; a response that
46
+ cannot be decoded is returned unchanged and is not cached.
47
+
48
+ ### Packaging
49
+
50
+ - The base install (`beautifulsoup4`, `markdownify`, `fastapi`) covers both
51
+ middlewares and the stores. The `server` extra adds `httpx`, `anyio`, and
52
+ `uvicorn[standard]` for the client, the service, and the `md4a` command, which
53
+ say how to install it when it is missing. `flask` and `wsgi` are names for the
54
+ base install.
55
+ - Python 3.11 to 3.14. Typed (`py.typed`).
56
+
57
+ [Unreleased]: https://github.com/adamtasteslikegood/md4a-fastapi/compare/v0.1.0...HEAD
58
+ [0.1.0]: https://github.com/adamtasteslikegood/md4a-fastapi/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 adam schoen - (https://github.com/adamtasteslikegood)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,351 @@
1
+ Metadata-Version: 2.5
2
+ Name: md4a-fastapi
3
+ Version: 0.1.0
4
+ Summary: Markdown for Agents content negotiation for FastAPI, Flask, and other WSGI apps
5
+ Project-URL: Changelog, https://github.com/adamtasteslikegood/md4a-fastapi/blob/main/CHANGELOG.md
6
+ Project-URL: Documentation, https://github.com/adamtasteslikegood/md4a-fastapi#readme
7
+ Project-URL: Issues, https://github.com/adamtasteslikegood/md4a-fastapi/issues
8
+ Project-URL: Repository, https://github.com/adamtasteslikegood/md4a-fastapi
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,asgi,fastapi,flask,markdown,wsgi
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Framework :: Flask
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
24
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: beautifulsoup4<5,>=4.12
28
+ Requires-Dist: fastapi<1,>=0.115
29
+ Requires-Dist: markdownify<2,>=0.14
30
+ Provides-Extra: flask
31
+ Provides-Extra: server
32
+ Requires-Dist: anyio<5,>=3.6.2; extra == 'server'
33
+ Requires-Dist: httpx<1,>=0.27; extra == 'server'
34
+ Requires-Dist: uvicorn[standard]<1,>=0.30; extra == 'server'
35
+ Provides-Extra: test
36
+ Requires-Dist: anyio<5,>=3.6.2; extra == 'test'
37
+ Requires-Dist: httpx<1,>=0.27; extra == 'test'
38
+ Requires-Dist: markdown-it-py<5,>=3; extra == 'test'
39
+ Requires-Dist: pytest<10,>=8; extra == 'test'
40
+ Requires-Dist: ruff<1,>=0.12; extra == 'test'
41
+ Requires-Dist: trio<1,>=0.25; extra == 'test'
42
+ Requires-Dist: uvicorn[standard]<1,>=0.30; extra == 'test'
43
+ Provides-Extra: wsgi
44
+ Description-Content-Type: text/markdown
45
+
46
+ # md4a-fastapi
47
+
48
+ [![CI](https://github.com/adamtasteslikegood/md4a-fastapi/actions/workflows/ci.yml/badge.svg)](https://github.com/adamtasteslikegood/md4a-fastapi/actions/workflows/ci.yml)
49
+ [![CodeQL](https://github.com/adamtasteslikegood/md4a-fastapi/actions/workflows/codeql.yml/badge.svg)](https://github.com/adamtasteslikegood/md4a-fastapi/actions/workflows/codeql.yml)
50
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
51
+
52
+ `md4a-fastapi` adds Markdown for Agents content negotiation to FastAPI applications.
53
+ When an agent sends `Accept: text/markdown`, an existing HTML route can return a
54
+ compact Markdown representation while normal browser requests remain unchanged.
55
+
56
+ The package also includes a client and standalone service for discovering whether
57
+ remote pages natively serve Markdown, then buffering or storing verified Markdown
58
+ responses.
59
+
60
+ > Status: early alpha. APIs may change before the first stable release.
61
+
62
+ ## Features
63
+
64
+ - ASGI middleware designed for FastAPI
65
+ - WSGI middleware for Flask and other WSGI applications
66
+ - `Accept: text/markdown` negotiation for GET requests
67
+ - HTML-to-Markdown conversion with transparent pass-through for other media types
68
+ - Synchronous provider hook for hand-authored Markdown
69
+ - In-memory and filesystem-backed content stores
70
+ - Remote Markdown discovery with redirect, host allowlist, timeout, and size controls
71
+ - Standalone FastAPI service and `md4a` command-line interface
72
+
73
+ ## Installation
74
+
75
+ ```bash
76
+ python -m pip install md4a-fastapi # FastAPI and Flask middleware
77
+ python -m pip install 'md4a-fastapi[server]' # adds the md4a command, client, and service
78
+ ```
79
+
80
+ With uv, use `uv add md4a-fastapi` or `uv pip install md4a-fastapi` the same way.
81
+
82
+ | Install | What you get | Extra dependencies |
83
+ | --- | --- | --- |
84
+ | `md4a-fastapi` | ASGI and WSGI middleware, content stores | `beautifulsoup4`, `markdownify`, `fastapi` |
85
+ | `md4a-fastapi[server]` | also the remote client, standalone service, `md4a` command | `httpx`, `anyio`, `uvicorn[standard]` |
86
+ | `md4a-fastapi[flask]` | the same as the base install | none |
87
+
88
+ The WSGI middleware needs nothing beyond the base install and never imports
89
+ FastAPI, so a Flask application can use the base install as it is. `[flask]` and
90
+ `[wsgi]` are accepted as names for it. Without the server extra, importing
91
+ `md4a.client` or running `md4a` says which package to install.
92
+
93
+ ### Development setup
94
+
95
+ Using [uv](https://docs.astral.sh/uv/) (recommended):
96
+
97
+ ```bash
98
+ git clone https://github.com/adamtasteslikegood/md4a-fastapi.git
99
+ cd md4a-fastapi
100
+ uv sync --extra test
101
+ ```
102
+
103
+ This creates or updates the project virtual environment in `.venv`, installs
104
+ the package in editable mode, and installs the test tools. No activation is
105
+ needed when using the `uv run` commands below. If you prefer to activate the
106
+ environment, use `source .venv/bin/activate` on macOS/Linux or
107
+ `.venv\Scripts\Activate.ps1` in Windows PowerShell, then run the commands
108
+ without the `uv run` prefix.
109
+
110
+ Using Python's built-in venv and pip:
111
+
112
+ ```bash
113
+ git clone https://github.com/adamtasteslikegood/md4a-fastapi.git
114
+ cd md4a-fastapi
115
+ python -m venv .venv
116
+ source .venv/bin/activate
117
+ python -m pip install --upgrade pip
118
+ python -m pip install -e '.[test]'
119
+ ```
120
+
121
+ ## FastAPI middleware
122
+
123
+ ```python
124
+ from fastapi import FastAPI
125
+ from fastapi.responses import HTMLResponse
126
+
127
+ from md4a import FileStore, add_md4a
128
+
129
+ app = FastAPI()
130
+ add_md4a(app, store=FileStore(".md4a-cache"))
131
+
132
+
133
+ @app.get("/", response_class=HTMLResponse)
134
+ def home() -> str:
135
+ return "<h1>Hello agents</h1><p>This remains HTML for browsers.</p>"
136
+ ```
137
+
138
+ Request either representation from the same URL:
139
+
140
+ ```bash
141
+ curl http://localhost:8000/
142
+ curl -H 'Accept: text/markdown' http://localhost:8000/
143
+ ```
144
+
145
+ For hand-authored Markdown, provide a callable that returns Markdown or `None`:
146
+
147
+ ```python
148
+ pages = {"/": "# Hello agents\n"}
149
+ add_md4a(app, provider=lambda key: pages.get(key), convert_html=False)
150
+ ```
151
+
152
+ Provider and cached content take precedence. Otherwise, when `convert_html=True`,
153
+ the middleware executes the normal route, converts a successful HTML response,
154
+ caches it by path and query string, and responds as `text/markdown`. JSON and other
155
+ response types pass through unchanged.
156
+
157
+ Converted responses retain application headers, including security headers, CORS,
158
+ and repeated `Set-Cookie` fields. md4a recalculates the content type and length,
159
+ merges `Accept` into `Vary`, and removes HTML-specific validators, digests, and
160
+ range metadata. Compressed, partial, and trailer-bearing responses pass through
161
+ unchanged.
162
+
163
+ Only successful HTML and Markdown responses are buffered, up to `max_html_bytes`
164
+ (5 MB by default; pass `max_html_bytes=` to `add_md4a`). Everything else, including
165
+ streaming responses and bodies over the limit, is forwarded as it arrives and is
166
+ neither converted nor cached. Bodies are decoded with the charset declared in
167
+ `Content-Type` (UTF-8 when absent); a response that cannot be decoded is returned
168
+ unchanged and is not cached.
169
+
170
+ For headers that must be present on **every** response, register your security/CORS
171
+ middleware **after** `add_md4a(app)` so it wraps md4a. Cache hits and provider
172
+ responses skip the wrapped application; stores hold Markdown text only, not
173
+ response headers. Use caching for public, non-personalized content, and keep
174
+ authentication outside md4a: route-level authentication does not run on these
175
+ shortcuts. See [middleware ordering](QUICKSTART.md#middleware-ordering-and-response-headers).
176
+
177
+ ### Converting only the content
178
+
179
+ By default the whole HTML document is converted, navigation and footer included.
180
+ `content_selector` is a CSS selector; only its first match is converted.
181
+ `strip_selectors` are CSS selectors whose elements are removed first:
182
+
183
+ ```python
184
+ add_md4a(app, content_selector="main", strip_selectors=[".related", "form"])
185
+ ```
186
+
187
+ A page the content selector does not match is converted whole. Provider, cached,
188
+ and application-authored Markdown are not affected.
189
+
190
+ ### Escaping untrusted text
191
+
192
+ Text on a page can contain Markdown syntax that an agent would read as a link or
193
+ image, for example a user comment such as `[Free coupons](https://evil.example)`.
194
+ Two options keep such text inert. Both are off by default.
195
+
196
+ ```python
197
+ add_md4a(app, escape_misc=True, literal_selectors=['[data-md="plain"]'])
198
+ ```
199
+
200
+ - `escape_misc=True` escapes Markdown punctuation (`[`, `]`, `#`, `>`, and the
201
+ like) in all page text, so it cannot form links, images, or headings. Some
202
+ text gains backslashes, which Markdown readers drop.
203
+ - `literal_selectors` are CSS selectors for elements whose text is emitted as an
204
+ inline code span. Escaping does not stop bare URLs and email addresses from
205
+ being linked, but nothing inside a code span is, so use this for user-written
206
+ text. Whitespace is collapsed to single spaces and nested markup is kept as text.
207
+ A matched link, image, or code element is replaced by the span, so its own
208
+ syntax cannot wrap the text; in a table cell, pipes are escaped.
209
+
210
+ Use the two together: without `escape_misc`, a stray backtick in the page's own
211
+ text can open a code span that runs into the literal one. Both options work the
212
+ same in the WSGI middleware.
213
+
214
+ ## Flask and other WSGI applications
215
+
216
+ `MarkdownForAgentsWSGIMiddleware` applies the same negotiation to a WSGI
217
+ application. It takes the same options as `add_md4a`:
218
+
219
+ ```python
220
+ from flask import Flask
221
+
222
+ from md4a import FileStore, MarkdownForAgentsWSGIMiddleware
223
+
224
+ app = Flask(__name__)
225
+ app.wsgi_app = MarkdownForAgentsWSGIMiddleware(app.wsgi_app, store=FileStore(".md4a-cache"))
226
+
227
+
228
+ @app.get("/")
229
+ def home() -> str:
230
+ return "<h1>Hello agents</h1><p>This remains HTML for browsers.</p>"
231
+ ```
232
+
233
+ The rules above apply unchanged: provider and cached content take precedence,
234
+ only successful HTML and Markdown `GET` responses up to `max_html_bytes` are
235
+ buffered, application headers are kept, and compressed, partial, and oversized
236
+ responses pass through. The cache key is the percent-encoded `SCRIPT_NAME` plus
237
+ `PATH_INFO`, followed by `?` and the query string when there is one.
238
+
239
+ A response the application sends through the legacy `write()` callable passes
240
+ through unconverted. Importing `md4a.wsgi`, or `MarkdownForAgentsWSGIMiddleware`
241
+ from `md4a`, does not import FastAPI or Starlette, although the base install
242
+ still includes them.
243
+
244
+ Use WSGI conversion for ordinary buffered page responses, not streaming HTML
245
+ iterables. The middleware must see the complete eligible body before it can choose
246
+ converted headers or an unchanged size/decoding fallback, so that conversion path
247
+ cannot preserve PEP 3333 asynchronous block-boundary scheduling. Responses whose
248
+ initial status and headers establish pass-through return the application's original
249
+ iterable, including `wsgi.file_wrapper` objects used for optimized file transfer.
250
+
251
+ As with ASGI, cache and provider hits return before the wrapped application runs,
252
+ so wrap only public, non-personalized pages and keep authentication outside md4a.
253
+
254
+ ## Cache lifetime
255
+
256
+ Cached Markdown is served until the store drops it, and by default a store never
257
+ does: a converted page stays as it was first rendered. Give the store a lifetime,
258
+ bound it, or stop caching responses:
259
+
260
+ ```python
261
+ from md4a import FileStore, MemoryStore, add_md4a
262
+
263
+ add_md4a(app, store=MemoryStore(ttl=300, max_entries=1000)) # 5 minutes, 1000 pages
264
+ add_md4a(app, store=FileStore(".md4a-cache", ttl=3600)) # 1 hour, on disk
265
+ add_md4a(app, cache_responses=False) # convert every request
266
+ ```
267
+
268
+ - `ttl` is in seconds. An entry that old is treated as missing, so the route runs
269
+ again and the fresh result is stored.
270
+ - `max_entries` (`MemoryStore` only) drops expired entries, then the oldest stored,
271
+ when the limit is reached. The cache key includes the query string, so set it
272
+ when callers control the query.
273
+ - `store.delete(key)` and `store.clear()` remove entries by hand, for example after
274
+ publishing a change.
275
+ - `cache_responses=False` stops md4a writing responses to the store. Content already
276
+ in the store, and provider content, is still served.
277
+
278
+ ## Remote discovery and standalone service
279
+
280
+ These need the server extra: `python -m pip install 'md4a-fastapi[server]'`.
281
+
282
+ With the uv-managed project environment, run the CLI with `uv run`:
283
+
284
+ ```bash
285
+ # Show all commands
286
+ uv run md4a -h
287
+
288
+ # Verify that the origin returns text/markdown, then save it
289
+ uv run md4a fetch https://blog.cloudflare.com/the-agentic-internet/ -o article.md
290
+
291
+ # Start the standalone API
292
+ uv run md4a serve --host 127.0.0.1 --port 8000
293
+ ```
294
+
295
+ After installing with pip, use the same commands without the `uv run` prefix:
296
+
297
+ ```bash
298
+ # Show all commands
299
+ md4a -h
300
+
301
+ # Verify that the origin returns text/markdown, then save it
302
+ md4a fetch https://blog.cloudflare.com/the-agentic-internet/ -o article.md
303
+
304
+ # Start the standalone API
305
+ md4a serve --host 127.0.0.1 --port 8000
306
+ curl -H 'Accept: text/markdown' \
307
+ 'http://127.0.0.1:8000/fetch?url=https%3A%2F%2Fblog.cloudflare.com%2Fthe-agentic-internet%2F'
308
+ ```
309
+
310
+ The uv and pip commands above start the same standalone FastAPI service. Keep
311
+ the server running in one terminal and use the `curl` request from another.
312
+
313
+ The remote client sends `Accept: text/markdown` and only considers the page
314
+ available when the origin confirms `Content-Type: text/markdown`. Redirects are
315
+ validated before following them, and responses are capped at 5 MB by default.
316
+
317
+ Standalone configuration:
318
+
319
+ | Variable | Default | Purpose |
320
+ | --- | --- | --- |
321
+ | `MD4A_CACHE_DIR` | `.md4a-cache` | Directory for fetched Markdown and its index |
322
+ | `MD4A_ALLOWED_HOSTS` | unrestricted | Comma-separated hostname allowlist |
323
+ | `MD4A_ALLOW_PRIVATE_ADDRESSES` | off | Set to `1`, `true`, or `yes` to permit non-public destinations |
324
+
325
+ The client refuses destinations that are, or resolve to, loopback, private,
326
+ link-local, or other non-public addresses, on the first request and on every
327
+ redirect, and connects to the address it checked. This applies to allowlisted hosts
328
+ too. Behind an environment proxy the proxy resolves names, so only literal addresses
329
+ and hosts that bypass the proxy are checked; see [SECURITY.md](SECURITY.md). For local development, pass `allow_private_addresses=True` to
330
+ `MarkdownForAgentsClient`, `--allow-private-addresses` to `md4a fetch`, or set
331
+ `MD4A_ALLOW_PRIVATE_ADDRESSES` for the service.
332
+
333
+ Always set `MD4A_ALLOWED_HOSTS` before exposing the standalone fetch service to
334
+ untrusted users. An unrestricted URL-fetching endpoint can create an SSRF risk.
335
+
336
+ ## Documentation
337
+
338
+ - [Quick start](QUICKSTART.md)
339
+ - [Contributing](CONTRIBUTING.md)
340
+ - [Security policy](SECURITY.md)
341
+
342
+ ## Project scope
343
+
344
+ `md4a-fastapi` focuses on FastAPI/ASGI integration, a WSGI middleware with the same
345
+ behavior, and verified Markdown retrieval.
346
+ It does not attempt to reproduce a browser, execute JavaScript, or claim that
347
+ converted Markdown is identical to a publisher-authored agent representation.
348
+
349
+ ## License
350
+
351
+ Licensed under the [MIT License](LICENSE).