Skip to content

Commit a2b083a

Browse files
committed
version 1.20260519.1
1. apps/_scaffold/AGENTS.md (new file) — Documents the 11 most common py4web paper-cuts in one place. Bundled into py4web new_app so every new app starts with it loaded. Scaffold zip rebuilt to include it. 2. pluralize/pluralize/__init__.py (already committed by you as 2fb799e) — Translator now accepts plain-string values both at load() time (auto-normalize to {"0": value} with warning) and at lookup time. Three new regression tests in tests/test_simple.py. 3. pydal/pydal/objects.py (Field.__init__) — Detects default=dict / default=list / default=set / default=tuple, warns, and rewrites to default=lambda: cls(). No more "TypeError: <class 'dict'> is not JSON serializable" on insert. 4. pydal/pydal/base.py (DAL.__init__) — For SQLite, when the DB file is missing but .table markers for this DB's URI hash exist on disk, raise a clear RuntimeError naming both the fix paths: rm <folder>/<hash>*.table or fake_migrate_all=True. Scoped by URI hash so stray markers from other DBs in the same folder don't false-positive (verified with the existing pydal test suite — 170 tests still pass, including ones that pile multiple DBs into the same dir). 5. py4web/py4web/core.py — Two changes: - Reloader.register_route: detects same (rule, method) bound to two different functions and raises a clear RuntimeError before ombott raises its noisy RouteMethodError, with a hint about the @action('/') + @action('index') aliasing. - run CLI: new --dev flag — sets --mode=development, --debug, --logging_level=DEBUG, prints a yellow "do not use in production" warning. Honors explicit flags (only flips defaults).
1 parent 94558d0 commit a2b083a

4 files changed

Lines changed: 238 additions & 6 deletions

File tree

