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.
- sqliter/constants.py +4 -3
- sqliter/exceptions.py +29 -0
- sqliter/helpers.py +27 -0
- sqliter/model/model.py +21 -4
- sqliter/orm/__init__.py +17 -0
- sqliter/orm/fields.py +412 -0
- sqliter/orm/foreign_key.py +8 -0
- sqliter/orm/m2m.py +784 -0
- sqliter/orm/model.py +308 -0
- sqliter/orm/query.py +221 -0
- sqliter/orm/registry.py +440 -0
- sqliter/query/query.py +573 -51
- sqliter/sqliter.py +182 -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 +537 -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.17.0.dist-info}/METADATA +28 -14
- sqliter_py-0.17.0.dist-info/RECORD +48 -0
- {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/WHEEL +2 -2
- sqliter_py-0.17.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
|
|
|
@@ -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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
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)
|
|
957
|
+
return self._create_instance_from_data(
|
|
958
|
+
model_class, data, pk=cursor.lastrowid
|
|
959
|
+
)
|
|
861
960
|
|
|
862
961
|
def get(
|
|
863
|
-
self,
|
|
864
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1066
|
+
conn = self.connect()
|
|
1067
|
+
cursor = conn.cursor()
|
|
1068
|
+
cursor.execute(update_sql, (*values, primary_key_value))
|
|
953
1069
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1070
|
+
# Check if any rows were updated
|
|
1071
|
+
if cursor.rowcount == 0:
|
|
1072
|
+
raise RecordNotFoundError(primary_key_value) # noqa: TRY301
|
|
957
1073
|
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1110
|
+
conn = self.connect()
|
|
1111
|
+
cursor = conn.cursor()
|
|
1112
|
+
cursor.execute(delete_sql, (primary_key_value,))
|
|
989
1113
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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(
|
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
|