sqliter-py 0.12.0__py3-none-any.whl → 0.17.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 (43) hide show
  1. sqliter/constants.py +4 -3
  2. sqliter/exceptions.py +29 -0
  3. sqliter/helpers.py +27 -0
  4. sqliter/model/model.py +21 -4
  5. sqliter/orm/__init__.py +17 -0
  6. sqliter/orm/fields.py +412 -0
  7. sqliter/orm/foreign_key.py +8 -0
  8. sqliter/orm/m2m.py +784 -0
  9. sqliter/orm/model.py +308 -0
  10. sqliter/orm/query.py +221 -0
  11. sqliter/orm/registry.py +440 -0
  12. sqliter/query/query.py +573 -51
  13. sqliter/sqliter.py +182 -47
  14. sqliter/tui/__init__.py +62 -0
  15. sqliter/tui/__main__.py +6 -0
  16. sqliter/tui/app.py +179 -0
  17. sqliter/tui/demos/__init__.py +96 -0
  18. sqliter/tui/demos/base.py +114 -0
  19. sqliter/tui/demos/caching.py +283 -0
  20. sqliter/tui/demos/connection.py +150 -0
  21. sqliter/tui/demos/constraints.py +211 -0
  22. sqliter/tui/demos/crud.py +154 -0
  23. sqliter/tui/demos/errors.py +231 -0
  24. sqliter/tui/demos/field_selection.py +150 -0
  25. sqliter/tui/demos/filters.py +389 -0
  26. sqliter/tui/demos/models.py +248 -0
  27. sqliter/tui/demos/ordering.py +156 -0
  28. sqliter/tui/demos/orm.py +537 -0
  29. sqliter/tui/demos/results.py +241 -0
  30. sqliter/tui/demos/string_filters.py +210 -0
  31. sqliter/tui/demos/timestamps.py +126 -0
  32. sqliter/tui/demos/transactions.py +177 -0
  33. sqliter/tui/runner.py +116 -0
  34. sqliter/tui/styles/app.tcss +130 -0
  35. sqliter/tui/widgets/__init__.py +7 -0
  36. sqliter/tui/widgets/code_display.py +81 -0
  37. sqliter/tui/widgets/demo_list.py +65 -0
  38. sqliter/tui/widgets/output_display.py +92 -0
  39. {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/METADATA +28 -14
  40. sqliter_py-0.17.0.dist-info/RECORD +48 -0
  41. {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/WHEEL +2 -2
  42. sqliter_py-0.17.0.dist-info/entry_points.txt +3 -0
  43. sqliter_py-0.12.0.dist-info/RECORD +0 -15
@@ -0,0 +1,177 @@
1
+ """Transaction demos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from sqliter import SqliterDB
10
+ from sqliter.model import BaseDBModel
11
+ from sqliter.tui.demos.base import Demo, DemoCategory, extract_demo_code
12
+
13
+
14
+ def _run_context_manager_transaction() -> str:
15
+ """Use context manager for automatic transaction management.
16
+
17
+ The `with db:` block auto-commits on success and rolls back on error.
18
+ """
19
+ output = io.StringIO()
20
+
21
+ class Account(BaseDBModel):
22
+ name: str
23
+ balance: float
24
+
25
+ db = SqliterDB(memory=True)
26
+ db.create_table(Account)
27
+
28
+ alice: Account = db.insert(Account(name="Alice", balance=100.0))
29
+ bob: Account = db.insert(Account(name="Bob", balance=50.0))
30
+
31
+ output.write(f"Before: Alice=${alice.balance}, Bob=${bob.balance}\n")
32
+
33
+ # Transfer money using context manager
34
+ with db:
35
+ alice.balance = alice.balance - 20.0
36
+ bob.balance = bob.balance + 20.0
37
+ db.update(alice)
38
+ db.update(bob)
39
+ alice_updated = alice
40
+ bob_updated = bob
41
+
42
+ output.write(
43
+ f"After: Alice=${alice_updated.balance}, Bob=${bob_updated.balance}\n"
44
+ )
45
+ output.write("Transaction auto-committed on success\n")
46
+
47
+ db.close()
48
+ return output.getvalue()
49
+
50
+
51
+ def _run_rollback() -> str:
52
+ """Demonstrate transaction rollback behavior.
53
+
54
+ When an exception occurs inside a `with db:` block, all changes made
55
+ within that transaction are automatically rolled back.
56
+ """
57
+ output = io.StringIO()
58
+
59
+ class Item(BaseDBModel):
60
+ name: str
61
+ quantity: int
62
+
63
+ # Use file database so we can reconnect after connection closes
64
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
65
+ db_path = f.name
66
+
67
+ db = None
68
+ try:
69
+ db = SqliterDB(db_filename=db_path)
70
+ db.create_table(Item)
71
+
72
+ item: Item = db.insert(Item(name="Widget", quantity=10))
73
+ output.write(f"Initial quantity: {item.quantity}\n")
74
+
75
+ # Use context manager for automatic rollback on error
76
+ try:
77
+ with db:
78
+ item.quantity = 5
79
+ db.update(item)
80
+ output.write("Inside transaction: updated to 5\n")
81
+ # If error occurs, changes are rolled back
82
+ error_msg = "Intentional error for rollback"
83
+ raise RuntimeError(error_msg) # noqa: TRY301
84
+ except RuntimeError:
85
+ output.write("Error occurred - transaction rolled back\n")
86
+ # Verify rollback with NEW connection
87
+ db2 = SqliterDB(db_filename=db_path)
88
+ try:
89
+ restored = db2.get(Item, item.pk)
90
+ if restored is not None:
91
+ qty_attr = "quantity" # db.get returns BaseDBModel
92
+ restored_quantity = getattr(restored, qty_attr)
93
+ msg = f"Database value: {restored_quantity}\n"
94
+ output.write(msg)
95
+ expected_quantity = 10
96
+ if restored_quantity == expected_quantity:
97
+ output.write("✓ Rollback worked correctly\n")
98
+ else:
99
+ msg = (
100
+ f"✗ Rollback failed: expected {expected_quantity}, "
101
+ f"got {restored_quantity}\n"
102
+ )
103
+ output.write(msg)
104
+ finally:
105
+ db2.close()
106
+ finally:
107
+ if db is not None:
108
+ db.close()
109
+ Path(db_path).unlink(missing_ok=True)
110
+
111
+ return output.getvalue()
112
+
113
+
114
+ def _run_manual_commit() -> str:
115
+ """Manually control transactions with explicit commit.
116
+
117
+ Call db.commit() to persist changes when not using context manager.
118
+ """
119
+ output = io.StringIO()
120
+
121
+ class Log(BaseDBModel):
122
+ message: str
123
+
124
+ db = SqliterDB(memory=True)
125
+ db.create_table(Log)
126
+
127
+ # Manual transaction control
128
+ db.connect()
129
+ log1 = db.insert(Log(message="First entry"))
130
+ output.write(f"Inserted: {log1.message}\n")
131
+ output.write("Not committed yet\n")
132
+ db.commit()
133
+ output.write("Committed\n")
134
+
135
+ db.insert(Log(message="Second entry"))
136
+ db.commit()
137
+
138
+ all_logs = db.select(Log).fetch_all()
139
+ output.write(f"Total logs: {len(all_logs)}\n")
140
+
141
+ db.close()
142
+ return output.getvalue()
143
+
144
+
145
+ def get_category() -> DemoCategory:
146
+ """Get the Transactions demo category."""
147
+ return DemoCategory(
148
+ id="transactions",
149
+ title="Transactions",
150
+ icon="",
151
+ demos=[
152
+ Demo(
153
+ id="txn_context",
154
+ title="Context Manager",
155
+ description="Auto commit/rollback with 'with' statement",
156
+ category="transactions",
157
+ code=extract_demo_code(_run_context_manager_transaction),
158
+ execute=_run_context_manager_transaction,
159
+ ),
160
+ Demo(
161
+ id="txn_rollback",
162
+ title="Rollback",
163
+ description="Automatic rollback on errors",
164
+ category="transactions",
165
+ code=extract_demo_code(_run_rollback),
166
+ execute=_run_rollback,
167
+ ),
168
+ Demo(
169
+ id="txn_manual",
170
+ title="Manual Commit",
171
+ description="Manually control transactions",
172
+ category="transactions",
173
+ code=extract_demo_code(_run_manual_commit),
174
+ execute=_run_manual_commit,
175
+ ),
176
+ ],
177
+ )
sqliter/tui/runner.py ADDED
@@ -0,0 +1,116 @@
1
+ """Demo execution engine with output capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import traceback
8
+ from contextlib import redirect_stderr, redirect_stdout
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from sqliter.tui.demos.base import Demo
14
+
15
+
16
+ @dataclass
17
+ class ExecutionResult:
18
+ """Result of executing a demo.
19
+
20
+ Attributes:
21
+ success: Whether the demo ran without exceptions
22
+ output: Combined stdout/stderr and returned output
23
+ error: Exception message if failed
24
+ traceback: Full traceback string if failed
25
+ """
26
+
27
+ success: bool
28
+ output: str
29
+ error: str | None = None
30
+ traceback: str | None = None
31
+
32
+
33
+ class DemoRunner:
34
+ """Executes demos and captures all output."""
35
+
36
+ def __init__(self) -> None:
37
+ """Initialize the demo runner."""
38
+ self._last_result: ExecutionResult | None = None
39
+
40
+ def run(self, demo: Demo) -> ExecutionResult:
41
+ """Execute a demo and capture all output.
42
+
43
+ Args:
44
+ demo: The demo to execute.
45
+
46
+ Returns:
47
+ ExecutionResult with output and status.
48
+ """
49
+ stdout_capture = io.StringIO()
50
+ stderr_capture = io.StringIO()
51
+
52
+ try:
53
+ with (
54
+ redirect_stdout(stdout_capture),
55
+ redirect_stderr(stderr_capture),
56
+ ):
57
+ output = demo.execute()
58
+
59
+ # Combine all output sources
60
+ combined = ""
61
+ stdout_val = stdout_capture.getvalue()
62
+ stderr_val = stderr_capture.getvalue()
63
+
64
+ if stdout_val:
65
+ combined += stdout_val
66
+ if output:
67
+ if combined and not combined.endswith("\n"):
68
+ combined += "\n"
69
+ combined += output
70
+ if stderr_val:
71
+ combined += f"\n[stderr]\n{stderr_val}"
72
+
73
+ self._last_result = ExecutionResult(
74
+ success=True,
75
+ output=combined or "(No output)",
76
+ )
77
+
78
+ # Catching Exception is necessary here since demo code may raise any
79
+ # type
80
+ except Exception: # noqa: BLE001
81
+ tb = traceback.format_exc()
82
+
83
+ # Combine stdout and stderr for error output
84
+ combined_output = stdout_capture.getvalue()
85
+ stderr_val = stderr_capture.getvalue()
86
+ if stderr_val:
87
+ combined_output += f"\n[stderr]\n{stderr_val}"
88
+
89
+ self._last_result = ExecutionResult(
90
+ success=False,
91
+ output=combined_output,
92
+ error="Exception in demo code",
93
+ traceback=tb,
94
+ )
95
+
96
+ finally:
97
+ # Run teardown if defined, ignoring any errors
98
+ if demo.teardown:
99
+ with contextlib.suppress(Exception):
100
+ demo.teardown()
101
+
102
+ return self._last_result
103
+
104
+ @property
105
+ def last_result(self) -> ExecutionResult | None:
106
+ """Get the last execution result."""
107
+ return self._last_result
108
+
109
+
110
+ # Global runner instance
111
+ _runner = DemoRunner()
112
+
113
+
114
+ def run_demo(demo: Demo) -> ExecutionResult:
115
+ """Run a demo using the global runner."""
116
+ return _runner.run(demo)
@@ -0,0 +1,130 @@
1
+ /* Main application styles for SQLiter TUI Demo */
2
+
3
+ Screen {
4
+ background: $surface;
5
+ }
6
+
7
+ #main-container {
8
+ layout: grid;
9
+ grid-size: 2;
10
+ grid-columns: auto 1fr;
11
+ height: 100%;
12
+ padding: 0 1;
13
+ }
14
+
15
+ /* Left panel - Demo list */
16
+ #demo-list {
17
+ border: solid $primary;
18
+ border-title-color: $text;
19
+ padding: 1;
20
+ }
21
+
22
+ #demo-list Tree {
23
+ padding: 0;
24
+ }
25
+
26
+ #demo-list Tree > .tree--cursor {
27
+ background: $primary;
28
+ color: $text;
29
+ }
30
+
31
+ #demo-list Tree > .tree--highlight {
32
+ background: $primary 30%;
33
+ }
34
+
35
+ /* Right panel container */
36
+ #right-panel {
37
+ padding-left: 1;
38
+ }
39
+
40
+ /* Code display */
41
+ #code-display {
42
+ height: 55%;
43
+ border: solid $primary;
44
+ border-title-color: $text;
45
+ padding: 1;
46
+ }
47
+
48
+ #code-display Static {
49
+ padding: 0;
50
+ }
51
+
52
+ /* Output display */
53
+ #output-display {
54
+ height: 35%;
55
+ border: solid $secondary;
56
+ border-title-color: $text;
57
+ padding: 1;
58
+ }
59
+
60
+ #output-display.success {
61
+ border: solid $success;
62
+ }
63
+
64
+ #output-display.error {
65
+ border: solid $error;
66
+ }
67
+
68
+ #output-display Static {
69
+ padding: 0;
70
+ }
71
+
72
+ /* Button bar */
73
+ #button-bar {
74
+ height: auto;
75
+ align: center middle;
76
+ padding: 1 0;
77
+ }
78
+
79
+ #button-bar Button {
80
+ margin: 0 1;
81
+ }
82
+
83
+ #run-btn {
84
+ background: $success;
85
+ }
86
+
87
+ #clear-btn {
88
+ background: $primary-darken-2;
89
+ }
90
+
91
+ /* Help screen */
92
+ HelpScreen {
93
+ align: center middle;
94
+ }
95
+
96
+ #help-scroll {
97
+ width: 70;
98
+ height: auto;
99
+ max-height: 80%;
100
+ padding: 2;
101
+ border: solid $primary;
102
+ background: $surface;
103
+ }
104
+
105
+ #help-content {
106
+ padding: 0;
107
+ }
108
+
109
+ /* Footer styling */
110
+ Footer {
111
+ background: $primary-darken-3;
112
+ }
113
+
114
+ /* Header styling */
115
+ Header {
116
+ background: $primary;
117
+ }
118
+
119
+ /* Scrollbars */
120
+ ScrollableContainer > .scrollbar {
121
+ background: $surface;
122
+ }
123
+
124
+ ScrollableContainer > .scrollbar--bar {
125
+ background: $primary 50%;
126
+ }
127
+
128
+ ScrollableContainer > .scrollbar--bar:hover {
129
+ background: $primary;
130
+ }
@@ -0,0 +1,7 @@
1
+ """TUI widgets for SQLiter demo app."""
2
+
3
+ from sqliter.tui.widgets.code_display import CodeDisplay
4
+ from sqliter.tui.widgets.demo_list import DemoList, DemoSelected
5
+ from sqliter.tui.widgets.output_display import OutputDisplay
6
+
7
+ __all__ = ["CodeDisplay", "DemoList", "DemoSelected", "OutputDisplay"]
@@ -0,0 +1,81 @@
1
+ """Syntax-highlighted code display widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.syntax import Syntax
8
+ from textual.containers import ScrollableContainer
9
+ from textual.css.query import NoMatches
10
+ from textual.widgets import Static
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from textual.app import ComposeResult
14
+
15
+
16
+ class CodeDisplay(ScrollableContainer):
17
+ """Display syntax-highlighted Python code."""
18
+
19
+ def __init__(
20
+ self,
21
+ code: str = "",
22
+ *,
23
+ widget_id: str | None = None,
24
+ classes: str | None = None,
25
+ ) -> None:
26
+ """Initialize the code display.
27
+
28
+ Args:
29
+ code: Initial code to display.
30
+ widget_id: Widget ID.
31
+ classes: CSS classes for the widget.
32
+ """
33
+ super().__init__(id=widget_id, classes=classes)
34
+ self._code = code
35
+
36
+ def compose(self) -> ComposeResult:
37
+ """Compose the code display."""
38
+ yield Static(id="code-content")
39
+
40
+ def on_mount(self) -> None:
41
+ """Initialize the display on mount."""
42
+ self._update_display()
43
+
44
+ @property
45
+ def code(self) -> str:
46
+ """Get the current code."""
47
+ return self._code
48
+
49
+ @code.setter
50
+ def code(self, value: str) -> None:
51
+ """Set new code and update display."""
52
+ self._code = value
53
+ self._update_display()
54
+
55
+ def _update_display(self) -> None:
56
+ """Update the code display with syntax highlighting."""
57
+ try:
58
+ content = self.query_one("#code-content", Static)
59
+ except NoMatches:
60
+ return # Not mounted yet
61
+
62
+ if not self._code:
63
+ content.update("Select a demo to view code")
64
+ return
65
+
66
+ # Use Rich's Syntax for highlighting
67
+ syntax = Syntax(
68
+ self._code.strip(),
69
+ "python",
70
+ theme="monokai",
71
+ line_numbers=True,
72
+ word_wrap=True,
73
+ )
74
+ content.update(syntax)
75
+
76
+ # Scroll to top when content changes
77
+ self.scroll_home()
78
+
79
+ def set_code(self, code: str) -> None:
80
+ """Set the code to display (public API)."""
81
+ self.code = code
@@ -0,0 +1,65 @@
1
+ """Demo list widget with expandable categories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.containers import ScrollableContainer
8
+ from textual.message import Message
9
+ from textual.widgets import Tree
10
+
11
+ from sqliter.tui.demos import DemoRegistry
12
+ from sqliter.tui.demos.base import Demo, DemoCategory
13
+
14
+ if TYPE_CHECKING: # pragma: no cover
15
+ from textual.app import ComposeResult
16
+
17
+
18
+ class DemoSelected(Message):
19
+ """Message sent when a demo is selected."""
20
+
21
+ def __init__(self, demo: Demo) -> None:
22
+ """Initialize the message.
23
+
24
+ Args:
25
+ demo: The demo that was selected.
26
+ """
27
+ self.demo = demo
28
+ super().__init__()
29
+
30
+
31
+ class DemoList(ScrollableContainer):
32
+ """Scrollable tree of demo categories and items."""
33
+
34
+ def compose(self) -> ComposeResult:
35
+ """Compose the demo tree."""
36
+ tree: Tree[Demo | DemoCategory] = Tree("SQLiter Demos", id="demo-tree")
37
+ tree.show_root = False
38
+
39
+ # Populate tree with categories and demos
40
+ for category in DemoRegistry.get_categories():
41
+ label = (
42
+ f"{category.icon} {category.title}"
43
+ if category.icon
44
+ else category.title
45
+ )
46
+ cat_node = tree.root.add(
47
+ label,
48
+ data=category,
49
+ expand=category.expanded,
50
+ )
51
+ for demo in category.demos:
52
+ cat_node.add_leaf(
53
+ demo.title,
54
+ data=demo,
55
+ )
56
+
57
+ yield tree
58
+
59
+ def on_tree_node_selected(
60
+ self,
61
+ event: Tree.NodeSelected[Demo | DemoCategory],
62
+ ) -> None:
63
+ """Handle tree node selection."""
64
+ if isinstance(event.node.data, Demo):
65
+ self.post_message(DemoSelected(event.node.data))
@@ -0,0 +1,92 @@
1
+ """Output display widget with status indication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.text import Text
8
+ from textual.containers import ScrollableContainer
9
+ from textual.widgets import Static
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from textual.app import ComposeResult
13
+
14
+
15
+ class OutputDisplay(ScrollableContainer):
16
+ """Display demo execution output with status styling."""
17
+
18
+ _PLACEHOLDER = "Run a demo to see output here"
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ widget_id: str | None = None,
24
+ classes: str | None = None,
25
+ ) -> None:
26
+ """Initialize the output display.
27
+
28
+ Args:
29
+ widget_id: Widget ID.
30
+ classes: CSS classes for the widget.
31
+ """
32
+ super().__init__(id=widget_id, classes=classes)
33
+
34
+ def compose(self) -> ComposeResult:
35
+ """Compose the output display."""
36
+ yield Static(self._PLACEHOLDER, id="output-content")
37
+
38
+ def show_output(self, output: str, *, success: bool = True) -> None:
39
+ """Display output from demo execution.
40
+
41
+ Args:
42
+ output: The output text to display
43
+ success: Whether the demo ran successfully
44
+ """
45
+ content = self.query_one("#output-content", Static)
46
+
47
+ self.remove_class("error", "success")
48
+ self.add_class("success" if success else "error")
49
+
50
+ if success:
51
+ text = Text()
52
+ text.append("Success\n\n", style="bold green")
53
+ text.append(output)
54
+ else:
55
+ text = Text()
56
+ text.append("Error\n\n", style="bold red")
57
+ text.append(output, style="red")
58
+
59
+ content.update(text)
60
+ self.scroll_home()
61
+
62
+ def show_error(
63
+ self,
64
+ error: str,
65
+ traceback_str: str | None = None,
66
+ ) -> None:
67
+ """Display an error message.
68
+
69
+ Args:
70
+ error: The error message
71
+ traceback_str: Optional full traceback
72
+ """
73
+ content = self.query_one("#output-content", Static)
74
+
75
+ self.remove_class("error", "success")
76
+ self.add_class("error")
77
+
78
+ text = Text()
79
+ text.append("Error\n\n", style="bold red")
80
+ text.append(error, style="red")
81
+ if traceback_str:
82
+ text.append("\n\nTraceback:\n", style="bold")
83
+ text.append(traceback_str, style="dim red")
84
+
85
+ content.update(text)
86
+ self.scroll_home()
87
+
88
+ def clear(self) -> None:
89
+ """Clear the output display."""
90
+ content = self.query_one("#output-content", Static)
91
+ content.update(self._PLACEHOLDER)
92
+ self.remove_class("error", "success")