A small async web micro-framework built directly on the raw ASGI protocol
Project description
NOTE
Смысл этого проекта, на работе и не только я перепогружался в тонкости работы asgi/wsgi/aiohttp/rsgi и granian. И вдруг понял, что в целом понимаю как работает вся эта машинерия и мне стало интересно смогу ли я сделать yet another one web framework.
Это не серебреная пуля - это просто проект, на покекать
Дизайн:
- Простота
- Скорость
- Микрофреймворк
Gunbullet
A small async web micro-framework built directly on the raw ASGI protocol.
No Flask, Django, or FastAPI under the hood — Gunbullet implements routing,
request parsing, parameter extraction, validation, and a test client by hand.
msgspec is the only
serialization/validation dependency.
- Python 3.13+
- Managed with
uv - ASGI app — runs under any ASGI server (uvicorn, hypercorn, …)
Install & run
uv sync --dev # install deps (incl. linters, pytest)
uv run uvicorn main:app_asgi # run the dev server from the repo root
make dev # uv sync --dev + run uvicorn
Always run from the repo root — imports assume that working directory.
Quickstart
A Gunbullet app is an ASGI callable. Create one, register handlers, and hand it to an ASGI server.
# app/__init__.py
from msgspec import Struct
from gunbullet import GunbulletApp, Request
class UserResponse(Struct):
name: str
age: int
def create_app_asgi() -> GunbulletApp:
app = GunbulletApp()
@app.get("/")
async def index(request: Request) -> UserResponse:
return UserResponse(name="loki", age=37)
return app
# main.py
from app import create_app_asgi
app_asgi = create_app_asgi()
uv run uvicorn main:app_asgi
Handlers
A handler is an async def that:
- takes
request: Requestas its first argument, - returns a
str,dict, ormsgspec.Struct.
Every return value is JSON-encoded with
Content-Type: application/json; charset=utf-8. A successful handler always
yields 200; 400, 404, and 405 are produced by the framework, not the
handler.
@app.get("/ping")
async def ping(request: Request) -> dict:
return {"pong": True}
There is no
Responseobject yet — you cannot set custom status codes or headers from a handler. See Roadmap.
Routing
Register routes with the decorators or with add_handler.
@app.route("/items") # all methods (default)
async def items(request: Request) -> dict: ...
# equivalent imperative form:
async def items(request: Request) -> dict: ...
app.add_handler("/items", items)
HTTP method dispatch
route / add_handler accept a methods= list. A route registered without
methods= answers all verbs (a catch-all). Per-verb shortcut decorators are
provided:
@app.get("/users")
async def list_users(request: Request) -> dict: ...
@app.post("/users")
async def create_user(request: Request) -> dict: ...
@app.route("/health", methods=["GET", "HEAD"])
async def health(request: Request) -> dict: ...
Shortcuts: @app.get, @app.post, @app.put, @app.patch, @app.delete.
The same path may hold several handlers, one per method group. Dispatch:
- path + matching method → the handler runs (200)
- path matches but no registered method allows the verb → 405
{"error": "Method not allowed"} - no path matches → 404
{"error": "Not found"}
Static vs dynamic routes
- Static routes (no
<param>) are stored in a dict for O(1) lookup. - Dynamic routes (
/users/<id>) are matched by compiled regex in registration order. Each<name>segment matches one path segment ([^/]+).
Path, query, and body parameters
Handlers declare where each argument comes from via Annotated markers, or by
naming a route parameter directly. All extraction uses msgspec, so values are
validated and coerced. Any validation/decoding error during extraction returns
400 {"error": ...}.
Markers are imported from gunbullet:
from gunbullet import Query, Body, Path
Bare path params
A plain typed argument whose name matches a <route_param> is coerced directly
— no marker struct needed.
@app.get("/age/<age>")
async def show_age(request: Request, age: int) -> dict:
return {"age": age} # GET /age/37 -> {"age": 37}
# GET /age/foo -> 400
Path[Struct] — group path params into a struct
Path[...] requires a msgspec.Struct; its fields are filled from the route
parameters.
from msgspec import Struct
class UserPath(Struct):
user_id: int
@app.get("/users/<user_id>")
async def get_user(request: Request, path: Path[UserPath]) -> dict:
return {"id": path.user_id}
Query[Struct] — parse the query string
The query string is converted into the struct with lenient coercion.
class Filters(Struct):
limit: int = 20
q: str = ""
@app.get("/search")
async def search(request: Request, filters: Query[Filters]) -> dict:
return {"limit": filters.limit, "q": filters.q}
# GET /search?q=cats&limit=5 -> {"limit": 5, "q": "cats"}
Body[Struct] — decode the JSON request body
class NewUser(Struct):
name: str
age: int
@app.post("/users")
async def create_user(request: Request, body: Body[NewUser]) -> dict:
return {"created": body.name, "age": body.age}
Registration-time validation
When a handler is registered, Gunbullet checks that every <route_param> in the
path is covered by a handler argument or a Path struct field. If not, it
raises ValueError at registration time (fail-fast), not on request:
@app.get("/users/<user_id>")
async def bad(request: Request) -> dict: # user_id is never consumed
return {}
# ValueError: route params not covered by any Path[...] struct: user_id
The Request object
Passed as the first argument to every handler. The scope is parsed lazily and
cached.
| Attribute / method | Description |
|---|---|
request.method |
Uppercased HTTP method ("GET", "POST", …) |
request.path |
Request path |
request.body |
Raw request body (bytes) |
request.headers |
Case-insensitive Headers mapping (.get, [], in) |
request.query |
dict[str, str] of query params (first value per key) |
request.cookies |
dict[str, str] parsed from the Cookie header |
request.json(type=dict) |
Decode the body via msgspec, optionally into a struct |
request.state |
Per-request view of the lifespan state dict |
request.app |
The owning GunbulletApp (e.g. request.app.state) |
@app.get("/whoami")
async def whoami(request: Request) -> dict:
return {
"ua": request.headers.get("user-agent"),
"session": request.cookies.get("session"),
}
Lifespan (startup & shutdown)
Register startup/shutdown logic FastAPI-style with @app.lifespan. The function
runs startup code, yields once, then runs shutdown code. An optional dict
yielded at the yield is merged into the ASGI scope state and surfaces on
request.state.
@app.lifespan
async def lifespan(app):
pool = await connect() # startup
yield {"db": pool} # state -> request.state["db"]
await pool.close() # shutdown
@app.get("/items")
async def items(request: Request) -> dict:
db = request.state["db"]
return {"items": await db.fetch_all()}
A plain async generator is auto-wrapped with
contextlib.asynccontextmanager; an already-wrapped CM factory is accepted
as-is. You can also pass it to the constructor:
app = GunbulletApp(lifespan=lifespan)
If startup raises, Gunbullet reports lifespan.startup.failed with the exception
message and the server does not start.
Application state
app.state is an attribute-access namespace for objects that live for the whole
application (clients, config, …), reachable from handlers via
request.app.state.
app.state.config = load_config()
@app.get("/version")
async def version(request: Request) -> dict:
return {"version": request.app.state.config.version}
request.stateis per-request data populated by the lifespan (the dict youyield);request.app.stateis the long-lived application namespace.
Testing
gunbullet.testclient.TestClient is a synchronous client (built on httpx +
anyio) that drives the app in-process — no network. It subclasses
httpx.Client, so it exposes .get, .post, .put, .patch, .delete, etc.
from gunbullet.testclient import TestClient
def test_index(app):
with TestClient(app) as client:
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"name": "loki", "age": 37}
Using it as a context manager (with TestClient(app) as client:) runs the
app's lifespan startup/shutdown around the block, so request.state is
populated. Without the context manager the lifespan does not run.
def test_method_dispatch(app):
with TestClient(app) as client:
assert client.post("/users").status_code == 200
assert client.put("/users").status_code == 405 # method not allowed
assert client.get("/nope").status_code == 404 # no such route
Run the suite:
make test # pytest
uv run pytest tests/test_methods.py # a single file
Project layout
main.py entry point: app_asgi = create_app_asgi() (served by uvicorn)
app/__init__.py create_app_asgi() — wires up a GunbulletApp with example routes
gunbullet/ the framework
app.py GunbulletApp — the ASGI callable, routing, lifespan
_routing.py Handler + registration-time validation
_http.py Request, Headers, State
params.py Query / Body / Path markers
testclient.py synchronous in-process TestClient
tests/ pytest suite driving the app through TestClient
Commands
uv sync --dev # install deps
make dev # sync + run uvicorn main:app_asgi
make check # lint + fix + types + test (run before finishing)
make lint # ruff check
make fix # ruff check --fix && ruff format
make types # pyright
make test # pytest
Roadmap
Not implemented yet (handlers are JSON-only and always 200 on success):
- A
Response/HTTPExceptionlayer — custom status codes and headers from handlers. - Repeated query values (
?tag=a&tag=bkeeps only the first today). - Middleware, typed path converters (
<int:id>), and non-JSON responses.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file gunbullet-0.1.0.tar.gz.
File metadata
- Download URL: gunbullet-0.1.0.tar.gz
- Upload date:
- Size: 48.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9a483dd900b151a24a0f2ea06b87bf13a080c343bf14d5402f54a7a09c5fbc1c
|
|
| MD5 |
5f2c0cf1d6e0cc993e84edc5b31c185b
|
|
| BLAKE2b-256 |
c124a9f4c01164a4bee857217d76843c706f67ac06256bd4b44e96bc8871b49e
|
Provenance
The following attestation bundles were made for gunbullet-0.1.0.tar.gz:
Publisher:
release.yml on gunlinux/gunbullet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gunbullet-0.1.0.tar.gz -
Subject digest:
9a483dd900b151a24a0f2ea06b87bf13a080c343bf14d5402f54a7a09c5fbc1c - Sigstore transparency entry: 1854586305
- Sigstore integration time:
-
Permalink:
gunlinux/gunbullet@b9aa79d121c80ccbaba8041484d8d6785b6e4050 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/gunlinux
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b9aa79d121c80ccbaba8041484d8d6785b6e4050 -
Trigger Event:
push
-
Statement type:
File details
Details for the file gunbullet-0.1.0-py3-none-any.whl.
File metadata
- Download URL: gunbullet-0.1.0-py3-none-any.whl
- Upload date:
- Size: 15.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ed96a201c6a70bbc23020bce73444e679416914baa758bd1b200e6877a71957
|
|
| MD5 |
5c8948986b2bff0b4e120a334e29b901
|
|
| BLAKE2b-256 |
78c108e2b781fbc8e140b4bdfd871fce82b75de66819ad6eca3581e95aa4b0a8
|
Provenance
The following attestation bundles were made for gunbullet-0.1.0-py3-none-any.whl:
Publisher:
release.yml on gunlinux/gunbullet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gunbullet-0.1.0-py3-none-any.whl -
Subject digest:
5ed96a201c6a70bbc23020bce73444e679416914baa758bd1b200e6877a71957 - Sigstore transparency entry: 1854586317
- Sigstore integration time:
-
Permalink:
gunlinux/gunbullet@b9aa79d121c80ccbaba8041484d8d6785b6e4050 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/gunlinux
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b9aa79d121c80ccbaba8041484d8d6785b6e4050 -
Trigger Event:
push
-
Statement type: