# Air Web Framework Air is a Python web framework built on top of FastAPI, Starlette, and Pydantic that provides a fresh approach to building web applications. It combines the power of FastAPI's modern async capabilities with an intuitive HTML generation system called Air Tags, allowing developers to write HTML using Python classes. Air eliminates the boilerplate typically required for HTML responses in FastAPI while maintaining full compatibility with FastAPI's REST API features, making it ideal for building both traditional web applications and modern APIs from a single codebase. The framework emphasizes developer experience with shortcuts for common patterns, seamless integration with both Jinja2 templates and Air Tags, built-in HTMX support, and Pydantic-powered form validation. Air is designed to be fast to code, easy to learn, and well-documented, offering an opinionated yet flexible approach to Python web development. It's currently in alpha state but actively maintained and suitable for developers who want to build web applications with minimal configuration while leveraging the full power of the FastAPI ecosystem. ## Core APIs and Functions ### Creating an Air Application Basic Air application initialization with automatic HTML response handling. ```python import air # Create an Air app with default settings app = air.Air() # The @app.page decorator automatically creates routes from function names @app.page def index(): # Function name "index" becomes route "/" return air.Html( air.Head(air.Title("Welcome")), air.Body(air.H1("Hello, World!")) ) @app.page def about_us(): # Function name "about_us" becomes route "/about-us" return air.H1("About Us Page") # Standard FastAPI route decorators also work @app.get("/api/data") async def get_data(): return {"message": "This is JSON from FastAPI"} # Run with: fastapi dev main.py # Output: Server running at http://127.0.0.1:8000 ``` ### Air Tags for HTML Generation Type-safe HTML generation using Python classes instead of templates. ```python import air app = air.Air() @app.get("/") def homepage(): # Air Tags are Python classes that render as HTML elements return air.Html( air.Head( air.Title("My Site"), air.Meta(charset="utf-8"), air.Link(rel="stylesheet", href="/static/style.css") ), air.Body( air.Header( air.H1("Welcome", style="color: blue;"), air.Nav( air.Ul( air.Li(air.A("Home", href="/")), air.Li(air.A("About", href="/about")) ) ) ), air.Main( air.Article( air.H2("Article Title"), air.P("This is a paragraph with ", air.Strong("bold text"), "."), air.Img(src="/image.jpg", alt="Description") ) ), air.Footer(air.P("© 2025 My Company")) ) ) # Renders to properly formatted HTML # Output: ...... ``` ### HTML Comments with Comment Tag Add HTML comments to your markup for documentation or debugging purposes. ```python import air app = air.Air() @app.get("/") def homepage(): return air.Html( air.Head( air.Title("My Site"), air.Comment("Stylesheet loaded from CDN") ), air.Body( air.Comment("Main navigation section"), air.Nav( air.Ul( air.Li(air.A("Home", href="/")), air.Li(air.A("About", href="/about")) ) ), air.Comment("TODO: Add footer content"), air.H1("Welcome to My Site") ) ) # Rendered output includes HTML comments: # # My Site #

Welcome to My Site

# # Note: Comments do not support multi-line text ``` ### Jinja Template Integration Simplified Jinja2 template rendering with Air Tag support in context. ```python import air from air.requests import Request app = air.Air() # Initialize Jinja renderer with template directory jinja = air.JinjaRenderer(directory="templates") @app.get("/") def index(request: Request): # Render Jinja template with context return jinja( request, name="home.html", context={ "title": "Home Page", "user": {"name": "Alice", "age": 30} } ) @app.get("/profile") def profile(request: Request): # Pass kwargs directly as context return jinja( request, "profile.html", username="Bob", email="bob@example.com" ) @app.get("/mixed") def mixed_content(request: Request): # Air Tags work inside Jinja context sidebar_content = air.Aside( air.H3("Sidebar"), air.P("Dynamic content from Python") ) return jinja( request, "layout.html", sidebar=sidebar_content, # Auto-rendered to HTML string page_title="Mixed Content" ) # templates/home.html: #

