Skip to main content

BlackBull

BlackBull is a Python web framework — native typed Connection end to end, ASGI 3.0 kept as an interop boundary — for developers who want one pip install, zero C compilers, and the ability to programmatically test their HTTP clients and servers against deliberate protocol misbehaviour.

PyPI Python License RFC conformance Benchmarked by HttpArena

Live demo → — BlackBull running in production on a free-tier host, no external server in front of it.

Hello, world

from blackbull import BlackBull

app = BlackBull()

@app.route(path='/')
async def hello():
    return "Hello, world!"

if __name__ == '__main__':
    app.run(port=8000)
$ python app.py &
$ curl -i http://localhost:8000/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13

Hello, world!

Why BlackBull

  • Zero ceremony. app.run() is the entire deploy story — or blackbull serve ./public for a static site with ETag + HTTP/2 and no code at all. No separate ASGI runner, no YAML config, no gunicorn class path.
  • Readable stack. Every byte on the wire passes through Python you can step through with pdb. No C extensions to debug.
  • New protocols, early. HTTP QUERY (RFC 10008) — a safe, idempotent, cacheable method with a request body — ships with routing, Accept-Query negotiation, and Content-Type enforcement, years before it reaches the standard library.
  • Declare, don't plumb. Handlers name what they need — path params, query params, the body, a Connection view, a WebSocket on a websocket route, or a Depends(get_db) resource with teardown when the handler is done — and the router resolves it all when the route is registered. The same signature means the same thing on an HTTP route and a WebSocket one. A handler that uses none of it compiles to the same bare wrapper: zero per-request cost for features you didn't ask for.
  • Break things on purpose. The same protocol code that serves real traffic can drive a programmable misbehaving client or server, on HTTP/1.1 and HTTP/2 — 40 named cases across the four combinations, in CI. Point it at your own client, your own server, or a proxy between them.
  • Test the path your requests actually take. blackbull.testing.native calls your app the way the server does — a typed Connection, no ASGI scope round-trip — and NativeTestServer runs the whole stack on a loopback port when the answer depends on the wire (keep-alive, HEAD, chunked framing). Neither needs a subprocess. See docs/guide/testing.md.
  • Multi-protocol, one process. A pure-Python MQTT 5 broker and gRPC ride beside HTTP/2 and WebSocket on the same runtime; extensions add new protocols without touching the core — still no C extension. That combination is an edge inference API server in one python app.py: SSE token streaming with HTTP/2 multiplexing for clients, MQTT ingest and $share/… work queues for devices — on any box CPython runs on, ARM included.
  • Actor-model internals. Connection-level isolation without a single shared lock: a ConnectionActor spawns a per-connection protocol actor whose inbox loop owns its own state. The same message-passing concurrency runs your HTTP routes, the MQTT broker, and the gRPC handlers — one model across every protocol.
  • RFC-grade correctness. Passes the same external conformance suites used to validate nginx and Envoy (h2spec, Autobahn). First Python framework with native HTTP QUERY (RFC 10008) support — the new safe, idempotent, cacheable method that carries a request body, with Accept-Query content negotiation.
  • Bounded by default, on every protocol. The rule every limit here is designed against: a path a peer can grow gets a bound on how big one unit may be, how big the total may be, and how long it may take. Covered today — request bodies by size and delivery rate, WebSocket messages after reassembly and after inflation, MQTT packets, queues and session state, HTTP/2 idle time, per-type control-frame rates and priority state, and a connection cap derived from the process's own descriptor budget. Rapid Reset (CVE-2023-44487), the 2019 HTTP/2 flood family, slow-drip bodies and decompression amplification are answered out of the box, with no configuration. docs/about/security-model.md states what each guarantee is, where it stops, and how the list of covered paths was arrived at — which is an audit, not a proof.
  • Typed throughout. Your editor and mypy / pyright see every parameter; PEP 561 typed distribution. The receive/send message channel is a discriminated union — all 19 ASGI event shapes are TypedDicts keyed by a Literal tag, so event['type'] narrows and a malformed send is a type error, not a traceback.

Install

pip install blackbull
pip install 'blackbull[compression]'      # add brotli + zstandard codecs
pip install 'blackbull[speed]'            # add uvloop event loop
pip install 'blackbull[reload]'           # add watchfiles for --reload
pip install 'blackbull[fault-injection]'  # add cryptography + httpx for the toolkit
pip install 'blackbull[mqtt]'             # MQTT 5 broker extension
pip install 'blackbull[grpc]'             # gRPC over HTTP/2 (all four RPC shapes)
pip install 'blackbull[protobuf]'         # + protobuf servicers, reflection, health, rich errors

