Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@ class ContactForm(AirForm[ContactModel]):

The widget callable receives `(*, model, data=None, errors=None, excludes=None)` and returns an HTML string.

### Custom template layouts

When a Jinja template or hand-authored Air tags own the visible fields, call
`form.render_csrf()` inside the `<form>`. It returns only AirForm's signed
hidden CSRF input and enforces that token on the subsequent `from_request()`
validation. This avoids a placeholder custom widget solely to obtain CSRF
protection.

### Excludes

Hide fields from rendering, saving, or both:
Expand Down Expand Up @@ -491,22 +499,34 @@ Use `lifespan` when you need async operations, cleanup on shutdown, or when the

## Database (AirModel)

Zero config. Set `DATABASE_URL` in the environment and Air auto-connects on startup. The pool is available as `app.db`. If `DATABASE_URL` is not set, `app.db` is `None` and no database is configured.
`AirModel` and `AirField` are part of Air core. Persistence is supplied by a separate backend package such as AirPostgres. Application model definitions and CRUD calls stay the same when the backend changes.

Air calls `create_tables()` automatically at startup. If you add a field to a model, the existing table is auto-migrated with `ALTER TABLE ADD COLUMN`.
For PostgreSQL, install `AirPostgres>=0.2.0`, create the backend explicitly, and own its connection lifecycle:

```python
from contextlib import asynccontextmanager
from os import getenv

import air
from air import AirField, AirModel
from airpostgres import AirPostgres

app = air.Air() # reads DATABASE_URL, connects automatically
database = AirPostgres()

class UnicornSighting(AirModel):
id: int | None = AirField(default=None, primary_key=True)
location: str
sparkle_rating: int
confirmed: bool = AirField(default=False)

@asynccontextmanager
async def lifespan(app):
async with database.lifespan(getenv("DATABASE_URL", ""))(app):
await database.create_tables()
yield

app = air.Air(lifespan=lifespan)

@app.post("/sightings")
async def create_sighting(request: air.Request):
form_data = await request.form()
Expand All @@ -522,7 +542,7 @@ async def sightings():
return air.Ul(*[air.Li(s.location) for s in all_sightings])
```

