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,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")
|