Bingo
The opinionated Python web framework for the AI era.
Bingo 0.2 provides one conventional path from a new project to a complete async CRUD application:
uv tool install .
bingo new blog
cd blog
python manage.py generate resource Post title:string body:text published:boolean
python manage.py migrate
python manage.py server
To initialize the current directory instead of creating a child directory:
mkdir blog
cd blog
bingo new .
Bingo derives the application name from the current directory. Unrelated files are preserved. If a generated path already exists, Bingo reports every conflict before writing anything.
New applications include a welcome controller and view at /, plus a small
stylesheet in public/, so python manage.py server immediately opens a working page.
Granian is Bingo's ASGI server and serves /public directly without sending
static-file requests through Python.
Applications use class controllers, routes in config/routes.py, async models,
standalone validators, Jinja templates, and Bingo migrations. Generated projects
include BINGO.md with concise architectural rules for coding agents.
Settings
Settings are ordinary uppercase Python values split by environment:
config/settings/base.py
config/settings/development.py
config/settings/test.py
config/settings/production.py
Bingo loads base.py and overlays the environment selected by BINGO_ENV. It
defaults to development. Application code accesses both framework and custom
settings through one object:
from bingo import settings
page_size = settings.POSTS_PER_PAGE
Environment files never import the base file. Bingo performs the merge, so each file contains only plain settings and environment-specific overrides.
Application commands
Inside a project, manage.py is the only command entry point. Application-owned
commands live directly in app/commands/:
from bingo import BaseCommand
class Command(BaseCommand):
help = "Publish pending posts."
async def handle(self, limit: int = 10, dry_run: bool = False): ...
Run it using its filename:
python manage.py publish_posts --limit 50 --dry-run
Required parameters become positional arguments. Parameters with defaults become
options, and boolean parameters become flags. python manage.py --help lists
built-in and application commands.
Background tasks
Tasks use one application-facing API while SAQ handles durable queue mechanics underneath. Generate a task with:
python manage.py generate task SendWelcomeEmail
This creates app/tasks/send_welcome_email_task.py:
from app.tasks.application_task import ApplicationTask
class SendWelcomeEmailTask(ApplicationTask):
queue = "mailers"
retries = 3
timeout = 60
async def run(self, user_id: int):
user = await User.find_or_fail(user_id)
await send_email(user.email)
Controllers and commands enqueue it through the class:
await SendWelcomeEmailTask.enqueue(user.id)
Run a worker for the default or a named queue:
python manage.py worker
python manage.py worker --queue mailers
TASK_QUEUE_URL selects the backend. Bingo defaults to Redis:
TASK_QUEUE_URL = "redis://localhost:6379/0"
PostgreSQL is also supported without changing task code. Install
bingo-framework[postgres] and use a postgres:// or postgresql:// URL.
Task arguments and return values must be JSON-compatible. Pass model IDs instead
of model instances, and make tasks safe to execute more than once because durable
queues provide at-least-once delivery. config/settings/test.py sets
TASKS_INLINE = True, so tests execute tasks immediately without a queue server.
Realtime channels
Channels provide ephemeral WebSocket updates through one multiplexed endpoint at
/channels. Generate a channel and its event views together:
python manage.py generate channel Chat message presence
The generated app/channels/chat_channel.py owns subscription and incoming
message behavior:
from app.channels.application_channel import ApplicationChannel
class ChatChannel(ApplicationChannel):
async def subscribed(self):
await self.stream(self.params["room"])
async def received(self, data: dict):
message = ChatMessageValidator(data).validate()
await type(self).broadcast(
self.params["room"],
"message",
**message,
)
Broadcasts render app/views/channels/chat/message.bjson, so channels never
serialize models implicitly. Validator failures are transmitted as the
validation_error event using the same error shape as JSON APIs.
Bingo serves its browser client at /channels.js; it maintains one WebSocket,
multiplexes subscriptions, and reconnects automatically:
<script src="/channels.js"></script>
<script>
const chat = Bingo.channels.subscribe("ChatChannel", { room: "lobby" }, {
connected() { console.log("connected") },
received(event, data) { console.log(event, data) },
disconnected() { console.log("reconnecting") },
})
chat.send({ name: "Ada", body: "Hello" })
</script>
ApplicationConnection is the single connection-level hook for attaching
application state or rejecting a socket. Bingo supplies no user model or default
authentication policy.
Channels and tasks may share infrastructure without sharing semantics:
CHANNEL_URL = TASK_QUEUE_URL
Redis uses Pub/Sub. PostgreSQL LISTEN/NOTIFY remains available through the
bingo-framework[postgres] extra. Tests use memory://.
Broadcasts are online-only, so persist anything clients must retrieve after
reconnecting. Configure additional browser origins with
CHANNEL_ALLOWED_ORIGINS; same-origin connections are accepted automatically.
The application shape
Routes have one home and one syntax:
from bingo import Router
routes = Router()
routes.resources("/posts")
routes.get("/posts/published", "PostsController.published")
Resource controllers are inferred from the resource path:
/posts → app/controllers/posts_controller.py → PostsController
Custom routes use a "Controller.action" target and an explicit HTTP verb.
Controllers are imported lazily when the application boots, after config/routes.py
has finished loading, so route files do not import application controllers.
Groups prefix both the URL and controller directory:
with routes.group("/admin"):
routes.resources("/posts")
routes.get("/posts/published", "PostsController.published")
/admin/posts
→ app/controllers/admin/posts_controller.py
→ PostsController
→ app/views/admin/posts/
The directory is the group, so controller classes remain short. Group blocks can nest, and every group name uses the same slash-prefixed path syntax:
with routes.group("/admin"):
with routes.group("/reports"):
routes.resources("/sales")
This resolves SalesController from
app/controllers/admin/reports/sales_controller.py and serves it under
/admin/reports/sales.
Resource controllers provide the seven actions index, show, new, create,
edit, update, and destroy. They receive self.request, self.params,
self.query, and self.session, and return self.render(...),
self.redirect(...), or self.json(...).
HTML and JSON are representations of the same resource actions:
GET /posts → app/views/posts/index.html
GET /posts.json → app/views/posts/index.bjson
GET /posts/1 → app/views/posts/show.html
GET /posts/1.json → app/views/posts/show.bjson
An extensionless render target follows the URL format:
return self.render("posts/show", post=post)
Adding .html or .json to the render target locks it to that format. A format
mismatch returns 404. HTML views use Jinja. A .bjson view contains one restricted
Python expression that explicitly selects JSON fields; it cannot call functions,
await work, query models, or execute statements.
Every controller has one async before_action lifecycle hook. It returns None
to continue or a response to stop dispatch. Application-owned base controllers
can use this hook for shared policy without middleware registries or decorators.
Models and validators stay separate:
from bingo.db import Model, fields
from bingo.validation import Validator, rules
class Post(Model):
title = fields.String(max_length=200)
published = fields.Boolean(default=False)
class PostCreateValidator(Validator):
title = rules.String(required=True, max_length=200)
published = rules.Boolean(required=True)
Validation has one execution path. validate() returns cleaned values or raises a
structured validation error:
async def create(self):
data = PostCreateValidator(self.request.data).validate()
post = await Post.create(**data)
if self.request.format == "json":
return self.render("posts/show", post=post, status=201)
return self.redirect(f"/posts/{post.id}")
Bingo handles the error at the controller boundary. A JSON request receives
{"errors": {"title": ["is required"]}} with status 422. An HTML create or
update request re-runs the conventional new or edit action with
self.validation set to the invalid validator, also with status 422.
HTML forms are generated from those same validator rules:
async def new(self):
validator = self.validation or PostCreateValidator()
return self.render("posts/new.html", validator=validator)
{% for field in form(validator) %}
<label for="{{ field.name }}">{{ field.label }}</label>
{{ field }}
{{ field.errors }}
{% endfor %}
The field rule determines the standard HTML control. Use rules.Text for a
textarea; string, email, boolean, numeric, date, and datetime rules select their
matching inputs. Submitted values and field errors remain on the validator, so a
failed HTML form is rendered without rebuilding its state.
All database operations are async:
post = await Post.create(title="Hello")
post = await Post.find_or_fail(1)
posts = await Post.where(published=True).order_by("-created_at").limit(10).all()
post.fill(title="Updated")
await post.save()
await post.delete()
Commands
bingo new NAME # use . to initialize the current directory
python manage.py server
python manage.py generate resource NAME [fields...]
python manage.py generate model NAME [fields...]
python manage.py generate controller NAME
python manage.py generate validator NAME [fields...]
python manage.py generate task NAME
python manage.py generate channel NAME [events...]
python manage.py migrate
python manage.py rollback
python manage.py routes
python manage.py inspect
python manage.py worker [--queue QUEUE] [--concurrency INTEGER]
Development settings use SQLite by default and accept DATABASE_URL for another
SQLAlchemy async database URL. python manage.py inspect reports convention
violations with the problem, expected structure, and a suggested fix.
python manage.py server maps the project's public/ directory to /public through
Granian. Application routes do not need a static-files route or controller.
See examples/blog for a complete generated Post resource.
Framework development
python -m venv .venv
.venv/bin/pip install -e '.[test]'
.venv/bin/pytest
Python 3.12 or newer is required.
Metadata
Release files for bingo-framework 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| bingo_framework-0.2.0.tar.gz | 64.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bingo_framework-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 127.0 kB
Release files / bingo_framework-0.2.0.tar.gz
| Download URL | bingo_framework-0.2.0.tar.gz |
|---|---|
| Size | 64.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f7eea268214dfb0e823d0415096a02e46c313fe49cc682b4b953ddb704426aea
|
|
BLAKE2b-256 checksum How to use checksums |
2c0d712c96f0a6308147aa8e097e269e82085e820806c6f1c9c7791f45e0e44c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / bingo_framework-0.2.0-py3-none-any.whl
| Download URL | bingo_framework-0.2.0-py3-none-any.whl |
|---|---|
| Size | 62.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2b5c288855b5e7507dd019c2ab1b2c81e30cc31267bc887dd009c6ff8cab4acb
|
|
BLAKE2b-256 checksum How to use checksums |
99cf2f4231b26833917ac091382ad29036b3cad4d6f72d1cee97cec64b702e7f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|