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,175 @@
1
+ """The delta of a Source run against the current head of its branch (design 9.1 steps 2-3).
2
+
3
+ Pure: the current state is passed in. A row is `added` when its key is not on the branch,
4
+ `changed` when the content hash of its new Record differs from the stored one, and otherwise
5
+ unchanged (Lineage is not content, so rewriting the same values is no change). Links of the
6
+ mapped Relations follow the rows. `removed` keys are the owned keys the run did not see (when
7
+ deletions apply) plus the keys of tombstone rows that exist. The changes of one key are kept
8
+ together in a `ChangeGroup`, so a batch boundary never splits a Record from its Links.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterable, Mapping
14
+ from dataclasses import dataclass, field
15
+
16
+ from pydantic import JsonValue
17
+
18
+ from lorebase_core.canonical import ObjectType, normalize, object_hash
19
+ from lorebase_core.model import Link, ProvenanceEntry, Record
20
+ from lorebase_core.validate import DataChange, LinkDelete, LinkUpsert, RecordDelete, RecordUpsert
21
+ from lorebase_scribe.sources.mapping import MappedRow, RowMapper
22
+
23
+ ProvenanceFactory = Callable[[MappedRow], ProvenanceEntry]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class StoredRecord:
28
+ """A Record on the branch: content hash and values."""
29
+
30
+ object_hash: bytes
31
+ values: Mapping[str, JsonValue]
32
+
33
+
34
+ @dataclass
35
+ class CurrentState:
36
+ """What the Source branch holds for the target Kind and the mapped Relations."""
37
+
38
+ records: Mapping[str, StoredRecord] = field(default_factory=dict)
39
+ owned: frozenset[str] = frozenset()
40
+ #: Relation id -> from key -> to keys.
41
+ links: Mapping[str, Mapping[str, frozenset[str]]] = field(default_factory=dict)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Change:
46
+ """A data change and the provenance entry it is attributed to (`None`: the commit's)."""
47
+
48
+ change: DataChange
49
+ provenance: ProvenanceEntry | None = None
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ChangeGroup:
54
+ key: str
55
+ changes: tuple[Change, ...]
56
+
57
+
58
+ @dataclass
59
+ class Delta:
60
+ added: list[str] = field(default_factory=list)
61
+ changed: list[str] = field(default_factory=list)
62
+ removed: list[str] = field(default_factory=list)
63
+ unchanged: int = 0
64
+ links_added: int = 0
65
+ links_removed: int = 0
66
+ groups: list[ChangeGroup] = field(default_factory=list)
67
+
68
+ @property
69
+ def change_count(self) -> int:
70
+ return sum(len(group.changes) for group in self.groups)
71
+
72
+ def counts(self) -> dict[str, int]:
73
+ return {
74
+ "added": len(self.added),
75
+ "changed": len(self.changed),
76
+ "removed": len(self.removed),
77
+ "unchanged": self.unchanged,
78
+ "links_added": self.links_added,
79
+ "links_removed": self.links_removed,
80
+ }
81
+
82
+
83
+ def content_hash(record: Record) -> bytes:
84
+ """Object hash of a Record, as the commit pipeline stores it (design 6.1)."""
85
+ body = normalize(record.content())
86
+ assert isinstance(body, dict)
87
+ return object_hash(ObjectType.RECORD, body)
88
+
89
+
90
+ def _link_changes(
91
+ relation_id: str,
92
+ key: str,
93
+ desired: frozenset[str],
94
+ existing: frozenset[str],
95
+ provenance: ProvenanceEntry | None,
96
+ delta: Delta,
97
+ ) -> list[Change]:
98
+ changes: list[Change] = []
99
+ for to_key in sorted(existing - desired):
100
+ changes.append(Change(LinkDelete(relation=relation_id, from_key=key, to_key=to_key)))
101
+ delta.links_removed += 1
102
+ for to_key in sorted(desired - existing):
103
+ link = Link(relation=relation_id, from_key=key, to_key=to_key)
104
+ changes.append(Change(LinkUpsert(link=link), provenance))
105
+ delta.links_added += 1
106
+ return changes
107
+
108
+
109
+ def _removal(mapper: RowMapper, key: str, current: CurrentState, delta: Delta) -> ChangeGroup:
110
+ changes: list[Change] = []
111
+ for relation_id in mapper.relations:
112
+ existing = current.links.get(relation_id, {}).get(key, frozenset())
113
+ changes.extend(_link_changes(relation_id, key, frozenset(), existing, None, delta))
114
+ changes.append(Change(RecordDelete(kind=mapper.kind.id, key=key)))
115
+ delta.removed.append(key)
116
+ return ChangeGroup(key, tuple(changes))
117
+
118
+
119
+ def compute_delta(
120
+ mapper: RowMapper,
121
+ rows: Iterable[MappedRow],
122
+ current: CurrentState,
123
+ *,
124
+ delete_missing: bool,
125
+ provenance: ProvenanceFactory,
126
+ ) -> Delta:
127
+ """Changes turning the branch into what the rows say. Later rows of a key win."""
128
+ latest: dict[str, MappedRow] = {}
129
+ for row in rows:
130
+ latest[row.key] = row
131
+ delta = Delta()
132
+ for key, row in latest.items():
133
+ stored = current.records.get(key)
134
+ if row.tombstone:
135
+ if stored is not None:
136
+ delta.groups.append(_removal(mapper, key, current, delta))
137
+ continue
138
+ record = mapper.record(row, None if stored is None else stored.values)
139
+ entry: ProvenanceEntry | None = None
140
+ changes: list[Change] = []
141
+ if stored is None or content_hash(record) != stored.object_hash:
142
+ entry = provenance(row)
143
+ changes.append(Change(RecordUpsert(record=record), entry))
144
+ (delta.added if stored is None else delta.changed).append(key)
145
+ else:
146
+ delta.unchanged += 1
147
+ for relation_id in mapper.relations:
148
+ existing = current.links.get(relation_id, {}).get(key, frozenset())
149
+ desired = row.links.get(relation_id, frozenset())
150
+ if desired != existing and entry is None:
151
+ entry = provenance(row)
152
+ changes.extend(_link_changes(relation_id, key, desired, existing, entry, delta))
153
+ if changes:
154
+ delta.groups.append(ChangeGroup(key, tuple(changes)))
155
+ if delete_missing:
156
+ for key in sorted(current.owned - latest.keys()):
157
+ if key in current.records:
158
+ delta.groups.append(_removal(mapper, key, current, delta))
159
+ return delta
160
+
161
+
162
+ def batches(groups: Iterable[ChangeGroup], size: int) -> list[list[ChangeGroup]]:
163
+ """Groups packed into batches of about `size` changes; a group is never split."""
164
+ out: list[list[ChangeGroup]] = []
165
+ current: list[ChangeGroup] = []
166
+ count = 0
167
+ for group in groups:
168
+ if current and count + len(group.changes) > size:
169
+ out.append(current)
170
+ current, count = [], 0
171
+ current.append(group)
172
+ count += len(group.changes)
173
+ if current:
174
+ out.append(current)
175
+ return out
@@ -0,0 +1,283 @@
1
+ """Reader of `http_json` Sources: GET requests to a JSON API, page by page.
2
+
3
+ The connection's value is the URL of the first page. Every request, next pages included, goes
4
+ through the guards of `access` with the `scribe.sources.http_json` settings: an allowed scheme
5
+ and host, addresses checked as for webhooks (global unicast only unless
6
+ `allow_private_targets`) and looked up once per host and run, connections pinned to them, no
7
+ redirects, responses refused past `max_bytes`, `timeout_s` per request from lookup to last
8
+ byte.
9
+
10
+ - Headers come from the Source's `http.headers`. A value that is a secret reference is
11
+ resolved like the connection, and only when it is listed in
12
+ `scribe.sources.allowed_connections`; credential headers (`Authorization`, `X-Api-Key`...)
13
+ accept nothing else, so no credential is ever stored in a schema.
14
+ - Records are the array at `records_path` of each response, or the response itself.
15
+ - Pagination (`http.pagination.type`): `none`; `cursor` (the value at `cursor_path` goes into
16
+ `cursor_param` of the next request); `page` (`page_param` counts up from `start_page`);
17
+ `offset` (`offset_param` moves by the records received); `link` (the `rel="next"` URL of the
18
+ `Link` header). Reading stops at a page without records or without a next page; a run that
19
+ would need more than `max_pages` pages (or the Source's lower `http.max_pages`) fails rather
20
+ than commit part of the Source.
21
+ - 429 and 5xx answers (`retry_statuses`), network errors and timeouts are retried up to
22
+ `max_attempts` times with exponential backoff, honoring `Retry-After` up to
23
+ `backoff_max_s`. Other statuses fail the read.
24
+ - Incremental reads send the previous watermark as `http.since_param` when it is set, and keep
25
+ only rows whose watermark column is greater either way.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import re
33
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
34
+ from dataclasses import dataclass
35
+ from datetime import UTC, datetime
36
+ from email.utils import parsedate_to_datetime
37
+ from pathlib import PurePosixPath
38
+ from typing import Any
39
+
40
+ import httpx
41
+
42
+ from lorebase_core.model import SECRET_REFERENCE_PREFIX, Source
43
+ from lorebase_core.model.source import (
44
+ HttpOptions,
45
+ HttpPagination,
46
+ PaginationType,
47
+ credential_header,
48
+ )
49
+ from lorebase_platform.secrets import SecretResolutionError, resolve_secret
50
+ from lorebase_scribe.settings import HttpJsonSourceSettings
51
+ from lorebase_scribe.sources.access import (
52
+ Fetched,
53
+ HttpRules,
54
+ PinnedClient,
55
+ SourceReadError,
56
+ resolve_location,
57
+ )
58
+ from lorebase_scribe.sources.mapping import Row, SourceDefinitionError, text_of
59
+ from lorebase_scribe.sources.records import flatten, keep, records_at, value_at
60
+ from lorebase_store.webhooks.delivery import Resolver, resolve_host
61
+
62
+ SECTION = "scribe.sources.http_json"
63
+ ACCEPT = "application/json"
64
+ _LINK = re.compile(r"\s*<([^>]*)>(.*)")
65
+ _REL = re.compile(r"""\brel\s*=\s*("([^"]*)"|[^\s;]+)""", re.IGNORECASE)
66
+
67
+ Sleep = Callable[[float], Awaitable[None]]
68
+
69
+
70
+ def next_link(header: str | None) -> str | None:
71
+ """The `rel="next"` target of a `Link` header (RFC 8288), or None."""
72
+ for part in (header or "").split(","):
73
+ found = _LINK.match(part)
74
+ if found is None:
75
+ continue
76
+ for rel in _REL.finditer(found.group(2)):
77
+ values = (rel.group(2) if rel.group(2) is not None else rel.group(1)).split()
78
+ if "next" in (value.lower() for value in values):
79
+ return found.group(1).strip()
80
+ return None
81
+
82
+
83
+ def retry_after(headers: httpx.Headers, now: datetime) -> float | None:
84
+ """Seconds a `Retry-After` header asks to wait (delay or HTTP date), or None."""
85
+ value = headers.get("retry-after")
86
+ if value is None:
87
+ return None
88
+ value = value.strip()
89
+ if value.isdigit():
90
+ return float(value)
91
+ try:
92
+ when = parsedate_to_datetime(value)
93
+ except (TypeError, ValueError):
94
+ return None
95
+ if when.tzinfo is None:
96
+ when = when.replace(tzinfo=UTC)
97
+ return max((when - now).total_seconds(), 0.0)
98
+
99
+
100
+ class _Pages:
101
+ """The request of each page."""
102
+
103
+ def __init__(self, pagination: HttpPagination, url: httpx.URL) -> None:
104
+ self.rule = pagination
105
+ if pagination.size_param is not None and pagination.page_size is not None:
106
+ url = url.copy_set_param(pagination.size_param, str(pagination.page_size))
107
+ self.base = url
108
+ self.index = 0
109
+ self.offset = 0
110
+ self.cursors: set[str] = set()
111
+
112
+ def first(self) -> httpx.URL:
113
+ if self.rule.type is PaginationType.PAGE:
114
+ return self.base.copy_set_param(self.rule.page_param, str(self.rule.start_page))
115
+ if self.rule.type is PaginationType.OFFSET:
116
+ return self.base.copy_set_param(self.rule.offset_param, "0")
117
+ return self.base
118
+
119
+ def next(
120
+ self, current: httpx.URL, fetched: Fetched, document: Any, count: int
121
+ ) -> httpx.URL | None:
122
+ """The next page's URL, or None after the last page."""
123
+ rule = self.rule
124
+ if rule.type is PaginationType.NONE or count == 0:
125
+ return None
126
+ self.index += 1
127
+ if rule.type is PaginationType.PAGE:
128
+ return self.base.copy_set_param(rule.page_param, str(rule.start_page + self.index))
129
+ if rule.type is PaginationType.OFFSET:
130
+ self.offset += count
131
+ return self.base.copy_set_param(rule.offset_param, str(self.offset))
132
+ if rule.type is PaginationType.LINK:
133
+ target = next_link(fetched.headers.get("link"))
134
+ if target is None:
135
+ return None
136
+ try:
137
+ return current.join(target)
138
+ except httpx.InvalidURL:
139
+ raise SourceReadError("the next page link is not a valid URL") from None
140
+ assert rule.cursor_path is not None
141
+ try:
142
+ cursor = value_at(document, rule.cursor_path)
143
+ except KeyError:
144
+ return None
145
+ if cursor is None or cursor == "" or isinstance(cursor, dict | list):
146
+ return None
147
+ token = text_of(cursor)
148
+ if token in self.cursors: # the server repeats itself: nothing new follows
149
+ return None
150
+ self.cursors.add(token)
151
+ return self.base.copy_set_param(rule.cursor_param, token)
152
+
153
+
154
+ def utc_now() -> datetime:
155
+ return datetime.now(UTC)
156
+
157
+
158
+ @dataclass
159
+ class HttpJsonConnector:
160
+ source: Source
161
+ settings: HttpJsonSourceSettings
162
+ allowed_secrets: tuple[str, ...] = ()
163
+ transport: httpx.AsyncBaseTransport | None = None
164
+ resolve: Resolver = resolve_host
165
+ sleep: Sleep = asyncio.sleep
166
+ clock: Callable[[], datetime] = utc_now
167
+
168
+ @property
169
+ def columns(self) -> tuple[str, ...]:
170
+ return ()
171
+
172
+ def _headers(self, options: HttpOptions) -> dict[str, str]:
173
+ headers = {"Accept": ACCEPT}
174
+ for name, value in options.headers.items():
175
+ if value.startswith(SECRET_REFERENCE_PREFIX):
176
+ if value not in self.allowed_secrets:
177
+ raise SourceDefinitionError(
178
+ f"header {name!r}: {value!r} is not in scribe.sources.allowed_connections"
179
+ )
180
+ try:
181
+ value = resolve_secret(value)
182
+ except SecretResolutionError as error:
183
+ raise SourceDefinitionError(f"header {name!r}: {error}") from None
184
+ elif credential_header(name):
185
+ raise SourceDefinitionError(
186
+ f"header {name!r} carries credentials: its value must be a secret reference"
187
+ )
188
+ headers[name] = value
189
+ return headers
190
+
191
+ def _first_url(self, options: HttpOptions, since: Any) -> httpx.URL:
192
+ try:
193
+ url = httpx.URL(resolve_location(self.source))
194
+ except httpx.InvalidURL:
195
+ raise SourceDefinitionError("connection: not a valid URL") from None
196
+ if since is not None and options.since_param is not None:
197
+ url = url.copy_set_param(options.since_param, text_of(since))
198
+ return url
199
+
200
+ def _delay(self, attempt: int, fetched: Fetched | None) -> float:
201
+ settings = self.settings
202
+ delay = settings.backoff_base_s * settings.backoff_factor ** (attempt - 1)
203
+ if fetched is not None:
204
+ asked = retry_after(fetched.headers, self.clock())
205
+ if asked is not None:
206
+ delay = max(delay, asked)
207
+ return min(delay, settings.backoff_max_s)
208
+
209
+ async def _get(
210
+ self, client: PinnedClient, url: httpx.URL, headers: Mapping[str, str], what: str
211
+ ) -> Fetched:
212
+ """A 200 response, after retries of transient failures."""
213
+ settings = self.settings
214
+ attempt = 0
215
+ while True:
216
+ attempt += 1
217
+ fetched: Fetched | None = None
218
+ try:
219
+ async with asyncio.timeout(settings.timeout_s):
220
+ fetched = await client.get(url, headers, what)
221
+ except TimeoutError:
222
+ failure = f"not complete within {SECTION}.timeout_s"
223
+ except httpx.TransportError as error:
224
+ failure = type(error).__name__
225
+ except httpx.HTTPError as error:
226
+ raise SourceReadError(f"GET {url.host}: {type(error).__name__}") from None
227
+ else:
228
+ if fetched.status == 200:
229
+ return fetched
230
+ if fetched.status not in settings.retry_statuses:
231
+ raise SourceReadError(f"GET {url.host}: HTTP {fetched.status}")
232
+ failure = f"HTTP {fetched.status}"
233
+ if attempt >= settings.max_attempts:
234
+ raise SourceReadError(f"GET {url.host}: {failure} after {attempt} attempts")
235
+ await self.sleep(self._delay(attempt, fetched))
236
+
237
+ async def rows(self, since: Any) -> AsyncIterator[tuple[str, Row]]:
238
+ options = self.source.http or HttpOptions()
239
+ settings = self.settings
240
+ headers = self._headers(options)
241
+ first = self._first_url(options, since)
242
+ name = PurePosixPath(first.path).name or first.host
243
+ pages = _Pages(options.pagination, first)
244
+ limit = min(options.max_pages or settings.max_pages, settings.max_pages)
245
+ rules = HttpRules(
246
+ SECTION,
247
+ settings.allowed_schemes,
248
+ settings.allowed_hosts,
249
+ settings.allow_private_targets,
250
+ settings.max_bytes,
251
+ )
252
+ separator = self.source.flatten_separator
253
+ async with httpx.AsyncClient(
254
+ trust_env=False,
255
+ follow_redirects=False,
256
+ timeout=settings.timeout_s,
257
+ transport=self.transport,
258
+ ) as http:
259
+ client = PinnedClient(http, rules, self.resolve)
260
+ url: httpx.URL | None = pages.first()
261
+ page = 0
262
+ number = 0
263
+ while url is not None:
264
+ page += 1
265
+ if page > limit:
266
+ raise SourceDefinitionError(
267
+ f"{name}: more than {limit} pages; raise {SECTION}.max_pages or the "
268
+ "Source's http.max_pages, or narrow the Source"
269
+ )
270
+ fetched = await self._get(
271
+ client, url, headers, "connection" if page == 1 else f"page {page}"
272
+ )
273
+ try:
274
+ document = json.loads(fetched.body)
275
+ except ValueError:
276
+ raise SourceReadError(f"{name}: page {page} is not valid JSON") from None
277
+ records = records_at(document, self.source.records_path, f"{name} page {page}")
278
+ for record in records:
279
+ number += 1
280
+ row = flatten(record, separator)
281
+ if keep(row, self.source, since):
282
+ yield f"{name}:{number}", row
283
+ url = pages.next(url, fetched, document, len(records))