curator-data 0.2.0__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.
- curator_data/__init__.py +25 -0
- curator_data/chain/__init__.py +12 -0
- curator_data/chain/rpc.py +121 -0
- curator_data/cli.py +204 -0
- curator_data/config.py +175 -0
- curator_data/default.py +34 -0
- curator_data/facts.py +143 -0
- curator_data/graph/__init__.py +11 -0
- curator_data/graph/errors.py +42 -0
- curator_data/graph/factory.py +48 -0
- curator_data/graph/gateway.py +172 -0
- curator_data/ports.py +106 -0
- curator_data/queries.py +266 -0
- curator_data/registry.py +294 -0
- curator_data/sources/__init__.py +59 -0
- curator_data/sources/aave.py +209 -0
- curator_data/sources/chainlink.py +206 -0
- curator_data/sources/feeds.py +69 -0
- curator_data/sources/messari.py +390 -0
- curator_data/sources/pools.py +44 -0
- curator_data/sources/protocols.py +152 -0
- curator_data/sources/token_api.py +315 -0
- curator_data/sources/tokens.py +42 -0
- curator_data/verify.py +270 -0
- curator_data/x402/__init__.py +27 -0
- curator_data/x402/client.py +167 -0
- curator_data/x402/payment.py +291 -0
- curator_data-0.2.0.dist-info/METADATA +381 -0
- curator_data-0.2.0.dist-info/RECORD +31 -0
- curator_data-0.2.0.dist-info/WHEEL +4 -0
- curator_data-0.2.0.dist-info/entry_points.txt +2 -0
curator_data/facts.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Constructing `Fact`s correctly, so sources don't each get it wrong.
|
|
2
|
+
|
|
3
|
+
Two invariants from the frozen schema are easy to violate and expensive when
|
|
4
|
+
violated:
|
|
5
|
+
|
|
6
|
+
* **`Fact.source` must be the registry key.** Provenance is the whole point;
|
|
7
|
+
a fact whose source is wrong is worse than a missing fact.
|
|
8
|
+
* **`apy_fraction` is 0.0432 for 4.32%, never 4.32.** Normalise at the source
|
|
9
|
+
adapter. Messari reports interest rates as percentages, so this is a live
|
|
10
|
+
trap, not a hypothetical one.
|
|
11
|
+
|
|
12
|
+
`FactBuilder` makes both structural. A source constructs one with its own key
|
|
13
|
+
and then cannot emit a fact attributed to anyone else, and the APY constructors
|
|
14
|
+
are named after the unit they consume (`apy_from_percent` / `apy_from_fraction`)
|
|
15
|
+
so the conversion is a decision at the call site rather than an assumption.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
|
|
23
|
+
from curator_schema.models import Fact, FactKind, FactSubject
|
|
24
|
+
|
|
25
|
+
_SLUG_STRIP = re.compile(r"[^a-z0-9]+")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def utcnow() -> datetime:
|
|
29
|
+
return datetime.now(timezone.utc)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _slug(value: str) -> str:
|
|
33
|
+
return _SLUG_STRIP.sub("-", value.strip().lower()).strip("-")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def subject_slug(subject: FactSubject) -> str:
|
|
37
|
+
"""Compact, human-readable identity for a fact's subject.
|
|
38
|
+
|
|
39
|
+
Appears in fact ids, which the dApp renders next to the agent's reasoning —
|
|
40
|
+
`messari:yield:aave-v3/usdc` is legible in a decision feed;
|
|
41
|
+
a hash is not.
|
|
42
|
+
"""
|
|
43
|
+
parts: list[str] = []
|
|
44
|
+
if subject.protocol:
|
|
45
|
+
parts.append(_slug(subject.protocol))
|
|
46
|
+
if subject.market:
|
|
47
|
+
parts.append(_slug(subject.market))
|
|
48
|
+
if subject.token:
|
|
49
|
+
parts.append(_slug(subject.token))
|
|
50
|
+
if subject.pair:
|
|
51
|
+
parts.append("-".join(_slug(p) for p in subject.pair))
|
|
52
|
+
return "/".join(parts) or "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FactBuilder:
|
|
56
|
+
"""Emits `Fact`s already stamped with one source's provenance.
|
|
57
|
+
|
|
58
|
+
Ids are deterministic (`source:kind:subject`) rather than sequential: the
|
|
59
|
+
same market observed in two consecutive snapshots keeps the same id, which
|
|
60
|
+
is what lets the dApp diff decisions and lets us spot a model citing a fact
|
|
61
|
+
it was never shown. The registry de-duplicates collisions at merge time.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, source: str, *, chain: str = "base", observed_at: datetime | None = None):
|
|
65
|
+
if not source:
|
|
66
|
+
raise ValueError("FactBuilder requires a source key — provenance is not optional")
|
|
67
|
+
self.source = source
|
|
68
|
+
self.chain = chain
|
|
69
|
+
self.observed_at = observed_at or utcnow()
|
|
70
|
+
|
|
71
|
+
# ── subject helpers ───────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def subject(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
protocol: str | None = None,
|
|
77
|
+
market: str | None = None,
|
|
78
|
+
token: str | None = None,
|
|
79
|
+
pair: list[str] | None = None,
|
|
80
|
+
) -> FactSubject:
|
|
81
|
+
return FactSubject(
|
|
82
|
+
protocol=protocol, market=market, token=token, pair=pair, chain=self.chain
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# ── the general form ──────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def fact(
|
|
88
|
+
self,
|
|
89
|
+
kind: FactKind,
|
|
90
|
+
subject: FactSubject,
|
|
91
|
+
value: float,
|
|
92
|
+
unit: str,
|
|
93
|
+
*,
|
|
94
|
+
observed_at: datetime | None = None,
|
|
95
|
+
confidence: float | None = None,
|
|
96
|
+
) -> Fact:
|
|
97
|
+
return Fact(
|
|
98
|
+
id=f"{self.source}:{kind}:{subject_slug(subject)}",
|
|
99
|
+
kind=kind,
|
|
100
|
+
subject=subject,
|
|
101
|
+
value=float(value),
|
|
102
|
+
unit=unit, # type: ignore[arg-type] # validated by pydantic
|
|
103
|
+
source=self.source,
|
|
104
|
+
observed_at=observed_at or self.observed_at,
|
|
105
|
+
confidence=confidence,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# ── unit-safe constructors ────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def apy_from_percent(self, subject: FactSubject, percent: float, **kw) -> Fact:
|
|
111
|
+
"""APY given as `4.32` meaning 4.32%. Converts to the 0.0432 the schema wants."""
|
|
112
|
+
return self.fact("yield", subject, float(percent) / 100.0, "apy_fraction", **kw)
|
|
113
|
+
|
|
114
|
+
def apy_from_fraction(self, subject: FactSubject, fraction: float, **kw) -> Fact:
|
|
115
|
+
"""APY already normalised: `0.0432` meaning 4.32%."""
|
|
116
|
+
return self.fact("yield", subject, float(fraction), "apy_fraction", **kw)
|
|
117
|
+
|
|
118
|
+
def usd(self, kind: FactKind, subject: FactSubject, value: float, **kw) -> Fact:
|
|
119
|
+
return self.fact(kind, subject, float(value), "usd", **kw)
|
|
120
|
+
|
|
121
|
+
def ratio(self, kind: FactKind, subject: FactSubject, value: float, **kw) -> Fact:
|
|
122
|
+
return self.fact(kind, subject, float(value), "ratio", **kw)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def dedupe_ids(facts: list[Fact]) -> list[Fact]:
|
|
126
|
+
"""Guarantee id uniqueness within one snapshot.
|
|
127
|
+
|
|
128
|
+
Deterministic ids can legitimately collide — two Aave markets whose names
|
|
129
|
+
slugify identically, say. `AllocationDecision.facts_used` points at these,
|
|
130
|
+
so a duplicate would make a decision's citation ambiguous. Later duplicates
|
|
131
|
+
get a `#n` suffix; the first keeps the clean id so the common case stays
|
|
132
|
+
readable.
|
|
133
|
+
"""
|
|
134
|
+
seen: dict[str, int] = {}
|
|
135
|
+
out: list[Fact] = []
|
|
136
|
+
for fact in facts:
|
|
137
|
+
count = seen.get(fact.id, 0)
|
|
138
|
+
seen[fact.id] = count + 1
|
|
139
|
+
out.append(fact if count == 0 else fact.model_copy(update={"id": f"{fact.id}#{count}"}))
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ["FactBuilder", "dedupe_ids", "subject_slug", "utcnow"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Transport for The Graph's decentralised gateway.
|
|
2
|
+
|
|
3
|
+
Kept separate from the sources so that *how* we reach a subgraph (API key,
|
|
4
|
+
x402 payment, retries) is independent of *what* we ask it. The x402 path is a
|
|
5
|
+
decorator over `GatewayClient` for exactly this reason.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .errors import GatewayAuthError, GatewayError, GatewayQueryError
|
|
9
|
+
from .gateway import GatewayClient
|
|
10
|
+
|
|
11
|
+
__all__ = ["GatewayClient", "GatewayError", "GatewayAuthError", "GatewayQueryError"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Transport error taxonomy.
|
|
2
|
+
|
|
3
|
+
Three cases, because callers genuinely act differently on each:
|
|
4
|
+
|
|
5
|
+
* `GatewayAuthError` — the credential is missing or rejected. Retrying is
|
|
6
|
+
pointless; the operator has to do something. `verify-live` reports this
|
|
7
|
+
distinctly so a missing key never looks like a network blip.
|
|
8
|
+
* `GatewayQueryError` — the gateway answered and rejected our GraphQL. That
|
|
9
|
+
is our bug (wrong field, wrong schema family), and the message carries the
|
|
10
|
+
subgraph id so it names the protocol to fix.
|
|
11
|
+
* `GatewayError` — everything else: transport, timeout, 5xx.
|
|
12
|
+
|
|
13
|
+
All three carry a message that is safe to surface into `MarketSnapshot.errors`
|
|
14
|
+
and read aloud during a demo. None of them ever contains the API key.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GatewayError(RuntimeError):
|
|
21
|
+
"""Base: something went wrong reaching or reading the gateway."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, *, subgraph_id: str | None = None):
|
|
24
|
+
self.subgraph_id = subgraph_id
|
|
25
|
+
super().__init__(f"{message} (subgraph {subgraph_id})" if subgraph_id else message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GatewayAuthError(GatewayError):
|
|
29
|
+
"""Missing, malformed or rejected credential — 401/402/403."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GatewayQueryError(GatewayError):
|
|
33
|
+
"""The gateway returned GraphQL `errors[]`. Our query is wrong."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, message: str, *, subgraph_id: str | None = None, errors: list[dict] | None = None
|
|
37
|
+
):
|
|
38
|
+
self.errors = errors or []
|
|
39
|
+
super().__init__(message, subgraph_id=subgraph_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["GatewayError", "GatewayAuthError", "GatewayQueryError"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Choosing a gateway transport.
|
|
2
|
+
|
|
3
|
+
One decision point, so no source has to know that x402 exists. `messari.py`
|
|
4
|
+
asks for a gateway and gets whichever transport the configuration warrants;
|
|
5
|
+
adding a third transport later touches only this file.
|
|
6
|
+
|
|
7
|
+
Defaults to the API-key client. The paid path requires the flag *and* a key —
|
|
8
|
+
a flag alone would produce a confusing per-query fallback rather than an
|
|
9
|
+
obvious "you forgot the key".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ..config import Settings
|
|
19
|
+
from .gateway import GatewayClient
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_gateway(
|
|
25
|
+
settings: Settings, *, client: httpx.AsyncClient | None = None
|
|
26
|
+
) -> GatewayClient:
|
|
27
|
+
"""The gateway transport this configuration calls for."""
|
|
28
|
+
if not settings.x402_ready:
|
|
29
|
+
if settings.x402_enabled:
|
|
30
|
+
logger.warning(
|
|
31
|
+
"X402_ENABLED is set but X402_PRIVATE_KEY is not - using the API-key path"
|
|
32
|
+
)
|
|
33
|
+
return GatewayClient(settings, client=client)
|
|
34
|
+
|
|
35
|
+
# Imported lazily: eth-account is an optional dependency, and a broken or
|
|
36
|
+
# absent signing stack must degrade to the API-key path rather than break
|
|
37
|
+
# importing the package at all.
|
|
38
|
+
try:
|
|
39
|
+
from ..x402.client import X402GatewayClient
|
|
40
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
41
|
+
logger.warning("x402 unavailable (%s) - using the API-key path", exc)
|
|
42
|
+
return GatewayClient(settings, client=client)
|
|
43
|
+
|
|
44
|
+
logger.info("x402 enabled - the agent will pay for its own queries")
|
|
45
|
+
return X402GatewayClient(settings, client=client)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["make_gateway"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""GraphQL client for subgraphs on The Graph's decentralised gateway.
|
|
2
|
+
|
|
3
|
+
Small on purpose. A GraphQL client library would buy schema validation and a
|
|
4
|
+
DSL we don't want — our queries are three fixed strings — at the cost of a
|
|
5
|
+
dependency and its own error taxonomy to translate. `httpx.AsyncClient` plus a
|
|
6
|
+
POST is the whole protocol.
|
|
7
|
+
|
|
8
|
+
Auth travels as `Authorization: Bearer <key>`. The gateway also accepts the key
|
|
9
|
+
in the path (`/api/<key>/subgraphs/id/<id>`), which we deliberately do not use:
|
|
10
|
+
a URL ends up in logs, tracebacks and screen shares, and this repo is public.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ..config import Settings
|
|
21
|
+
from .errors import GatewayAuthError, GatewayError, GatewayQueryError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
#: Substrings that mark a GraphQL-level error as a credential problem rather
|
|
26
|
+
#: than a bad query. The gateway answers an unauthenticated request with
|
|
27
|
+
#: HTTP 200 and {"errors":[{"message":"auth error: missing authorization
|
|
28
|
+
#: header"}]}, so status code alone cannot tell these apart.
|
|
29
|
+
_AUTH_ERROR_MARKERS = (
|
|
30
|
+
"auth error",
|
|
31
|
+
"missing authorization",
|
|
32
|
+
"invalid api key",
|
|
33
|
+
"unauthorized",
|
|
34
|
+
"payment required",
|
|
35
|
+
"rate limit",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _looks_like_auth_failure(message: str) -> bool:
|
|
40
|
+
lowered = message.lower()
|
|
41
|
+
return any(marker in lowered for marker in _AUTH_ERROR_MARKERS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GatewayClient:
|
|
45
|
+
"""Executes GraphQL documents against subgraphs by id.
|
|
46
|
+
|
|
47
|
+
One client is shared by every source that needs a subgraph, so connection
|
|
48
|
+
pooling and timeouts are configured in exactly one place.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
settings: Settings,
|
|
54
|
+
*,
|
|
55
|
+
client: httpx.AsyncClient | None = None,
|
|
56
|
+
):
|
|
57
|
+
self.settings = settings
|
|
58
|
+
# An injected client is how tests reach this without a network or a
|
|
59
|
+
# credential — `httpx.MockTransport` needs no new dependency.
|
|
60
|
+
self._client = client
|
|
61
|
+
self._owns_client = client is None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def client(self) -> httpx.AsyncClient:
|
|
65
|
+
if self._client is None:
|
|
66
|
+
self._client = httpx.AsyncClient(timeout=self.settings.request_timeout_s)
|
|
67
|
+
return self._client
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def label(self) -> str:
|
|
71
|
+
"""How this transport identifies itself in logs and in the CLI."""
|
|
72
|
+
return "api-key"
|
|
73
|
+
|
|
74
|
+
def _headers(self) -> dict[str, str]:
|
|
75
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
76
|
+
if self.settings.graph_api_key:
|
|
77
|
+
headers["Authorization"] = f"Bearer {self.settings.graph_api_key}"
|
|
78
|
+
return headers
|
|
79
|
+
|
|
80
|
+
def _url(self, subgraph_id: str) -> str:
|
|
81
|
+
return self.settings.subgraph_url(subgraph_id)
|
|
82
|
+
|
|
83
|
+
async def query(
|
|
84
|
+
self,
|
|
85
|
+
subgraph_id: str,
|
|
86
|
+
document: str,
|
|
87
|
+
variables: dict[str, Any] | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Run `document` against `subgraph_id`, returning the `data` object.
|
|
90
|
+
|
|
91
|
+
Raises `GatewayAuthError` / `GatewayQueryError` / `GatewayError`. Sources
|
|
92
|
+
let these propagate to the registry, which turns them into
|
|
93
|
+
`MarketSnapshot.errors` entries.
|
|
94
|
+
"""
|
|
95
|
+
if not self.settings.graph_api_key:
|
|
96
|
+
# ASCII only in messages that reach a terminal: Windows consoles
|
|
97
|
+
# default to cp1252 and turn a stray em dash into a mojibake box
|
|
98
|
+
# mid-demo.
|
|
99
|
+
raise GatewayAuthError(
|
|
100
|
+
"GRAPH_API_KEY is not set - get one free at https://thegraph.com/studio "
|
|
101
|
+
"-> API Keys and put it in .env",
|
|
102
|
+
subgraph_id=subgraph_id,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
payload = {"query": document, "variables": variables or {}}
|
|
106
|
+
return await self._post(self._url(subgraph_id), payload, subgraph_id)
|
|
107
|
+
|
|
108
|
+
async def _post(
|
|
109
|
+
self, url: str, payload: dict[str, Any], subgraph_id: str
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
try:
|
|
112
|
+
response = await self.client.post(url, json=payload, headers=self._headers())
|
|
113
|
+
except httpx.TimeoutException as exc:
|
|
114
|
+
raise GatewayError(f"gateway timed out: {exc}", subgraph_id=subgraph_id) from exc
|
|
115
|
+
except httpx.HTTPError as exc:
|
|
116
|
+
raise GatewayError(f"gateway unreachable: {exc}", subgraph_id=subgraph_id) from exc
|
|
117
|
+
|
|
118
|
+
return self._read(response, subgraph_id)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _read(response: httpx.Response, subgraph_id: str) -> dict[str, Any]:
|
|
122
|
+
if response.status_code in (401, 403):
|
|
123
|
+
raise GatewayAuthError(
|
|
124
|
+
f"gateway rejected the API key (HTTP {response.status_code})",
|
|
125
|
+
subgraph_id=subgraph_id,
|
|
126
|
+
)
|
|
127
|
+
if response.status_code == 402:
|
|
128
|
+
raise GatewayAuthError(
|
|
129
|
+
"gateway requires payment (HTTP 402) — this endpoint is x402-gated",
|
|
130
|
+
subgraph_id=subgraph_id,
|
|
131
|
+
)
|
|
132
|
+
if response.status_code >= 400:
|
|
133
|
+
raise GatewayError(
|
|
134
|
+
f"gateway returned HTTP {response.status_code}: {response.text[:200]}",
|
|
135
|
+
subgraph_id=subgraph_id,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
body = response.json()
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise GatewayError(
|
|
142
|
+
f"gateway returned non-JSON: {response.text[:200]}", subgraph_id=subgraph_id
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
if isinstance(body, dict) and body.get("errors"):
|
|
146
|
+
errors = body["errors"]
|
|
147
|
+
first = errors[0].get("message", "unknown") if errors else "unknown"
|
|
148
|
+
# Verified against the live gateway: a missing or rejected key comes
|
|
149
|
+
# back as HTTP *200* with {"errors":[{"message":"auth error: ..."}]},
|
|
150
|
+
# not as a 401. Classifying that as a query error would send someone
|
|
151
|
+
# to debug our GraphQL when the real fix is a credential.
|
|
152
|
+
if _looks_like_auth_failure(first):
|
|
153
|
+
raise GatewayAuthError(f"gateway rejected the request: {first}",
|
|
154
|
+
subgraph_id=subgraph_id)
|
|
155
|
+
raise GatewayQueryError(
|
|
156
|
+
f"GraphQL error: {first}", subgraph_id=subgraph_id, errors=errors
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
160
|
+
if data is None:
|
|
161
|
+
raise GatewayError(
|
|
162
|
+
"gateway response contained no data", subgraph_id=subgraph_id
|
|
163
|
+
)
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
async def aclose(self) -> None:
|
|
167
|
+
if self._client is not None and self._owns_client:
|
|
168
|
+
await self._client.aclose()
|
|
169
|
+
self._client = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = ["GatewayClient"]
|
curator_data/ports.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The lane's view of the frozen `DataSource` seam.
|
|
2
|
+
|
|
3
|
+
`packages/schema` owns the protocol — it is frozen and this module does not
|
|
4
|
+
redefine it, it re-exports it. What lives here is the *convenience* layer a
|
|
5
|
+
source author actually writes against, which the frozen protocol deliberately
|
|
6
|
+
does not specify.
|
|
7
|
+
|
|
8
|
+
Writing a new source is: subclass `BaseSource`, set `key`, implement
|
|
9
|
+
`fetch`. Nothing else in the package changes except one registration line.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
|
|
16
|
+
from curator_schema.models import Fact
|
|
17
|
+
from curator_schema.ports import DataSource, DataSourceRegistry
|
|
18
|
+
|
|
19
|
+
__all__ = ["DataSource", "DataSourceRegistry", "BaseSource"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseSource(ABC):
|
|
23
|
+
"""Optional base class for a `DataSource`.
|
|
24
|
+
|
|
25
|
+
The frozen protocol is structural, so a source need not inherit from this —
|
|
26
|
+
any object with `key` and `async fetch` satisfies it. This exists to carry
|
|
27
|
+
the two obligations that are easy to forget:
|
|
28
|
+
|
|
29
|
+
1. `close()` — sources own HTTP clients and the registry must be able to
|
|
30
|
+
release them without knowing what a source is made of.
|
|
31
|
+
2. `describe()` — the MCP server and the genesis UI both need to tell a
|
|
32
|
+
human what a source key means. Without this, "messari" is an opaque
|
|
33
|
+
string in a checkbox list.
|
|
34
|
+
|
|
35
|
+
Failure contract (from the frozen port, restated because it is the rule
|
|
36
|
+
most likely to be broken): `fetch` must not raise for *expected* failure —
|
|
37
|
+
a timeout, a rate limit, a missing market. Return what you have. The
|
|
38
|
+
registry does catch exceptions, but a source that raises on a missing
|
|
39
|
+
market loses the facts it could have returned alongside it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
#: Registry key. Named in `Mandate.permitted_data_sources`, shown in the
|
|
43
|
+
#: genesis UI, and stamped onto every Fact as provenance — so it is
|
|
44
|
+
#: user-visible and effectively permanent once shipped.
|
|
45
|
+
key: str = ""
|
|
46
|
+
|
|
47
|
+
#: One line, human-facing. Shown wherever a user picks data sources.
|
|
48
|
+
description: str = ""
|
|
49
|
+
|
|
50
|
+
#: Which `Fact.kind`s this source can contribute.
|
|
51
|
+
#:
|
|
52
|
+
#: Declared so callers can ask for a *capability* rather than naming a
|
|
53
|
+
#: provider: "who has prices?" resolves through the registry instead of
|
|
54
|
+
#: through a hardcoded list somewhere else in the codebase. That is what
|
|
55
|
+
#: keeps "adding a source is one line" true — a new price source starts
|
|
56
|
+
#: serving price queries the moment it is registered, with no second edit.
|
|
57
|
+
#:
|
|
58
|
+
#: An empty tuple means "unknown", and such a source is consulted for every
|
|
59
|
+
#: capability. Erring toward including it costs one call that returns
|
|
60
|
+
#: nothing; erring the other way silently drops a source the user granted.
|
|
61
|
+
provides: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._notes: list[str] = []
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def fetch(self, assets: list[str]) -> list[Fact]:
|
|
68
|
+
"""Facts relevant to `assets` (symbols from the mandate)."""
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
# ── partial-failure channel ───────────────────────────────────────────
|
|
72
|
+
#
|
|
73
|
+
# The frozen port models a source as all-or-nothing: return facts, or raise
|
|
74
|
+
# and be recorded in `MarketSnapshot.errors`. Real sources are not
|
|
75
|
+
# all-or-nothing — this one queries five protocols and any one of them can
|
|
76
|
+
# be down. Returning the other four silently would tell the model it saw
|
|
77
|
+
# the whole market when it did not, which is the single most dangerous
|
|
78
|
+
# failure mode we have: the agent holds a key and acts on this.
|
|
79
|
+
#
|
|
80
|
+
# So a source may record notes about what it could not fetch. The registry
|
|
81
|
+
# looks for `drain_notes` and folds each into `errors[]`. This is additive —
|
|
82
|
+
# `key` + `fetch` still satisfies the frozen protocol, and a source that
|
|
83
|
+
# ignores this mechanism behaves exactly as before.
|
|
84
|
+
|
|
85
|
+
def note(self, message: str) -> None:
|
|
86
|
+
"""Record a partial failure to surface in `MarketSnapshot.errors`."""
|
|
87
|
+
self._notes.append(message)
|
|
88
|
+
|
|
89
|
+
def drain_notes(self) -> list[str]:
|
|
90
|
+
"""Return and clear notes. Called by the registry after each fetch."""
|
|
91
|
+
notes, self._notes = self._notes, []
|
|
92
|
+
return notes
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None:
|
|
95
|
+
"""Release held resources. Safe to call more than once."""
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def describe(self) -> dict[str, str]:
|
|
99
|
+
return {
|
|
100
|
+
"key": self.key,
|
|
101
|
+
"description": self.description,
|
|
102
|
+
"provides": ", ".join(self.provides),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def __repr__(self) -> str: # pragma: no cover - debugging affordance
|
|
106
|
+
return f"<{type(self).__name__} key={self.key!r}>"
|