sqliter-py 0.9.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 +43 -0
- sqliter/model/__init__.py +38 -3
- sqliter/model/foreign_key.py +153 -0
- sqliter/model/model.py +42 -3
- sqliter/model/unique.py +20 -11
- 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 +720 -69
- sqliter/sqliter.py +533 -76
- 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.9.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +27 -11
- sqliter_py-0.16.0.dist-info/RECORD +47 -0
- {sqliter_py-0.9.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.9.0.dist-info/RECORD +0 -14
sqliter/tui/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""TUI demo application for SQLiter.
|
|
2
|
+
|
|
3
|
+
This module provides an interactive terminal-based demonstration of SQLiter
|
|
4
|
+
features using the Textual library.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python -m sqliter.tui
|
|
8
|
+
# or
|
|
9
|
+
sqliter-demo
|
|
10
|
+
|
|
11
|
+
Requires:
|
|
12
|
+
uv add sqliter-py[demo]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib.util import find_spec
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
_TEXTUAL_AVAILABLE = find_spec("textual") is not None
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
23
|
+
from sqliter.tui.app import SQLiterDemoApp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _missing_dependency_error() -> None:
|
|
27
|
+
"""Raise informative error when textual is not installed."""
|
|
28
|
+
msg = (
|
|
29
|
+
"The SQLiter TUI demo requires the 'textual' library.\n"
|
|
30
|
+
"Install it with: uv add sqliter-py[demo]\n"
|
|
31
|
+
)
|
|
32
|
+
raise ImportError(msg)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_app() -> SQLiterDemoApp:
|
|
36
|
+
"""Get the TUI application instance.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The SQLiterDemoApp instance.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ImportError: If textual is not installed.
|
|
43
|
+
"""
|
|
44
|
+
if not _TEXTUAL_AVAILABLE:
|
|
45
|
+
_missing_dependency_error()
|
|
46
|
+
|
|
47
|
+
from sqliter.tui.app import SQLiterDemoApp # noqa: PLC0415
|
|
48
|
+
|
|
49
|
+
return SQLiterDemoApp()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run() -> None:
|
|
53
|
+
"""Run the TUI demo application.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ImportError: If textual is not installed.
|
|
57
|
+
"""
|
|
58
|
+
app = get_app()
|
|
59
|
+
app.run()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["get_app", "run"]
|
sqliter/tui/__main__.py
ADDED
sqliter/tui/app.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Main SQLiter TUI demo application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding, BindingType
|
|
10
|
+
from textual.containers import (
|
|
11
|
+
Container,
|
|
12
|
+
Horizontal,
|
|
13
|
+
Vertical,
|
|
14
|
+
VerticalScroll,
|
|
15
|
+
)
|
|
16
|
+
from textual.css.query import NoMatches
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import Button, Footer, Header, Markdown, Tree
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
from sqliter.tui.demos.base import Demo
|
|
22
|
+
|
|
23
|
+
from sqliter.tui.demos import DemoRegistry
|
|
24
|
+
from sqliter.tui.runner import run_demo
|
|
25
|
+
from sqliter.tui.widgets import (
|
|
26
|
+
CodeDisplay,
|
|
27
|
+
DemoList,
|
|
28
|
+
DemoSelected,
|
|
29
|
+
OutputDisplay,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HelpScreen(ModalScreen[None]):
|
|
34
|
+
"""Modal help screen."""
|
|
35
|
+
|
|
36
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
37
|
+
Binding("escape", "dismiss", "Close"),
|
|
38
|
+
Binding("q", "dismiss", "Close"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
"""Compose the help screen."""
|
|
43
|
+
with VerticalScroll(id="help-scroll"):
|
|
44
|
+
yield Markdown(
|
|
45
|
+
"""
|
|
46
|
+
# SQLiter Demo - Help
|
|
47
|
+
|
|
48
|
+
## Navigation
|
|
49
|
+
|
|
50
|
+
| Key | Action |
|
|
51
|
+
|-----|--------|
|
|
52
|
+
| Up/Down or j/k | Navigate demo list |
|
|
53
|
+
| Left/Right or h/l | Collapse/expand category |
|
|
54
|
+
| Enter | Select demo / Run demo |
|
|
55
|
+
| Tab | Move focus between panels |
|
|
56
|
+
|
|
57
|
+
## Actions
|
|
58
|
+
|
|
59
|
+
| Key | Action |
|
|
60
|
+
|-----|--------|
|
|
61
|
+
| F5 | Run selected demo |
|
|
62
|
+
| F8 | Clear output |
|
|
63
|
+
| ? or F1 | Show this help |
|
|
64
|
+
| q | Quit application |
|
|
65
|
+
|
|
66
|
+
## Mouse
|
|
67
|
+
|
|
68
|
+
- Click categories to expand/collapse
|
|
69
|
+
- Click demos to select and view code
|
|
70
|
+
- Click buttons to run/clear
|
|
71
|
+
|
|
72
|
+
Press Escape or q to close this help.
|
|
73
|
+
""",
|
|
74
|
+
id="help-content",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SQLiterDemoApp(App[None]):
|
|
79
|
+
"""Main SQLiter TUI demo application."""
|
|
80
|
+
|
|
81
|
+
CSS_PATH = "styles/app.tcss"
|
|
82
|
+
TITLE = "SQLiter Interactive Demo"
|
|
83
|
+
|
|
84
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
85
|
+
Binding("q", "quit", "Quit", show=True, priority=True),
|
|
86
|
+
Binding("f5", "run_demo", "Run", show=True),
|
|
87
|
+
Binding("f8", "clear_output", "Clear", show=True),
|
|
88
|
+
Binding("question_mark", "show_help", "Help", show=True),
|
|
89
|
+
Binding("f1", "show_help", show=False),
|
|
90
|
+
Binding("j", "tree_cursor_down", show=False),
|
|
91
|
+
Binding("k", "tree_cursor_up", show=False),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
"""Initialize the application."""
|
|
96
|
+
super().__init__()
|
|
97
|
+
self._current_demo: Demo | None = None
|
|
98
|
+
|
|
99
|
+
def compose(self) -> ComposeResult:
|
|
100
|
+
"""Compose the application layout."""
|
|
101
|
+
yield Header()
|
|
102
|
+
with Container(id="main-container"):
|
|
103
|
+
yield DemoList(id="demo-list")
|
|
104
|
+
with Vertical(id="right-panel"):
|
|
105
|
+
yield CodeDisplay(widget_id="code-display")
|
|
106
|
+
yield OutputDisplay(widget_id="output-display")
|
|
107
|
+
with Horizontal(id="button-bar"):
|
|
108
|
+
yield Button(
|
|
109
|
+
"Run Demo (F5)", id="run-btn", variant="primary"
|
|
110
|
+
)
|
|
111
|
+
yield Button("Clear Output (F8)", id="clear-btn")
|
|
112
|
+
yield Footer()
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
"""Set initial focus on the demo list."""
|
|
116
|
+
with suppress(NoMatches):
|
|
117
|
+
demo_list = self.query_one("#demo-list", DemoList)
|
|
118
|
+
tree = demo_list.query_one("#demo-tree", Tree)
|
|
119
|
+
tree.focus()
|
|
120
|
+
|
|
121
|
+
def on_demo_selected(self, event: DemoSelected) -> None:
|
|
122
|
+
"""Handle demo selection from the list."""
|
|
123
|
+
self._current_demo = event.demo
|
|
124
|
+
code_display = self.query_one("#code-display", CodeDisplay)
|
|
125
|
+
code_display.set_code(DemoRegistry.get_demo_code(event.demo.id))
|
|
126
|
+
|
|
127
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
128
|
+
"""Handle button presses."""
|
|
129
|
+
if event.button.id == "run-btn":
|
|
130
|
+
self.action_run_demo()
|
|
131
|
+
elif event.button.id == "clear-btn":
|
|
132
|
+
self.action_clear_output()
|
|
133
|
+
|
|
134
|
+
def action_run_demo(self) -> None:
|
|
135
|
+
"""Run the currently selected demo."""
|
|
136
|
+
if self._current_demo is None:
|
|
137
|
+
output_display = self.query_one("#output-display", OutputDisplay)
|
|
138
|
+
output_display.show_output(
|
|
139
|
+
"Please select a demo first.", success=False
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
result = run_demo(self._current_demo)
|
|
144
|
+
output_display = self.query_one("#output-display", OutputDisplay)
|
|
145
|
+
|
|
146
|
+
if result.success:
|
|
147
|
+
output_display.show_output(result.output, success=True)
|
|
148
|
+
else:
|
|
149
|
+
output_display.show_error(
|
|
150
|
+
result.error or "Unknown error",
|
|
151
|
+
result.traceback,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def action_clear_output(self) -> None:
|
|
155
|
+
"""Clear the output display."""
|
|
156
|
+
output_display = self.query_one("#output-display", OutputDisplay)
|
|
157
|
+
output_display.clear()
|
|
158
|
+
|
|
159
|
+
def action_show_help(self) -> None:
|
|
160
|
+
"""Show the help screen."""
|
|
161
|
+
self.push_screen(HelpScreen())
|
|
162
|
+
|
|
163
|
+
def action_tree_cursor_down(self) -> None:
|
|
164
|
+
"""Move cursor down in the tree (vim-style j key)."""
|
|
165
|
+
try:
|
|
166
|
+
demo_list = self.query_one(DemoList)
|
|
167
|
+
tree = cast("Tree[object]", demo_list.query_one("#demo-tree"))
|
|
168
|
+
tree.action_cursor_down()
|
|
169
|
+
except NoMatches:
|
|
170
|
+
pass # Tree might not be focused
|
|
171
|
+
|
|
172
|
+
def action_tree_cursor_up(self) -> None:
|
|
173
|
+
"""Move cursor up in the tree (vim-style k key)."""
|
|
174
|
+
try:
|
|
175
|
+
demo_list = self.query_one(DemoList)
|
|
176
|
+
tree = cast("Tree[object]", demo_list.query_one("#demo-tree"))
|
|
177
|
+
tree.action_cursor_up()
|
|
178
|
+
except NoMatches:
|
|
179
|
+
pass # Tree might not be focused
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Demo registry for managing all available demos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from sqliter.tui.demos import (
|
|
8
|
+
caching,
|
|
9
|
+
connection,
|
|
10
|
+
constraints,
|
|
11
|
+
crud,
|
|
12
|
+
errors,
|
|
13
|
+
field_selection,
|
|
14
|
+
filters,
|
|
15
|
+
models,
|
|
16
|
+
ordering,
|
|
17
|
+
orm,
|
|
18
|
+
results,
|
|
19
|
+
string_filters,
|
|
20
|
+
timestamps,
|
|
21
|
+
transactions,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
|
|
27
|
+
from sqliter.tui.demos.base import Demo, DemoCategory
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DemoRegistry:
|
|
31
|
+
"""Registry for all available demos."""
|
|
32
|
+
|
|
33
|
+
_categories: ClassVar[list[DemoCategory]] = []
|
|
34
|
+
_demos_by_id: ClassVar[dict[str, Demo]] = {}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def register_category(cls, category: DemoCategory) -> None:
|
|
38
|
+
"""Register a demo category with all its demos."""
|
|
39
|
+
cls._categories.append(category)
|
|
40
|
+
for demo in category.demos:
|
|
41
|
+
if demo.id in cls._demos_by_id:
|
|
42
|
+
msg = f"Duplicate demo id: {demo.id}"
|
|
43
|
+
raise ValueError(msg)
|
|
44
|
+
cls._demos_by_id[demo.id] = demo
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def get_categories(cls) -> Sequence[DemoCategory]:
|
|
48
|
+
"""Get all registered categories in order."""
|
|
49
|
+
return tuple(cls._categories)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def get_demo(cls, demo_id: str) -> Demo | None:
|
|
53
|
+
"""Get a demo by its unique ID."""
|
|
54
|
+
return cls._demos_by_id.get(demo_id)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def get_demo_code(cls, demo_id: str) -> str:
|
|
58
|
+
"""Get the display code for a demo (including setup if any)."""
|
|
59
|
+
demo = cls.get_demo(demo_id)
|
|
60
|
+
if demo is None:
|
|
61
|
+
return ""
|
|
62
|
+
code_parts: list[str] = []
|
|
63
|
+
if demo.setup_code:
|
|
64
|
+
code_parts.append(f"# Setup\n{demo.setup_code}\n")
|
|
65
|
+
code_parts.append(demo.code)
|
|
66
|
+
return "\n".join(code_parts)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def reset(cls) -> None:
|
|
70
|
+
"""Reset the registry (for testing).
|
|
71
|
+
|
|
72
|
+
After calling reset(), call _init_registry() to repopulate.
|
|
73
|
+
"""
|
|
74
|
+
cls._categories = []
|
|
75
|
+
cls._demos_by_id = {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _init_registry() -> None:
|
|
79
|
+
"""Initialize the demo registry with all categories."""
|
|
80
|
+
DemoRegistry.register_category(connection.get_category())
|
|
81
|
+
DemoRegistry.register_category(models.get_category())
|
|
82
|
+
DemoRegistry.register_category(crud.get_category())
|
|
83
|
+
DemoRegistry.register_category(filters.get_category())
|
|
84
|
+
DemoRegistry.register_category(results.get_category())
|
|
85
|
+
DemoRegistry.register_category(ordering.get_category())
|
|
86
|
+
DemoRegistry.register_category(field_selection.get_category())
|
|
87
|
+
DemoRegistry.register_category(string_filters.get_category())
|
|
88
|
+
DemoRegistry.register_category(constraints.get_category())
|
|
89
|
+
DemoRegistry.register_category(orm.get_category())
|
|
90
|
+
DemoRegistry.register_category(caching.get_category())
|
|
91
|
+
DemoRegistry.register_category(timestamps.get_category())
|
|
92
|
+
DemoRegistry.register_category(transactions.get_category())
|
|
93
|
+
DemoRegistry.register_category(errors.get_category())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_init_registry()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Base classes for demo definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import textwrap
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Optional
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_demo_code(func: Callable[..., str]) -> str:
|
|
15
|
+
"""Extract and format source code from a demo function.
|
|
16
|
+
|
|
17
|
+
This function uses Python's inspect module to dynamically extract the
|
|
18
|
+
source code from a demo function, ensuring the displayed code always
|
|
19
|
+
matches what's actually executed.
|
|
20
|
+
|
|
21
|
+
Removes demo infrastructure (function definition, output setup, return)
|
|
22
|
+
but keeps the docstring for context.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
func: The demo function to extract code from
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Formatted source code string with proper dedentation and whitespace
|
|
29
|
+
"""
|
|
30
|
+
# Get the source code and split into lines
|
|
31
|
+
source = inspect.getsource(func)
|
|
32
|
+
lines = source.splitlines()
|
|
33
|
+
|
|
34
|
+
# Skip decorator lines and function definition
|
|
35
|
+
start_idx = 0
|
|
36
|
+
for i, line in enumerate(lines):
|
|
37
|
+
stripped = line.strip()
|
|
38
|
+
if stripped.startswith(("def ", "async def ")):
|
|
39
|
+
start_idx = i + 1
|
|
40
|
+
break
|
|
41
|
+
lines = lines[start_idx:]
|
|
42
|
+
|
|
43
|
+
# Dedent the remaining code
|
|
44
|
+
code = textwrap.dedent("\n".join(lines))
|
|
45
|
+
lines = code.splitlines()
|
|
46
|
+
|
|
47
|
+
# Filter out unwanted lines
|
|
48
|
+
filtered: list[str] = []
|
|
49
|
+
for original_line in lines:
|
|
50
|
+
# Skip output setup
|
|
51
|
+
if "output = io.StringIO()" in original_line:
|
|
52
|
+
continue
|
|
53
|
+
# Rename output.write to print
|
|
54
|
+
line = original_line
|
|
55
|
+
if "output.write(" in line:
|
|
56
|
+
line = line.replace("output.write(", "print(")
|
|
57
|
+
# Stop at return statement
|
|
58
|
+
if "return output.getvalue()" in line:
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
filtered.append(line)
|
|
62
|
+
|
|
63
|
+
# Remove trailing empty lines
|
|
64
|
+
while filtered and not filtered[-1].strip():
|
|
65
|
+
filtered.pop()
|
|
66
|
+
|
|
67
|
+
return "\n".join(filtered).strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["Demo", "DemoCategory", "extract_demo_code"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Demo:
|
|
75
|
+
"""Represents a single demo example.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
id: Unique identifier for the demo (e.g., "conn_memory")
|
|
79
|
+
title: Display title in the list (e.g., "In-memory Database")
|
|
80
|
+
description: Brief description shown as tooltip/subtitle
|
|
81
|
+
category: Category ID this demo belongs to
|
|
82
|
+
code: The Python code to display (syntax highlighted)
|
|
83
|
+
execute: Callable that runs the demo and returns output string
|
|
84
|
+
setup_code: Optional setup code shown before main code
|
|
85
|
+
teardown: Optional cleanup function called after execution
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
id: str
|
|
89
|
+
title: str
|
|
90
|
+
description: str
|
|
91
|
+
category: str
|
|
92
|
+
code: str
|
|
93
|
+
execute: Callable[[], str]
|
|
94
|
+
setup_code: Optional[str] = None
|
|
95
|
+
teardown: Optional[Callable[[], None]] = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class DemoCategory:
|
|
100
|
+
"""A category of related demos.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
id: Unique identifier (e.g., "connection")
|
|
104
|
+
title: Display title (e.g., "Connection & Setup")
|
|
105
|
+
icon: Optional emoji icon for the category
|
|
106
|
+
demos: List of demos in this category
|
|
107
|
+
expanded: Whether category starts expanded in the tree
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
id: str
|
|
111
|
+
title: str
|
|
112
|
+
icon: str = ""
|
|
113
|
+
demos: list[Demo] = field(default_factory=list)
|
|
114
|
+
expanded: bool = False
|