|
| 1 | +# py4web Development Guide for Claude |
| 2 | + |
| 3 | +## Project Overview |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +## App Structure |
| 8 | + |
| 9 | +Every py4web app is a Python package (folder with `__init__.py`) inside the `apps/` directory. Standard layout: |
| 10 | + |
| 11 | +``` |
| 12 | +apps/myapp/ |
| 13 | + __init__.py # App init: version check, import controllers and db |
| 14 | + settings.py # All configuration (DB_URI, session type, auth, etc.) |
| 15 | + common.py # Initialize fixtures: db, session, auth, T, cache, flash, logger |
| 16 | + models.py # Database table definitions using PyDAL |
| 17 | + controllers.py # Action handlers (routes) |
| 18 | + tasks.py # Scheduler/Celery tasks (optional) |
| 19 | + templates/ |
| 20 | + layout.html # Base template (extend this) |
| 21 | + index.html # Page templates |
| 22 | + auth.html # Auth forms |
| 23 | + static/ |
| 24 | + js/utils.js # Standard JS utility library (Q selector, AJAX, etc.) |
| 25 | + css/no.css # Default minimal CSS framework |
| 26 | + databases/ # SQLite files and migration metadata |
| 27 | + translations/ # i18n JSON files |
| 28 | + uploads/ # User-uploaded files |
| 29 | +``` |
| 30 | + |
| 31 | +## Python Conventions |
| 32 | + |
| 33 | +### Actions (Controllers) |
| 34 | + |
| 35 | +```python |
| 36 | +from py4web import action, URL, redirect, request, abort |
| 37 | +from .common import db, session, auth, T, flash, cache |
| 38 | + |
| 39 | +# Basic action |
| 40 | +@action("index") |
| 41 | +@action.uses("index.html", auth, T) |
| 42 | +def index(): |
| 43 | + user = auth.get_user() |
| 44 | + return dict(message="Hello") |
| 45 | + |
| 46 | +# With URL parameters |
| 47 | +@action("item/<id:int>") |
| 48 | +@action.uses(db, auth.user) |
| 49 | +def item(id): |
| 50 | + record = db.thing[id] or abort(404) |
| 51 | + return dict(record=record) |
| 52 | + |
| 53 | +# API endpoint returning JSON (no template) |
| 54 | +@action("api/data", method="GET") |
| 55 | +@action.uses(db, auth.user) |
| 56 | +def api_data(): |
| 57 | + rows = db(db.thing).select() |
| 58 | + return dict(data=rows.as_list()) |
| 59 | + |
| 60 | +# POST action |
| 61 | +@action("api/create", method="POST") |
| 62 | +@action.uses(db, auth.user) |
| 63 | +def api_create(): |
| 64 | + db.thing.insert(**request.json) |
| 65 | + return dict(ok=True) |
| 66 | +``` |
| 67 | + |
| 68 | +**Rules:** |
| 69 | +- `@action("path")` defines the route. The URL is `/{app_name}/{path}`. |
| 70 | +- `@action.uses(...)` declares fixtures. Template must be **first** if present. |
| 71 | +- `@action.uses(auth.user)` requires login; `@action.uses(auth)` makes login optional. |
| 72 | +- Actions return `dict` (rendered by template or as JSON), `str`, or raise `redirect()`/`abort()`. |
| 73 | +- Use `auth.get_user()` to get current user dict, `auth.user_id` for just the ID. |
| 74 | +- Use `request.query.get("param")` for query params, `request.json` for JSON body, `request.forms` for form data. |
| 75 | +- Multiple routes: stack `@action` decorators on the same function. |
| 76 | +- HTTP methods: `@action("path", method=["GET", "POST"])`. |
| 77 | + |
| 78 | +### Convenience Decorators |
| 79 | + |
| 80 | +`common.py` defines `authenticated` and `unauthenticated` ActionFactory decorators: |
| 81 | + |
| 82 | +```python |
| 83 | +# Instead of @action.uses(db, session, T, auth.user) |
| 84 | +@authenticated() |
| 85 | +def protected(): |
| 86 | + return dict() |
| 87 | + |
| 88 | +# Instead of @action.uses(db, session, T, auth) |
| 89 | +@unauthenticated() |
| 90 | +def public(): |
| 91 | + return dict() |
| 92 | +``` |
| 93 | + |
| 94 | +### Database Models |
| 95 | + |
| 96 | +```python |
| 97 | +from pydal.validators import * |
| 98 | +from .common import db, Field, auth |
| 99 | + |
| 100 | +db.define_table("thing", |
| 101 | + Field("name", requires=IS_NOT_EMPTY()), |
| 102 | + Field("description", "text"), |
| 103 | + Field("quantity", "integer", default=0), |
| 104 | + Field("owner", "reference auth_user"), |
| 105 | + auth.signature, # Adds created_by, created_on, modified_by, modified_on |
| 106 | +) |
| 107 | +db.commit() |
| 108 | +``` |
| 109 | + |
| 110 | +**Rules:** |
| 111 | +- Define tables in `models.py` at module level (executed once at startup). |
| 112 | +- Always call `db.commit()` after table definitions. |
| 113 | +- `auth.signature` adds ownership/timestamp fields automatically. |
| 114 | +- Use PyDAL field types: `string`, `text`, `integer`, `float`, `boolean`, `date`, `datetime`, `reference tablename`, `upload`, `json`, `list:string`, `list:integer`. |
| 115 | +- Validators: `IS_NOT_EMPTY()`, `IS_IN_SET(["a","b"])`, `IS_INT_IN_RANGE(0, 100)`, `IS_EMAIL()`, `IS_MATCH(regex)`, etc. |
| 116 | +- Only field attributes (readable, writable, requires, default) are thread-safe to modify in actions. |
| 117 | + |
| 118 | +### Database Queries |
| 119 | + |
| 120 | +```python |
| 121 | +# Select |
| 122 | +rows = db(db.thing.owner == auth.user_id).select(orderby=~db.thing.created_on, limitby=(0, 100)) |
| 123 | + |
| 124 | +# Insert |
| 125 | +id = db.thing.insert(name="foo", description="bar") |
| 126 | + |
| 127 | +# Update |
| 128 | +db(db.thing.id == id).update(name="new name") |
| 129 | + |
| 130 | +# Delete |
| 131 | +db(db.thing.id == id).delete() |
| 132 | + |
| 133 | +# Joins |
| 134 | +rows = db(db.thing.owner == db.auth_user.id).select(db.thing.ALL, db.auth_user.email) |
| 135 | + |
| 136 | +# Count |
| 137 | +n = db(db.thing.owner == auth.user_id).count() |
| 138 | + |
| 139 | +# Complex queries with & (AND) and | (OR) |
| 140 | +query = (db.thing.name.contains("foo")) & (db.thing.quantity > 0) |
| 141 | +rows = db(query).select() |
| 142 | +``` |
| 143 | + |
| 144 | +### Forms |
| 145 | + |
| 146 | +```python |
| 147 | +form = Form(db.thing, csrf_session=session) |
| 148 | +if form.accepted: |
| 149 | + redirect(URL("index")) |
| 150 | +return dict(form=form) |
| 151 | +``` |
| 152 | + |
| 153 | +- `Form(db.table)` for create, `Form(db.table, record_id)` for edit. |
| 154 | +- Check `form.accepted`, `form.deleted`, `form.errors`. |
| 155 | + |
| 156 | +### Grid |
| 157 | + |
| 158 | +```python |
| 159 | +grid = Grid(db.thing, formstyle=FormStyleDefault) |
| 160 | +return dict(grid=grid) |
| 161 | +``` |
| 162 | + |
| 163 | +### Authorization with Tags |
| 164 | + |
| 165 | +```python |
| 166 | +from pydal.tools.tags import Tags |
| 167 | +groups = Tags(db.auth_user, "groups") |
| 168 | +groups.add(user_id, "manager") |
| 169 | +if "manager" in groups.get(user_id): |
| 170 | + ... |
| 171 | +``` |
| 172 | + |
| 173 | +### URL Generation |
| 174 | + |
| 175 | +```python |
| 176 | +URL("action_name") # /{app}/action_name |
| 177 | +URL("action", 1, 2) # /{app}/action/1/2 |
| 178 | +URL("action", vars=dict(x=1)) # /{app}/action?x=1 |
| 179 | +URL("static", "js/index.js") # /{app}/static/js/index.js |
| 180 | +URL("action", scheme=True) # https://host/{app}/action |
| 181 | +``` |
| 182 | + |
| 183 | +### Custom Fixtures |
| 184 | + |
| 185 | +```python |
| 186 | +from py4web.core import Fixture |
| 187 | + |
| 188 | +class MyFixture(Fixture): |
| 189 | + def on_request(self, context): |
| 190 | + # Called before action |
| 191 | + pass |
| 192 | + def on_success(self, context): |
| 193 | + # Called after successful action |
| 194 | + pass |
| 195 | + def on_error(self, context): |
| 196 | + # Called on error |
| 197 | + pass |
| 198 | +``` |
| 199 | + |
| 200 | +### URL Signing (CSRF/tamper protection for callbacks) |
| 201 | + |
| 202 | +```python |
| 203 | +from py4web.utils.url_signer import URLSigner |
| 204 | +url_signer = URLSigner(session) |
| 205 | + |
| 206 | +@action("callback", method="POST") |
| 207 | +@action.uses(db, session, url_signer.verify()) |
| 208 | +def callback(): |
| 209 | + ... |
| 210 | + |
| 211 | +# In controller, pass signed URL: |
| 212 | +return dict(callback_url=URL("callback", signer=url_signer)) |
| 213 | +``` |
| 214 | + |
| 215 | +## Template Conventions (YATL) |
| 216 | + |
| 217 | +py4web uses `[[...]]` delimiters (NOT `{{...}}` or `{%...%}`) to avoid conflicts with Vue.js/Angular. |
| 218 | + |
| 219 | +```html |
| 220 | +[[extend "layout.html"]] |
| 221 | + |
| 222 | +<!-- Output (auto-escaped) --> |
| 223 | +[[=variable]] |
| 224 | + |
| 225 | +<!-- Raw output (no escaping) --> |
| 226 | +[[=XML(html_string)]] |
| 227 | + |
| 228 | +<!-- Conditionals --> |
| 229 | +[[if condition:]] |
| 230 | + <p>yes</p> |
| 231 | +[[elif other:]] |
| 232 | + <p>other</p> |
| 233 | +[[else:]] |
| 234 | + <p>no</p> |
| 235 | +[[pass]] |
| 236 | + |
| 237 | +<!-- Loops --> |
| 238 | +[[for item in items:]] |
| 239 | + <p>[[=item.name]]</p> |
| 240 | +[[pass]] |
| 241 | + |
| 242 | +<!-- Blocks (for template inheritance) --> |
| 243 | +[[block page_head]] |
| 244 | + <link rel="stylesheet" href="[[=URL('static','css/custom.css')]]"> |
| 245 | +[[end]] |
| 246 | + |
| 247 | +<!-- Include --> |
| 248 | +[[include "component.html"]] |
| 249 | +``` |
| 250 | + |
| 251 | +**Rules:** |
| 252 | +- Always `[[extend "layout.html"]]` at top for consistent look. |
| 253 | +- Use `[[block name]]...[[end]]` to override sections from layout. |
| 254 | +- Actions return `dict(...)` and template variables are accessed directly by name. |
| 255 | +- `[[=form]]` renders a Form object. `[[=grid]]` renders a Grid. |
| 256 | +- `BEAUTIFY(__vars__)` in `generic.html` for debugging. |
| 257 | + |
| 258 | +## JavaScript Conventions |
| 259 | + |
| 260 | +### Q Utility Library (utils.js) |
| 261 | + |
| 262 | +All apps include `utils.js` which provides a lightweight jQuery alternative: |
| 263 | + |
| 264 | +```javascript |
| 265 | +// DOM selection |
| 266 | +Q("selector") // querySelectorAll wrapper |
| 267 | +Q(".myclass")[0].style.display = "none" |
| 268 | + |
| 269 | +// AJAX |
| 270 | +Q.get(url) // GET, returns Promise |
| 271 | +Q.post(url, data) // POST with JSON body |
| 272 | +Q.put(url, data) // PUT |
| 273 | +Q.delete(url) // DELETE |
| 274 | +Q.ajax("GET", url, data, headers) // Full control |
| 275 | + |
| 276 | +// Cookies |
| 277 | +Q.get_cookie("name") |
| 278 | + |
| 279 | +// Forms |
| 280 | +Q.trap_form(action, elem_id) // AJAX form submission |
| 281 | + |
| 282 | +// Components |
| 283 | +Q.handle_components() // Process <ajax-component> tags |
| 284 | +Q.flash({message, class}) // Show flash alert |
| 285 | + |
| 286 | +// Translation |
| 287 | +T("string") // Client-side i18n |
| 288 | +``` |
| 289 | + |
| 290 | +### Vue.js Pattern (for reactive UIs) |
| 291 | + |
| 292 | +```javascript |
| 293 | +// static/js/index.js |
| 294 | +var app = { |
| 295 | + data() { |
| 296 | + return { items: [], content: "" }; |
| 297 | + }, |
| 298 | + methods: { |
| 299 | + submit() { |
| 300 | + axios.post(create_url, { content: this.content }) |
| 301 | + .then(() => { this.content = ""; this.reload(); }); |
| 302 | + }, |
| 303 | + reload() { |
| 304 | + axios.get(data_url).then(r => { this.items = r.data.items; }); |
| 305 | + }, |
| 306 | + }, |
| 307 | + mounted() { |
| 308 | + this.reload(); |
| 309 | + }, |
| 310 | +}; |
| 311 | + |
| 312 | +Vue.createApp(app).mount("#app"); |
| 313 | +``` |
| 314 | + |
| 315 | +- Use Vue 3 with `createApp()`. |
| 316 | +- Use Axios for HTTP requests. |
| 317 | +- Pass URLs from controller via template variables (do NOT hardcode paths in JS). |
| 318 | +- Template uses `v-model`, `v-for`, `@click`, `v-if` etc. |
| 319 | + |
| 320 | +### Inline JS in Templates |
| 321 | + |
| 322 | +For simple interactivity without Vue: |
| 323 | + |
| 324 | +```html |
| 325 | +<script> |
| 326 | +function callback(elem) { |
| 327 | + Q.post(elem.getAttribute("data-url"), {}) |
| 328 | + .then(() => location.reload()); |
| 329 | +} |
| 330 | +</script> |
| 331 | +<button data-url="[[=URL('my_action')]]" onclick="callback(this)">Click</button> |
| 332 | +``` |
| 333 | + |
| 334 | +## CSS Conventions |
| 335 | + |
| 336 | +- Default framework: `no.css` (classless, styles semantic HTML automatically). |
| 337 | +- Alternative: Bulma (used in birdwatching app). |
| 338 | +- Grid classes: `.columns`, `.c25`, `.c33`, `.c50`, `.c66`, `.c75`. |
| 339 | +- Color classes: `.success`, `.warning`, `.error`, `.info`, `.black`, `.white`. |
| 340 | +- Use `class="padded"` for spacing. |
| 341 | + |
| 342 | +## Important Rules |
| 343 | + |
| 344 | +1. **Fixtures declare dependencies.** Every action must list what it needs via `@action.uses()`. No global middleware. |
| 345 | +2. **Template first.** When using a template fixture, it must be the first argument in `@action.uses("template.html", ...)`. |
| 346 | +3. **No hardcoded URLs.** Always use `URL()` to generate paths. Pass URLs to JavaScript via template variables. |
| 347 | +4. **Settings in settings.py.** Never hardcode configuration values in controllers or models. |
| 348 | +5. **Shared state in common.py.** All fixtures (`db`, `session`, `auth`, `T`, `cache`, `flash`) are initialized in `common.py` and imported elsewhere. |
| 349 | +6. **`db.commit()` after table definitions.** Required in `models.py` after `db.define_table()` calls. |
| 350 | +7. **Thread safety.** Table definitions are NOT thread-safe. Only field attributes (readable, writable, requires, default) can be modified per-request. |
| 351 | +8. **DAL transactions are automatic.** Commit on success, rollback on error. No manual commit needed in actions. |
| 352 | +9. **YATL delimiters are `[[...]]`**, not `{{...}}`. This avoids conflicts with Vue.js. |
| 353 | +10. **Use `auth.signature`** on tables that need ownership tracking. |
| 354 | +11. **Static files** go in `static/` and are served at `/{app}/static/...`. |
| 355 | +12. **Translations** go in `translations/` as JSON files keyed by language code. |
| 356 | + |
| 357 | +## Testing |
| 358 | + |
| 359 | +Run tests with: |
| 360 | +```bash |
| 361 | +python -m pytest tests/ |
| 362 | +``` |
| 363 | + |
| 364 | +## Building & Running |
| 365 | + |
| 366 | +```bash |
| 367 | +# Install |
| 368 | +pip install py4web |
| 369 | + |
| 370 | +# Setup (first time) |
| 371 | +py4web setup apps |
| 372 | + |
| 373 | +# Run development server |
| 374 | +py4web run apps |
| 375 | + |
| 376 | +# Run on specific port |
| 377 | +py4web run --port 8000 apps |
| 378 | +``` |
0 commit comments