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.
Files changed (41) hide show
  1. sqliter/constants.py +4 -3
  2. sqliter/exceptions.py +13 -0
  3. sqliter/model/model.py +42 -3
  4. sqliter/orm/__init__.py +16 -0
  5. sqliter/orm/fields.py +412 -0
  6. sqliter/orm/foreign_key.py +8 -0
  7. sqliter/orm/model.py +243 -0
  8. sqliter/orm/query.py +221 -0
  9. sqliter/orm/registry.py +169 -0
  10. sqliter/query/query.py +573 -51
  11. sqliter/sqliter.py +141 -47
  12. sqliter/tui/__init__.py +62 -0
  13. sqliter/tui/__main__.py +6 -0
  14. sqliter/tui/app.py +179 -0
  15. sqliter/tui/demos/__init__.py +96 -0
  16. sqliter/tui/demos/base.py +114 -0
  17. sqliter/tui/demos/caching.py +283 -0
  18. sqliter/tui/demos/connection.py +150 -0
  19. sqliter/tui/demos/constraints.py +211 -0
  20. sqliter/tui/demos/crud.py +154 -0
  21. sqliter/tui/demos/errors.py +231 -0
  22. sqliter/tui/demos/field_selection.py +150 -0
  23. sqliter/tui/demos/filters.py +389 -0
  24. sqliter/tui/demos/models.py +248 -0
  25. sqliter/tui/demos/ordering.py +156 -0
  26. sqliter/tui/demos/orm.py +460 -0
  27. sqliter/tui/demos/results.py +241 -0
  28. sqliter/tui/demos/string_filters.py +210 -0
  29. sqliter/tui/demos/timestamps.py +126 -0
  30. sqliter/tui/demos/transactions.py +177 -0
  31. sqliter/tui/runner.py +116 -0
  32. sqliter/tui/styles/app.tcss +130 -0
  33. sqliter/tui/widgets/__init__.py +7 -0
  34. sqliter/tui/widgets/code_display.py +81 -0
  35. sqliter/tui/widgets/demo_list.py +65 -0
  36. sqliter/tui/widgets/output_display.py +92 -0
  37. {sqliter_py-0.12.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +23 -7
  38. sqliter_py-0.16.0.dist-info/RECORD +47 -0
  39. {sqliter_py-0.12.0.dist-info → sqliter_py-0.16.0.dist-info}/WHEEL +2 -2
  40. sqliter_py-0.16.0.dist-info/entry_points.txt +3 -0
  41. 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
 
@@ -782,6 +800,44 @@ class SqliterDB:
782
800
  if model_instance.updated_at == 0:
783
801
  model_instance.updated_at = current_timestamp
784
802
 
803
+ def _create_instance_from_data(
804
+ self,
805
+ model_class: type[T],
806
+ data: dict[str, Any],
807
+ pk: Optional[int] = None,
808
+ ) -> T:
809
+ """Create a model instance from deserialized data.
810
+
811
+ Handles ORM-specific field exclusions and db_context setup.
812
+
813
+ Args:
814
+ model_class: The model class to instantiate.
815
+ data: Raw data dictionary from the database.
816
+ pk: Optional primary key value to set.
817
+
818
+ Returns:
819
+ A new model instance with db_context set if applicable.
820
+ """
821
+ # Deserialize each field before creating the model instance
822
+ deserialized_data: dict[str, Any] = {}
823
+ for field_name, value in data.items():
824
+ deserialized_data[field_name] = model_class.deserialize_field(
825
+ field_name, value, return_local_time=self.return_local_time
826
+ )
827
+ # For ORM mode, exclude FK descriptor fields from data
828
+ for fk_field in getattr(model_class, "fk_descriptors", {}):
829
+ deserialized_data.pop(fk_field, None)
830
+
831
+ if pk is not None:
832
+ instance = model_class(pk=pk, **deserialized_data)
833
+ else:
834
+ instance = model_class(**deserialized_data)
835
+
836
+ # Set db_context for ORM lazy loading and reverse relationships
837
+ if hasattr(instance, "db_context"):
838
+ instance.db_context = self
839
+ return instance
840
+
785
841
  def insert(
786
842
  self, model_instance: T, *, timestamp_override: bool = False
787
843
  ) -> T:
@@ -832,12 +888,15 @@ class SqliterDB:
832
888
  """ # noqa: S608
833
889
 
834
890
  try:
835
- with self.connect() as conn:
836
- cursor = conn.cursor()
837
- cursor.execute(insert_sql, values)
838
- self._maybe_commit()
891
+ conn = self.connect()
892
+ cursor = conn.cursor()
893
+ cursor.execute(insert_sql, values)
894
+ self._maybe_commit()
839
895
 
840
896
  except sqlite3.IntegrityError as exc:
897
+ # Rollback implicit transaction if not in user-managed transaction
898
+ if not self._in_transaction and self.conn:
899
+ self.conn.rollback()
841
900
  # Check for foreign key constraint violation
842
901
  if "FOREIGN KEY constraint failed" in str(exc):
843
902
  fk_operation = "insert"
@@ -847,26 +906,32 @@ class SqliterDB:
847
906
  ) from exc
848
907
  raise RecordInsertionError(table_name) from exc
849
908
  except sqlite3.Error as exc:
909
+ # Rollback implicit transaction if not in user-managed transaction
910
+ if not self._in_transaction and self.conn:
911
+ self.conn.rollback()
850
912
  raise RecordInsertionError(table_name) from exc
851
913
  else:
852
914
  self._cache_invalidate_table(table_name)
853
915
  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)
916
+ return self._create_instance_from_data(
917
+ model_class, data, pk=cursor.lastrowid
918
+ )
861
919
 
862
920
  def get(
863
- self, model_class: type[BaseDBModel], primary_key_value: int
864
- ) -> BaseDBModel | None:
921
+ self,
922
+ model_class: type[T],
923
+ primary_key_value: int,
924
+ *,
925
+ bypass_cache: bool = False,
926
+ cache_ttl: Optional[int] = None,
927
+ ) -> T | None:
865
928
  """Retrieve a single record from the database by its primary key.
866
929
 
867
930
  Args:
868
931
  model_class: The Pydantic model class representing the table.
869
932
  primary_key_value: The value of the primary key to look up.
933
+ bypass_cache: If True, skip reading/writing cache for this call.
934
+ cache_ttl: Optional TTL override for this specific lookup.
870
935
 
871
936
  Returns:
872
937
  An instance of the model class if found, None otherwise.
@@ -874,8 +939,18 @@ class SqliterDB:
874
939
  Raises:
875
940
  RecordFetchError: If there's an error fetching the record.
876
941
  """
942
+ if cache_ttl is not None and cache_ttl < 0:
943
+ msg = "cache_ttl must be non-negative"
944
+ raise ValueError(msg)
945
+
877
946
  table_name = model_class.get_table_name()
878
947
  primary_key = model_class.get_primary_key()
948
+ cache_key = f"pk:{primary_key_value}"
949
+
950
+ if not bypass_cache:
951
+ hit, cached = self._cache_get(table_name, cache_key)
952
+ if hit:
953
+ return cast("Optional[T]", cached)
879
954
 
880
955
  fields = ", ".join(model_class.model_fields)
881
956
 
@@ -884,30 +959,29 @@ class SqliterDB:
884
959
  """ # noqa: S608
885
960
 
886
961
  try:
887
- with self.connect() as conn:
888
- cursor = conn.cursor()
889
- cursor.execute(select_sql, (primary_key_value,))
890
- result = cursor.fetchone()
962
+ conn = self.connect()
963
+ cursor = conn.cursor()
964
+ cursor.execute(select_sql, (primary_key_value,))
965
+ result = cursor.fetchone()
891
966
 
892
967
  if result:
893
968
  result_dict = {
894
969
  field: result[idx]
895
970
  for idx, field in enumerate(model_class.model_fields)
896
971
  }
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
- )
972
+ instance = self._create_instance_from_data(
973
+ model_class, result_dict
974
+ )
975
+ if not bypass_cache:
976
+ self._cache_set(
977
+ table_name, cache_key, instance, ttl=cache_ttl
906
978
  )
