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.
- pyth_hermes/__init__.py +31 -0
- pyth_hermes/_config.py +97 -0
- pyth_hermes/async_client.py +189 -0
- pyth_hermes/client.py +173 -0
- pyth_hermes/models.py +115 -0
- pyth_hermes/pandas.py +38 -0
- pyth_hermes/py.typed +0 -0
- pyth_hermes-0.1.0.dist-info/METADATA +171 -0
- pyth_hermes-0.1.0.dist-info/RECORD +11 -0
- pyth_hermes-0.1.0.dist-info/WHEEL +4 -0
- pyth_hermes-0.1.0.dist-info/licenses/LICENSE +21 -0
pyth_hermes/__init__.py
ADDED
|
@@ -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,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.
|