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/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}>"