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
sqliter/sqliter.py CHANGED
@@ -13,7 +13,7 @@ import sqlite3
13
13
  import sys
14
14
  import time
15
15
  from collections import OrderedDict
16
- from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
16
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast
17
17
 
18
18
  from typing_extensions import Self
19
19
 
@@ -32,6 +32,7 @@ from sqliter.exceptions import (
32
32
  )
33
33
  from sqliter.helpers import infer_sqlite_type
34
34
  from sqliter.model.foreign_key import ForeignKeyInfo, get_foreign_key_info
35
+ from sqliter.model.model import BaseDBModel
35
36
  from sqliter.query.query import QueryBuilder
36
37
 
37
38
  if TYPE_CHECKING: # pragma: no cover
@@ -39,9 +40,7 @@ if TYPE_CHECKING: # pragma: no cover
39
40
 
40
41
  from pydantic.fields import FieldInfo
41
42
 
42
- from sqliter.model.model import BaseDBModel
43
-
44
- T = TypeVar("T", bound="BaseDBModel")
43
+ T = TypeVar("T", bound=BaseDBModel)
45
44
 
46
45
 
47
46
  class SqliterDB:
@@ -474,6 +473,25 @@ class SqliterDB:
474
473
  "hit_rate": round(hit_rate, 2),
475
474
  }
476
475
 
476
+ def clear_cache(self) -> None:
477
+ """Clear all cached query results.
478
+
479
+ This method removes all cached data from memory, freeing up resources
480
+ and forcing subsequent queries to fetch fresh data from the database.
481
+
482
+ Use this when you want to:
483
+ - Free memory used by the cache
484
+ - Force fresh queries after external data changes
485
+
486
+ Note:
487
+ Cache statistics (hits/misses) are preserved. To reset statistics,
488
+ create a new database connection.
489
+
490
+ Example:
491
+ >>> db.clear_cache()
492
+ """
493
+ self._cache.clear()
494
+
477
495
  def close(self) -> None:
478
496
  """Close the database connection.
479
497
 
@@ -660,6 +678,47 @@ class SqliterDB:
660
678
  model_class, model_class.Meta.unique_indexes, unique=True
661
679
  )
662
680
 
681
+ # Create junction tables for M2M relationships
682
+ self._create_m2m_junction_tables(model_class)
683
+
684
+ def _create_m2m_junction_tables(
685
+ self, model_class: type[BaseDBModel]
686
+ ) -> None:
687
+ """Create junction tables for M2M relationships on a model.
688
+
689
+ Queries the ModelRegistry for M2M relationships registered for
690
+ this model and creates the corresponding junction tables.
691
+
692
+ Args:
693
+ model_class: The model class to check for M2M relationships.
694
+ """
695
+ try:
696
+ from sqliter.orm.m2m import ( # noqa: PLC0415
697
+ create_junction_table,
698
+ )
699
+ from sqliter.orm.registry import ( # noqa: PLC0415
700
+ ModelRegistry,
701
+ )
702
+ except ImportError:
703
+ return
704
+
705
+ table_name = model_class.get_table_name()
706
+ m2m_rels = ModelRegistry.get_m2m_relationships(table_name)
707
+
708
+ for rel in m2m_rels:
709
+ junction_table = rel["junction_table"]
710
+ to_model = rel["to_model"]
711
+ to_table = to_model.get_table_name()
712
+
713
+ # Sort alphabetically for consistent column naming
714
+ sorted_tables = sorted([table_name, to_table])
715
+ create_junction_table(
716
+ self,
717
+ junction_table,
718
+ sorted_tables[0],
719
+ sorted_tables[1],
720
+ )
721
+
663
722
  def _create_indexes(
664
723
  self,
665
724
  model_class: type[BaseDBModel],
@@ -782,6 +841,44 @@ class SqliterDB:
782
841
  if model_instance.updated_at == 0:
783
842
  model_instance.updated_at = current_timestamp
784
843
 
844
+ def _create_instance_from_data(
845
+ self,
846
+ model_class: type[T],
847
+ data: dict[str, Any],
848
+ pk: Optional[int] = None,
849
+ ) -> T:
850
+ """Create a model instance from deserialized data.
851
+
852
+ Handles ORM-specific field exclusions and db_context setup.
853
+
854
+ Args:
855
+ model_class: The model class to instantiate.
856
+ data: Raw data dictionary from the database.
857
+ pk: Optional primary key value to set.
858
+
859
+ Returns:
860
+ A new model instance with db_context set if applicable.
861
+ """
862
+ # Deserialize each field before creating the model instance
863
+ deserialized_data: dict[str, Any] = {}
864
+ for field_name, value in data.items():
865
+ deserialized_data[field_name] = model_class.deserialize_field(
866
+ field_name, value, return_local_time=self.return_local_time
867
+ )
868
+ # For ORM mode, exclude FK descriptor fields from data
869
+ for fk_field in getattr(model_class, "fk_descriptors", {}):
870
+ deserialized_data.pop(fk_field, None)
871
+
872
+ if pk is not None:
873
+ instance = model_class(pk=pk, **deserialized_data)
874
+ else:
875
+ instance = model_class(**deserialized_data)
876
+
877
+ # Set db_context for ORM lazy loading and reverse relationships
878
+ if hasattr(instance, "db_context"):
879
+ instance.db_context = self
880
+ return instance
881
+
785
882
  def insert(
786
883
  self, model_instance: T, *, timestamp_override: bool = False
787
884
  ) -> T:
@@ -832,12 +929,15 @@ class SqliterDB:
832
929
  """ # noqa: S608