Simplified handlers

Route handlers may return a str, bytes, dict, or Response; path parameters are coerced to the annotation type:

@app.route(path='/tasks/{task_id:int}')
async def get_task(task_id: int):
    return {"id": task_id, "title": "..."}

Need more than the URL? Declare a Connection parameter — headers, cookies, client, and body()/json()/text(), injected only for handlers that ask for it:

@app.route(path='/notes/{note_id:int}', methods=[HTTPMethod.POST])
async def create_note(note_id: int, conn: Connection):
    return {"id": note_id, "note": await conn.json()}

Query params and per-request resources are declared the same way — everything is resolved when the route is registered, so handlers that don't use a feature pay nothing for it at request time:

from blackbull import Depends

async def get_db():
    conn = await pool.acquire()
    try:
        yield conn                 # injected value
    finally:
        await pool.release(conn)  # runs after the response is sent

@app.route(path='/search')
async def search(q: str, page: int = 1, db=Depends(get_db)):
    return await db.find(q, page=page)   # /search?q=bull&page=2

Drop down to the full (conn, receive, send) form whenever you need it — routes accept either shape. conn is the native typed Connection; receive/send carry ASGI 3.0 events, declared as TypedDicts so a type checker narrows them on event['type'].

TLS + HTTP/2

app.run(port=8443, certfile='cert.pem', keyfile='key.pem')

ALPN negotiates h2 automatically; HTTP/1.1 clients fall back via the same socket.

HTTP QUERY (RFC 10008)

A safe, idempotent, cacheable method that carries a request body — for the searches that don't fit in a URL and shouldn't be a POST:

from blackbull import QUERY

@app.route(path='/search', methods=[QUERY])
async def search(body: bytes):
    return run_query(body)

BlackBull ships QUERY routing, Accept-Query content negotiation, and Content-Type enforcement. http.HTTPMethod has no QUERY member — RFC 10008 postdates the 3.15 feature freeze, so the earliest stdlib arrival is 3.16 — and because HTTPMethod is a StrEnum, the exported string stays equal- and hash-compatible with a future HTTPMethod.QUERY. Routes registered today need no migration.

See the routing guide.

WebSocket

from blackbull import WebSocket
from blackbull.utils import Scheme

@app.route(path='/ws', scheme=Scheme.websocket)
async def ws_echo(ws: WebSocket):
    await ws.accept()
    async for message in ws:
        await ws.send(message)

The loop ends when the client disconnects. send_text / send_bytes / send_json and receive_text / receive_bytes / receive_json are there when you want to be explicit, and await ws.close(code, reason) before accept() rejects the handshake outright.

A WebSocket handler declares what it needs the same way an HTTP one does — path params, query params, and Depends all resolve from the signature:

@app.route(path='/rooms/{room}', scheme=Scheme.websocket)
async def chat(ws: WebSocket, room: str, since: int = 0, db=Depends(get_db)):
    await ws.accept()
    ...

A Depends on a socket is resolved once per connection and released when the handler exits, so give scarce resources application scope and borrow them per use — the guide explains why. The raw (conn, receive, send) event form is still supported — see WebSockets.

Beyond HTTP — the Non-ASGI bridge

One process, more than one protocol. Extensions attach non-HTTP protocols through a single seam — app.add_extension(...) — while the HTTP core stays protocol-agnostic. The flagship is a pure-Python MQTT 5 broker: CONNECT / SUBSCRIBE / PUBLISH at QoS 0–2, retained messages, Last-Will, and shared subscriptions ($share/… work queues) on the standard :1883 port — or over TLS with MQTTExtension(port=8883, tls=True) — beside your HTTP routes, no Mosquitto sidecar, no C extension.

from blackbull import BlackBull
from blackbull.mqtt import MQTTExtension, Message

app = BlackBull()
mqtt = app.add_extension(MQTTExtension(port=1883))

@mqtt.on_message(topic='sensors/{room}/temperature')
async def on_temp(msg: Message, room: str):
    print(room, msg.payload.decode())   # {room} captured like an HTTP path param

app.run(port=8000)   # HTTP on 8000, MQTT on 1883

Need a protocol BlackBull doesn't ship? @app.raw_handler hands you the raw reader/writer for a port. See docs/guide/mqtt.md and docs/guide/raw-protocols.md.

Deeper dives: Architecture · Is BlackBull right for you? · Known limitations

Event API