Methods: `create`, `get`, `filter`, `all`, `count`, `save`, `delete`, `bulk_create`, `bulk_update`, `bulk_delete`. Django-style lookups: `field__gte=5`, `field__icontains="x"`, `field__isnull=True`. Transactions: `async with app.db.transaction():`. See [AirModel AGENTS.md](https://github.com/feldroy/AirModel/blob/main/AGENTS.md) for lookups, bulk ops, and transactions.
Methods: `create`, `get`, `filter`, `all`, `count`, `save`, `delete`, `bulk_create`, `bulk_update`, `bulk_delete`. AirPostgres supports Django-style lookups such as `field__gte=5`, `field__icontains="x"`, and `field__isnull=True`. Transactions use `async with database.transaction():`. If no backend is configured, model CRUD methods raise an actionable `RuntimeError`.

## Common Patterns

Expand Down Expand Up @@ -666,6 +686,6 @@ Railway detects `uv.lock` and installs dependencies with uv. The `$PORT` variabl

## Friction Notes

### Local editable installs of Air sub-packages (2026-03-19)
### Local editable installs of Air backends (2026-07-28)

When developing against unreleased versions of AirField, AirForm, or AirModel in a downstream app, `[tool.uv.sources]` path overrides only take effect for **direct** dependencies. Packages that are only transitive (e.g. AirModel pulled in through Air) must be added to `[project.dependencies]` before the source override works. Without this, `uv sync` silently installs the PyPI version instead of the local one.
When developing against an unreleased backend such as AirPostgres in a downstream app, add it as a direct project dependency and point `[tool.uv.sources]` at the local package. Keep Air itself as a direct dependency too when testing an unreleased backend protocol.
160 changes: 160 additions & 0 deletions CHANGELOG/0.49.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Air 0.49.0: Swappable model backends and WebAssembly support

Air 0.49.0 makes AirModel portable across databases, brings Air applications to
threadless WebAssembly runtimes, and adds an `air check` command that catches
application wiring mistakes before they reach a request. AirField, AirForm, and
AirModel also live directly in Air core, so one installation and one import
surface provide the complete model-and-form experience.

```bash
uv add --upgrade air
```

## What's new

- **AirModel has swappable persistence backends.** Model definitions,
validation, naming, and public CRUD methods stay in Air. Database packages
implement the new `AirModelBackend` protocol and own their connection
lifecycle, SQL dialect, transactions, and schema management. Applications can
move between AirPostgres and future backends without rewriting their
model classes. Backends can satisfy the protocol structurally or subclass the
`AirDatabase` convenience base class.

- **`air check` validates a complete application.** Run `air check main.py` to
find duplicate routes, missing path parameters, missing templates, and Jinja
syntax errors. The command imports the application, reports every issue it can
find, and exits nonzero when errors would make the app incoherent.
([#1125](https://github.com/feldroy/air/pull/1125))

- **Air runs on Emscripten/WebAssembly.** Air detects threadless Python runtimes
and keeps synchronous handlers, dependencies, generator dependencies, and
exception handlers on the event loop. Runtime-only terminal and server
dependencies are omitted where they cannot run, while the ASGI application,
routes, forms, templates, and HTML tools remain available.
([#1159](https://github.com/feldroy/air/pull/1159))

## What's better

- **AirField, AirForm, and AirModel are part of Air core.** Applications can use
`from air import AirField, AirForm, AirModel` without coordinating separate
package versions. The public APIs and field metadata remain reusable by
external persistence backends. ([#1121](https://github.com/feldroy/air/pull/1121))

- **Form widgets match browser semantics.** Boolean fields round-trip through
checkboxes, optional booleans preserve `None`, and semantic widget names such
as toggle and slider render as valid HTML controls. Custom layouts can call
`AirForm.render_csrf()` to retain signed CSRF validation without rendering the
default widget.
([#1129](https://github.com/feldroy/air/pull/1129))

- **HTML parsing is portable and more faithful.** Air's formatting and response
paths preserve document fragments, comments, valueless attributes, raw-text
elements, SVG namespaces, and top-level text while using the
WebAssembly-compatible tinyhtml5 parser.

- **AirForm CSRF validation checks request origin.** Browser form submissions
must carry a same-origin `Origin` header, or a same-origin `Referer` fallback.
Production signing secrets must contain at least 32 unpredictable bytes, and
runtimes without normal environment variables can call
`air.configure_csrf_secret(secret)`. Reverse proxies must preserve the
browser-facing scheme and host. ([#1163](https://github.com/feldroy/air/pull/1163))

- **AirModel can declare former table and column names.** Models can opt into
`legacy_table_names` and `legacy_column_names` so a development backend can
preserve data across a deliberate rename, while production schema changes
still go through reviewed, versioned SQL migrations.

## What's fixed

- **Missing route returns fail clearly.** An Air endpoint that falls through
without returning a response raises an actionable `TypeError` instead of
rendering `None`. `AirResponse.render()` also validates its runtime input
consistently with its type contract.
([#1106](https://github.com/feldroy/air/pull/1106),
[#1108](https://github.com/feldroy/air/pull/1108))

- **FastAPI-owned constructor parameters have a clear customization path.** If
an Air application passes parameters that belong on the wrapped FastAPI app,
Air directs the caller to `fastapi_app` instead of producing a confusing
duplicate-argument error. Thanks
[@francisdbillones](https://github.com/francisdbillones)!
([#1112](https://github.com/feldroy/air/pull/1112))

- **The AirModel custom-widget example binds correctly.** The documented
`staticmethod` pattern no longer receives an unintended form instance.
([#1160](https://github.com/feldroy/air/pull/1160))

- **Documentation builds reliably and examples are easier to follow.** API docs
point at the core `air.form` module, build checks catch broken references,
and the README, quickstart, routing, and Air Tag examples are clearer. Thanks
[@MrValdez](https://github.com/MrValdez)!
([#1146](https://github.com/feldroy/air/pull/1146),
[#1162](https://github.com/feldroy/air/pull/1162))

## What's changed

- **PostgreSQL persistence moved to AirPostgres.** Air no longer exports
`AirDB`, installs asyncpg through an `air[postgres]` extra, reads
`DATABASE_URL` automatically, or creates `app.db`. Install AirPostgres and
make the database lifecycle explicit:

```bash
uv add "AirPostgres>=0.2.0"
```

```python
from contextlib import asynccontextmanager

import air
from airpostgres import AirPostgres

database = AirPostgres()


@asynccontextmanager
async def lifespan(app):
async with database.lifespan("postgresql://localhost/example")(app):
await database.create_tables()
yield


app = air.Air(lifespan=lifespan)
```

Existing model definitions and calls such as `Model.create()`,
`Model.filter()`, and `instance.save()` do not change.

- **CSRF-aware request tests need source headers.** Tests that submit a browser
form through `AirForm.from_request()` should include an `Origin` or `Referer`
matching the request URL. Code that validates an explicit mapping without a
request is unchanged.

## Why I built Air 0.49.0

Air 0.49.0 was shaped by building
[WriterStead](https://writerstead.com/from/air-0.49.0), now live for authors
who want a lasting home for their books, writing, and readers.

WriterStead runs on Air in production, from accounts and subscriptions to forms
and author sites. Building it has pushed Air to become better at the parts that
matter for real production sites.

WriterStead is now open. If you’re ready to give your work a permanent home,
not just another profile on someone else’s platform, [build your author site
with WriterStead](https://writerstead.com/from/air-0.49.0).

## Contributors

[@audreyfeldroy](https://github.com/audreyfeldroy) (Audrey M. Roy Greenfeld)
designed the backend-neutral AirModel contract, core package consolidation,
WebAssembly support, application checks, form hardening, and HTML compatibility
work in this release.

[@pydanny](https://github.com/pydanny) (Daniel Roy Greenfeld) contributed to the
core model and form direction and the release's documentation work.

Thanks to [@francisdbillones](https://github.com/francisdbillones) for aligning
Air's FastAPI constructor and response contracts, to
[@msaizar](https://github.com/msaizar) for the missing-route-return fix, and to
[@MrValdez](https://github.com/MrValdez) for restoring the documentation build
and improving Air's introductory documentation and examples.
2 changes: 0 additions & 2 deletions CHANGELOG/latest.md
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
### Latest Changes

- Harden AirForm CSRF validation by rejecting cross-origin submissions and weak signing secrets.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@

---

## Built for the real world

Air is free and open source. [WriterStead](https://writerstead.com/) runs on it
in production, and its real needs, including accounts, subscriptions, forms,
and author sites, drive Air forward.

---

**Website**: <a href="https://airwebframework.org" target="_blank"><https://airwebframework.org></a>

**Documentation**: <a href="https://docs.airwebframework.org" target="_blank"><https://docs.airwebframework.org></a>
Expand Down
2 changes: 1 addition & 1 deletion docs/about/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Here is what is planned for the Beta release, which is tracked on the <a href="h
Forms are a core part of any web framework. While the foundations for forms are in place with Air Tags, there is still a lot of work to be done. Part of it is that form libraries have to support a lot of edge cases. This includes:

- [x] Form validation - Ensure error messages are clear and helpful
- [ ] CSRF protection - Implement CSRF protection for forms
- [x] CSRF protection - Sign form tokens and reject cross-origin submissions
- [ ] Integration with FastAPI's dependency injection - This is coded but it is not stable yet
- [ ] Default widget cleanup - It is working but the code is ungainly and hard to extend

Expand Down
3 changes: 3 additions & 0 deletions docs/api/fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Fields

::: air.field
2 changes: 2 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ Here is the Air reference documentation. It explains how to do things, as well a
- [Background Tasks](../api/background.md) - Background tasks for Air
- [Exception Handlers](../api/exception_handlers.md) - Exceptions are returned to the user, specifically 404 and 500
- [Exceptions](../api/exceptions.md) - Sometimes it's good to know exactly what is breaking
- [Fields](../api/fields.md) - Pydantic field metadata for forms, tables, and persistence
- [Forms](../api/forms.md) - Receive and validate data from users on web pages
- [Layouts](../api/layouts.md) - Utilities for building layout functions and two example layouts for css microframeworks (mvcss and picocss)
- [Middleware](../api/middleware.md) - Middleware for Air
- [Models](../api/models.md) - Validated models and swappable persistence backend contracts
- [Requests](../api/requests.md) - HTMX utility function that can be used with dependency injection
- [Responses](../api/responses.md) - AirResponse for normal responses and SSEResponse for Server Sent Events
- [Routing](../api/routing.md) - For compositing multiple apps inside each other
Expand Down
3 changes: 3 additions & 0 deletions docs/api/models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Models

::: air.model
1 change: 1 addition & 0 deletions docs/community/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Community articles and blog posts about Air:

Showcase of sites built with the Air framework:

- <a href="https://writerstead.com/" target="_blank">WriterStead</a>: A live author-site platform built and run on Air, with accounts, subscriptions, forms, and author sites.
- <a href="https://feldroy.com/", target="_blank">Feldroy</a>: The website of the people behind Air, Daniel and Audrey Roy Greenfeld
- <a href="https://airconvert.fastapicloud.dev/", target="_blank">Air Convert:</a> Convert HTML to Air Tags
- <a href="https://airmcp.fastapicloud.dev/", target="_blank">Air MCP:</a> A hosted MCP server with tools related to Air
Expand Down
Loading