Skip to content

Releases: feldroy/air

Air 0.48.1

03 Apr 03:39
Immutable release. Only release title and notes can be modified.
v0.48.1
06e4d97

Choose a tag to compare

Air sites now respond correctly to HTTP HEAD requests. If you use Facebook link previews, Twitter cards, Slack unfurls, or uptime monitors that send HEAD, they work on Air out of the box.

uv tool upgrade air

What's fixed

  • HTTP HEAD requests return 200 instead of 405. Every GET route (@app.page, @app.get, @router.page, @router.get) now responds to HEAD with the correct status, headers, and empty body. FastAPI's routing layer has never added HEAD to GET routes the way Starlette does. Air now restores that behavior in its own route class. (#1123)

  • Cleaner linter config for dependency injection. Air's ruff.toml now lists its immutable function calls (Depends, Query, Header, etc.) in extend-immutable-calls, removing the need for noqa: B008 comments throughout the codebase. (#1109)

Contributors

@audreyfeldroy (Audrey M. Roy Greenfeld) traced the HEAD bug to FastAPI's route initialization and wrote the fix.

@pydanny (Daniel Roy Greenfeld) cleaned up the doc build.

Thanks to @francisdbillones (Francis Billones) for adding extend-immutable-calls to the ruff config and cleaning up the noqa comments.

Air 0.48.0

21 Mar 15:34
Immutable release. Only release title and notes can be modified.
v0.48.0
4af8e4f

Choose a tag to compare

Air 0.48.0: The AirModel ORM, AirForm, and AirField

We wanted to release this before our keynote at PythonAsia 2026 tomorrow morning. Air 0.48.0 has major features to the point we almost called it 1.0.0. It's got rough edges, so be ready to file issues on whatever you notice. We'll polish it together during the PythonAsia sprints and in the days to come.

Air's form system, field metadata, and database ORM are new and each live in their own package now. AirForm validates and renders forms with CSRF protection. AirField carries presentation metadata across every context. AirModel talks to PostgreSQL with async CRUD. All three are published on PyPI and usable independently, but from air import AirForm, AirModel, AirField still works.

uv add air --upgrade

What's new

  • AirForm package. Form validation, rendering, and CSRF protection live in AirForm. form.render() returns SafeHTML that embeds directly in Air Tags without air.Raw() wrapping. CSRF tokens are automatic: render pushes a hidden field, validate pops and checks it.

  • AirField package. Django has models.CharField and forms.CharField, two parallel field systems you keep in sync. AirField unifies them: one field carries database metadata (primary_key), form rendering hints (type, label, widget, choices), and Pydantic validation (min_length, ge) in a single declaration. Both AirField(type="email") and Annotated[str, Widget("email")] work.

  • AirModel package. An async ORM for Pydantic models and PostgreSQL, with a Django-flavored API: create, get, filter, save, delete, plus bulk operations and transactions. AirModel derives table names from class names, maps Python types to PostgreSQL columns, and supports Django-style lookups (sparkle_rating__gte=8, location__icontains="falls"). Set DATABASE_URL and Air auto-connects on startup. Add a field to your model and create_tables() auto-migrates the table with ALTER TABLE ADD COLUMN.

  • Excludes with scoped tuples. Control which fields appear in the form and which reach the database:

    class OrderForm(AirForm[Order]):
        excludes = (
            "internal_notes",           # hidden from display and save
            ("slug", "display"),        # not rendered, still in save_data()
            ("tracking_id", "save"),    # rendered, excluded from save_data()
        )

    PrimaryKey fields are default display excludes. form.save_data() returns a dict ready for Model.create(**form.save_data()).

  • __html__ protocol. Air Tags trust any object with __html__ (the Jinja2/MarkupSafe convention). AirForm's render output embeds without escaping.

