termcore 0.2.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 (55) hide show
  1. termcore/__init__.py +6 -0
  2. termcore/cli/__init__.py +3 -0
  3. termcore/cli/output.py +70 -0
  4. termcore/io/__init__.py +7 -0
  5. termcore/io/app_state_storage.py +185 -0
  6. termcore/io/database.py +655 -0
  7. termcore/io/errors.py +71 -0
  8. termcore/io/file.py +263 -0
  9. termcore/io/textfile.py +92 -0
  10. termcore/py.typed +0 -0
  11. termcore/tui/__init__.py +9 -0
  12. termcore/tui/custom_bindings.py +551 -0
  13. termcore/tui/custom_widgets/__init__.py +1 -0
  14. termcore/tui/custom_widgets/custom_checkbox.py +41 -0
  15. termcore/tui/custom_widgets/custom_data_table.py +247 -0
  16. termcore/tui/custom_widgets/custom_selection.py +80 -0
  17. termcore/tui/custom_widgets/multiline_footer.py +240 -0
  18. termcore/tui/question_screen.py +107 -0
  19. termcore/tui/theme_loader.py +495 -0
  20. termcore/tui/themes/__init__.py +2 -0
  21. termcore/tui/themes/classic-black-saturated/style.css +57 -0
  22. termcore/tui/themes/classic-black-saturated/theme.py +22 -0
  23. termcore/tui/themes/classic-black-v1/style.css +57 -0
  24. termcore/tui/themes/classic-black-v1/theme.py +22 -0
  25. termcore/tui/themes/classic-black-v2/style.css +57 -0
  26. termcore/tui/themes/classic-black-v2/theme.py +24 -0
  27. termcore/tui/themes/classic-blue/theme.py +21 -0
  28. termcore/tui/themes/compact-gray/style.tcss +175 -0
  29. termcore/tui/themes/compact-gray/theme.py +22 -0
  30. termcore/tui/themes/mnml-black/style.css +57 -0
  31. termcore/tui/themes/mnml-black/theme.py +21 -0
  32. termcore/tui/themes/mnml-deepblack/style.css +57 -0
  33. termcore/tui/themes/mnml-deepblack/theme.py +21 -0
  34. termcore/tui/themes/pure-amber/theme.py +18 -0
  35. termcore/tui/themes/pure-black/theme.py +18 -0
  36. termcore/tui/themes/pure-blue/theme.py +18 -0
  37. termcore/tui/themes/pure-green/theme.py +18 -0
  38. termcore/tui/themes/pure-sweet16/theme.py +20 -0
  39. termcore/tui/themes/xplore-black/theme.py +18 -0
  40. termcore/tui/themes/xplore-blue/theme.py +18 -0
  41. termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
  42. termcore/tui/themes/xplore-teal/theme.py +18 -0
  43. termcore/util/__init__.py +10 -0
  44. termcore/util/datetime.py +173 -0
  45. termcore/util/debug.py +196 -0
  46. termcore/util/index.py +112 -0
  47. termcore/util/logger.py +36 -0
  48. termcore/util/singleton.py +46 -0
  49. termcore/util/string.py +263 -0
  50. termcore/util/validation.py +31 -0
  51. termcore/util/version.py +56 -0
  52. termcore-0.2.0.dist-info/METADATA +504 -0
  53. termcore-0.2.0.dist-info/RECORD +55 -0
  54. termcore-0.2.0.dist-info/WHEEL +4 -0
  55. termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,655 @@
