/\/\/\/\/\/\/\/\
< g r i d s a w > the Ripsaw
\/\/\/\/\/\/\/\/ data cut into clean grids, forms & widgets
A pluggable multi-engine renderer + data-bound, web2py-style components (SmartGrid, CRUD forms) — loosely coupled, reusable in any Python framework.
gridsaw is the rendering/component counterpart to
sqladal (the data layer):
- Render with upytl, YATL (web2py), or Jinja2 — each an optional extra. Use one engine, or mix them.
- Cross-engine embedding: render an upytl component (the grid/form)
inside a Jinja2 or YATL page via a
component()bridge (and pure-Jinja / pure-YATL works too). - Backend-agnostic data: the SAME SmartGrid/Form work over classic pydal,
voodoodal, sqladal, and SQLAlchemy ORM — sync or async — via a
DataSourceadapter (gridsaw.data). ORM validation is auto-derived from the column and can reuse pydal validators viacolumn.info['requires']. - HTMX-first interactivity:
htmx=Truemakes grids/forms emithx-*and the route returns a partial fragment onHX-Request; live updates via SSE/WebSocket (gridsaw.htmx).
from gridsaw.components import SmartGrid, Form
SmartGrid(db(db.person)) # pydal / voodoodal / sqladal (sync)
await SmartGrid(adb(adb.person)).aview_model() # sqladal AsyncDAL (async)
SmartGrid(Person, session=session) # SQLAlchemy ORM (sync Session)
SmartGrid(Person, session=async_session) # SQLAlchemy ORM (async AsyncSession)from gridsaw.htmx import is_htmx
grid = SmartGrid(db(db.person), base_url="/people", htmx=True, target="people-grid")
@app.get("/people") # sort/search/paginate swap the grid via hx-get
def people():
frag = grid.render(request_vars=dict(request.query))
return frag if is_htmx(request.environ) else PAGE.format(body=frag)- Pluggable styles (py4web
FormStyle/GridClassStyle):style="bulma"(default, responsive),"bootstrap5","tailwind","plain", aStyleinstance, or a dict of per-slot overrides. Every element also keeps a stablegridsaw-*hook so custom CSS always works. (theme=is a back-compat alias.) - Bundled field widgets (web2py
SQLFORM.widgets): string, text, password, integer/double/decimal, boolean (checkbox), date/time/datetime, select, radio, checkboxes, email/url/number/color/range, file upload, readonly, and a server-backed autocomplete (AutocompleteWidget(url)+autocomplete_options) — chosen by field type. Custom widgets are a one-liner:Form(..., widgets= {"color": lambda field, value, ctx: '<input type=color name=%s>' % ctx.name}). - Per-field form options:
labels=,comments=(help text),placeholders=,readonly=[...]. - Grid
Columns:Column(name, label=, represent=lambda v,row: ..., html=True, sortable=, css=, virtual=True)for formatters, badges/links, and computed columns; conditional row actions ({"show": lambda row: ...}),truncate=, andeditable=/deletable=/details=flags. - FK / relationship widgets: a
reference(pydal) orForeignKey(ORM) column renders as a<select>(options from the target table, current value pre-selected) and shows the referenced label in grids. - File uploads: an
uploadfield renders a file input and the form switches tomultipart/form-data;gridsaw.uploads.save_upload()stores it andmerge_files()folds the framework's uploaded files into the values dict. - CSRF:
gridsaw.security(new_token/verify, or statelesssigned_token/verify_signed);Form(csrf=token)embeds the hidden field.
The same grids/forms drive three engines — pick per component:
- HTMX (
htmx=True): sort/search/paginate issuehx-getand swap the grid; formshx-postand swap themselves. Fragment onHX-Request, page otherwise. - Turbo (
turbo=True): the grid is a<turbo-frame>(plain links navigate the frame); form submits return a<turbo-stream>. Helpers ingridsaw.turbo(turbo_frame,turbo_stream,is_turbo,turbo_response_headers). - Live (
gridsaw.live, Meld / Phoenix-LiveView style): aLiveComponentholds server-side state with@actionmethods; the bundled client opens a WebSocket, sendsdata-action/data-modelevents, and morphs the returned HTML in place (Idiomorph) so inputs keep focus.SmartGrid(live=True)/Form(live=True)emit thedata-*controls. Built on top:LiveGrid— live search + sort + pagination, plus in-row inline edit (inline_edit=True: click edit → the row's cells become inputs → save).LiveForm— inline create/edit/save with server validation and "Saved ✓" in place;live_validate=Truevalidates as you type (no write).- Cross-component pub/sub + multi-user broadcast — a component
publishes=("topic",); otherssubscribes=("topic",)re-render automatically, across all connected clients (aHubtracks every live connection).LivePresence/online_count()show who's online. - The bundled client adds optimistic loading state (
aria-busywhile an action is in flight) and auto-reconnect out of the box.
from gridsaw.live import LiveComponent, action, register, serve
@register
class Counter(LiveComponent):
def initial_state(self): return {"n": 0}
@action
def inc(self): self.state["n"] += 1
def render(self): return 'Count: %d <button data-action="inc">+</button>' % self.state["n"]
@app.websocket("/live")
async def live(ws): await serve(ws) # + serve gridsaw.live.client_js() at /gridsaw-live.jspixi run python examples/htmx_demo/serve.py # raw ombott, sqladal + ORM :8010
pixi run python examples/websaw_htmx/serve.py # websaw + FK + style/engine switch :8020
pixi run python examples/live_demo/serve.py # live counter + live-search grid :8030from sqladal import DAL, Field
from gridsaw import ComponentRegistry
from gridsaw.render import UpytlRenderer, Jinja2Renderer, YatlRenderer
from gridsaw.components import SmartGrid
db = DAL("sqlite://app.db", folder=".")
db.define_table("person", Field("name"), Field("age", "integer"))
reg = ComponentRegistry()
reg.add_engine(UpytlRenderer())
jinja = reg.add_engine(Jinja2Renderer())
yatl = reg.add_engine(YatlRenderer())
# register an upytl grid, fed by a sqladal query
reg.register("people", SmartGrid(db(db.person), columns=["name", "age"], base_url="/people"))
# ...call it from a Jinja2 page
jinja.render("<h1>Team</h1>{{ component('people', request_vars=rv) }}", {"rv": {"order": "name"}})
# ...or from a YATL page
yatl.render("<h1>Team</h1>{{=component('people', request_vars=rv)}}", {"rv": {"order": "name"}})The bridge renders the upytl grid and returns it marked safe for the calling
engine (markupsafe.Markup for Jinja2, XML() for YATL).
SmartGrid(db(db.person), columns=["name", "age"], per_page=20,
searchable=["name"], base_url="/people")Reads request_vars for search (q), sort (order/dir, sortable
header links), and pagination (page); renders an HTML table with a pager.
Headers come from Field.label; cells from Field.represent/represent_value.
Source may be a Set (db(query)), a Table, or Rows.
form = Form(db.person, action="/people/new")
form.render() # create form (inputs typed from Field metadata)
form.create(request_vars) # -> validate_and_insert (id / errors / success)
form = Form(db.person, record_id=7, action="/people/7")
form.render() # edit form, prefilled
form.update(7, request_vars) # -> Table.validate_and_update
form.delete(7) # -> db(table._id == 7).delete()Inputs are built from Field type/label/options; validation errors render inline.
The grid and form follow the data source's real primary key — a single id, a
composite key, a natural key, or none at all (legacy / warehouse
tables). Composite keys travel through row-action URLs and form fields as one
URL-safe token, so /edit/{id} templates keep working:
SmartGrid(db(db.membership), base_url="/m", editable=True) # composite pk -> tokenised links
Form(db.membership, record_id=token).update(token, vars) # token decoded to the full keyA table with no primary key renders as a read-only grid (no edit/delete —
there's no addressable row), while inserts via a Form still work. The
DataSource exposes pk_names(), has_pk(), pk_of(row), and
encode_pk/decode_pk for both the pydal-family and SQLAlchemy-ORM backends.
| extra | enables |
|---|---|
gridsaw[upytl] |
upytl rendering + the component kit (grid/form are upytl-based) |
gridsaw[jinja2] |
Jinja2 rendering |
gridsaw[yatl] |
YATL (web2py) rendering |
The core package imports with no engine installed; adapters load lazily.
gridsaw.integration.websaw.RenderFixture wraps any renderer as a websaw
Fixture (drop into app.use(...)), injecting the component() bridge — no
changes to websaw core required. The same renderers work in Flask, pure-WSGI,
CLI, etc.
Early development. pixi run test — 21 tests passing: render adapters,
cross-engine embedding, SmartGrid (search/sort/paginate/represent) and CRUD Form
on sqladal, and the websaw RenderFixture + a live HTTP request through ombott.
BSD-3-Clause.
Part of the websaw-ng platform · forging your dreams · install: pixi add gridsaw from the websaw-ng conda channel.