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