bootstack 0.1.0__py3-none-any.whl
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.
- bootstack/__init__.py +157 -0
- bootstack/__main__.py +5 -0
- bootstack/_core/__init__.py +21 -0
- bootstack/_core/capabilities/__init__.py +45 -0
- bootstack/_core/capabilities/after.py +103 -0
- bootstack/_core/capabilities/bind.py +192 -0
- bootstack/_core/capabilities/bindtags.py +112 -0
- bootstack/_core/capabilities/busy.py +72 -0
- bootstack/_core/capabilities/clipboard.py +89 -0
- bootstack/_core/capabilities/focus.py +118 -0
- bootstack/_core/capabilities/grab.py +65 -0
- bootstack/_core/capabilities/grid.py +211 -0
- bootstack/_core/capabilities/localization.py +231 -0
- bootstack/_core/capabilities/pack.py +140 -0
- bootstack/_core/capabilities/place.py +113 -0
- bootstack/_core/capabilities/selection.py +136 -0
- bootstack/_core/capabilities/signals.py +244 -0
- bootstack/_core/capabilities/winfo.py +315 -0
- bootstack/_core/colorutils.py +234 -0
- bootstack/_core/exceptions.py +25 -0
- bootstack/_core/images.py +463 -0
- bootstack/_core/mixins/__init__.py +1 -0
- bootstack/_core/mixins/ttk_state.py +35 -0
- bootstack/_core/mixins/widget.py +132 -0
- bootstack/_core/paths.py +49 -0
- bootstack/_core/publisher.py +149 -0
- bootstack/_core/variables.py +62 -0
- bootstack/_runtime/__init__.py +3 -0
- bootstack/_runtime/app.py +930 -0
- bootstack/_runtime/base_window.py +945 -0
- bootstack/_runtime/events.py +399 -0
- bootstack/_runtime/shortcuts.py +496 -0
- bootstack/_runtime/tk_patch.py +43 -0
- bootstack/_runtime/toplevel.py +276 -0
- bootstack/_runtime/utility.py +457 -0
- bootstack/_runtime/visual_focus.py +240 -0
- bootstack/_runtime/window_utilities.py +1123 -0
- bootstack/assets/__init__.py +21 -0
- bootstack/assets/bootstack.ico +0 -0
- bootstack/assets/bootstack.png +0 -0
- bootstack/assets/elements/__init__.py +0 -0
- bootstack/assets/elements/badge-pill.png +0 -0
- bootstack/assets/elements/badge-square.png +0 -0
- bootstack/assets/elements/button-compact.png +0 -0
- bootstack/assets/elements/button-default.png +0 -0
- bootstack/assets/elements/buttongroup-after-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-after-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-after-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-after-v-default.png +0 -0
- bootstack/assets/elements/buttongroup-before-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-before-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-before-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-before-v-default.png +0 -0
- bootstack/assets/elements/buttongroup-center-h-compact.png +0 -0
- bootstack/assets/elements/buttongroup-center-h-default.png +0 -0
- bootstack/assets/elements/buttongroup-center-v-compact.png +0 -0
- bootstack/assets/elements/buttongroup-center-v-default.png +0 -0
- bootstack/assets/elements/card.png +0 -0
- bootstack/assets/elements/checkbox-checked.png +0 -0
- bootstack/assets/elements/checkbox-indeterminate.png +0 -0
- bootstack/assets/elements/checkbox-unchecked.png +0 -0
- bootstack/assets/elements/field.png +0 -0
- bootstack/assets/elements/input-addon-compact.png +0 -0
- bootstack/assets/elements/input-addon-default.png +0 -0
- bootstack/assets/elements/input-compact.png +0 -0
- bootstack/assets/elements/input-default.png +0 -0
- bootstack/assets/elements/list-item-separated.png +0 -0
- bootstack/assets/elements/list-item.png +0 -0
- bootstack/assets/elements/listrow-compact.png +0 -0
- bootstack/assets/elements/listrow-default.png +0 -0
- bootstack/assets/elements/manifest.toml +361 -0
- bootstack/assets/elements/menu-item.png +0 -0
- bootstack/assets/elements/navitem-compact.png +0 -0
- bootstack/assets/elements/navitem-default.png +0 -0
- bootstack/assets/elements/progressbar-h-compact.png +0 -0
- bootstack/assets/elements/progressbar-h-default.png +0 -0
- bootstack/assets/elements/progressbar-v-compact.png +0 -0
- bootstack/assets/elements/progressbar-v-default.png +0 -0
- bootstack/assets/elements/radiobutton.png +0 -0
- bootstack/assets/elements/scrollbar-horizontal.png +0 -0
- bootstack/assets/elements/scrollbar-vertical.png +0 -0
- bootstack/assets/elements/slider-handle.png +0 -0
- bootstack/assets/elements/slider-track-h.png +0 -0
- bootstack/assets/elements/slider-track-v.png +0 -0
- bootstack/assets/elements/switch-off.png +0 -0
- bootstack/assets/elements/switch-on.png +0 -0
- bootstack/assets/elements/tab-h.png +0 -0
- bootstack/assets/elements/tab-v.png +0 -0
- bootstack/assets/icons/bootstrap.ttf +0 -0
- bootstack/assets/icons/glyphmap.json +2080 -0
- bootstack/assets/icons/icon_metrics.json +12470 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +878 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +878 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +856 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +854 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +917 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +844 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +845 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
- bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +845 -0
- bootstack/cli/__init__.py +133 -0
- bootstack/cli/__main__.py +6 -0
- bootstack/cli/add.py +395 -0
- bootstack/cli/appicon.py +285 -0
- bootstack/cli/build.py +115 -0
- bootstack/cli/config.py +313 -0
- bootstack/cli/demo.py +564 -0
- bootstack/cli/dev.py +153 -0
- bootstack/cli/doctor.py +195 -0
- bootstack/cli/icons.py +98 -0
- bootstack/cli/promote.py +120 -0
- bootstack/cli/pyinstaller.py +268 -0
- bootstack/cli/run.py +95 -0
- bootstack/cli/start.py +117 -0
- bootstack/cli/templates/__init__.py +931 -0
- bootstack/clipboard.py +48 -0
- bootstack/constants.py +318 -0
- bootstack/data/README.md +615 -0
- bootstack/data/__init__.py +78 -0
- bootstack/data/_observable.py +276 -0
- bootstack/data/base.py +780 -0
- bootstack/data/file_source.py +367 -0
- bootstack/data/memory_source.py +450 -0
- bootstack/data/query.py +367 -0
- bootstack/data/readers.py +289 -0
- bootstack/data/sqlite_source.py +869 -0
- bootstack/data/types.py +354 -0
- bootstack/data/writers.py +232 -0
- bootstack/dev/__init__.py +36 -0
- bootstack/dev/_capture.py +141 -0
- bootstack/dev/_env.py +43 -0
- bootstack/dev/_registry.py +140 -0
- bootstack/dev/_reloader.py +351 -0
- bootstack/dev/_reset.py +50 -0
- bootstack/dev/_watcher.py +91 -0
- bootstack/dialogs/__init__.py +948 -0
- bootstack/dialogs/_impl/__init__.py +47 -0
- bootstack/dialogs/_impl/colorchooser.py +588 -0
- bootstack/dialogs/_impl/datedialog.py +450 -0
- bootstack/dialogs/_impl/dialog.py +594 -0
- bootstack/dialogs/_impl/filterdialog.py +358 -0
- bootstack/dialogs/_impl/fontdialog.py +364 -0
- bootstack/dialogs/_impl/formdialog.py +564 -0
- bootstack/dialogs/_impl/message.py +486 -0
- bootstack/dialogs/_impl/query.py +570 -0
- bootstack/errors.py +67 -0
- bootstack/events/__init__.py +111 -0
- bootstack/events/_event.py +135 -0
- bootstack/events/_payloads.py +539 -0
- bootstack/events/_subscription.py +38 -0
- bootstack/i18n/README.md +77 -0
- bootstack/i18n/__init__.py +23 -0
- bootstack/i18n/catalog.py +121 -0
- bootstack/i18n/intl_format.py +584 -0
- bootstack/i18n/msgcat.py +425 -0
- bootstack/i18n/specs.py +156 -0
- bootstack/images.py +563 -0
- bootstack/py.typed +1 -0
- bootstack/scheduling/__init__.py +11 -0
- bootstack/scheduling/_schedule.py +218 -0
- bootstack/shortcuts.py +21 -0
- bootstack/signals/README.md +98 -0
- bootstack/signals/__init__.py +10 -0
- bootstack/signals/integration.py +100 -0
- bootstack/signals/signal.py +353 -0
- bootstack/signals/types.py +6 -0
- bootstack/store.py +286 -0
- bootstack/streams/__init__.py +12 -0
- bootstack/streams/_stream.py +321 -0
- bootstack/style/__init__.py +38 -0
- bootstack/style/builders/__init__.py +51 -0
- bootstack/style/builders/badge.py +46 -0
- bootstack/style/builders/button.py +339 -0
- bootstack/style/builders/buttongroup.py +311 -0
- bootstack/style/builders/calendar.py +271 -0
- bootstack/style/builders/checkbutton.py +110 -0
- bootstack/style/builders/combobox.py +113 -0
- bootstack/style/builders/contextmenu.py +268 -0
- bootstack/style/builders/entry.py +82 -0
- bootstack/style/builders/expander.py +148 -0
- bootstack/style/builders/field.py +335 -0
- bootstack/style/builders/frame.py +50 -0
- bootstack/style/builders/label.py +28 -0
- bootstack/style/builders/labelframe.py +34 -0
- bootstack/style/builders/listview.py +369 -0
- bootstack/style/builders/menubar.py +91 -0
- bootstack/style/builders/menubutton.py +359 -0
- bootstack/style/builders/panedwindow.py +25 -0
- bootstack/style/builders/progressbar.py +67 -0
- bootstack/style/builders/radiobutton.py +99 -0
- bootstack/style/builders/scale.py +64 -0
- bootstack/style/builders/scrollbar.py +225 -0
- bootstack/style/builders/separator.py +49 -0
- bootstack/style/builders/sidenav.py +643 -0
- bootstack/style/builders/sizegrip.py +15 -0
- bootstack/style/builders/spinbox.py +119 -0
- bootstack/style/builders/switch.py +70 -0
- bootstack/style/builders/tabitem.py +204 -0
- bootstack/style/builders/togglegroup.py +294 -0
- bootstack/style/builders/toolbutton.py +275 -0
- bootstack/style/builders/tooltip.py +26 -0
- bootstack/style/builders/treeview.py +193 -0
- bootstack/style/builders/utils.py +455 -0
- bootstack/style/builders_tk/__init__.py +16 -0
- bootstack/style/builders_tk/defaults.py +229 -0
- bootstack/style/element.py +173 -0
- bootstack/style/fonts.py +123 -0
- bootstack/style/style.py +609 -0
- bootstack/style/style_builder_base.py +716 -0
- bootstack/style/style_builder_mixed.py +93 -0
- bootstack/style/style_builder_tk.py +109 -0
- bootstack/style/style_builder_ttk.py +353 -0
- bootstack/style/style_resolver.py +447 -0
- bootstack/style/theme.py +245 -0
- bootstack/style/theme_provider.py +471 -0
- bootstack/style/themes/__init__.py +128 -0
- bootstack/style/tk_patch.py +5 -0
- bootstack/style/token_maps.py +41 -0
- bootstack/style/types.py +32 -0
- bootstack/style/typography.py +523 -0
- bootstack/style/utility.py +746 -0
- bootstack/types.py +39 -0
- bootstack/validation/__init__.py +6 -0
- bootstack/validation/types.py +15 -0
- bootstack/validation/validation_result.py +17 -0
- bootstack/validation/validation_rules.py +205 -0
- bootstack/widgets/__init__.py +74 -0
- bootstack/widgets/_core/__init__.py +31 -0
- bootstack/widgets/_core/app_config.py +266 -0
- bootstack/widgets/_core/base.py +461 -0
- bootstack/widgets/_core/container.py +334 -0
- bootstack/widgets/_core/context.py +35 -0
- bootstack/widgets/_core/events.py +130 -0
- bootstack/widgets/_core/field_mixin.py +353 -0
- bootstack/widgets/_core/icon_image_props.py +72 -0
- bootstack/widgets/_core/image_binding.py +93 -0
- bootstack/widgets/_core/navmodel.py +345 -0
- bootstack/widgets/_core/options.py +210 -0
- bootstack/widgets/_core/selection_group.py +130 -0
- bootstack/widgets/_core/window_controls.py +96 -0
- bootstack/widgets/_core/window_menu.py +246 -0
- bootstack/widgets/_impl/__init__.py +1 -0
- bootstack/widgets/_impl/_internal/__init__.py +0 -0
- bootstack/widgets/_impl/_internal/wrapper_base.py +307 -0
- bootstack/widgets/_impl/_parts/__init__.py +11 -0
- bootstack/widgets/_impl/_parts/numberentry_part.py +385 -0
- bootstack/widgets/_impl/_parts/spinnerentry_part.py +434 -0
- bootstack/widgets/_impl/_parts/textentry_part.py +406 -0
- bootstack/widgets/_impl/composites/__init__.py +33 -0
- bootstack/widgets/_impl/composites/_dateutils.py +33 -0
- bootstack/widgets/_impl/composites/_image_fit.py +105 -0
- bootstack/widgets/_impl/composites/accordion.py +381 -0
- bootstack/widgets/_impl/composites/avatar.py +191 -0
- bootstack/widgets/_impl/composites/buttongroup.py +371 -0
- bootstack/widgets/_impl/composites/calendar.py +972 -0
- bootstack/widgets/_impl/composites/carousel.py +567 -0
- bootstack/widgets/_impl/composites/chart.py +882 -0
- bootstack/widgets/_impl/composites/compositeframe.py +298 -0
- bootstack/widgets/_impl/composites/contextmenu.py +1951 -0
- bootstack/widgets/_impl/composites/dateentry.py +404 -0
- bootstack/widgets/_impl/composites/dropdownbutton.py +325 -0
- bootstack/widgets/_impl/composites/expander.py +515 -0
- bootstack/widgets/_impl/composites/field.py +670 -0
- bootstack/widgets/_impl/composites/form.py +1066 -0
- bootstack/widgets/_impl/composites/gallery.py +551 -0
- bootstack/widgets/_impl/composites/list/__init__.py +15 -0
- bootstack/widgets/_impl/composites/list/listitem.py +802 -0
- bootstack/widgets/_impl/composites/list/listview.py +1433 -0
- bootstack/widgets/_impl/composites/menu/__init__.py +18 -0
- bootstack/widgets/_impl/composites/menu/model.py +358 -0
- bootstack/widgets/_impl/composites/menu/render_native.py +134 -0
- bootstack/widgets/_impl/composites/menu/render_themed.py +134 -0
- bootstack/widgets/_impl/composites/meter.py +860 -0
- bootstack/widgets/_impl/composites/numericentry.py +201 -0
- bootstack/widgets/_impl/composites/pagestack.py +395 -0
- bootstack/widgets/_impl/composites/passwordentry.py +142 -0
- bootstack/widgets/_impl/composites/pathentry.py +168 -0
- bootstack/widgets/_impl/composites/picture.py +289 -0
- bootstack/widgets/_impl/composites/radiogroup.py +511 -0
- bootstack/widgets/_impl/composites/scrolledtext.py +375 -0
- bootstack/widgets/_impl/composites/scrolledtext.pyi +186 -0
- bootstack/widgets/_impl/composites/scrollview.py +764 -0
- bootstack/widgets/_impl/composites/selectbox.py +1026 -0
- bootstack/widgets/_impl/composites/shell/__init__.py +40 -0
- bootstack/widgets/_impl/composites/shell/content_host.py +52 -0
- bootstack/widgets/_impl/composites/shell/layout.py +335 -0
- bootstack/widgets/_impl/composites/shell/nav_panel.py +345 -0
- bootstack/widgets/_impl/composites/shell/providers.py +558 -0
- bootstack/widgets/_impl/composites/shell/rail.py +117 -0
- bootstack/widgets/_impl/composites/shell/shell.py +581 -0
- bootstack/widgets/_impl/composites/shell/workspace.py +273 -0
- bootstack/widgets/_impl/composites/sidenav/__init__.py +16 -0
- bootstack/widgets/_impl/composites/sidenav/header.py +81 -0
- bootstack/widgets/_impl/composites/sidenav/separator.py +44 -0
- bootstack/widgets/_impl/composites/slider/__init__.py +7 -0
- bootstack/widgets/_impl/composites/slider/_shared.py +195 -0
- bootstack/widgets/_impl/composites/slider/rangeslider.py +982 -0
- bootstack/widgets/_impl/composites/slider/slider.py +851 -0
- bootstack/widgets/_impl/composites/spinnerentry.py +185 -0
- bootstack/widgets/_impl/composites/tableview/__init__.py +5 -0
- bootstack/widgets/_impl/composites/tableview/tableview.py +3388 -0
- bootstack/widgets/_impl/composites/tableview/types.py +169 -0
- bootstack/widgets/_impl/composites/tabs/__init__.py +23 -0
- bootstack/widgets/_impl/composites/tabs/tabitem.py +389 -0
- bootstack/widgets/_impl/composites/tabs/tabs.py +974 -0
- bootstack/widgets/_impl/composites/tabs/tabview.py +650 -0
- bootstack/widgets/_impl/composites/textarea/__init__.py +26 -0
- bootstack/widgets/_impl/composites/textarea/change.py +46 -0
- bootstack/widgets/_impl/composites/textarea/codeeditor.py +526 -0
- bootstack/widgets/_impl/composites/textarea/core.py +495 -0
- bootstack/widgets/_impl/composites/textarea/decoration.py +42 -0
- bootstack/widgets/_impl/composites/textarea/diff.py +127 -0
- bootstack/widgets/_impl/composites/textarea/extensions/__init__.py +1 -0
- bootstack/widgets/_impl/composites/textarea/extensions/bracket_matcher.py +138 -0
- bootstack/widgets/_impl/composites/textarea/extensions/indent_guides.py +158 -0
- bootstack/widgets/_impl/composites/textarea/extensions/line_numbers.py +138 -0
- bootstack/widgets/_impl/composites/textarea/extensions/pygments_highlighter.py +312 -0
- bootstack/widgets/_impl/composites/textarea/extensions/smart_indent.py +177 -0
- bootstack/widgets/_impl/composites/textarea/filter.py +171 -0
- bootstack/widgets/_impl/composites/textarea/search_overlay.py +459 -0
- bootstack/widgets/_impl/composites/textarea/sidebar.py +88 -0
- bootstack/widgets/_impl/composites/textarea/style_registry.py +178 -0
- bootstack/widgets/_impl/composites/textarea/textarea.py +606 -0
- bootstack/widgets/_impl/composites/textarea/undo.py +217 -0
- bootstack/widgets/_impl/composites/textentry.py +57 -0
- bootstack/widgets/_impl/composites/timeentry.py +176 -0
- bootstack/widgets/_impl/composites/toast.py +390 -0
- bootstack/widgets/_impl/composites/toast_stack.py +156 -0
- bootstack/widgets/_impl/composites/togglegroup.py +418 -0
- bootstack/widgets/_impl/composites/toolbar.py +608 -0
- bootstack/widgets/_impl/composites/tooltip.py +491 -0
- bootstack/widgets/_impl/composites/tree/__init__.py +7 -0
- bootstack/widgets/_impl/composites/tree/source_binding.py +136 -0
- bootstack/widgets/_impl/composites/tree/treeitem.py +393 -0
- bootstack/widgets/_impl/composites/tree/treenode.py +174 -0
- bootstack/widgets/_impl/composites/tree/treeview.py +841 -0
- bootstack/widgets/_impl/mixins/__init__.py +24 -0
- bootstack/widgets/_impl/mixins/configure_mixin.py +216 -0
- bootstack/widgets/_impl/mixins/entry_mixin.py +134 -0
- bootstack/widgets/_impl/mixins/font_mixin.py +368 -0
- bootstack/widgets/_impl/mixins/icon_mixin.py +61 -0
- bootstack/widgets/_impl/mixins/localization_mixin.py +253 -0
- bootstack/widgets/_impl/mixins/signal_mixin.py +268 -0
- bootstack/widgets/_impl/mixins/validation_mixin.py +226 -0
- bootstack/widgets/_impl/primitives/__init__.py +49 -0
- bootstack/widgets/_impl/primitives/_menubutton.py +107 -0
- bootstack/widgets/_impl/primitives/badge.py +45 -0
- bootstack/widgets/_impl/primitives/button.py +76 -0
- bootstack/widgets/_impl/primitives/card.py +45 -0
- bootstack/widgets/_impl/primitives/checkbutton.py +124 -0
- bootstack/widgets/_impl/primitives/checktoggle.py +62 -0
- bootstack/widgets/_impl/primitives/combobox.py +156 -0
- bootstack/widgets/_impl/primitives/entry.py +87 -0
- bootstack/widgets/_impl/primitives/flexframe.py +448 -0
- bootstack/widgets/_impl/primitives/frame.py +185 -0
- bootstack/widgets/_impl/primitives/gridframe.py +546 -0
- bootstack/widgets/_impl/primitives/label.py +84 -0
- bootstack/widgets/_impl/primitives/labelframe.py +54 -0
- bootstack/widgets/_impl/primitives/optionmenu.py +387 -0
- bootstack/widgets/_impl/primitives/packframe.py +227 -0
- bootstack/widgets/_impl/primitives/panedwindow.py +45 -0
- bootstack/widgets/_impl/primitives/progressbar.py +83 -0
- bootstack/widgets/_impl/primitives/radiobutton.py +115 -0
- bootstack/widgets/_impl/primitives/radiotoggle.py +54 -0
- bootstack/widgets/_impl/primitives/scrollbar.py +42 -0
- bootstack/widgets/_impl/primitives/separator.py +43 -0
- bootstack/widgets/_impl/primitives/sizegrip.py +33 -0
- bootstack/widgets/_impl/primitives/spinbox.py +95 -0
- bootstack/widgets/_impl/primitives/switch.py +44 -0
- bootstack/widgets/_impl/primitives/treeview.py +69 -0
- bootstack/widgets/app.py +371 -0
- bootstack/widgets/appshell.py +1179 -0
- bootstack/widgets/avatar.py +140 -0
- bootstack/widgets/boolean_controls.py +455 -0
- bootstack/widgets/button.py +224 -0
- bootstack/widgets/buttongroup.py +239 -0
- bootstack/widgets/calendar.py +195 -0
- bootstack/widgets/card.py +159 -0
- bootstack/widgets/carousel.py +241 -0
- bootstack/widgets/chart.py +302 -0
- bootstack/widgets/codeeditor.py +675 -0
- bootstack/widgets/contextmenu.py +371 -0
- bootstack/widgets/datatable.py +688 -0
- bootstack/widgets/datefield.py +395 -0
- bootstack/widgets/divider.py +60 -0
- bootstack/widgets/expander.py +579 -0
- bootstack/widgets/form.py +200 -0
- bootstack/widgets/gallery.py +250 -0
- bootstack/widgets/gauge.py +168 -0
- bootstack/widgets/grid.py +121 -0
- bootstack/widgets/groupbox.py +162 -0
- bootstack/widgets/label.py +229 -0
- bootstack/widgets/listview.py +354 -0
- bootstack/widgets/menubutton.py +419 -0
- bootstack/widgets/numberfield.py +431 -0
- bootstack/widgets/pagestack.py +345 -0
- bootstack/widgets/passwordfield.py +383 -0
- bootstack/widgets/pathfield.py +454 -0
- bootstack/widgets/picture.py +251 -0
- bootstack/widgets/progressbar.py +106 -0
- bootstack/widgets/radio_variants.py +271 -0
- bootstack/widgets/radiogroup.py +228 -0
- bootstack/widgets/scrollbar.py +64 -0
- bootstack/widgets/scrollview.py +186 -0
- bootstack/widgets/select.py +302 -0
- bootstack/widgets/selectbutton.py +182 -0
- bootstack/widgets/sidebar_toggle.py +130 -0
- bootstack/widgets/sizegrip.py +42 -0
- bootstack/widgets/slider.py +408 -0
- bootstack/widgets/spinbox.py +147 -0
- bootstack/widgets/spinnerfield.py +413 -0
- bootstack/widgets/splash.py +367 -0
- bootstack/widgets/splitview.py +482 -0
- bootstack/widgets/stacks.py +249 -0
- bootstack/widgets/statusbar.py +189 -0
- bootstack/widgets/tabs.py +425 -0
- bootstack/widgets/textarea.py +459 -0
- bootstack/widgets/textfield.py +410 -0
- bootstack/widgets/theme_toggle.py +92 -0
- bootstack/widgets/timefield.py +340 -0
- bootstack/widgets/toast.py +314 -0
- bootstack/widgets/togglegroup.py +231 -0
- bootstack/widgets/toolbar.py +347 -0
- bootstack/widgets/tooltip.py +94 -0
- bootstack/widgets/tree.py +648 -0
- bootstack/widgets/types.py +305 -0
- bootstack/widgets/window.py +307 -0
- bootstack-0.1.0.dist-info/METADATA +301 -0
- bootstack-0.1.0.dist-info/RECORD +471 -0
- bootstack-0.1.0.dist-info/WHEEL +5 -0
- bootstack-0.1.0.dist-info/entry_points.txt +2 -0
- bootstack-0.1.0.dist-info/licenses/LICENSE +21 -0
- bootstack-0.1.0.dist-info/licenses/NOTICE +10 -0
- bootstack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"""SQLite-backed data source implementation with persistence, filtering, and pagination.
|
|
2
|
+
|
|
3
|
+
Provides a database-backed data manager that supports:
|
|
4
|
+
- Persistent storage using SQLite
|
|
5
|
+
- All features of MemoryDataSource (pagination, filtering, sorting, CRUD)
|
|
6
|
+
- Efficient handling of large datasets
|
|
7
|
+
- SQL-native filtering and sorting
|
|
8
|
+
- Automatic schema inference
|
|
9
|
+
- Data persistence across application restarts
|
|
10
|
+
|
|
11
|
+
The SqliteDataSource is ideal for:
|
|
12
|
+
- Large datasets that don't fit comfortably in memory
|
|
13
|
+
- Applications requiring data persistence
|
|
14
|
+
- Scenarios needing SQL query capabilities
|
|
15
|
+
- Multi-user or multi-session data sharing
|
|
16
|
+
|
|
17
|
+
For in-memory, lightweight scenarios, consider MemoryDataSource instead.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
.. code-block:: python
|
|
21
|
+
|
|
22
|
+
ds = SqliteDataSource("mydata.db", page_size=50)
|
|
23
|
+
ds.load([{"name": "Alice", "age": 30}])
|
|
24
|
+
ds.where(col("age") >= 25)
|
|
25
|
+
first = ds.page(0)
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import sqlite3
|
|
33
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union, Sequence
|
|
34
|
+
|
|
35
|
+
from bootstack.data.base import BaseDataSource
|
|
36
|
+
from bootstack.data.query import Condition, SortKey, normalize_sort_keys
|
|
37
|
+
from bootstack.data.types import Primitive
|
|
38
|
+
from bootstack.errors import DuplicateIdError, SerializationError
|
|
39
|
+
from bootstack.events import DataChangeEvent
|
|
40
|
+
|
|
41
|
+
# Internal column names — reserved by SqliteDataSource, never exposed to user data.
|
|
42
|
+
_ROW_ID = "_bs_row_id"
|
|
43
|
+
_ROW_SEL = "_bs_selected"
|
|
44
|
+
# Hidden JSON column carrying every non-scalar record field (the data bag). SQL
|
|
45
|
+
# columns are scalar-only, so lists/dicts/etc. ride here and are merged back into
|
|
46
|
+
# the record transparently on read. See `_split_for_write` / `_row_to_record`.
|
|
47
|
+
_ROW_DATA = "_bs_data"
|
|
48
|
+
_INTERNAL_COLUMNS = (_ROW_ID, _ROW_SEL, _ROW_DATA)
|
|
49
|
+
|
|
50
|
+
# Values storable directly in a SQL column. Everything else is bagged as JSON.
|
|
51
|
+
_SCALAR_TYPES = (str, bytes, bytearray, bool, int, float, type(None))
|
|
52
|
+
|
|
53
|
+
# Leading rows scanned by `load()` to infer a column's type. Scanning past a
|
|
54
|
+
# leading NULL keeps an otherwise-numeric column from being typed TEXT.
|
|
55
|
+
_SCHEMA_SAMPLE_SIZE = 1000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SqliteDataSource(BaseDataSource):
|
|
59
|
+
"""SQLite-backed data manager with pagination, filtering, sorting, and CRUD operations.
|
|
60
|
+
|
|
61
|
+
Provides persistent storage using SQLite database with automatic schema inference.
|
|
62
|
+
Filtering and sorting use the `col` expression API (rendered to parameterized
|
|
63
|
+
SQL internally). Supports all operations defined in DataSourceProtocol.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
name: Database file path or ":memory:" for in-memory database (default: ":memory:")
|
|
67
|
+
page_size: Number of records per page (default: 10)
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
conn: SQLite database connection
|
|
71
|
+
page_size: Current page size setting
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
.. code-block:: python
|
|
75
|
+
|
|
76
|
+
ds = SqliteDataSource("data.db", page_size=20)
|
|
77
|
+
ds.load([{"name": "Alice", "age": 30}])
|
|
78
|
+
ds.where(col("age") > 25)
|
|
79
|
+
first = ds.page(0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Note:
|
|
83
|
+
- The database connection persists for the lifetime of the object
|
|
84
|
+
- Close the connection explicitly with conn.close() if needed
|
|
85
|
+
- Schema is inferred from first record's data types
|
|
86
|
+
- An internal row-identity column (_bs_row_id) is added automatically as PRIMARY KEY
|
|
87
|
+
- An internal selection column (_bs_selected) is added automatically for selection tracking
|
|
88
|
+
- An internal JSON column (_bs_data) carries non-scalar record fields (the data bag)
|
|
89
|
+
- These internal columns are filtered out of the display column list automatically
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, name: str = ":memory:", page_size: int = 10, id_field: str = "id"):
|
|
93
|
+
"""Create SQLite datasource and set initial pagination state.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
name: Database file path or ':memory:' for an in-memory database.
|
|
97
|
+
page_size: Number of records returned per page during pagination.
|
|
98
|
+
id_field: Record field used as the stable row identity. When a record
|
|
99
|
+
carries this field, its value becomes the row id (so selection and
|
|
100
|
+
events round-trip your own ids); otherwise an id is auto-assigned.
|
|
101
|
+
"""
|
|
102
|
+
super().__init__(page_size)
|
|
103
|
+
self.conn = sqlite3.connect(name)
|
|
104
|
+
self.conn.row_factory = sqlite3.Row
|
|
105
|
+
self._table = "records"
|
|
106
|
+
self._id_field = id_field
|
|
107
|
+
self._filter: Optional[Condition] = None
|
|
108
|
+
self._sort: List[SortKey] = []
|
|
109
|
+
self._columns = []
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""Close the underlying SQLite connection. Idempotent.
|
|
113
|
+
|
|
114
|
+
Use a `with` block to close automatically::
|
|
115
|
+
|
|
116
|
+
with SqliteDataSource("app.db") as ds:
|
|
117
|
+
ds.load(rows)
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
self.conn.close()
|
|
121
|
+
except sqlite3.Error:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
# ---- internal query-fragment builders -------------------------------
|
|
125
|
+
|
|
126
|
+
def _where_clause(self) -> tuple[str, list]:
|
|
127
|
+
"""Return `(" WHERE ...", params)` for the active filter, or `("", [])`."""
|
|
128
|
+
if self._filter is None:
|
|
129
|
+
return "", []
|
|
130
|
+
clause, params = self._filter.to_sql()
|
|
131
|
+
return f" WHERE {clause}", params
|
|
132
|
+
|
|
133
|
+
def _order_clause(self) -> str:
|
|
134
|
+
"""Return `" ORDER BY ..."` for the active sort, or `""`."""
|
|
135
|
+
if not self._sort:
|
|
136
|
+
return ""
|
|
137
|
+
terms = [
|
|
138
|
+
f"{self._quote_identifier(k.column)} {'DESC' if k.descending else 'ASC'}"
|
|
139
|
+
for k in self._sort
|
|
140
|
+
]
|
|
141
|
+
return " ORDER BY " + ", ".join(terms)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _quote_identifier(name: str) -> str:
|
|
145
|
+
"""Safely quote an identifier (column/table) for SQLite."""
|
|
146
|
+
text = str(name).replace('"', '""')
|
|
147
|
+
return f'"{text}"'
|
|
148
|
+
|
|
149
|
+
# ---- data bag (hidden JSON column) ----------------------------------
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _is_scalar(value: Any) -> bool:
|
|
153
|
+
"""Whether a value can live directly in a SQL column (vs. the JSON bag)."""
|
|
154
|
+
return isinstance(value, _SCALAR_TYPES)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _resolve_column_type(cls, values: Iterable[Any]) -> str:
|
|
158
|
+
"""Infer a column's SQL type from a sample of its values.
|
|
159
|
+
|
|
160
|
+
Unlike inferring from a single value, this scans the sample and ignores
|
|
161
|
+
`None`, so a leading NULL no longer forces TEXT affinity on an otherwise-
|
|
162
|
+
numeric column. Mixed integer and real values resolve to REAL (numeric
|
|
163
|
+
promotion); any other mix of kinds falls back to TEXT.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
values: An iterable of a column's sampled values.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
One of `'INTEGER'`, `'REAL'`, `'BLOB'`, or `'TEXT'`.
|
|
170
|
+
"""
|
|
171
|
+
types: set[str] = set()
|
|
172
|
+
for value in values:
|
|
173
|
+
if value is None:
|
|
174
|
+
continue
|
|
175
|
+
types.add(cls._infer_type(value))
|
|
176
|
+
# A mix that is not purely numeric can only be stored faithfully as
|
|
177
|
+
# TEXT — stop early once we know that.
|
|
178
|
+
if len(types) > 1 and not types <= {"INTEGER", "REAL"}:
|
|
179
|
+
return "TEXT"
|
|
180
|
+
if not types:
|
|
181
|
+
return "TEXT"
|
|
182
|
+
if types == {"INTEGER"}:
|
|
183
|
+
return "INTEGER"
|
|
184
|
+
if types <= {"INTEGER", "REAL"}:
|
|
185
|
+
return "REAL"
|
|
186
|
+
return next(iter(types))
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _dumps_bag(bag: Dict[str, Any]) -> Optional[str]:
|
|
190
|
+
"""Serialize the non-scalar field bag to JSON (or None when empty)."""
|
|
191
|
+
if not bag:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
return json.dumps(bag)
|
|
195
|
+
except (TypeError, ValueError) as exc:
|
|
196
|
+
raise SerializationError(
|
|
197
|
+
f"A record field could not be stored in this SQLite-backed source: "
|
|
198
|
+
f"{exc}. Persistent sources carry non-scalar fields as JSON, so values "
|
|
199
|
+
f"must be JSON-serializable (scalars, lists, dicts). To carry arbitrary "
|
|
200
|
+
f"Python objects, use an in-memory source instead."
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
def _split_for_write(
|
|
204
|
+
self, record: Dict[str, Any], column_fields: Sequence[str], default_id: Any = None
|
|
205
|
+
) -> Tuple[Dict[str, Any], Dict[str, Any], Any]:
|
|
206
|
+
"""Split a record into (scalar column values, JSON bag, row id).
|
|
207
|
+
|
|
208
|
+
Scalar values for known column fields stay as real columns; every other
|
|
209
|
+
field — non-scalar values, or keys without a column — rides the JSON bag.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
record: The user record to write.
|
|
213
|
+
column_fields: Field names backed by real (scalar) columns.
|
|
214
|
+
default_id: Row id to use when the record carries none.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
A `(column_values, bag, row_id)` tuple.
|
|
218
|
+
"""
|
|
219
|
+
col_set = set(column_fields)
|
|
220
|
+
col_vals: Dict[str, Any] = {}
|
|
221
|
+
bag: Dict[str, Any] = {}
|
|
222
|
+
for key, value in record.items():
|
|
223
|
+
if key in _INTERNAL_COLUMNS:
|
|
224
|
+
continue
|
|
225
|
+
if key in col_set and self._is_scalar(value):
|
|
226
|
+
col_vals[key] = value
|
|
227
|
+
else:
|
|
228
|
+
bag[key] = value
|
|
229
|
+
|
|
230
|
+
if record.get(_ROW_ID) is not None:
|
|
231
|
+
row_id = record[_ROW_ID]
|
|
232
|
+
elif self._id_field in record and self._is_scalar(record[self._id_field]):
|
|
233
|
+
row_id = record[self._id_field]
|
|
234
|
+
else:
|
|
235
|
+
row_id = default_id
|
|
236
|
+
return col_vals, bag, row_id
|
|
237
|
+
|
|
238
|
+
def _row_to_record(self, row: Any) -> Dict[str, Any]:
|
|
239
|
+
"""Convert a raw SQL row to a record, merging the JSON bag back in.
|
|
240
|
+
|
|
241
|
+
Keeps the internal identity/selection columns (widgets read them); only
|
|
242
|
+
the JSON bag column is consumed — its keys are merged in where the row
|
|
243
|
+
has no scalar value, so the record reads back flat and complete.
|
|
244
|
+
"""
|
|
245
|
+
rec = dict(row)
|
|
246
|
+
blob = rec.pop(_ROW_DATA, None)
|
|
247
|
+
if blob:
|
|
248
|
+
try:
|
|
249
|
+
extra = json.loads(blob)
|
|
250
|
+
except (TypeError, ValueError):
|
|
251
|
+
extra = None
|
|
252
|
+
if isinstance(extra, dict):
|
|
253
|
+
for key, value in extra.items():
|
|
254
|
+
if rec.get(key) is None:
|
|
255
|
+
rec[key] = value
|
|
256
|
+
return rec
|
|
257
|
+
|
|
258
|
+
def _query(self, condition: Optional[Condition], sort_keys: List[SortKey]) -> List[Dict[str, Any]]:
|
|
259
|
+
"""Run a one-off filtered/sorted read without touching view state."""
|
|
260
|
+
where, params = "", []
|
|
261
|
+
if condition is not None:
|
|
262
|
+
clause, params = condition.to_sql()
|
|
263
|
+
where = f" WHERE {clause}"
|
|
264
|
+
order = ""
|
|
265
|
+
if sort_keys:
|
|
266
|
+
terms = [
|
|
267
|
+
f"{self._quote_identifier(k.column)} {'DESC' if k.descending else 'ASC'}"
|
|
268
|
+
for k in sort_keys
|
|
269
|
+
]
|
|
270
|
+
order = " ORDER BY " + ", ".join(terms)
|
|
271
|
+
try:
|
|
272
|
+
cursor = self.conn.execute(f"SELECT * FROM {self._table}{where}{order}", params)
|
|
273
|
+
return [self._row_to_record(row) for row in cursor.fetchall()]
|
|
274
|
+
except sqlite3.OperationalError:
|
|
275
|
+
# Table does not exist yet (no data loaded).
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
def load(
|
|
279
|
+
self,
|
|
280
|
+
records: "Iterable[Primitive] | Iterable[dict[str, Any]] | Iterable[Sequence[Any]]",
|
|
281
|
+
column_keys: Optional[Sequence[str]] = None,
|
|
282
|
+
*,
|
|
283
|
+
chunk_size: int = 10_000,
|
|
284
|
+
on_progress: Optional[Callable[[int], None]] = None,
|
|
285
|
+
) -> "SqliteDataSource":
|
|
286
|
+
"""Load records into the database, creating the table with an inferred schema.
|
|
287
|
+
|
|
288
|
+
Records are consumed as a stream and inserted in chunks within a single
|
|
289
|
+
transaction, so a lazy iterator of millions of rows loads with bounded
|
|
290
|
+
memory — only `chunk_size` rows are held at a time. The whole load is
|
|
291
|
+
atomic: if any row fails (for example a duplicate id), the table is rolled
|
|
292
|
+
back to its prior state.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
records: An iterable of dicts, primitives, or row sequences — a list,
|
|
296
|
+
a generator, or any other iterable.
|
|
297
|
+
column_keys: Optional column names when supplying row sequences
|
|
298
|
+
(lists/tuples).
|
|
299
|
+
chunk_size: Number of rows buffered per `executemany` batch.
|
|
300
|
+
on_progress: Optional callback invoked after each chunk with the
|
|
301
|
+
running count of rows inserted so far.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Self for method chaining
|
|
305
|
+
"""
|
|
306
|
+
it = iter(records)
|
|
307
|
+
try:
|
|
308
|
+
first = next(it)
|
|
309
|
+
except StopIteration:
|
|
310
|
+
# Empty input clears to an empty source (consistent with MemoryDataSource).
|
|
311
|
+
try:
|
|
312
|
+
self.conn.execute(f"DROP TABLE IF EXISTS {self._table}")
|
|
313
|
+
except sqlite3.Error:
|
|
314
|
+
pass
|
|
315
|
+
self._columns = []
|
|
316
|
+
self._hub.emit(DataChangeEvent(kind="load"))
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
# Apply fast, in-memory friendly pragmas when using ":memory:" to speed up bulk loads
|
|
320
|
+
try:
|
|
321
|
+
if self.conn.execute("PRAGMA database_list").fetchone()[2] == ":memory:":
|
|
322
|
+
self.conn.execute("PRAGMA synchronous = OFF")
|
|
323
|
+
self.conn.execute("PRAGMA journal_mode = MEMORY")
|
|
324
|
+
self.conn.execute("PRAGMA temp_store = MEMORY")
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
# Build a per-item normalizer to a dict from the first item's shape (the
|
|
329
|
+
# input is assumed homogeneous). We never mutate the caller's records.
|
|
330
|
+
if isinstance(first, dict):
|
|
331
|
+
def to_dict(item: Any) -> Dict[str, Any]:
|
|
332
|
+
return dict(item)
|
|
333
|
+
elif isinstance(first, (list, tuple)):
|
|
334
|
+
keys = list(column_keys or [str(i) for i in range(len(first))])
|
|
335
|
+
|
|
336
|
+
def to_dict(item: Any) -> Dict[str, Any]:
|
|
337
|
+
values = list(item)
|
|
338
|
+
if len(values) < len(keys):
|
|
339
|
+
values += [None] * (len(keys) - len(values))
|
|
340
|
+
return dict(zip(keys, values))
|
|
341
|
+
else:
|
|
342
|
+
def to_dict(item: Any) -> Dict[str, Any]:
|
|
343
|
+
return {"text": str(item)}
|
|
344
|
+
|
|
345
|
+
# Real (scalar) columns come from the first record; every other field —
|
|
346
|
+
# non-scalar values, and keys absent from the first record — rides the
|
|
347
|
+
# hidden JSON data column instead.
|
|
348
|
+
first_rec = to_dict(first)
|
|
349
|
+
column_fields = [
|
|
350
|
+
k for k, v in first_rec.items()
|
|
351
|
+
if k not in _INTERNAL_COLUMNS and self._is_scalar(v)
|
|
352
|
+
]
|
|
353
|
+
self._columns = column_fields + list(_INTERNAL_COLUMNS)
|
|
354
|
+
|
|
355
|
+
# Sample the leading rows to infer column types. Scanning past a leading
|
|
356
|
+
# NULL keeps an otherwise-numeric column from being typed TEXT (which
|
|
357
|
+
# would store its ints as strings). The sampled rows are buffered so they
|
|
358
|
+
# are still inserted below; memory stays bounded by the sample size.
|
|
359
|
+
sample: List[Dict[str, Any]] = [first_rec]
|
|
360
|
+
sample_cap = max(1, min(int(chunk_size), _SCHEMA_SAMPLE_SIZE))
|
|
361
|
+
for raw in it:
|
|
362
|
+
sample.append(to_dict(raw))
|
|
363
|
+
if len(sample) >= sample_cap:
|
|
364
|
+
break
|
|
365
|
+
|
|
366
|
+
col_types = {
|
|
367
|
+
col: self._resolve_column_type(rec.get(col) for rec in sample)
|
|
368
|
+
for col in column_fields
|
|
369
|
+
}
|
|
370
|
+
if self._id_field in column_fields:
|
|
371
|
+
# Type the identity from the adopted id values (e.g. TEXT for UUIDs).
|
|
372
|
+
col_types[_ROW_ID] = self._resolve_column_type(
|
|
373
|
+
rec.get(self._id_field) for rec in sample
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
col_types[_ROW_ID] = "INTEGER"
|
|
377
|
+
col_types[_ROW_SEL] = "INTEGER"
|
|
378
|
+
col_types[_ROW_DATA] = "TEXT"
|
|
379
|
+
|
|
380
|
+
col_definitions = ", ".join(
|
|
381
|
+
f"{self._quote_identifier(col)} {col_types[col]}" + (" PRIMARY KEY" if col == _ROW_ID else "")
|
|
382
|
+
for col in self._columns
|
|
383
|
+
)
|
|
384
|
+
placeholders = ", ".join("?" for _ in self._columns)
|
|
385
|
+
insert_sql = f"INSERT INTO {self._table} VALUES ({placeholders})"
|
|
386
|
+
|
|
387
|
+
def row_for(record: Dict[str, Any], index: int) -> tuple:
|
|
388
|
+
col_vals, bag, row_id = self._split_for_write(record, column_fields, default_id=index)
|
|
389
|
+
values = [col_vals.get(col) for col in column_fields]
|
|
390
|
+
values.append(row_id)
|
|
391
|
+
values.append(0)
|
|
392
|
+
values.append(self._dumps_bag(bag))
|
|
393
|
+
return tuple(values)
|
|
394
|
+
|
|
395
|
+
# Recreate the table and stream-insert in chunks within one transaction:
|
|
396
|
+
# bounded Python memory (one chunk at a time) and atomic (rolls back on
|
|
397
|
+
# any error). SQLite buffers the pending rows itself, not Python. We drive
|
|
398
|
+
# the transaction explicitly (autocommit off) so the DROP/CREATE are part
|
|
399
|
+
# of it too — Python's sqlite3 otherwise auto-commits DDL, which would
|
|
400
|
+
# leave the table dropped if a later row failed.
|
|
401
|
+
original_row_factory = self.conn.row_factory
|
|
402
|
+
original_isolation = self.conn.isolation_level
|
|
403
|
+
inserted = 0
|
|
404
|
+
try:
|
|
405
|
+
self.conn.row_factory = None # avoid row wrapping overhead during inserts
|
|
406
|
+
self.conn.isolation_level = None # manual transaction control
|
|
407
|
+
self.conn.execute("BEGIN")
|
|
408
|
+
self.conn.execute(f"DROP TABLE IF EXISTS {self._table}")
|
|
409
|
+
self.conn.execute(f"CREATE TABLE {self._table} ({col_definitions})")
|
|
410
|
+
|
|
411
|
+
buffer: List[tuple] = []
|
|
412
|
+
|
|
413
|
+
def flush() -> None:
|
|
414
|
+
nonlocal inserted, buffer
|
|
415
|
+
if not buffer:
|
|
416
|
+
return
|
|
417
|
+
self.conn.executemany(insert_sql, buffer)
|
|
418
|
+
inserted += len(buffer)
|
|
419
|
+
buffer = []
|
|
420
|
+
if on_progress is not None:
|
|
421
|
+
on_progress(inserted)
|
|
422
|
+
|
|
423
|
+
# Insert the buffered sample first, then the rest of the stream. A
|
|
424
|
+
# single running index gives rows without an explicit id their
|
|
425
|
+
# default 0, 1, 2, ... identity.
|
|
426
|
+
index = 0
|
|
427
|
+
for rec in sample:
|
|
428
|
+
buffer.append(row_for(rec, index))
|
|
429
|
+
index += 1
|
|
430
|
+
if len(buffer) >= chunk_size:
|
|
431
|
+
flush()
|
|
432
|
+
for raw in it:
|
|
433
|
+
buffer.append(row_for(to_dict(raw), index))
|
|
434
|
+
index += 1
|
|
435
|
+
if len(buffer) >= chunk_size:
|
|
436
|
+
flush()
|
|
437
|
+
flush()
|
|
438
|
+
self.conn.execute("COMMIT")
|
|
439
|
+
except sqlite3.IntegrityError as exc:
|
|
440
|
+
self._safe_rollback()
|
|
441
|
+
raise DuplicateIdError(
|
|
442
|
+
f"Duplicate value in id field {self._id_field!r} — record ids must be "
|
|
443
|
+
f"unique. ({exc})"
|
|
444
|
+
) from exc
|
|
445
|
+
except BaseException:
|
|
446
|
+
# Any other failure (serialization error, a raising progress callback,
|
|
447
|
+
# cancellation) must roll the whole load back too.
|
|
448
|
+
self._safe_rollback()
|
|
449
|
+
raise
|
|
450
|
+
finally:
|
|
451
|
+
self.conn.row_factory = original_row_factory
|
|
452
|
+
self.conn.isolation_level = original_isolation
|
|
453
|
+
self._hub.emit(DataChangeEvent(kind="load"))
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
def _safe_rollback(self) -> None:
|
|
457
|
+
"""Roll back the active transaction, swallowing any rollback error."""
|
|
458
|
+
try:
|
|
459
|
+
self.conn.execute("ROLLBACK")
|
|
460
|
+
except sqlite3.Error:
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
def _table_exists(self) -> bool:
|
|
464
|
+
"""Whether the records table exists yet (False before any data is loaded)."""
|
|
465
|
+
try:
|
|
466
|
+
self.conn.execute(f"SELECT 1 FROM {self._table} LIMIT 1")
|
|
467
|
+
return True
|
|
468
|
+
except sqlite3.OperationalError:
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
def where(self, condition: Optional[Condition] = None) -> "SqliteDataSource":
|
|
472
|
+
"""Filter rows by a condition built with `col` (None clears the filter).
|
|
473
|
+
|
|
474
|
+
The condition is rendered to a parameterized query — values are always
|
|
475
|
+
bound, never interpolated — so user input cannot inject SQL.
|
|
476
|
+
"""
|
|
477
|
+
self._filter = condition
|
|
478
|
+
self._hub.emit(DataChangeEvent(kind="filter"))
|
|
479
|
+
return self
|
|
480
|
+
|
|
481
|
+
def order(self, *keys) -> "SqliteDataSource":
|
|
482
|
+
"""Sort rows by one or more keys (no arguments clears sorting)."""
|
|
483
|
+
self._sort = normalize_sort_keys(keys)
|
|
484
|
+
self._hub.emit(DataChangeEvent(kind="sort"))
|
|
485
|
+
return self
|
|
486
|
+
|
|
487
|
+
def page(self, page: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
488
|
+
"""Get records for specified page."""
|
|
489
|
+
if page is not None:
|
|
490
|
+
self._page = page
|
|
491
|
+
if not self._table_exists():
|
|
492
|
+
return []
|
|
493
|
+
offset = self._page * self.page_size
|
|
494
|
+
|
|
495
|
+
where, params = self._where_clause()
|
|
496
|
+
query = (
|
|
497
|
+
f"SELECT * FROM {self._table}{where}{self._order_clause()}"
|
|
498
|
+
f" LIMIT {self.page_size} OFFSET {offset}"
|
|
499
|
+
)
|
|
500
|
+
cursor = self.conn.execute(query, params)
|
|
501
|
+
return [self._row_to_record(row) for row in cursor.fetchall()]
|
|
502
|
+
|
|
503
|
+
def next_page(self) -> List[Dict[str, Any]]:
|
|
504
|
+
"""Advance to next page and return its records."""
|
|
505
|
+
self._page += 1
|
|
506
|
+
return self.page()
|
|
507
|
+
|
|
508
|
+
def prev_page(self) -> List[Dict[str, Any]]:
|
|
509
|
+
"""Move to previous page and return its records."""
|
|
510
|
+
self._page = max(0, self._page - 1)
|
|
511
|
+
return self.page()
|
|
512
|
+
|
|
513
|
+
def has_next_page(self) -> bool:
|
|
514
|
+
"""Check if more pages exist after current page."""
|
|
515
|
+
return (self._page + 1) * self.page_size < self.count
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def count(self) -> int:
|
|
519
|
+
"""Total number of records matching the current filter."""
|
|
520
|
+
if not self._table_exists():
|
|
521
|
+
return 0
|
|
522
|
+
where, params = self._where_clause()
|
|
523
|
+
query = f"SELECT COUNT(*) FROM {self._table}{where}"
|
|
524
|
+
return self.conn.execute(query, params).fetchone()[0]
|
|
525
|
+
|
|
526
|
+
# === CRUD OPERATIONS ===
|
|
527
|
+
|
|
528
|
+
def insert(self, record: Dict[str, Any]) -> int:
|
|
529
|
+
"""Create new record and return its ID."""
|
|
530
|
+
# Field names backed by real columns: the established schema, or — for a
|
|
531
|
+
# still-empty source — this record's own scalar fields.
|
|
532
|
+
if self._user_column_fields:
|
|
533
|
+
column_fields = self._user_column_fields
|
|
534
|
+
else:
|
|
535
|
+
column_fields = [
|
|
536
|
+
k for k, v in record.items()
|
|
537
|
+
if k not in _INTERNAL_COLUMNS and self._is_scalar(v)
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
col_vals, bag, row_id = self._split_for_write(record, column_fields)
|
|
541
|
+
if row_id is None:
|
|
542
|
+
row_id = self._generate_new_id()
|
|
543
|
+
|
|
544
|
+
# Ensure table exists (handles empty datasources)
|
|
545
|
+
self._ensure_table(column_fields, sample=record)
|
|
546
|
+
|
|
547
|
+
keys = list(col_vals.keys()) + list(_INTERNAL_COLUMNS)
|
|
548
|
+
values = [col_vals[k] for k in col_vals] + [row_id, 0, self._dumps_bag(bag)]
|
|
549
|
+
cols = ", ".join(self._quote_identifier(k) for k in keys)
|
|
550
|
+
placeholders = ", ".join("?" for _ in keys)
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
with self.conn:
|
|
554
|
+
self.conn.execute(
|
|
555
|
+
f"INSERT INTO {self._table} ({cols}) VALUES ({placeholders})", tuple(values)
|
|
556
|
+
)
|
|
557
|
+
except sqlite3.IntegrityError as exc:
|
|
558
|
+
raise DuplicateIdError(
|
|
559
|
+
f"A record with id {row_id!r} already exists — record ids "
|
|
560
|
+
f"must be unique. ({exc})"
|
|
561
|
+
) from exc
|
|
562
|
+
self._hub.emit(DataChangeEvent(kind="insert", id=row_id, record=dict(record)))
|
|
563
|
+
return row_id
|
|
564
|
+
|
|
565
|
+
def get(self, record_id: Any) -> Optional[Dict[str, Any]]:
|
|
566
|
+
"""Retrieve single record by ID."""
|
|
567
|
+
if not self._table_exists():
|
|
568
|
+
return None
|
|
569
|
+
cursor = self.conn.execute(f"SELECT * FROM {self._table} WHERE {_ROW_ID} = ?", (record_id,))
|
|
570
|
+
row = cursor.fetchone()
|
|
571
|
+
return self._row_to_record(row) if row else None
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def _user_column_fields(self) -> List[str]:
|
|
575
|
+
"""The real (scalar) column field names, excluding internal columns."""
|
|
576
|
+
return [c for c in self._columns if c not in _INTERNAL_COLUMNS]
|
|
577
|
+
|
|
578
|
+
# ---------- row identity ----------
|
|
579
|
+
def _internal_fields(self) -> "frozenset[str]":
|
|
580
|
+
"""Hide the internal identity, selection, and data-bag columns from users."""
|
|
581
|
+
return frozenset(_INTERNAL_COLUMNS)
|
|
582
|
+
|
|
583
|
+
def _record_id(self, record: Any) -> Any:
|
|
584
|
+
"""Identity is the internal `_bs_row_id` column."""
|
|
585
|
+
if not record:
|
|
586
|
+
return None
|
|
587
|
+
return record.get(_ROW_ID)
|
|
588
|
+
|
|
589
|
+
def update(self, record_id: Any, updates: Dict[str, Any]) -> bool:
|
|
590
|
+
"""Update record fields by ID."""
|
|
591
|
+
if not updates or not self._table_exists():
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
col_set = set(self._user_column_fields)
|
|
595
|
+
set_parts: List[str] = []
|
|
596
|
+
params: List[Any] = []
|
|
597
|
+
bag_updates: Dict[str, Any] = {}
|
|
598
|
+
for key, value in updates.items():
|
|
599
|
+
if key in _INTERNAL_COLUMNS:
|
|
600
|
+
continue
|
|
601
|
+
if key in col_set and self._is_scalar(value):
|
|
602
|
+
set_parts.append(f"{self._quote_identifier(key)} = ?")
|
|
603
|
+
params.append(value)
|
|
604
|
+
else:
|
|
605
|
+
# A non-scalar value (or a field without a column) rides the bag.
|
|
606
|
+
# If the field also has a real column, null it so the bag wins on read.
|
|
607
|
+
if key in col_set:
|
|
608
|
+
set_parts.append(f"{self._quote_identifier(key)} = NULL")
|
|
609
|
+
bag_updates[key] = value
|
|
610
|
+
|
|
611
|
+
if bag_updates:
|
|
612
|
+
self._ensure_data_column()
|
|
613
|
+
row = self.conn.execute(
|
|
614
|
+
f"SELECT {_ROW_DATA} FROM {self._table} WHERE {_ROW_ID} = ?", (record_id,)
|
|
615
|
+
).fetchone()
|
|
616
|
+
if row is None:
|
|
617
|
+
return False
|
|
618
|
+
existing: Dict[str, Any] = {}
|
|
619
|
+
blob = row[0]
|
|
620
|
+
if blob:
|
|
621
|
+
try:
|
|
622
|
+
parsed = json.loads(blob)
|
|
623
|
+
if isinstance(parsed, dict):
|
|
624
|
+
existing = parsed
|
|
625
|
+
except (TypeError, ValueError):
|
|
626
|
+
pass
|
|
627
|
+
existing.update(bag_updates)
|
|
628
|
+
set_parts.append(f"{self._quote_identifier(_ROW_DATA)} = ?")
|
|
629
|
+
params.append(self._dumps_bag(existing))
|
|
630
|
+
|
|
631
|
+
if not set_parts:
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
params.append(record_id)
|
|
635
|
+
with self.conn:
|
|
636
|
+
cur = self.conn.execute(
|
|
637
|
+
f"UPDATE {self._table} SET {', '.join(set_parts)} WHERE {_ROW_ID} = ?", params
|
|
638
|
+
)
|
|
639
|
+
changed = cur.rowcount > 0
|
|
640
|
+
if changed:
|
|
641
|
+
self._hub.emit(DataChangeEvent(kind="update", id=record_id, record=dict(updates)))
|
|
642
|
+
return changed
|
|
643
|
+
|
|
644
|
+
def delete(self, record_id: Any) -> bool:
|
|
645
|
+
"""Delete record by ID."""
|
|
646
|
+
if not self._table_exists():
|
|
647
|
+
return False
|
|
648
|
+
with self.conn:
|
|
649
|
+
cur = self.conn.execute(f"DELETE FROM {self._table} WHERE {_ROW_ID} = ?", (record_id,))
|
|
650
|
+
changed = cur.rowcount > 0
|
|
651
|
+
if changed:
|
|
652
|
+
self._hub.emit(DataChangeEvent(kind="delete", id=record_id))
|
|
653
|
+
return changed
|
|
654
|
+
|
|
655
|
+
def _generate_new_id(self) -> int:
|
|
656
|
+
"""Generate next available integer ID."""
|
|
657
|
+
try:
|
|
658
|
+
cursor = self.conn.execute(f"SELECT MAX({_ROW_ID}) FROM {self._table}")
|
|
659
|
+
max_id = cursor.fetchone()[0]
|
|
660
|
+
except Exception:
|
|
661
|
+
max_id = 0
|
|
662
|
+
if max_id is None:
|
|
663
|
+
return 1
|
|
664
|
+
if not isinstance(max_id, int):
|
|
665
|
+
raise DuplicateIdError(
|
|
666
|
+
f"Cannot auto-assign an id: existing ids in field {self._id_field!r} are "
|
|
667
|
+
f"non-integer (e.g. {max_id!r}). Provide an explicit {self._id_field!r} "
|
|
668
|
+
f"on every inserted record when using custom ids."
|
|
669
|
+
)
|
|
670
|
+
return max_id + 1
|
|
671
|
+
|
|
672
|
+
# ------------------------------------------------------------------ helpers
|
|
673
|
+
def _ensure_table(self, column_fields: Sequence[str], sample: Dict[str, Any]) -> None:
|
|
674
|
+
"""Create the table if it does not yet exist, inferring scalar columns.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
column_fields: Scalar field names that become real columns.
|
|
678
|
+
sample: A representative record used for column-type inference.
|
|
679
|
+
"""
|
|
680
|
+
try:
|
|
681
|
+
# Quick existence check
|
|
682
|
+
self.conn.execute(f"SELECT 1 FROM {self._table} LIMIT 1")
|
|
683
|
+
return
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
|
|
687
|
+
cols = list(column_fields) + list(_INTERNAL_COLUMNS)
|
|
688
|
+
self._columns = cols
|
|
689
|
+
|
|
690
|
+
col_types: Dict[str, str] = {}
|
|
691
|
+
for c in cols:
|
|
692
|
+
if c == _ROW_SEL:
|
|
693
|
+
col_types[c] = "INTEGER"
|
|
694
|
+
elif c == _ROW_DATA:
|
|
695
|
+
col_types[c] = "TEXT"
|
|
696
|
+
elif c == _ROW_ID:
|
|
697
|
+
if self._id_field in sample and self._is_scalar(sample[self._id_field]):
|
|
698
|
+
col_types[c] = self._infer_type(sample[self._id_field])
|
|
699
|
+
else:
|
|
700
|
+
col_types[c] = "INTEGER"
|
|
701
|
+
else:
|
|
702
|
+
col_types[c] = self._infer_type(sample.get(c))
|
|
703
|
+
|
|
704
|
+
col_definitions = ", ".join(
|
|
705
|
+
f"{self._quote_identifier(col)} {col_types[col]}" + (" PRIMARY KEY" if col == _ROW_ID else "")
|
|
706
|
+
for col in cols
|
|
707
|
+
)
|
|
708
|
+
with self.conn:
|
|
709
|
+
self.conn.execute(f"CREATE TABLE IF NOT EXISTS {self._table} ({col_definitions})")
|
|
710
|
+
|
|
711
|
+
def _ensure_data_column(self) -> None:
|
|
712
|
+
"""Add the hidden JSON data column if it is missing (legacy tables)."""
|
|
713
|
+
if _ROW_DATA in self._columns:
|
|
714
|
+
return
|
|
715
|
+
try:
|
|
716
|
+
with self.conn:
|
|
717
|
+
self.conn.execute(f"ALTER TABLE {self._table} ADD COLUMN {_ROW_DATA} TEXT")
|
|
718
|
+
except sqlite3.OperationalError:
|
|
719
|
+
pass # no table yet, or column already present
|
|
720
|
+
if _ROW_DATA not in self._columns:
|
|
721
|
+
self._columns.append(_ROW_DATA)
|
|
722
|
+
|
|
723
|
+
# === SELECTION ====
|
|
724
|
+
|
|
725
|
+
def is_selected(self, record_id: Any) -> bool:
|
|
726
|
+
"""Check whether a record is currently selected."""
|
|
727
|
+
if not self._table_exists() or _ROW_SEL not in self._columns:
|
|
728
|
+
return False
|
|
729
|
+
row = self.conn.execute(
|
|
730
|
+
f"SELECT {_ROW_SEL} FROM {self._table} WHERE {_ROW_ID} = ?",
|
|
731
|
+
(record_id,),
|
|
732
|
+
).fetchone()
|
|
733
|
+
if row is None:
|
|
734
|
+
return False
|
|
735
|
+
return bool(row[0])
|
|
736
|
+
|
|
737
|
+
def select(self, record_id: Any) -> bool:
|
|
738
|
+
"""Mark record as selected."""
|
|
739
|
+
changed = self._set_selected_flag(record_id, 1)
|
|
740
|
+
if changed:
|
|
741
|
+
self._hub.emit(DataChangeEvent(kind="select", id=record_id))
|
|
742
|
+
return changed
|
|
743
|
+
|
|
744
|
+
def deselect(self, record_id: Any) -> bool:
|
|
745
|
+
"""Mark record as unselected."""
|
|
746
|
+
changed = self._set_selected_flag(record_id, 0)
|
|
747
|
+
if changed:
|
|
748
|
+
self._hub.emit(DataChangeEvent(kind="select", id=record_id))
|
|
749
|
+
return changed
|
|
750
|
+
|
|
751
|
+
def select_all(self, current_page_only: bool = False) -> int:
|
|
752
|
+
"""Select all records (optionally only current page)."""
|
|
753
|
+
if not self._table_exists():
|
|
754
|
+
return 0
|
|
755
|
+
self._ensure_selected_column()
|
|
756
|
+
if current_page_only:
|
|
757
|
+
ids = [row[_ROW_ID] for row in self.page()]
|
|
758
|
+
if not ids:
|
|
759
|
+
return 0
|
|
760
|
+
placeholders = ", ".join("?" for _ in ids)
|
|
761
|
+
query = f"UPDATE {self._table} SET {_ROW_SEL} = 1 WHERE {_ROW_ID} IN ({placeholders})"
|
|
762
|
+
with self.conn:
|
|
763
|
+
cur = self.conn.execute(query, ids)
|
|
764
|
+
count = cur.rowcount
|
|
765
|
+
else:
|
|
766
|
+
with self.conn:
|
|
767
|
+
cur = self.conn.execute(f"UPDATE {self._table} SET {_ROW_SEL} = 1")
|
|
768
|
+
count = cur.rowcount
|
|
769
|
+
if count:
|
|
770
|
+
self._hub.emit(DataChangeEvent(kind="select"))
|
|
771
|
+
return count
|
|
772
|
+
|
|
773
|
+
def deselect_all(self, current_page_only: bool = False) -> int:
|
|
774
|
+
"""Deselect all records (optionally only current page)."""
|
|
775
|
+
if not self._table_exists():
|
|
776
|
+
return 0
|
|
777
|
+
self._ensure_selected_column()
|
|
778
|
+
if current_page_only:
|
|
779
|
+
ids = [row[_ROW_ID] for row in self.page()]
|
|
780
|
+
if not ids:
|
|
781
|
+
return 0
|
|
782
|
+
placeholders = ", ".join("?" for _ in ids)
|
|
783
|
+
query = f"UPDATE {self._table} SET {_ROW_SEL} = 0 WHERE {_ROW_ID} IN ({placeholders})"
|
|
784
|
+
with self.conn:
|
|
785
|
+
cur = self.conn.execute(query, ids)
|
|
786
|
+
count = cur.rowcount
|
|
787
|
+
else:
|
|
788
|
+
with self.conn:
|
|
789
|
+
cur = self.conn.execute(f"UPDATE {self._table} SET {_ROW_SEL} = 0")
|
|
790
|
+
count = cur.rowcount
|
|
791
|
+
if count:
|
|
792
|
+
self._hub.emit(DataChangeEvent(kind="select"))
|
|
793
|
+
return count
|
|
794
|
+
|
|
795
|
+
def selected(self, page: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
796
|
+
"""Get selected records, optionally paginated."""
|
|
797
|
+
if not self._table_exists():
|
|
798
|
+
return []
|
|
799
|
+
self._ensure_selected_column()
|
|
800
|
+
query = f"SELECT * FROM {self._table} WHERE {_ROW_SEL} = 1"
|
|
801
|
+
|
|
802
|
+
if page is not None:
|
|
803
|
+
offset = page * self.page_size
|
|
804
|
+
query += f" LIMIT {self.page_size} OFFSET {offset}"
|
|
805
|
+
|
|
806
|
+
cursor = self.conn.execute(query)
|
|
807
|
+
return [self._row_to_record(row) for row in cursor.fetchall()]
|
|
808
|
+
|
|
809
|
+
def _ensure_selected_column(self):
|
|
810
|
+
"""Add selection column to table if it doesn't exist."""
|
|
811
|
+
if _ROW_SEL not in self._columns:
|
|
812
|
+
with self.conn:
|
|
813
|
+
self.conn.execute(f"ALTER TABLE {self._table} ADD COLUMN {_ROW_SEL} INTEGER DEFAULT 0")
|
|
814
|
+
self._columns.append(_ROW_SEL)
|
|
815
|
+
|
|
816
|
+
@property
|
|
817
|
+
def selected_count(self) -> int:
|
|
818
|
+
"""Number of selected records."""
|
|
819
|
+
if not self._table_exists():
|
|
820
|
+
return 0
|
|
821
|
+
self._ensure_selected_column()
|
|
822
|
+
query = f"SELECT COUNT(*) FROM {self._table} WHERE {_ROW_SEL} = 1"
|
|
823
|
+
return self.conn.execute(query).fetchone()[0]
|
|
824
|
+
|
|
825
|
+
def _set_selected_flag(self, record_id: Any, flag: int) -> bool:
|
|
826
|
+
"""Set selection flag for record by ID."""
|
|
827
|
+
if not self._table_exists():
|
|
828
|
+
return False
|
|
829
|
+
if _ROW_SEL not in self._columns:
|
|
830
|
+
with self.conn:
|
|
831
|
+
self.conn.execute(f"ALTER TABLE {self._table} ADD COLUMN {_ROW_SEL} INTEGER DEFAULT 0")
|
|
832
|
+
self._columns.append(_ROW_SEL)
|
|
833
|
+
|
|
834
|
+
with self.conn:
|
|
835
|
+
cur = self.conn.execute(
|
|
836
|
+
f"UPDATE {self._table} SET {_ROW_SEL} = ? WHERE {_ROW_ID} = ?",
|
|
837
|
+
(flag, record_id),
|
|
838
|
+
)
|
|
839
|
+
return cur.rowcount > 0
|
|
840
|
+
|
|
841
|
+
def page_slice(self, start_index: int, count: int) -> List[Dict[str, Any]]:
|
|
842
|
+
"""Get records by start index and count (respects filter/sort)."""
|
|
843
|
+
if not self._table_exists():
|
|
844
|
+
return []
|
|
845
|
+
where, params = self._where_clause()
|
|
846
|
+
query = (
|
|
847
|
+
f"SELECT * FROM {self._table}{where}{self._order_clause()}"
|
|
848
|
+
f" LIMIT {count} OFFSET {start_index}"
|
|
849
|
+
)
|
|
850
|
+
cursor = self.conn.execute(query, params)
|
|
851
|
+
return [self._row_to_record(row) for row in cursor.fetchall()]
|
|
852
|
+
|
|
853
|
+
def get_distinct_values(self, column: str, limit: int = 1000) -> List[Any]:
|
|
854
|
+
"""Get distinct values for a column.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
column: Column name to get distinct values from.
|
|
858
|
+
limit: Maximum number of distinct values to return.
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
List of distinct values sorted alphabetically.
|
|
862
|
+
"""
|
|
863
|
+
if not self._table_exists():
|
|
864
|
+
return []
|
|
865
|
+
quoted_col = self._quote_identifier(column)
|
|
866
|
+
query = f"SELECT DISTINCT {quoted_col} FROM {self._table}"
|
|
867
|
+
query += f" ORDER BY {quoted_col} LIMIT {limit}"
|
|
868
|
+
cursor = self.conn.execute(query)
|
|
869
|
+
return [row[0] for row in cursor.fetchall()]
|