{{ title }}

#

Welcome, {{ user.name }}!

``` ### AirModel and Form Generation Create Pydantic models that automatically generate Air Forms with the `.to_form()` method. ```python import air from pydantic import EmailStr app = air.Air() # Define AirModel for automatic form generation class ContactModel(air.AirModel): name: str = air.AirField(min_length=2, max_length=50, label="Full Name") email: EmailStr = air.AirField(type="email", label="Email Address") message: str = air.AirField(label="Your Message") @app.get("/contact") def show_contact_form(): # Generate form directly from model using .to_form() form = ContactModel.to_form() return air.Html( air.Body( air.H1("Contact Us"), air.Form( form.render(), # Generates all form fields automatically air.Button("Submit", type="submit"), method="post", action="/contact" ) ) ) @app.post("/contact") async def handle_contact(request: air.Request): form = await ContactModel.to_form().from_request(request) if form.is_valid: # Access validated data through form.data return air.Html( air.Body( air.H1("Thank You!"), air.P(f"Name: {form.data.name}"), air.P(f"Email: {form.data.email}") ) ) # Re-render with errors return air.Html( air.Body( air.H1("Please fix the errors"), air.Form( form.render(), # Shows validation errors air.Button("Submit", type="submit"), method="post", action="/contact" ) ) ) # You can also convert standard Pydantic models to forms from pydantic import BaseModel class UserModel(BaseModel): username: str email: EmailStr # Use the to_form function directly UserForm = air.to_form(UserModel) user_form = UserForm() # AirModel.to_form() supports optional parameters: # - name: Custom class name for the form # - includes: Specific fields to include in the form # - widget: Custom rendering callable ``` ### Form Validation with AirForm Pydantic-powered HTML form validation with automatic error handling. ```python import air from pydantic import BaseModel, Field app = air.Air() # Define Pydantic model for validation class ContactModel(BaseModel): name: str = Field(min_length=2, max_length=50) email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$") age: int = Field(ge=18, le=120) message: str # Create AirForm subclass class ContactForm(air.AirForm): model = ContactModel @app.get("/contact") def show_contact_form(): form = ContactForm() return air.Html( air.Body( air.H1("Contact Us"), air.Form( form.render(), # Generates form fields from model air.Button("Submit", type="submit"), method="post", action="/contact" ) ) ) @app.post("/contact") async def handle_contact_form(request: air.Request): # Validate form data from request form = await ContactForm.from_request(request) if form.is_valid: # Access validated data return air.Html( air.Body( air.H1("Thank You!"), air.P(f"Name: {form.data.name}"), air.P(f"Email: {form.data.email}"), air.P(f"Message: {form.data.message}") ) ) # Re-render form with error messages return air.Html( air.Body( air.H1("Contact Us"), air.P("Please correct the errors below:", style="color: red;"), air.Form( form.render(), # Shows errors and preserves values air.Button("Submit", type="submit"), method="post", action="/contact" ) ) ) # Errors automatically shown with user-friendly messages: # "This field is required." # "Please enter a valid email address." ``` ### Custom Form Fields with AirField Enhanced form field definition with custom labels and input types. ```python import air from pydantic import BaseModel app = air.Air() class RegistrationModel(BaseModel): username: str = air.AirField( label="Username", min_length=3, max_length=20, autofocus=True # Focus on page load ) email: str = air.AirField( label="Email Address", type="email" # Use HTML5 email input ) password: str = air.AirField( label="Password", type="password", # Use password input min_length=8 ) age: int = air.AirField( label="Age", ge=18, le=100 ) website: str = air.AirField( label="Website", type="url" # Use HTML5 URL input ) class RegistrationForm(air.AirForm): model = RegistrationModel @app.get("/register") def show_registration(): form = RegistrationForm() return air.Html( air.Body( air.H1("Register"), air.Form( form.render(), air.Button("Register", type="submit"), method="post", action="/register" ) ) ) @app.post("/register") async def process_registration(request: air.Request): form = await RegistrationForm.from_request(request) if form.is_valid: return air.Html( air.Body( air.H1("Registration Complete"), air.P(f"Welcome, {form.data.username}!") ) ) return air.Html( air.Body( air.H1("Registration Failed"), air.Form( form.render(), air.Button("Try Again", type="submit"), method="post", action="/register" ) ) ) # Rendered HTML includes proper input types: # # # ``` ### Router Organization with AirRouter Organize routes into modular routers for better code structure. ```python import air app = air.Air() # Create a router for user-related routes users_router = air.AirRouter(prefix="/users", tags=["users"]) @users_router.page def index(): # Route becomes /users/ return air.H1("User List") @users_router.get("/{user_id}") def get_user(user_id: int): # Route becomes /users/{user_id} return air.Html( air.Body( air.H1(f"User Profile: {user_id}"), air.P("User details here") ) ) # Create a router for admin routes admin_router = air.AirRouter(prefix="/admin", tags=["admin"]) @admin_router.page def dashboard(): # Route becomes /admin/ return air.H1("Admin Dashboard") @admin_router.get("/settings") def settings(): return air.H1("Admin Settings") # Include routers in main app app.include_router(users_router) app.include_router(admin_router) # Mount a separate FastAPI app for API routes from fastapi import FastAPI api = FastAPI() @api.get("/health") def health_check(): return {"status": "ok"} app.mount("/api", api) # Routes available: # GET /users/ (HTML) # GET /users/{user_id} (HTML) # GET /admin/ (HTML) # GET /admin/settings (HTML) # GET /api/health (JSON) ``` ### REST API Methods: PATCH, PUT, DELETE Air supports all standard HTTP methods including PATCH, PUT, and DELETE decorators. ```python import air app = air.Air() # Store some sample data users = { 1: {"name": "Alice", "email": "alice@example.com"}, 2: {"name": "Bob", "email": "bob@example.com"} } @app.get("/users/{user_id}") def get_user(user_id: int): """Get a user by ID""" if user_id in users: return users[user_id] return {"error": "User not found"} @app.post("/users") def create_user(name: str, email: str): """Create a new user""" new_id = max(users.keys()) + 1 if users else 1 users[new_id] = {"name": name, "email": email} return {"id": new_id, **users[new_id]} @app.patch("/users/{user_id}") def partial_update_user(user_id: int, name: str | None = None, email: str | None = None): """Partially update a user (only provided fields)""" if user_id not in users: return {"error": "User not found"} if name is not None: users[user_id]["name"] = name if email is not None: users[user_id]["email"] = email return users[user_id] @app.put("/users/{user_id}") def full_update_user(user_id: int, name: str, email: str): """Replace entire user record""" users[user_id] = {"name": name, "email": email} return users[user_id] @app.delete("/users/{user_id}") def delete_user(user_id: int): """Delete a user""" if user_id in users: del users[user_id] return {"message": "User deleted successfully"} return {"error": "User not found"} # Available HTTP methods: # @app.get() - Retrieve resources # @app.post() - Create new resources # @app.patch() - Partially update resources # @app.put() - Replace resources entirely # @app.delete() - Remove resources ``` ### Programmatic URL Generation with .url() Method Generate URLs programmatically from route functions using the `.url()` method. ```python import air app = air.Air() @app.get("/users/{user_id}") def get_user(user_id: int): return air.Html( air.Body( air.H1(f"User Profile: {user_id}"), air.P(air.A("View all users", href="/users")) ) ) @app.get("/users/{user_id}/posts/{post_id}") def get_user_post(user_id: int, post_id: int): # Generate URL dynamically using .url() method user_url = get_user.url(user_id=user_id) return air.Html( air.Body( air.H1(f"Post {post_id}"), air.P(air.A(f"Back to user {user_id}", href=user_url)) ) ) @app.get("/") def index(): # Generate URLs for navigation user_123_url = get_user.url(user_id=123) post_url = get_user_post.url(user_id=123, post_id=456) return air.Html( air.Body( air.H1("Home"), air.Ul( air.Li(air.A("User 123", href=user_123_url)), air.Li(air.A("Post 456", href=post_url)) ) ) ) # The .url() method works with routers too users_router = air.AirRouter(prefix="/api/users") @users_router.get("/{user_id}/profile") def user_profile(user_id: int): return air.H1(f"Profile for user {user_id}") app.include_router(users_router) # Generate URL: /api/users/42/profile profile_url = user_profile.url(user_id=42) # Output: get_user.url(user_id=123) returns "/users/123" # Output: get_user_post.url(user_id=123, post_id=456) returns "/users/123/posts/456" # Output: user_profile.url(user_id=42) returns "/api/users/42/profile" ``` ### HTMX Integration with Dependencies Built-in HTMX request detection for building hypermedia-driven applications. ```python import air from typing import Annotated from fastapi import Depends app = air.Air() @app.get("/") def index(): return air.Html( air.Head( air.Script(src="https://unpkg.com/htmx.org@1.9.10") ), air.Body( air.H1("HTMX Demo"), air.Button( "Click Me", hx_get="/clicked", hx_target="#result" ), air.Div(id="result") ) ) @app.get("/clicked") def handle_click(is_htmx: Annotated[bool, air.is_htmx_request]): if is_htmx: # Return just the HTML fragment for HTMX return air.P("Button clicked!", style="color: green;") else: # Return full page for direct navigation return air.Html( air.Body( air.H1("Clicked"), air.P("Button was clicked!") ) ) @app.get("/list") def show_list(is_htmx: Annotated[bool, Depends(air.is_htmx_request)]): items = ["Apple", "Banana", "Cherry"] if is_htmx: # Partial update return air.Ul(*[air.Li(item) for item in items]) # Full page return air.Html( air.Body( air.H1("Items"), air.Ul(*[air.Li(item) for item in items]) ) ) # HTMX requests automatically detected via hx-request header ``` ### Server-Sent Events with SSEResponse Stream real-time updates to the browser using Server-Sent Events. ```python import air from asyncio import sleep import random app = air.Air() @app.get("/") def sse_demo(): return air.Html( air.Head( air.Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js") ), air.Body( air.H1("Live Updates"), air.Section( hx_ext="sse", sse_connect="/events", sse_swap="message", hx_swap="beforeend" ) ) ) async def event_generator(): """Generate events continuously""" count = 0 while count < 10: count += 1 # Air Tags work in SSE responses yield air.P(f"Event {count}: {random.randint(1, 100)}") await sleep(1) # Can also yield plain strings yield "Stream complete!" @app.get("/events") async def stream_events(): return air.SSEResponse(event_generator()) # Alternative: Infinite stream async def infinite_updates(): while True: temperature = random.randint(20, 30) yield air.Div( air.Strong("Temperature: "), air.Span(f"{temperature}°C") ) await sleep(2) @app.get("/temperature") async def temperature_stream(): return air.SSEResponse(infinite_updates()) # Client receives formatted SSE events: # event: message # data:

