py4web is a Python web framework for rapid development of database-driven web applications. It is the modern successor to web2py, built on ombott (optimized Bottle fork) and PyDAL.
Every py4web app is a Python package (folder with __init__.py) inside the apps/ directory. Standard layout:
apps/myapp/
__init__.py # App init: version check, import controllers and db
settings.py # All configuration (DB_URI, session type, auth, etc.)
common.py # Initialize fixtures: db, session, auth, T, cache, flash, logger
models.py # Database table definitions using PyDAL
controllers.py # Action handlers (routes)
tasks.py # Scheduler/Celery tasks (optional)
templates/
layout.html # Base template (extend this)
index.html # Page templates
auth.html # Auth forms
static/
js/utils.js # Standard JS utility library (Q selector, AJAX, etc.)
css/no.css # Default minimal CSS framework
databases/ # SQLite files and migration metadata
translations/ # i18n JSON files
uploads/ # User-uploaded files
from py4web import action, URL, redirect, request, abort
from .common import db, session, auth, T, flash, cache
# Basic action
@action("index")
@action.uses("index.html", auth, T)
def index():
user = auth.get_user()
return dict(message="Hello")
# With URL parameters
@action("api/item/<id:int>")
@action.uses(db, auth.user)
def item(item_id):
record = db.thing[item_id] or abort(404)
return dict(record=record)
# API endpoint returning JSON (no template)
@action("api/items", method="GET")
@action.uses(db, auth.user)
def api_items():
items = db(db.thing).select()
return dict(data=itemas.as_list())
# POST action
@action("api/item", method="POST")
@action.uses(db, auth.user)
def api_new_item():
res = db.thing.validate_and_insert(**request.json)
return dict(ok=record_id=res.get("id"), errors=res.get("errors"), ok=not res.get("errors"))
# PUT action
@action("api/item/<id:int>", method="PUT")
@action.uses(db, auth.user)
def api_new_item(item_id):
res = db.thing.validate_and_update(item_id, **request.json)
return dict(record_id=res.get("id"), errors=res.get("errors"), ok=bool(res.get("updated")))Rules:
@action("path")defines the route. The URL is/{app_name}/{path}.@action.uses(...)declares fixtures. Template must be first if present.@action.uses(auth.user)requires login;@action.uses(auth)makes login optional.- Actions return
dict(rendered by template or as JSON),str, or raiseredirect()/abort(). - Use
auth.get_user()to get current user dict,auth.user_idfor just the ID. - Use
request.query.get("param")for query params,request.jsonfor JSON body,request.formsfor form data. - Multiple routes: stack
@actiondecorators on the same function. - HTTP methods:
@action("path", method=["GET", "POST"]).
common.py defines authenticated and unauthenticated ActionFactory decorators:
# Instead of @action.uses(db, session, T, auth.user)
@authenticated()
def protected():
return dict()
# Instead of @action.uses(db, session, T, auth)
@unauthenticated()
def public():
return dict()from pydal.validators import *
from .common import db, Field, auth
db.define_table("thing",
Field("name", requires=IS_NOT_EMPTY()),
Field("description", "text"),
Field("quantity", "integer", default=0),
Field("owner", "reference auth_user"),
auth.signature, # Adds created_by, created_on, modified_by, modified_on
)
db.commit()Rules:
- Define tables in
models.pyat module level (executed once at startup). - Always call
db.commit()after table definitions. auth.signatureadds ownership/timestamp fields automatically.- Use PyDAL field types:
string,text,integer,float,boolean,date,datetime,reference tablename,upload,json,list:string,list:integer. - Validators:
IS_NOT_EMPTY(),IS_IN_SET(["a","b"]),IS_INT_IN_RANGE(0, 100),IS_EMAIL(),IS_MATCH(regex), etc. - Only field attributes (readable, writable, requires, default) are thread-safe to modify in actions.
# Select
rows = db(db.thing.owner == auth.user_id).select(orderby=~db.thing.created_on, limitby=(0, 100))
# Insert
id = db.thing.insert(name="foo", description="bar")
# Update
db(db.thing.id == id).update(name="new name")
# Delete
db(db.thing.id == id).delete()
# Joins
rows = db(db.thing.owner == db.auth_user.id).select(db.thing.ALL, db.auth_user.email)
# Count
n = db(db.thing.owner == auth.user_id).count()
# Complex queries with & (AND) and | (OR)
query = (db.thing.name.contains("foo")) & (db.thing.quantity > 0)
rows = db(query).select()from py4web import action, URL, redirect, request, abort
from .common import db, session, auth, T, flash, cache
from utils.form import Form
# create form
@action("create_item")
@action.uses(db, auth.user, "create_template.html")
def create_item():
form = Form(db.thing) # does postback
if form.accepted:
# on success
item_id = form.vars.get("id")
redirect(URL("other_action", item_id))
return dict(form=form)
# create form
@action("edit_item")
@action.uses(db, auth.user, "edit_template.html")
def edit_item(item_id):
form = Form(db.thing, item_id) # does postback
if form.accepted:
# on success
redirect(URL("other_action", item_id))
return dict(form=form)- template but contain [[=form]] to embed the form
Form(db.table)for create,Form(db.table, record_id)for edit.- Check
form.accepted,form.deleted,form.errors.
grid = Grid(db.thing, formstyle=FormStyleDefault)
return dict(grid=grid)from pydal.tools.tags import Tags
groups = Tags(db.auth_user, "groups")
groups.add(user_id, "manager")
if "manager" in groups.get(user_id):
...URL("action_name") # /{app}/action_name
URL("action", 1, 2) # /{app}/action/1/2
URL("action", vars=dict(x=1)) # /{app}/action?x=1
URL("static", "js/index.js") # /{app}/static/js/index.js
URL("action", scheme=True) # https://host/{app}/actionfrom py4web.utils.url_signer import URLSigner
url_signer = URLSigner(session)
@action("callback", method="POST")
@action.uses(db, session, url_signer.verify())
def callback():
...
# In controller, pass signed URL:
return dict(callback_url=URL("callback", signer=url_signer))py4web uses [[...]] delimiters (NOT {{...}} or {%...%}) to avoid conflicts with Vue.js/Angular.
[[extend "layout.html"]]
<!-- Output (auto-escaped) -->
[[=variable]]
<!-- Raw output (no escaping) -->
[[=XML(html_string)]]
<!-- Conditionals -->
[[if condition:]]
<p>yes</p>
[[elif other:]]
<p>other</p>
[[else:]]
<p>no</p>
[[pass]]
<!-- Loops -->
[[for item in items:]]
<p>[[=item.name]]</p>
[[pass]]
<!-- Blocks (for template inheritance) -->
[[block page_head]]
<link rel="stylesheet" href="[[=URL('static','css/custom.css')]]">
[[end]]
<!-- Include -->
[[include "component.html"]]Rules:
- Always
[[extend "layout.html"]]at top for consistent look. - Use
[[block name]]...[[end]]to override sections from layout. - Actions return
dict(...)and template variables are accessed directly by name. [[=form]]renders a Form object.[[=grid]]renders a Grid.BEAUTIFY(__vars__)ingeneric.htmlfor debugging.
All apps include utils.js which provides a lightweight jQuery alternative:
// DOM selection
Q("selector") // querySelectorAll wrapper
Q(".myclass")[0].style.display = "none"
// AJAX
Q.get(url) // GET, returns Promise
Q.post(url, data) // POST with JSON body
Q.put(url, data) // PUT
Q.delete(url) // DELETE
Q.ajax("GET", url, data, headers) // Full control
// Cookies
Q.get_cookie("name")
// Forms
Q.trap_form(action, elem_id) // AJAX form submission
// Components
Q.handle_components() // Process <ajax-component> tags
Q.flash({message, class}) // Show flash alert
// Translation
T("string") // Client-side i18n// static/js/index.js
var app = {
data() {
return { items: [], content: "" };
},
methods: {
submit() {
axios.post(create_url, { content: this.content })
.then(() => { this.content = ""; this.reload(); });
},
reload() {
axios.get(data_url).then(r => { this.items = r.data.items; });
},
},
mounted() {
this.reload();
},
};
Vue.createApp(app).mount("#app");- Use Vue 3 with
createApp(). - Use Axios for HTTP requests.
- Pass URLs from controller via template variables (do NOT hardcode paths in JS).
- Template uses
v-model,v-for,@click,v-ifetc.
For simple interactivity without Vue:
<script>
function callback(elem) {
Q.post(elem.getAttribute("data-url"), {})
.then(() => location.reload());
}
</script>
<button data-url="[[=URL('my_action')]]" onclick="callback(this)">Click</button>- Default framework:
no.css(classless, styles semantic HTML automatically). - Alternative: Bulma (used in birdwatching app).
- Grid classes:
.columns,.c25,.c33,.c50,.c66,.c75. - Color classes:
.success,.warning,.error,.info,.black,.white. - Use
class="padded"for spacing.
- Fixtures declare dependencies. Every action must list what it needs via
@action.uses(). No global middleware. - Template first. When using a template fixture, it must be the first argument in
@action.uses("template.html", ...). - No hardcoded URLs. Always use
URL()to generate paths. Pass URLs to JavaScript via template variables. - Settings in settings.py. Never hardcode configuration values in controllers or models.
- Shared state in common.py. All fixtures (
db,session,auth,T,cache,flash) are initialized incommon.pyand imported elsewhere. db.commit()after table definitions. Required inmodels.pyafterdb.define_table()calls.- Thread safety. Table definitions are NOT thread-safe. Only field attributes (readable, writable, requires, default) can be modified per-request.
- DAL transactions are automatic. Commit on success, rollback on error. No manual commit needed in actions.
- YATL delimiters are
[[...]], not{{...}}. This avoids conflicts with Vue.js. - Use
auth.signatureon tables that need ownership tracking. - Static files go in
static/and are served at/{app}/static/.... - Translations go in
translations/as JSON files keyed by language code.
Run tests with:
python -m pytest tests/# Install
pip install py4web
# Setup (first time)
py4web setup apps
# Run development server
py4web run apps
# Run on specific port
py4web run --port 8000 apps