1
+ """
2
+ Lightweight database management module for handling SQLite databases with ease.
3
+
4
+ This module provides an abstraction layer for SQLite databases, simplifying
5
+ common database operations such as fetching, inserting, updating and deleting
6
+ records. It includes classes for defining query conditions, sorting orders
7
+ and combination operators to facilitate SQL query construction.
8
+
9
+ Features:
10
+
11
+ - Establish and manage SQLite database connections
12
+ - Query execution with debugging support
13
+ - Fetching data with filtering, ordering, and pagination
14
+ - Insert, update, and delete operations with structured condition handling
15
+ - Automatic resource management using context (`with` statement)
16
+ - Ensures proper connection closure on object deletion
17
+
18
+ Ideal for applications requiring a simple, efficient database interaction layer.
19
+
20
+ How statements are built
21
+ ------------------------
22
+ The `# noqa: S608` markers below say the same thing in machine terms: the
23
+ rule sees an f-string in a statement and cannot see that every value is a
24
+ parameter and every identifier came through `_quoted_table` or
25
+ `_quoted_column`, which reject anything the schema does not know.
26
+
27
+ Values never reach the statement text. They are bound as parameters, so no
28
+ value can be read as SQL, whatever it contains.
29
+
30
+ Identifiers cannot be parameters - SQL has no placeholder for a table or a
31
+ column - so they are checked against the schema of the open database before
32
+ they are used, and quoted afterwards. A name that is not a table, or not a
33
+ column of the table being addressed, raises `UnknownIdentifierError` instead
34
+ of becoming a fragment of the query.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import sqlite3
40
+ from dataclasses import dataclass
41
+ from enum import Enum
42
+ from typing import TYPE_CHECKING, cast
43
+
44
+ from termcore.io.errors import EmptyConditionsError, UnknownIdentifierError
45
+
46
+ __all__ = [
47
+ "VALUELESS_OPERATORS",
48
+ "ColumnOrder",
49
+ "Condition",
50
+ "Database",
51
+ "SQLCombinationOperator",
52
+ "SQLComparisonOperator",
53
+ "SQLOrderByDirection",
54
+ ]
55
+
56
+ if TYPE_CHECKING:
57
+ from collections.abc import Mapping, Sequence
58
+ from types import TracebackType
59
+
60
+ _logger = logging.getLogger(__name__)
61
+
62
+
63
+ class SQLComparisonOperator(Enum):
64
+ """
65
+ Enumeration defining comparison operators for SQL queries.
66
+
67
+ `IS_NULL` and `IS_NOT_NULL` are complete on their own; the `value` of a
68
+ `Condition` using them is ignored.
69
+ """
70
+
71
+ LT = "<"
72
+ LE = "<="
73
+ EQ = "="
74
+ NE = "!="
75
+ GE = ">="
76
+ GT = ">"
77
+ LIKE = "LIKE"
78
+ IS_NULL = "IS NULL"
79
+ IS_NOT_NULL = "IS NOT NULL"
80
+
81
+
82
+ # The operators that carry no value and therefore bind no parameter
83
+ VALUELESS_OPERATORS = (
84
+ SQLComparisonOperator.IS_NULL,
85
+ SQLComparisonOperator.IS_NOT_NULL,
86
+ )
87
+
88
+
89
+ class SQLCombinationOperator(Enum):
90
+ """Combination operators (AND, OR) joining two SQL conditions."""
91
+
92
+ AND = "AND"
93
+ OR = "OR"
94
+
95
+
96
+ class SQLOrderByDirection(Enum):
97
+ """Enumeration defining sorting order (ASC or DESC) for SQL queries."""
98
+
99
+ ASC = "ASC"
100
+ DESC = "DESC"
101
+
102
+
103
+ @dataclass(slots=True, frozen=True)
104
+ class ColumnOrder:
105
+ """
106
+ Class representing column sorting order in SQL queries.
107
+
108
+ Attributes
109
+ ----------
110
+ column_name : str
111
+ Name of the column to sort by.
112
+ direction : SQLOrderByDirection
113
+ Direction of sorting (ASC or DESC).
114
+ """
115
+
116
+ column_name: str
117
+ direction: SQLOrderByDirection
118
+
119
+
120
+ @dataclass(slots=True, frozen=True) # Higher memory efficiency with slots=True
121
+ class Condition:
122
+ """
123
+ Class representing a condition for the WHERE statement of an SQL query.
124
+
125
+ Attributes
126
+ ----------
127
+ column_name : str
128
+ Name of the column for the condition.
129
+ operator : SQLComparisonOperator
130
+ Comparison operator to use.
131
+ value : str or int or float or bytes or None, optional
132
+ Value to compare against. Ignored by `IS_NULL` and `IS_NOT_NULL`.
133
+ combination : SQLCombinationOperator, optional
134
+ Logical operator joining this condition to the previous one (default
135
+ is AND). The first condition of a list has nothing to join to, so its
136
+ combination is ignored.
137
+
138
+ Examples
139
+ --------
140
+ >>> model = Condition("model", SQLComparisonOperator.EQ, "standard")
141
+ >>> size = Condition("size", SQLComparisonOperator.GE, 4)
142
+
143
+ These conditions are translated by the Database class into this WHERE
144
+ statement, with both values bound as parameters::
145
+
146
+ WHERE "model" = ? AND "size" >= ?
147
+ """
148
+
149
+ column_name: str
150
+ operator: SQLComparisonOperator
151
+ value: str | int | float | bytes | None = None
152
+ combination: SQLCombinationOperator = SQLCombinationOperator.AND
153
+
154
+
155
+ class Database:
156
+ """
157
+ Class for managing an SQLite database.
158
+
159
+ It provides a structured API for retrieving, inserting, updating and
160
+ deleting data.
161
+
162
+ Attributes
163
+ ----------
164
+ debug_mode : bool
165
+ If this is True, every statement is written to the module logger at
166
+ debug level before it runs.
167
+ connection : sqlite3.Connection
168
+ Instance of the database connection.
169
+ cursor : sqlite3.Cursor
170
+ Instance of the database cursor.
171
+ """
172
+
173
+ connection: sqlite3.Connection
174
+ cursor: sqlite3.Cursor
175
+ debug_mode: bool
176
+ _table_cache: frozenset[str] | None
177
+ _column_cache: dict[str, frozenset[str]]
178
+
179
+
180
+ def __init__(self, database: str) -> None:
181
+ """
182
+ Opens the connection to the database.
183
+
184
+ Parameters
185
+ ----------
186
+ database : str
187
+ Path of the SQLite database file.
188
+ """
189
+ self.debug_mode = False
190
+ self._table_cache = None
191
+ self._column_cache = {}
192
+
193
+ # Establish database connection
194
+ self.connection = sqlite3.connect(database=database)
195
+
196
+ # Make sure that a dictionary in the form {column_name: value} is
197
+ # returned on queries (instead of a list with just the values)
198
+ self.connection.row_factory = sqlite3.Row
199
+
200
+ # Get cursor
201
+ self.cursor = self.connection.cursor()
202
+
203
+ def __enter__(self) -> Database:
204
+ """
205
+ Enables the use of the `with` statement for the database connection.
206
+
207
+ Returns
208
+ -------
209
+ Database
210
+ The Database instance.
211
+ """
212
+ return self
213
+
214
+ def __exit__(
215
+ self,
216
+ exc_type: type[BaseException] | None,
217
+ exc_value: BaseException | None,
218
+ traceback: TracebackType | None
219
+ ) -> None:
220
+ """
221
+ Commits and closes the connection when leaving a `with` block.
222
+
223
+ On the way out of an exception the transaction is rolled back
224
+ instead, so a block that failed half way through leaves nothing
225
+ behind.
226
+
227
+ Parameters
228
+ ----------
229
+ exc_type : type or None
230
+ Exception type if an exception was raised.
231
+ exc_value : Exception or None
232
+ Exception instance if an exception was raised.
233
+ traceback : traceback or None
234
+ Traceback object if an exception was raised.
235
+ """
236
+ if exc_type is None:
237
+ self.connection.commit()
238
+ else:
239
+ self.connection.rollback()
240
+
241
+ self.close()
242
+
243
+ def __del__(self) -> None:
244
+ """Closes the connection when the object is garbage collected."""
245
+ # __init__ may have failed before the connection was assigned, and an
246
+ # AttributeError raised in here would surface as an unraisable
247
+ if hasattr(self, "connection"):
248
+ self.close()
249
+
250
+ def close(self) -> None:
251
+ """Closes the database connection."""
252
+ if self.connection:
253
+ self.connection.close()
254
+
255
+ def query(
256
+ self, sql: str, params: Sequence[object] = ()
257
+ ) -> sqlite3.Cursor:
258
+ """
259
+ Runs an SQL command.
260
+
261
+ Use this method for commands that are not wrapped by this class. It
262
+ is the one place where the statement text comes from the caller, so
263
+ pass values through `params` rather than formatting them into `sql`.
264
+
265
+ Parameters
266
+ ----------
267
+ sql : str
268
+ The SQL command, with a `?` for every value.
269
+ params : Sequence[object], optional
270
+ Values bound to the placeholders in `sql`.
271
+
272
+ Returns
273
+ -------
274
+ sqlite3.Cursor
275
+ The cursor.
276
+ """
277
+ # The statement may have altered the schema, and the schema is what
278
+ # the identifier check is validated against
279
+ self._invalidate_schema_cache()
280
+
281
+ return self._execute(sql, params)
282
+
283
+ def fetch( # noqa: PLR0913 - the clauses of a SELECT are the point
284
+ self,
285
+ table: str,
286
+ columns: list[str] | None = None,
287
+ conditions: list[Condition] | None = None,
288
+ orderby: list[ColumnOrder] | None = None,
289
+ limit: int | None = None,
290
+ offset: int | None = None
291
+ ) -> list[dict[str, str | int | float | bytes | None]]:
292
+ """
293
+ Fetches entries from a table.
294
+
295
+ Parameters
296
+ ----------
297
+ table : str
298
+ Name of the table.
299
+ columns : list[str] or None, optional
300
+ Optional list of columns to be fetched; if None is given
301
+ all columns will be fetched.
302
+ conditions : list[Condition] or None, optional
303
+ Optional list of conditions.
304
+ orderby : list[ColumnOrder] or None, optional
305
+ Optional list defining column order.
306
+ limit : int or None, optional
307
+ Optional limit for the query.
308
+ offset : int or None, optional
309
+ Optional offset for the query.
310
+
311
+ Returns
312
+ -------
313
+ list[dict[str, Any]]
314
+ List of dictionaries in the form [{column_name: value}]
315
+
316
+ Raises
317
+ ------
318
+ UnknownIdentifierError
319
+ If the table or one of the columns is not part of the schema.
320
+ ValueError
321
+ If `columns` is an empty list, or a limit or offset is negative.
322
+ """
323
+ quoted_table = self._quoted_table(table)
324
+ sql = f"SELECT {self._select_list(table, columns)} FROM {quoted_table}" # noqa: S608
325
+ params: list[object] = []
326
+
327
+ # WHERE
328
+ if conditions:
329
+ where_sql, where_params = self._build_where(table, conditions)
330
+ sql += f" WHERE {where_sql}"
331
+ params.extend(where_params)
332
+
333
+ # ORDER BY
334
+ if orderby:
335
+ sql += f" ORDER BY {self._order_list(table, orderby)}"
336
+
337
+ # LIMIT / OFFSET
338
+ limit_sql, limit_params = self._build_limit(limit, offset)
339
+ sql += limit_sql
340
+ params.extend(limit_params)
341
+
342
+ rows: list[sqlite3.Row] = self._execute(sql, params).fetchall()
343
+
344
+ return [dict(row) for row in rows]
345
+
346
+ def insert(self, table: str, data: Sequence[Mapping[str, object]]) \
347
+ -> list[dict[str, str | int | float | bytes | None]]:
348
+ """
349
+ Inserts rows into the table.
350
+
351
+ All rows are written in one transaction: if any of them fails, none
352
+ of them is kept.
353
+
354
+ Parameters
355
+ ----------
356
+ table : str
357
+ Name of the table.
358
+ data : Sequence[Mapping[str, object]]
359
+ List of dictionaries, e.g.:
360
+ data = [{'col1': 'val1', 'col2': 'val2'}, ...]
361
+
362
+ Returns
363
+ -------
364
+ list[dict[str, str | int | float | bytes | None]]
365
+ List of dictionaries containing the inserted rows, e.g.:
366
+ [{'id': 1, 'col1': 'val1', 'col2': 'val2'}, ...]
367
+
368
+ Raises
369
+ ------
370
+ UnknownIdentifierError
371
+ If the table or one of the columns is not part of the schema.
372
+ ValueError
373
+ If one of the rows has no columns.
374
+ """
375
+ quoted_table = self._quoted_table(table)
376
+ inserted: list[dict[str, str | int | float | bytes | None]] = []
377
+
378
+ try:
379
+ for row in data:
380
+ inserted.append(self._insert_row(table, quoted_table, row))
381
+ except Exception:
382
+ self.connection.rollback()
383
+ raise
384
+
385
+ self.connection.commit()
386
+
387
+ return inserted
388
+
389
+ def update(
390
+ self, table: str,
391
+ values: Mapping[str, object],
392
+ conditions: list[Condition]
393
+ ) -> int:
394
+ """
395
+ Updates every row matching the given conditions.
396
+
397
+ Parameters
398
+ ----------
399
+ table : str
400
+ Name of the table.
401
+ values : Mapping[str, object]
402
+ New values, in the form {column_name: value}.
403
+ conditions : list[Condition]
404
+ Conditions selecting the rows to update.
405
+
406
+ Returns
407
+ -------
408
+ int
409
+ Number of rows that were changed.
410
+
411
+ Raises
412
+ ------
413
+ EmptyConditionsError
414
+ If `conditions` is empty. Rewriting every row is not something
415
+ this method does by accident.
416
+ UnknownIdentifierError
417
+ If the table or one of the columns is not part of the schema.
418
+ ValueError
419
+ If `values` is empty.
420
+ """
421
+ if not conditions:
422
+ raise EmptyConditionsError("update")
423
+ if not values:
424
+ raise ValueError("values must not be empty")
425
+
426
+ quoted_table = self._quoted_table(table)
427
+ columns = list(values.keys())
428
+ assignments = ", ".join(
429
+ f"{self._quoted_column(table, column)} = ?" for column in columns
430
+ )
431
+ where_sql, where_params = self._build_where(table, conditions)
432
+
433
+ sql = f"UPDATE {quoted_table} SET {assignments} WHERE {where_sql}" # noqa: S608
434
+ params = [values[column] for column in columns] + where_params
435
+
436
+ cursor = self._execute(sql, params)
437
+ self.connection.commit()
438
+
439
+ return cursor.rowcount
440
+
441
+ def remove(self, table: str, conditions: list[Condition]) -> int:
442
+ """
443
+ Removes the rows from the table which match the list of conditions.
444
+
445
+ Parameters
446
+ ----------
447
+ table : str
448
+ Name of the table.
449
+ conditions : list[Condition]
450
+ List of conditions.
451
+
452
+ Returns
453
+ -------
454
+ int
455
+ Number of rows that were deleted.
456
+
457
+ Raises
458
+ ------
459
+ EmptyConditionsError
460
+ If `conditions` is empty. Emptying the table is not something
461
+ this method does by accident.
462
+ UnknownIdentifierError
463
+ If the table or one of the columns is not part of the schema.
464
+ """
465
+ if not conditions:
466
+ raise EmptyConditionsError("delete")
467
+
468
+ quoted_table = self._quoted_table(table)
469
+ where_sql, where_params = self._build_where(table, conditions)
470
+
471
+ sql = f"DELETE FROM {quoted_table} WHERE {where_sql}" # noqa: S608
472
+
473
+ cursor = self._execute(sql, where_params)
474
+ self.connection.commit()
475
+
476
+ return cursor.rowcount
477
+
478
+ def save(self) -> None:
479
+ """Commits the current transaction."""
480
+ self.connection.commit()
481
+
482
+ def _insert_row(
483
+ self, table: str, quoted_table: str, row: Mapping[str, object]
484
+ ) -> dict[str, str | int | float | bytes | None]:
485
+ """Inserts one row and returns it as it was stored."""
486
+ if not row:
487
+ raise ValueError("cannot insert a row without any columns")
488
+
489
+ columns = list(row.keys())
490
+ column_list = ", ".join(
491
+ self._quoted_column(table, column) for column in columns
492
+ )
493
+ placeholders = ", ".join("?" for _ in columns)
494
+
495
+ sql = (f"INSERT INTO {quoted_table} ({column_list}) " # noqa: S608
496
+ f"VALUES ({placeholders})")
497
+ cursor = self._execute(sql, [row[column] for column in columns])
498
+
499
+ # Read the row back by rowid: every ordinary table has one, whatever
500
+ # its own columns happen to be called
501
+ stored: sqlite3.Row | None = self._execute(
502
+ f"SELECT * FROM {quoted_table} WHERE rowid = ?", # noqa: S608
503
+ [cursor.lastrowid]
504
+ ).fetchone()
505
+
506
+ # A table without a rowid cannot be read back this way; handing
507
+ # the caller what was written beats handing them nothing
508
+ if stored is None:
509
+ return cast(
510
+ "dict[str, str | int | float | bytes | None]", dict(row)
511
+ )
512
+
513
+ return dict(stored)
514
+
515
+ def _select_list(self, table: str, columns: list[str] | None) -> str:
516
+ """Builds the column list of a SELECT, or `*` for all columns."""
517
+ if columns is None:
518
+ return "*"
519
+ if not columns:
520
+ raise ValueError(
521
+ "columns must not be empty; pass None to select all columns"
522
+ )
523
+
524
+ return ", ".join(
525
+ self._quoted_column(table, column) for column in columns
526
+ )
527
+
528
+ def _order_list(self, table: str, orderby: list[ColumnOrder]) -> str:
529
+ """Builds the column list of an ORDER BY clause."""
530
+ return ", ".join(
531
+ f"{self._quoted_column(table, column.column_name)} "
532
+ f"{column.direction.value}"
533
+ for column in orderby
534
+ )
535
+
536
+ def _build_where(
537
+ self, table: str, conditions: list[Condition]
538
+ ) -> tuple[str, list[object]]:
539
+ """
540
+ Builds a WHERE clause plus the values bound to its placeholders.
541
+
542
+ The first condition has nothing to join to, so its `combination` is
543
+ ignored.
544
+ """
545
+ fragments: list[str] = []
546
+ params: list[object] = []
547
+
548
+ for position, condition in enumerate(conditions):
549
+ # Add "AND"/"OR" if it's not the first condition
550
+ if position > 0:
551
+ fragments.append(condition.combination.value)
552
+
553
+ column = self._quoted_column(table, condition.column_name)
554
+ if condition.operator in VALUELESS_OPERATORS:
555
+ fragments.append(f"{column} {condition.operator.value}")
556
+ else:
557
+ fragments.append(f"{column} {condition.operator.value} ?")
558
+ params.append(condition.value)
559
+
560
+ return " ".join(fragments), params
561
+
562
+ def _build_limit(
563
+ self, limit: int | None, offset: int | None
564
+ ) -> tuple[str, list[object]]:
565
+ """
566
+ Builds the LIMIT and OFFSET clause.
567
+
568
+ SQLite accepts OFFSET only together with LIMIT, so an offset given on
569
+ its own is paired with -1, which is SQLite's "no limit".
570
+ """
571
+ if limit is not None and limit < 0:
572
+ raise ValueError(f"limit must not be negative, got {limit}")
573
+ if offset is not None and offset < 0:
574
+ raise ValueError(f"offset must not be negative, got {offset}")
575
+
576
+ if limit is None and offset is None:
577
+ return "", []
578
+
579
+ clause = " LIMIT ?"
580
+ params: list[object] = [-1 if limit is None else limit]
581
+
582
+ if offset is not None:
583
+ clause += " OFFSET ?"
584
+ params.append(offset)
585
+
586
+ return clause, params
587
+
588
+ def _quoted_table(self, table: str) -> str:
589
+ """Checks a table name against the schema and quotes it."""
590
+ known = self._table_names()
591
+ if table not in known:
592
+ raise UnknownIdentifierError(table, tuple(sorted(known)))
593
+
594
+ return _quote_identifier(table)
595
+
596
+ def _quoted_column(self, table: str, column: str) -> str:
597
+ """Checks a column name against the table's schema and quotes it."""
598
+ known = self._column_names(table)
599
+ if column not in known:
600
+ raise UnknownIdentifierError(column, tuple(sorted(known)))
601
+
602
+ return _quote_identifier(column)
603
+
604
+ def _table_names(self) -> frozenset[str]:
605
+ """Returns the names of every table and view in the database."""
606
+ if self._table_cache is None:
607
+ rows: list[sqlite3.Row] = self._execute(
608
+ "SELECT name FROM sqlite_master "
609
+ "WHERE type IN ('table', 'view')"
610
+ ).fetchall()
611
+ self._table_cache = frozenset(
612
+ cast("str", row["name"]) for row in rows
613
+ )
614
+
615
+ return self._table_cache
616
+
617
+ def _column_names(self, table: str) -> frozenset[str]:
618
+ """Returns the column names of the given table."""
619
+ if table not in self._column_cache:
620
+ quoted_table = self._quoted_table(table)
621
+ rows: list[sqlite3.Row] = self._execute(
622
+ f"PRAGMA table_info({quoted_table})"
623
+ ).fetchall()
624
+ self._column_cache[table] = frozenset(
625
+ cast("str", row["name"]) for row in rows
626
+ )
627
+
628
+ return self._column_cache[table]
629
+
630
+ def _invalidate_schema_cache(self) -> None:
631
+ """Forgets the cached schema, e.g. after a statement that altered it."""
632
+ self._table_cache = None
633
+ self._column_cache.clear()
634
+
635
+ def _execute(
636
+ self, sql: str, params: Sequence[object] = ()
637
+ ) -> sqlite3.Cursor:
638
+ """Runs one statement with its values bound as parameters."""
639
+ if self.debug_mode:
640
+ _logger.debug("SQL: %s -- params: %r", sql, list(params))
641
+
642
+ return self.cursor.execute(sql, params)
643
+
644
+
645
+ def _quote_identifier(name: str) -> str:
646
+ """
647
+ Quotes an identifier so that it can be used in a statement.
648
+
649
+ Quoting is what makes a reserved word or a name containing a space usable
650
+ as a column. It is not the safety mechanism on its own - the callers check
651
+ the name against the schema first.
652
+ """
653
+ escaped = name.replace("\"", "\"\"")
654
+
655
+ return "\"" + escaped + "\""