What's changed

  • to_form() is gone. Use class MyForm(AirForm[MyModel]): pass instead. (#1100)

  • includes is gone, replaced by excludes. Forms that used includes = ("name", "email") should switch to excludes listing the fields to hide.

  • default_form_widget signature changed. The includes parameter is replaced by excludes. Import from airform directly (from airform import default_form_widget), not from air.forms.

  • Helper functions moved. errors_to_dict, get_user_error_message, pydantic_type_to_html_type, and label_for_field now import from airform, not air.forms. Air's forms.py re-exports only AirForm.

  • render() returns SafeHTML, not SafeStr. The new type follows the __html__ protocol. Code that checked isinstance(result, SafeStr) needs updating. Code that just used the string doesn't.

  • Rendered HTML has <div class="air-field"> wrappers. Each field is wrapped in a div with label, input, and error elements. Code that matched exact HTML output from the old renderer needs updating.

What's better

  • app.jinja is the documented pattern. Docs and quickstart use app.jinja(request, "template.html") instead of creating a manual JinjaRenderer. The auto-created renderer has been available since 0.47, now the docs match.

  • validate() accepts any Mapping. Pass Starlette's FormData directly, no dict() wrapping needed.

  • Production CSRF. Set AIRFORM_SECRET env var for multi-worker deployments so all workers share the same signing key.

Contributors

@audreyfeldroy (Audrey M. Roy Greenfeld) designed and built this release: the AirForm, AirField, and AirModel package extractions, the excludes system, CSRF push/pop, SafeHTML protocol, and the complete documentation rewrite.

@pydanny (Daniel Roy Greenfeld) designed the original AirForm class, the swappable widget pattern, and the render/validate lifecycle that the extraction preserved.

Air 0.47.0

16 Mar 11:57
Immutable release. Only release title and notes can be modified.
v0.47.0
d966650

Choose a tag to compare

Air 0.47.0 makes Jinja templates as effortless as static files: drop a templates/ directory into your project and app.jinja is ready to use. This release also ships AGENTS.md, a complete guide that teaches AI coding assistants how to build Air apps from scratch. Upgrade with:

uv add --upgrade air

What's new

Zero-config Jinja templates. app.jinja is now always available on every air.Air() instance, no setup required. Drop a templates/ directory into your project and start rendering. The same pattern as static files: put files where Air expects them, and the wiring happens automatically.

import air

app = air.Air()

@app.page
def index(request: air.Request):
    return app.jinja(request, "home.html", title="Home")

AGENTS.md for AI coding tools. A comprehensive build guide lives in the repo root and is served from the docs site at docs.airwebframework.org/AGENTS.md. AI assistants that read it can scaffold a complete Air app: routes, Jinja templates, static files, forms, HTMX, SSE, and Railway deployment. The guide was tested end-to-end by an AI building its first Air app, and four gaps it found were fixed immediately.

Railway deployment in the AI guide. The AGENTS.md build instructions cover the full path from blank project to production on Railway: a railway.json, a hypercorn dependency, and four CLI commands.

What's better

Static files cookbook rewritten. The cookbook recipe now shows the actual zero-config workflow instead of a manual app.mount() call that hasn't been needed since 0.46.0.

Docs build works with griffe v2. Bumped mkdocstrings-python to 2.0.3, which depends on griffelib directly instead of the removed griffe meta-package. The docs build had been broken by the griffe 2.0 package split but hadn't been deployed since October 2025.

Contributors

@audreyfeldroy (Audrey M. Roy Greenfeld) and @pydanny (Daniel Roy Greenfeld) designed and built this release: zero-config Jinja template detection, the AGENTS.md build guide, the mockup-to-template workflow, Railway deployment instructions, and the docs build fix.

Air 0.46.0

14 Mar 14:10
Immutable release. Only release title and notes can be modified.
v0.46.0
b4ee20a

Choose a tag to compare

Air 0.46.0 brings three features that change how you build with Air: sync route handlers that don't block the event loop, automatic cache busting for static files, and the ability to mix Jinja templates directly into Air tag trees. Upgrade with:

uv add --upgrade air

What's new

Cache-busted static files. Drop a static/ directory into your project and Air handles the rest. Every file gets a content hash in its URL, served with immutable cache headers. HTML responses have their /static/ paths rewritten automatically, so templates, Air tags, and hardcoded HTML all benefit without opt-in. Powered by staticware.

Jinja templates inside Air tag trees. JinjaRenderer now accepts as_string=True, returning rendered HTML that can be embedded directly in Air tags. Mix and match both approaches in the same page. (#981)

jinja = air.JinjaRenderer('templates')

@app.page
def index(request: air.Request):
    return air.layouts.mvpcss(
        air.Title('Home Page'),
        jinja(request, 'content.html', as_string=True)
    )

Railway deployment guide. New docs walk through deploying Air to Railway with Hypercorn, including configuration and environment setup.

What's better

Windows support. Air's CI now tests on Windows across Python 3.13 and 3.14. Windows users can rely on the test suite catching platform-specific issues.

air.__version__ available at runtime. Read the installed version programmatically without importlib boilerplate.

What's fixed

Sync handlers run in a threadpool. Route handlers written as plain def functions now run in Starlette's threadpool instead of blocking the event loop. Database queries, file I/O, and other blocking operations work naturally without async/await. Air preserves the sync/async nature of your endpoint, so Starlette dispatches it correctly.

Custom exception handlers take priority. User-defined exception handlers now override Air's defaults instead of being silently replaced. Air's built-in 404/500 handlers are also applied automatically when wrapping a custom FastAPI instance. Thanks @msaizar!

status_code honored in route decorators. @app.post("/created", status_code=201) now produces a 201 response as expected, both in the HTTP response and in OpenAPI docs.

Contributors

@audreyfeldroy (Audrey M. Roy Greenfeld) and @pydanny (Daniel Roy Greenfeld) designed and built this release: sync handler dispatch, staticware integration, cache-busted static files, the release automation pipeline, deployment docs, and the README rewrite.

Thanks to @msaizar (Martin Saizar) for fixing exception handler priority and ensuring custom handlers work correctly with Air's defaults, and for adding the missing-examples audit to the QA workflow.

Thanks to @kaapstorm (Norman Hooper) for a docs formatting fix, @garzuze (Lucas Garzuze Cordeiro) for correcting doc callout rendering, and @sankarebarri for ruff naming convention compliance.

v0.45.0

09 Jan 02:51

Choose a tag to compare

What's Changed

New Contributors

Build items

  • BUILD: Added djhtml hook to both .pre-commit-config-check.yaml and .pre-commit-config-format.yaml to automate formatting of HTML files and Jinja Templates! by @pygarap in #932
  • BUILD: Improvements to the project's code quality tooling and configuration management. by @pygarap in #957
  • BUILD: Add pre-commit-hooks: Some out-of-the-box hooks for pre-commit. by @pygarap in #935
  • BUILD: Added the validate-pyproject hook by @pygarap in #959
  • BUILD: Some hooks can run in parallel by priority(the new prek priority feature) & Added new hooks: check-jsonschema, yamlfmt & typos now run inside prek! by @pygarap in #960
  • BUILD: Added several new pre-commit hooks: creosote, complexipy, flake8-class-attributes-order, flake8-pydantic and pyroma! by @pygarap in #962
  • BUILD: Added new pre-commit hooks: rumdl - A modern, high-performance Markdown linter and formatter, built for speed in Rust & editorconfig-checker - A tool to verify that your files are in harmony with your .editorconfig by @pygarap in #966
  • BUILD: hotfix: now renovate will upgrade the just-install action correctly! by @pygarap in #970

Chores

  • CHORE: enable Ruff rule A (builtins shadowing) by @ohhaus in #919
  • CHORE: Remove ruff lint per file ignores by @msaizar in #930
  • CHORE: Remove air_tag_source_samples.py from ruff lint per-file-ignore by @msaizar in #954
  • CHORE: chore(deps): update endbug/latest-tag digest to 52ce15b by @renovate[bot] in #968
  • CHORE: chore(deps): update actions/checkout action to v6.0.1 by @renovate[bot] in #971
  • CHORE: chore(deps): update astral-sh/setup-uv action to v7.2.0 - autoclosed by @renovate[bot] in #972
  • CHORE: chore-ruff-rule: tc-sub-issue task of #642 completed by @sankarebarri in #973
  • CHORE: chore(deps): update pre-commit hook tombi-toml/tombi-pre-commit to v0.7.16 by @renovate[bot] in #974
  • CHORE: chore(deps): update pre-commit hook rvben/rumdl-pre-commit to v0.0.212 by @renovate[bot] in #975

Doc changes

  • DOCS: Make location of docs consistent by @pydanny in #963
  • DOCS: Instructions for serving air uses the air CLI now by @pydanny in #958
  • DOCS: Add a few more tools to the Air site by @pydanny in #980

New features

  • FEAT: Air CLI polish for clarity and joy by @audreyfeldroy in #929
  • FEAT: Convert routing.AirRouter to composition by @pydanny in #964
  • FEAT: Remove extraneous HTTP method args from AirRouter by @pydanny in #976

Bug fixes

  • FIX: id_ inconsistently used to represent HTML attribute of id by @msaizar in #926
  • FIX: Replace type argument with type_ in tags by @msaizar in #931
  • FIX: Rename max argument to max_ in tags to prevent builtin shadowing by @msaizar in #933
  • FIX: Rename min to min_ to prevent builtin shadowing in tags by @msaizar in #936
  • FIX: Rename open to open_ to prevent builtin shadowing in tags by @msaizar in #938
  • FIX: Rename reversed to reversed_ to prevent builtin shadowing in tags by @msaizar in #947
  • FIX: Rename list to list_ to prevent builtin shadowing in tags by @msaizar in #952
  • FIX: Rename dir to dir_ to prevent builtin shadowing in tags by @msaizar in #953

Refactors

  • REFACTOR: Rename nested helper functions inside tests by @msaizar in #934

Full Changelog: v0.44.0...v0.45.0

v0.44.1

31 Dec 03:50

Choose a tag to compare

What's Changed

  • CHORE: Air CLI polish for clarity and joy by @audreyfeldroy in #929
  • CHORE: enable Ruff rule A (builtins shadowing) by @ohhaus in #919
  • CHORE: id_ inconsistently used to represent HTML attribute of id by @msaizar in #926
  • CHORE: Replace type argument with type_ in tags by @msaizar in #931
  • CHORE: Remove ruff lint per file ignores by @msaizar in #930
  • CHORE: Added djhtml hook to both .pre-commit-config-check.yaml and .pre-commit-config-format.yaml to automate formatting of HTML files and Jinja Templates! by @pygarap in #932
  • CHORE: Rename max argument to max_ in tags to prevent builtin shadowing by @msaizar in #933
  • CHORE: Rename min to min_ to prevent builtin shadowing in tags by @msaizar in #936
  • CHORE: Rename open to open_ to prevent builtin shadowing in tags by @msaizar in #938
  • CHORE: Add pre-commit-hooks: Some out-of-the-box hooks for pre-commit. by @pygarap in #935
  • CHORE: Rename reversed to reversed_ to prevent builtin shadowing in tags by @msaizar in #947
  • CHORE: Rename list to list_ to prevent builtin shadowing in tags by @msaizar in #952
  • CHORE: Rename dir to dir_ to prevent builtin shadowing in tags by @msaizar in #953
  • CHORE: Remove air_tag_source_samples.py from ruff lint per-file-ignore by @msaizar in #954

New Contributors

Full Changelog: v0.44.0...v0.44.1

v0.44.0

27 Dec 01:50

Choose a tag to compare

What's Changed

Features

  • FEAT: Add "air run" and "air version" CLI commands, make uvicorn a main dep by @audreyfeldroy in #920
  • FEAT: add prek a better pre-commit, re-engineered in Rust & blacken-docs, Run black on python code blocks in documentation files! by @pygarap in #918
  • FEAT: Introduced two new class methods to BaseTag in src/air/tags/models/base.py by @pygarap in #917:
    • from_html_file for building an air-tag tree from a file
    • from_html_file_to_source for generating the instantiable source from a file

Refactoring

  • REFACTOR: Type Annotations Adjustments in the air.tags.models.base.BaseTag class! by @pygarap in #914
  • REFACTOR: rename kwargs to custom_attributes for improved clarity! by @pygarap in #915
  • REFACTOR: reorganize test files into tags subdirectory! by @pygarap in #916
  • REFACTOR: Convert air.Application from inheritance to composition by @pydanny in #906

Docs

Bugfixes

Full Changelog: v0.43.0...v0.44.0

v0.43.0

21 Dec 16:30

Choose a tag to compare

What's Changed

New feature

  • FEAT: Add support for query params in .url() function by @aybruhm in #899 (First time contribution!)
  • FEAT: Add caching for inspect.signature and inspect.unwrap by @msaizar in #903

Bugfixes

  • BUGFIX Handle more edge cases in from_html by @pygarap in #891
  • BUGFIX: Resolve PEP 563 string annotations in AirRoute by @msaizar in #893

Documentation changes

Chores

New Contributors

Full Changelog: v0.42.0...v0.43.0

v0.42.0

08 Dec 06:07

Choose a tag to compare

What's Changed

  • feat: migrate AirConvert functionality to air-tags by introducing BaseTag.from_html_to_source! by @pygarap in #879
  • [FEAT] Add new AirConvert functionality powered by selectolax by @pygarap in #879
  • [FEAT] Improvements to AirTag typing and constants management by @pygarap in #879
  • [FEAT] New utility methods for BaseTag and children by @pygarap in #879
    • BaseTag.is_attribute_free_void_element
    • BaseTag.has_children
    • BaseTag.first_child
    • BaseTag.last_child
    • BaseTag.first_attribute
    • BaseTag.last_attribute
    • BaseTag.num_of_direct_children
    • BaseTag.num_of_attributes
    • BaseTag.tag_id
  • [CHORE] Remove FURB189 ruff rule by @msaizar in #872
  • [CHORE] Add 100% test coverage to missing_examples script by @msaizar in #873
  • [CHORE] Add baseline and check modes for scripts/missing_examples.py by @msaizar in #874
  • [CHORE] Remove ANN001 from pyproject.toml by @msaizar in #877

Full Changelog: v0.41.2...v0.42.0

v0.41.2

28 Nov 02:54

Choose a tag to compare

What's Changed

Full Changelog: v0.41.1...v0.41.2