907
- return model_class(**deserialized_data)
979
+ return instance
908
980
  except sqlite3.Error as exc:
909
981
  raise RecordFetchError(table_name) from exc
910
982
  else:
983
+ if not bypass_cache:
984
+ self._cache_set(table_name, cache_key, None, ttl=cache_ttl)
911
985
  return None
912
986
 
913
987
  def update(self, model_instance: BaseDBModel) -> None:
@@ -930,6 +1004,7 @@ class SqliterDB:
930
1004
 
931
1005
  # Get the data and serialize any datetime/date fields
932
1006
  data = model_instance.model_dump()
1007
+
933
1008
  for field_name, value in list(data.items()):
934
1009
  data[field_name] = model_instance.serialize_field(value)
935
1010
 
@@ -947,22 +1022,30 @@ class SqliterDB:
947
1022
  """ # noqa: S608
948
1023
 
949
1024
  try:
950
- with self.connect() as conn:
951
- cursor = conn.cursor()
952
- cursor.execute(update_sql, (*values, primary_key_value))
1025
+ conn = self.connect()
1026
+ cursor = conn.cursor()
1027
+ cursor.execute(update_sql, (*values, primary_key_value))
953
1028
 
954
- # Check if any rows were updated
955
- if cursor.rowcount == 0:
956
- raise RecordNotFoundError(primary_key_value)
1029
+ # Check if any rows were updated
1030
+ if cursor.rowcount == 0:
1031
+ raise RecordNotFoundError(primary_key_value) # noqa: TRY301
957
1032
 
958
- self._maybe_commit()
959
- self._cache_invalidate_table(table_name)
1033
+ self._maybe_commit()
1034
+ self._cache_invalidate_table(table_name)
960
1035
 
1036
+ except RecordNotFoundError:
1037
+ # Rollback implicit transaction if not in user-managed transaction
1038
+ if not self._in_transaction and self.conn:
1039
+ self.conn.rollback()
1040
+ raise
961
1041
  except sqlite3.Error as exc:
1042
+ # Rollback implicit transaction if not in user-managed transaction
1043
+ if not self._in_transaction and self.conn:
1044
+ self.conn.rollback()
962
1045
  raise RecordUpdateError(table_name) from exc
963
1046
 
964
1047
  def delete(
965
- self, model_class: type[BaseDBModel], primary_key_value: str
1048
+ self, model_class: type[BaseDBModel], primary_key_value: Union[int, str]
966
1049
  ) -> None:
967
1050
  """Delete a record from the database by its primary key.