Event 1: 42

``` ### Combining Air with FastAPI REST APIs Build unified applications with both HTML pages and JSON APIs. ```python import air from fastapi import FastAPI from pydantic import BaseModel # Create Air app for web pages app = air.Air() # Create separate FastAPI app for REST API api = FastAPI() # Data model for API class TodoItem(BaseModel): id: int title: str completed: bool # In-memory storage todos = [ TodoItem(id=1, title="Learn Air", completed=False), TodoItem(id=2, title="Build app", completed=False) ] # HTML pages using Air @app.get("/") def homepage(): return air.Html( air.Body( air.H1("Todo Application"), air.P(air.A("View Todos", href="/todos")), air.P(air.A("API Documentation", href="/api/docs", target="_blank")) ) ) @app.get("/todos") def todos_page(): return air.Html( air.Body( air.H1("My Todos"), air.Ul(*[ air.Li(f"{todo.title} - {'✓' if todo.completed else '○'}") for todo in todos ]), air.P(air.A("Back to Home", href="/")) ) ) # REST API endpoints using FastAPI @api.get("/todos", response_model=list[TodoItem]) def get_todos(): return todos @api.get("/todos/{todo_id}", response_model=TodoItem) def get_todo(todo_id: int): for todo in todos: if todo.id == todo_id: return todo return {"error": "Not found"} @api.post("/todos", response_model=TodoItem, status_code=201) def create_todo(todo: TodoItem): todos.append(todo) return todo # Mount the API app app.mount("/api", api) # Available endpoints: # GET / - HTML home page # GET /todos - HTML todo list # GET /api/docs - Auto-generated API docs # GET /api/todos - JSON list of todos # POST /api/todos - Create new todo (JSON) ``` ### Custom Response Types and Redirects Handle different response types and URL redirections. ```python import air from air import RedirectResponse app = air.Air() @app.get("/") def index(): return air.Html( air.Body( air.H1("Home"), air.A("Go to Dashboard", href="/dashboard"), air.Br(), air.A("Old Link", href="/old-page") ) ) @app.get("/old-page") def old_page(): # Redirect with 303 status (default) return RedirectResponse(url="/new-page") @app.get("/login") def login(): # 307 redirect (preserves request method) return RedirectResponse(url="/dashboard", status_code=307) @app.get("/new-page") def new_page(): return air.Html( air.Body( air.H1("New Page"), air.P("You've been redirected here.") ) ) @app.get("/dashboard") def dashboard(): return air.Html( air.Body(air.H1("Dashboard")) ) @app.get("/download") def download_file(): # Return different response types from starlette.responses import FileResponse return FileResponse( path="report.pdf", filename="monthly-report.pdf", media_type="application/pdf" ) @app.get("/json-data") def json_endpoint(): # Return JSON directly from air.responses import JSONResponse return JSONResponse({ "status": "success", "data": [1, 2, 3, 4, 5] }) # RedirectResponse uses 303 by default (GET after POST pattern) # All standard Starlette responses work with Air ``` ## Summary and Use Cases Air is ideally suited for building modern full-stack web applications where you want the simplicity of server-rendered HTML combined with the power of a modern async framework. Primary use cases include rapid prototyping of web applications, building admin dashboards and internal tools, creating marketing websites with interactive elements, developing HTMX-powered hypermedia applications, and building hybrid applications that serve both HTML pages and REST APIs. The framework shines when you need Pydantic's validation for form handling, want to avoid JavaScript build tooling while still having dynamic interfaces, or prefer Python's type system for generating HTML rather than working with template strings. The new AirModel class with its `.to_form()` method makes form creation even more streamlined by automatically generating Air Forms from Pydantic models. Air integrates seamlessly with the broader FastAPI ecosystem, allowing you to mount multiple FastAPI apps, use FastAPI's dependency injection system, leverage all Starlette middleware and background tasks, and access the entire Pydantic ecosystem for data validation. The framework supports gradual adoption - you can start with Air Tags for simple pages and add Jinja templates for complex layouts, or begin with a pure API and add HTML pages later. Installation options include standard features via `pip install air` or `uv add air`, additional packages like SQLModel for database support (being deprecated), Authlib for OAuth authentication, and lxml/rich for pretty HTML rendering. Recent additions include the `.url()` method on route functions for programmatic URL generation, the `Comment` tag for HTML comments, `AirModel` with `.to_form()` for streamlined form generation, and support for PATCH, PUT, and DELETE HTTP methods. The framework requires Python 3.13+ and follows FastAPI's patterns for maximum compatibility with modern async Python development.