Two decorators cover both lifecycle hooks and per-request behaviour — observation (@app.on, fire-and-forget) and interception (@app.intercept, synchronous, may short-circuit):

@app.on_startup
async def warm_caches():
    ...

@app.intercept('request_received')
async def auth(scope, receive, send, call_next):
    # raise to abort, or skip call_next to short-circuit
    await call_next(scope, receive, send)

@app.on('request_completed')
async def emit_metrics(event):
    metrics.increment('requests', status=event['status'])

Events: app_startup, app_shutdown, request_received, before_handler, request_completed, websocket_message. @app.on isolates exceptions per observer; @app.intercept is part of the request path and can deny / rewrite / pass through. See docs/guide/events.md for the full event catalogue and detail payloads.

Built-in middleware

Compose via app.use(...) or per-route middlewares=[...]:

Middleware What it does
Compression Negotiates br / zstd / gzip from Accept-Encoding
StaticFiles Serves files from a directory under a URL prefix — register with app.static(prefix, root), which attaches it to a route
Cache Per-worker LRU + ETag / Cache-Control honouring
CORS Preflight + actual-request header injection
TrustedProxy Rewrites scope['client'] / scope['scheme'] from proxy headers
websocket Auto-accepts the WebSocket handshake and emits websocket.close after the handler returns

OpenAPI / Swagger UI

app.enable_openapi()   # publishes /openapi.json and /docs

Auto-generates an OpenAPI 3.1 spec from route signatures, path-param converters, docstrings, and @dataclass annotations on body parameters. Dataclass-typed bodies are also deserialized at runtime — async def h(body: CreateTask): ... receives a constructed instance, no manual json.loads.

For the MQTT broker there is a messaging counterpart: AsyncAPIExtension publishes an AsyncAPI 3.0 document for your @mqtt.on_message taps at /asyncapi.json (with an HTML viewer at /asyncapi). See docs/guide/mqtt.md.

Fault injection

BlackBull's single most distinctive feature: a programmable deliberate-misbehaviour toolkit you can point at your own client or your own server — over HTTP/1.1 or HTTP/2 — directly from a pytest suite.

import pytest
from blackbull.fault_injection import H2FaultServer, make_self_signed_h2_context
from blackbull.fault_injection.catalogue import half_closed_stream_no_data

@pytest.mark.asyncio
async def test_my_client_handles_half_closed_streams():
    ssl_ctx = make_self_signed_h2_context()
    async with H2FaultServer(
        scenario=half_closed_stream_no_data(), ssl_context=ssl_ctx,
    ) as srv:
        # Your client must time out or RST_STREAM rather than block
        # forever when the server sends HEADERS without END_STREAM.
        with pytest.raises(TimeoutError):
            await my_h2_client.get(srv.url, timeout=1.0)

Both roles, on both protocols, with 40 named cases across the four cells:

Broken client → your server Broken server → your client
HTTP/1.1 14 cases — two Content-Lengths that disagree, obs-fold, bare LF, a head trickled a byte at a time 10 cases — a Content-Length that lies, a chunked body that stops mid-chunk, a half-close mid-body
HTTP/2 11 cases — Rapid Reset, PING/SETTINGS floods, a frame header that lies about its length 5 cases — half-closed streams, exhausted windows, illegal SETTINGS, a dropped CONTINUATION

One vocabulary covers all four: WaitFor… reads past what does not match and counts the skips, Expect… reads exactly one message and records whether the scenario's premise held. That matters most on HTTP/2, where the first frame any correct server sends is its handshake SETTINGS — so a verdict like GOAWAY always arrives behind frames you did not ask about.

Every cell is exercised against an implementation that is not BlackBull — CPython's http.server, h2, httpx, curl, and nginx — because a fault toolkit whose only counterpart is its own project can pass while testing nothing.

HTTP/1.1 and HTTP/2 are the covered protocols — MQTT is out of scope and gRPC gets transport-layer misbehaviour only, since it rides HTTP/2. See docs/guide/fault_injection.md for the full tutorial.

Early Alpha

⚠ Early Alpha — The API may change between MINOR versions. See Conformance for protocol-level test coverage and Known Limitations for the explicit list of behaviours to expect before adopting.

Examples

