pyth-hermes 0.1.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.
@@ -0,0 +1,31 @@
1
+ """pyth-hermes: a Python client for the Pyth Network Hermes price-oracle API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyth_hermes.async_client import AsyncHermesClient
6
+ from pyth_hermes.client import HermesClient
7
+ from pyth_hermes.models import (
8
+ BinaryUpdate,
9
+ FeedAttributes,
10
+ ParsedPriceUpdate,
11
+ PriceFeed,
12
+ PriceFeedMetadata,
13
+ PriceUpdateResponse,
14
+ RpcPrice,
15
+ price_to_decimal,
16
+ )
17
+
18
+ __all__ = [
19
+ "AsyncHermesClient",
20
+ "BinaryUpdate",
21
+ "FeedAttributes",
22
+ "HermesClient",
23
+ "ParsedPriceUpdate",
24
+ "PriceFeed",
25
+ "PriceFeedMetadata",
26
+ "PriceUpdateResponse",
27
+ "RpcPrice",
28
+ "price_to_decimal",
29
+ ]
30
+
31
+ __version__ = "0.1.0"
pyth_hermes/_config.py ADDED
@@ -0,0 +1,97 @@
1
+ """Shared configuration, constants and retry helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import warnings
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import httpx
11
+
12
+ PRODUCTION_URL = "https://hermes.pyth.network"
13
+ BETA_URL = "https://hermes-beta.pyth.network"
14
+
15
+ #: Sentinel for ``base_url`` so we can tell "left at default" from "explicitly
16
+ #: passed" (needed to warn when an injected client makes base_url a no-op).
17
+ _BASE_URL_UNSET = "\x00__pyth_hermes_base_url_unset__"
18
+
19
+
20
+ def warn_if_base_url_ignored(base_url: str, client_injected: bool) -> None:
21
+ """Warn when an explicit ``base_url`` cannot take effect.
22
+
23
+ Auth headers are applied per-request and so are honored even with a
24
+ caller-supplied httpx client, but the request URL is resolved against that
25
+ client's own ``base_url``. Passing both an explicit ``base_url`` and a
26
+ ``client`` therefore silently drops the former; make that loud instead.
27
+ """
28
+ if client_injected and base_url is not _BASE_URL_UNSET:
29
+ warnings.warn(
30
+ "base_url is ignored when a custom httpx client is supplied; the "
31
+ "request host is taken from the injected client. Set base_url on "
32
+ "the client itself (httpx.Client(base_url=...)) or omit the client.",
33
+ UserWarning,
34
+ stacklevel=3,
35
+ )
36
+
37
+
38
+ def resolve_base_url(base_url: str) -> str:
39
+ """Map the sentinel back to the production default."""
40
+ return PRODUCTION_URL if base_url is _BASE_URL_UNSET else base_url
41
+
42
+
43
+ # Rate limit: 10 requests / 10s per IP; exceeding -> HTTP 429 for the next 60s.
44
+ RATE_LIMIT_WINDOW_SECONDS = 60.0
45
+
46
+ #: Status codes worth retrying. 429 is rate limiting; 5xx are transient.
47
+ RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ClientConfig:
52
+ """Connection + retry settings shared by the sync and async clients."""
53
+
54
+ base_url: str = PRODUCTION_URL
55
+ api_key: Optional[str] = None
56
+ api_key_header: str = "Authorization"
57
+ api_key_scheme: str = "Bearer"
58
+ timeout: float = 30.0
59
+ max_retries: int = 3
60
+ backoff_base: float = 1.0
61
+ backoff_cap: float = 60.0
62
+
63
+ def normalized_base_url(self) -> str:
64
+ return self.base_url.rstrip("/")
65
+
66
+ def auth_headers(self) -> dict[str, str]:
67
+ if not self.api_key:
68
+ return {}
69
+ value = (
70
+ f"{self.api_key_scheme} {self.api_key}".strip() if self.api_key_scheme else self.api_key
71
+ )
72
+ return {self.api_key_header: value}
73
+
74
+
75
+ def retry_delay(attempt: int, response: Optional[httpx.Response], config: ClientConfig) -> float:
76
+ """Compute how long to sleep before the next attempt.
77
+
78
+ Honors a ``Retry-After`` header when present (cap-limited), otherwise uses
79
+ exponential backoff with full jitter. For 429 responses without a header we
80
+ do not exceed the 60s rate-limit window per delay.
81
+ """
82
+ if response is not None and "Retry-After" in response.headers:
83
+ try:
84
+ # Clamp to >= 0: a negative Retry-After would make time.sleep() raise.
85
+ return max(0.0, min(float(response.headers["Retry-After"]), config.backoff_cap))
86
+ except ValueError:
87
+ pass
88
+
89
+ exp = config.backoff_base * (2**attempt)
90
+ cap = config.backoff_cap
91
+ if response is not None and response.status_code == 429:
92
+ cap = min(cap, RATE_LIMIT_WINDOW_SECONDS)
93
+ return min(cap, random.uniform(0, exp)) if exp > 0 else 0.0
94
+
95
+
96
+ def should_retry(response: httpx.Response) -> bool:
97
+ return response.status_code in RETRY_STATUS_CODES
@@ -0,0 +1,189 @@
1
+ """Asynchronous Pyth Hermes client with SSE price streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from decimal import Decimal
8
+ from types import TracebackType
9
+ from typing import Any, Optional
10
+
11
+ import httpx
12
+ from httpx_sse import aconnect_sse
13
+
14
+ from pyth_hermes._config import (
15
+ _BASE_URL_UNSET,
16
+ ClientConfig,
17
+ resolve_base_url,
18
+ retry_delay,
19
+ should_retry,
20
+ warn_if_base_url_ignored,
21
+ )
22
+ from pyth_hermes.client import _price_params
23
+ from pyth_hermes.models import PriceFeed, PriceUpdateResponse
24
+
25
+
26
+ class AsyncHermesClient:
27
+ """Asynchronous client for the Pyth Network Hermes API.
28
+
29
+ Adds :meth:`stream_prices`, an async iterator over the SSE price stream
30
+ with automatic reconnect + backoff. Mirrors :class:`HermesClient` for the
31
+ request/response endpoints.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str = _BASE_URL_UNSET,
37
+ *,
38
+ api_key: Optional[str] = None,
39
+ api_key_header: str = "Authorization",
40
+ api_key_scheme: str = "Bearer",
41
+ timeout: float = 30.0,
42
+ max_retries: int = 3,
43
+ backoff_base: float = 1.0,
44
+ backoff_cap: float = 60.0,
45
+ client: Optional[httpx.AsyncClient] = None,
46
+ ) -> None:
47
+ warn_if_base_url_ignored(base_url, client is not None)
48
+ self._config = ClientConfig(
49
+ base_url=resolve_base_url(base_url),
50
+ api_key=api_key,
51
+ api_key_header=api_key_header,
52
+ api_key_scheme=api_key_scheme,
53
+ timeout=timeout,
54
+ max_retries=max_retries,
55
+ backoff_base=backoff_base,
56
+ backoff_cap=backoff_cap,
57
+ )
58
+ self._http = client or httpx.AsyncClient(
59
+ base_url=self._config.normalized_base_url(), timeout=timeout
60
+ )
61
+
62
+ @property
63
+ def base_url(self) -> str:
64
+ return self._config.normalized_base_url()
65
+
66
+ def _auth_headers(self) -> dict[str, str]:
67
+ return self._config.auth_headers()
68
+
69
+ async def __aenter__(self) -> AsyncHermesClient:
70
+ return self
71
+
72
+ async def __aexit__(
73
+ self,
74
+ exc_type: Optional[type[BaseException]],
75
+ exc: Optional[BaseException],
76
+ tb: Optional[TracebackType],
77
+ ) -> None:
78
+ await self.aclose()
79
+
80
+ async def aclose(self) -> None:
81
+ await self._http.aclose()
82
+
83
+ async def _request(self, method: str, path: str, *, params: Any = None) -> httpx.Response:
84
+ for attempt in range(self._config.max_retries + 1):
85
+ try:
86
+ response = await self._http.request(
87
+ method, path, params=params, headers=self._auth_headers()
88
+ )
89
+ except httpx.TransportError:
90
+ if attempt >= self._config.max_retries:
91
+ raise
92
+ await asyncio.sleep(retry_delay(attempt, None, self._config))
93
+ continue
94
+
95
+ if should_retry(response) and attempt < self._config.max_retries:
96
+ await asyncio.sleep(retry_delay(attempt, response, self._config))
97
+ continue
98
+ response.raise_for_status()
99
+ return response
100
+
101
+ raise RuntimeError("unreachable") # pragma: no cover
102
+
103
+ async def list_price_feeds(
104
+ self, *, query: Optional[str] = None, asset_type: Optional[str] = None
105
+ ) -> list[PriceFeed]:
106
+ params: dict[str, str] = {}
107
+ if query is not None:
108
+ params["query"] = query
109
+ if asset_type is not None:
110
+ params["asset_type"] = asset_type
111
+ response = await self._request("GET", "/v2/price_feeds", params=params)
112
+ return [PriceFeed.model_validate(item) for item in response.json()]
113
+
114
+ async def get_feed_id(self, symbol: str) -> Optional[str]:
115
+ """Resolve a canonical feed id by EXACT ``attributes.symbol`` match."""
116
+ feeds = await self.list_price_feeds(query=symbol)
117
+ for feed in feeds:
118
+ if feed.symbol == symbol:
119
+ return feed.id
120
+ return None
121
+
122
+ async def get_latest_price(
123
+ self, ids: list[str], *, parsed: bool = True, encoding: str = "hex"
124
+ ) -> PriceUpdateResponse:
125
+ params = _price_params(ids, parsed=parsed, encoding=encoding)
126
+ response = await self._request("GET", "/v2/updates/price/latest", params=params)
127
+ return PriceUpdateResponse.model_validate(response.json())
128
+
129
+ async def get_price_at(
130
+ self,
131
+ publish_time: int,
132
+ ids: list[str],
133
+ *,
134
+ parsed: bool = True,
135
+ encoding: str = "hex",
136
+ ) -> PriceUpdateResponse:
137
+ params = _price_params(ids, parsed=parsed, encoding=encoding)
138
+ response = await self._request("GET", f"/v2/updates/price/{publish_time}", params=params)
139
+ return PriceUpdateResponse.model_validate(response.json())
140
+
141
+ async def get_price_decimal(self, feed_id: str) -> Decimal:
142
+ resp = await self.get_latest_price([feed_id])
143
+ if not resp.parsed:
144
+ raise ValueError(f"no parsed price returned for {feed_id!r}")
145
+ return resp.parsed[0].to_decimal()
146
+
147
+ async def stream_prices(
148
+ self,
149
+ ids: list[str],
150
+ *,
151
+ parsed: bool = True,
152
+ reconnect: bool = True,
153
+ ) -> AsyncIterator[PriceUpdateResponse]:
154
+ """Yield :class:`PriceUpdateResponse` objects from the SSE price stream.
155
+
156
+ With ``reconnect=True`` (default) the stream transparently re-opens
157
+ after a disconnect, applying exponential backoff between attempts. Set
158
+ ``reconnect=False`` to stop once the server closes the connection.
159
+ """
160
+ params: list[tuple[str, str]] = [("ids[]", fid) for fid in ids]
161
+ params.append(("parsed", "true" if parsed else "false"))
162
+
163
+ attempt = 0
164
+ while True:
165
+ try:
166
+ async with aconnect_sse(
167
+ self._http,
168
+ "GET",
169
+ "/v2/updates/price/stream",
170
+ params=params,
171
+ headers=self._auth_headers(),
172
+ ) as event_source:
173
+ event_source.response.raise_for_status()
174
+ attempt = 0 # reset backoff after a successful connect
175
+ async for sse in event_source.aiter_sse():
176
+ if not sse.data:
177
+ continue
178
+ yield PriceUpdateResponse.model_validate_json(sse.data)
179
+ except (httpx.TransportError, httpx.HTTPStatusError):
180
+ if not reconnect:
181
+ raise
182
+ await asyncio.sleep(retry_delay(attempt, None, self._config))
183
+ attempt += 1
184
+ continue
185
+
186
+ if not reconnect:
187
+ return
188
+ await asyncio.sleep(retry_delay(attempt, None, self._config))
189
+ attempt += 1
pyth_hermes/client.py ADDED
@@ -0,0 +1,173 @@
1
+ """Synchronous Pyth Hermes client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from decimal import Decimal
7
+ from types import TracebackType
8
+ from typing import Any, Optional
9
+
10
+ import httpx
11
+
12
+ from pyth_hermes._config import (
13
+ _BASE_URL_UNSET,
14
+ ClientConfig,
15
+ resolve_base_url,
16
+ retry_delay,
17
+ should_retry,
18
+ warn_if_base_url_ignored,
19
+ )
20
+ from pyth_hermes.models import PriceFeed, PriceUpdateResponse
21
+
22
+
23
+ class HermesClient:
24
+ """Synchronous client for the Pyth Network Hermes API.
25
+
26
+ Get a BTC/USD price in a few lines::
27
+
28
+ from pyth_hermes import HermesClient
29
+ client = HermesClient()
30
+ feed_id = client.get_feed_id("Crypto.BTC/USD")
31
+ print(client.get_price_decimal(feed_id))
32
+
33
+ An ``api_key`` and custom ``base_url`` may be supplied for paid providers
34
+ and for the mandatory-key requirement landing 2026-07-31.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ base_url: str = _BASE_URL_UNSET,
40
+ *,
41
+ api_key: Optional[str] = None,
42
+ api_key_header: str = "Authorization",
43
+ api_key_scheme: str = "Bearer",
44
+ timeout: float = 30.0,
45
+ max_retries: int = 3,
46
+ backoff_base: float = 1.0,
47
+ backoff_cap: float = 60.0,
48
+ client: Optional[httpx.Client] = None,
49
+ ) -> None:
50
+ warn_if_base_url_ignored(base_url, client is not None)
51
+ self._config = ClientConfig(
52
+ base_url=resolve_base_url(base_url),
53
+ api_key=api_key,
54
+ api_key_header=api_key_header,
55
+ api_key_scheme=api_key_scheme,
56
+ timeout=timeout,
57
+ max_retries=max_retries,
58
+ backoff_base=backoff_base,
59
+ backoff_cap=backoff_cap,
60
+ )
61
+ self._http = client or httpx.Client(
62
+ base_url=self._config.normalized_base_url(), timeout=timeout
63
+ )
64
+
65
+ @property
66
+ def base_url(self) -> str:
67
+ return self._config.normalized_base_url()
68
+
69
+ def _auth_headers(self) -> dict[str, str]:
70
+ return self._config.auth_headers()
71
+
72
+ def __enter__(self) -> HermesClient:
73
+ return self
74
+
75
+ def __exit__(
76
+ self,
77
+ exc_type: Optional[type[BaseException]],
78
+ exc: Optional[BaseException],
79
+ tb: Optional[TracebackType],
80
+ ) -> None:
81
+ self.close()
82
+
83
+ def close(self) -> None:
84
+ self._http.close()
85
+
86
+ def _request(self, method: str, path: str, *, params: Any = None) -> httpx.Response:
87
+ last: Optional[httpx.Response] = None
88
+ for attempt in range(self._config.max_retries + 1):
89
+ try:
90
+ response = self._http.request(
91
+ method, path, params=params, headers=self._auth_headers()
92
+ )
93
+ except httpx.TransportError:
94
+ if attempt >= self._config.max_retries:
95
+ raise
96
+ time.sleep(retry_delay(attempt, None, self._config))
97
+ continue
98
+
99
+ if should_retry(response) and attempt < self._config.max_retries:
100
+ time.sleep(retry_delay(attempt, response, self._config))
101
+ last = response
102
+ continue
103
+ response.raise_for_status()
104
+ return response
105
+
106
+ assert last is not None
107
+ last.raise_for_status()
108
+ return last # pragma: no cover
109
+
110
+ def list_price_feeds(
111
+ self, *, query: Optional[str] = None, asset_type: Optional[str] = None
112
+ ) -> list[PriceFeed]:
113
+ """Return the feed catalog, optionally filtered by ``query``/``asset_type``."""
114
+ params: dict[str, str] = {}
115
+ if query is not None:
116
+ params["query"] = query
117
+ if asset_type is not None:
118
+ params["asset_type"] = asset_type
119
+ response = self._request("GET", "/v2/price_feeds", params=params)
120
+ return [PriceFeed.model_validate(item) for item in response.json()]
121
+
122
+ def get_feed_id(self, symbol: str) -> Optional[str]:
123
+ """Resolve a canonical feed id by EXACT ``attributes.symbol`` match.
124
+
125
+ ``/v2/price_feeds?query=...`` returns deprecated/variant feeds (MBTC,
126
+ XBTC, ...) first and matches substrings, so we filter on an exact symbol
127
+ equality. Returns ``None`` if no feed matches exactly.
128
+ """
129
+ feeds = self.list_price_feeds(query=symbol)
130
+ for feed in feeds:
131
+ if feed.symbol == symbol:
132
+ return feed.id
133
+ return None
134
+
135
+ def get_latest_price(
136
+ self,
137
+ ids: list[str],
138
+ *,
139
+ parsed: bool = True,
140
+ encoding: str = "hex",
141
+ ) -> PriceUpdateResponse:
142
+ """Latest price update for one or more feed ids."""
143
+ params = _price_params(ids, parsed=parsed, encoding=encoding)
144
+ response = self._request("GET", "/v2/updates/price/latest", params=params)
145
+ return PriceUpdateResponse.model_validate(response.json())
146
+
147
+ def get_price_at(
148
+ self,
149
+ publish_time: int,
150
+ ids: list[str],
151
+ *,
152
+ parsed: bool = True,
153
+ encoding: str = "hex",
154
+ ) -> PriceUpdateResponse:
155
+ """Historical price update at (or first after) a unix ``publish_time``."""
156
+ params = _price_params(ids, parsed=parsed, encoding=encoding)
157
+ response = self._request("GET", f"/v2/updates/price/{publish_time}", params=params)
158
+ return PriceUpdateResponse.model_validate(response.json())
159
+
160
+ def get_price_decimal(self, feed_id: str) -> Decimal:
161
+ """Convenience: latest human-readable spot price for one feed id."""
162
+ resp = self.get_latest_price([feed_id])
163
+ if not resp.parsed:
164
+ raise ValueError(f"no parsed price returned for {feed_id!r}")
165
+ return resp.parsed[0].to_decimal()
166
+
167
+
168
+ def _price_params(ids: list[str], *, parsed: bool, encoding: str) -> list[tuple[str, str]]:
169
+ """Build repeatable ``ids[]`` query params plus flags."""
170
+ params: list[tuple[str, str]] = [("ids[]", fid) for fid in ids]
171
+ params.append(("parsed", "true" if parsed else "false"))
172
+ params.append(("encoding", encoding))
173
+ return params
pyth_hermes/models.py ADDED
@@ -0,0 +1,115 @@
1
+ """Pydantic v2 models for Pyth Hermes API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import Optional, Union
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ def price_to_decimal(price: Union[int, str], expo: int) -> Decimal:
12
+ """Convert a raw Pyth price + exponent into a real ``Decimal`` value.
13
+
14
+ Real price = ``price * 10 ** expo``. Computed with ``Decimal`` to avoid
15
+ binary float imprecision.
16
+
17
+ Example: ``price=6395282153102, expo=-8`` -> ``Decimal("63952.82153102")``.
18
+ """
19
+ return Decimal(str(price)) * (Decimal(10) ** expo)
20
+
21
+
22
+ class RpcPrice(BaseModel):
23
+ """A single price reading with its exponent and publish time."""
24
+
25
+ model_config = ConfigDict(extra="ignore")
26
+
27
+ price: int
28
+ conf: int
29
+ expo: int
30
+ publish_time: int
31
+
32
+ def to_decimal(self) -> Decimal:
33
+ """Return the human-readable price as a ``Decimal``."""
34
+ return price_to_decimal(self.price, self.expo)
35
+
36
+ def conf_to_decimal(self) -> Decimal:
37
+ """Return the confidence interval as a ``Decimal`` (same exponent)."""
38
+ return price_to_decimal(self.conf, self.expo)
39
+
40
+
41
+ class PriceFeedMetadata(BaseModel):
42
+ """Metadata attached to a parsed price update."""
43
+
44
+ model_config = ConfigDict(extra="ignore")
45
+
46
+ slot: Optional[int] = None
47
+ proof_available_time: Optional[int] = None
48
+ prev_publish_time: Optional[int] = None
49
+
50
+
51
+ class ParsedPriceUpdate(BaseModel):
52
+ """A single parsed price update for one feed id."""
53
+
54
+ model_config = ConfigDict(extra="ignore")
55
+
56
+ id: str
57
+ price: RpcPrice
58
+ ema_price: RpcPrice
59
+ metadata: PriceFeedMetadata = Field(default_factory=PriceFeedMetadata)
60
+
61
+ def to_decimal(self) -> Decimal:
62
+ """Convenience: human-readable spot price as a ``Decimal``."""
63
+ return self.price.to_decimal()
64
+
65
+
66
+ class BinaryUpdate(BaseModel):
67
+ """The encoded binary VAA/update payload."""
68
+
69
+ model_config = ConfigDict(extra="ignore")
70
+
71
+ encoding: str
72
+ data: list[str]
73
+
74
+
75
+ class PriceUpdateResponse(BaseModel):
76
+ """Response from the latest / historical price update endpoints."""
77
+
78
+ model_config = ConfigDict(extra="ignore")
79
+
80
+ binary: BinaryUpdate
81
+ parsed: Optional[list[ParsedPriceUpdate]] = None
82
+
83
+
84
+ class FeedAttributes(BaseModel):
85
+ """Attributes block of a price feed catalog entry.
86
+
87
+ The Hermes ``attributes`` object is an open string map; the well-known
88
+ keys are surfaced as typed fields and any extras are preserved.
89
+ """
90
+
91
+ model_config = ConfigDict(extra="allow")
92
+
93
+ asset_type: Optional[str] = None
94
+ base: Optional[str] = None
95
+ quote_currency: Optional[str] = None
96
+ country: Optional[str] = None
97
+ description: Optional[str] = None
98
+ display_symbol: Optional[str] = None
99
+ publish_interval: Optional[str] = None
100
+ schedule: Optional[str] = None
101
+ symbol: Optional[str] = None
102
+
103
+
104
+ class PriceFeed(BaseModel):
105
+ """A single price feed catalog entry from ``/v2/price_feeds``."""
106
+
107
+ model_config = ConfigDict(extra="ignore")
108
+
109
+ id: str
110
+ attributes: FeedAttributes = Field(default_factory=FeedAttributes)
111
+
112
+ @property
113
+ def symbol(self) -> Optional[str]:
114
+ """Shortcut to ``attributes.symbol`` (e.g. ``"Crypto.BTC/USD"``)."""
115
+ return self.attributes.symbol
pyth_hermes/pandas.py ADDED
@@ -0,0 +1,38 @@
1
+ """Optional pandas helpers. Requires the ``pandas`` extra: ``pip install pyth-hermes[pandas]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ try:
8
+ import pandas as pd
9
+ except ImportError as exc: # pragma: no cover
10
+ raise ImportError(
11
+ "pandas is required for pyth_hermes.pandas; install with 'pip install pyth-hermes[pandas]'"
12
+ ) from exc
13
+
14
+ from pyth_hermes.models import PriceUpdateResponse
15
+
16
+ _COLUMNS = ["id", "publish_time", "price", "conf", "expo", "price_decimal"]
17
+
18
+
19
+ def updates_to_dataframe(responses: Iterable[PriceUpdateResponse]) -> pd.DataFrame:
20
+ """Flatten a sequence of price-update responses into a tidy DataFrame.
21
+
22
+ One row per parsed update with the raw integer ``price``/``conf``/``expo``
23
+ plus a ``price_decimal`` column carrying the human-readable ``Decimal``.
24
+ """
25
+ rows: list[dict[str, object]] = []
26
+ for response in responses:
27
+ for update in response.parsed or []:
28
+ rows.append(
29
+ {
30
+ "id": update.id,
31
+ "publish_time": update.price.publish_time,
32
+ "price": update.price.price,
33
+ "conf": update.price.conf,
34
+ "expo": update.price.expo,
35
+ "price_decimal": update.price.to_decimal(),
36
+ }
37
+ )
38
+ return pd.DataFrame(rows, columns=_COLUMNS)
pyth_hermes/py.typed ADDED
File without changes
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyth-hermes
3
+ Version: 0.1.0
4
+ Summary: Python client for the Pyth Network Hermes price-oracle API (sync + async, SSE streaming).
5
+ Project-URL: Homepage, https://github.com/robertruben98/pyth-hermes
6
+ Project-URL: Repository, https://github.com/robertruben98/pyth-hermes
7
+ Project-URL: Documentation, https://docs.pyth.network/price-feeds/core/how-pyth-works/hermes
8
+ Project-URL: Issues, https://github.com/robertruben98/pyth-hermes/issues
9
+ Author: Robert Ruben
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: defi,hermes,oracle,price-feed,pyth,solana
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx-sse>=0.4
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: pydantic>=2.6
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pandas-stubs; extra == 'dev'
30
+ Requires-Dist: pandas>=2.0; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: respx>=0.21; extra == 'dev'
34
+ Requires-Dist: ruff>=0.5; extra == 'dev'
35
+ Provides-Extra: pandas
36
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # pyth-hermes
40
+
41
+ A typed Python client for the [Pyth Network](https://pyth.network) **Hermes** price-oracle API. Sync **and** async clients, Pydantic v2 models, Server-Sent-Events price streaming with auto-reconnect, and a `Decimal` price helper.
42
+
43
+ - Sync (`HermesClient`) and async (`AsyncHermesClient`) APIs over `httpx`
44
+ - SSE streaming with reconnect + backoff
45
+ - Graceful 429 rate-limit handling (retries that respect the 60s window)
46
+ - Configurable `base_url` (production, beta, or paid providers) and optional API key from day one
47
+ - `mypy --strict` clean, fully type-hinted, ships `py.typed`
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install pyth-hermes
53
+ # with the optional pandas helper:
54
+ pip install "pyth-hermes[pandas]"
55
+ ```
56
+
57
+ ## Quickstart — BTC/USD price in under 5 lines
58
+
59
+ ```python
60
+ from pyth_hermes import HermesClient
61
+
62
+ client = HermesClient()
63
+ feed_id = client.get_feed_id("Crypto.BTC/USD") # exact-symbol lookup
64
+ print(client.get_price_decimal(feed_id)) # -> Decimal("63952.82...")
65
+ ```
66
+
67
+ ### Async + streaming
68
+
69
+ ```python
70
+ import asyncio
71
+ from pyth_hermes import AsyncHermesClient
72
+
73
+ BTC = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"
74
+
75
+ async def main():
76
+ async with AsyncHermesClient() as client:
77
+ async for update in client.stream_prices([BTC]):
78
+ print(update.parsed[0].to_decimal())
79
+
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ### Historical price
84
+
85
+ ```python
86
+ resp = client.get_price_at(1718900000, [BTC]) # unix timestamp
87
+ print(resp.parsed[0].to_decimal())
88
+ ```
89
+
90
+ ### pandas
91
+
92
+ ```python
93
+ from pyth_hermes.pandas import updates_to_dataframe
94
+ df = updates_to_dataframe([client.get_latest_price([BTC])])
95
+ ```
96
+
97
+ ## Prices and exponents
98
+
99
+ Pyth returns integer prices plus an exponent. The real value is `price * 10**expo`, computed exactly as a `Decimal`:
100
+
101
+ ```python
102
+ from pyth_hermes import price_to_decimal
103
+ price_to_decimal(6395282153102, -8) # Decimal("63952.82153102")
104
+ ```
105
+
106
+ `RpcPrice.to_decimal()` and `ParsedPriceUpdate.to_decimal()` are convenience wrappers.
107
+
108
+ ## Finding feed ids — use the EXACT symbol
109
+
110
+ `/v2/price_feeds?query=btc` returns **deprecated / variant** feeds (e.g. `MBTC`, `XBTC`) *before* the canonical one and matches substrings. `get_feed_id()` therefore matches on exact `attributes.symbol`:
111
+
112
+ ```python
113
+ client.get_feed_id("Crypto.BTC/USD")
114
+ # -> "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"
115
+ ```
116
+
117
+ ## 🔴 Authentication (changes 2026-07-31)
118
+
119
+ Today the public endpoint needs **no** API key. **From 2026-07-31 an API key becomes mandatory.** This client accepts one from day one — pass it now to be ready:
120
+
121
+ ```python
122
+ client = HermesClient(api_key="YOUR_KEY") # default: Authorization: Bearer YOUR_KEY
123
+ ```
124
+
125
+ The exact header is not finalized publicly, so both the header name and scheme are configurable:
126
+
127
+ ```python
128
+ HermesClient(api_key="KEY", api_key_header="X-Api-Key", api_key_scheme="") # -> X-Api-Key: KEY
129
+ ```
130
+
131
+ ## Endpoints / base URLs
132
+
133
+ ```python
134
+ from pyth_hermes import HermesClient
135
+
136
+ HermesClient() # production: https://hermes.pyth.network
137
+ HermesClient(base_url="https://hermes-beta.pyth.network") # beta
138
+ HermesClient(base_url="https://your-paid-provider.example") # Triton / P2P / extrnode / Liquify
139
+ ```
140
+
141
+ You may also inject your own preconfigured `httpx.Client` / `httpx.AsyncClient` via `client=...` (e.g. for custom transports, proxies, or connection pools). In that case the request host is taken from **your** client, so set `base_url` on the client itself — passing both `base_url=` and `client=` raises a `UserWarning` because the constructor's `base_url` would be a no-op. The `api_key` is still applied per-request, so it works with an injected client.
142
+
143
+ ```python
144
+ import httpx
145
+ from pyth_hermes import HermesClient
146
+
147
+ http = httpx.Client(base_url="https://your-paid-provider.example", proxy="http://localhost:8080")
148
+ client = HermesClient(api_key="KEY", client=http) # base_url comes from `http`
149
+ ```
150
+
151
+ ## Rate limits
152
+
153
+ The public endpoint allows **10 requests / 10 seconds per IP**. Exceeding it returns HTTP 429 for the next 60 seconds. The client retries 429 and 5xx responses with exponential backoff + jitter, honoring any `Retry-After` header and never exceeding the 60s rate-limit window per delay. Tune via `max_retries`, `backoff_base`, `backoff_cap`.
154
+
155
+ ## Not implemented
156
+
157
+ The TWAP endpoints (`/v2/updates/twap/...`) are intentionally omitted — the API returns HTTP 400 "deprecated and no longer available".
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ pip install -e ".[dev]"
163
+ pytest # unit tests (no network)
164
+ pytest -m integration # live smoke tests against production
165
+ mypy
166
+ ruff check .
167
+ ```
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,11 @@
1
+ pyth_hermes/__init__.py,sha256=nHOrUiz_mUgxbkIt_YnM8ZMhveAgIRtotDPnrE_NjMY,671
2
+ pyth_hermes/_config.py,sha256=-tAx1E63_6P6H3aWieue6lzBvy87N48lPWs7mswD11s,3537
3
+ pyth_hermes/async_client.py,sha256=6CFhl0F6MlFJrr0K-o_TRBYx5W_h7AEW-GElSrIWGBA,6809
4
+ pyth_hermes/client.py,sha256=gvnSPYrWMnWA0tRdgVijMs-kBWICUlYoeuYmr8mAGZA,5987
5
+ pyth_hermes/models.py,sha256=ikp6H_-aiZyseyBEZrI3QfGj3bqWKUaXmsOG_P4odFU,3257
6
+ pyth_hermes/pandas.py,sha256=qiZXcVLJThBPbs7S8AuXkYKGN6AGEkBLUAy3uviRjQs,1388
7
+ pyth_hermes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pyth_hermes-0.1.0.dist-info/METADATA,sha256=C4P6X5836CJ63PiTo0bXHlaY83i35K-WYXXLWS3KS2k,6210
9
+ pyth_hermes-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ pyth_hermes-0.1.0.dist-info/licenses/LICENSE,sha256=-AcjerVulaZXw9Ub2qG825vAXbFN8KNwHs9VEcf2XDo,1069
11
+ pyth_hermes-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Ruben
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.