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.
Files changed (471) hide show
  1. bootstack/__init__.py +157 -0
  2. bootstack/__main__.py +5 -0
  3. bootstack/_core/__init__.py +21 -0
  4. bootstack/_core/capabilities/__init__.py +45 -0
  5. bootstack/_core/capabilities/after.py +103 -0
  6. bootstack/_core/capabilities/bind.py +192 -0
  7. bootstack/_core/capabilities/bindtags.py +112 -0
  8. bootstack/_core/capabilities/busy.py +72 -0
  9. bootstack/_core/capabilities/clipboard.py +89 -0
  10. bootstack/_core/capabilities/focus.py +118 -0
  11. bootstack/_core/capabilities/grab.py +65 -0
  12. bootstack/_core/capabilities/grid.py +211 -0
  13. bootstack/_core/capabilities/localization.py +231 -0
  14. bootstack/_core/capabilities/pack.py +140 -0
  15. bootstack/_core/capabilities/place.py +113 -0
  16. bootstack/_core/capabilities/selection.py +136 -0
  17. bootstack/_core/capabilities/signals.py +244 -0
  18. bootstack/_core/capabilities/winfo.py +315 -0
  19. bootstack/_core/colorutils.py +234 -0
  20. bootstack/_core/exceptions.py +25 -0
  21. bootstack/_core/images.py +463 -0
  22. bootstack/_core/mixins/__init__.py +1 -0
  23. bootstack/_core/mixins/ttk_state.py +35 -0
  24. bootstack/_core/mixins/widget.py +132 -0
  25. bootstack/_core/paths.py +49 -0
  26. bootstack/_core/publisher.py +149 -0
  27. bootstack/_core/variables.py +62 -0
  28. bootstack/_runtime/__init__.py +3 -0
  29. bootstack/_runtime/app.py +930 -0
  30. bootstack/_runtime/base_window.py +945 -0
  31. bootstack/_runtime/events.py +399 -0
  32. bootstack/_runtime/shortcuts.py +496 -0
  33. bootstack/_runtime/tk_patch.py +43 -0
  34. bootstack/_runtime/toplevel.py +276 -0
  35. bootstack/_runtime/utility.py +457 -0
  36. bootstack/_runtime/visual_focus.py +240 -0
  37. bootstack/_runtime/window_utilities.py +1123 -0
  38. bootstack/assets/__init__.py +21 -0
  39. bootstack/assets/bootstack.ico +0 -0
  40. bootstack/assets/bootstack.png +0 -0
  41. bootstack/assets/elements/__init__.py +0 -0
  42. bootstack/assets/elements/badge-pill.png +0 -0
  43. bootstack/assets/elements/badge-square.png +0 -0
  44. bootstack/assets/elements/button-compact.png +0 -0
  45. bootstack/assets/elements/button-default.png +0 -0
  46. bootstack/assets/elements/buttongroup-after-h-compact.png +0 -0
  47. bootstack/assets/elements/buttongroup-after-h-default.png +0 -0
  48. bootstack/assets/elements/buttongroup-after-v-compact.png +0 -0
  49. bootstack/assets/elements/buttongroup-after-v-default.png +0 -0
  50. bootstack/assets/elements/buttongroup-before-h-compact.png +0 -0
  51. bootstack/assets/elements/buttongroup-before-h-default.png +0 -0
  52. bootstack/assets/elements/buttongroup-before-v-compact.png +0 -0
  53. bootstack/assets/elements/buttongroup-before-v-default.png +0 -0
  54. bootstack/assets/elements/buttongroup-center-h-compact.png +0 -0
  55. bootstack/assets/elements/buttongroup-center-h-default.png +0 -0
  56. bootstack/assets/elements/buttongroup-center-v-compact.png +0 -0
  57. bootstack/assets/elements/buttongroup-center-v-default.png +0 -0
  58. bootstack/assets/elements/card.png +0 -0
  59. bootstack/assets/elements/checkbox-checked.png +0 -0
  60. bootstack/assets/elements/checkbox-indeterminate.png +0 -0
  61. bootstack/assets/elements/checkbox-unchecked.png +0 -0
  62. bootstack/assets/elements/field.png +0 -0
  63. bootstack/assets/elements/input-addon-compact.png +0 -0
  64. bootstack/assets/elements/input-addon-default.png +0 -0
  65. bootstack/assets/elements/input-compact.png +0 -0
  66. bootstack/assets/elements/input-default.png +0 -0
  67. bootstack/assets/elements/list-item-separated.png +0 -0
  68. bootstack/assets/elements/list-item.png +0 -0
  69. bootstack/assets/elements/listrow-compact.png +0 -0
  70. bootstack/assets/elements/listrow-default.png +0 -0
  71. bootstack/assets/elements/manifest.toml +361 -0
  72. bootstack/assets/elements/menu-item.png +0 -0
  73. bootstack/assets/elements/navitem-compact.png +0 -0
  74. bootstack/assets/elements/navitem-default.png +0 -0
  75. bootstack/assets/elements/progressbar-h-compact.png +0 -0
  76. bootstack/assets/elements/progressbar-h-default.png +0 -0
  77. bootstack/assets/elements/progressbar-v-compact.png +0 -0
  78. bootstack/assets/elements/progressbar-v-default.png +0 -0
  79. bootstack/assets/elements/radiobutton.png +0 -0
  80. bootstack/assets/elements/scrollbar-horizontal.png +0 -0
  81. bootstack/assets/elements/scrollbar-vertical.png +0 -0
  82. bootstack/assets/elements/slider-handle.png +0 -0
  83. bootstack/assets/elements/slider-track-h.png +0 -0
  84. bootstack/assets/elements/slider-track-v.png +0 -0
  85. bootstack/assets/elements/switch-off.png +0 -0
  86. bootstack/assets/elements/switch-on.png +0 -0
  87. bootstack/assets/elements/tab-h.png +0 -0
  88. bootstack/assets/elements/tab-v.png +0 -0
  89. bootstack/assets/icons/bootstrap.ttf +0 -0
  90. bootstack/assets/icons/glyphmap.json +2080 -0
  91. bootstack/assets/icons/icon_metrics.json +12470 -0
  92. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.mo +0 -0
  93. bootstack/assets/locales/ar/LC_MESSAGES/bootstack.po +856 -0
  94. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.mo +0 -0
  95. bootstack/assets/locales/bg/LC_MESSAGES/bootstack.po +878 -0
  96. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.mo +0 -0
  97. bootstack/assets/locales/cs/LC_MESSAGES/bootstack.po +856 -0
  98. bootstack/assets/locales/da/LC_MESSAGES/bootstack.mo +0 -0
  99. bootstack/assets/locales/da/LC_MESSAGES/bootstack.po +856 -0
  100. bootstack/assets/locales/de/LC_MESSAGES/bootstack.mo +0 -0
  101. bootstack/assets/locales/de/LC_MESSAGES/bootstack.po +856 -0
  102. bootstack/assets/locales/en/LC_MESSAGES/bootstack.mo +0 -0
  103. bootstack/assets/locales/en/LC_MESSAGES/bootstack.po +878 -0
  104. bootstack/assets/locales/es/LC_MESSAGES/bootstack.mo +0 -0
  105. bootstack/assets/locales/es/LC_MESSAGES/bootstack.po +856 -0
  106. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.mo +0 -0
  107. bootstack/assets/locales/fr/LC_MESSAGES/bootstack.po +856 -0
  108. bootstack/assets/locales/he/LC_MESSAGES/bootstack.mo +0 -0
  109. bootstack/assets/locales/he/LC_MESSAGES/bootstack.po +854 -0
  110. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.mo +0 -0
  111. bootstack/assets/locales/hi/LC_MESSAGES/bootstack.po +845 -0
  112. bootstack/assets/locales/it/LC_MESSAGES/bootstack.mo +0 -0
  113. bootstack/assets/locales/it/LC_MESSAGES/bootstack.po +844 -0
  114. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.mo +0 -0
  115. bootstack/assets/locales/ja/LC_MESSAGES/bootstack.po +917 -0
  116. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.mo +0 -0
  117. bootstack/assets/locales/ko/LC_MESSAGES/bootstack.po +845 -0
  118. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.mo +0 -0
  119. bootstack/assets/locales/nb/LC_MESSAGES/bootstack.po +844 -0
  120. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.mo +0 -0
  121. bootstack/assets/locales/nl/LC_MESSAGES/bootstack.po +844 -0
  122. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.mo +0 -0
  123. bootstack/assets/locales/pl/LC_MESSAGES/bootstack.po +845 -0
  124. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.mo +0 -0
  125. bootstack/assets/locales/pt/LC_MESSAGES/bootstack.po +845 -0
  126. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.mo +0 -0
  127. bootstack/assets/locales/pt_BR/LC_MESSAGES/bootstack.po +845 -0
  128. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.mo +0 -0
  129. bootstack/assets/locales/sl/LC_MESSAGES/bootstack.po +845 -0
  130. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.mo +0 -0
  131. bootstack/assets/locales/sv/LC_MESSAGES/bootstack.po +845 -0
  132. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.mo +0 -0
  133. bootstack/assets/locales/tr/LC_MESSAGES/bootstack.po +845 -0
  134. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.mo +0 -0
  135. bootstack/assets/locales/zh_CN/LC_MESSAGES/bootstack.po +845 -0
  136. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.mo +0 -0
  137. bootstack/assets/locales/zh_TW/LC_MESSAGES/bootstack.po +845 -0
  138. bootstack/cli/__init__.py +133 -0
  139. bootstack/cli/__main__.py +6 -0
  140. bootstack/cli/add.py +395 -0
  141. bootstack/cli/appicon.py +285 -0
  142. bootstack/cli/build.py +115 -0
  143. bootstack/cli/config.py +313 -0
  144. bootstack/cli/demo.py +564 -0
  145. bootstack/cli/dev.py +153 -0
  146. bootstack/cli/doctor.py +195 -0
  147. bootstack/cli/icons.py +98 -0
  148. bootstack/cli/promote.py +120 -0
  149. bootstack/cli/pyinstaller.py +268 -0
  150. bootstack/cli/run.py +95 -0
  151. bootstack/cli/start.py +117 -0
  152. bootstack/cli/templates/__init__.py +931 -0
  153. bootstack/clipboard.py +48 -0
  154. bootstack/constants.py +318 -0
  155. bootstack/data/README.md +615 -0
  156. bootstack/data/__init__.py +78 -0
  157. bootstack/data/_observable.py +276 -0
  158. bootstack/data/base.py +780 -0
  159. bootstack/data/file_source.py +367 -0
  160. bootstack/data/memory_source.py +450 -0
  161. bootstack/data/query.py +367 -0
  162. bootstack/data/readers.py +289 -0
  163. bootstack/data/sqlite_source.py +869 -0
  164. bootstack/data/types.py +354 -0
  165. bootstack/data/writers.py +232 -0
  166. bootstack/dev/__init__.py +36 -0
  167. bootstack/dev/_capture.py +141 -0
  168. bootstack/dev/_env.py +43 -0
  169. bootstack/dev/_registry.py +140 -0
  170. bootstack/dev/_reloader.py +351 -0
  171. bootstack/dev/_reset.py +50 -0
  172. bootstack/dev/_watcher.py +91 -0
  173. bootstack/dialogs/__init__.py +948 -0
  174. bootstack/dialogs/_impl/__init__.py +47 -0
  175. bootstack/dialogs/_impl/colorchooser.py +588 -0
  176. bootstack/dialogs/_impl/datedialog.py +450 -0
  177. bootstack/dialogs/_impl/dialog.py +594 -0
  178. bootstack/dialogs/_impl/filterdialog.py +358 -0
  179. bootstack/dialogs/_impl/fontdialog.py +364 -0
  180. bootstack/dialogs/_impl/formdialog.py +564 -0
  181. bootstack/dialogs/_impl/message.py +486 -0
  182. bootstack/dialogs/_impl/query.py +570 -0
  183. bootstack/errors.py +67 -0
  184. bootstack/events/__init__.py +111 -0
  185. bootstack/events/_event.py +135 -0
  186. bootstack/events/_payloads.py +539 -0
  187. bootstack/events/_subscription.py +38 -0
  188. bootstack/i18n/README.md +77 -0
  189. bootstack/i18n/__init__.py +23 -0
  190. bootstack/i18n/catalog.py +121 -0
  191. bootstack/i18n/intl_format.py +584 -0
  192. bootstack/i18n/msgcat.py +425 -0
  193. bootstack/i18n/specs.py +156 -0
  194. bootstack/images.py +563 -0
  195. bootstack/py.typed +1 -0
  196. bootstack/scheduling/__init__.py +11 -0
  197. bootstack/scheduling/_schedule.py +218 -0
  198. bootstack/shortcuts.py +21 -0
  199. bootstack/signals/README.md +98 -0
  200. bootstack/signals/__init__.py +10 -0
  201. bootstack/signals/integration.py +100 -0
  202. bootstack/signals/signal.py +353 -0
  203. bootstack/signals/types.py +6 -0
  204. bootstack/store.py +286 -0
  205. bootstack/streams/__init__.py +12 -0
  206. bootstack/streams/_stream.py +321 -0
  207. bootstack/style/__init__.py +38 -0
  208. bootstack/style/builders/__init__.py +51 -0
  209. bootstack/style/builders/badge.py +46 -0
  210. bootstack/style/builders/button.py +339 -0
  211. bootstack/style/builders/buttongroup.py +311 -0
  212. bootstack/style/builders/calendar.py +271 -0
  213. bootstack/style/builders/checkbutton.py +110 -0
  214. bootstack/style/builders/combobox.py +113 -0
  215. bootstack/style/builders/contextmenu.py +268 -0
  216. bootstack/style/builders/entry.py +82 -0
  217. bootstack/style/builders/expander.py +148 -0
  218. bootstack/style/builders/field.py +335 -0
  219. bootstack/style/builders/frame.py +50 -0
  220. bootstack/style/builders/label.py +28 -0
  221. bootstack/style/builders/labelframe.py +34 -0
  222. bootstack/style/builders/listview.py +369 -0
  223. bootstack/style/builders/menubar.py +91 -0
  224. bootstack/style/builders/menubutton.py +359 -0
  225. bootstack/style/builders/panedwindow.py +25 -0
  226. bootstack/style/builders/progressbar.py +67 -0
  227. bootstack/style/builders/radiobutton.py +99 -0
  228. bootstack/style/builders/scale.py +64 -0
  229. bootstack/style/builders/scrollbar.py +225 -0
  230. bootstack/style/builders/separator.py +49 -0
  231. bootstack/style/builders/sidenav.py +643 -0
  232. bootstack/style/builders/sizegrip.py +15 -0
  233. bootstack/style/builders/spinbox.py +119 -0
  234. bootstack/style/builders/switch.py +70 -0
  235. bootstack/style/builders/tabitem.py +204 -0
  236. bootstack/style/builders/togglegroup.py +294 -0
  237. bootstack/style/builders/toolbutton.py +275 -0
  238. bootstack/style/builders/tooltip.py +26 -0
  239. bootstack/style/builders/treeview.py +193 -0
  240. bootstack/style/builders/utils.py +455 -0
  241. bootstack/style/builders_tk/__init__.py +16 -0
  242. bootstack/style/builders_tk/defaults.py +229 -0
  243. bootstack/style/element.py +173 -0
  244. bootstack/style/fonts.py +123 -0
  245. bootstack/style/style.py +609 -0
  246. bootstack/style/style_builder_base.py +716 -0
  247. bootstack/style/style_builder_mixed.py +93 -0
  248. bootstack/style/style_builder_tk.py +109 -0
  249. bootstack/style/style_builder_ttk.py +353 -0
  250. bootstack/style/style_resolver.py +447 -0
  251. bootstack/style/theme.py +245 -0
  252. bootstack/style/theme_provider.py +471 -0
  253. bootstack/style/themes/__init__.py +128 -0
  254. bootstack/style/tk_patch.py +5 -0
  255. bootstack/style/token_maps.py +41 -0
  256. bootstack/style/types.py +32 -0
  257. bootstack/style/typography.py +523 -0
  258. bootstack/style/utility.py +746 -0
  259. bootstack/types.py +39 -0
  260. bootstack/validation/__init__.py +6 -0
  261. bootstack/validation/types.py +15 -0
  262. bootstack/validation/validation_result.py +17 -0
  263. bootstack/validation/validation_rules.py +205 -0
  264. bootstack/widgets/__init__.py +74 -0
  265. bootstack/widgets/_core/__init__.py +31 -0
  266. bootstack/widgets/_core/app_config.py +266 -0
  267. bootstack/widgets/_core/base.py +461 -0
  268. bootstack/widgets/_core/container.py +334 -0
  269. bootstack/widgets/_core/context.py +35 -0
  270. bootstack/widgets/_core/events.py +130 -0
  271. bootstack/widgets/_core/field_mixin.py +353 -0
  272. bootstack/widgets/_core/icon_image_props.py +72 -0
  273. bootstack/widgets/_core/image_binding.py +93 -0
  274. bootstack/widgets/_core/navmodel.py +345 -0
  275. bootstack/widgets/_core/options.py +210 -0
  276. bootstack/widgets/_core/selection_group.py +130 -0
  277. bootstack/widgets/_core/window_controls.py +96 -0
  278. bootstack/widgets/_core/window_menu.py +246 -0
  279. bootstack/widgets/_impl/__init__.py +1 -0
  280. bootstack/widgets/_impl/_internal/__init__.py +0 -0
  281. bootstack/widgets/_impl/_internal/wrapper_base.py +307 -0
  282. bootstack/widgets/_impl/_parts/__init__.py +11 -0
  283. bootstack/widgets/_impl/_parts/numberentry_part.py +385 -0
  284. bootstack/widgets/_impl/_parts/spinnerentry_part.py +434 -0
  285. bootstack/widgets/_impl/_parts/textentry_part.py +406 -0
  286. bootstack/widgets/_impl/composites/__init__.py +33 -0
  287. bootstack/widgets/_impl/composites/_dateutils.py +33 -0
  288. bootstack/widgets/_impl/composites/_image_fit.py +105 -0
  289. bootstack/widgets/_impl/composites/accordion.py +381 -0
  290. bootstack/widgets/_impl/composites/avatar.py +191 -0
  291. bootstack/widgets/_impl/composites/buttongroup.py +371 -0
  292. bootstack/widgets/_impl/composites/calendar.py +972 -0
  293. bootstack/widgets/_impl/composites/carousel.py +567 -0
  294. bootstack/widgets/_impl/composites/chart.py +882 -0
  295. bootstack/widgets/_impl/composites/compositeframe.py +298 -0
  296. bootstack/widgets/_impl/composites/contextmenu.py +1951 -0
  297. bootstack/widgets/_impl/composites/dateentry.py +404 -0
  298. bootstack/widgets/_impl/composites/dropdownbutton.py +325 -0
  299. bootstack/widgets/_impl/composites/expander.py +515 -0
  300. bootstack/widgets/_impl/composites/field.py +670 -0
  301. bootstack/widgets/_impl/composites/form.py +1066 -0
  302. bootstack/widgets/_impl/composites/gallery.py +551 -0
  303. bootstack/widgets/_impl/composites/list/__init__.py +15 -0
  304. bootstack/widgets/_impl/composites/list/listitem.py +802 -0
  305. bootstack/widgets/_impl/composites/list/listview.py +1433 -0
  306. bootstack/widgets/_impl/composites/menu/__init__.py +18 -0
  307. bootstack/widgets/_impl/composites/menu/model.py +358 -0
  308. bootstack/widgets/_impl/composites/menu/render_native.py +134 -0
  309. bootstack/widgets/_impl/composites/menu/render_themed.py +134 -0
  310. bootstack/widgets/_impl/composites/meter.py +860 -0
  311. bootstack/widgets/_impl/composites/numericentry.py +201 -0
  312. bootstack/widgets/_impl/composites/pagestack.py +395 -0
  313. bootstack/widgets/_impl/composites/passwordentry.py +142 -0
  314. bootstack/widgets/_impl/composites/pathentry.py +168 -0
  315. bootstack/widgets/_impl/composites/picture.py +289 -0
  316. bootstack/widgets/_impl/composites/radiogroup.py +511 -0
  317. bootstack/widgets/_impl/composites/scrolledtext.py +375 -0
  318. bootstack/widgets/_impl/composites/scrolledtext.pyi +186 -0
  319. bootstack/widgets/_impl/composites/scrollview.py +764 -0
  320. bootstack/widgets/_impl/composites/selectbox.py +1026 -0
  321. bootstack/widgets/_impl/composites/shell/__init__.py +40 -0
  322. bootstack/widgets/_impl/composites/shell/content_host.py +52 -0
  323. bootstack/widgets/_impl/composites/shell/layout.py +335 -0
  324. bootstack/widgets/_impl/composites/shell/nav_panel.py +345 -0
  325. bootstack/widgets/_impl/composites/shell/providers.py +558 -0
  326. bootstack/widgets/_impl/composites/shell/rail.py +117 -0
  327. bootstack/widgets/_impl/composites/shell/shell.py +581 -0
  328. bootstack/widgets/_impl/composites/shell/workspace.py +273 -0
  329. bootstack/widgets/_impl/composites/sidenav/__init__.py +16 -0
  330. bootstack/widgets/_impl/composites/sidenav/header.py +81 -0
  331. bootstack/widgets/_impl/composites/sidenav/separator.py +44 -0
  332. bootstack/widgets/_impl/composites/slider/__init__.py +7 -0
  333. bootstack/widgets/_impl/composites/slider/_shared.py +195 -0
  334. bootstack/widgets/_impl/composites/slider/rangeslider.py +982 -0
  335. bootstack/widgets/_impl/composites/slider/slider.py +851 -0
  336. bootstack/widgets/_impl/composites/spinnerentry.py +185 -0
  337. bootstack/widgets/_impl/composites/tableview/__init__.py +5 -0
  338. bootstack/widgets/_impl/composites/tableview/tableview.py +3388 -0
  339. bootstack/widgets/_impl/composites/tableview/types.py +169 -0
  340. bootstack/widgets/_impl/composites/tabs/__init__.py +23 -0
  341. bootstack/widgets/_impl/composites/tabs/tabitem.py +389 -0
  342. bootstack/widgets/_impl/composites/tabs/tabs.py +974 -0
  343. bootstack/widgets/_impl/composites/tabs/tabview.py +650 -0
  344. bootstack/widgets/_impl/composites/textarea/__init__.py +26 -0
  345. bootstack/widgets/_impl/composites/textarea/change.py +46 -0
  346. bootstack/widgets/_impl/composites/textarea/codeeditor.py +526 -0
  347. bootstack/widgets/_impl/composites/textarea/core.py +495 -0
  348. bootstack/widgets/_impl/composites/textarea/decoration.py +42 -0
  349. bootstack/widgets/_impl/composites/textarea/diff.py +127 -0
  350. bootstack/widgets/_impl/composites/textarea/extensions/__init__.py +1 -0
  351. bootstack/widgets/_impl/composites/textarea/extensions/bracket_matcher.py +138 -0
  352. bootstack/widgets/_impl/composites/textarea/extensions/indent_guides.py +158 -0
  353. bootstack/widgets/_impl/composites/textarea/extensions/line_numbers.py +138 -0
  354. bootstack/widgets/_impl/composites/textarea/extensions/pygments_highlighter.py +312 -0
  355. bootstack/widgets/_impl/composites/textarea/extensions/smart_indent.py +177 -0
  356. bootstack/widgets/_impl/composites/textarea/filter.py +171 -0
  357. bootstack/widgets/_impl/composites/textarea/search_overlay.py +459 -0
  358. bootstack/widgets/_impl/composites/textarea/sidebar.py +88 -0
  359. bootstack/widgets/_impl/composites/textarea/style_registry.py +178 -0
  360. bootstack/widgets/_impl/composites/textarea/textarea.py +606 -0
  361. bootstack/widgets/_impl/composites/textarea/undo.py +217 -0
  362. bootstack/widgets/_impl/composites/textentry.py +57 -0
  363. bootstack/widgets/_impl/composites/timeentry.py +176 -0
  364. bootstack/widgets/_impl/composites/toast.py +390 -0
  365. bootstack/widgets/_impl/composites/toast_stack.py +156 -0
  366. bootstack/widgets/_impl/composites/togglegroup.py +418 -0
  367. bootstack/widgets/_impl/composites/toolbar.py +608 -0
  368. bootstack/widgets/_impl/composites/tooltip.py +491 -0
  369. bootstack/widgets/_impl/composites/tree/__init__.py +7 -0
  370. bootstack/widgets/_impl/composites/tree/source_binding.py +136 -0
  371. bootstack/widgets/_impl/composites/tree/treeitem.py +393 -0
  372. bootstack/widgets/_impl/composites/tree/treenode.py +174 -0
  373. bootstack/widgets/_impl/composites/tree/treeview.py +841 -0
  374. bootstack/widgets/_impl/mixins/__init__.py +24 -0
  375. bootstack/widgets/_impl/mixins/configure_mixin.py +216 -0
  376. bootstack/widgets/_impl/mixins/entry_mixin.py +134 -0
  377. bootstack/widgets/_impl/mixins/font_mixin.py +368 -0
  378. bootstack/widgets/_impl/mixins/icon_mixin.py +61 -0
  379. bootstack/widgets/_impl/mixins/localization_mixin.py +253 -0
  380. bootstack/widgets/_impl/mixins/signal_mixin.py +268 -0
  381. bootstack/widgets/_impl/mixins/validation_mixin.py +226 -0
  382. bootstack/widgets/_impl/primitives/__init__.py +49 -0
  383. bootstack/widgets/_impl/primitives/_menubutton.py +107 -0
  384. bootstack/widgets/_impl/primitives/badge.py +45 -0
  385. bootstack/widgets/_impl/primitives/button.py +76 -0
  386. bootstack/widgets/_impl/primitives/card.py +45 -0
  387. bootstack/widgets/_impl/primitives/checkbutton.py +124 -0
  388. bootstack/widgets/_impl/primitives/checktoggle.py +62 -0
  389. bootstack/widgets/_impl/primitives/combobox.py +156 -0
  390. bootstack/widgets/_impl/primitives/entry.py +87 -0
  391. bootstack/widgets/_impl/primitives/flexframe.py +448 -0
  392. bootstack/widgets/_impl/primitives/frame.py +185 -0
  393. bootstack/widgets/_impl/primitives/gridframe.py +546 -0
  394. bootstack/widgets/_impl/primitives/label.py +84 -0
  395. bootstack/widgets/_impl/primitives/labelframe.py +54 -0
  396. bootstack/widgets/_impl/primitives/optionmenu.py +387 -0
  397. bootstack/widgets/_impl/primitives/packframe.py +227 -0
  398. bootstack/widgets/_impl/primitives/panedwindow.py +45 -0
  399. bootstack/widgets/_impl/primitives/progressbar.py +83 -0
  400. bootstack/widgets/_impl/primitives/radiobutton.py +115 -0
  401. bootstack/widgets/_impl/primitives/radiotoggle.py +54 -0
  402. bootstack/widgets/_impl/primitives/scrollbar.py +42 -0
  403. bootstack/widgets/_impl/primitives/separator.py +43 -0
  404. bootstack/widgets/_impl/primitives/sizegrip.py +33 -0
  405. bootstack/widgets/_impl/primitives/spinbox.py +95 -0
  406. bootstack/widgets/_impl/primitives/switch.py +44 -0
  407. bootstack/widgets/_impl/primitives/treeview.py +69 -0
  408. bootstack/widgets/app.py +371 -0
  409. bootstack/widgets/appshell.py +1179 -0
  410. bootstack/widgets/avatar.py +140 -0
  411. bootstack/widgets/boolean_controls.py +455 -0
  412. bootstack/widgets/button.py +224 -0
  413. bootstack/widgets/buttongroup.py +239 -0
  414. bootstack/widgets/calendar.py +195 -0
  415. bootstack/widgets/card.py +159 -0
  416. bootstack/widgets/carousel.py +241 -0
  417. bootstack/widgets/chart.py +302 -0
  418. bootstack/widgets/codeeditor.py +675 -0
  419. bootstack/widgets/contextmenu.py +371 -0
  420. bootstack/widgets/datatable.py +688 -0
  421. bootstack/widgets/datefield.py +395 -0
  422. bootstack/widgets/divider.py +60 -0
  423. bootstack/widgets/expander.py +579 -0
  424. bootstack/widgets/form.py +200 -0
  425. bootstack/widgets/gallery.py +250 -0
  426. bootstack/widgets/gauge.py +168 -0
  427. bootstack/widgets/grid.py +121 -0
  428. bootstack/widgets/groupbox.py +162 -0
  429. bootstack/widgets/label.py +229 -0
  430. bootstack/widgets/listview.py +354 -0
  431. bootstack/widgets/menubutton.py +419 -0
  432. bootstack/widgets/numberfield.py +431 -0
  433. bootstack/widgets/pagestack.py +345 -0
  434. bootstack/widgets/passwordfield.py +383 -0
  435. bootstack/widgets/pathfield.py +454 -0
  436. bootstack/widgets/picture.py +251 -0
  437. bootstack/widgets/progressbar.py +106 -0
  438. bootstack/widgets/radio_variants.py +271 -0
  439. bootstack/widgets/radiogroup.py +228 -0
  440. bootstack/widgets/scrollbar.py +64 -0
  441. bootstack/widgets/scrollview.py +186 -0
  442. bootstack/widgets/select.py +302 -0
  443. bootstack/widgets/selectbutton.py +182 -0
  444. bootstack/widgets/sidebar_toggle.py +130 -0
  445. bootstack/widgets/sizegrip.py +42 -0
  446. bootstack/widgets/slider.py +408 -0
  447. bootstack/widgets/spinbox.py +147 -0
  448. bootstack/widgets/spinnerfield.py +413 -0
  449. bootstack/widgets/splash.py +367 -0
  450. bootstack/widgets/splitview.py +482 -0
  451. bootstack/widgets/stacks.py +249 -0
  452. bootstack/widgets/statusbar.py +189 -0
  453. bootstack/widgets/tabs.py +425 -0
  454. bootstack/widgets/textarea.py +459 -0
  455. bootstack/widgets/textfield.py +410 -0
  456. bootstack/widgets/theme_toggle.py +92 -0
  457. bootstack/widgets/timefield.py +340 -0
  458. bootstack/widgets/toast.py +314 -0
  459. bootstack/widgets/togglegroup.py +231 -0
  460. bootstack/widgets/toolbar.py +347 -0
  461. bootstack/widgets/tooltip.py +94 -0
  462. bootstack/widgets/tree.py +648 -0
  463. bootstack/widgets/types.py +305 -0
  464. bootstack/widgets/window.py +307 -0
  465. bootstack-0.1.0.dist-info/METADATA +301 -0
  466. bootstack-0.1.0.dist-info/RECORD +471 -0
  467. bootstack-0.1.0.dist-info/WHEEL +5 -0
  468. bootstack-0.1.0.dist-info/entry_points.txt +2 -0
  469. bootstack-0.1.0.dist-info/licenses/LICENSE +21 -0
  470. bootstack-0.1.0.dist-info/licenses/NOTICE +10 -0
  471. 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()]