Skip to content

Your First App

The Hello World used the full ASGI (scope, receive, send) triplet so you could see what BlackBull gives every handler. Most real handlers don't need most of that. BlackBull detects this at route-registration time and lets you omit whatever you aren't using.

Simplified handlers

Drop the parameters you don't use; return the response value instead of calling send:

from blackbull import BlackBull

app = BlackBull()

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

BlackBull wraps the return value into a response automatically. No send call, no Response object, no scope boilerplate.

Path parameters

Declare parameters whose names match {name} segments in the path; BlackBull injects the captured value:

@app.route(path='/tasks/{task_id}')
async def get_task(task_id):       # str by default
    return f"Task {task_id}"

Add a type annotation and BlackBull will coerce the captured string for you:

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

For URL patterns that should only match a specific type (so /tasks/abc returns 404 instead of being routed in and then failing to convert), use a path converter:

@app.route(path='/tasks/{task_id:int}')
async def get_task(task_id: int):  # router enforces int → no 500s on bad input
    return {"id": task_id}

Request body

Name a parameter body to receive the complete request body as bytes. BlackBull reads all chunks before calling the handler:

import json
from http import HTTPMethod

@app.route(path='/echo', methods=[HTTPMethod.POST])
async def echo(body: bytes):
    data = json.loads(body)
    return data          # dict → JSONResponse automatically

The scope dict

Name a parameter scope to receive the full scope dict alongside other simplified parameters:

@app.route(path='/items/{item_id:int}')
async def get_item(item_id: int, scope):
    lang = scope['headers'].get(b'accept-language', b'en').decode()
    return {"id": item_id, "lang": lang}

Return value mapping

What you return determines what BlackBull sends:

Return type Response sent Content-Type
str Response(value.encode()) text/html; charset=utf-8
bytes Response(value) text/html; charset=utf-8
dict or list JSONResponse(value) application/json
Response / JSONResponse Passed through as-is (whatever the response sets)
None Nothing sent Handler called send directly, or intentionally empty

For finer control — custom status codes, extra headers, streaming bodies — return a Response / JSONResponse / StreamingResponse explicitly. See the Guide for details.

A small worked example

A tiny in-memory task tracker:

from http import HTTPMethod, HTTPStatus
from blackbull import BlackBull, Response

app = BlackBull()
_tasks: dict[int, dict] = {}
_next_id = 1


@app.route(path='/tasks', methods=[HTTPMethod.GET])
async def list_tasks():
    return list(_tasks.values())                # list → JSONResponse


@app.route(path='/tasks', methods=[HTTPMethod.POST])
async def create_task(body: bytes):
    global _next_id
    import json
    payload = json.loads(body)
    task = {'id': _next_id, 'title': payload['title'], 'done': False}
    _tasks[_next_id] = task
    _next_id += 1
    return task                                  # dict → JSONResponse


@app.route(path='/tasks/{task_id:int}', methods=[HTTPMethod.GET])
async def get_task(task_id: int):
    task = _tasks.get(task_id)
    if task is None:
        return Response(b'not found', status=HTTPStatus.NOT_FOUND,
                        content_type='text/plain')
    return task


if __name__ == '__main__':
    app.run(port=8000)

Run it:

$ python tasks.py
$ curl -X POST -d '{"title":"buy milk"}' localhost:8000/tasks
{"id": 1, "title": "buy milk", "done": false}

$ curl localhost:8000/tasks/1
{"id": 1, "title": "buy milk", "done": false}

$ curl localhost:8000/tasks
[{"id": 1, "title": "buy milk", "done": false}]

When to use the full triplet

The simplified form covers most needs, but not all:

  • WebSocket handlers always receive the full (scope, receive, send) triplet.
  • Middleware functions keep the (scope, receive, send, call_next) shape; the simplified adaptation does not apply to them.
  • Streaming uploads / long polling need to call receive() in a loop, so the full form is clearer.

The Guide covers each of these in turn.

Next

  • Guide — routing, middleware, WebSockets, HTTP/2, static files, error handling, configuration, logging, and more.