lorebase-scribe 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,298 @@
1
+ """Readers of Sources whose rows are records: JSON documents, JSON Lines and Parquet files.
2
+
3
+ The connection's value is a local path (or a `file:` URL) under the `allowed_roots` of the
4
+ connector's settings section, opened through the guards of `access` (no symbolic link
5
+ followed after the roots check, regular files only).
6
+
7
+ - `json`: the document is parsed whole (at most `scribe.sources.json_files.max_bytes`); its
8
+ records are the array at `records_path` (dotted keys, `data.items`), or the document itself
9
+ when it is an array. Locators are `file.json:<record number>`.
10
+ - `jsonl`: one object per line, streamed; lines are at most `max_line_bytes`, blank lines are
11
+ skipped. Locators are `file.jsonl:<line number>`.
12
+ - `parquet`: needs pyarrow (`lorebase-scribe[parquet]`); the file is streamed in batches of
13
+ `scribe.sources.parquet.batch_rows` rows, never read whole. Locators are
14
+ `file.parquet:<row number>`.
15
+
16
+ Values keep their JSON (or Arrow) types: nested objects and arrays are JSON values, which a
17
+ `json` Attribute stores as they are and textual Attributes receive as canonical JSON text. With
18
+ `flatten_separator`, nested objects become columns named `parent<separator>child` instead
19
+ (arrays stay values). Records have no fixed columns: a key missing from a record is null
20
+ there (Parquet columns come from the file schema). Incremental reads compare the watermark
21
+ column with the previous watermark natively, or as text when the types differ.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import os
29
+ from collections.abc import AsyncIterator, Iterator, Mapping
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from types import ModuleType
33
+ from typing import Any
34
+
35
+ from lorebase_core.model import Source
36
+ from lorebase_scribe.settings import JsonSourceSettings, ParquetSourceSettings
37
+ from lorebase_scribe.sources.access import (
38
+ READ_CHUNK,
39
+ SourceReadError,
40
+ local_path,
41
+ open_local,
42
+ read_bounded,
43
+ resolve_location,
44
+ )
45
+ from lorebase_scribe.sources.mapping import Row, SourceDefinitionError, text_of
46
+
47
+ JSON_SECTION = "scribe.sources.json_files"
48
+ PARQUET_SECTION = "scribe.sources.parquet"
49
+ PARQUET_EXTRA = "lorebase-scribe[parquet]"
50
+
51
+
52
+ # Record helpers -------------------------------------------------------------------------------
53
+
54
+
55
+ def value_at(document: Any, path: str) -> Any:
56
+ """The value at a dotted path of nested objects; `KeyError` when a step is missing."""
57
+ node = document
58
+ for part in path.split("."):
59
+ if not isinstance(node, dict) or part not in node:
60
+ raise KeyError(path)
61
+ node = node[part]
62
+ return node
63
+
64
+
65
+ def records_at(document: Any, path: str | None, where: str) -> list[Mapping[str, Any]]:
66
+ """The records of a parsed document: the array at `path`, or the document itself."""
67
+ node = document
68
+ if path is not None:
69
+ try:
70
+ node = value_at(document, path)
71
+ except KeyError:
72
+ raise SourceDefinitionError(f"{where}: no {path!r} in the document") from None
73
+ if node is None:
74
+ return []
75
+ if not isinstance(node, list):
76
+ what = "the document" if path is None else repr(path)
77
+ hint = "" if path is not None else " (set records_path)"
78
+ raise SourceDefinitionError(f"{where}: {what} is not an array of records{hint}")
79
+ for index, record in enumerate(node, start=1):
80
+ if not isinstance(record, dict):
81
+ raise SourceDefinitionError(f"{where}: record {index} is not an object")
82
+ return node
83
+
84
+
85
+ def flatten(record: Mapping[str, Any], separator: str | None) -> dict[str, Any]:
86
+ """The record with nested objects as `parent<separator>child` columns; unchanged (a copy)
87
+ without a separator. Empty objects and arrays stay values."""
88
+ if separator is None:
89
+ return dict(record)
90
+ flat: dict[str, Any] = {}
91
+
92
+ def walk(prefix: str, node: Mapping[str, Any]) -> None:
93
+ for key, value in node.items():
94
+ name = f"{prefix}{separator}{key}" if prefix else str(key)
95
+ if isinstance(value, dict) and value:
96
+ walk(name, value)
97
+ else:
98
+ flat[name] = value
99
+
100
+ walk("", record)
101
+ return flat
102
+
103
+
104
+ def after(value: Any, since: Any) -> bool:
105
+ """Whether a watermark value is greater than `since` (natively, else as text)."""
106
+ if value is None or value == "":
107
+ return False
108
+ try:
109
+ return bool(value > since)
110
+ except TypeError:
111
+ return text_of(value) > text_of(since)
112
+
113
+
114
+ def keep(row: Row, source: Source, since: Any) -> bool:
115
+ """Whether an incremental read keeps the row (always, without a previous watermark)."""
116
+ column = source.watermark_column
117
+ return since is None or column is None or after(row.get(column), since)
118
+
119
+
120
+ # JSON and JSON Lines --------------------------------------------------------------------------
121
+
122
+
123
+ @dataclass
124
+ class JsonConnector:
125
+ """A JSON document holding an array of records."""
126
+
127
+ source: Source
128
+ settings: JsonSourceSettings
129
+
130
+ @property
131
+ def columns(self) -> tuple[str, ...]:
132
+ return ()
133
+
134
+ def _read(self, path: Path) -> bytes:
135
+ descriptor = open_local(path, self.settings.allowed_roots, JSON_SECTION)
136
+ try:
137
+ return read_bounded(
138
+ descriptor, path.name, self.settings.max_bytes, f"{JSON_SECTION}.max_bytes"
139
+ )
140
+ except OSError as error:
141
+ raise SourceReadError(f"{path.name}: {error.strerror}") from None
142
+ finally:
143
+ os.close(descriptor)
144
+
145
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
146
+ path = local_path(resolve_location(self.source), "JSON")
147
+ data = await asyncio.to_thread(self._read, path)
148
+ try:
149
+ document = json.loads(data)
150
+ except ValueError as error:
151
+ raise SourceDefinitionError(f"{path.name}: not valid JSON ({error})") from None
152
+ separator = self.source.flatten_separator
153
+ records = records_at(document, self.source.records_path, path.name)
154
+ for number, record in enumerate(records, start=1):
155
+ row = flatten(record, separator)
156
+ if keep(row, self.source, since):
157
+ yield f"{path.name}:{number}", row
158
+
159
+
160
+ @dataclass
161
+ class JsonLinesConnector:
162
+ """A JSON Lines file: one object per line, streamed."""
163
+
164
+ source: Source
165
+ settings: JsonSourceSettings
166
+
167
+ @property
168
+ def columns(self) -> tuple[str, ...]:
169
+ return ()
170
+
171
+ async def _lines(self, descriptor: int, name: str) -> AsyncIterator[tuple[int, bytes]]:
172
+ """Numbered lines, read `READ_CHUNK` bytes at a time; none longer than the limit."""
173
+ limit = self.settings.max_line_bytes
174
+ buffer = b""
175
+ number = 0
176
+ while True:
177
+ try:
178
+ chunk = await asyncio.to_thread(os.read, descriptor, READ_CHUNK)
179
+ except OSError as error:
180
+ raise SourceReadError(f"{name}: {error.strerror}") from None
181
+ if not chunk:
182
+ if buffer: # a last line without a newline
183
+ yield number + 1, buffer
184
+ return
185
+ *complete, buffer = (buffer + chunk).split(b"\n")
186
+ for line in complete:
187
+ number += 1
188
+ if len(line) > limit:
189
+ raise self._too_long(name, number)
190
+ yield number, line
191
+ if len(buffer) > limit:
192
+ raise self._too_long(name, number + 1)
193
+
194
+ def _too_long(self, name: str, number: int) -> SourceDefinitionError:
195
+ return SourceDefinitionError(
196
+ f"{name}: line {number} is longer than the {self.settings.max_line_bytes} bytes "
197
+ f"allowed by {JSON_SECTION}.max_line_bytes"
198
+ )
199
+
200
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
201
+ path = local_path(resolve_location(self.source), "JSON Lines")
202
+ descriptor = await asyncio.to_thread(
203
+ open_local, path, self.settings.allowed_roots, JSON_SECTION
204
+ )
205
+ separator = self.source.flatten_separator
206
+ try:
207
+ async for number, line in self._lines(descriptor, path.name):
208
+ if number == 1:
209
+ line = line.removeprefix(b"\xef\xbb\xbf")
210
+ if not line.strip():
211
+ continue
212
+ try:
213
+ record = json.loads(line)
214
+ except ValueError:
215
+ raise SourceDefinitionError(
216
+ f"{path.name}: line {number} is not valid JSON"
217
+ ) from None
218
+ if not isinstance(record, dict):
219
+ raise SourceDefinitionError(f"{path.name}: line {number} is not an object")
220
+ row = flatten(record, separator)
221
+ if keep(row, self.source, since):
222
+ yield f"{path.name}:{number}", row
223
+ finally:
224
+ os.close(descriptor)
225
+
226
+
227
+ # Parquet --------------------------------------------------------------------------------------
228
+
229
+
230
+ def _pyarrow() -> tuple[ModuleType, ModuleType]:
231
+ """`pyarrow` and `pyarrow.parquet`, or a definition error naming the extra."""
232
+ try:
233
+ import pyarrow # type: ignore[import-untyped]
234
+ import pyarrow.parquet as parquet # type: ignore[import-untyped]
235
+ except ImportError:
236
+ raise SourceDefinitionError(
237
+ f"parquet Sources need pyarrow: install {PARQUET_EXTRA}"
238
+ ) from None
239
+ return pyarrow, parquet
240
+
241
+
242
+ def _column_names(pyarrow: ModuleType, schema: Any, separator: str | None) -> tuple[str, ...]:
243
+ """Column names of an Arrow schema; struct fields flattened as rows are."""
244
+
245
+ def names(field: Any, prefix: str) -> Iterator[str]:
246
+ name = f"{prefix}{separator}{field.name}" if prefix else str(field.name)
247
+ if separator is not None and pyarrow.types.is_struct(field.type) and field.type.num_fields:
248
+ for index in range(field.type.num_fields):
249
+ yield from names(field.type.field(index), name)
250
+ else:
251
+ yield name
252
+
253
+ return tuple(name for field in schema for name in names(field, ""))
254
+
255
+
256
+ @dataclass
257
+ class ParquetConnector:
258
+ """A Parquet file, read batch by batch."""
259
+
260
+ source: Source
261
+ settings: ParquetSourceSettings
262
+ _columns: tuple[str, ...] = ()
263
+
264
+ @property
265
+ def columns(self) -> tuple[str, ...]:
266
+ return self._columns
267
+
268
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
269
+ pyarrow, parquet = _pyarrow()
270
+ path = local_path(resolve_location(self.source), "Parquet")
271
+ descriptor = await asyncio.to_thread(
272
+ open_local, path, self.settings.allowed_roots, PARQUET_SECTION
273
+ )
274
+ separator = self.source.flatten_separator
275
+ with os.fdopen(descriptor, "rb") as handle:
276
+ try:
277
+ file = parquet.ParquetFile(handle)
278
+ self._columns = _column_names(pyarrow, file.schema_arrow, separator)
279
+ batches = file.iter_batches(batch_size=self.settings.batch_rows)
280
+ except ValueError as error: # pyarrow.ArrowInvalid: not a Parquet file
281
+ raise SourceDefinitionError(f"{path.name}: not a Parquet file ({error})") from None
282
+ except OSError as error:
283
+ raise SourceReadError(f"{path.name}: {error}") from None
284
+ number = 0
285
+ while True:
286
+ try:
287
+ batch = await asyncio.to_thread(next, batches, None)
288
+ except ValueError as error:
289
+ raise SourceDefinitionError(f"{path.name}: {error}") from None
290
+ except OSError as error:
291
+ raise SourceReadError(f"{path.name}: {error}") from None
292
+ if batch is None:
293
+ return
294
+ for record in batch.to_pylist():
295
+ number += 1
296
+ row = flatten(record, separator)
297
+ if keep(row, self.source, since):
298
+ yield f"{path.name}:{number}", row
@@ -0,0 +1,332 @@
1
+ """MySQL, MariaDB and SQL Server Sources: the `table` or `query` of the Source, filtered by
2
+ `scope_filter`, read like a Postgres Source (see `connectors`) and fetched `fetch_rows` at a
3
+ time. Locators are `table:<row number>` (the table name), or `query:<row number>`.
4
+
5
+ - `mysql_table`, `mysql_query`: MySQL 5.7+ and MariaDB 10.x through asyncmy (Apache-2.0, async,
6
+ prebuilt wheels), installed with `lorebase-scribe[mysql]`. The session is read-only and
7
+ bounded by `max_execution_time` (`max_statement_time` on MariaDB). The connection value is
8
+ `mysql://user:password@host:3306/database` (`mariadb://` works too).
9
+ - `mssql_table`, `mssql_query`: SQL Server 2017+ and Azure SQL through pymssql (LGPL-2.1,
10
+ FreeTDS bundled in the wheels, so no ODBC driver is needed), installed with
11
+ `lorebase-scribe[mssql]`. pymssql is blocking: every driver call runs in a worker thread.
12
+ SQL Server has no read-only transaction, so the login must only be able to read. The
13
+ connection value is `mssql://user:password@host:1433/database`.
14
+
15
+ A `query` is wrapped as `SELECT * FROM (<query>) AS lorebase_source` and a trailing `;` is
16
+ removed. SQL Server refuses an `ORDER BY` in a derived table unless it has `TOP` or `OFFSET`, so
17
+ a query of that kind whose top level ends in `ORDER BY` without either gets `OFFSET 0 ROWS`
18
+ appended (valid from SQL Server 2012, and it does not limit the rows).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import importlib
25
+ import math
26
+ import re
27
+ from collections.abc import AsyncIterator, Callable, Iterator
28
+ from contextlib import contextmanager
29
+ from datetime import datetime
30
+ from typing import Any, ClassVar
31
+
32
+ from sqlalchemy import URL, create_engine, make_url, text
33
+ from sqlalchemy.exc import ArgumentError, DBAPIError
34
+ from sqlalchemy.ext.asyncio import create_async_engine
35
+ from sqlalchemy.pool import NullPool
36
+
37
+ from lorebase_core.model import Source
38
+ from lorebase_core.model.source import SourceType
39
+ from lorebase_scribe.sources.access import SourceReadError, resolve_location
40
+ from lorebase_scribe.sources.mapping import Row, SourceDefinitionError
41
+
42
+ __all__ = ["MssqlConnector", "MysqlConnector", "quote_identifier"]
43
+
44
+ APPLICATION_NAME = "lorebase-scribe"
45
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
46
+ _MAX_ERROR_CHARS = 300
47
+
48
+
49
+ def quote_identifier(name: str, opening: str = '"', closing: str = '"') -> str:
50
+ """A dotted SQL identifier, each part checked and quoted."""
51
+ parts = name.split(".")
52
+ if not parts or any(_IDENTIFIER.fullmatch(part) is None for part in parts):
53
+ raise SourceDefinitionError(f"not a plain SQL identifier: {name!r}")
54
+ return ".".join(f"{opening}{part}{closing}" for part in parts)
55
+
56
+
57
+ _WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_$#@]*")
58
+ _OPAQUE = re.compile(
59
+ r"'(?:[^']|'')*'|\[(?:[^\]]|\]\])*\]|\"(?:[^\"]|\"\")*\"|--[^\n]*|/\*.*?\*/", re.S
60
+ )
61
+
62
+
63
+ def top_level_words(query: str) -> list[tuple[str, int]]:
64
+ """Upper-cased keywords of `query` with their offsets, outside parentheses, string
65
+ literals, quoted identifiers and comments; a name after a dot (`t.top`) is not one."""
66
+ words: list[tuple[str, int]] = []
67
+ depth = 0
68
+ index = 0
69
+ while index < len(query):
70
+ quoted = _OPAQUE.match(query, index)
71
+ if quoted is not None:
72
+ index = quoted.end()
73
+ continue
74
+ char = query[index]
75
+ if char == "(":
76
+ depth += 1
77
+ elif char == ")":
78
+ depth = max(0, depth - 1)
79
+ elif depth == 0 and (word := _WORD.match(query, index)) is not None:
80
+ if index == 0 or query[index - 1] != ".":
81
+ words.append((word.group().upper(), index))
82
+ index = word.end()
83
+ continue
84
+ index += 1
85
+ return words
86
+
87
+
88
+ _SET_OPERATORS = frozenset({"UNION", "INTERSECT", "EXCEPT"})
89
+
90
+
91
+ def _has_top(names: list[str]) -> bool:
92
+ """Whether the (single) SELECT of the query has a `TOP` clause: the word right after
93
+ `SELECT` and `DISTINCT` or `ALL`, so an alias named top does not count."""
94
+ if "SELECT" not in names:
95
+ return False
96
+ position = names.index("SELECT") + 1
97
+ while position < len(names) and names[position] in ("DISTINCT", "ALL"):
98
+ position += 1
99
+ return position < len(names) and names[position] == "TOP"
100
+
101
+
102
+ def mssql_orderable(query: str) -> str:
103
+ """`query` made valid as a derived table: a top-level trailing `ORDER BY` gets
104
+ `OFFSET 0 ROWS`, which keeps the order and all the rows. Left alone: a query without that
105
+ clause, one with `TOP` (without a set operator, whose `ORDER BY` covers all its SELECTs),
106
+ and one with `OFFSET`, `FETCH` or `FOR` after the `ORDER BY`. A trailing `OPTION (...)`
107
+ stays last."""
108
+ words = top_level_words(query)
109
+ names = [name for name, _ in words]
110
+ last = next(
111
+ (i for i in range(len(names) - 1, 0, -1) if names[i - 1 : i + 1] == ["ORDER", "BY"]), None
112
+ )
113
+ if last is None:
114
+ return query
115
+ if not _SET_OPERATORS.intersection(names) and _has_top(names):
116
+ return query
117
+ after = names[last + 1 :]
118
+ if {"OFFSET", "FETCH", "FOR"}.intersection(after):
119
+ return query
120
+ if "OPTION" in after:
121
+ at = words[last + 1 + after.index("OPTION")][1]
122
+ return f"{query[:at].rstrip()}\nOFFSET 0 ROWS {query[at:]}"
123
+ return f"{query}\nOFFSET 0 ROWS"
124
+
125
+
126
+ def _require_driver(module: str, extra: str, label: str) -> None:
127
+ try:
128
+ importlib.import_module(module)
129
+ except ImportError:
130
+ raise SourceDefinitionError(
131
+ f"{label} Sources need {module}: install lorebase-scribe[{extra}]"
132
+ ) from None
133
+
134
+
135
+ def _driver_url(location: str, schemes: frozenset[str], driver: str, hint: str) -> URL:
136
+ """The URL of `location` on `driver`; refuses other schemes without echoing the value."""
137
+ try:
138
+ parsed = make_url(location)
139
+ except ArgumentError:
140
+ raise SourceDefinitionError("connection: not a valid database URL") from None
141
+ if parsed.drivername not in schemes:
142
+ raise SourceDefinitionError(
143
+ f"connection: unsupported database driver {parsed.drivername!r}; use {hint}://"
144
+ )
145
+ return parsed.set(drivername=driver)
146
+
147
+
148
+ @contextmanager
149
+ def _read_errors(name: str) -> Iterator[None]:
150
+ """Driver and network failures as `SourceReadError`, naming no connection value."""
151
+ try:
152
+ yield
153
+ except DBAPIError as error:
154
+ detail = " ".join(str(error.orig).split())[:_MAX_ERROR_CHARS]
155
+ raise SourceReadError(f"{name}: {type(error.orig).__name__}: {detail}") from None
156
+ except (OSError, TimeoutError) as error:
157
+ raise SourceReadError(f"{name}: {type(error).__name__}: {error}") from None
158
+
159
+
160
+ class _SqlConnector:
161
+ """Statement building shared by the SQL connectors; subclasses read."""
162
+
163
+ #: `mysql` or `mssql`: the URL scheme to use, the extra to install, the settings name.
164
+ label: ClassVar[str]
165
+ query_type: ClassVar[SourceType]
166
+ opening: ClassVar[str]
167
+ closing: ClassVar[str]
168
+ schemes: ClassVar[frozenset[str]]
169
+ driver: ClassVar[str]
170
+ #: Replaced by tests to read through a fake engine.
171
+ engine_factory: ClassVar[Callable[..., Any]]
172
+
173
+ def __init__(self, source: Source, settings: Any) -> None:
174
+ self.source = source
175
+ self.settings = settings
176
+ self._columns: tuple[str, ...] = ()
177
+
178
+ @property
179
+ def columns(self) -> tuple[str, ...]:
180
+ return self._columns
181
+
182
+ def _quote(self, name: str) -> str:
183
+ return quote_identifier(name, self.opening, self.closing)
184
+
185
+ def _derived(self, query: str) -> str:
186
+ """The query as the body of a derived table (unchanged unless a dialect needs more)."""
187
+ return query
188
+
189
+ def statement(self, since: Any) -> tuple[str, dict[str, Any], str]:
190
+ """SQL text, parameters and the relation name used in row locators."""
191
+ source = self.source
192
+ if source.type is self.query_type:
193
+ query = (source.query or "").strip().rstrip(";").rstrip()
194
+ if not query:
195
+ raise SourceDefinitionError(f"{self.query_type.value} Sources need a query")
196
+ body = f"SELECT * FROM ({self._derived(query)}) AS lorebase_source"
197
+ name = "query"
198
+ else:
199
+ name = source.table or source.api_name.rsplit(".", 1)[-1]
200
+ body = f"SELECT * FROM {self._quote(name)} AS lorebase_source"
201
+ conditions: list[str] = []
202
+ parameters: dict[str, Any] = {}
203
+ if source.scope_filter:
204
+ conditions.append(f"({source.scope_filter})")
205
+ order = ""
206
+ if source.watermark_column is not None:
207
+ column = self._quote(source.watermark_column)
208
+ if since is not None:
209
+ conditions.append(f"{column} > :since")
210
+ parameters["since"] = since
211
+ order = f" ORDER BY {column}"
212
+ where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
213
+ return body + where + order, parameters, name
214
+
215
+ def _url(self) -> URL:
216
+ _require_driver(self.driver, self.label, self.label)
217
+ return _driver_url(
218
+ resolve_location(self.source),
219
+ self.schemes,
220
+ f"{self.label}+{self.driver}",
221
+ self.label,
222
+ )
223
+
224
+
225
+ class MysqlConnector(_SqlConnector):
226
+ """A MySQL or MariaDB table or query, streamed through a server-side cursor in a
227
+ read-only session."""
228
+
229
+ label = "mysql"
230
+ query_type = SourceType.MYSQL_QUERY
231
+ opening = closing = "`"
232
+ driver = "asyncmy"
233
+ schemes = frozenset({"mysql", "mariadb", "mysql+asyncmy", "mariadb+asyncmy"})
234
+ engine_factory = staticmethod(create_async_engine)
235
+
236
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
237
+ sql, parameters, name = self.statement(since)
238
+ url = self._url()
239
+ settings = self.settings
240
+ connect_args: dict[str, Any] = {
241
+ "connect_timeout": settings.connect_timeout_s,
242
+ "program_name": APPLICATION_NAME,
243
+ }
244
+ if "charset" not in url.query:
245
+ connect_args["charset"] = "utf8mb4"
246
+ engine = self.engine_factory(url, poolclass=NullPool, connect_args=connect_args)
247
+ try:
248
+ with _read_errors(name):
249
+ async with engine.connect() as connection:
250
+ # Session-wide, so that it holds for the transaction below; the implicit
251
+ # transaction the first statement opens is ended before the read.
252
+ await connection.execute(text("SET SESSION TRANSACTION READ ONLY"))
253
+ timeout = int(settings.statement_timeout_ms)
254
+ if getattr(connection.dialect, "is_mariadb", False):
255
+ seconds = math.ceil(timeout / 1000)
256
+ limit = f"SET SESSION max_statement_time = {seconds}"
257
+ else:
258
+ limit = f"SET SESSION max_execution_time = {timeout}"
259
+ await connection.execute(text(limit))
260
+ await connection.rollback()
261
+ async with connection.begin():
262
+ result = await connection.stream(text(sql), parameters)
263
+ self._columns = tuple(result.keys())
264
+ number = 0
265
+ async for partition in result.mappings().partitions(settings.fetch_rows):
266
+ for row in partition:
267
+ number += 1
268
+ yield f"{name}:{number}", dict(row)
269
+ finally:
270
+ await engine.dispose()
271
+
272
+
273
+ class MssqlConnector(_SqlConnector):
274
+ """A SQL Server table or query; the blocking driver runs in worker threads."""
275
+
276
+ label = "mssql"
277
+ query_type = SourceType.MSSQL_QUERY
278
+ opening, closing = "[", "]"
279
+ driver = "pymssql"
280
+ schemes = frozenset({"mssql", "sqlserver", "mssql+pymssql"})
281
+ engine_factory = staticmethod(lambda url, **options: create_engine(url, **options))
282
+
283
+ def _derived(self, query: str) -> str:
284
+ return mssql_orderable(query)
285
+
286
+ def statement(self, since: Any) -> tuple[str, dict[str, Any], str]:
287
+ sql, parameters, name = super().statement(since)
288
+ if isinstance(parameters.get("since"), datetime):
289
+ # pymssql binds a datetime with millisecond precision; SQL Server converts the
290
+ # text to the type of the watermark column, microseconds included.
291
+ parameters["since"] = parameters["since"].isoformat(sep=" ")
292
+ return sql, parameters, name
293
+
294
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
295
+ sql, parameters, name = self.statement(since)
296
+ url = self._url()
297
+ settings = self.settings
298
+ timeout_ms = int(settings.statement_timeout_ms)
299
+ engine = self.engine_factory(
300
+ url,
301
+ poolclass=NullPool,
302
+ connect_args={
303
+ "login_timeout": math.ceil(settings.connect_timeout_s),
304
+ "timeout": math.ceil(timeout_ms / 1000),
305
+ "appname": APPLICATION_NAME,
306
+ # datetime2 keeps its microseconds: a watermark must not lose precision.
307
+ "use_datetime2": True,
308
+ },
309
+ )
310
+ connection: Any = None
311
+ try:
312
+ with _read_errors(name):
313
+ connection = await asyncio.to_thread(engine.connect)
314
+ await asyncio.to_thread(connection.execute, text(f"SET LOCK_TIMEOUT {timeout_ms}"))
315
+ result = await asyncio.to_thread(connection.execute, text(sql), parameters)
316
+ self._columns = tuple(result.keys())
317
+ rows = result.mappings()
318
+ number = 0
319
+ while batch := await asyncio.to_thread(rows.fetchmany, settings.fetch_rows):
320
+ for row in batch:
321
+ number += 1
322
+ yield f"{name}:{number}", dict(row)
323
+ finally:
324
+ await asyncio.shield(asyncio.to_thread(self._close, engine, connection))
325
+
326
+ @staticmethod
327
+ def _close(engine: Any, connection: Any) -> None:
328
+ try:
329
+ if connection is not None:
330
+ connection.close()
331
+ finally:
332
+ engine.dispose()