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
bootstack/data/query.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Expressive, SQL-free filtering and sorting for data sources.
|
|
2
|
+
|
|
3
|
+
Build filter conditions with `col` and combine them with the usual operators::
|
|
4
|
+
|
|
5
|
+
from bootstack.data import col
|
|
6
|
+
|
|
7
|
+
ds.where(col("age") >= 25)
|
|
8
|
+
ds.where((col("status") == "active") & col("name").contains("ada"))
|
|
9
|
+
ds.where(col("department").is_in(["Sales", "Engineering"]))
|
|
10
|
+
ds.order("-salary", "name") # '-' prefix sorts descending
|
|
11
|
+
|
|
12
|
+
A condition is backend-agnostic: in-memory sources evaluate it with
|
|
13
|
+
`matches()`, while SQLite renders it to a parameterized query with `to_sql()`
|
|
14
|
+
(values are always bound, never interpolated, so user input can't inject SQL).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, List, Mapping, Sequence, Tuple, Union
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"col",
|
|
23
|
+
"Column",
|
|
24
|
+
"Condition",
|
|
25
|
+
"SortKey",
|
|
26
|
+
"any_of",
|
|
27
|
+
"all_of",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# SQL rendering helpers
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
_SQL_OPS = {"eq": "=", "ne": "!=", "lt": "<", "le": "<=", "gt": ">", "ge": ">="}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _quote(identifier: str) -> str:
|
|
39
|
+
"""Quote a column identifier for safe use in SQL."""
|
|
40
|
+
return '"' + str(identifier).replace('"', '""') + '"'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _escape_like(value: str) -> str:
|
|
44
|
+
"""Escape LIKE wildcards so they match literally (paired with ESCAPE '\\')."""
|
|
45
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SqlFragment = Tuple[str, List[Any]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Conditions
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
class Condition:
|
|
56
|
+
"""A filter expression. Combine with `&` (and), `|` (or), and `~` (not)."""
|
|
57
|
+
|
|
58
|
+
def __and__(self, other: "Condition") -> "Condition":
|
|
59
|
+
return And([self, other])
|
|
60
|
+
|
|
61
|
+
def __or__(self, other: "Condition") -> "Condition":
|
|
62
|
+
return Or([self, other])
|
|
63
|
+
|
|
64
|
+
def __invert__(self) -> "Condition":
|
|
65
|
+
return Not(self)
|
|
66
|
+
|
|
67
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
68
|
+
"""Return whether `row` satisfies this condition (in-memory evaluation)."""
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
def to_sql(self) -> SqlFragment:
|
|
72
|
+
"""Render to a parameterized SQL fragment: `(clause, params)`."""
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Compare(Condition):
|
|
78
|
+
"""A value comparison: `column <op> value`."""
|
|
79
|
+
|
|
80
|
+
column: str
|
|
81
|
+
op: str # eq, ne, lt, le, gt, ge
|
|
82
|
+
value: Any
|
|
83
|
+
|
|
84
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
85
|
+
actual = row.get(self.column)
|
|
86
|
+
if self.op == "eq":
|
|
87
|
+
return actual == self.value
|
|
88
|
+
if self.op == "ne":
|
|
89
|
+
return actual != self.value
|
|
90
|
+
if actual is None or self.value is None:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
if self.op == "lt":
|
|
94
|
+
return actual < self.value
|
|
95
|
+
if self.op == "le":
|
|
96
|
+
return actual <= self.value
|
|
97
|
+
if self.op == "gt":
|
|
98
|
+
return actual > self.value
|
|
99
|
+
if self.op == "ge":
|
|
100
|
+
return actual >= self.value
|
|
101
|
+
except TypeError:
|
|
102
|
+
return False
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def to_sql(self) -> SqlFragment:
|
|
106
|
+
q = _quote(self.column)
|
|
107
|
+
if self.value is None and self.op in ("eq", "ne"):
|
|
108
|
+
return (f"{q} IS NULL" if self.op == "eq" else f"{q} IS NOT NULL", [])
|
|
109
|
+
return (f"{q} {_SQL_OPS[self.op]} ?", [self.value])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Match(Condition):
|
|
114
|
+
"""A case-insensitive text match: contains / startswith / endswith."""
|
|
115
|
+
|
|
116
|
+
column: str
|
|
117
|
+
kind: str # contains, startswith, endswith
|
|
118
|
+
value: str
|
|
119
|
+
|
|
120
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
121
|
+
actual = row.get(self.column)
|
|
122
|
+
if actual is None:
|
|
123
|
+
return False
|
|
124
|
+
haystack = str(actual).lower()
|
|
125
|
+
needle = str(self.value).lower()
|
|
126
|
+
if self.kind == "contains":
|
|
127
|
+
return needle in haystack
|
|
128
|
+
if self.kind == "startswith":
|
|
129
|
+
return haystack.startswith(needle)
|
|
130
|
+
if self.kind == "endswith":
|
|
131
|
+
return haystack.endswith(needle)
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def to_sql(self) -> SqlFragment:
|
|
135
|
+
esc = _escape_like(str(self.value))
|
|
136
|
+
pattern = {
|
|
137
|
+
"contains": f"%{esc}%",
|
|
138
|
+
"startswith": f"{esc}%",
|
|
139
|
+
"endswith": f"%{esc}",
|
|
140
|
+
}[self.kind]
|
|
141
|
+
return (f"{_quote(self.column)} LIKE ? ESCAPE '\\'", [pattern])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class In(Condition):
|
|
146
|
+
"""Membership test: `column IN (values)`."""
|
|
147
|
+
|
|
148
|
+
column: str
|
|
149
|
+
values: List[Any]
|
|
150
|
+
|
|
151
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
152
|
+
return row.get(self.column) in self.values
|
|
153
|
+
|
|
154
|
+
def to_sql(self) -> SqlFragment:
|
|
155
|
+
if not self.values:
|
|
156
|
+
return ("0", []) # IN () matches nothing
|
|
157
|
+
placeholders = ", ".join("?" for _ in self.values)
|
|
158
|
+
return (f"{_quote(self.column)} IN ({placeholders})", list(self.values))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class IsNull(Condition):
|
|
163
|
+
"""Null test: `column IS NULL`."""
|
|
164
|
+
|
|
165
|
+
column: str
|
|
166
|
+
|
|
167
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
168
|
+
return row.get(self.column) is None
|
|
169
|
+
|
|
170
|
+
def to_sql(self) -> SqlFragment:
|
|
171
|
+
return (f"{_quote(self.column)} IS NULL", [])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class And(Condition):
|
|
176
|
+
"""All sub-conditions must hold."""
|
|
177
|
+
|
|
178
|
+
parts: List[Condition] = field(default_factory=list)
|
|
179
|
+
|
|
180
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
181
|
+
return all(p.matches(row) for p in self.parts)
|
|
182
|
+
|
|
183
|
+
def to_sql(self) -> SqlFragment:
|
|
184
|
+
return _combine(self.parts, " AND ", empty="1")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class Or(Condition):
|
|
189
|
+
"""At least one sub-condition must hold."""
|
|
190
|
+
|
|
191
|
+
parts: List[Condition] = field(default_factory=list)
|
|
192
|
+
|
|
193
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
194
|
+
return any(p.matches(row) for p in self.parts)
|
|
195
|
+
|
|
196
|
+
def to_sql(self) -> SqlFragment:
|
|
197
|
+
return _combine(self.parts, " OR ", empty="0")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class Not(Condition):
|
|
202
|
+
"""Negates a condition."""
|
|
203
|
+
|
|
204
|
+
part: Condition
|
|
205
|
+
|
|
206
|
+
def matches(self, row: Mapping[str, Any]) -> bool:
|
|
207
|
+
return not self.part.matches(row)
|
|
208
|
+
|
|
209
|
+
def to_sql(self) -> SqlFragment:
|
|
210
|
+
clause, params = self.part.to_sql()
|
|
211
|
+
return (f"NOT ({clause})", params)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _combine(parts: Sequence[Condition], joiner: str, *, empty: str) -> SqlFragment:
|
|
215
|
+
if not parts:
|
|
216
|
+
return (empty, [])
|
|
217
|
+
clauses: List[str] = []
|
|
218
|
+
params: List[Any] = []
|
|
219
|
+
for part in parts:
|
|
220
|
+
clause, p = part.to_sql()
|
|
221
|
+
clauses.append(clause)
|
|
222
|
+
params.extend(p)
|
|
223
|
+
return ("(" + joiner.join(clauses) + ")", params)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Column reference (builds conditions / sort keys)
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
class Column:
|
|
231
|
+
"""A reference to a column. Build conditions and sort keys from it.
|
|
232
|
+
|
|
233
|
+
Created by `col(name)`. Supports the comparison operators (`==`, `!=`,
|
|
234
|
+
`<`, `<=`, `>`, `>=`), text matching (`contains`, `startswith`, `endswith`),
|
|
235
|
+
membership (`is_in`), null tests (`is_null`, `is_not_null`), and sort
|
|
236
|
+
direction (`asc`, `desc`).
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
__slots__ = ("name",)
|
|
240
|
+
|
|
241
|
+
def __init__(self, name: str) -> None:
|
|
242
|
+
self.name = name
|
|
243
|
+
|
|
244
|
+
# comparisons
|
|
245
|
+
def __eq__(self, other: Any) -> Condition: # type: ignore[override]
|
|
246
|
+
return Compare(self.name, "eq", other)
|
|
247
|
+
|
|
248
|
+
def __ne__(self, other: Any) -> Condition: # type: ignore[override]
|
|
249
|
+
return Compare(self.name, "ne", other)
|
|
250
|
+
|
|
251
|
+
def __lt__(self, other: Any) -> Condition:
|
|
252
|
+
return Compare(self.name, "lt", other)
|
|
253
|
+
|
|
254
|
+
def __le__(self, other: Any) -> Condition:
|
|
255
|
+
return Compare(self.name, "le", other)
|
|
256
|
+
|
|
257
|
+
def __gt__(self, other: Any) -> Condition:
|
|
258
|
+
return Compare(self.name, "gt", other)
|
|
259
|
+
|
|
260
|
+
def __ge__(self, other: Any) -> Condition:
|
|
261
|
+
return Compare(self.name, "ge", other)
|
|
262
|
+
|
|
263
|
+
__hash__ = None # type: ignore[assignment]
|
|
264
|
+
|
|
265
|
+
# text
|
|
266
|
+
def contains(self, value: str) -> Condition:
|
|
267
|
+
"""Case-insensitive substring match."""
|
|
268
|
+
return Match(self.name, "contains", value)
|
|
269
|
+
|
|
270
|
+
def startswith(self, value: str) -> Condition:
|
|
271
|
+
"""Case-insensitive prefix match."""
|
|
272
|
+
return Match(self.name, "startswith", value)
|
|
273
|
+
|
|
274
|
+
def endswith(self, value: str) -> Condition:
|
|
275
|
+
"""Case-insensitive suffix match."""
|
|
276
|
+
return Match(self.name, "endswith", value)
|
|
277
|
+
|
|
278
|
+
# set / null
|
|
279
|
+
def is_in(self, values: Sequence[Any]) -> Condition:
|
|
280
|
+
"""Match any value in `values`."""
|
|
281
|
+
return In(self.name, list(values))
|
|
282
|
+
|
|
283
|
+
def is_null(self) -> Condition:
|
|
284
|
+
"""Match rows where this column is null/missing."""
|
|
285
|
+
return IsNull(self.name)
|
|
286
|
+
|
|
287
|
+
def is_not_null(self) -> Condition:
|
|
288
|
+
"""Match rows where this column is not null."""
|
|
289
|
+
return Not(IsNull(self.name))
|
|
290
|
+
|
|
291
|
+
# sort
|
|
292
|
+
def asc(self) -> "SortKey":
|
|
293
|
+
"""Sort by this column, ascending."""
|
|
294
|
+
return SortKey(self.name, False)
|
|
295
|
+
|
|
296
|
+
def desc(self) -> "SortKey":
|
|
297
|
+
"""Sort by this column, descending."""
|
|
298
|
+
return SortKey(self.name, True)
|
|
299
|
+
|
|
300
|
+
def __repr__(self) -> str:
|
|
301
|
+
return f"col({self.name!r})"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def col(name: str) -> Column:
|
|
305
|
+
"""Reference a column by name for use in `where()` / `order()`."""
|
|
306
|
+
return Column(name)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Sorting
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class SortKey:
|
|
315
|
+
"""A single sort term: a column and a direction."""
|
|
316
|
+
|
|
317
|
+
column: str
|
|
318
|
+
descending: bool = False
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
SortSpec = Union[str, Column, SortKey]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def normalize_sort_keys(keys: Sequence[SortSpec]) -> List[SortKey]:
|
|
325
|
+
"""Normalize mixed sort specs into a list of `SortKey`.
|
|
326
|
+
|
|
327
|
+
Accepts `"name"` (ascending), `"-name"` (descending), a `Column`
|
|
328
|
+
(ascending), or a `SortKey`.
|
|
329
|
+
"""
|
|
330
|
+
result: List[SortKey] = []
|
|
331
|
+
for key in keys:
|
|
332
|
+
if isinstance(key, SortKey):
|
|
333
|
+
result.append(key)
|
|
334
|
+
elif isinstance(key, Column):
|
|
335
|
+
result.append(SortKey(key.name, False))
|
|
336
|
+
elif isinstance(key, str):
|
|
337
|
+
if key.startswith("-"):
|
|
338
|
+
result.append(SortKey(key[1:], True))
|
|
339
|
+
else:
|
|
340
|
+
result.append(SortKey(key, False))
|
|
341
|
+
else:
|
|
342
|
+
raise TypeError(f"Invalid sort key: {key!r}")
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# Combinators
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def any_of(*conditions: Condition) -> Condition | None:
|
|
351
|
+
"""Combine conditions with OR. Returns None if none are given."""
|
|
352
|
+
parts = [c for c in conditions if c is not None]
|
|
353
|
+
if not parts:
|
|
354
|
+
return None
|
|
355
|
+
if len(parts) == 1:
|
|
356
|
+
return parts[0]
|
|
357
|
+
return Or(parts)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def all_of(*conditions: Condition) -> Condition | None:
|
|
361
|
+
"""Combine conditions with AND. Returns None if none are given."""
|
|
362
|
+
parts = [c for c in conditions if c is not None]
|
|
363
|
+
if not parts:
|
|
364
|
+
return None
|
|
365
|
+
if len(parts) == 1:
|
|
366
|
+
return parts[0]
|
|
367
|
+
return And(parts)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Streaming record readers for file-backed data sources.
|
|
2
|
+
|
|
3
|
+
A *reader* turns a file into a lazy iterator of record dicts, parsed a row at a
|
|
4
|
+
time so large files stream with bounded memory rather than loading all at once.
|
|
5
|
+
Readers are keyed by file extension in a registry: the stdlib formats
|
|
6
|
+
(CSV / TSV / JSON / JSONL / XML) are always available, and the optional columnar
|
|
7
|
+
and scientific formats (Parquet / Feather / HDF5) register too but raise a clear
|
|
8
|
+
"install the extra" error when used without their dependency.
|
|
9
|
+
|
|
10
|
+
The reader contract is intentionally small — `reader(path, config) ->
|
|
11
|
+
Iterator[Record]` — so it composes directly with the streaming
|
|
12
|
+
`SqliteDataSource.load(...)`, which chunks the iterator itself.
|
|
13
|
+
|
|
14
|
+
Register a custom reader for your own extension::
|
|
15
|
+
|
|
16
|
+
from bootstack.data.readers import register_reader
|
|
17
|
+
|
|
18
|
+
@register_reader(".myfmt")
|
|
19
|
+
def read_myfmt(path, config):
|
|
20
|
+
for record in parse(path):
|
|
21
|
+
yield record
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import csv
|
|
27
|
+
import importlib
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Callable, Dict, Iterator, List, Optional
|
|
31
|
+
|
|
32
|
+
from bootstack.data.types import Record
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from bootstack.data.file_source import FileSourceConfig
|
|
36
|
+
|
|
37
|
+
# A reader yields record dicts lazily: (path, config) -> Iterator[Record].
|
|
38
|
+
Reader = Callable[..., Iterator[Record]]
|
|
39
|
+
|
|
40
|
+
_READERS: Dict[str, Reader] = {}
|
|
41
|
+
|
|
42
|
+
# Format-name aliases (FileSourceConfig.file_format) → canonical extension.
|
|
43
|
+
_FORMAT_ALIASES: Dict[str, str] = {
|
|
44
|
+
"csv": ".csv",
|
|
45
|
+
"tsv": ".tsv",
|
|
46
|
+
"json": ".json",
|
|
47
|
+
"jsonl": ".jsonl",
|
|
48
|
+
"ndjson": ".jsonl",
|
|
49
|
+
"xml": ".xml",
|
|
50
|
+
"parquet": ".parquet",
|
|
51
|
+
"feather": ".feather",
|
|
52
|
+
"hdf5": ".h5",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _norm_ext(ext: str) -> str:
|
|
57
|
+
"""Normalize an extension to lowercase with a leading dot."""
|
|
58
|
+
ext = ext.lower()
|
|
59
|
+
return ext if ext.startswith(".") else "." + ext
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_reader(*extensions: str, reader: Optional[Reader] = None):
|
|
63
|
+
"""Register a reader for one or more file extensions.
|
|
64
|
+
|
|
65
|
+
Use as a decorator (`@register_reader(".csv", ".tsv")`) or call directly with
|
|
66
|
+
`reader=`. Extensions may be given with or without the leading dot.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
extensions: File extensions the reader handles.
|
|
70
|
+
reader: The reader callable, when not used as a decorator.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The reader (so it works as a decorator).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def _apply(fn: Reader) -> Reader:
|
|
77
|
+
for ext in extensions:
|
|
78
|
+
_READERS[_norm_ext(ext)] = fn
|
|
79
|
+
return fn
|
|
80
|
+
|
|
81
|
+
return _apply(reader) if reader is not None else _apply
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def supported_read_extensions() -> List[str]:
|
|
85
|
+
"""Return the sorted list of registered readable file extensions."""
|
|
86
|
+
return sorted(_READERS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_reader(path_or_ext: "str | Path") -> Reader:
|
|
90
|
+
"""Resolve the reader for a path or extension.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
path_or_ext: A file path or a bare extension (e.g. `".csv"` or `"csv"`).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The registered reader callable.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If no reader is registered for the extension.
|
|
100
|
+
"""
|
|
101
|
+
suffix = Path(str(path_or_ext)).suffix or str(path_or_ext)
|
|
102
|
+
ext = _norm_ext(suffix)
|
|
103
|
+
try:
|
|
104
|
+
return _READERS[ext]
|
|
105
|
+
except KeyError:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"No reader registered for {ext!r}. Supported formats: "
|
|
108
|
+
f"{', '.join(supported_read_extensions())}."
|
|
109
|
+
) from None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_records(path: "str | Path", config: "Optional[FileSourceConfig]" = None) -> Iterator[Record]:
|
|
113
|
+
"""Stream records from a file, choosing the reader by format or extension.
|
|
114
|
+
|
|
115
|
+
Honors `config.file_format` when set to something other than `'auto'`,
|
|
116
|
+
otherwise resolves the reader from the path's extension.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
path: Path to the data file.
|
|
120
|
+
config: Parsing configuration (a default `FileSourceConfig` is used if
|
|
121
|
+
omitted).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A lazy iterator of record dicts.
|
|
125
|
+
"""
|
|
126
|
+
from bootstack.data.file_source import FileSourceConfig
|
|
127
|
+
|
|
128
|
+
cfg = config or FileSourceConfig()
|
|
129
|
+
fmt = getattr(cfg, "file_format", "auto")
|
|
130
|
+
if fmt and fmt != "auto":
|
|
131
|
+
reader = get_reader(_FORMAT_ALIASES.get(fmt, fmt))
|
|
132
|
+
else:
|
|
133
|
+
reader = get_reader(path)
|
|
134
|
+
return reader(path, cfg)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _require(module: str, extra: str):
|
|
138
|
+
"""Import an optional dependency or raise a clear install hint."""
|
|
139
|
+
try:
|
|
140
|
+
return importlib.import_module(module)
|
|
141
|
+
except ImportError as exc:
|
|
142
|
+
raise ImportError(
|
|
143
|
+
f"Reading this format needs the optional {module!r} dependency. "
|
|
144
|
+
f"Install it with: pip install bootstack[{extra}]"
|
|
145
|
+
) from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------- stdlib
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@register_reader(".csv", ".tsv", ".txt")
|
|
152
|
+
def read_csv(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
153
|
+
"""Stream records from a CSV/TSV file, one row at a time."""
|
|
154
|
+
delimiter = config.delimiter
|
|
155
|
+
if delimiter is None:
|
|
156
|
+
delimiter = "\t" if _norm_ext(Path(path).suffix) == ".tsv" else ","
|
|
157
|
+
with open(path, "r", encoding=config.encoding, newline="") as f:
|
|
158
|
+
for _ in range(config.skip_rows or 0):
|
|
159
|
+
next(f, None)
|
|
160
|
+
reader = csv.DictReader(f, delimiter=delimiter, quotechar=config.quotechar)
|
|
161
|
+
for row in reader:
|
|
162
|
+
yield dict(row)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@register_reader(".jsonl", ".ndjson")
|
|
166
|
+
def read_jsonl(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
167
|
+
"""Stream records from a line-delimited JSON file (one object per line)."""
|
|
168
|
+
with open(path, "r", encoding=config.encoding) as f:
|
|
169
|
+
for line in f:
|
|
170
|
+
line = line.strip()
|
|
171
|
+
if not line:
|
|
172
|
+
continue
|
|
173
|
+
obj = json.loads(line)
|
|
174
|
+
yield obj if isinstance(obj, dict) else {"value": obj}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@register_reader(".json")
|
|
178
|
+
def read_json(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
179
|
+
"""Read records from a JSON file.
|
|
180
|
+
|
|
181
|
+
Supported shapes:
|
|
182
|
+
|
|
183
|
+
- a top-level **array of objects** → one record per element;
|
|
184
|
+
- a top-level **object** with its records under `config.json_records_key`
|
|
185
|
+
(e.g. an API response like `{"data": [...]}`) → that list;
|
|
186
|
+
- any other top-level **object** → a single record.
|
|
187
|
+
|
|
188
|
+
A JSON array is parsed in full — the stdlib parser cannot stream an array, so
|
|
189
|
+
prefer JSONL / `.ndjson` (or set `config.json_lines`) for very large data.
|
|
190
|
+
"""
|
|
191
|
+
if getattr(config, "json_lines", False):
|
|
192
|
+
yield from read_jsonl(path, config)
|
|
193
|
+
return
|
|
194
|
+
with open(path, "r", encoding=config.encoding) as f:
|
|
195
|
+
data = json.load(f)
|
|
196
|
+
|
|
197
|
+
key = getattr(config, "json_records_key", None)
|
|
198
|
+
if key is not None:
|
|
199
|
+
items = data.get(key) if isinstance(data, dict) else None
|
|
200
|
+
if not isinstance(items, list):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"json_records_key={key!r} did not resolve to a list of records in {path}."
|
|
203
|
+
)
|
|
204
|
+
for item in items:
|
|
205
|
+
yield item if isinstance(item, dict) else {"value": item}
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if isinstance(data, list):
|
|
209
|
+
for item in data:
|
|
210
|
+
yield item if isinstance(item, dict) else {"value": item}
|
|
211
|
+
elif isinstance(data, dict):
|
|
212
|
+
yield data
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@register_reader(".xml")
|
|
216
|
+
def read_xml(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
217
|
+
"""Stream records from an XML file using incremental parsing.
|
|
218
|
+
|
|
219
|
+
Each record element (those matching `config.xml_record_tag`, or the direct
|
|
220
|
+
children of the root when it is unset) becomes one record: its attributes and
|
|
221
|
+
the text of its leaf child elements become fields. Parsed elements are cleared
|
|
222
|
+
so memory stays bounded. Nested/repeated children are skipped in this version.
|
|
223
|
+
"""
|
|
224
|
+
import xml.etree.ElementTree as ET
|
|
225
|
+
|
|
226
|
+
record_tag = getattr(config, "xml_record_tag", None)
|
|
227
|
+
depth = 0
|
|
228
|
+
root = None
|
|
229
|
+
for event, elem in ET.iterparse(path, events=("start", "end")):
|
|
230
|
+
if event == "start":
|
|
231
|
+
if depth == 0:
|
|
232
|
+
root = elem
|
|
233
|
+
depth += 1
|
|
234
|
+
continue
|
|
235
|
+
# end event
|
|
236
|
+
depth -= 1
|
|
237
|
+
is_record = (elem.tag == record_tag) if record_tag else (depth == 1)
|
|
238
|
+
if not is_record:
|
|
239
|
+
continue
|
|
240
|
+
record: Record = dict(elem.attrib)
|
|
241
|
+
for child in elem:
|
|
242
|
+
if len(child) == 0: # leaf element → a scalar field
|
|
243
|
+
record[child.tag] = child.text
|
|
244
|
+
yield record
|
|
245
|
+
elem.clear()
|
|
246
|
+
if root is not None:
|
|
247
|
+
root.clear() # drop processed siblings to keep memory bounded
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------- optional
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@register_reader(".parquet")
|
|
254
|
+
def read_parquet(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
255
|
+
"""Stream records from a Parquet file in row-group batches (needs pyarrow)."""
|
|
256
|
+
pq = _require("pyarrow.parquet", "parquet")
|
|
257
|
+
parquet_file = pq.ParquetFile(str(path))
|
|
258
|
+
for batch in parquet_file.iter_batches():
|
|
259
|
+
for row in batch.to_pylist():
|
|
260
|
+
yield row
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@register_reader(".feather", ".arrow")
|
|
264
|
+
def read_feather(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
265
|
+
"""Stream records from a Feather/Arrow IPC file in batches (needs pyarrow)."""
|
|
266
|
+
feather = _require("pyarrow.feather", "parquet")
|
|
267
|
+
table = feather.read_table(str(path))
|
|
268
|
+
for batch in table.to_batches():
|
|
269
|
+
for row in batch.to_pylist():
|
|
270
|
+
yield row
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@register_reader(".h5", ".hdf5", ".hdf")
|
|
274
|
+
def read_hdf5(path: "str | Path", config: "FileSourceConfig") -> Iterator[Record]:
|
|
275
|
+
"""Stream records from a tabular HDF5 file in chunks (needs pandas + tables)."""
|
|
276
|
+
pd = _require("pandas", "hdf5")
|
|
277
|
+
key = getattr(config, "hdf5_key", None)
|
|
278
|
+
store = pd.HDFStore(str(path), mode="r")
|
|
279
|
+
try:
|
|
280
|
+
if key is None:
|
|
281
|
+
keys = store.keys()
|
|
282
|
+
if not keys:
|
|
283
|
+
return
|
|
284
|
+
key = keys[0]
|
|
285
|
+
for chunk in store.select(key, chunksize=10_000):
|
|
286
|
+
for record in chunk.to_dict(orient="records"):
|
|
287
|
+
yield record
|
|
288
|
+
finally:
|
|
289
|
+
store.close()
|