968
1051
 
@@ -983,15 +1066,23 @@ class SqliterDB:
983
1066
  """ # noqa: S608
984
1067
 
985
1068
  try:
986
- with self.connect() as conn:
987
- cursor = conn.cursor()
988
- cursor.execute(delete_sql, (primary_key_value,))
1069
+ conn = self.connect()
1070
+ cursor = conn.cursor()
1071
+ cursor.execute(delete_sql, (primary_key_value,))
989
1072
 
990
- if cursor.rowcount == 0:
991
- raise RecordNotFoundError(primary_key_value)
992
- self._maybe_commit()
993
- self._cache_invalidate_table(table_name)
1073
+ if cursor.rowcount == 0:
1074
+ raise RecordNotFoundError(primary_key_value) # noqa: TRY301
1075
+ self._maybe_commit()
1076
+ self._cache_invalidate_table(table_name)
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
994
1082
  except sqlite3.IntegrityError 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()
995
1086
  # Check for foreign key constraint violation (RESTRICT)
996
1087
  if "FOREIGN KEY constraint failed" in str(exc):
997
1088
  fk_operation = "delete"
@@ -1001,6 +1092,9 @@ class SqliterDB:
1001
1092
  ) from exc
1002
1093
  raise RecordDeletionError(table_name) from exc
1003
1094
  except sqlite3.Error as exc:
1095
+ # Rollback implicit transaction if not in user-managed transaction
1096
+ if not self._in_transaction and self.conn:
1097
+ self.conn.rollback()
1004
1098
  raise RecordDeletionError(table_name) from exc
1005
1099
 
1006
1100
  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
@@ -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()