wapyt 0.1.0__tar.gz → 0.2.0.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wapyt-0.2.0.dev1/MANIFEST.in +2 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/PKG-INFO +26 -8
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/README.md +21 -3
- wapyt-0.2.0.dev1/pyproject.toml +35 -0
- wapyt-0.2.0.dev1/tests/test_chart.py +63 -0
- wapyt-0.2.0.dev1/tests/test_chat_stream.py +236 -0
- wapyt-0.2.0.dev1/tests/test_contextmenu.py +64 -0
- wapyt-0.2.0.dev1/tests/test_datatable_classes.py +20 -0
- wapyt-0.2.0.dev1/tests/test_datatable_csv.py +23 -0
- wapyt-0.2.0.dev1/tests/test_datatable_editing.py +36 -0
- wapyt-0.2.0.dev1/tests/test_datatable_frozen.py +11 -0
- wapyt-0.2.0.dev1/tests/test_filetransfer_csrf.py +60 -0
- wapyt-0.2.0.dev1/tests/test_form_buttons.py +72 -0
- wapyt-0.2.0.dev1/tests/test_form_clear.py +8 -0
- wapyt-0.2.0.dev1/tests/test_form_fields.py +91 -0
- wapyt-0.2.0.dev1/tests/test_form_fieldsets.py +51 -0
- wapyt-0.2.0.dev1/tests/test_form_files.py +42 -0
- wapyt-0.2.0.dev1/tests/test_form_focus.py +45 -0
- wapyt-0.2.0.dev1/tests/test_form_options_icons.py +45 -0
- wapyt-0.2.0.dev1/tests/test_form_properties.py +72 -0
- wapyt-0.2.0.dev1/tests/test_form_range.py +46 -0
- wapyt-0.2.0.dev1/tests/test_form_validators.py +113 -0
- wapyt-0.2.0.dev1/tests/test_jsnull.py +76 -0
- wapyt-0.2.0.dev1/tests/test_kanban.py +40 -0
- wapyt-0.2.0.dev1/tests/test_layout_splitters.py +26 -0
- wapyt-0.2.0.dev1/tests/test_listbox.py +39 -0
- wapyt-0.2.0.dev1/tests/test_menubar.py +47 -0
- wapyt-0.2.0.dev1/tests/test_message.py +74 -0
- wapyt-0.2.0.dev1/tests/test_modal_events.py +8 -0
- wapyt-0.2.0.dev1/tests/test_new_widget_configs.py +389 -0
- wapyt-0.2.0.dev1/tests/test_pagination.py +49 -0
- wapyt-0.2.0.dev1/tests/test_popup.py +34 -0
- wapyt-0.2.0.dev1/tests/test_progressbar.py +66 -0
- wapyt-0.2.0.dev1/tests/test_release_metadata.py +33 -0
- wapyt-0.2.0.dev1/tests/test_scheduler.py +89 -0
- wapyt-0.2.0.dev1/tests/test_sidebar_groups.py +40 -0
- wapyt-0.2.0.dev1/tests/test_toolbar.py +83 -0
- wapyt-0.2.0.dev1/tests/test_toolbar_dropdowns.py +54 -0
- wapyt-0.2.0.dev1/tests/test_tree_check_move.py +25 -0
- wapyt-0.2.0.dev1/tests/test_window.py +23 -0
- wapyt-0.2.0.dev1/wapyt/__init__.py +150 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/_runtime.py +44 -2
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/cardpanel.js +41 -20
- wapyt-0.2.0.dev1/wapyt/assets/chart.js +627 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/chat.js +657 -53
- wapyt-0.2.0.dev1/wapyt/assets/contextmenu.js +526 -0
- wapyt-0.2.0.dev1/wapyt/assets/datatable.js +1322 -0
- wapyt-0.2.0.dev1/wapyt/assets/filetransfer.js +535 -0
- wapyt-0.2.0.dev1/wapyt/assets/form.js +2472 -0
- wapyt-0.2.0.dev1/wapyt/assets/icons.js +114 -0
- wapyt-0.2.0.dev1/wapyt/assets/kanban.js +399 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/layout.js +238 -18
- wapyt-0.2.0.dev1/wapyt/assets/listbox.js +841 -0
- wapyt-0.2.0.dev1/wapyt/assets/menubar.js +327 -0
- wapyt-0.2.0.dev1/wapyt/assets/message.js +271 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/modal.js +86 -11
- wapyt-0.2.0.dev1/wapyt/assets/pagination.js +336 -0
- wapyt-0.2.0.dev1/wapyt/assets/popup.js +235 -0
- wapyt-0.2.0.dev1/wapyt/assets/progressbar.js +144 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/resourceboard.js +22 -8
- wapyt-0.2.0.dev1/wapyt/assets/scheduler.js +1453 -0
- wapyt-0.2.0.dev1/wapyt/assets/sidebar.js +483 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/tabwidget.js +12 -2
- wapyt-0.2.0.dev1/wapyt/assets/terminal.js +690 -0
- wapyt-0.2.0.dev1/wapyt/assets/toolbar.js +538 -0
- wapyt-0.2.0.dev1/wapyt/assets/tooltip.js +180 -0
- wapyt-0.2.0.dev1/wapyt/assets/tree.js +984 -0
- wapyt-0.2.0.dev1/wapyt/assets/vendor-chartjs.LICENSE.md +9 -0
- wapyt-0.2.0.dev1/wapyt/assets/vendor-chartjs.min.js +13 -0
- wapyt-0.2.0.dev1/wapyt/assets/wapyt.css +4137 -0
- wapyt-0.2.0.dev1/wapyt/assets/window.js +538 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/cardpanel/cardpanel.py +2 -2
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/cardpanel/cardpanel_config.py +8 -3
- wapyt-0.2.0.dev1/wapyt/chart/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/chart/chart.py +152 -0
- wapyt-0.2.0.dev1/wapyt/chart/chart_config.py +196 -0
- wapyt-0.2.0.dev1/wapyt/chat/__init__.py +4 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/chat/chat.py +155 -27
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/chat/chat_config.py +24 -0
- wapyt-0.2.0.dev1/wapyt/contextmenu/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/contextmenu/contextmenu.py +161 -0
- wapyt-0.2.0.dev1/wapyt/contextmenu/contextmenu_config.py +110 -0
- wapyt-0.2.0.dev1/wapyt/datatable/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/datatable/datatable.py +318 -0
- wapyt-0.2.0.dev1/wapyt/datatable/datatable_config.py +217 -0
- wapyt-0.2.0.dev1/wapyt/filetransfer/__init__.py +39 -0
- wapyt-0.2.0.dev1/wapyt/filetransfer/filetransfer.py +318 -0
- wapyt-0.2.0.dev1/wapyt/form/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/form/form.py +447 -0
- wapyt-0.2.0.dev1/wapyt/form/form_config.py +547 -0
- wapyt-0.2.0.dev1/wapyt/kanban/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/kanban/kanban.py +188 -0
- wapyt-0.2.0.dev1/wapyt/kanban/kanban_config.py +120 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/layout/layout.py +86 -3
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/layout/layout_config.py +12 -0
- wapyt-0.2.0.dev1/wapyt/listbox/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/listbox/listbox.py +173 -0
- wapyt-0.2.0.dev1/wapyt/listbox/listbox_config.py +104 -0
- wapyt-0.2.0.dev1/wapyt/menubar/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/menubar/menubar.py +145 -0
- wapyt-0.2.0.dev1/wapyt/menubar/menubar_config.py +35 -0
- wapyt-0.2.0.dev1/wapyt/message/__init__.py +23 -0
- wapyt-0.2.0.dev1/wapyt/message/message.py +205 -0
- wapyt-0.2.0.dev1/wapyt/modal/modal.py +121 -0
- wapyt-0.2.0.dev1/wapyt/pagination/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/pagination/pagination.py +145 -0
- wapyt-0.2.0.dev1/wapyt/pagination/pagination_config.py +77 -0
- wapyt-0.2.0.dev1/wapyt/popup/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/popup/popup.py +128 -0
- wapyt-0.2.0.dev1/wapyt/popup/popup_config.py +86 -0
- wapyt-0.2.0.dev1/wapyt/progressbar/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/progressbar/progressbar.py +95 -0
- wapyt-0.2.0.dev1/wapyt/progressbar/progressbar_config.py +122 -0
- wapyt-0.2.0.dev1/wapyt/pytincture-assets.json +147 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/resourceboard/resourceboard.py +3 -3
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/resourceboard/resourceboard_config.py +3 -1
- wapyt-0.2.0.dev1/wapyt/scheduler/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/scheduler/scheduler.py +224 -0
- wapyt-0.2.0.dev1/wapyt/scheduler/scheduler_config.py +266 -0
- wapyt-0.2.0.dev1/wapyt/sidebar/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/sidebar/sidebar.py +131 -0
- wapyt-0.2.0.dev1/wapyt/sidebar/sidebar_config.py +119 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/tabwidget/tabwidget.py +3 -3
- wapyt-0.2.0.dev1/wapyt/terminal/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/terminal/terminal.py +181 -0
- wapyt-0.2.0.dev1/wapyt/terminal/terminal_config.py +106 -0
- wapyt-0.2.0.dev1/wapyt/toolbar/__init__.py +17 -0
- wapyt-0.2.0.dev1/wapyt/toolbar/toolbar.py +220 -0
- wapyt-0.2.0.dev1/wapyt/toolbar/toolbar_config.py +168 -0
- wapyt-0.2.0.dev1/wapyt/tree/__init__.py +4 -0
- wapyt-0.2.0.dev1/wapyt/tree/tree.py +238 -0
- wapyt-0.2.0.dev1/wapyt/tree/tree_config.py +183 -0
- wapyt-0.2.0.dev1/wapyt/window/__init__.py +3 -0
- wapyt-0.2.0.dev1/wapyt/window/window.py +219 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt.egg-info/PKG-INFO +26 -8
- wapyt-0.2.0.dev1/wapyt.egg-info/SOURCES.txt +144 -0
- wapyt-0.2.0.dev1/wapyt.egg-info/top_level.txt +1 -0
- wapyt-0.1.0/MANIFEST.in +0 -1
- wapyt-0.1.0/pyproject.toml +0 -25
- wapyt-0.1.0/tests/cardpanel_demo.py +0 -119
- wapyt-0.1.0/tests/chat_demo.py +0 -775
- wapyt-0.1.0/tests/layout_demo.py +0 -103
- wapyt-0.1.0/tests/multiaiproxy.py +0 -285
- wapyt-0.1.0/tests/tabs_demo.py +0 -30
- wapyt-0.1.0/wapyt/__init__.py +0 -44
- wapyt-0.1.0/wapyt/assets/sidebar.js +0 -281
- wapyt-0.1.0/wapyt/assets/wapyt.css +0 -225
- wapyt-0.1.0/wapyt/chat/__init__.py +0 -4
- wapyt-0.1.0/wapyt/modal/modal.py +0 -65
- wapyt-0.1.0/wapyt/sidebar/__init__.py +0 -4
- wapyt-0.1.0/wapyt/sidebar/sidebar.py +0 -81
- wapyt-0.1.0/wapyt/sidebar/sidebar_config.py +0 -65
- wapyt-0.1.0/wapyt.egg-info/SOURCES.txt +0 -44
- wapyt-0.1.0/wapyt.egg-info/requires.txt +0 -2
- wapyt-0.1.0/wapyt.egg-info/top_level.txt +0 -4
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/setup.cfg +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/__init__.py +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/assets/fonts/.keep +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/cardpanel/__init__.py +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/layout/__init__.py +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/modal/__init__.py +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/resourceboard/__init__.py +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt/tabwidget/__init__.py +0 -0
- {wapyt-0.1.0 → wapyt-0.2.0.dev1}/wapyt.egg-info/dependency_links.txt +0 -0
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wapyt
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: DHTMLX-free
|
|
3
|
+
Version: 0.2.0.dev1
|
|
4
|
+
Summary: DHTMLX-free pyTincture widgetset: layouts, forms, tables, trees, chat, terminal, menus, Kanban and more, declared in Python and rendered in the browser.
|
|
5
5
|
License: MIT
|
|
6
|
-
Project-URL: homepage, https://github.com/
|
|
6
|
+
Project-URL: homepage, https://github.com/WAwesome-AI/wa_pytincture_widgetset
|
|
7
|
+
Project-URL: documentation, https://github.com/WAwesome-AI/wa_pytincture_widgetset/wiki
|
|
8
|
+
Project-URL: changelog, https://github.com/WAwesome-AI/wa_pytincture_widgetset/wiki/Widget-Catalog#changes
|
|
7
9
|
Keywords: pytincture,widgetset,pyodide,wapyt
|
|
8
10
|
Requires-Python: >=3.13
|
|
9
11
|
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: pyodide-py>=0.28.0
|
|
11
|
-
Requires-Dist: itsdangerous>=2.2.0
|
|
12
12
|
|
|
13
13
|
# wA PyTincture Widgetset (`wapyt`)
|
|
14
14
|
|
|
15
|
-
`wapyt` is a lightweight widgetset for [pyTincture](https://github.com/
|
|
15
|
+
`wapyt` is a lightweight widgetset for [pyTincture](https://github.com/pytincture/pytincture) that avoids the commercial DHTMLX dependency used by the legacy package. It focuses on a DOM-driven layout engine and a set of first-class widgets that can be composed entirely from Python when running inside Pyodide.
|
|
16
16
|
|
|
17
17
|
## Highlights
|
|
18
18
|
- **Theme-aware layout**: Flexbox-based layout manager that mirrors the familiar `LayoutConfig`/`CellConfig` API, supports nested rows/columns, and exposes helper methods such as `attach_html`, `collapse`, `toggle`, `hide`, etc.
|
|
@@ -26,9 +26,18 @@ Requires-Dist: itsdangerous>=2.2.0
|
|
|
26
26
|
- `Chat`: Streaming chat surface with agent metadata, artifact events, and helpers for incremental responses.
|
|
27
27
|
- `CardPanel`: Searchable, template-driven card grid with add/view/action callbacks and custom renderers.
|
|
28
28
|
- `TabWidget`: Lightweight tab host that supports badges, closable tabs, and attaching HTML or PyTincture components per tab.
|
|
29
|
-
- `Sidebar`: Collapsible navigation rail with badges,
|
|
29
|
+
- `Sidebar`: Collapsible navigation rail with icons, badges, nested groups (a flyout when collapsed), headings, separators and a spacer for bottom-pinned items; keyboard navigable.
|
|
30
30
|
- `ModalWindow`: Simple modal shell that can host arbitrary Layout instances or HTML snippets.
|
|
31
31
|
- `ResourceBoard`: Master/detail resource explorer with selectable rows, action hooks, and optional add button wiring.
|
|
32
|
+
- `message`: Toasts plus awaitable `alert` / `confirm` / `prompt` dialogs, styled and themed, in place of `window.confirm()`.
|
|
33
|
+
- `Toolbar`: Buttons, toggles, one-of-several groups, text, separators and spacers, dropping to icons when space runs out.
|
|
34
|
+
- `ContextMenu`: Right-click menus with icons, shortcut hints, danger and disabled items, checkable and radio items, separators and submenus; keyboard accessible.
|
|
35
|
+
- `MenuBar`: A File / Edit / View application menu whose titles open ContextMenu dropdowns; full menubar keyboard model.
|
|
36
|
+
- `Kanban`: A board of Listbox columns with counts, WIP limits (optionally strict), add-card buttons, collapsible and reorderable columns, a board-wide filter and selection.
|
|
37
|
+
- `Listbox`: Rich list items (fields or an escaped template) that can host live widgets, with actions, selection, a filter, and pointer and keyboard drag-and-drop within and between lists; the base for Kanban.
|
|
38
|
+
- `Popup` and tooltips: An anchored popover for pickers, help and small forms; styled, keyboard-reachable tooltips that replace native `title` text across the widgets.
|
|
39
|
+
- `ProgressBar` / `progress_html`: Determinate or indeterminate bars with active/done/error/paused states, as a live widget or static markup.
|
|
40
|
+
- `Pagination`: First/previous/next/last, a page box or numbered buttons, a page-size selector and a "1–50 of 1,234" summary; copes with totals the server could not count.
|
|
32
41
|
|
|
33
42
|
## Runtime Architecture
|
|
34
43
|
- Layouts and widgets are declared in Python but render as DOM nodes through the JavaScript bundles under `wapyt/assets/`. Each helper (e.g., `Layout.add_chat`) forwards config dictionaries directly to the matching JS constructor.
|
|
@@ -72,7 +81,16 @@ wapyt/
|
|
|
72
81
|
|
|
73
82
|
- The JavaScript bundles define the `window.wapyt` namespace and are evaluated automatically by PyTincture’s loader (see `pytincture/frontend/pytincture.js`).
|
|
74
83
|
- Layout styles live in `assets/wapyt.css`; adjust tokens there to integrate with your design system.
|
|
75
|
-
-
|
|
84
|
+
- Message content is rendered by the escaping markdown renderer in `assets/chat.js`. If a page also loads
|
|
85
|
+
[`marked`](https://marked.js.org/), the widget uses it **only** when `DOMPurify` is present as well —
|
|
86
|
+
`marked` passes raw HTML through, so without a sanitizer the widget falls back to its own renderer.
|
|
87
|
+
- Artifact previews run in `<iframe sandbox="allow-scripts">` with no `allow-same-origin`: artifact code is
|
|
88
|
+
treated as untrusted and cannot reach the host page.
|
|
89
|
+
- `tests/` holds two different things: the `*_demo.py` files are small, framework-free PyTincture apps you can
|
|
90
|
+
copy/paste into your own projects, and `test_*.py` are CPython unit tests for the pure-Python half of the
|
|
91
|
+
widgetset. Run the latter with `uv run --group dev pytest tests/` (or `pytest tests/` in an activated venv);
|
|
92
|
+
they need no browser and no demo dependencies, and `tests/conftest.py` puts the repo root on `sys.path` so
|
|
93
|
+
they work from a fresh clone.
|
|
76
94
|
|
|
77
95
|
## License
|
|
78
96
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# wA PyTincture Widgetset (`wapyt`)
|
|
2
2
|
|
|
3
|
-
`wapyt` is a lightweight widgetset for [pyTincture](https://github.com/
|
|
3
|
+
`wapyt` is a lightweight widgetset for [pyTincture](https://github.com/pytincture/pytincture) that avoids the commercial DHTMLX dependency used by the legacy package. It focuses on a DOM-driven layout engine and a set of first-class widgets that can be composed entirely from Python when running inside Pyodide.
|
|
4
4
|
|
|
5
5
|
## Highlights
|
|
6
6
|
- **Theme-aware layout**: Flexbox-based layout manager that mirrors the familiar `LayoutConfig`/`CellConfig` API, supports nested rows/columns, and exposes helper methods such as `attach_html`, `collapse`, `toggle`, `hide`, etc.
|
|
@@ -14,9 +14,18 @@
|
|
|
14
14
|
- `Chat`: Streaming chat surface with agent metadata, artifact events, and helpers for incremental responses.
|
|
15
15
|
- `CardPanel`: Searchable, template-driven card grid with add/view/action callbacks and custom renderers.
|
|
16
16
|
- `TabWidget`: Lightweight tab host that supports badges, closable tabs, and attaching HTML or PyTincture components per tab.
|
|
17
|
-
- `Sidebar`: Collapsible navigation rail with badges,
|
|
17
|
+
- `Sidebar`: Collapsible navigation rail with icons, badges, nested groups (a flyout when collapsed), headings, separators and a spacer for bottom-pinned items; keyboard navigable.
|
|
18
18
|
- `ModalWindow`: Simple modal shell that can host arbitrary Layout instances or HTML snippets.
|
|
19
19
|
- `ResourceBoard`: Master/detail resource explorer with selectable rows, action hooks, and optional add button wiring.
|
|
20
|
+
- `message`: Toasts plus awaitable `alert` / `confirm` / `prompt` dialogs, styled and themed, in place of `window.confirm()`.
|
|
21
|
+
- `Toolbar`: Buttons, toggles, one-of-several groups, text, separators and spacers, dropping to icons when space runs out.
|
|
22
|
+
- `ContextMenu`: Right-click menus with icons, shortcut hints, danger and disabled items, checkable and radio items, separators and submenus; keyboard accessible.
|
|
23
|
+
- `MenuBar`: A File / Edit / View application menu whose titles open ContextMenu dropdowns; full menubar keyboard model.
|
|
24
|
+
- `Kanban`: A board of Listbox columns with counts, WIP limits (optionally strict), add-card buttons, collapsible and reorderable columns, a board-wide filter and selection.
|
|
25
|
+
- `Listbox`: Rich list items (fields or an escaped template) that can host live widgets, with actions, selection, a filter, and pointer and keyboard drag-and-drop within and between lists; the base for Kanban.
|
|
26
|
+
- `Popup` and tooltips: An anchored popover for pickers, help and small forms; styled, keyboard-reachable tooltips that replace native `title` text across the widgets.
|
|
27
|
+
- `ProgressBar` / `progress_html`: Determinate or indeterminate bars with active/done/error/paused states, as a live widget or static markup.
|
|
28
|
+
- `Pagination`: First/previous/next/last, a page box or numbered buttons, a page-size selector and a "1–50 of 1,234" summary; copes with totals the server could not count.
|
|
20
29
|
|
|
21
30
|
## Runtime Architecture
|
|
22
31
|
- Layouts and widgets are declared in Python but render as DOM nodes through the JavaScript bundles under `wapyt/assets/`. Each helper (e.g., `Layout.add_chat`) forwards config dictionaries directly to the matching JS constructor.
|
|
@@ -60,7 +69,16 @@ wapyt/
|
|
|
60
69
|
|
|
61
70
|
- The JavaScript bundles define the `window.wapyt` namespace and are evaluated automatically by PyTincture’s loader (see `pytincture/frontend/pytincture.js`).
|
|
62
71
|
- Layout styles live in `assets/wapyt.css`; adjust tokens there to integrate with your design system.
|
|
63
|
-
-
|
|
72
|
+
- Message content is rendered by the escaping markdown renderer in `assets/chat.js`. If a page also loads
|
|
73
|
+
[`marked`](https://marked.js.org/), the widget uses it **only** when `DOMPurify` is present as well —
|
|
74
|
+
`marked` passes raw HTML through, so without a sanitizer the widget falls back to its own renderer.
|
|
75
|
+
- Artifact previews run in `<iframe sandbox="allow-scripts">` with no `allow-same-origin`: artifact code is
|
|
76
|
+
treated as untrusted and cannot reach the host page.
|
|
77
|
+
- `tests/` holds two different things: the `*_demo.py` files are small, framework-free PyTincture apps you can
|
|
78
|
+
copy/paste into your own projects, and `test_*.py` are CPython unit tests for the pure-Python half of the
|
|
79
|
+
widgetset. Run the latter with `uv run --group dev pytest tests/` (or `pytest tests/` in an activated venv);
|
|
80
|
+
they need no browser and no demo dependencies, and `tests/conftest.py` puts the repo root on `sys.path` so
|
|
81
|
+
they work from a fresh clone.
|
|
64
82
|
|
|
65
83
|
## License
|
|
66
84
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wapyt"
|
|
7
|
+
version = "0.2.0.dev1"
|
|
8
|
+
description = "DHTMLX-free pyTincture widgetset: layouts, forms, tables, trees, chat, terminal, menus, Kanban and more, declared in Python and rendered in the browser."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
keywords = ["pytincture", "widgetset", "pyodide", "wapyt"]
|
|
13
|
+
# No runtime dependencies: `pyodide.ffi` is provided by the Pyodide runtime itself
|
|
14
|
+
# (soft-imported in _runtime.py), not by the `pyodide-py` distribution. Declaring
|
|
15
|
+
# either here forces micropip to resolve them in the browser at install time.
|
|
16
|
+
dependencies = []
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
homepage = "https://github.com/WAwesome-AI/wa_pytincture_widgetset"
|
|
20
|
+
documentation = "https://github.com/WAwesome-AI/wa_pytincture_widgetset/wiki"
|
|
21
|
+
changelog = "https://github.com/WAwesome-AI/wa_pytincture_widgetset/wiki/Widget-Catalog#changes"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
include-package-data = true
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
# Only wapyt: without this, tests/ (demo apps and unit tests) was packed as a
|
|
34
|
+
# top-level `tests` package and installed into site-packages.
|
|
35
|
+
include = ["wapyt*"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""CPython tests for the Chart config (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import wapyt
|
|
7
|
+
from wapyt import ChartConfig, ChartDataset
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_exported_with_a_layout_helper():
|
|
11
|
+
from wapyt.layout.layout import Layout
|
|
12
|
+
assert {"Chart", "ChartConfig", "ChartDataset"} <= set(wapyt.__all__)
|
|
13
|
+
assert hasattr(Layout, "add_chart")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_defaults_are_minimal():
|
|
17
|
+
assert ChartConfig().to_dict() == {"type": "line", "labels": [], "datasets": [], "legend": "auto"}
|
|
18
|
+
assert ChartDataset("a", [1, 2]).to_dict() == {"label": "a", "data": [1, 2]}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_options_are_camel_cased():
|
|
22
|
+
payload = ChartConfig(type="bar", labels=["Q1"], datasets=[ChartDataset("x", [1], color="#123456")],
|
|
23
|
+
title="T", legend="bottom", stacked=True, horizontal=True, x_label="Quarter",
|
|
24
|
+
y_label="$", y_min=0, y_max=10, begin_at_zero=False, value_prefix="$", decimals=1,
|
|
25
|
+
compact=True, animation=False, resize_delay=80, height=240, table_toggle=True,
|
|
26
|
+
aria_label="Rev", options={"plugins": {"legend": {"align": "end"}}}).to_dict()
|
|
27
|
+
assert payload["format"] == {"prefix": "$", "decimals": 1, "compact": True}
|
|
28
|
+
assert (payload["stacked"], payload["horizontal"], payload["beginAtZero"], payload["animation"]) == (True, True, False, False)
|
|
29
|
+
assert (payload["resizeDelay"], payload["height"], payload["tableToggle"]) == (80, 240, True)
|
|
30
|
+
assert payload["options"] == {"plugins": {"legend": {"align": "end"}}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_dataset_options():
|
|
34
|
+
payload = ChartDataset("t", [1, None], type="line", fill=True, tension=0.3, stack="a", dashed=True,
|
|
35
|
+
point_radius=0, hidden=True).to_dict()
|
|
36
|
+
assert payload == {"label": "t", "data": [1, None], "type": "line", "fill": True, "tension": 0.3,
|
|
37
|
+
"stack": "a", "dashed": True, "pointRadius": 0, "hidden": True}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("bad", [dict(type="gauge"), dict(legend="middle"), dict(type="line", horizontal=True),
|
|
41
|
+
dict(type="pie", stacked=True)])
|
|
42
|
+
def test_invalid_configs(bad):
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
ChartConfig(**bad).to_dict()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_legend_false_means_none():
|
|
48
|
+
assert ChartConfig(legend=False).to_dict()["legend"] == "none"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_more_series_than_palette_colours_is_refused():
|
|
52
|
+
series = [ChartDataset(f"s{i}", [i]) for i in range(9)]
|
|
53
|
+
with pytest.raises(ValueError, match="palette has 8"):
|
|
54
|
+
ChartConfig(datasets=series).to_dict()
|
|
55
|
+
coloured = series[:8] + [ChartDataset("s8", [8], color="#777777")]
|
|
56
|
+
ChartConfig(datasets=coloured).to_dict()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_pie_slices_beyond_the_palette_need_colours():
|
|
60
|
+
labels = [f"l{i}" for i in range(9)]
|
|
61
|
+
with pytest.raises(ValueError, match="9 slices"):
|
|
62
|
+
ChartConfig(type="pie", labels=labels, datasets=[ChartDataset("x", list(range(9)))]).to_dict()
|
|
63
|
+
ChartConfig(type="pie", labels=labels, datasets=[ChartDataset("x", list(range(9)), colors=["#111111"] * 9)]).to_dict()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for `Chat`'s stream consumption.
|
|
3
|
+
|
|
4
|
+
These cover the Python side of the backend -> widget boundary: how chunks from
|
|
5
|
+
a streaming proxy (see `multiaiproxy.py`) are turned into text, how in-band
|
|
6
|
+
provider errors surface, and how the coroutine is scheduled under Pyodide.
|
|
7
|
+
|
|
8
|
+
No browser is involved. `Chat.__init__` requires the Pyodide runtime, so the
|
|
9
|
+
tests drive a subclass that replaces only the two JS-facing methods the stream
|
|
10
|
+
helpers call.
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from wapyt.chat import chat as chat_module
|
|
17
|
+
from wapyt.chat.chat import Chat, ChatStreamError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeChat(Chat):
|
|
21
|
+
"""
|
|
22
|
+
A `Chat` with the JS bridge stubbed out.
|
|
23
|
+
|
|
24
|
+
Bypasses `Chat.__init__` (which needs `js.wapyt.ChatWidget`) and records
|
|
25
|
+
what the real widget would have been told to render.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None: # noqa: D107 - deliberately skips super()
|
|
29
|
+
self.appended: list[str] = []
|
|
30
|
+
self.finished = 0
|
|
31
|
+
|
|
32
|
+
def append_stream(self, message_id: str, chunk: str) -> None:
|
|
33
|
+
self.appended.append(chunk)
|
|
34
|
+
|
|
35
|
+
def finish_stream(self, message_id: str, final_chunk=None) -> None:
|
|
36
|
+
self.finished += 1
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def text(self) -> str:
|
|
40
|
+
return "".join(self.appended)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def chat() -> FakeChat:
|
|
45
|
+
return FakeChat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def openai_chunk(text: str) -> dict:
|
|
49
|
+
"""A LiteLLM/OpenAI-shaped content chunk."""
|
|
50
|
+
return {"choices": [{"delta": {"content": text}}]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Text extraction
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize(
|
|
59
|
+
("chunk", "expected"),
|
|
60
|
+
[
|
|
61
|
+
(openai_chunk("hi"), "hi"),
|
|
62
|
+
({"type": "chunk", "chunk": {"choices": [{"delta": {"content": "hi"}}]}}, "hi"),
|
|
63
|
+
# `content.delta` used to return "" unconditionally, which dropped the
|
|
64
|
+
# entire response from any proxy that forwards provider chunks as-is.
|
|
65
|
+
({"type": "content.delta", "delta": {"text": "hi"}}, "hi"),
|
|
66
|
+
({"type": "content.delta", "delta": {"content": "hi"}}, "hi"),
|
|
67
|
+
({"type": "content.delta", "delta": "hi"}, "hi"),
|
|
68
|
+
({"type": "response.output_text.delta", "delta": {"text": "hi"}}, "hi"),
|
|
69
|
+
({"type": "response.output_text.delta", "delta": "hi"}, "hi"),
|
|
70
|
+
("hi", "hi"),
|
|
71
|
+
(None, ""),
|
|
72
|
+
({}, ""),
|
|
73
|
+
({"choices": []}, ""),
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
def test_extract_stream_text(chunk, expected):
|
|
77
|
+
assert Chat.extract_stream_text(chunk) == expected
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_content_delta_is_assembled_in_order(chat):
|
|
81
|
+
chat.consume_stream(
|
|
82
|
+
"msg",
|
|
83
|
+
[
|
|
84
|
+
{"type": "content.delta", "delta": {"text": "He"}},
|
|
85
|
+
{"type": "content.delta", "delta": {"content": "llo"}},
|
|
86
|
+
{"type": "response.output_text.delta", "delta": "!"},
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
assert chat.text == "Hello!"
|
|
90
|
+
assert chat.finished == 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# In-band provider errors
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize(
|
|
99
|
+
("chunk", "expected"),
|
|
100
|
+
[
|
|
101
|
+
({"error": {"message": "boom", "type": "provider_error"}}, "boom"),
|
|
102
|
+
({"error": {"detail": "boom"}}, "boom"),
|
|
103
|
+
({"error": {"code": "stream_error"}}, "stream_error"),
|
|
104
|
+
({"error": "boom"}, "boom"),
|
|
105
|
+
({"type": "error", "message": "boom"}, "boom"),
|
|
106
|
+
({"error": {}}, "Unknown backend error"),
|
|
107
|
+
(openai_chunk("hi"), None),
|
|
108
|
+
({}, None),
|
|
109
|
+
("plain text", None),
|
|
110
|
+
(None, None),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
def test_extract_stream_error(chunk, expected):
|
|
114
|
+
assert Chat.extract_stream_error(chunk) == expected
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_error_chunk_keeps_partial_text_and_closes_stream(chat):
|
|
118
|
+
chat.consume_stream(
|
|
119
|
+
"msg",
|
|
120
|
+
[
|
|
121
|
+
openai_chunk("partial "),
|
|
122
|
+
{"error": {"message": "Model 'x' is not supported."}},
|
|
123
|
+
openai_chunk("never reached"),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
assert chat.text == "partial Backend error: Model 'x' is not supported."
|
|
127
|
+
assert chat.finished == 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_error_chunk_reaches_on_error_hook(chat):
|
|
131
|
+
seen: list[Exception] = []
|
|
132
|
+
|
|
133
|
+
chat.consume_stream("msg", [{"error": "boom"}], on_error=seen.append)
|
|
134
|
+
|
|
135
|
+
assert len(seen) == 1
|
|
136
|
+
assert isinstance(seen[0], ChatStreamError)
|
|
137
|
+
assert str(seen[0]) == "boom"
|
|
138
|
+
# A custom hook owns the presentation, so nothing is written by default.
|
|
139
|
+
assert chat.text == ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_transport_exception_still_reaches_on_error(chat):
|
|
143
|
+
def exploding():
|
|
144
|
+
yield openai_chunk("partial ")
|
|
145
|
+
raise ConnectionError("socket closed")
|
|
146
|
+
|
|
147
|
+
seen: list[Exception] = []
|
|
148
|
+
chat.consume_stream("msg", exploding(), on_error=seen.append)
|
|
149
|
+
|
|
150
|
+
assert chat.text == "partial "
|
|
151
|
+
assert isinstance(seen[0], ConnectionError)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Ordinary streaming
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_sync_stream_appends_and_finishes(chat):
|
|
160
|
+
chat.consume_stream("msg", [openai_chunk("a"), openai_chunk("b")])
|
|
161
|
+
assert chat.text == "ab"
|
|
162
|
+
assert chat.finished == 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_finish_false_leaves_stream_open(chat):
|
|
166
|
+
chat.consume_stream("msg", [openai_chunk("a")], finish=False)
|
|
167
|
+
assert chat.text == "a"
|
|
168
|
+
assert chat.finished == 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_custom_parser_overrides_extraction(chat):
|
|
172
|
+
chat.consume_stream("msg", [{"raw": "a"}, {"raw": "b"}], parser=lambda c: c["raw"])
|
|
173
|
+
assert chat.text == "ab"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_non_iterable_stream_is_rejected(chat):
|
|
177
|
+
with pytest.raises(ValueError, match="iterable"):
|
|
178
|
+
chat.consume_stream("msg", 42)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Scheduling (item: asyncio.run must never run under Pyodide)
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.fixture
|
|
187
|
+
def browser_loop(monkeypatch):
|
|
188
|
+
"""
|
|
189
|
+
Stand in for Pyodide: a `js` module is importable and an event loop is the
|
|
190
|
+
current loop, but nothing is *running* — the state a synchronous JS event
|
|
191
|
+
handler sees. `asyncio.run` would create and close a second loop here,
|
|
192
|
+
permanently breaking Pyodide's WebLoop, so it is made fatal.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def forbidden(*args, **kwargs):
|
|
196
|
+
raise AssertionError("asyncio.run() must not be called under Pyodide")
|
|
197
|
+
|
|
198
|
+
monkeypatch.setattr(chat_module, "js", object())
|
|
199
|
+
monkeypatch.setattr(asyncio, "run", forbidden)
|
|
200
|
+
|
|
201
|
+
loop = asyncio.new_event_loop()
|
|
202
|
+
asyncio.set_event_loop(loop)
|
|
203
|
+
try:
|
|
204
|
+
yield loop
|
|
205
|
+
finally:
|
|
206
|
+
asyncio.set_event_loop(None)
|
|
207
|
+
loop.close()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_async_stream_is_scheduled_not_blocked(chat, browser_loop):
|
|
211
|
+
async def agen():
|
|
212
|
+
for token in ("x", "y"):
|
|
213
|
+
yield openai_chunk(token)
|
|
214
|
+
|
|
215
|
+
chat.consume_stream("msg", agen())
|
|
216
|
+
|
|
217
|
+
# consume_stream returns immediately; the browser drives the coroutine.
|
|
218
|
+
assert chat.text == ""
|
|
219
|
+
|
|
220
|
+
browser_loop.run_until_complete(asyncio.sleep(0))
|
|
221
|
+
assert chat.text == "xy"
|
|
222
|
+
assert chat.finished == 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_async_error_chunk_reaches_on_error(chat, browser_loop):
|
|
226
|
+
async def agen():
|
|
227
|
+
yield openai_chunk("partial ")
|
|
228
|
+
yield {"error": {"message": "upstream died"}}
|
|
229
|
+
|
|
230
|
+
seen: list[Exception] = []
|
|
231
|
+
chat.consume_stream("msg", agen(), on_error=seen.append)
|
|
232
|
+
browser_loop.run_until_complete(asyncio.sleep(0))
|
|
233
|
+
|
|
234
|
+
assert chat.text == "partial "
|
|
235
|
+
assert isinstance(seen[0], ChatStreamError)
|
|
236
|
+
assert str(seen[0]) == "upstream died"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""CPython tests for the ContextMenu config (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from wapyt import ContextMenuConfig, MenuItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_exported():
|
|
10
|
+
import wapyt
|
|
11
|
+
|
|
12
|
+
assert {"ContextMenu", "ContextMenuConfig", "MenuItem"} <= set(wapyt.__all__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_item_defaults_are_minimal():
|
|
16
|
+
assert MenuItem("open").to_dict() == {"id": "open"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_item_options_are_serialised():
|
|
20
|
+
assert MenuItem("del", "Delete", "mdi-delete", shortcut="Del", danger=True, disabled=True).to_dict() == {
|
|
21
|
+
"id": "del", "label": "Delete", "icon": "mdi-delete", "shortcut": "Del",
|
|
22
|
+
"danger": True, "disabled": True,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_separator_ignores_everything_else():
|
|
27
|
+
assert MenuItem("x", "X", separator=True).to_dict() == {"separator": True}
|
|
28
|
+
assert MenuItem(separator=True).to_dict() == {"separator": True}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_item_needs_an_id():
|
|
32
|
+
with pytest.raises(ValueError, match="needs an id"):
|
|
33
|
+
MenuItem(label="Nameless").to_dict()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_submenus_nest():
|
|
37
|
+
payload = MenuItem("share", "Share", items=[MenuItem("link"), MenuItem("email")]).to_dict()
|
|
38
|
+
assert payload["items"] == [{"id": "link"}, {"id": "email"}]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_duplicate_ids_are_rejected_across_submenus():
|
|
42
|
+
config = ContextMenuConfig(items=[
|
|
43
|
+
MenuItem("copy"),
|
|
44
|
+
MenuItem("share", items=[MenuItem("copy")]),
|
|
45
|
+
MenuItem(separator=True),
|
|
46
|
+
])
|
|
47
|
+
with pytest.raises(ValueError, match="duplicate menu item ids: copy"):
|
|
48
|
+
config.to_dict()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_config_payload():
|
|
52
|
+
payload = ContextMenuConfig(items=[MenuItem("a"), MenuItem(separator=True)], label="Rows").to_dict()
|
|
53
|
+
assert payload == {"items": [{"id": "a"}, {"separator": True}], "label": "Rows"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_payload_jsnull_becomes_none():
|
|
57
|
+
from wapyt._runtime import to_plain as _plain
|
|
58
|
+
|
|
59
|
+
class JsNull:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
assert _plain({"id": "a", "context": JsNull(), "target": "row-1"}) == {
|
|
63
|
+
"id": "a", "context": None, "target": "row-1",
|
|
64
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""CPython tests for DataTable row/cell classes and hidden columns (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from wapyt import ColumnConfig, DataTableConfig
|
|
5
|
+
from wapyt.datatable.datatable import DataTable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_new_keys_only_sent_when_set():
|
|
9
|
+
assert not {"hidden", "css", "cellClassBy"} & set(ColumnConfig(id="a").to_dict())
|
|
10
|
+
assert "rowClassBy" not in DataTableConfig().to_dict()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_payloads():
|
|
14
|
+
column = ColumnConfig(id="due", hidden=True, css="num col-due", cell_class_by="due_class").to_dict()
|
|
15
|
+
assert column["hidden"] is True and column["css"] == "num col-due" and column["cellClassBy"] == "due_class"
|
|
16
|
+
assert DataTableConfig(row_class_by="status_class").to_dict()["rowClassBy"] == "status_class"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_wrapper_has_hidden_column_api():
|
|
20
|
+
assert all(hasattr(DataTable, n) for n in ("hide_column", "show_column", "set_column_hidden", "is_column_hidden"))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""CPython tests for DataTable CSV export options (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from wapyt.datatable.datatable import DataTable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_wrapper_has_csv_api():
|
|
10
|
+
assert hasattr(DataTable, "to_csv") and hasattr(DataTable, "export_csv")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_csv_options_payload():
|
|
14
|
+
opts = DataTable._csv_opts(True, ["a", "b"], True, False, False, ";")
|
|
15
|
+
assert opts == {"selectedOnly": True, "columns": ["a", "b"], "raw": True, "header": False,
|
|
16
|
+
"safe": False, "delimiter": ";"}
|
|
17
|
+
assert DataTable._csv_opts(False, None, False, True, True, ",")["columns"] is None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize("bad", ["", ";;", '"', "\n"])
|
|
21
|
+
def test_bad_delimiters(bad):
|
|
22
|
+
with pytest.raises(ValueError, match="delimiter"):
|
|
23
|
+
DataTable._csv_opts(False, None, False, True, True, bad)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CPython tests for editable DataTable columns (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from wapyt import ColumnConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_editing_keys_only_sent_when_set():
|
|
10
|
+
payload = ColumnConfig(id="name").to_dict()
|
|
11
|
+
assert not {"editable", "editor", "options", "required"} & set(payload)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_editable_column_payload():
|
|
15
|
+
payload = ColumnConfig(id="status", editable=True, editor="select", required=True,
|
|
16
|
+
options=["open", {"value": "done", "label": "Done"}]).to_dict()
|
|
17
|
+
assert payload["editable"] is True and payload["editor"] == "select" and payload["required"] is True
|
|
18
|
+
assert payload["options"] == ["open", {"value": "done", "label": "Done"}]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize("editor", ["text", "number", "checkbox", "date"])
|
|
22
|
+
def test_editors_accepted(editor):
|
|
23
|
+
assert ColumnConfig(id="c", editable=True, editor=editor).to_dict()["editor"] == editor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_bad_editor_and_select_without_options():
|
|
27
|
+
with pytest.raises(ValueError, match="editor must be one of"):
|
|
28
|
+
ColumnConfig(id="c", editable=True, editor="textarea").to_dict()
|
|
29
|
+
with pytest.raises(ValueError, match="needs options"):
|
|
30
|
+
ColumnConfig(id="c", editable=True, editor="select").to_dict()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_wrapper_has_editing_api():
|
|
34
|
+
from wapyt.datatable.datatable import DataTable
|
|
35
|
+
|
|
36
|
+
assert all(hasattr(DataTable, n) for n in ("on_edit", "set_cell", "set_cell_error", "edit_cell", "cancel_edit"))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""CPython tests for DataTable frozen columns (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_frozen_columns_payload():
|
|
6
|
+
from wapyt import DataTableConfig
|
|
7
|
+
from wapyt.datatable.datatable import DataTable
|
|
8
|
+
|
|
9
|
+
assert "frozenColumns" not in DataTableConfig().to_dict()
|
|
10
|
+
assert DataTableConfig(frozen_columns=2).to_dict()["frozenColumns"] == 2
|
|
11
|
+
assert hasattr(DataTable, "set_frozen_columns")
|