apps/_scaffold/AGENTS.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# py4web — Notes for AI Agents and New Contributors
2+
3+
This file lists the framework gotchas that bite people (and AI assistants) most
4+
often. If you're an AI agent working in a py4web app, load this file into your
5+
context before you start editing. If you're a human, skim it once — every line
6+
here represents at least one wasted afternoon.
7+
8+
See also: `CLAUDE.md` in the repo root (if present) for higher-level conventions.
9+
10+
---
11+
12+
## Quick rules
13+
14+
1. Templates use `[[ ... ]]`, **not** `{{ ... }}`. (Avoids Vue/Angular conflict.)
15+
2. Every action declares its fixtures: `@action.uses(template_first, db, session, auth.user, ...)`.
16+
The template fixture (a string) must be the **first** argument when present.
17+
3. Never hardcode URLs. Use `URL("action", arg, vars=dict(x=1))`.
18+
4. After defining tables in `models.py`, call `db.commit()`.
19+
5. Field attributes that are thread-safe to mutate per-request: `readable`, `writable`,
20+
`requires`, `default`, `label`, `widget`, `represent`, `filter_in`, `filter_out`,
21+
`update`. **Nothing else.** Never `db.define_table()` inside an action.
22+
23+
---
24+
25+
## Gotchas (silent or surprising)
26+
27+
### 1. `Field("blob", "json", default=dict)` can leak the `dict` *class*
28+
29+
Symptom: `TypeError: <class 'dict'> is not JSON serializable` on insert.
30+
31+
Fix: use a lambda.
32+
33+
```python
34+
Field("payload", "json", default=lambda: {})
35+
Field("tags", "json", default=lambda: [])
36+
```
37+
38+
(py4web ≥ 1.20260520 warns and auto-rewrites `default=dict` / `default=list` /
39+
`default=set` at table-definition time, but the lambda form is still the
40+
documented best practice.)
41+
42+
### 2. Pluralize translation values must be dicts, not strings
43+
44+
In `translations/it.json`, this is silently ignored:
45+
46+
```json
47+
{ "Hello": "Ciao" }
48+
```
49+
50+
You'll see "Hello" in the browser and waste an hour. The correct shape is:
51+
52+
```json
53+
{ "Hello": {"0": "Ciao"} }
54+
{ "thing": {"0": "cosa", "1": "cose"} }
55+
```
56+
57+
(py4web ≥ 1.20260520 auto-normalizes string values to `{"0": value}` and logs a
58+
warning at load time, so plain-string entries now work. Prefer the dict form
59+
when writing new files.)
60+
61+
### 3. `request.query` is **single-value** in ombott
62+
63+
For a URL like `?tag=a&tag=b`, `request.query.get("tag")` returns only `"b"`.
64+
To get repeated params:
65+
66+
```python
67+
from urllib.parse import parse_qs
68+
parse_qs(request.query_string)["tag"] # ["a", "b"]
69+
```
70+
71+
### 4. Built-in `/auth/*` routes do **not** go through your `@action.uses` chain
72+
73+
The auth fixtures register their own routes. If you want i18n on the auth pages
74+
(register, login, etc.), import `T` at the top of `layout.html`:
75+
76+
```html
77+
[[from py4web import URL]]
78+
[[from .common import T]]
79+
```
80+
81+
…and use `[[=T('Register')]]` in the auth templates.
82+
83+
### 5. `validate_and_insert` / `validate_and_update` **return** errors, they don't raise
84+
85+
```python
86+
res = db.thing.validate_and_insert(**request.json)
87+
if res.get("errors"):
88+
abort(400)
89+
record_id = res["id"]
90+
```
91+
92+
Same for update: check `res.get("errors")` before continuing.
93+
94+
### 6. `auth.sender` is a `Mailer` — call it with keyword args
95+
96+
The `Mailer.send` signature is `send(to, subject=..., body=..., html=...)`,
97+
**not** `send(name, user, **kwargs)`. If you write a custom dev sender to dump
98+
mail to stdout, mirror that signature:
99+
100+
```python
101+
class StdoutMailer:
102+
def send(self, to, subject="", body="", html=None, **_):
103+
print(f"--- mail to {to}: {subject} ---\n{body}\n---")
104+
105+
if MODE == "development":
106+
auth.sender = StdoutMailer()
107+
```
108+
109+
### 7. Stale `.table` files vs. missing `storage.db` → "no such table"
110+
111+
If you delete `databases/storage.db` but leave the `*.table` migration markers
112+
behind, pydal thinks the schema is already applied and refuses to recreate it.
113+
114+
Fix: `rm databases/*.table` and let py4web re-migrate, **or** start once with
115+
`DAL(..., fake_migrate_all=True)` then revert.
116+
117+
(py4web ≥ 1.20260520 raises a clearer error at startup when this state is
118+
detected for SQLite.)
119+
120+
### 8. Duplicate `@action` paths silently win-last
121+
122+
```python
123+
@action("/")
124+
@action.uses(...)
125+
def home(): ...
126+
127+
@action("index") # also resolves to "/"
128+
@action.uses(...)
129+
def home2(): ...
130+
```
131+
132+
The framework auto-aliases `/index``/`, so the second decorator overwrites
133+
the first and you get whichever loaded last. (py4web ≥ 1.20260520 logs a
134+
warning naming both registrations.)
135+
136+
### 9. Action return types
137+
138+
Return value handling:
139+
140+
- `dict` → rendered by the template fixture if one is declared; otherwise JSON.
141+
- `str` → returned as-is.
142+
- `None` → empty body.
143+
- A `yatl` tag (e.g. `DIV(...)`) → coerced to string.
144+
- A `redirect()` or `abort()` is raised, not returned.
145+
146+
Anything else raises `RuntimeError: Cannot return type <name>`.
147+
148+
### 10. Settings come from `settings.py`, fixtures from `common.py`
149+
150+
Don't hardcode config in controllers. Don't initialize fixtures (db, auth,
151+
session) anywhere except `common.py`. Other modules import them.
152+
153+
### 11. `--dev` flag flips a bunch of knobs for you
154+
155+
`py4web run apps --dev` is equivalent to `--mode development` and additionally
156+
sets verbose logging. The scaffold's `settings.py` reads `PY4WEB_MODE`, so
157+
`--dev` also relaxes `PASSWORD_ENTROPY` and `VERIFY_EMAIL`. Use it in dev,
158+
don't use it in prod.
159+
160+
---
161+
162+
## When in doubt
163+
164+
- Read `apps/showcase/` for end-to-end examples (auth, forms, grids, uploads,
165+
scheduler, i18n, OAuth, HTMX).
166+
- Read `apps/_dashboard/` for an example of a complex multi-controller app
167+
with restricted access.
168+
- `py4web shell apps` gives you an interactive REPL with `db`, `auth`, etc.
169+
pre-loaded for the app you select.
170+
- Tickets (server errors) are at `/_dashboard/tickets`.
171+
172+
---
173+
174+
## File you're reading
175+
176+
This file is part of the `_scaffold` app. When you create a new app with
177+
`py4web new_app apps myapp`, this file is copied along. Keep it — every AI
178+
assistant that touches the new app will start from these notes.

