Files
pyfconfig/README.md
woggioni 4fa6c0cf2e Add self-contained GraalPy native executable
Build the app to a single native binary with graalpy's 'standalone
native' command (Dockerfile.native). granian/rloop cannot be bundled
into a standalone binary, so the executable serves the app over ASGI
with Hypercorn via native/entrypoint.py, which also works around
GraalPy standalone quirks (socket.listen, str response bodies, signal
handling).

Adds docker-compose.native.yml, a -native CI job, and README docs.
2026-09-24 11:11:00 +00:00

7.9 KiB

pyfconfig

A clone of ifconfig.me built on the kaya framework (kaya-core + kaya-rsgi), served by Granian over the RSGI protocol.

Stack

  • kaya-core — routing and HTTP request/response handling
  • kaya-rsgi — Granian (RSGI) adapter
  • kaya-forwarded — trusted-proxy handling of Forwarded / X-Forwarded-* headers
  • granian — application server
  • rloop — Rust event loop used by Granian instead of the stdlib asyncio loop
  • httpx + pwo — test client over kaya's ASGI transport

No database, sessions, or authentication — the app is stateless.

A second image runs the exact same app on GraalPy (GraalVM Community Edition 25) instead of CPython — see Dockerfile.graalpy and the GraalPy image section. Because GraalPy is not ABI-compatible with CPython wheels, the compiled granian and rloop extensions are built from source against GraalPy during the image build.

A third image compiles the app to a single self-contained native executable with GraalPy's standalone native command — see Dockerfile.native and the native executable section. That build serves the app over ASGI with Hypercorn instead of granian/rloop, because compiled extensions cannot be bundled into a standalone binary.

Endpoints

Method Path Response
GET / HTML page for browsers (Accept: text/html), plain-text IP otherwise
GET /ip Client IP address
GET /ua User-Agent header
GET /lang Accept-Language header
GET /encoding Accept-Encoding header
GET /mime Accept header
GET /charset Accept-Charset header
GET /forwarded X-Forwarded-For header
GET /all All connection fields as key: value lines (empty fields included; remote_host shown as unavailable)
GET /all.json Same fields as a JSON object, empty fields omitted
GET /api/health Liveness probe ({"status":"ok"})

The /all field order mirrors the reference site: ip_addr, remote_host, user_agent, port, language, referer, connection, keep_alive, method, encoding, mime, charset, via, forwarded.

The reported client IP/port honor the Forwarded, X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Port proxy headers only when the direct peer belongs to one of the TRUSTED_PROXY_CIDRS (see Configuration); the header chain is walked right-to-left skipping trusted proxies, so spoofed entries prepended by the client are never selected. Without trusted proxies configured, or when the peer is untrusted, the socket peer address is used.

Example:

$ curl http://localhost:8080/
203.0.113.7
$ curl http://localhost:8080/all.json
{"ip_addr":"203.0.113.7","user_agent":"curl/8.21.0","port":"51342","method":"GET","mime":"*/*"}

Running

docker compose up -d --build
# App published at http://localhost:8080

GraalPy image

An equivalent image based on ghcr.io/graalvm/graalpy-community:25 (GraalPy 25.0.2 / Python 3.12.8) is available via a separate compose file:

docker compose -f docker-compose.graalpy.yml up -d --build
# App published at http://localhost:8080

This image is published to the registry as the *-graalpy tags (e.g. latest-graalpy, 0.1.0-graalpy).

Native executable

The app can be compiled to a single self-contained executable (GraalPy runtime

  • standard library + dependencies + app, no Python installation required) with GraalPy's standalone native command:
docker compose -f docker-compose.native.yml up -d --build
# App published at http://localhost:8080

The executable is built in Dockerfile.native on top of ghcr.io/graalvm/graalpy-community:25 with the version-matched native-image toolchain, and runs on a slim oraclelinux:10-slim base. It is published as the *-native tags (e.g. latest-native, 0.1.0-native).

Notes:

  • The native build is memory-hungry: native-image needs several GB of RAM (tunable with the NATIVE_IMAGE_XMX build arg, default 5500m) and takes roughly 8 minutes per build.
  • It uses GraalPy's interpreter-only runtime (-Os) for a smaller binary and a lighter build. Remove -Os in Dockerfile.native to enable the JIT at the cost of build time and memory.
  • The native executable serves HTTP via Hypercorn over ASGI rather than granian/rloop (see native/entrypoint.py).
  • HOST / PORT configure the bind address (defaults 0.0.0.0:8080).

On the host

cp .env.example .env   # optional; all settings have defaults
python -m venv .venv && . .venv/bin/activate
pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \
            --extra-index-url https://pypi.org/simple \
            -e .
granian --interface rsgi --loop rloop --host 0.0.0.0 --port 8000 pyfconfig.app:app

The --loop rloop flag (or GRANIAN_LOOP=rloop, already set in the Docker image) makes Granian run the app on the rloop Rust event loop instead of the stdlib asyncio loop.

Configuration

Environment variables (see .env.example):

Variable Default Description
SITE_NAME pyfconfig Public name used in the HTML page title and the curl examples (set to your domain, e.g. ifconfig.example.com)
TRUSTED_PROXY_CIDRS (empty) Comma-separated CIDRs/IPs of trusted reverse proxies (e.g. 127.0.0.1,10.0.0.0/8). Forwarded headers are honored only from these peers; empty means no proxy is trusted

The bind address is configured through Granian itself (GRANIAN_HOST / GRANIAN_PORT env vars or --host / --port CLI flags).

Tests

. .venv/bin/activate
python -m unittest discover -s tests -t .

The tests drive the app in-process through kaya's ASGI interface using httpx.ASGITransport; no external services are needed.

Project layout

src/pyfconfig/
├── app.py              # KayaApp assembly; imports route modules
├── config.py           # env-driven Settings dataclass
├── page.py             # HTML landing page builder (kaya has no templating)
└── routes/
    ├── health.py       # GET /api/health
    ├── root.py         # GET / with content negotiation
    └── connection.py   # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json
tests/
└── test_routes.py      # unittest + httpx.ASGITransport
native/
└── entrypoint.py       # Hypercorn ASGI entry point for the native executable

Route modules import the module-level app from pyfconfig.app and register handlers with @app.GET(...) decorators at import time — pyfconfig.app imports them last to complete the wiring.

Deployment

Three images are built from this repository:

  • Dockerfile — multi-stage alpine build on CPython, producing a minimal, non-root runtime image.
  • Dockerfile.graalpy — multi-stage Oracle Linux build on GraalPy 25, where the compiled granian and rloop extensions are compiled from source against GraalPy (the Rust toolchain stays in the builder stage).
  • Dockerfile.native — compiles the app to a single self-contained native executable with GraalPy's standalone native command, served by Hypercorn over ASGI; the runtime image is a slim oraclelinux:10-slim.

Pushing a release/* tag triggers the Gitea Actions workflow in .gitea/workflows/build.yaml, which builds and publishes all three images to gitea.woggioni.net/woggioni/pyfconfig: the CPython image as latest / <version>, the GraalPy image as latest-graalpy / <version>-graalpy, and the native executable as latest-native / <version>-native.