833
930
 
834
931
  try:
835
- with self.connect() as conn:
836
- cursor = conn.cursor()
837
- cursor.execute(insert_sql, values)
838
- self._maybe_commit()
932
+ conn = self.connect()
933
+ cursor = conn.cursor()
934
+ cursor.execute(insert_sql, values)
935
+ self._maybe_commit()
839
936
 
840
937
  except sqlite3.IntegrityError as exc:
938
+ # Rollback implicit transaction if not in user-managed transaction
939
+ if not self._in_transaction and self.conn:
940
+ self.conn.rollback()
841
941
  # Check for foreign key constraint violation
842
942
  if "FOREIGN KEY constraint failed" in str(exc):
843
943
  fk_operation = "insert"
@@ -847,26 +947,32 @@ class SqliterDB:
847
947
  ) from exc
848
948
  raise RecordInsertionError(table_name) from exc
849
949
  except sqlite3.Error as exc:
950
+ # Rollback implicit transaction if not in user-managed transaction
951
+ if not self._in_transaction and self.conn:
952
+ self.conn.rollback()
850
953
  raise RecordInsertionError(table_name) from exc
851
954
  else:
852
955
  self._cache_invalidate_table(table_name)
853
956
  data.pop("pk", None)
854
- # Deserialize each field before creating the model instance
855
- deserialized_data = {}
856
- for field_name, value in data.items():
857
- deserialized_data[field_name] = model_class.deserialize_field(
858
- field_name, value, return_local_time=self.return_local_time
859
- )
860
- return model_class(pk=cursor.lastrowid, **deserialized_data)
957
+ return self._create_instance_from_data(
958
+ model_class, data, pk=cursor.lastrowid
959
+ )
861
960
 
862
961
  def get(
863
- self, model_class: type[BaseDBModel], primary_key_value: int
864
- ) -> BaseDBModel | None:
962
+ self,
963
+ model_class: type[T],
964
+ primary_key_value: int,
965
+ *,
966
+ bypass_cache: bool = False,
967
+ cache_ttl: Optional[int] = None,
968
+ ) -> T | None:
865
969
  """Retrieve a single record from the database by its primary key.
866
970
 
867
971
  Args:
868
972
  model_class: The Pydantic model class representing the table.
869
973
  primary_key_value: The value of the primary key to look up.
974
+ bypass_cache: If True, skip reading/writing cache for this call.
975
+ cache_ttl: Optional TTL override for this specific lookup.
870
976
 
871
977
  Returns:
872
978
  An instance of the model class if found, None otherwise.
@@ -874,8 +980,18 @@ class SqliterDB:
874
980
  Raises:
875
981
  RecordFetchError: If there's an error fetching the record.
876
982
  """
983
+ if cache_ttl is not None and cache_ttl < 0:
984
+ msg = "cache_ttl must be non-negative"
985
+ raise ValueError(msg)
986
+
877
987
  table_name = model_class.get_table_name()
878
988
  primary_key = model_class.get_primary_key()
989
+ cache_key = f"pk:{primary_key_value}"
990
+
991
+ if not bypass_cache:
992
+ hit, cached = self._cache_get(table_name, cache_key)
993
+ if hit:
994
+ return cast("Optional[T]", cached)
879
995
 
880
996
  fields = ", ".join(model_class.model_fields)
881
997
 