py4web/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__author__ = "Massimo Di Pierro <massimo.dipierro@gmail.com>"
44
__license__ = "BSD-3-Clause"
5-
__version__ = "1.20260518.2"
5+
__version__ = "1.20260519.1"
66

77

88
def _maybe_gevent():

py4web/core.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,14 +1724,42 @@ def register_route(app_name, rule, kwargs, func):
17241724
rule = url_prefix
17251725
else:
17261726
rule = url_prefix + rule
1727-
dec_func = action.catch_errors(app_name, func)
17281727
if "method" not in kwargs:
17291728
kwargs["method"] = ["GET", "POST"]
1730-
bottle.route(rule, **kwargs)(dec_func)
1731-
filename = module2filename(func.__module__)
17321729
methods = kwargs.get("method")
17331730
if isinstance(methods, str):
17341731
methods = [methods]
1732+
1733+
# Look up any existing registration for (rule, method) and surface
1734+
# a clearer error before ombott raises a noisy RouteMethodError.
1735+
# The canonical foot-gun is @action("/") + @action("index"), since
1736+
# py4web auto-aliases /index to / for callers.
1737+
existing = {
1738+
(entry["rule"], entry["method"]): entry
1739+
for entry in Reloader.ROUTES[app_name]
1740+
}
1741+
for method in methods:
1742+
prior = existing.get((rule, method))
1743+
if prior is not None and prior["action"] != func.__name__:
1744+
raise RuntimeError(
1745+
"py4web: duplicate route registration for %s %s in app "
1746+
"%r — previously bound to %s() from %s, now %s() from %s. "
1747+
"Hint: @action('/') and @action('index') both resolve to "
1748+
"'/'; pick one, or give them distinct paths."
1749+
% (
1750+
method,
1751+
rule,
1752+
app_name,
1753+
prior["action"],
1754+
prior.get("filename", "<?>"),
1755+
func.__name__,
1756+
module2filename(func.__module__),
1757+
)
1758+
)
1759+
1760+
dec_func = action.catch_errors(app_name, func)
1761+
bottle.route(rule, **kwargs)(dec_func)
1762+
filename = module2filename(func.__module__)
17351763
for method in methods:
17361764
Reloader.ROUTES[app_name].append(
17371765
{
@@ -2448,8 +2476,34 @@ def new_app(apps_folder, app_name, yes, scaffold_zip):
24482476
help="default or development",
24492477
show_default=True,
24502478
)
2479+
@click.option(
2480+
"--dev",
2481+
is_flag=True,
2482+
default=False,
2483+
help="Development preset: --mode=development, --debug, verbose logs, "
2484+
"lazy reload. Apps read PY4WEB_MODE=development from settings.py "
2485+
"to relax password rules and disable email verification.",
2486+
)
24512487
def run(**kwargs):
24522488
"""Run the applications on apps_folder"""
2489+
if kwargs.pop("dev", False):
2490+
# --dev is a convenience preset. Only flip a knob if the user did
2491+
# not pass an explicit value for it, so explicit flags win.
2492+
if kwargs.get("mode") == "default":
2493+
kwargs["mode"] = "development"
2494+
if not kwargs.get("debug"):
2495+
kwargs["debug"] = True
2496+
if kwargs.get("logging_level") == logging.INFO:
2497+
kwargs["logging_level"] = logging.DEBUG
2498+
if kwargs.get("watch") == "lazy":
2499+
# Lazy is already the default but keep it explicit so the
2500+
# banner reflects it.
2501+
kwargs["watch"] = "lazy"
2502+
click.secho(
2503+
"[--dev] mode=development, debug=on, verbose logs. "
2504+
"Do not use in production.",
2505+
fg="yellow",
2506+
)
24532507
install_args(kwargs)
24542508

24552509
from py4web import __version__ # pylint: disable=import-outside-toplevel

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ dependencies = [
2525
"pyjwt >= 2.12.0",
2626
"pycryptodome",
2727
"pygments >= 2.20.0",
28-
"pluralize >= 20250901.2",
28+
"pluralize >= 20260519.2",
2929
"rocket3 >= 20241225.1",
3030
"yatl >= 20260518.1",
31-
"pydal >= 20260518.1",
31+
"pydal >= 20260519.1",
3232
"watchgod >= 0.6",
3333
]
3434

0 commit comments

Comments
 (0)