diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5a35cd3..811e536 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -162,7 +162,7 @@ jobs: - name: Verify the downloadable Node invoice and guide run: python tools/verify_node_examples.py - name: Verify the downloadable Python web starter and actual HTTP servers - run: python tools/verify_python_web_starter.py + run: python tools/verify_python_web_starter.py --docker - uses: actions/upload-artifact@v4 if: always() with: @@ -171,6 +171,7 @@ jobs: output/python-web-verification/*.json output/python-web-verification/*.log output/python-web-verification/checks + output/python-web-verification/docker retention-days: 14 - uses: actions/setup-dotnet@v4 with: diff --git a/docs/assets/python-web/invoice.pdf b/docs/assets/python-web/invoice.pdf index 2b1cd79..35b799a 100644 Binary files a/docs/assets/python-web/invoice.pdf and b/docs/assets/python-web/invoice.pdf differ diff --git a/docs/assets/python-web/invoice.png b/docs/assets/python-web/invoice.png index 0018a8c..c638706 100644 Binary files a/docs/assets/python-web/invoice.png and b/docs/assets/python-web/invoice.png differ diff --git a/docs/assets/python-web/project.zip b/docs/assets/python-web/project.zip index c600aae..1bf18aa 100644 Binary files a/docs/assets/python-web/project.zip and b/docs/assets/python-web/project.zip differ diff --git a/docs/assets/python-web/source.json b/docs/assets/python-web/source.json index c2bb183..1e07f27 100644 --- a/docs/assets/python-web/source.json +++ b/docs/assets/python-web/source.json @@ -1,14 +1,24 @@ { "schema": "fullbleed.python-web-starter.v1", - "engineVersion": "2.5.6", + "engineVersion": "2.5.11", "sourceRepository": "https://github.com/fullbleed-engine/fullbleed-official", - "sourceCommit": "76769ccd58426717c10cf0d988838182c203382f", + "sourceCommit": "5a604cb79854793b1e64b2e04ef6286d76a28760", "files": [ + { + "path": ".dockerignore", + "bytes": 372, + "sha256": "a9fd1d7bcf4f3ec1a04f179b80e91c1d82c1d08fef028a90d358ad2f92b6ab9c" + }, { "path": ".gitignore", "bytes": 28, "sha256": "d3b4beec83000bfbd6f81222b601d367412f30ef10bf55a6bc51b275bdfd905f" }, + { + "path": "Dockerfile", + "bytes": 984, + "sha256": "706596fcd098b3ccb667e0d2b69d46e253ce2d6a7291fbdce79cf79b3583c190" + }, { "path": "LICENSE", "bytes": 1079, @@ -16,13 +26,18 @@ }, { "path": "README.md", - "bytes": 5145, - "sha256": "b70edbd2649dbf4ea64ebeedaba6522ef8b484c02309febfde22be8babd99b78" + "bytes": 8604, + "sha256": "1c310b4e57a0795532673c131374e8ea11f134bdb091cee90e2b648fcada8b0e" + }, + { + "path": "check_docker.py", + "bytes": 13706, + "sha256": "ccb96de543f66e741cee69ccb9468bcc589d623925d2937e96589755dfb3591f" }, { "path": "check_examples.py", - "bytes": 6388, - "sha256": "2e675d4b0a1d55cba31fa0dd8d4f1eb6ce779af32ade71b90c2a0b2c63988628" + "bytes": 6422, + "sha256": "b5f4b1c73fc1453e2701aebbd3af877aa8616b045317b7d6694836c735f661ba" }, { "path": "check_http.py", @@ -99,6 +114,11 @@ "bytes": 15, "sha256": "56cbd1c0a43496327d9af1bf231abd19bb1b0e2f0a14734319fe3108dda90b13" }, + { + "path": "requirements-docker.txt", + "bytes": 406, + "sha256": "767e832458f822f62042cc7d31e212eb78449e27f53215b04e7887490293d1ef" + }, { "path": "requirements-fastapi.txt", "bytes": 33, @@ -116,8 +136,8 @@ }, { "path": "static/invoice.png", - "bytes": 130289, - "sha256": "a38d23651c355d1b87bc16fa37fe9121a377a12e6d515b4cd1871750c2241175" + "bytes": 129914, + "sha256": "acb15ac236a84a98c460fb0b55115093e64ab44ce7cd232e58b7b621cad731fa" }, { "path": "templates/invoice.css", @@ -130,22 +150,22 @@ "sha256": "c8c522d0f983c9917587083e5472af39c515c447b33ebf3edf2fafcf2f21673e" } ], - "checkedAt": "2026-10-04T23:24:23.430920+00:00", + "checkedAt": "2026-10-07T01:39:11.130140+00:00", "assets": [ { "file": "project.zip", - "bytes": 230094, - "sha256": "6bcd44c551aeace4e658bf96b7528ea7237d386dfebd52de5d48bbdab4712f1c" + "bytes": 238116, + "sha256": "59561aa6e40146d813cde78e9f0d25b2c56ebac2ee4dc171ce06206f49504386" }, { "file": "invoice.pdf", - "bytes": 91537, - "sha256": "2e908a21e5f9ac5b1fa761d678833c62f3acc772ba7162d8da1b900f861320e6" + "bytes": 40277, + "sha256": "3c70915599a7e88b93203f1e64e269231621a0105db90ee31f5f762e1b138057" }, { "file": "invoice.png", - "bytes": 130289, - "sha256": "a38d23651c355d1b87bc16fa37fe9121a377a12e6d515b4cd1871750c2241175" + "bytes": 129914, + "sha256": "acb15ac236a84a98c460fb0b55115093e64ab44ce7cd232e58b7b621cad731fa" } ] } diff --git a/docs/examples.md b/docs/examples.md index 25f0b45..853253f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -39,6 +39,7 @@ their own roles. Grid and table layouts keep the page ordered. Start with the [invoice guide](guides/invoices.md), or [serve an invoice from FastAPI, Flask, or Django](guides/web-frameworks.md). +The Python starter also includes a [tested Docker image](guides/python-docker.md). For C# web applications, try the [ASP.NET Core download starter](guides/aspnet-pdf.md). diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index a8459aa..fba8a2f 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -45,12 +45,20 @@ python -m pip install fullbleed The stable-ABI wheels cover supported CPython versions. For an unsupported target, a source build requires Rust; [source build instructions](https://github.com/fullbleed-engine/fullbleed-official#install) are in the engine repository. +## Run in Docker + +The [Docker invoice starter](../guides/python-docker.md) includes a FastAPI app, +editable HTML/CSS, bundled fonts, and a Dockerfile tested on Debian slim and +Alpine on x64 and ARM64. It installs wheels with `--only-binary=:all:` and runs +as a non-root user with a read-only filesystem. Use it to check your container +integration before adapting the renderer to your application. + ## Troubleshooting | Symptom | Next step | | --- | --- | | `fullbleed` command is not found | Use `python -m fullbleed` from the environment where you installed it. | -| pip starts a Rust build | Upgrade pip and check that your Python/platform has a published wheel. | +| pip starts a Rust build | Upgrade pip and check that your Python/platform has a published wheel. Use `--only-binary=:all:` to require one; the [Docker starter](../guides/python-docker.md) demonstrates this in slim and Alpine images. | | Permission or externally managed environment error | Install inside a virtual environment. | | Missing characters | Register a font containing those characters; see [fonts and assets](../engine/assets.md). | diff --git a/docs/guides/python-docker.md b/docs/guides/python-docker.md new file mode 100644 index 0000000..c8b4221 --- /dev/null +++ b/docs/guides/python-docker.md @@ -0,0 +1,136 @@ +--- +title: Generate PDF invoices in Docker with Python +description: Run a FastAPI PDF endpoint in Debian slim or Alpine using Fullbleed wheels, bundled fonts, a non-root user, and a read-only filesystem. Includes a tested downloadable starter. +--- +# Generate PDF invoices in Docker with Python + +The Python invoice starter includes a Dockerfile that serves a designed PDF from +FastAPI. It installs prebuilt wheels, carries its fonts with the application, and +runs without adding a compiler, browser, or system PDF renderer. + +[Download the Docker-ready Python starter](../assets/python-web/project.zip){ .md-button .md-button--primary } +[Open the sample PDF](../assets/python-web/invoice.pdf){ .md-button } + +This is the same editable HTML/CSS project used in the +[FastAPI, Flask, and Django guide](web-frameworks.md). Its container starts the +FastAPI adapter. The included invoice is fictional and has three line items; +connect your application's authorized lookup before serving real records. + +## Build and download an invoice + +Use Docker with Linux containers. Extract the ZIP and open its +`fullbleed-python-invoice` directory, then build the image: + +```bash +docker build --tag fullbleed-invoice . +``` + +Run the server with a loopback-only published port: + +```bash +docker run --rm --name fullbleed-invoice --publish 127.0.0.1:8000:8000 --read-only --cap-drop ALL --security-opt no-new-privileges:true --memory 512m --cpus 2 --pids-limit 128 fullbleed-invoice +``` + +These single-line commands work in PowerShell too. Open +`http://127.0.0.1:8000/` and select **Download invoice**, or request +`http://127.0.0.1:8000/invoices/INV-1042.pdf` directly. The response is an +`application/pdf` attachment with `Cache-Control: private, no-store`. + +[![Northstar Studio invoice with large green typography, a three-item table, and a total of USD 1,870.00.](../assets/python-web/invoice.png)](../assets/python-web/invoice.pdf) + +Stop it from another terminal: + +```bash +docker stop fullbleed-invoice +``` + +Edit `templates/invoice.html`, `templates/invoice.css`, or `invoice.py`, rebuild +the image, and start it again to use your changes. The local source files are +copied into the image; this command does not mount them from your machine. + +## What the image contains + +| Component | Included configuration | +| --- | --- | +| Python | Official Python 3.14.8 Debian Bookworm slim image, pinned by multi-platform digest | +| PDF engine | Published Fullbleed 2.5.11 wheel | +| HTTP server | FastAPI 0.142.2 and Uvicorn 0.54.0; one worker, proxy headers disabled | +| Dependencies | Exact runtime versions in `requirements-docker.txt`; wheel URLs and hashes in `/app/pip-install.json` | +| Fonts | Inter from the wheel; vendored DM Serif Display and Bebas Neue with their licenses | +| Application user | UID/GID 10001; source files owned by root | +| Build context | Explicit `.dockerignore` allowlist for the application, templates, fonts, and dependency files | + +The install command uses `--only-binary=:all:`. If an appropriate wheel is +unavailable, pip stops with an installation error instead of attempting a Rust +or C build. The official Python image documentation also explains why source +builds can fail in a slim image without development tools. See +[Docker's Python image reference](https://hub.docker.com/_/python). + +The run command makes the filesystem read-only, drops Linux capabilities, +disallows privilege escalation, and sets memory, CPU, and process limits. The +renderer returns PDF bytes in memory; concurrent requests do not share output +filenames. Builds fetch dependencies, while this render uses only local assets. +See [Docker's runtime options](https://docs.docker.com/engine/containers/run/) +for the behavior of these settings. + +## Alpine and ARM64 + +The same Dockerfile accepts an alternate official Python image: + +```bash +docker build --build-arg PYTHON_IMAGE=python:3.14.8-alpine3.24@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72 --tag fullbleed-invoice-alpine . +``` + +Use `fullbleed-invoice-alpine` as the image in the run command. The pinned slim +and Alpine image references include Linux x64 and ARM64 variants; Docker selects +the platform for your daemon. + +[Container CI](https://github.com/fullbleed-engine/fullbleed-official/actions/workflows/python-docker-example.yml) +builds and runs both images on native x64 and ARM64 runners. It checks actual +downloads, four simultaneous responses, HTTP 404/405 behavior, independently +extracted PDF text and embedded fonts, the native preview, and normal server +shutdown. A build-context check plants dummy secret files at the root and inside +asset directories, then verifies that Docker excludes them. It also renders in a +separate container with networking disabled. +All four resulting PDFs and previews must match. + +Each run retains the PDF, PNG, build/server logs, resolved packages, wheel hashes, +image ID, and runtime settings. The documentation workflow additionally builds +and exercises the default image from the exact downloadable ZIP. +The [source record](../assets/python-web/source.json) identifies that ZIP and its +sample assets by hash. + +## Repeat the checks locally + +With a local Linux-container Docker daemon and Python on the host, run from the +extracted starter directory: + +```bash +python -m pip install pypdf==6.19.0 +python check_docker.py --out output/docker-check +``` + +The output directory must be new. The script removes only its own uniquely named +containers and image tag; Docker's shared image/build cache stays under your +control. To check Alpine, pass the same pinned image reference using +`--base-image`. `pypdf` is a host-side verification dependency, separate from the +application image. + +## Connect it to your deployment + +Replace the fictional `load_invoice()` with your application's authorized record +lookup. Configure TLS, trusted reverse proxies, request limits, monitoring, and +worker or job-queue capacity in your hosting setup. The example's resource limits +are test settings, not a throughput or sizing recommendation. The route has no +authentication, and the fixed one-page layout needs pagination work for long +invoices. + +Update the pinned image and dependency versions periodically, then rebuild and +rerun the checks. These checks establish the behavior of the included container +and fixture; they do not establish production capacity or PDF standards +conformance. For larger document jobs, use a queue and store the finished PDF; +the [variable-data guide](bank-statements.md) covers compiled document families. + +[Python installation](../getting-started/installation.md) · +[FastAPI, Flask, and Django](web-frameworks.md) · +[Editable invoice data](invoices.md) diff --git a/docs/guides/web-frameworks.md b/docs/guides/web-frameworks.md index 29261e3..1ab2a5a 100644 --- a/docs/guides/web-frameworks.md +++ b/docs/guides/web-frameworks.md @@ -20,6 +20,9 @@ the exact source files, fonts, package version, and generated sample. This is a fictional integration example; connect your application's authorized record lookup before serving real invoices. +Want a container? The same download includes a [tested Docker path](python-docker.md) +for the FastAPI app, using Debian slim or Alpine. + ## Set up the examples Use Python 3.10–3.14. Extract the starter, open its `fullbleed-python-invoice` @@ -36,7 +39,7 @@ is `examples/web_frameworks`. Choose one framework below. Each requirement file installs that framework; `fullbleed` remains a separate package. The examples are checked with Fullbleed -2.5.6, FastAPI 0.142.2, Flask 3.1.3, and Django 5.2.17. +2.5.11, FastAPI 0.142.2, Flask 3.1.3, and Django 5.2.17. Each app serves this fictional record: @@ -52,7 +55,7 @@ returns HTTP 404. Stop the server before trying another framework on the same po ## FastAPI ```bash -python -m pip install fullbleed==2.5.6 -r requirements-fastapi.txt +python -m pip install fullbleed==2.5.11 -r requirements-fastapi.txt python -m uvicorn fastapi_app:app --host 127.0.0.1 --port 8000 ``` @@ -88,7 +91,7 @@ finishes. See [FastAPI's concurrency documentation](https://fastapi.tiangolo.com ## Flask ```bash -python -m pip install fullbleed==2.5.6 -r requirements-flask.txt +python -m pip install fullbleed==2.5.11 -r requirements-flask.txt python -m flask --app flask_app run --host 127.0.0.1 --port 8000 ``` @@ -116,7 +119,7 @@ for response bodies, media types, and headers. ## Django ```bash -python -m pip install fullbleed==2.5.6 -r requirements-django.txt +python -m pip install fullbleed==2.5.11 -r requirements-django.txt python django_app.py runserver 127.0.0.1:8000 --noreload ``` @@ -219,8 +222,9 @@ invoice** link always renders the current HTML/CSS. Verification covers these synthetic fixtures and local servers; it does not establish production capacity or PDF standards conformance. -The launch commands above run local development servers. Use your framework's -deployment setup for a public app. For large jobs, render in your job queue and +For the FastAPI app, follow the [Docker deployment guide](python-docker.md). +The Flask and Django commands above run local development servers; use your +framework's deployment setup for a public app. For large jobs, render in your job queue and store the finished PDF for download. The [variable-data guide](bank-statements.md) covers compiled document families when many records share a template. diff --git a/mkdocs.yml b/mkdocs.yml index a837277..ada3526 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,7 @@ nav: - Rebuild as you edit: guides/render-watch.md - Check PDFs in CI: guides/pdf-regression-ci.md - FastAPI, Flask, and Django: guides/web-frameworks.md + - Python in Docker: guides/python-docker.md - Browser PDF generation: guides/browser-pdf.md - React PDF previews: guides/react-pdf.md - Vue PDF previews: guides/vue-pdf.md diff --git a/tools/verify_python_web_starter.py b/tools/verify_python_web_starter.py index c82e158..b754718 100644 --- a/tools/verify_python_web_starter.py +++ b/tools/verify_python_web_starter.py @@ -1,4 +1,5 @@ """Install and exercise the exact downloadable Python web starter in isolation.""" +import argparse from datetime import datetime, timezone import hashlib import json @@ -14,9 +15,12 @@ def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--docker", action="store_true", help="Also build and exercise the image from the extracted ZIP.") + args = parser.parse_args() OUT.mkdir(parents=True, exist_ok=False) source = json.loads((ASSETS / "source.json").read_text(encoding="utf-8")) - assert source["engineVersion"] == "2.5.6" + assert source["engineVersion"] == "2.5.11" for item in source["assets"]: data = (ASSETS / item["file"]).read_bytes() assert len(data) == item["bytes"] @@ -31,6 +35,7 @@ def main(): manifest = json.loads((project / "MANIFEST.json").read_text(encoding="utf-8")) assert manifest["sourceCommit"] == source["sourceCommit"] assert manifest["files"] == source["files"] + assert {"Dockerfile", ".dockerignore", "requirements-docker.txt", "check_docker.py", "LICENSE"} <= {item["path"] for item in manifest["files"]} for item in manifest["files"]: data = (project / item["path"]).read_bytes() assert hashlib.sha256(data).hexdigest() == item["sha256"], item["path"] @@ -39,7 +44,7 @@ def main(): python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") commands = [ ("install", [str(python), "-m", "pip", "install", "--disable-pip-version-check", - "fullbleed==2.5.6", "-r", "requirements-check.txt"]), + "--only-binary=:all:", "fullbleed==2.5.11", "-r", "requirements-check.txt"]), ("verify", [str(python), "check_examples.py", "--out", str(OUT / "checks")]), ] for name, command in commands: @@ -51,11 +56,20 @@ def main(): for suffix in ["", "-http"]: assert (OUT / "checks" / f"{framework}{suffix}.pdf").read_bytes() == (ASSETS / "invoice.pdf").read_bytes() assert (OUT / "checks/preview/invoice_page1.png").read_bytes() == (ASSETS / "invoice.png").read_bytes() + if args.docker: + with (OUT / "docker.log").open("w", encoding="utf-8") as log: + result = subprocess.run([str(python), "check_docker.py", "--out", str(OUT / "docker")], + cwd=project, stdout=log, stderr=subprocess.STDOUT, timeout=900) + if result.returncode: + raise RuntimeError(f"Docker verification failed; inspect {OUT / 'docker.log'}") + for filename in ["invoice.pdf", "invoice.png"]: + assert (OUT / "docker" / filename).read_bytes() == (ASSETS / filename).read_bytes() report = {"ok": True, "checkedAt": datetime.now(timezone.utc).isoformat(), - "sourceCommit": source["sourceCommit"], "engineVersion": "2.5.6", + "sourceCommit": source["sourceCommit"], "engineVersion": "2.5.11", "manifestFilesVerified": len(manifest["files"]), "frameworkClientsPassed": 3, "actualHttpServersPassed": 3, "pdfAndPreviewMatchPublishedAssets": True, + "dockerFromExactZipPassed": args.docker, "scope": "Isolated ZIP install and local HTTP verification; no production deployment claim."} (OUT / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(json.dumps(report))