Alpine.js integration for Air framework with excellent DX
Project description
Airpine 🏔️
[!WARNING] I this is in early dev. Could be rewrites, breaking changes, bugs, and more.
Alpine.js integration for the Air framework with excellent Python DX
Airpine provides a Pythonic, type-safe API for working with Alpine.js directives in Air applications. Get excellent IDE autocomplete, natural chained syntax, and type-safe modifiers.
Installation
pip install airpine
Or with uv:
uv pip install airpine
Quick Start
from air import Air, Div, Button, Input, Span
from airpine import Alpine
app = Air()
@app.page
def index():
return Div(
# Counter with reactive state
Button("-", **Alpine.at.click("count--")),
Span(**Alpine.x.text("count")),
Button("+", **Alpine.at.click("count++")),
**Alpine.x.data({"count": 0}),
)
Don't forget to include Alpine.js in your HTML:
from air import Html, Head, Body, Script
def layout(content):
return Html(
Head(
Script(
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js",
defer=True
)
),
Body(content)
)
Why Airpine?
Before (painful)
# No autocomplete, easy typos, ugly syntax
Button(**{"@click.prevent.once": "save()"})
Form(**{"x-data": '{"email": "", "valid": false}', "@submit.prevent": "send()"})
With Airpine (delightful)
# Full IDE autocomplete, natural syntax, composable
Button(**Alpine.at.click.prevent.once("save()"))
Form(**(
Alpine.x.data({"email": "", "valid": False}) |
Alpine.at.submit.prevent("send()")
))
Features
- ✨ Excellent IDE autocomplete - All Alpine.js directives and modifiers typed
- 🔗 Natural chained syntax -
Alpine.at.click.prevent.once("handler()") - 🎯 Type-safe - Catch errors at dev time, not runtime
- 🐍 Pythonic - Use Python dicts for x-data, no manual JSON
- 🧩 Composable - Merge attributes with
|operator - 🚀 Production-ready - Comprehensive tests, proper escaping
Python → Alpine Cheat Sheet
| Python | Alpine HTML | Description |
|---|---|---|
Alpine.x.data({"count": 0}) |
x-data='{ "count": 0 }' |
Component state |
Alpine.x.text("message") |
x-text="message" |
Set text content |
Alpine.x.show("visible") |
x-show="visible" |
Toggle visibility (CSS) |
Alpine.x.if_("condition") |
x-if="condition" |
Conditional rendering (DOM) |
Alpine.x.for_("item in items") |
x-for="item in items" |
Loop rendering |
Alpine.x.model("email") |
x-model="email" |
Two-way binding |
Alpine.x.bind.class_("active") |
x-bind:class="active" |
Bind class |
Alpine.x.ref("myInput") |
x-ref="myInput" |
Element reference |
Alpine.at.click("handler()") |
@click="handler()" |
Click event |
Alpine.at.submit.prevent("send()") |
@submit.prevent="send()" |
Submit with preventDefault |
Alpine.at.keydown.enter("submit()") |
@keydown.enter="submit()" |
Enter key |
Alpine.at.click.outside("close()") |
@click.outside="close()" |
Click outside |
Alpine.at.input.debounce(300)("search()") |
@input.debounce.300ms="search()" |
Debounced input |
Common Patterns
Modal with ESC key
Div(
Button("Open", **Alpine.at.click("open = true")),
Div(
# Modal content
**Alpine.x.show("open"),
),
**(
Alpine.x.data({"open": False}) |
Alpine.at.keydown.escape.window("open = false") |
Alpine.at.click.outside("open = false")
)
)
Form Validation
Form(
Input(
type="email",
**(
Alpine.x.model("email") |
Alpine.at.input.debounce(300)("validate()")
)
),
Button(
"Submit",
**Alpine.x.bind.disabled("!valid")
),
**Alpine.x.data({
"email": "",
"valid": False,
"validate": RawJS("function() { this.valid = this.email.includes('@'); }")
})
)
Tabs
from airpine import Alpine
Div(
# Tab buttons
Div(
Button("Tab 1", **(
Alpine.at.click("tab = 0") |
Alpine.x.bind.class_("{ 'active': tab === 0 }")
)),
Button("Tab 2", **(
Alpine.at.click("tab = 1") |
Alpine.x.bind.class_("{ 'active': tab === 1 }")
)),
),
# Tab content
Div("Content 1", **Alpine.x.show("tab === 0")),
Div("Content 2", **Alpine.x.show("tab === 1")),
**Alpine.x.data({"tab": 0})
)
Search with Debounce
Div(
Input(
placeholder="Search...",
**(
Alpine.x.model("query") |
Alpine.at.input.debounce(300)("search()")
)
),
Div(**Alpine.x.html("results")),
**Alpine.x.data({
"query": "",
"results": "",
"search": RawJS("""function() {
fetch('/search?q=' + this.query)
.then(r => r.text())
.then(html => { this.results = html; });
}""")
})
)
API Reference
Events (Alpine.at.*)
Common Events
click,dblclick- Mouse clicksinput,change- Form inputsubmit- Form submissionkeydown,keyup,keypress- Keyboardfocus,blur- Focus eventsmouseenter,mouseleave- Mouse movementscroll,resize- Window events
Event Modifiers
.prevent- preventDefault().stop- stopPropagation().once- Run only once.self- Only if event.target is element itself.window- Listen on window.document- Listen on document.outside/.away- Click outside element.passive- Passive event listener.capture- Use capture phase.debounce(ms)- Debounce handler (default 250ms).throttle(ms)- Throttle handler (default 250ms)
Keyboard Modifiers
.enter,.space,.escape,.tab.up,.down,.left,.right.backspace,.delete,.home,.end.page_up,.page_down.shift,.ctrl,.alt,.meta,.cmd.key(name)- Custom key (e.g.,.key("f1"))
Chain modifiers: Alpine.at.keydown.ctrl.enter("submit()")
Directives (Alpine.x.*)
State & Rendering
data(dict | str)- Component statetext(expr)- Set text contenthtml(expr)- Set innerHTML (⚠️ XSS risk with user input)show(expr)- Toggle visibility (CSS)if_(expr)- Conditional rendering (DOM)for_(expr)- Loop rendering
Binding
model(expr)- Two-way data bindingbind.class_(expr)- Bind classbind.style(expr)- Bind stylebind.href(expr)- Bind hrefbind.{attribute}(expr)- Bind any attribute
Lifecycle & Utils
init(expr)- Run on initializationeffect(expr)- Re-run when dependencies changeref(name)- Element reference (access via$refs.name)cloak()- Hide until Alpine loadsignore()- Ignore element and childrenignore_self()- Ignore only element, not childrenkey(expr)- Unique key for x-for itemsid(list)- Generate scoped IDs for accessibilityteleport(selector)- Move content to selectormodelable(prop)- Make property bindable with x-model
Transitions
transition()- Simple transitiontransition.enter(classes)- Enter transitiontransition.enter_start(classes)- Enter start statetransition.enter_end(classes)- Enter end statetransition.leave(classes)- Leave transitiontransition.leave_start(classes)- Leave start statetransition.leave_end(classes)- Leave end state
Plugins (require Alpine.js plugins)
intersect(expr)- Intersection observermask(expr)- Input maskingtrap(expr)- Focus trappingcollapse()- Collapse animation
Model Modifiers
Alpine.x.model(expr)- Basic two-way bindingAlpine.x.model.lazy(expr)- Update on change instead of inputAlpine.x.model.number(expr)- Convert to numberAlpine.x.model.boolean(expr)- Convert to booleanAlpine.x.model.trim(expr)- Trim whitespaceAlpine.x.model.fill(expr)- Use input's value attribute to initializeAlpine.x.model.debounce(ms)(expr)- Debounce updatesAlpine.x.model.throttle(ms)(expr)- Throttle updates
Using RawJS
For JavaScript functions/expressions in x-data, use RawJS:
from airpine import Alpine, RawJS
Alpine.x.data({
"count": 0,
"increment": RawJS("function() { this.count++; }"),
"reset": RawJS("() => { this.count = 0; }")
})
⚠️ Security Warning: Never use RawJS with user input - it can lead to XSS vulnerabilities.
Escaping & Security
How Escaping Works
- Airpine converts Python values to valid JavaScript
- Air (the framework) handles HTML attribute escaping at render time
- You don't need to pre-escape values
Safe by Default
# Safe - strings are automatically escaped
Alpine.x.data({"message": "User's <script>alert('xss')</script> input"})
# Generates: x-data='{ "message": "User\'s <script>alert(\'xss\')</script> input" }'
# Air escapes this when rendering to HTML
Only Use Raw JS for Functions
# Safe - RawJS for JavaScript code only
Alpine.x.data({
"userInput": user_provided_data, # ✅ Safe - escaped
"handler": RawJS("function() { ... }") # ✅ Safe - your code
})
# NEVER do this:
Alpine.x.data({
"handler": RawJS(f"function() {{ alert('{user_input}'); }}") # ❌ XSS!
})
Merging Attributes
Use Python's | operator to merge attributes:
attrs = (
Alpine.x.data({"count": 0}) |
Alpine.at.click("count++") |
Alpine.x.bind.class_("'active'")
)
Button("Click me", **attrs)
Note: When merging, the last value wins for duplicate keys.
Supported Versions
- Python: ≥ 3.11
- Alpine.js: 3.x
- Air: ≥ 0.30.0
Examples
See examples/demo.py for a complete demo application with:
- Counter
- Toggle visibility
- Form validation
- Dropdowns
- Modals
- Search with debounce
- Tabs
- And more!
Run the demo:
python examples/demo.py
# Visit http://localhost:8001
Development
Setup
# Install with dev dependencies
uv pip install -e ".[dev]"
# Install playwright browsers (for integration tests)
playwright install chromium
Commands (using just)
# Run tests
just test
# Run specific tests
just test-serializer
just test-builders
# Lint and format
just lint
just format
just fix
# Type check
just typecheck
# Run all checks
just check
# Run demo
just demo
License
MIT License - see LICENSE for details.
Contributing
Contributions welcome! Please:
- Add tests for new features
- Run
just checkbefore submitting - Follow existing code style
Links
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file airpine-0.2.1.tar.gz.
File metadata
- Download URL: airpine-0.2.1.tar.gz
- Upload date:
- Size: 100.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dce13674abd974a4ed9b8c215c9c6197ddce63ce027be360a2cb4e41c4d89e01
|
|
| MD5 |
d5ff549dbd7a7e014712357905f434c9
|
|
| BLAKE2b-256 |
cd59c3b6866ba84cf38bebcfaedb806b3551f07f0dd9ce85f0fab587a18be393
|
File details
Details for the file airpine-0.2.1-py3-none-any.whl.
File metadata
- Download URL: airpine-0.2.1-py3-none-any.whl
- Upload date:
- Size: 13.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ee2a6f63f7392a842a2c6cb6726a1b8b2d71ea0838b668a881c6bee96c512d41
|
|
| MD5 |
6859b21a71e5ab1dc573cefddb227aae
|
|
| BLAKE2b-256 |
17ae5b0f02f179da6cff24065acef0081b230334133c1cdb349a4563f70346ef
|