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,241 @@
1
+ """Guarded access to Source locations: local files under allowed roots, pinned HTTP requests.
2
+
3
+ - Local files: the path is resolved (symbolic links included) and must lie under a resolved
4
+ root; the file is then opened from the root one component at a time without following
5
+ symbolic links, so a link swapped in after the check is refused instead of followed, and it
6
+ must be a regular file.
7
+ - HTTP: the URL needs an allowed scheme and host; the host must resolve to global unicast
8
+ addresses only (unless private targets are allowed), checked as for webhooks, and requests
9
+ connect to those checked addresses, never to a new lookup (one lookup per host and client).
10
+ Redirects are never followed and response bodies are read in bounded steps.
11
+
12
+ Messages name the settings section (``scribe.sources.csv``...) and never the resolved value of
13
+ a connection, the host it resolves to or a header value.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import errno
19
+ import os
20
+ import stat
21
+ from collections.abc import Mapping, Sequence
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path, PurePath
24
+ from typing import Any
25
+ from urllib.parse import urlsplit
26
+
27
+ import httpx
28
+
29
+ from lorebase_core.model import Source
30
+ from lorebase_platform.secrets import SecretResolutionError, resolve_secret
31
+ from lorebase_scribe.sources.mapping import SourceDefinitionError
32
+ from lorebase_scribe.webhooks import pin
33
+ from lorebase_store.webhooks import WebhookSettings
34
+ from lorebase_store.webhooks.delivery import Resolver, check_target
35
+
36
+ READ_CHUNK = 1024 * 1024
37
+ """Bytes read from a local file or a response per step; totals are bounded by settings."""
38
+
39
+
40
+ class SourceReadError(RuntimeError):
41
+ """The Source could not be read (network, file system or database); a retry may work."""
42
+
43
+
44
+ def resolve_location(source: Source) -> str:
45
+ """The value behind the Source's connection reference."""
46
+ try:
47
+ return resolve_secret(source.connection)
48
+ except SecretResolutionError as error:
49
+ raise SourceDefinitionError(f"connection: {error}") from None
50
+
51
+
52
+ # Local files ----------------------------------------------------------------------------------
53
+
54
+
55
+ def local_path(location: str, kind: str) -> Path:
56
+ """The path of a local location: a plain path or a ``file:`` URL."""
57
+ parts = urlsplit(location)
58
+ if parts.scheme == "file":
59
+ return Path(parts.path)
60
+ if parts.scheme:
61
+ raise SourceDefinitionError(f"connection: unsupported {kind} location scheme")
62
+ return Path(location)
63
+
64
+
65
+ def _open_beneath(root: Path, relative: PurePath) -> int:
66
+ """A descriptor of `root / relative`, opened without following any symbolic link.
67
+
68
+ Each directory below `root` is opened relative to its parent with `O_NOFOLLOW`, and so is
69
+ the file itself (non-blocking, so a FIFO cannot hold the worker): a component replaced by
70
+ a symbolic link after the path was checked fails with ELOOP or ENOTDIR.
71
+ """
72
+ directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
73
+ try:
74
+ for part in relative.parts[:-1]:
75
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
76
+ os.close(directory)
77
+ directory = child
78
+ return os.open(
79
+ relative.parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory
80
+ )
81
+ finally:
82
+ os.close(directory)
83
+
84
+
85
+ def _beneath_root(path: Path, roots: Sequence[str], section: str) -> tuple[Path, PurePath]:
86
+ """The allowed root `path` resolves under, and the resolved path relative to it.
87
+
88
+ Symbolic links are resolved here, so a link inside a root that points inside a root keeps
89
+ working; the file is then opened along the resolved path, which has no links, without
90
+ following any (`_open_beneath`)."""
91
+ try:
92
+ resolved = path.resolve()
93
+ resolved_roots = [Path(root).resolve() for root in roots]
94
+ except (OSError, RuntimeError):
95
+ raise SourceReadError(f"{path.name}: the path cannot be resolved") from None
96
+ for root in resolved_roots:
97
+ if resolved != root and resolved.is_relative_to(root):
98
+ return root, resolved.relative_to(root)
99
+ raise SourceDefinitionError(f"connection: the path is outside {section}.allowed_roots")
100
+
101
+
102
+ def open_local(path: Path, roots: Sequence[str], section: str) -> int:
103
+ """A read-only descriptor of the regular file `path` under one of `roots`."""
104
+ root, relative = _beneath_root(path, roots, section)
105
+ try:
106
+ descriptor = _open_beneath(root, relative)
107
+ except OSError as error:
108
+ if error.errno in (errno.ELOOP, errno.ENOTDIR):
109
+ raise SourceDefinitionError(
110
+ "connection: the path meets a symbolic link or a non-directory when opened "
111
+ f"under {section}.allowed_roots"
112
+ ) from None
113
+ raise SourceReadError(f"{path.name}: {error.strerror}") from None
114
+ try:
115
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
116
+ raise SourceDefinitionError(f"{path.name}: not a regular file")
117
+ except BaseException:
118
+ os.close(descriptor)
119
+ raise
120
+ return descriptor
121
+
122
+
123
+ def read_bounded(descriptor: int, name: str, limit: int, setting: str) -> bytes:
124
+ """At most `limit` bytes of the file behind `descriptor` (`setting` names the limit)."""
125
+ size = os.fstat(descriptor).st_size
126
+ if size > limit:
127
+ raise SourceDefinitionError(
128
+ f"{name}: {size} bytes, more than the {limit} allowed by {setting}"
129
+ )
130
+ chunks: list[bytes] = []
131
+ size = 0
132
+ while chunk := os.read(descriptor, min(READ_CHUNK, limit + 1 - size)):
133
+ size += len(chunk)
134
+ if size > limit: # the file grew after fstat
135
+ raise SourceDefinitionError(f"{name}: more than the {limit} bytes allowed by {setting}")
136
+ chunks.append(chunk)
137
+ return b"".join(chunks)
138
+
139
+
140
+ # HTTP -----------------------------------------------------------------------------------------
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class HttpRules:
145
+ """What a section of settings allows of URLs and responses."""
146
+
147
+ section: str
148
+ allowed_schemes: tuple[str, ...]
149
+ allowed_hosts: tuple[str, ...]
150
+ allow_private_targets: bool
151
+ max_bytes: int
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Fetched:
156
+ """A response: its status, headers and, for a 200, its body."""
157
+
158
+ status: int
159
+ headers: httpx.Headers
160
+ body: bytes = b""
161
+
162
+
163
+ @dataclass
164
+ class PinnedClient:
165
+ """GET requests to checked URLs over an `httpx.AsyncClient` built with `trust_env=False`
166
+ and `follow_redirects=False` (owned by the caller)."""
167
+
168
+ client: httpx.AsyncClient
169
+ rules: HttpRules
170
+ resolve: Resolver
171
+ _addresses: dict[str, tuple[str, ...]] = field(default_factory=dict)
172
+
173
+ def check(self, url: httpx.URL, what: str) -> None:
174
+ """Refuse a URL whose scheme or host is not allowed (`what` starts the message)."""
175
+ if url.scheme not in self.rules.allowed_schemes:
176
+ raise SourceDefinitionError(
177
+ f"{what}: the URL scheme is not in {self.rules.section}.allowed_schemes"
178
+ )
179
+ if url.host not in self.rules.allowed_hosts:
180
+ raise SourceDefinitionError(
181
+ f"{what}: the URL host is not in {self.rules.section}.allowed_hosts"
182
+ )
183
+
184
+ async def addresses(self, url: httpx.URL, what: str) -> tuple[str, ...]:
185
+ """The checked addresses of the URL host, looked up once per client.
186
+
187
+ Empty when private targets are allowed: the client then resolves the host itself."""
188
+ if url.host in self._addresses:
189
+ return self._addresses[url.host]
190
+ checks = WebhookSettings(
191
+ allowed_schemes=self.rules.allowed_schemes,
192
+ allow_private_targets=self.rules.allow_private_targets,
193
+ )
194
+ target = await check_target(str(url), checks, self.resolve)
195
+ if target.problem is not None:
196
+ if target.problem.startswith("cannot resolve"):
197
+ raise SourceReadError(f"{what}: the URL host cannot be resolved")
198
+ # `check_target` problems never contain the host name or the URL.
199
+ raise SourceDefinitionError(
200
+ f"{what}: the URL host {target.problem}, refused unless "
201
+ f"{self.rules.section}.allow_private_targets"
202
+ )
203
+ self._addresses[url.host] = target.addresses
204
+ return target.addresses
205
+
206
+ async def get(self, url: httpx.URL, headers: Mapping[str, str], what: str) -> Fetched:
207
+ """GET `url` from its checked addresses; the body is read only for a 200."""
208
+ self.check(url, what)
209
+ addresses = await self.addresses(url, what)
210
+ if not addresses:
211
+ return await self._get(url, dict(headers), {})
212
+ failure: httpx.TransportError | None = None
213
+ for address in addresses:
214
+ pinned, host, extensions = pin(url, address)
215
+ try:
216
+ return await self._get(pinned, {**headers, **host}, extensions)
217
+ except (httpx.ConnectError, httpx.ConnectTimeout) as error:
218
+ failure = error # the next checked address, never a new lookup
219
+ assert failure is not None
220
+ raise failure
221
+
222
+ async def _get(
223
+ self, target: httpx.URL, headers: dict[str, str], extensions: dict[str, Any]
224
+ ) -> Fetched:
225
+ limit = self.rules.max_bytes
226
+ chunks: list[bytes] = []
227
+ size = 0
228
+ async with self.client.stream(
229
+ "GET", target, headers=headers, extensions=extensions
230
+ ) as response:
231
+ if response.status_code != 200:
232
+ return Fetched(response.status_code, response.headers)
233
+ async for chunk in response.aiter_bytes():
234
+ size += len(chunk)
235
+ if size > limit:
236
+ raise SourceDefinitionError(
237
+ f"download larger than the {limit} bytes allowed by "
238
+ f"{self.rules.section}.max_bytes"
239
+ )
240
+ chunks.append(chunk)
241
+ return Fetched(200, response.headers, b"".join(chunks))
@@ -0,0 +1,320 @@
1
+ """Readers of Source rows (design 9.1): CSV, JSON, JSON Lines and Parquet files, JSON APIs,
2
+ Postgres, MySQL/MariaDB and SQL Server tables and queries.
3
+
4
+ A connector yields `(locator, row)` pairs; the locator (`name:row number`) becomes the row
5
+ locator of the provenance entries. The connection of a Source is a secret reference; its
6
+ value is the file location, the URL or the Postgres DSN.
7
+
8
+ - CSV: a local path under one of `scribe.sources.csv.allowed_roots`, or an `http(s)` URL whose
9
+ scheme and host are allowed (`allowed_schemes`, `allowed_hosts`), both read through the
10
+ guards of `access` (no symbolic link followed after the roots check, regular files only,
11
+ hosts resolving to global unicast addresses only unless `allow_private_targets`, requests
12
+ pinned to the checked addresses, no redirects). Files and downloads are read in bounded
13
+ steps and refused past `max_bytes`. The delimiter is sniffed, the first decodable encoding
14
+ of `encodings` wins, and the first row is the header.
15
+ - JSON, JSON Lines and Parquet files (`records`) and JSON APIs (`http_json`): see those
16
+ modules.
17
+ - Postgres: the `table` (default: the last segment of the Source api name) or the `query` of
18
+ the Source, filtered by `scope_filter`, read in one read-only transaction with a statement
19
+ timeout and fetched `fetch_rows` at a time.
20
+ - MySQL/MariaDB (`mysql_table`, `mysql_query`) and SQL Server (`mssql_table`, `mssql_query`):
21
+ the same, see `sql`; their drivers come with the `mysql` and `mssql` extras.
22
+
23
+ Incremental Sources read only rows whose watermark column is greater than the highest watermark
24
+ of the previous successful run (compared in SQL with the column's own type for Postgres, as text
25
+ for CSV, so CSV watermarks should be ISO timestamps or zero-padded numbers, and natively, or as
26
+ text when the types differ, for the other Sources).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import csv
33
+ import io
34
+ import os
35
+ from collections.abc import AsyncIterator, Callable
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any, Protocol
39
+ from urllib.parse import urlsplit
40
+
41
+ import httpx
42
+ from sqlalchemy import text
43
+ from sqlalchemy.ext.asyncio import create_async_engine
44
+ from sqlalchemy.pool import NullPool
45
+
46
+ from lorebase_core.model import Source
47
+ from lorebase_core.model.source import SourceType
48
+ from lorebase_scribe.settings import CsvSourceSettings, PostgresSourceSettings, SourceSettings
49
+ from lorebase_scribe.sources.access import (
50
+ HttpRules,
51
+ PinnedClient,
52
+ SourceReadError,
53
+ local_path,
54
+ open_local,
55
+ read_bounded,
56
+ resolve_location,
57
+ )
58
+ from lorebase_scribe.sources.http_json import HttpJsonConnector
59
+ from lorebase_scribe.sources.mapping import Row, SourceDefinitionError
60
+ from lorebase_scribe.sources.records import JsonConnector, JsonLinesConnector, ParquetConnector
61
+ from lorebase_scribe.sources.sql import MssqlConnector, MysqlConnector, quote_identifier
62
+ from lorebase_store.db import async_url
63
+ from lorebase_store.webhooks.delivery import Resolver, resolve_host
64
+
65
+ __all__ = [
66
+ "Connector",
67
+ "ConnectorFactory",
68
+ "CsvConnector",
69
+ "HttpJsonConnector",
70
+ "JsonConnector",
71
+ "JsonLinesConnector",
72
+ "MssqlConnector",
73
+ "MysqlConnector",
74
+ "ParquetConnector",
75
+ "PostgresConnector",
76
+ "SourceReadError",
77
+ "check_connection",
78
+ "default_connector",
79
+ "quote_identifier",
80
+ ]
81
+
82
+ APPLICATION_NAME = "lorebase-scribe"
83
+ CSV_SECTION = "scribe.sources.csv"
84
+
85
+
86
+ class Connector(Protocol):
87
+ """Reads the rows of one Source."""
88
+
89
+ @property
90
+ def columns(self) -> tuple[str, ...]:
91
+ """Column names, known after the first row was read (header for CSV). Empty for
92
+ Sources without fixed columns (JSON records): a key missing from a row is null there,
93
+ and the mapped columns are checked against every key the run saw."""
94
+ ...
95
+
96
+ def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
97
+ """Rows in source order; with `since`, only rows whose watermark is greater."""
98
+ ...
99
+
100
+
101
+ ConnectorFactory = Callable[[Source, SourceSettings], Connector]
102
+
103
+
104
+ def check_connection(source: Source, settings: SourceSettings) -> None:
105
+ """Refuse a Source whose connection is not an allowed secret reference.
106
+
107
+ Sources come from the schema, written by API callers; resolving any reference they name
108
+ would let them read whatever the host can reach with it (the Lorebase database DSN
109
+ included). Messages here and below name the reference at most, never its value.
110
+ """
111
+ if source.connection not in settings.allowed_connections:
112
+ raise SourceDefinitionError(
113
+ f"connection {source.connection!r} is not in scribe.sources.allowed_connections"
114
+ )
115
+
116
+
117
+ # CSV ----------------------------------------------------------------------------------------
118
+
119
+
120
+ @dataclass
121
+ class CsvConnector:
122
+ source: Source
123
+ settings: CsvSourceSettings
124
+ transport: httpx.AsyncBaseTransport | None = None
125
+ resolve: Resolver = resolve_host
126
+ _columns: tuple[str, ...] = ()
127
+
128
+ @property
129
+ def columns(self) -> tuple[str, ...]:
130
+ return self._columns
131
+
132
+ async def _load(self) -> tuple[str, bytes]:
133
+ location = resolve_location(self.source)
134
+ parts = urlsplit(location)
135
+ if parts.scheme in ("http", "https"):
136
+ return Path(parts.path).name or parts.netloc, await self._download(location)
137
+ return self._read_file(local_path(location, "CSV"))
138
+
139
+ def _read_file(self, path: Path) -> tuple[str, bytes]:
140
+ descriptor = open_local(path, self.settings.allowed_roots, CSV_SECTION)
141
+ try:
142
+ return path.name, read_bounded(
143
+ descriptor, path.name, self.settings.max_bytes, f"{CSV_SECTION}.max_bytes"
144
+ )
145
+ except OSError as error:
146
+ raise SourceReadError(f"{path.name}: {error.strerror}") from None
147
+ finally:
148
+ os.close(descriptor)
149
+
150
+ async def _download(self, location: str) -> bytes:
151
+ settings = self.settings
152
+ try:
153
+ url = httpx.URL(location)
154
+ except httpx.InvalidURL:
155
+ raise SourceDefinitionError("connection: not a valid URL") from None
156
+ rules = HttpRules(
157
+ CSV_SECTION,
158
+ settings.allowed_schemes,
159
+ settings.allowed_hosts,
160
+ settings.allow_private_targets,
161
+ settings.max_bytes,
162
+ )
163
+ try:
164
+ async with (
165
+ asyncio.timeout(settings.timeout_s),
166
+ httpx.AsyncClient(
167
+ trust_env=False,
168
+ follow_redirects=False,
169
+ timeout=settings.timeout_s,
170
+ transport=self.transport,
171
+ ) as client,
172
+ ):
173
+ fetched = await PinnedClient(client, rules, self.resolve).get(url, {}, "connection")
174
+ except TimeoutError:
175
+ raise SourceReadError(
176
+ f"GET {url.host}: not complete within {CSV_SECTION}.timeout_s"
177
+ ) from None
178
+ except httpx.HTTPError as error:
179
+ raise SourceReadError(f"GET {url.host}: {type(error).__name__}") from None
180
+ if fetched.status != 200:
181
+ raise SourceReadError(f"GET {url.host}: HTTP {fetched.status}")
182
+ return fetched.body
183
+
184
+ def _decode(self, name: str, data: bytes) -> str:
185
+ for encoding in self.settings.encodings:
186
+ try:
187
+ return data.decode(encoding)
188
+ except (UnicodeDecodeError, LookupError):
189
+ continue
190
+ raise SourceDefinitionError(
191
+ f"{name}: not decodable as {', '.join(self.settings.encodings)}"
192
+ )
193
+
194
+ def _delimiter(self, sample: str, name: str) -> str:
195
+ if name.lower().endswith((".tsv", ".tab")):
196
+ return "\t"
197
+ try:
198
+ return csv.Sniffer().sniff(sample, delimiters=self.settings.delimiters).delimiter
199
+ except csv.Error:
200
+ return ","
201
+
202
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
203
+ name, data = await self._load()
204
+ content = self._decode(name, data)
205
+ delimiter = self._delimiter(content[: self.settings.sniff_bytes], name)
206
+ reader = csv.reader(io.StringIO(content, newline=""), delimiter=delimiter)
207
+ try:
208
+ header = next(reader, None)
209
+ if header is None:
210
+ raise SourceDefinitionError(f"{name}: the file is empty")
211
+ self._columns = tuple(cell.strip() for cell in header)
212
+ watermark = self.source.watermark_column
213
+ for number, cells in enumerate(reader, start=2):
214
+ if not any(cell.strip() for cell in cells):
215
+ continue
216
+ row = {
217
+ c: (cells[i] if i < len(cells) else None) for i, c in enumerate(self._columns)
218
+ }
219
+ if since is not None and watermark is not None:
220
+ value = row.get(watermark)
221
+ if value is None or value <= str(since):
222
+ continue
223
+ yield f"{name}:{number}", row
224
+ except csv.Error as error:
225
+ raise SourceDefinitionError(f"{name}: not valid delimited text ({error})") from None
226
+
227
+
228
+ # Postgres -----------------------------------------------------------------------------------
229
+
230
+
231
+ @dataclass
232
+ class PostgresConnector:
233
+ source: Source
234
+ settings: PostgresSourceSettings
235
+ _columns: tuple[str, ...] = ()
236
+
237
+ @property
238
+ def columns(self) -> tuple[str, ...]:
239
+ return self._columns
240
+
241
+ def statement(self, since: Any) -> tuple[str, dict[str, Any], str]:
242
+ """SQL text, parameters and the relation name used in row locators."""
243
+ source = self.source
244
+ if source.type is SourceType.POSTGRES_QUERY:
245
+ if not (source.query or "").strip():
246
+ raise SourceDefinitionError("postgres_query Sources need a query")
247
+ body = f"SELECT * FROM ({source.query.strip().rstrip(';')}) AS lorebase_source" # type: ignore[union-attr]
248
+ name = "query"
249
+ else:
250
+ name = source.table or source.api_name.rsplit(".", 1)[-1]
251
+ body = f"SELECT * FROM {quote_identifier(name)} AS lorebase_source"
252
+ conditions: list[str] = []
253
+ parameters: dict[str, Any] = {}
254
+ if source.scope_filter:
255
+ conditions.append(f"({source.scope_filter})")
256
+ order = ""
257
+ if source.watermark_column is not None:
258
+ column = quote_identifier(source.watermark_column)
259
+ if since is not None:
260
+ conditions.append(f"{column} > :since")
261
+ parameters["since"] = since
262
+ order = f" ORDER BY {column}"
263
+ where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
264
+ return body + where + order, parameters, name
265
+
266
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
267
+ sql, parameters, name = self.statement(since)
268
+ try:
269
+ url = async_url(resolve_location(self.source))
270
+ except ValueError as error:
271
+ raise SourceDefinitionError(f"connection: {error}") from None
272
+ engine = create_async_engine(
273
+ url,
274
+ poolclass=NullPool,
275
+ connect_args={
276
+ "timeout": self.settings.connect_timeout_s,
277
+ "server_settings": {"application_name": APPLICATION_NAME},
278
+ },
279
+ )
280
+ try:
281
+ async with engine.connect() as connection, connection.begin():
282
+ await connection.execute(text("SET TRANSACTION READ ONLY"))
283
+ await connection.execute(
284
+ text(f"SET LOCAL statement_timeout = {int(self.settings.statement_timeout_ms)}")
285
+ )
286
+ result = await connection.stream(text(sql), parameters)
287
+ self._columns = tuple(result.keys())
288
+ number = 0
289
+ async for partition in result.mappings().partitions(self.settings.fetch_rows):
290
+ for row in partition:
291
+ number += 1
292
+ yield f"{name}:{number}", dict(row)
293
+ except (OSError, TimeoutError) as error:
294
+ raise SourceReadError(f"{name}: {type(error).__name__}: {error}") from None
295
+ finally:
296
+ await engine.dispose()
297
+
298
+
299
+ def default_connector(source: Source, settings: SourceSettings) -> Connector:
300
+ """The connector of a Source type.
301
+
302
+ The connection must be one of `settings.allowed_connections`."""
303
+ check_connection(source, settings)
304
+ if source.type is SourceType.CSV:
305
+ return CsvConnector(source, settings.csv)
306
+ if source.type is SourceType.JSON:
307
+ return JsonConnector(source, settings.json_files)
308
+ if source.type is SourceType.JSONL:
309
+ return JsonLinesConnector(source, settings.json_files)
310
+ if source.type is SourceType.PARQUET:
311
+ return ParquetConnector(source, settings.parquet)
312
+ if source.type is SourceType.HTTP_JSON:
313
+ return HttpJsonConnector(source, settings.http_json, settings.allowed_connections)
314
+ if source.type in (SourceType.POSTGRES_TABLE, SourceType.POSTGRES_QUERY):
315
+ return PostgresConnector(source, settings.postgres)
316
+ if source.type in (SourceType.MYSQL_TABLE, SourceType.MYSQL_QUERY):
317
+ return MysqlConnector(source, settings.mysql)
318
+ if source.type in (SourceType.MSSQL_TABLE, SourceType.MSSQL_QUERY):
319
+ return MssqlConnector(source, settings.mssql)
320
+ raise SourceDefinitionError(f"Source type {source.type.value!r} is not supported")