Example Demonstrates
examples/SimpleTaskManager/ REST API + HTML UI, middleware pipeline, route groups, SQLite, Bearer token auth
examples/ChatServer/ WebSocket, SSE, long polling side by side; blackbull-session + Compression + custom auth
examples/mqtt_broker.py MQTT 5 broker beside HTTP; on_message taps with {capture} topic params
examples/translation_hub.py Protocol translation hub — MQTT → WebSocket, MQTT → SSE, REST → gRPC in one process
examples/edge_inference.py Edge inference API server — SSE token streaming (browser demo included) + MQTT telemetry + $share work queue, dependency-free fake model
examples/grpc_greeter.py Canonical gRPC Greeter speaking real protobuf — works with stock grpcurl / grpcio clients unmodified
examples/typed_routes_ok.py {param:converter} syntax, url_path_for
examples/fault_injection.py Every cell of the fault-injection grid — a broken client against a real server, a broken server against real clients (ours and httpx), and scenarios as JSON you can replay
examples/PriorityExample/ RFC 9218 priority hints via conn.extensions['http.response.priority'] — echo and urgency-scaled work endpoints
examples/connection_object.py Opt-in Connection context object — headers, cookies, client, body()/json()/text()
examples/websocket_object.py High-level WebSocket object — async for messages, send_json, path/query injection, handshake rejection, and the raw event form side by side
examples/dependency_injection.py Depends on a pseudo DB pool — per-request acquire/release with teardown after the response, query params, use_cache sharing

Documentation

Versioning

BlackBull uses ZeroVer prior to a 1.0 commitment. MINOR advances at each sprint close; PATCH is for bug fixes and harness work between sprints. See CHANGELOG.md for the full release history.

License

Apache License 2.0 — © TOKUJI.

Next steps

→ Read the Guide — routing, middleware, WebSockets, HTTP/2, and more.

→ Browse the examples — copy-pasteable starting points for REST APIs, chat servers, and SSE streams.

→ Star on GitHub — every star helps the project grow.

Metadata

Release files for blackbull 0.81.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for blackbull 0.81.0
File Size Uploaded
blackbull-0.81.0.tar.gz 608.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for blackbull 0.81.0
File Interpreter ABI Platform
blackbull-0.81.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.3 MB

Release files / blackbull-0.81.0.tar.gz

Download URL blackbull-0.81.0.tar.gz
Size 608.1 kB
Tags Source
SHA-256 checksum
How to use checksums
79626571dee50c09c38d865f6601f12ef9bf0592cf1accd440ee86fce5ab5cb5
BLAKE2b-256 checksum
How to use checksums
d8f949f34a946018ac445bdb9fbaf706f92f4b4ac1458a80fba066adb8cb3874
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release files / blackbull-0.81.0-py3-none-any.whl

Download URL blackbull-0.81.0-py3-none-any.whl
Size 667.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5c595f09e04d725e3ba74ef8e4c3cffe70de8d19ec5155395185af5706e7c48c
BLAKE2b-256 checksum
How to use checksums
38bce869bd77e1859ea09c55b7b1722f75eab0f24ff9b54daefba23d847b7d46
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.81.0 This release

2 release files

0.80.0

2 release files

0.79.0

2 release files

0.78.1

2 release files

0.78.0

2 release files

0.77.2

2 release files

0.77.1

2 release files

0.77.0

2 release files

0.76.2

2 release files

0.76.1

2 release files

0.75.1

2 release files

0.75.0

2 release files

0.67.0

2 release files

0.66.0

2 release files

0.65.0

2 release files

0.64.0

2 release files

0.63.0

2 release files

0.62.0

2 release files

0.61.0

2 release files

0.60.0

2 release files

0.59.1

2 release files

0.59.0

2 release files

0.58.0

2 release files

0.57.0

2 release files

0.56.0

2 release files

0.55.0

2 release files

0.54.0

2 release files

0.53.4

2 release files

0.53.3

2 release files

0.53.2

2 release files

0.53.1

2 release files

0.53.0

2 release files

0.52.0

2 release files

0.51.0

2 release files

0.50.0

2 release files

0.49.4

2 release files

0.46.0

2 release files

0.45.0

2 release files

0.44.1

2 release files

0.44.0

2 release files

0.43.2

2 release files

0.43.1

2 release files

0.43.0

2 release files

0.42.3

2 release files

0.42.1

2 release files

0.42.0

2 release files

0.41.0

2 release files

0.40.1

2 release files

0.40.0

2 release files

0.39.1

2 release files

0.39.0

2 release files

0.38.0

2 release files

0.37.0

2 release files

0.36.0

2 release files

0.35.0

2 release files

0.34.0

2 release files

0.33.1

2 release files

0.33.0

2 release files

0.32.0

2 release files

0.31.3

2 release files

0.28.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page