One file per endpoint, dependencies included.
# routes/repo/get.py
# /// script
# dependencies = ["httpx"]
# ///
import httpx
def handle(request):
return httpx.get("https://api.github.com/repos/tanrax/bitpoint").json()Drop that file into routes/, start the server, and GET /repo is live. The line at the top is PEP 723 inline metadata: Bitpoint installs httpx into an environment that belongs to this endpoint alone. No shared virtualenv, no requirements.txt, no app object. Two endpoints can even depend on incompatible versions of the same library without conflict.
- Per-endpoint dependencies: each file declares what it needs and runs in its own isolated, uv-managed environment. No other framework does this.
- File-based routing: the directory structure defines the routes, the file name defines the HTTP method. Add or remove a file and the routes change, no restart needed.
Bitpoint is for when the API is not the product, just plumbing: a webhook, a mock for the frontend, a small internal service, a few endpoints on a personal server. You write one file and you get one endpoint, dependencies included.
There is nothing to register and nothing to configure: no app object, no decorators, no config files, just a handle function per file. Dependency isolation is delegated to uv, and responses follow the type you return: a string becomes plain text, a dict becomes JSON.
Some use cases:
- Webhooks
- Internal tools
- API mocks
- Glue between services
- Personal server
- Easy deployment
pip install bitpointOr, if you use uv:
uv tool install bitpointPython 3.9 or later. If you want per-endpoint dependencies, uv must be available on the server.
Create a directory called routes and, inside it, a subdirectory called hello (this will be the route). Inside hello, create a file called get.py with the following content:
# routes/hello/get.py
def handle(request):
return "world"Start the server with:
bitpointpython -m bitpoint works too. And with uv you can even skip the installation step and run it directly:
uvx bitpointYou can now try your endpoint:
curl 127.0.0.1:8000/helloIt will return:
world
The URL path maps to the directory path inside routes, and the HTTP method maps to the file name:
routes/
├── webhooks/
│ └── github/
│ └── post.py → POST /webhooks/github (a webhook receiver)
├── invoices/
│ ├── get.py → GET /invoices (an internal service, or a mock)
│ ├── post.py → POST /invoices
│ └── [id]/
│ ├── get.py → GET /invoices/42
│ └── delete.py → DELETE /invoices/42
└── lib/ → shared code, does not generate routes
└── db.py
Valid file names are the HTTP methods in lowercase: get.py, post.py, put.py, patch.py, delete.py, head.py and options.py. Any other file is ignored and does not generate routes, so you can keep helper modules next to your endpoints.
Prefix a name with an underscore to disable it without deleting anything. Renaming get.py to _get.py stops serving that method, and renaming a directory to _invoices/ takes its whole subtree offline: requests respond with 404 (or 405 if the route still serves other methods). Rename it back and the endpoint is live again, no restart needed.
One detail: a [param] directory still captures URL segments that happen to start with an underscore, disabling only affects how your files are routed, not which URLs are valid.
A directory whose name is wrapped in brackets captures a URL segment:
# routes/invoices/[id]/get.py
def handle(request):
return {"invoice_id": request.params["id"]}curl 127.0.0.1:8000/invoices/42
# {"invoice_id": "42"}Captured values always arrive as str. Type conversion is the endpoint's responsibility.
Each endpoint exposes a handle(request) function. It is the only symbol Bitpoint looks for in the file, the rest of the module is yours.
| Attribute | Type | Description |
|---|---|---|
request.method |
str |
HTTP method in uppercase, for example "GET" |
request.path |
str |
Request path, for example /users/42 |
request.params |
dict[str, str] |
Dynamic path segments |
request.args |
dict[str, str] |
Query string parameters |
request.headers |
dict[str, str] |
Headers, case-insensitive keys |
request.body |
bytes |
Raw request body |
request.json() |
Any |
Body parsed as JSON, raises a 400 error if invalid |
Example with a query string:
# routes/greet/get.py
def handle(request):
name = request.args.get("name", "world")
return f"Hello, {name}!"curl "127.0.0.1:8000/greet?name=Bob"
# Hello, Bob!The return type determines the response:
| Return | Response |
|---|---|
str |
200, text/plain; charset=utf-8 |
dict or list |
200, application/json |
bytes |
200, application/octet-stream |
(body, status) |
As above, with the given status code |
(body, status, headers) |
Additionally with custom headers |
None |
204 No Content |
# routes/users/post.py
def handle(request):
data = request.json()
return {"created": data["name"]}, 201- If
handleraises an exception, Bitpoint responds with500. - In development mode (the default) the body includes the traceback. In production (
--production) the body is a generic message and the traceback goes to the log. - Path with no matching directory:
404. Directory exists but there is no file for that method:405with theAllowheader listing the available methods.
Declare each endpoint's dependencies with a PEP 723 block at the top of the file, the same format understood by uv and pipx:
# routes/repo/get.py
# /// script
# dependencies = ["httpx"]
# ///
import httpx
def handle(request):
return httpx.get("https://api.github.com/repos/tanrax/bitpoint").json()Bitpoint delegates resolution and installation to uv: each endpoint runs with its dependencies in an isolated, cached environment. Two endpoints can use incompatible versions of the same library without conflict.
- An endpoint without a PEP 723 block runs in the base environment, with no extra cost.
- Installation happens at startup and on reload, not on every request.
Security note: installing dependencies automatically after a
git pullmeans running third-party code at deploy time. If you prefer to control that step, start with--no-installand Bitpoint will fail with a clear error on endpoints whose dependencies are not already installed.
The routes/lib/ directory is reserved: it does not generate routes and is importable from any endpoint:
# routes/lib/db.py
def get_connection():
...# routes/users/get.py
from lib.db import get_connection
def handle(request):
conn = get_connection()
...Everything is controlled from the command line, there is no configuration file. The same options work with every launcher: bitpoint, python -m bitpoint or uvx bitpoint.
bitpoint [options]| Option | Default | Description |
|---|---|---|
--port |
8000 |
Listening port |
--host |
127.0.0.1 |
Listening address |
--dir |
./routes |
Root directory for endpoints |
--production |
disabled | Hides error tracebacks in responses and writes a bitpoint.pid file |
--no-install |
disabled | Does not install dependencies automatically |
Bitpoint loads each endpoint once and caches it for the life of the process, so editing a file does not change a running server. Routing is the exception: it resolves against the filesystem on every request, so adding or removing an endpoint changes the available routes without a restart.
To pick up a change to existing code, reload. Started with --production, Bitpoint writes a bitpoint.pid file and reloads every endpoint on SIGHUP, re-executing modules, respawning workers and re-syncing dependencies, which makes a git pull deploy a one-liner:
git pull && kill -HUP $(cat bitpoint.pid)There is no shared state across a reload: if an endpoint keeps in-memory state (caches, connections), it is lost. For persistent state use external resources (a database, Redis, files).
The built-in server is enough for development and small deployments. For production you can serve Bitpoint with Gunicorn or any other WSGI server, since create_app is a standard WSGI application factory:
pip install gunicorn
gunicorn "bitpoint.app:create_app('./routes', production=True)" --bind 127.0.0.1:8000 --workers 4 --pid gunicorn.pidThe factory takes the same options as the command line: create_app(routes_dir="./routes", production=False, install=True). Pass production=True to hide tracebacks, and install=False if you want the --no-install behavior:
gunicorn "bitpoint.app:create_app('./routes', production=True, install=False)" --bind 127.0.0.1:8000Two things to keep in mind. Each Gunicorn worker is a separate process with its own pool of endpoint workers, so with --workers 4 and three PEP 723 endpoints you can end up with twelve worker processes: keep the worker count modest. And Gunicorn manages its own reload, so the deploy flow becomes a git pull followed by a signal to the Gunicorn master:
git pull && kill -HUP $(cat gunicorn.pid)A GitHub webhook that sends a Telegram message on every push:
# routes/webhooks/github/post.py
# /// script
# dependencies = ["httpx"]
# ///
import os
import httpx
def handle(request):
if request.headers.get("x-github-event") != "push":
return None
payload = request.json()
pusher = payload["pusher"]["name"]
repo = payload["repository"]["name"]
commits = len(payload["commits"])
httpx.post(
f"https://api.telegram.org/bot{os.environ['TELEGRAM_TOKEN']}/sendMessage",
json={
"chat_id": os.environ["TELEGRAM_CHAT_ID"],
"text": f"{pusher} pushed {commits} commit(s) to {repo}",
},
)
return NoneOne file, deployed with a git pull. No project, no virtualenv, no route registration, no restart.
Some areas are explicitly not covered in order to keep the core small:
- Middleware and global hooks
- Authentication
- WebSockets and streaming
asyncendpoints- Static files
If you need any of this today, Bitpoint is not your tool, you will have to use external tools.