Pytincture + wapyt
Overview
wapyt declares widgets in Python and renders them as plain DOM through its
bundled JS, inside Pyodide. Every widget follows one path: a *Config
dataclass → .to_dict() (camelCase) → js.wapyt.<Widget>.new(root, payload);
events come back through .on_* handlers. Targets pytincture 1.0.0rc5 and
wapyt 0.1.0. Service mode needs Python 3.13 or 3.14.
wapyt is not dhxpyt. The widget set, names and signatures differ (TabWidget
not Tabbar, DataTable not Grid, no menu bar or Chart), so do not carry
dhxpyt code across — look the API up in references/wapyt/.
Rules that apply to every task
- Implement
load_ui()only. Never define__init__to call it — theLoadUICallermetaclass already callsload_ui()after construction, so doing both builds the UI twice. - Declare the entrypoint. pytincture's MainWindow detection only knows
dhxpyt's class. Put
APP_ENTRYPOINT = "ClassName"at module level, or name the class after the module file. Otherwise the page answers HTTP 422. - Ship the browser wheel. wapyt is not on PyPI and not in pytincture's
wheel locks. Build it into the modules folder with wapyt's
scripts/dev_wheel.sh <modules_path>and re-run it after any wapyt change. - Mount widgets with the
Layout.add_*helpers into existing cell ids. Inside a tab, a modal or any other element, construct the widget withcontainer=(Form(cfg, container=modal.body),DataTable(cfg, container=tabs.get_cell("files"))). - BFF calls are
await X().name_async(...), scheduled from sync handlers withasyncio.ensure_futureand wrapped so exceptions are logged. Neverasyncio.run— it breaks Pyodide's event loop for the session.
Task 1: Service-mode app
- Write the browser entrypoint: a
MainWindowsubclass that implementsload_ui(), plusAPP_ENTRYPOINT. - Add
widget.pywith literal__widgetset__ = "wapyt"and__version__ = "0.1.0", andimport widgetfrom the entrypoint. - Build the wheel into the folder:
.../wa_pytincture_widgetset/scripts/dev_wheel.sh . - Add BFF classes with
@backend_for_frontend. Keep state in storage, never in BFF module globals — the module is re-executed for every call. - Create the ASGI app with
create_app(PytinctureConfig(...))inservice.py. If any export carries@bff_policy, register the hook by dotted path:environment={"BFF_POLICY_HOOK_PATH": "service.policy_hook"}.set_bff_policy_hook()does not reach acreate_app()app. - Run
python -m uvicorn service:app --port 8070and open/{module filename}.
Start from assets/examples/wapyt_app/.
Read references/pytincture.md for the BFF
contract, policy hooks, wheel resolution and configuration.
Task 2: wapyt UI
- Give the
MainWindowsubclass a class-levellayout_config = LayoutConfig(rows=[...])ofCellConfigs. Without one you get the defaultmainwindow_header+mainwindowcells. - Mount widgets into those cells with
add_toolbar(header row),add_tree,add_datatable,add_form,add_tabwidget, … or put HTML in one withattach_html. Build toolbars withToolbar, not HTML strings. - Wire events with
.on_*; most handlers receive one dict payload. - Load data in an async method scheduled from
load_ui(), then push it in withset_rows/set_items/set_values.
Read references/wapyt.md for composition, sizing,
events, icons, theming and escaping rules, and
references/wapyt/index.md for the module index
and add_* helpers. Load a single module page such as
references/wapyt/datatable.md when you need
exact config fields — do not load them all.
Known gotchas
| Symptom | Cause and fix |
|---|---|
| HTTP 422 opening the app | No entrypoint found. Add APP_ENTRYPOINT = "ClassName". |
| Page loads but no widgets / widgetset 404 | No wapyt wheel in modules_path, or a stale one. Re-run dev_wheel.sh, restart. |
| Widgetset discovery finds nothing with from wapyt import ... | wapyt installed editable server-side. Add widget.py + import widget, or install non-editable. |
| UI renders twice | __init__ calls load_ui() and so does the metaclass. Delete the __init__. |
| Service refuses to start, mentions @bff_policy | Hook registered with set_bff_policy_hook() under create_app(). Use BFF_POLICY_HOOK_PATH. |
| Data written by a BFF call is gone on the next call | State kept in a BFF module global; the module re-executes per call. Use storage. |
| CellConfig(width=...) ignored / pane squeezed | Use "100%" for fill-the-rest, "auto" for content size, "220px" for fixed. |
| Modal buttons clipped below the fold | ModalConfig(height=...) is honest; body content scrolls inside it. Make it taller. |
| Hidden modals pile up in the DOM | hide() keeps the overlay. Use ModalConfig(dispose_on_close=True) and close() for per-use dialogs. |
| Form vanished after set_content | set_content replaces the body. Mount into modal.body instead. |
| A body { font-family } rule doesn't change the UI | Layouts and modals read --wapyt-font-family. Override that on :root. |
| Button text overlapping / icons show as words | A ligature icon font. Use MDI classes (mdi-pencil); never add a Material Icons/Symbols <link>. |
| if el is None: never fires on a DOM lookup | Your own JS calls (js.document.getElementById) return JS null as JsNull, not None. Test truthiness. wapyt payloads and getters already give None. |
| Exception in an async handler vanishes | ensure_future swallows it. Wrap the coroutine and js.console.error(traceback.format_exc()). |
| Chat history lost on reload | ChatConfig(storage_key=...) unset; without it the prefix is random per instance. |
| Stream renders an empty message | Backend sent an in-band {"error": ...} chunk. Use Chat.consume_stream, or check Chat.extract_stream_error. |
| Drawing a progress bar from divs and widths | ProgressBar (live: set_value(v, max, text=), set_state) or progress_html(...) for string-built markup. |
| Building a right-click menu from HTML | Use ContextMenu: attach(selector) plus data-context on rows, or show_at(x, y, hide=[...], disable=[...]) per opening. |
| Reaching for window.confirm() or a hand-made toast | Use wapyt.message: toast(...), and await message.confirm(...) / alert / prompt from an async handler. |
| File picker raises SecurityError | A filetransfer.pick_* call ran after an await. Call it first in the click handler. |
| Mic button does nothing | pytincture sends Permissions-Policy: microphone=(). Set PYTINCTURE_ALLOW_MICROPHONE=1 and serve a secure context. |
Resources
references/
pytincture.md— service mode, wheel resolution, BFF contract, policy hookswapyt.md— composition, sizing, events, icons, theming, escaping, per-widget noteswapyt/index.md— module index and theadd_*helper tablewapyt/<module>.md— generated per-module config fields and widget methodsexamples.md— what the bundled example demonstrates
assets/
examples/wapyt_app/— service-mode app: Tree + DataTable + Form in a modal, fed by a BFF