@@ -884,30 +1000,29 @@ class SqliterDB:
884
1000
  """ # noqa: S608
885
1001
 
886
1002
  try:
887
- with self.connect() as conn:
888
- cursor = conn.cursor()
889
- cursor.execute(select_sql, (primary_key_value,))
890
- result = cursor.fetchone()
1003
+ conn = self.connect()
1004
+ cursor = conn.cursor()
1005
+ cursor.execute(select_sql, (primary_key_value,))
1006
+ result = cursor.fetchone()
891
1007
 
892
1008
  if result:
893
1009
  result_dict = {
894
1010
  field: result[idx]
895
1011
  for idx, field in enumerate(model_class.model_fields)
896
1012
  }
897
- # Deserialize each field before creating the model instance
898
- deserialized_data = {}
899
- for field_name, value in result_dict.items():
900
- deserialized_data[field_name] = (
901
- model_class.deserialize_field(
902
- field_name,
903
- value,
904
- return_local_time=self.return_local_time,
905
- )
1013
+ instance = self._create_instance_from_data(
1014
+ model_class, result_dict
1015
+ )
1016
+ if not bypass_cache:
1017
+ self._cache_set(
1018
+ table_name, cache_key, instance, ttl=cache_ttl
906
1019
  )
907
- return model_class(**deserialized_data)
1020
+ return instance
908
1021
  except sqlite3.Error as exc:
909
1022
  raise RecordFetchError(table_name) from exc
910
1023
  else:
1024
+ if not bypass_cache:
1025
+ self._cache_set(table_name, cache_key, None, ttl=cache_ttl)
911
1026
  return None
912
1027
 
913
1028
  def update(self, model_instance: BaseDBModel) -> None:
@@ -930,6 +1045,7 @@ class SqliterDB:
930
1045
 
931
1046
  # Get the data and serialize any datetime/date fields
932
1047
  data = model_instance.model_dump()
1048
+
933
1049
  for field_name, value in list(data.items()):
934
1050
  data[field_name] = model_instance.serialize_field(value)
935
1051
 
@@ -947,22 +1063,30 @@ class SqliterDB:
947
1063
  """ # noqa: S608
948
1064
 
949
1065
  try:
950
- with self.connect() as conn:
951
- cursor = conn.cursor()
952
- cursor.execute(update_sql, (*values, primary_key_value))
1066
+ conn = self.connect()
1067
+ cursor = conn.cursor()
1068
+ cursor.execute(update_sql, (*values, primary_key_value))
953
1069
 
954
- # Check if any rows were updated
955
- if cursor.rowcount == 0:
956
- raise RecordNotFoundError(primary_key_value)
1070
+ # Check if any rows were updated
1071
+ if cursor.rowcount == 0:
1072
+ raise RecordNotFoundError(primary_key_value) # noqa: TRY301
957
1073
 
958
- self._maybe_commit()
959
- self._cache_invalidate_table(table_name)
1074
+ self._maybe_commit()
1075
+ self._cache_invalidate_table(table_name)
960
1076
 
1077
+ except RecordNotFoundError:
1078
+ # Rollback implicit transaction if not in user-managed transaction
1079
+ if not self._in_transaction and self.conn:
1080
+ self.conn.rollback()
1081
+ raise
961
1082
  except sqlite3.Error as exc:
1083
+ # Rollback implicit transaction if not in user-managed transaction
1084
+ if not self._in_transaction and self.conn:
1085
+ self.conn.rollback()
962
1086
  raise RecordUpdateError(table_name) from exc
963
1087
 
964
1088
  def delete(
965
- self, model_class: type[BaseDBModel], primary_key_value: str
1089
+ self, model_class: type[BaseDBModel], primary_key_value: Union[int, str]
966
1090
  ) -> None:
967
1091
  """Delete a record from the database by its primary key.
968
1092
 
@@ -983,15 +1107,23 @@ class SqliterDB:
983
1107
  """ # noqa: S608
984
1108
 
985
1109
  try:
986
- with self.connect() as conn:
987
- cursor = conn.cursor()
988
- cursor.execute(delete_sql, (primary_key_value,))
1110
+ conn = self.connect()
1111
+ cursor = conn.cursor()
1112
+ cursor.execute(delete_sql, (primary_key_value,))
989
1113
 
990
- if cursor.rowcount == 0:
991
- raise RecordNotFoundError(primary_key_value)
992
- self._maybe_commit()
993
- self._cache_invalidate_table(table_name)
1114
+ if cursor.rowcount == 0:
1115
+ raise RecordNotFoundError(primary_key_value) # noqa: TRY301
1116
+ self._maybe_commit()
1117
+ self._cache_invalidate_table(table_name)
1118
+ except RecordNotFoundError:
1119
+ # Rollback implicit transaction if not in user-managed transaction
1120
+ if not self._in_transaction and self.conn:
1121
+ self.conn.rollback()
1122
+ raise
994
1123
  except sqlite3.IntegrityError as exc:
1124
+ # Rollback implicit transaction if not in user-managed transaction
1125
+ if not self._in_transaction and self.conn:
1126
+ self.conn.rollback()
995
1127
  # Check for foreign key constraint violation (RESTRICT)
996
1128
  if "FOREIGN KEY constraint failed" in str(exc):
997
1129
  fk_operation = "delete"
@@ -1001,6 +1133,9 @@ class SqliterDB:
1001
1133
  ) from exc
1002
1134
  raise RecordDeletionError(table_name) from exc
1003
1135
  except sqlite3.Error as exc:
1136
+ # Rollback implicit transaction if not in user-managed transaction
1137
+ if not self._in_transaction and self.conn:
1138
+ self.conn.rollback()
1004
1139
  raise RecordDeletionError(table_name) from exc
1005
1140
 
1006
1141
  def select(
@@ -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"]
@@ -0,0 +1,6 @@
1
+ """Entry point for running SQLiter TUI as a module."""
2
+
3
+ from sqliter.tui import run
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ run()
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