sqliter-py 0.12.0__py3-none-any.whl → 0.16.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.
- sqliter/constants.py +4 -3
- sqliter/exceptions.py +13 -0
- sqliter/model/model.py +42 -3
- sqliter/orm/__init__.py +16 -0
- sqliter/orm/fields.py +412 -0
- sqliter/orm/foreign_key.py +8 -0
- sqliter/orm/model.py +243 -0
- sqliter/orm/query.py +221 -0
- sqliter/orm/registry.py +169 -0
- sqliter/query/query.py +573 -51
- sqliter/sqliter.py +141 -47
- sqliter/tui/__init__.py +62 -0
- sqliter/tui/__main__.py +6 -0
- sqliter/tui/app.py +179 -0
- sqliter/tui/demos/__init__.py +96 -0
- sqliter/tui/demos/base.py +114 -0
- sqliter/tui/demos/caching.py +283 -0
- sqliter/tui/demos/connection.py +150 -0
- sqliter/tui/demos/constraints.py +211 -0
- sqliter/tui/demos/crud.py +154 -0
- sqliter/tui/demos/errors.py +231 -0
- sqliter/tui/demos/field_selection.py +150 -0
- sqliter/tui/demos/filters.py +389 -0
- sqliter/tui/demos/models.py +248 -0
- sqliter/tui/demos/ordering.py +156 -0
- sqliter/tui/demos/orm.py +460 -0
- sqliter/tui/demos/results.py +241 -0
- sqliter/tui/demos/string_filters.py +210 -0
- sqliter/tui/demos/timestamps.py +126 -0
- sqliter/tui/demos/transactions.py +177 -0
- sqliter/tui/runner.py +116 -0
- sqliter/tui/styles/app.tcss +130 -0
- sqliter/tui/widgets/__init__.py +7 -0
- sqliter/tui/widgets/code_display.py +81 -0
- sqliter/tui/widgets/demo_list.py +65 -0
- sqliter/tui/widgets/output_display.py +92 -0
- {sqliter_py-0.12.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +23 -7
- sqliter_py-0.16.0.dist-info/RECORD +47 -0
- {sqliter_py-0.12.0.dist-info → sqliter_py-0.16.0.dist-info}/WHEEL +2 -2
- sqliter_py-0.16.0.dist-info/entry_points.txt +3 -0
- sqliter_py-0.12.0.dist-info/RECORD +0 -15
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""String Filter demos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
from sqliter import SqliterDB
|
|
8
|
+
from sqliter.model import BaseDBModel
|
|
9
|
+
from sqliter.tui.demos.base import Demo, DemoCategory, extract_demo_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_startswith() -> str:
|
|
13
|
+
"""Filter strings that start with a specific prefix.
|
|
14
|
+
|
|
15
|
+
Use __startswith to match records where a field begins with
|
|
16
|
+
the specified value.
|
|
17
|
+
"""
|
|
18
|
+
output = io.StringIO()
|
|
19
|
+
|
|
20
|
+
class User(BaseDBModel):
|
|
21
|
+
username: str
|
|
22
|
+
|
|
23
|
+
db = SqliterDB(memory=True)
|
|
24
|
+
db.create_table(User)
|
|
25
|
+
|
|
26
|
+
db.insert(User(username="alice_wonder"))
|
|
27
|
+
db.insert(User(username="alice_smith"))
|
|
28
|
+
db.insert(User(username="bob_builder"))
|
|
29
|
+
|
|
30
|
+
# Find usernames starting with "alice"
|
|
31
|
+
results = db.select(User).filter(username__startswith="alice").fetch_all()
|
|
32
|
+
output.write(f"Users starting with 'alice': {len(results)}\n")
|
|
33
|
+
for user in results:
|
|
34
|
+
output.write(f" - {user.username}\n")
|
|
35
|
+
|
|
36
|
+
db.close()
|
|
37
|
+
return output.getvalue()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_endswith() -> str:
|
|
41
|
+
"""Filter strings that end with a specific suffix.
|
|
42
|
+
|
|
43
|
+
Use __endswith to match records where a field ends with
|
|
44
|
+
the specified value.
|
|
45
|
+
"""
|
|
46
|
+
output = io.StringIO()
|
|
47
|
+
|
|
48
|
+
class File(BaseDBModel):
|
|
49
|
+
filename: str
|
|
50
|
+
|
|
51
|
+
db = SqliterDB(memory=True)
|
|
52
|
+
db.create_table(File)
|
|
53
|
+
|
|
54
|
+
db.insert(File(filename="document.txt"))
|
|
55
|
+
db.insert(File(filename="image.png"))
|
|
56
|
+
db.insert(File(filename="notes.txt"))
|
|
57
|
+
db.insert(File(filename="data.csv"))
|
|
58
|
+
|
|
59
|
+
# Find files ending with ".txt"
|
|
60
|
+
results = db.select(File).filter(filename__endswith=".txt").fetch_all()
|
|
61
|
+
output.write(f"Text files: {len(results)}\n")
|
|
62
|
+
for file in results:
|
|
63
|
+
output.write(f" - {file.filename}\n")
|
|
64
|
+
|
|
65
|
+
db.close()
|
|
66
|
+
return output.getvalue()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_contains() -> str:
|
|
70
|
+
"""Filter strings that contain a specific substring.
|
|
71
|
+
|
|
72
|
+
Use __contains to match records where a field contains
|
|
73
|
+
the specified value anywhere within it.
|
|
74
|
+
"""
|
|
75
|
+
output = io.StringIO()
|
|
76
|
+
|
|
77
|
+
class Product(BaseDBModel):
|
|
78
|
+
name: str
|
|
79
|
+
|
|
80
|
+
db = SqliterDB(memory=True)
|
|
81
|
+
db.create_table(Product)
|
|
82
|
+
|
|
83
|
+
db.insert(Product(name="Apple iPhone"))
|
|
84
|
+
db.insert(Product(name="Samsung Galaxy"))
|
|
85
|
+
db.insert(Product(name="Apple iPad"))
|
|
86
|
+
db.insert(Product(name="Google Pixel"))
|
|
87
|
+
|
|
88
|
+
# Find products containing "Apple"
|
|
89
|
+
results = db.select(Product).filter(name__contains="Apple").fetch_all()
|
|
90
|
+
output.write(f"Products containing 'Apple': {len(results)}\n")
|
|
91
|
+
for product in results:
|
|
92
|
+
output.write(f" - {product.name}\n")
|
|
93
|
+
|
|
94
|
+
db.close()
|
|
95
|
+
return output.getvalue()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_case_insensitive() -> str:
|
|
99
|
+
"""Filter strings ignoring case with __istartswith and __iendswith.
|
|
100
|
+
|
|
101
|
+
Use case-insensitive operators to match strings regardless of
|
|
102
|
+
capitalization.
|
|
103
|
+
"""
|
|
104
|
+
output = io.StringIO()
|
|
105
|
+
|
|
106
|
+
class User(BaseDBModel):
|
|
107
|
+
email: str
|
|
108
|
+
|
|
109
|
+
db = SqliterDB(memory=True)
|
|
110
|
+
db.create_table(User)
|
|
111
|
+
|
|
112
|
+
db.insert(User(email="ALICE@example.com"))
|
|
113
|
+
db.insert(User(email="bob@EXAMPLE.com"))
|
|
114
|
+
db.insert(User(email="charlie@test.com"))
|
|
115
|
+
|
|
116
|
+
# Find emails ending with "@example.com" (case-insensitive)
|
|
117
|
+
results = (
|
|
118
|
+
db.select(User).filter(email__iendswith="@example.com").fetch_all()
|
|
119
|
+
)
|
|
120
|
+
output.write(f"Emails ending with '@example.com': {len(results)}\n")
|
|
121
|
+
for user in results:
|
|
122
|
+
output.write(f" - {user.email}\n")
|
|
123
|
+
|
|
124
|
+
# Find emails starting with "BOB" (case-insensitive)
|
|
125
|
+
bob_results = db.select(User).filter(email__istartswith="BOB").fetch_all()
|
|
126
|
+
output.write(f"\nEmails starting with 'BOB': {len(bob_results)}\n")
|
|
127
|
+
for user in bob_results:
|
|
128
|
+
output.write(f" - {user.email}\n")
|
|
129
|
+
|
|
130
|
+
db.close()
|
|
131
|
+
return output.getvalue()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_icontains() -> str:
|
|
135
|
+
"""Filter strings containing a substring, ignoring case.
|
|
136
|
+
|
|
137
|
+
Use __icontains for case-insensitive substring matching.
|
|
138
|
+
"""
|
|
139
|
+
output = io.StringIO()
|
|
140
|
+
|
|
141
|
+
class Article(BaseDBModel):
|
|
142
|
+
title: str
|
|
143
|
+
|
|
144
|
+
db = SqliterDB(memory=True)
|
|
145
|
+
db.create_table(Article)
|
|
146
|
+
|
|
147
|
+
db.insert(Article(title="Python Programming Guide"))
|
|
148
|
+
db.insert(Article(title="Advanced PYTHON Techniques"))
|
|
149
|
+
db.insert(Article(title="Web Development"))
|
|
150
|
+
db.insert(Article(title="python for Beginners"))
|
|
151
|
+
|
|
152
|
+
# Find articles containing "python" (case-insensitive)
|
|
153
|
+
results = db.select(Article).filter(title__icontains="python").fetch_all()
|
|
154
|
+
output.write(f"Articles containing 'python': {len(results)}\n")
|
|
155
|
+
for article in results:
|
|
156
|
+
output.write(f" - {article.title}\n")
|
|
157
|
+
|
|
158
|
+
db.close()
|
|
159
|
+
return output.getvalue()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_category() -> DemoCategory:
|
|
163
|
+
"""Get the String Filters demo category."""
|
|
164
|
+
return DemoCategory(
|
|
165
|
+
id="string_filters",
|
|
166
|
+
title="String Filters",
|
|
167
|
+
icon="",
|
|
168
|
+
demos=[
|
|
169
|
+
Demo(
|
|
170
|
+
id="string_startswith",
|
|
171
|
+
title="Starts With (__startswith)",
|
|
172
|
+
description="Match strings starting with prefix",
|
|
173
|
+
category="string_filters",
|
|
174
|
+
code=extract_demo_code(_run_startswith),
|
|
175
|
+
execute=_run_startswith,
|
|
176
|
+
),
|
|
177
|
+
Demo(
|
|
178
|
+
id="string_endswith",
|
|
179
|
+
title="Ends With (__endswith)",
|
|
180
|
+
description="Match strings ending with suffix",
|
|
181
|
+
category="string_filters",
|
|
182
|
+
code=extract_demo_code(_run_endswith),
|
|
183
|
+
execute=_run_endswith,
|
|
184
|
+
),
|
|
185
|
+
Demo(
|
|
186
|
+
id="string_contains",
|
|
187
|
+
title="Contains (__contains)",
|
|
188
|
+
description="Match strings containing substring",
|
|
189
|
+
category="string_filters",
|
|
190
|
+
code=extract_demo_code(_run_contains),
|
|
191
|
+
execute=_run_contains,
|
|
192
|
+
),
|
|
193
|
+
Demo(
|
|
194
|
+
id="string_case_insensitive",
|
|
195
|
+
title="Case-Insensitive (__i*)",
|
|
196
|
+
description="Match strings ignoring case",
|
|
197
|
+
category="string_filters",
|
|
198
|
+
code=extract_demo_code(_run_case_insensitive),
|
|
199
|
+
execute=_run_case_insensitive,
|
|
200
|
+
),
|
|
201
|
+
Demo(
|
|
202
|
+
id="string_icontains",
|
|
203
|
+
title="Contains Case-Insensitive",
|
|
204
|
+
description="Match substring ignoring case",
|
|
205
|
+
category="string_filters",
|
|
206
|
+
code=extract_demo_code(_run_icontains),
|
|
207
|
+
execute=_run_icontains,
|
|
208
|
+
),
|
|
209
|
+
],
|
|
210
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Auto Timestamp demos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
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_created_at() -> str:
|
|
15
|
+
"""Automatically track when records are created.
|
|
16
|
+
|
|
17
|
+
The created_at field is set automatically when you insert a record.
|
|
18
|
+
"""
|
|
19
|
+
output = io.StringIO()
|
|
20
|
+
|
|
21
|
+
class Article(BaseDBModel):
|
|
22
|
+
title: str
|
|
23
|
+
|
|
24
|
+
db = SqliterDB(memory=True)
|
|
25
|
+
db.create_table(Article)
|
|
26
|
+
|
|
27
|
+
article1 = db.insert(Article(title="First Post"))
|
|
28
|
+
dt1 = datetime.fromtimestamp(article1.created_at, tz=timezone.utc)
|
|
29
|
+
formatted_dt1 = dt1.strftime("%Y-%m-%d %H:%M:%S")
|
|
30
|
+
output.write(f"Article: {article1.title}\n")
|
|
31
|
+
output.write(f"Created: {article1.created_at} ({formatted_dt1} UTC)\n")
|
|
32
|
+
|
|
33
|
+
time.sleep(0.1)
|
|
34
|
+
|
|
35
|
+
article2 = db.insert(Article(title="Second Post"))
|
|
36
|
+
dt2 = datetime.fromtimestamp(article2.created_at, tz=timezone.utc)
|
|
37
|
+
formatted_dt2 = dt2.strftime("%Y-%m-%d %H:%M:%S")
|
|
38
|
+
output.write(f"\nArticle: {article2.title}\n")
|
|
39
|
+
output.write(f"Created: {article2.created_at} ({formatted_dt2} UTC)\n")
|
|
40
|
+
|
|
41
|
+
db.close()
|
|
42
|
+
return output.getvalue()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_updated_at() -> str:
|
|
46
|
+
"""Automatically track when records are last modified.
|
|
47
|
+
|
|
48
|
+
The updated_at field changes automatically when you update a record.
|
|
49
|
+
"""
|
|
50
|
+
output = io.StringIO()
|
|
51
|
+
|
|
52
|
+
class Task(BaseDBModel):
|
|
53
|
+
title: str
|
|
54
|
+
done: bool = False
|
|
55
|
+
|
|
56
|
+
db = SqliterDB(memory=True)
|
|
57
|
+
db.create_table(Task)
|
|
58
|
+
|
|
59
|
+
task = db.insert(Task(title="Original Task"))
|
|
60
|
+
created_dt = datetime.fromtimestamp(task.created_at, tz=timezone.utc)
|
|
61
|
+
updated_dt = datetime.fromtimestamp(task.updated_at, tz=timezone.utc)
|
|
62
|
+
formatted_created_dt = created_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
63
|
+
formatted_updated_dt = updated_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
64
|
+
output.write(f"Task: {task.title}\n")
|
|
65
|
+
output.write(f"Created: {task.created_at} ({formatted_created_dt} UTC)\n")
|
|
66
|
+
output.write(f"Updated: {task.updated_at} ({formatted_updated_dt} UTC)\n")
|
|
67
|
+
|
|
68
|
+
# Sleep for 1 second to ensure different timestamps on fast machines
|
|
69
|
+
time.sleep(1)
|
|
70
|
+
|
|
71
|
+
task.title = "Updated Task"
|
|
72
|
+
task.done = True
|
|
73
|
+
db.update(task)
|
|
74
|
+
updated_task = task
|
|
75
|
+
updated_created_dt = datetime.fromtimestamp(
|
|
76
|
+
updated_task.created_at, tz=timezone.utc
|
|
77
|
+
)
|
|
78
|
+
updated_updated_dt = datetime.fromtimestamp(
|
|
79
|
+
updated_task.updated_at, tz=timezone.utc
|
|
80
|
+
)
|
|
81
|
+
formatted_updated_created_dt = updated_created_dt.strftime(
|
|
82
|
+
"%Y-%m-%d %H:%M:%S"
|
|
83
|
+
)
|
|
84
|
+
formatted_updated_updated_dt = updated_updated_dt.strftime(
|
|
85
|
+
"%Y-%m-%d %H:%M:%S"
|
|
86
|
+
)
|
|
87
|
+
output.write("\nAfter update:\n")
|
|
88
|
+
output.write(f"Title: {updated_task.title}\n")
|
|
89
|
+
output.write(
|
|
90
|
+
f"Created: {updated_task.created_at} "
|
|
91
|
+
f"({formatted_updated_created_dt} UTC)\n"
|
|
92
|
+
)
|
|
93
|
+
output.write(
|
|
94
|
+
f"Updated: {updated_task.updated_at} "
|
|
95
|
+
f"({formatted_updated_updated_dt} UTC)\n"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
db.close()
|
|
99
|
+
return output.getvalue()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_category() -> DemoCategory:
|
|
103
|
+
"""Get the Auto Timestamp demo category."""
|
|
104
|
+
return DemoCategory(
|
|
105
|
+
id="timestamps",
|
|
106
|
+
title="Auto Timestamps",
|
|
107
|
+
icon="",
|
|
108
|
+
demos=[
|
|
109
|
+
Demo(
|
|
110
|
+
id="timestamp_created",
|
|
111
|
+
title="Auto created_at",
|
|
112
|
+
description="Track when records are created",
|
|
113
|
+
category="timestamps",
|
|
114
|
+
code=extract_demo_code(_run_created_at),
|
|
115
|
+
execute=_run_created_at,
|
|
116
|
+
),
|
|
117
|
+
Demo(
|
|
118
|
+
id="timestamp_updated",
|
|
119
|
+
title="Auto updated_at",
|
|
120
|
+
description="Track when records are modified",
|
|
121
|
+
category="timestamps",
|
|
122
|
+
code=extract_demo_code(_run_updated_at),
|
|
123
|
+
execute=_run_updated_at,
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
@@ -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)
|