composeui 0.1.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 (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1183 @@
1
+ """A model keeping the app's data in a SQLite database of its own.
2
+
3
+ The app writes its schema and its queries, in `SqliteTable` classes; the model gives
4
+ them a connection, runs each block of work as one transaction, and saves and opens the
5
+ database with the study. It works on a scratch copy in a temporary file: the user's
6
+ study only changes when it's saved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import csv
13
+ import datetime
14
+ import decimal
15
+ import functools
16
+ import gzip
17
+ import io
18
+ import itertools
19
+ import os
20
+ import re
21
+ import sqlite3
22
+ import tempfile
23
+ import threading
24
+ import time
25
+ import weakref
26
+ from pathlib import Path
27
+ from types import MappingProxyType
28
+ from typing import (
29
+ TYPE_CHECKING,
30
+ Any,
31
+ Callable,
32
+ ClassVar,
33
+ Generic,
34
+ NamedTuple,
35
+ Optional,
36
+ TypeVar,
37
+ Union,
38
+ cast,
39
+ get_args,
40
+ overload,
41
+ )
42
+ from urllib.parse import urlsplit
43
+ from urllib.request import urlopen
44
+
45
+ import attrs
46
+ from typing_extensions import dataclass_transform
47
+
48
+ from composeui.core.jobs import current_job
49
+ from composeui.core.model import InvalidStateError, Model, RefusedError
50
+ from composeui.core.signal import Signal, SignalNoArgs
51
+ from composeui.core.view import (
52
+ CONVERTER,
53
+ ColumnKind,
54
+ Filter,
55
+ FilterOperator,
56
+ Sort,
57
+ SourceColumn,
58
+ column_kind,
59
+ )
60
+
61
+ if TYPE_CHECKING:
62
+ from collections.abc import Generator, Iterable, Mapping, Sequence
63
+
64
+ from attrs import AttrsInstance
65
+ from typing_extensions import TypeAlias, Unpack
66
+
67
+ #: a query's parameters: by position, or by name
68
+ Parameters: TypeAlias = Union[Sequence[Any], Mapping[str, Any]]
69
+
70
+ __all__ = [
71
+ "CSVSqliteTable",
72
+ "SqliteModel",
73
+ "SqliteSource",
74
+ "SqliteTable",
75
+ "as_parameters",
76
+ "csv_field",
77
+ "define_csv_row",
78
+ "open_csv_url",
79
+ "table",
80
+ "unique_name",
81
+ ]
82
+
83
+ _T = TypeVar("_T")
84
+ SqliteTableT = TypeVar("SqliteTableT", bound="SqliteTable")
85
+
86
+
87
+ def as_parameters(row: object) -> dict[str, Any]:
88
+ """Get an `attrs` instance's fields by name, as named parameters of a query."""
89
+ parameters: dict[str, Any] = CONVERTER.unstructure(row)
90
+ return parameters
91
+
92
+
93
+ def _identifier(name: str) -> str:
94
+ """Quote a table's or a column's name for SQL, which can't take it as a parameter."""
95
+ return '"' + name.replace('"', '""') + '"'
96
+
97
+
98
+ def unique_name(db: sqlite3.Connection, table: str, column: str, base: str) -> str:
99
+ """Get `base`, or else "`base` 2", "`base` 3"..., the first no row of `table` has.
100
+
101
+ Names differing only by case are the same name (a ``COLLATE NOCASE`` column's), e.g. to
102
+ add a "Room" whatever rooms there are already.
103
+ """
104
+ sql = f"SELECT {_identifier(column)} FROM {_identifier(table)}" # noqa: S608 (quoted)
105
+ taken = {str(name).casefold() for (name,) in db.execute(sql) if name is not None}
106
+ name, number = base, 1
107
+ while name.casefold() in taken:
108
+ number += 1
109
+ name = f"{base} {number}"
110
+ return name
111
+
112
+
113
+ class SqliteTable:
114
+ """A table of a `SqliteModel` (or a few related ones), with the queries the app needs.
115
+
116
+ `create` is the SQL script that creates it, run in every new study. Its methods read
117
+ and write through `connection()`. `changed` is emitted once a transaction that changed
118
+ rows through the table has committed, so the views that show them can refresh.
119
+
120
+ A constraint that fails in a transaction refuses it: `connection()` raises a
121
+ `RefusedError`. Its message is the one of the first key of `refusals` found in
122
+ SQLite's message: a constraint's name, or e.g. "UNIQUE constraint failed: room.name".
123
+ Else it is SQLite's message.
124
+ """
125
+
126
+ create: ClassVar[str] = ""
127
+ #: by a part of SQLite's message, the user's message when a constraint fails, as
128
+ #: written in the code: the view showing it translates it (see `View.refusing`)
129
+ refusals: ClassVar[Mapping[str, str]] = MappingProxyType({})
130
+
131
+ def __init__(self, model: SqliteModel) -> None:
132
+ # weak: the model owns its tables, and should go (with its file) once unused
133
+ self._model = cast("SqliteModel", weakref.proxy(model))
134
+ self.changed = SignalNoArgs()
135
+
136
+ @contextlib.contextmanager
137
+ def connection(self) -> Generator[sqlite3.Connection]:
138
+ """Give the database's connection for one transaction; see `SqliteModel.connection`.
139
+
140
+ Raises `RefusedError` when a constraint fails, the transaction rolled back.
141
+ """
142
+ try:
143
+ with self._model.connection(changing=self) as db:
144
+ yield db
145
+ except sqlite3.IntegrityError as error:
146
+ message = str(error)
147
+ refusal = next((text for key, text in self.refusals.items() if key in message), message)
148
+ raise RefusedError(refusal) from error
149
+
150
+ @contextlib.contextmanager
151
+ def reading(self) -> Generator[sqlite3.Connection]:
152
+ """Give a connection reading the database alone; see `SqliteModel.reading`."""
153
+ with self._model.reading() as db:
154
+ yield db
155
+
156
+ def fetch(self, cls: type[_T], sql: str, parameters: Parameters = ()) -> list[_T]:
157
+ """Run the query `sql` and get its rows as `cls` instances (an `attrs` class)."""
158
+ with self.connection() as db:
159
+ return [CONVERTER.structure(dict(row), cls) for row in db.execute(sql, parameters)]
160
+
161
+ def fetch_one(self, cls: type[_T], sql: str, parameters: Parameters = ()) -> _T | None:
162
+ """Run the query `sql` and get its first row as a `cls` instance; None if none."""
163
+ with self.connection() as db:
164
+ row = db.execute(sql, parameters).fetchone()
165
+ return None if row is None else CONVERTER.structure(dict(row), cls)
166
+
167
+ def fetch_column(self, sql: str, parameters: Parameters = ()) -> list[Any]:
168
+ """Run the query `sql` and get its rows' first column, e.g. a list of names."""
169
+ with self.connection() as db:
170
+ return [row[0] for row in db.execute(sql, parameters)]
171
+
172
+ def insert_batches(
173
+ self,
174
+ sql: str,
175
+ rows: Iterable[Parameters],
176
+ *,
177
+ db: sqlite3.Connection | None = None,
178
+ batch_size: int = 100_000,
179
+ limit: int | None = None,
180
+ ) -> Generator[int]:
181
+ """Insert rows in batches and yield the cumulative number of rows submitted.
182
+
183
+ Rows are positional or named SQL parameters. Read at most ``limit`` rows, or all
184
+ rows if None. ``batch_size`` must be positive; ``limit`` must be nonnegative.
185
+ Counts describe submitted rows, not affected rows (e.g. with INSERT OR IGNORE).
186
+
187
+ Exhaust the generator to commit, or use ``contextlib.closing`` to roll back when
188
+ stopping early. All batches share one transaction. Inside ``connection()``, they
189
+ join its transaction instead: the caller must raise to roll that transaction back.
190
+ If ``db`` is supplied, use it without committing, rolling back or closing it.
191
+ Pass a connection from ``connection()`` to keep the table's change notifications.
192
+ """
193
+ if batch_size <= 0:
194
+ msg = "batch_size must be positive"
195
+ raise ValueError(msg)
196
+ if limit is not None and limit < 0:
197
+ msg = "limit must be nonnegative"
198
+ raise ValueError(msg)
199
+ iterator = iter(rows)
200
+ count = 0
201
+ with self.connection() if db is None else contextlib.nullcontext(db) as connection:
202
+ while limit is None or count < limit:
203
+ size = batch_size if limit is None else min(batch_size, limit - count)
204
+ batch = list(itertools.islice(iterator, size))
205
+ if not batch:
206
+ break
207
+ connection.executemany(sql, batch)
208
+ count += len(batch)
209
+ yield count
210
+
211
+
212
+ @overload
213
+ def csv_field(
214
+ name: str,
215
+ *,
216
+ date_format: str,
217
+ default: Any = attrs.NOTHING,
218
+ factory: Optional[Callable[[], Any]] = None,
219
+ converter: Any = None,
220
+ init: bool = True,
221
+ alias: Optional[str] = None,
222
+ kw_only: bool = False,
223
+ **kwargs: Any,
224
+ ) -> datetime.datetime: ...
225
+
226
+
227
+ @overload
228
+ def csv_field(
229
+ name: str,
230
+ *,
231
+ date_format: None = None,
232
+ default: Any = attrs.NOTHING,
233
+ factory: Optional[Callable[[], Any]] = None,
234
+ converter: Any = None,
235
+ init: bool = True,
236
+ alias: Optional[str] = None,
237
+ kw_only: bool = False,
238
+ **kwargs: Any,
239
+ ) -> Any: ...
240
+
241
+
242
+ def csv_field(name: str, *, date_format: str | None = None, **kwargs: Any) -> Any:
243
+ """Declare a CSV field, with an optional strptime format for a date or datetime."""
244
+ if date_format is not None and kwargs.get("converter") is not None:
245
+ msg = "Use either date_format or converter, not both"
246
+ raise TypeError(msg)
247
+ metadata = dict(kwargs.pop("metadata", {}))
248
+ metadata["csv_name"] = name
249
+ if date_format is not None:
250
+ metadata["csv_date_format"] = date_format
251
+ return attrs.field(metadata=metadata, **kwargs)
252
+
253
+
254
+ _RowClassT = TypeVar("_RowClassT", bound=type)
255
+
256
+
257
+ @dataclass_transform(field_specifiers=(attrs.field, csv_field))
258
+ def define_csv_row(cls: _RowClassT) -> _RowClassT:
259
+ """Define an attrs row class with typed CSV field specifiers.
260
+
261
+ Use ``@define_csv_row`` instead of ``@attrs.define`` when fields use `csv_field`.
262
+ For mypy, enable the ``composeui.mypy`` plugin.
263
+ """
264
+ return cast("_RowClassT", attrs.define(cls))
265
+
266
+
267
+ def _csv_structure_hook(field: attrs.Attribute[Any]) -> Callable[[Any, Any], Any] | None:
268
+ date_format = field.metadata.get("csv_date_format")
269
+ if date_format is None:
270
+ return None if field.converter is not None else CONVERTER.get_structure_hook(field.type)
271
+ args = get_args(field.type)
272
+ non_null = tuple(arg for arg in args if arg is not type(None))
273
+ nullable = len(non_null) == 1 and type(None) in args
274
+ field_type = non_null[0] if nullable else field.type
275
+ if field_type not in (datetime.date, datetime.datetime):
276
+ msg = f"date_format requires a date or datetime field: {field.name}"
277
+ raise TypeError(msg)
278
+
279
+ def structure(value: Any, _type: Any) -> datetime.date | None:
280
+ if value is None and nullable:
281
+ return None
282
+ parsed = datetime.datetime.strptime(value, date_format) # noqa: DTZ007 (preserve CSV timezone semantics)
283
+ return parsed.date() if field_type is datetime.date else parsed
284
+
285
+ return structure
286
+
287
+
288
+ class _CSVField(NamedTuple):
289
+ header: str
290
+ alias: str
291
+ field_type: Any
292
+ structure: Callable[[Any, Any], Any] | None
293
+
294
+
295
+ @contextlib.contextmanager
296
+ def _csv_text(binary: io.BufferedIOBase, *, encoding: str) -> Generator[io.TextIOWrapper]:
297
+ """Decode a binary CSV stream, detect gzip, and close its wrappers on exit."""
298
+ with contextlib.ExitStack() as stack:
299
+ buffer = stack.enter_context(io.BufferedReader(binary))
300
+ stream = (
301
+ stack.enter_context(gzip.GzipFile(fileobj=buffer, mode="rb"))
302
+ if buffer.peek(2).startswith(b"\x1f\x8b")
303
+ else buffer
304
+ )
305
+ text = stack.enter_context(io.TextIOWrapper(stream, encoding=encoding, newline=""))
306
+ yield text
307
+
308
+
309
+ def _replay_csv_sample(sample: str, text: io.TextIOWrapper) -> Generator[str]:
310
+ with io.StringIO(sample, newline="") as prefix:
311
+ for line in prefix:
312
+ # A sample can end inside a physical line, including a quoted field.
313
+ yield line if line.endswith(("\r", "\n")) else line + text.readline()
314
+ yield from text
315
+
316
+
317
+ @contextlib.contextmanager
318
+ def open_csv_url(
319
+ url: str, *, encoding: str = "utf-8", timeout: float = 30.0
320
+ ) -> Generator[io.TextIOWrapper]:
321
+ """Open a trusted HTTP, HTTPS or file URL as CSV text, and close its streams on exit.
322
+
323
+ Detect gzip by its content, not its filename. Preserve CSV newlines and decode with
324
+ ``encoding``. ``timeout`` sets the socket timeout in seconds, not a total download
325
+ deadline. Network, decompression and decoding errors propagate to the caller.
326
+ Do not pass untrusted URLs: the helper does not restrict hosts or local file access.
327
+ """
328
+ if urlsplit(url).scheme not in {"http", "https", "file"}:
329
+ msg = "CSV URLs must use http, https or file"
330
+ raise ValueError(msg)
331
+ with (
332
+ urlopen(url, timeout=timeout) as response, # noqa: S310 (schemes checked above; trusted URLs only)
333
+ _csv_text(response, encoding=encoding) as text,
334
+ ):
335
+ yield text
336
+
337
+
338
+ class CSVSqliteTable(SqliteTable):
339
+ """A CSV table whose schema comes from an attrs row class."""
340
+
341
+ row_type: ClassVar[type[AttrsInstance]]
342
+ name: ClassVar[str]
343
+ insert: ClassVar[str] = ""
344
+ _column_names: ClassVar[tuple[str, ...]]
345
+ _csv_fields: ClassVar[tuple[_CSVField, ...]]
346
+
347
+ def __init_subclass__(
348
+ cls, *, row_type: type[AttrsInstance] | None = None, name: str | None = None, **kwargs: Any
349
+ ) -> None:
350
+ super().__init_subclass__(**kwargs)
351
+ if row_type is None and name is None:
352
+ return
353
+ if row_type is None or not name:
354
+ msg = "CSVSqliteTable requires an attrs row_type and a table name"
355
+ raise TypeError(msg)
356
+ attrs.resolve_types(row_type)
357
+ fields = attrs.fields(row_type)
358
+ if not fields:
359
+ msg = "CSV row classes must have at least one field"
360
+ raise TypeError(msg)
361
+ sql_types = {
362
+ str: "TEXT",
363
+ int: "INTEGER",
364
+ bool: "INTEGER",
365
+ float: "REAL",
366
+ bytes: "BLOB",
367
+ datetime.date: "DATE",
368
+ datetime.datetime: "TIMESTAMP",
369
+ decimal.Decimal: "REAL",
370
+ }
371
+ columns = []
372
+ for field in fields:
373
+ args = get_args(field.type)
374
+ non_null = tuple(arg for arg in args if arg is not type(None))
375
+ nullable = len(non_null) == 1 and type(None) in args
376
+ field_type = non_null[0] if nullable else field.type
377
+ if field_type not in sql_types:
378
+ msg = f"Unsupported CSV field type for {field.name}: {field.type}"
379
+ raise TypeError(msg)
380
+ constraint = "" if nullable else " NOT NULL"
381
+ columns.append(f"{_identifier(field.name)} {sql_types[field_type]}{constraint}")
382
+ cls.row_type = row_type
383
+ cls.name = name
384
+ cls._column_names = tuple(field.name for field in fields)
385
+ cls._csv_fields = tuple(
386
+ _CSVField(
387
+ header=field.metadata.get("csv_name", field.name),
388
+ alias=field.alias,
389
+ field_type=field.type,
390
+ structure=_csv_structure_hook(field),
391
+ )
392
+ for field in fields
393
+ if field.init
394
+ )
395
+ cls.create = f"CREATE TABLE {_identifier(name)} ({', '.join(columns)});"
396
+ names = ", ".join(_identifier(field.name) for field in fields)
397
+ placeholders = ", ".join("?" for _ in fields)
398
+ cls.insert = f"INSERT INTO {_identifier(name)} ({names}) VALUES ({placeholders})" # noqa: S608 (quoted identifiers)
399
+
400
+ def source(self, *, headers: Mapping[str, str] | None = None) -> SqliteSource:
401
+ """Build a source with schema-derived types and SQLite row ids.
402
+
403
+ By default, show every field under its CSV header (or its field name).
404
+ ``headers`` selects, orders and labels the fields to show. Raises `KeyError`
405
+ for an unknown field, or `ValueError` if fields shadow all SQLite row-id names.
406
+ Use `SqliteSource` directly for custom queries or explicit primary keys.
407
+ """
408
+ fields = attrs.fields_dict(self.row_type)
409
+ if headers is None:
410
+ headers = {
411
+ field.name: field.metadata.get("csv_name", field.name) for field in fields.values()
412
+ }
413
+ columns = [
414
+ SourceColumn(name, header, fields[name].type) for name, header in headers.items()
415
+ ]
416
+ names = {name.casefold() for name in fields}
417
+ row_id = next((name for name in ("rowid", "_rowid_", "oid") if name not in names), None)
418
+ if row_id is None:
419
+ msg = "CSV fields shadow all SQLite row-id names; use SqliteSource with an explicit key"
420
+ raise ValueError(msg)
421
+ # SQLite otherwise names every hidden row-id expression "rowid".
422
+ identifier = _identifier(row_id)
423
+ query = f"SELECT {identifier} AS {identifier}, * FROM {_identifier(self.name)}" # noqa: S608 (quoted identifiers)
424
+ return SqliteSource(self, query, id_column=row_id, columns=columns)
425
+
426
+ def row_parameters(self, row: dict[str, Any]) -> Parameters:
427
+ """Convert CSV fields through the row class and return SQL parameters."""
428
+ values = {}
429
+ try:
430
+ for field in self._csv_fields:
431
+ if field.header in row:
432
+ value = row[field.header]
433
+ values[field.alias] = (
434
+ value
435
+ if field.structure is None
436
+ else field.structure(value, field.field_type)
437
+ )
438
+ instance = self.row_type(**values)
439
+ except (ValueError, TypeError) as error:
440
+ raise RefusedError(str(error)) from error
441
+ return tuple(_sql_value(getattr(instance, name)) for name in self._column_names)
442
+
443
+ def fill(
444
+ self,
445
+ text: Iterable[str],
446
+ *,
447
+ batch_size: int = 100_000,
448
+ limit: int | None = None,
449
+ **csv_options: Any,
450
+ ) -> Generator[int]:
451
+ """Import CSV text and yield cumulative row counts.
452
+
453
+ Pass an open text file (with ``newline=''``) or another iterable of lines.
454
+ The caller owns and closes it. Extra keywords go to ``csv.DictReader``,
455
+ for example ``delimiter=';'`` or ``fieldnames=['name', 'value']``.
456
+ Exhaust the generator to commit. Use ``contextlib.closing`` when stopping
457
+ early to roll back rows. Nested transactions follow
458
+ `SqliteTable.insert_batches`'s rules.
459
+ """
460
+ rows = (self.row_parameters(row) for row in csv.DictReader(text, **csv_options))
461
+ yield from self.insert_batches(self.insert, rows, batch_size=batch_size, limit=limit)
462
+
463
+ def fill_from_file( # noqa: PLR0913 (keep decoding, detection and import options explicit)
464
+ self,
465
+ path: str | os.PathLike[str],
466
+ *,
467
+ encoding: str = "utf-8",
468
+ sniff: bool = False,
469
+ sniff_sample_size: int = 8192,
470
+ batch_size: int = 100_000,
471
+ limit: int | None = None,
472
+ **csv_options: Any,
473
+ ) -> Generator[int]:
474
+ """Import a plain or gzip CSV file and yield cumulative submitted-row counts.
475
+
476
+ Accept a path string or path-like object, relative or absolute. Detect gzip
477
+ from the content. Own and close the file; other options follow `fill`.
478
+ Exhaust the generator to commit, or use ``contextlib.closing`` when stopping
479
+ early to close the file and roll back. File and decoding errors propagate.
480
+ Set ``sniff=True`` to detect the dialect from at most ``sniff_sample_size``
481
+ decoded characters (8192 by default). The size must be positive when sniffing
482
+ and is ignored otherwise.
483
+ Explicit CSV options override detection. Detection failure raises `RefusedError`.
484
+ """
485
+ with (
486
+ Path(path).open("rb") as binary,
487
+ _csv_text(binary, encoding=encoding) as text,
488
+ contextlib.closing(
489
+ self._fill_stream(
490
+ text,
491
+ sniff=sniff,
492
+ sniff_sample_size=sniff_sample_size,
493
+ batch_size=batch_size,
494
+ limit=limit,
495
+ **csv_options,
496
+ )
497
+ ) as batches,
498
+ ):
499
+ yield from batches
500
+
501
+ def fill_from_url( # noqa: PLR0913 (keep transport, detection and import options explicit)
502
+ self,
503
+ url: str,
504
+ *,
505
+ encoding: str = "utf-8",
506
+ timeout: float = 30.0,
507
+ sniff: bool = False,
508
+ sniff_sample_size: int = 8192,
509
+ batch_size: int = 100_000,
510
+ limit: int | None = None,
511
+ **csv_options: Any,
512
+ ) -> Generator[int]:
513
+ """Import CSV from a trusted URL and yield cumulative submitted-row counts.
514
+
515
+ `open_csv_url` handles decoding, gzip and stream cleanup. Other options follow
516
+ `fill`. Exhaust the generator to commit; use ``contextlib.closing`` if iteration
517
+ can stop early, to close the streams and roll back. Existing rows are retained.
518
+ ``sniff=True`` detects the dialect as in `fill_from_file`, without seeking.
519
+ ``sniff_sample_size`` sets its limit in decoded characters (8192 by default).
520
+ """
521
+ with (
522
+ open_csv_url(url, encoding=encoding, timeout=timeout) as text,
523
+ contextlib.closing(
524
+ self._fill_stream(
525
+ text,
526
+ sniff=sniff,
527
+ sniff_sample_size=sniff_sample_size,
528
+ batch_size=batch_size,
529
+ limit=limit,
530
+ **csv_options,
531
+ )
532
+ ) as batches,
533
+ ):
534
+ yield from batches
535
+
536
+ def _fill_stream(
537
+ self,
538
+ text: io.TextIOWrapper,
539
+ *,
540
+ sniff: bool,
541
+ sniff_sample_size: int,
542
+ batch_size: int,
543
+ limit: int | None,
544
+ **csv_options: Any,
545
+ ) -> Generator[int]:
546
+ lines: Iterable[str] = text
547
+ if sniff:
548
+ if sniff_sample_size <= 0:
549
+ msg = "sniff_sample_size must be positive"
550
+ raise ValueError(msg)
551
+ sample = text.read(sniff_sample_size)
552
+ try:
553
+ dialect = csv.Sniffer().sniff(sample)
554
+ except csv.Error as error:
555
+ msg = (
556
+ "Cannot detect the CSV dialect; increase sniff_sample_size or disable sniff"
557
+ " and supply explicit CSV options"
558
+ )
559
+ raise RefusedError(msg) from error
560
+ csv_options = {"dialect": dialect, **csv_options}
561
+ lines = _replay_csv_sample(sample, text)
562
+ yield from self.fill(lines, batch_size=batch_size, limit=limit, **csv_options)
563
+
564
+
565
+ #: the seconds a connection waits for another's lock before it fails: in the WAL journal,
566
+ #: only a write for another's, or a `PRAGMA` changing the journal
567
+ WAIT_SECONDS = 60.0
568
+ #: the ids a query takes at most at once, below SQLite's limit of parameters
569
+ _IDS_PER_QUERY = 900
570
+ #: each text operator, as a `LIKE` pattern of the filter's value (escaped), negated or not
571
+ _LIKES: Mapping[FilterOperator, tuple[str, bool]] = MappingProxyType(
572
+ {
573
+ FilterOperator.CONTAINS: ("%{}%", False),
574
+ FilterOperator.NOT_CONTAINS: ("%{}%", True),
575
+ FilterOperator.STARTS_WITH: ("{}%", False),
576
+ FilterOperator.ENDS_WITH: ("%{}", False),
577
+ }
578
+ )
579
+ #: each text comparison, in SQL, ignoring the case of ASCII letters
580
+ _TEXT_COMPARISONS: Mapping[FilterOperator, str] = MappingProxyType(
581
+ {FilterOperator.TEXT_EQUAL: "=", FilterOperator.TEXT_NOT_EQUAL: "<>"}
582
+ )
583
+ #: each comparison, in SQL
584
+ _COMPARISONS: Mapping[FilterOperator, str] = MappingProxyType(
585
+ {
586
+ FilterOperator.EQUAL: "=",
587
+ FilterOperator.NOT_EQUAL: "<>",
588
+ FilterOperator.LESS: "<",
589
+ FilterOperator.LESS_OR_EQUAL: "<=",
590
+ FilterOperator.GREATER: ">",
591
+ FilterOperator.GREATER_OR_EQUAL: ">=",
592
+ }
593
+ )
594
+
595
+
596
+ def _escaped(text: str) -> str:
597
+ r"""Escape `LIKE`'s wildcards in `text`, with a backslash (``ESCAPE '\'``)."""
598
+ return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
599
+
600
+
601
+ def _sql_value(value: Any) -> Any:
602
+ """Get a filter's value as SQLite stores it: a date as ISO text, a number as a number."""
603
+ if isinstance(value, (datetime.date, datetime.datetime)):
604
+ return value.isoformat()
605
+ if isinstance(value, decimal.Decimal):
606
+ return float(value)
607
+ return value # an enum's column filters as text: never compared
608
+
609
+
610
+ @functools.lru_cache(maxsize=32)
611
+ def _compiled(pattern: str) -> re.Pattern[str]:
612
+ return re.compile(pattern, re.IGNORECASE)
613
+
614
+
615
+ def _casefold(value: Any) -> str:
616
+ """SQL's ``casefold``: a cell's text as Python compares it, ignoring any case."""
617
+ return "" if value is None else str(value).casefold()
618
+
619
+
620
+ def _folded(cell: str, *, is_ascii: bool) -> str:
621
+ """Get the SQL of `cell`, case-folded by Python unless compared with ASCII text typed.
622
+
623
+ SQLite ignores the case of ASCII letters alone: "é" wouldn't find "É". Text typed in
624
+ ASCII keeps SQLite's comparison, which an index serves, without Python's call per row
625
+ (3 to 4 times slower). It doesn't find a cell whose letters fold into it: "strasse"
626
+ doesn't find "Straße", which "Straße" finds.
627
+ """
628
+ return cell if is_ascii else f"casefold({cell})"
629
+
630
+
631
+ def _regexp(pattern: str, value: Any) -> bool:
632
+ """SQLite's ``REGEXP``: whether `pattern` is found in `value`'s text, ignoring case."""
633
+ return _compiled(pattern).search("" if value is None else str(value)) is not None
634
+
635
+
636
+ class SqliteSource:
637
+ """A `TableSource` of a query's rows, which a large table reads a window at a time.
638
+
639
+ It takes the `SqliteTable` whose rows the query reads, the query itself, the column
640
+ of the rows' ids (e.g. a primary key) and the query's columns that the table shows.
641
+ SQLite sorts and filters the rows: the order is a ``SELECT`` of the ids, and the cells
642
+ a ``SELECT`` of some ids' rows, so no more rows than those shown come to Python. The
643
+ table's `changed` reads them again. Only the declared columns sort and filter, by name:
644
+ SQLite takes no column name as a parameter, the values are. It reads on connections
645
+ of its own (`SqliteModel.reading`): the rows committed, never waiting for a write, nor
646
+ the cells for the order a job reads.
647
+
648
+ A text compares ignoring its case as a `ListSource` does, but text typed in ASCII
649
+ alone compares in SQLite (see `_folded`). It sorts ignoring the case of ASCII letters
650
+ alone (``NOCASE``). Equal cells, and the rows unsorted, keep the order of their ids.
651
+ A ``/regex/`` filter, or a text not in ASCII, calls Python on each row: index the
652
+ columns that sort instead.
653
+ """
654
+
655
+ def __init__(
656
+ self,
657
+ table: SqliteTable,
658
+ query: str,
659
+ *,
660
+ id_column: str,
661
+ columns: Sequence[SourceColumn],
662
+ ) -> None:
663
+ self._table = table
664
+ self._query = query
665
+ self._id = _identifier(id_column)
666
+ self._columns = {column.name: column for column in columns}
667
+
668
+ @property
669
+ def columns(self) -> Sequence[SourceColumn]:
670
+ return tuple(self._columns.values())
671
+
672
+ @property
673
+ def changed(self) -> SignalNoArgs:
674
+ return self._table.changed
675
+
676
+ def count(self) -> int:
677
+ with self._table.reading() as db:
678
+ # read to the end: the connection is reused (see `SqliteModel.reading`)
679
+ ((count,),) = db.execute(f"SELECT COUNT(*) FROM ({self._query})").fetchall() # noqa: S608 (the app's query)
680
+ return int(count)
681
+
682
+ def ordered_ids(
683
+ self, sort: Sort | None, search: str, filters: Mapping[str, Filter]
684
+ ) -> list[Any]:
685
+ conditions: list[str] = []
686
+ parameters: list[Any] = []
687
+ needle = search.strip()
688
+ texts = [
689
+ name
690
+ for name, column in self._columns.items()
691
+ if column_kind(column.type) is ColumnKind.TEXT
692
+ ]
693
+ if needle != "" and len(texts) == 0:
694
+ conditions.append("0") # no text column holds it, as a `ListSource` finds
695
+ elif needle != "":
696
+ is_ascii = needle.isascii()
697
+ if not is_ascii:
698
+ needle = needle.casefold()
699
+ cells = [_folded(_identifier(name), is_ascii=is_ascii) for name in texts]
700
+ conditions.append(
701
+ "(" + " OR ".join(f"{cell} LIKE ? ESCAPE '\\'" for cell in cells) + ")"
702
+ )
703
+ parameters.extend([f"%{_escaped(needle)}%"] * len(texts))
704
+ for name, row_filter in filters.items():
705
+ condition, values = self._condition(self._column(name), row_filter)
706
+ conditions.append(condition)
707
+ parameters.extend(values)
708
+ # by id unsorted: else in the order of an index a filter reads by, no header showing it
709
+ queries = [(conditions, self._id)]
710
+ if sort is not None:
711
+ column = self._column(sort.column)
712
+ name = _identifier(column.name)
713
+ is_text = column_kind(column.type) is ColumnKind.TEXT
714
+ order = f"{name} COLLATE NOCASE" if is_text else name
715
+ direction = " DESC" if sort.descending else ""
716
+ empty = f"({name} IS NULL OR {name} = '')"
717
+ if len(conditions) > 0:
718
+ # the empty cells last, in both orders, SQLite sorting the rows passing:
719
+ # read in their column's index order, the table would be read row by row
720
+ queries = [(conditions, f"{empty} ASC, {order}{direction}, {self._id}")]
721
+ else:
722
+ # apart, the cells with a value read in their column's index order: an
723
+ # ORDER BY starting with the empty cells couldn't
724
+ queries = [
725
+ ([f"NOT {empty}"], f"{order}{direction}, {self._id}"),
726
+ ([empty], self._id),
727
+ ]
728
+ with self._table.reading() as db:
729
+ db.create_function("regexp", 2, _regexp, deterministic=True)
730
+ db.create_function("casefold", 1, _casefold, deterministic=True)
731
+ db.execute("BEGIN") # the queries read the same rows, a write committed between
732
+ try:
733
+ return [
734
+ row[0]
735
+ for where, order_by in queries
736
+ for row in db.execute(self._select_ids(where, order_by), parameters)
737
+ ]
738
+ finally:
739
+ db.rollback()
740
+
741
+ def _select_ids(self, conditions: list[str], order_by: str) -> str:
742
+ """Get the query of the ids of the rows passing `conditions`, ordered by `order_by`."""
743
+ sql = f"SELECT {self._id} FROM ({self._query})" # noqa: S608 (quoted, the app's query)
744
+ if len(conditions) > 0:
745
+ sql += " WHERE " + " AND ".join(conditions)
746
+ return f"{sql} ORDER BY {order_by}"
747
+
748
+ def cells(self, ids: Sequence[Any], columns: Sequence[str]) -> dict[Any, list[Any]]:
749
+ types = [self._column(name).type for name in columns]
750
+ names = ", ".join(_identifier(name) for name in columns)
751
+ found: dict[Any, list[Any]] = {}
752
+ with self._table.reading() as db:
753
+ for start in range(0, len(ids), _IDS_PER_QUERY):
754
+ chunk = list(ids[start : start + _IDS_PER_QUERY])
755
+ marks = ", ".join("?" * len(chunk))
756
+ sql = (
757
+ f"SELECT {self._id}, {names} FROM ({self._query})" # noqa: S608 (quoted)
758
+ f" WHERE {self._id} IN ({marks})"
759
+ )
760
+ for row in db.execute(sql, chunk):
761
+ found[row[0]] = [
762
+ None if value is None else CONVERTER.structure(value, field_type)
763
+ for value, field_type in zip(tuple(row)[1:], types)
764
+ ]
765
+ return found
766
+
767
+ def _column(self, name: str) -> SourceColumn:
768
+ column = self._columns.get(name)
769
+ if column is None:
770
+ msg = f"Cannot read the column {name!r} of a SqliteSource: it declares none so."
771
+ raise ValueError(msg)
772
+ return column
773
+
774
+ @staticmethod
775
+ def _condition(column: SourceColumn, row_filter: Filter) -> tuple[str, list[Any]]:
776
+ """Get the SQL condition of `row_filter` on `column`, and its parameters.
777
+
778
+ As `Filter.matches` reads a cell: an empty one's text is "", and it fails a
779
+ comparison.
780
+ """
781
+ name = _identifier(column.name)
782
+ text = f"COALESCE({name}, '')"
783
+ operator, value = row_filter.operator, row_filter.value
784
+ if operator is FilterOperator.EMPTY:
785
+ return f"({name} IS NULL OR {name} = '')", []
786
+ if operator in _LIKES:
787
+ pattern, is_negated = _LIKES[operator]
788
+ negation = "NOT " if is_negated else ""
789
+ cell = _folded(text, is_ascii=row_filter.typed_ascii)
790
+ return f"{cell} {negation}LIKE ? ESCAPE '\\'", [pattern.format(_escaped(value))]
791
+ if operator in _TEXT_COMPARISONS:
792
+ collation = " COLLATE NOCASE" if row_filter.typed_ascii else ""
793
+ cell = _folded(text, is_ascii=row_filter.typed_ascii)
794
+ return f"{cell} {_TEXT_COMPARISONS[operator]} ?{collation}", [value]
795
+ if operator is FilterOperator.MATCHES:
796
+ return f"regexp(?, {name})", [value.pattern]
797
+ if operator is FilterOperator.RANGE:
798
+ return f"{name} BETWEEN ? AND ?", [_sql_value(value), _sql_value(row_filter.upper)]
799
+ return f"{name} {_COMPARISONS[operator]} ?", [_sql_value(value)]
800
+
801
+
802
+ class _DeclaredTable(Generic[SqliteTableT]):
803
+ """A table a `SqliteModel` class declares, which each model builds (see `table`)."""
804
+
805
+ def __init__(self, table_type: type[SqliteTableT]) -> None:
806
+ self.table_type = table_type
807
+
808
+
809
+ def table(table_type: type[SqliteTableT]) -> SqliteTableT:
810
+ """Declare a table of a `SqliteModel`, in the model's class.
811
+
812
+ E.g. ``runs = table(Runs)``. Each model builds its own table, as an attribute of the
813
+ same name, before it creates its database. The tables are built in the order the
814
+ classes declare them, a base class's first.
815
+ """
816
+ # the model's attribute is the table: typed as one, as `attrs.Factory` is
817
+ return cast("SqliteTableT", _DeclaredTable(table_type))
818
+
819
+
820
+ class SqliteModel(Model):
821
+ """A model whose data is a SQLite database, saved and opened with the study.
822
+
823
+ Declare the tables in the class, ``runs = table(Runs)``, for the callbacks to reach
824
+ (``model.runs.add(...)``), or add them in `__init__` with `add_table`. A study's
825
+ database is created from the tables' `create` scripts at `schema_version`. When a
826
+ study saved at an older version is opened, `migrations` bring it up to date.
827
+ """
828
+
829
+ data_name: ClassVar[str | None] = "data.sqlite"
830
+ #: the tables' version, kept in the database (``PRAGMA user_version``); raise it with
831
+ #: every change to the tables, adding the migration from the previous one
832
+ schema_version: ClassVar[int] = 1
833
+ #: by version: the function bringing a database at that version to the next one
834
+ migrations: ClassVar[Mapping[int, Callable[[sqlite3.Connection], None]]] = {}
835
+ #: seconds opening a study's database may take, checks and migrations included: its
836
+ #: schema comes from the file, and a view of it may never finish (e.g. a recursive one)
837
+ open_time_limit: ClassVar[float] = 30.0
838
+ #: set on the connections of every study's database: SQLite keeps these per
839
+ #: connection, not in the file, and New and Open each start a connection of their own
840
+ pragmas: ClassVar[Mapping[str, int | str]] = MappingProxyType(
841
+ {
842
+ # the connections `reading` read during a write, its rows uncommitted (else a
843
+ # write spilling to the disk locks them out until it commits); the study file
844
+ # is saved in the default journal (`save_data`)
845
+ "journal_mode": "WAL",
846
+ "foreign_keys": "ON",
847
+ # the database is a scratch copy nothing reopens after a crash: waiting for the
848
+ # disk on every commit protects nothing, the study file is written apart
849
+ "synchronous": "OFF",
850
+ # a row replaced (`INSERT OR REPLACE`) fires its delete triggers, and a trigger
851
+ # may fire itself, so triggers keeping other tables up to date see every change
852
+ "recursive_triggers": "ON",
853
+ }
854
+ )
855
+
856
+ def __init__(self) -> None:
857
+ self._tables: list[SqliteTable] = []
858
+ declared: dict[str, _DeclaredTable[Any]] = {}
859
+ for cls in reversed(type(self).__mro__):
860
+ declared.update(
861
+ (name, value)
862
+ for name, value in vars(cls).items()
863
+ if isinstance(value, _DeclaredTable)
864
+ )
865
+ for name, declared_table in declared.items():
866
+ # before the database, which `_new_database` creates with them
867
+ built = declared_table.table_type(self)
868
+ self._tables.append(built)
869
+ setattr(self, name, built)
870
+ # one connection for the model, which threads (a worker, web requests) take in turn
871
+ self._lock = threading.RLock()
872
+ # which database the queries `reading` read, and how many: held for no query, so a
873
+ # read never waits for the turn a write holds
874
+ self._readers_lock = threading.Lock()
875
+ self._closed = False
876
+ self._depth = 0
877
+ self._changed: list[SqliteTable] = []
878
+ self._database = self._new_database()
879
+
880
+ @property
881
+ def signals(self) -> list[Signal[Unpack[tuple[Any, ...]]]]:
882
+ """Get the model's signals, its tables' (`SqliteTable.changed`) among them."""
883
+ tables = [vars(table).values() for table in self._tables]
884
+ return [
885
+ *super().signals,
886
+ *(value for values in tables for value in values if isinstance(value, Signal)),
887
+ ]
888
+
889
+ def add_table(self, table_type: type[SqliteTableT]) -> SqliteTableT:
890
+ """Add a table, created in the current database too, and return it."""
891
+ table = table_type(self)
892
+ with self._lock:
893
+ self._database.connection.executescript(table.create)
894
+ self._tables.append(table)
895
+ return table
896
+
897
+ @contextlib.contextmanager
898
+ def connection(self, *, changing: SqliteTable | None = None) -> Generator[sqlite3.Connection]:
899
+ """Give the database's connection for one transaction.
900
+
901
+ The transaction commits once the block ends, or rolls back if the block raises, so
902
+ its writes land together or not at all. A block inside another (one table's method
903
+ calling another's) joins the outer transaction. If the block changed rows,
904
+ `changing`'s `changed` is emitted after the commit. For a block in a job's thread, it
905
+ is emitted on the main thread (see `Job.call_on_main_thread`), where the views it
906
+ refreshes are.
907
+ """
908
+ changed: list[SqliteTable] = []
909
+ with self._lock:
910
+ db = self._database.connection
911
+ before = db.total_changes
912
+ outermost = self._depth == 0
913
+ self._depth += 1
914
+ try:
915
+ yield db
916
+ if (
917
+ changing is not None
918
+ and db.total_changes != before
919
+ and changing not in self._changed
920
+ ):
921
+ self._changed.append(changing)
922
+ if outermost:
923
+ db.commit()
924
+ changed, self._changed = self._changed, []
925
+ except BaseException:
926
+ if outermost:
927
+ db.rollback()
928
+ self._changed = []
929
+ raise
930
+ finally:
931
+ self._depth -= 1
932
+ # outside the lock: a view refreshing may query another thread's way
933
+ job = current_job()
934
+ for table in changed:
935
+ if job is None:
936
+ table.changed.emit()
937
+ else:
938
+ job.call_on_main_thread(table.changed.emit)
939
+
940
+ @contextlib.contextmanager
941
+ def reading(self) -> Generator[sqlite3.Connection]:
942
+ """Give a connection that only reads the database, for a long query.
943
+
944
+ Unlike `connection()`, it waits for no other thread's turn, and holds none: the
945
+ model's other queries run meanwhile, e.g. a large table's cells while a job orders
946
+ its rows or writes some (`SqliteSource`). In the WAL journal, it reads the rows
947
+ committed, during a write too. A database New or Open replace meanwhile, or the
948
+ model's `close`, is deleted once the query is over. Once the model is closed,
949
+ raises `sqlite3.ProgrammingError`, as `connection()` does: e.g. a web download
950
+ its session's end cuts short.
951
+
952
+ The connection is reused, e.g. for the next window of a large table: read each
953
+ query's rows to the end in the block. A query left half read keeps the rows as
954
+ they were when it started, for the next reader too.
955
+ """
956
+ with self._readers_lock:
957
+ if self._closed: # its file is gone
958
+ msg = "Cannot operate on a closed database."
959
+ raise sqlite3.ProgrammingError(msg)
960
+ database = self._database
961
+ database.readers += 1
962
+ db = database.idle_readers.pop() if len(database.idle_readers) > 0 else None
963
+ try:
964
+ if db is None:
965
+ db = database.open_reader()
966
+ yield db
967
+ finally:
968
+ with self._readers_lock:
969
+ database.readers -= 1
970
+ if db is not None:
971
+ database.idle_readers.append(db)
972
+ if database.readers == 0 and (database is not self._database or self._closed):
973
+ database.close()
974
+
975
+ def new(self) -> None:
976
+ self._replace_database(self._new_database())
977
+
978
+ def save_data(self, path: Path) -> None:
979
+ target = sqlite3.connect(str(path))
980
+ try:
981
+ with self._lock:
982
+ self._database.connection.backup(target)
983
+ # the backup copies the WAL journal: a file another app opens leaves none beside it
984
+ target.execute("PRAGMA journal_mode = DELETE")
985
+ finally:
986
+ target.close()
987
+
988
+ def open_data(self, path: Path) -> None:
989
+ """Open a study's database, refused unless it is made as a new one is.
990
+
991
+ The file may come from anyone (e.g. a web upload): its schema is the file's own,
992
+ whose views and triggers would run in the app. Until it is checked, its views and
993
+ triggers may call no function with side effects (`trusted_schema`), and the whole
994
+ open is cut after `open_time_limit`.
995
+ """
996
+ database = _Database(self.pragmas)
997
+ db = database.connection
998
+ try:
999
+ source = sqlite3.connect(str(path))
1000
+ try:
1001
+ source.backup(db)
1002
+ finally:
1003
+ source.close()
1004
+ database.start_journal() # after the backup: it keeps the file's page size
1005
+ db.execute("PRAGMA trusted_schema = OFF")
1006
+ deadline = time.monotonic() + self.open_time_limit
1007
+ # a true answer interrupts the statement running (`OperationalError`)
1008
+ db.set_progress_handler(lambda: time.monotonic() > deadline, 10_000)
1009
+ try:
1010
+ self._check(db)
1011
+ self._upgrade(db)
1012
+ self._check_schema(db)
1013
+ finally:
1014
+ db.set_progress_handler(None, 0)
1015
+ db.execute(
1016
+ _pragma_statement("trusted_schema", self.pragmas.get("trusted_schema", "ON"))
1017
+ )
1018
+ except sqlite3.DatabaseError as error:
1019
+ database.close()
1020
+ msg = "its data isn't a valid database"
1021
+ raise InvalidStateError(msg) from error
1022
+ except BaseException:
1023
+ database.close()
1024
+ raise
1025
+ self._replace_database(database)
1026
+
1027
+ def loaded(self) -> None:
1028
+ # every table may have changed; emitted only now, so the views refreshing read the
1029
+ # study's views (e.g. a filter), not the ones it replaces
1030
+ for table in self._tables:
1031
+ table.changed.emit()
1032
+
1033
+ def close(self) -> None:
1034
+ # not the write's turn: closing the app doesn't wait for a job's long write
1035
+ with self._readers_lock:
1036
+ self._closed = True
1037
+ if self._database.readers == 0: # else the last query `reading` it closes it
1038
+ self._database.close()
1039
+
1040
+ def _new_database(self) -> _Database:
1041
+ database = _Database(self.pragmas)
1042
+ database.start_journal()
1043
+ db = database.connection
1044
+ for table in self._tables:
1045
+ db.executescript(table.create)
1046
+ # an int, which a pragma can't take as a parameter
1047
+ db.execute(f"PRAGMA user_version = {int(self.schema_version)}")
1048
+ db.commit()
1049
+ return database
1050
+
1051
+ def _replace_database(self, database: _Database) -> None:
1052
+ with self._lock, self._readers_lock:
1053
+ previous, self._database = self._database, database
1054
+ if previous.readers > 0:
1055
+ return # closed once the queries `reading` it are over
1056
+ previous.close()
1057
+
1058
+ def _check(self, db: sqlite3.Connection) -> None:
1059
+ # not `quick_check`, which skips the indexes' content: a study's database is small
1060
+ (result,) = db.execute("PRAGMA integrity_check").fetchone()
1061
+ if result != "ok":
1062
+ msg = "its data is damaged"
1063
+ raise InvalidStateError(msg)
1064
+
1065
+ def _check_schema(self, db: sqlite3.Connection) -> None:
1066
+ """Refuse a database (brought up to date) not made as a new one is."""
1067
+ new = self._new_database()
1068
+ try:
1069
+ expected = _schema(new.connection)
1070
+ finally:
1071
+ new.close()
1072
+ if _schema(db) != expected:
1073
+ msg = "its data doesn't match this application"
1074
+ raise InvalidStateError(msg)
1075
+
1076
+ def _upgrade(self, db: sqlite3.Connection) -> None:
1077
+ """Bring a database opened from a study up to `schema_version`."""
1078
+ (version,) = db.execute("PRAGMA user_version").fetchone()
1079
+ if version > self.schema_version:
1080
+ msg = "it was saved by a newer version of the application"
1081
+ raise InvalidStateError(msg)
1082
+ if version < 1: # every study's database has a version: this one isn't a study's
1083
+ msg = "its data doesn't match this application"
1084
+ raise InvalidStateError(msg)
1085
+ for step in range(version, self.schema_version):
1086
+ migrate = self.migrations.get(step)
1087
+ if migrate is None:
1088
+ msg = f"its data (version {version}) can't be brought up to date"
1089
+ raise InvalidStateError(msg)
1090
+ migrate(db)
1091
+ db.execute(f"PRAGMA user_version = {int(self.schema_version)}")
1092
+ db.commit()
1093
+
1094
+
1095
+ class _Database:
1096
+ """A connection to a scratch database in a temporary file, deleted with it.
1097
+
1098
+ Its journal is SQLite's default until `start_journal` sets the one of `pragmas`: a
1099
+ backup into a WAL database from one of another page size fails.
1100
+ """
1101
+
1102
+ def __init__(self, pragmas: Mapping[str, int | str]) -> None:
1103
+ statements = [
1104
+ _pragma_statement(name, value)
1105
+ for name, value in pragmas.items()
1106
+ if name != "journal_mode"
1107
+ ]
1108
+ #: the model's `pragmas`, set on each connection: SQLite keeps them per connection
1109
+ self.pragma_statements = statements
1110
+ journal = pragmas.get("journal_mode")
1111
+ self._journal = None if journal is None else _pragma_statement("journal_mode", journal)
1112
+ fd, name = tempfile.mkstemp(prefix="composeui-", suffix=".sqlite")
1113
+ os.close(fd)
1114
+ self.path = Path(name)
1115
+ # threads take turns through the model's lock
1116
+ self.connection = sqlite3.connect(name, check_same_thread=False, timeout=WAIT_SECONDS)
1117
+ self.connection.row_factory = sqlite3.Row
1118
+ for statement in statements:
1119
+ self.connection.execute(statement)
1120
+ #: the read-only connections not in use, for the next queries `reading` it
1121
+ self.idle_readers: list[sqlite3.Connection] = []
1122
+ # also when the model is dropped unclosed (e.g. an expired web session's)
1123
+ self._finalizer = weakref.finalize(
1124
+ self, _discard, self.connection, self.path, self.idle_readers
1125
+ )
1126
+ #: the queries reading it on connections of their own (`SqliteModel.reading`)
1127
+ self.readers = 0
1128
+
1129
+ def open_reader(self) -> sqlite3.Connection:
1130
+ """Open a read-only connection, with the model's `pragmas`: any thread uses it."""
1131
+ uri = f"{self.path.as_uri()}?mode=ro"
1132
+ reader = sqlite3.connect(uri, uri=True, timeout=WAIT_SECONDS, check_same_thread=False)
1133
+ for statement in self.pragma_statements:
1134
+ reader.execute(statement)
1135
+ return reader
1136
+
1137
+ def start_journal(self) -> None:
1138
+ """Set the journal of the model's `pragmas`, which the file keeps."""
1139
+ if self._journal is not None:
1140
+ self.connection.execute(self._journal)
1141
+
1142
+ def close(self) -> None:
1143
+ self._finalizer()
1144
+
1145
+
1146
+ def _schema(db: sqlite3.Connection) -> set[tuple[object, ...]]:
1147
+ """Get what a database is made of: each table's columns, each other object's SQL.
1148
+
1149
+ A table's columns, not its SQL: a migration can only add a column (`ALTER TABLE ...
1150
+ ADD COLUMN`), without the `CHECK` or `REFERENCES` a new database's table may have.
1151
+ Neither can query the database, so the file's own are kept.
1152
+ """
1153
+ schema: set[tuple[object, ...]] = set()
1154
+ for kind, name, table_name, sql in db.execute(
1155
+ "SELECT type, name, tbl_name, sql FROM sqlite_master"
1156
+ ):
1157
+ if kind == "table":
1158
+ # by name, not position: a column added by a migration comes last
1159
+ rows = db.execute("SELECT * FROM pragma_table_xinfo(?)", (name,))
1160
+ schema.add((kind, name, frozenset(tuple(row)[1:] for row in rows)))
1161
+ else:
1162
+ schema.add((kind, name, table_name, " ".join((sql or "").split())))
1163
+ return schema
1164
+
1165
+
1166
+ def _pragma_statement(name: str, value: int | str) -> str:
1167
+ """Get the statement setting a pragma, which can't take its value as a parameter."""
1168
+ if re.fullmatch(r"[A-Za-z_]+", name) is None or (
1169
+ isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]+", value) is None
1170
+ ):
1171
+ msg = f"Not a pragma setting: {name} = {value!r}"
1172
+ raise ValueError(msg)
1173
+ return f"PRAGMA {name} = {value}"
1174
+
1175
+
1176
+ def _discard(connection: sqlite3.Connection, path: Path, readers: list[sqlite3.Connection]) -> None:
1177
+ for reader in readers: # first: the last connection closed deletes the WAL journal
1178
+ reader.close()
1179
+ readers.clear()
1180
+ connection.close()
1181
+ # the WAL journal's files too, which the last connection closed deletes, unless it crashed
1182
+ for name in (path.name, f"{path.name}-wal", f"{path.name}-shm"):
1183
+ path.with_name(name).unlink(missing_ok=True)