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
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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,
|
|
864
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1025
|
+
conn = self.connect()
|
|
1026
|
+
cursor = conn.cursor()
|
|
1027
|
+
cursor.execute(update_sql, (*values, primary_key_value))
|
|
953
1028
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1029
|
+
# Check if any rows were updated
|
|
1030
|
+
if cursor.rowcount == 0:
|
|
1031
|
+
raise RecordNotFoundError(primary_key_value) # noqa: TRY301
|
|
957
1032
|
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1069
|
+
conn = self.connect()
|
|
1070
|
+
cursor = conn.cursor()
|
|
1071
|
+
cursor.execute(delete_sql, (primary_key_value,))
|
|
989
1072
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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(
|
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()
|