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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- 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()
|