openchainbench 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,48 @@
1
+ """Official Python client for OpenChainBench.
2
+
3
+ OpenChainBench (https://openchainbench.com) publishes live, reproducible
4
+ benchmarks of crypto infrastructure: RPC latency, bridge fees, perp venue
5
+ performance, oracle deviation, and more. This package wraps the public,
6
+ CC-BY-4.0 licensed JSON API so that Python applications, notebooks, and
7
+ agents can cite live numbers without scraping HTML.
8
+ """
9
+
10
+ from .client import DEFAULT_BASE_URL, OpenChainBench
11
+ from .exceptions import (
12
+ APIUnavailableError,
13
+ NotFoundError,
14
+ OpenChainBenchError,
15
+ RateLimitError,
16
+ )
17
+ from .models import (
18
+ Benchmark,
19
+ BenchmarkSummary,
20
+ CitableIndex,
21
+ Latency,
22
+ Leader,
23
+ ProviderResult,
24
+ Series,
25
+ SeriesProvider,
26
+ Source,
27
+ )
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "DEFAULT_BASE_URL",
33
+ "OpenChainBench",
34
+ "OpenChainBenchError",
35
+ "NotFoundError",
36
+ "RateLimitError",
37
+ "APIUnavailableError",
38
+ "Benchmark",
39
+ "BenchmarkSummary",
40
+ "CitableIndex",
41
+ "Latency",
42
+ "Leader",
43
+ "ProviderResult",
44
+ "Series",
45
+ "SeriesProvider",
46
+ "Source",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,202 @@
1
+ """Synchronous HTTP client for the public OpenChainBench API.
2
+
3
+ Wraps three endpoints:
4
+
5
+ * ``GET /api/citable`` -> :meth:`OpenChainBench.list_benchmarks`
6
+ * ``GET /api/stat/<slug>`` -> :meth:`OpenChainBench.get_benchmark`
7
+ * ``GET /api/series/<slug>?range=...`` -> :meth:`OpenChainBench.get_series`
8
+
9
+ The client keeps a long-lived ``httpx.Client`` and supports the context
10
+ manager protocol. Errors are mapped to a typed hierarchy in
11
+ :mod:`openchainbench.exceptions` so callers can ``except`` on intent rather
12
+ than HTTP status codes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, List, Optional
18
+
19
+ import httpx
20
+
21
+ from .exceptions import (
22
+ APIUnavailableError,
23
+ NotFoundError,
24
+ OpenChainBenchError,
25
+ RateLimitError,
26
+ )
27
+ from .models import Benchmark, BenchmarkSummary, CitableIndex, Series
28
+
29
+ DEFAULT_BASE_URL = "https://openchainbench.com"
30
+ DEFAULT_TIMEOUT = 30.0
31
+ USER_AGENT = "openchainbench-python/0.1.0"
32
+
33
+ _SUPPORTED_RANGES = ("24h", "7d", "30d")
34
+
35
+
36
+ def _parse_retry_after(value: Optional[str]) -> Optional[int]:
37
+ if not value:
38
+ return None
39
+ try:
40
+ return int(value)
41
+ except (TypeError, ValueError):
42
+ return None
43
+
44
+
45
+ def _raise_for_status(response: httpx.Response) -> None:
46
+ if response.status_code < 400:
47
+ return
48
+
49
+ body: Any
50
+ try:
51
+ body = response.json()
52
+ except ValueError:
53
+ body = {}
54
+
55
+ message = (
56
+ body.get("error")
57
+ if isinstance(body, dict) and body.get("error")
58
+ else f"HTTP {response.status_code} from {response.request.url}"
59
+ )
60
+
61
+ if response.status_code == 404:
62
+ raise NotFoundError(str(message))
63
+ if response.status_code == 429:
64
+ retry = _parse_retry_after(response.headers.get("retry-after"))
65
+ if retry is None and isinstance(body, dict):
66
+ retry = body.get("retryAfterSec")
67
+ raise RateLimitError(str(message), retry_after_sec=retry)
68
+ if response.status_code == 503:
69
+ retry = _parse_retry_after(response.headers.get("retry-after"))
70
+ if retry is None and isinstance(body, dict):
71
+ retry = body.get("retryAfterSec")
72
+ raise APIUnavailableError(str(message), retry_after_sec=retry)
73
+
74
+ raise OpenChainBenchError(str(message), status_code=response.status_code)
75
+
76
+
77
+ class OpenChainBench:
78
+ """Synchronous client for openchainbench.com.
79
+
80
+ Example:
81
+ >>> from openchainbench import OpenChainBench
82
+ >>> with OpenChainBench() as ocb:
83
+ ... for bench in ocb.list_benchmarks():
84
+ ... print(bench.slug, bench.value)
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ base_url: str = DEFAULT_BASE_URL,
90
+ *,
91
+ timeout: float = DEFAULT_TIMEOUT,
92
+ user_agent: str = USER_AGENT,
93
+ client: Optional[httpx.Client] = None,
94
+ ) -> None:
95
+ self._owns_client = client is None
96
+ self._client = client or httpx.Client(
97
+ base_url=base_url.rstrip("/"),
98
+ timeout=timeout,
99
+ headers={
100
+ "User-Agent": user_agent,
101
+ "Accept": "application/json",
102
+ },
103
+ )
104
+
105
+ def list_benchmarks(self) -> List[BenchmarkSummary]:
106
+ """Return every live benchmark with its current headline figure.
107
+
108
+ Wraps the ``CitableIndex`` envelope and returns the list directly
109
+ for ergonomic iteration. Use :meth:`fetch_citable_index` if you
110
+ need the site metadata as well.
111
+ """
112
+ return list(self.fetch_citable_index().benchmarks)
113
+
114
+ def fetch_citable_index(self) -> CitableIndex:
115
+ """Return the full ``/api/citable`` payload including site metadata."""
116
+ data = self._get("/api/citable")
117
+ return CitableIndex.from_dict(data)
118
+
119
+ def get_benchmark(
120
+ self,
121
+ slug: str,
122
+ *,
123
+ chain: Optional[str] = None,
124
+ region: Optional[str] = None,
125
+ ) -> Benchmark:
126
+ """Return the full benchmark detail.
127
+
128
+ Args:
129
+ slug: Benchmark slug, e.g. ``"bridge-fee"``.
130
+ chain: Optional chain filter (e.g. ``"ethereum"``).
131
+ region: Optional region filter (e.g. ``"eu-west"``).
132
+
133
+ Raises:
134
+ NotFoundError: The slug does not exist or is not live.
135
+ """
136
+ params: dict[str, str] = {}
137
+ if chain:
138
+ params["chain"] = chain
139
+ if region:
140
+ params["region"] = region
141
+ data = self._get(f"/api/stat/{slug}", params=params or None)
142
+ return Benchmark.from_dict(data)
143
+
144
+ def get_series(
145
+ self,
146
+ slug: str,
147
+ *,
148
+ range: str = "24h",
149
+ chain: Optional[str] = None,
150
+ region: Optional[str] = None,
151
+ providers: Optional[List[str]] = None,
152
+ ) -> Series:
153
+ """Return the per-provider time series for a benchmark.
154
+
155
+ Args:
156
+ slug: Benchmark slug.
157
+ range: One of ``"24h"``, ``"7d"``, ``"30d"``.
158
+ chain: Optional chain filter.
159
+ region: Optional region filter.
160
+ providers: Optional list of provider slugs to restrict the result to.
161
+
162
+ Raises:
163
+ ValueError: ``range`` is not supported.
164
+ NotFoundError: The slug does not exist or has no data for ``range``.
165
+ """
166
+ if range not in _SUPPORTED_RANGES:
167
+ raise ValueError(
168
+ f"range must be one of {_SUPPORTED_RANGES}, got {range!r}"
169
+ )
170
+ params: dict[str, str] = {"range": range}
171
+ if chain:
172
+ params["chain"] = chain
173
+ if region:
174
+ params["region"] = region
175
+ if providers:
176
+ params["providers"] = ",".join(providers)
177
+ data = self._get(f"/api/series/{slug}", params=params)
178
+ return Series.from_dict(data)
179
+
180
+ def close(self) -> None:
181
+ """Close the underlying HTTP client (if owned)."""
182
+ if self._owns_client:
183
+ self._client.close()
184
+
185
+ def __enter__(self) -> "OpenChainBench":
186
+ return self
187
+
188
+ def __exit__(self, *exc_info: Any) -> None:
189
+ self.close()
190
+
191
+ def _get(self, path: str, *, params: Optional[dict[str, str]] = None) -> Any:
192
+ try:
193
+ response = self._client.get(path, params=params)
194
+ except httpx.HTTPError as exc:
195
+ raise OpenChainBenchError(f"HTTP request failed: {exc}") from exc
196
+ _raise_for_status(response)
197
+ try:
198
+ return response.json()
199
+ except ValueError as exc:
200
+ raise OpenChainBenchError(
201
+ f"Response was not valid JSON: {exc}"
202
+ ) from exc
@@ -0,0 +1,41 @@
1
+ """Exception hierarchy for the OpenChainBench client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class OpenChainBenchError(Exception):
9
+ """Base error for every failure surfaced by this client."""
10
+
11
+ def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+
15
+
16
+ class NotFoundError(OpenChainBenchError):
17
+ """Raised when a benchmark slug or range does not exist."""
18
+
19
+ def __init__(self, message: str) -> None:
20
+ super().__init__(message, status_code=404)
21
+
22
+
23
+ class RateLimitError(OpenChainBenchError):
24
+ """Raised when the API returns HTTP 429.
25
+
26
+ Attributes:
27
+ retry_after_sec: Seconds the caller should wait before retrying,
28
+ parsed from the ``Retry-After`` header or the response body.
29
+ """
30
+
31
+ def __init__(self, message: str, *, retry_after_sec: Optional[int] = None) -> None:
32
+ super().__init__(message, status_code=429)
33
+ self.retry_after_sec = retry_after_sec
34
+
35
+
36
+ class APIUnavailableError(OpenChainBenchError):
37
+ """Raised when the API returns HTTP 503 (no live snapshot available)."""
38
+
39
+ def __init__(self, message: str, *, retry_after_sec: Optional[int] = None) -> None:
40
+ super().__init__(message, status_code=503)
41
+ self.retry_after_sec = retry_after_sec
@@ -0,0 +1,319 @@
1
+ """Dataclass models that mirror the public OpenChainBench API payloads.
2
+
3
+ Every model is ``frozen=True`` so instances are safe to share across threads
4
+ and cache. Parsing is permissive on unknown fields (the API may add new keys
5
+ without bumping a version), but strict on the fields the client documents.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Mapping, Optional, Sequence
12
+
13
+
14
+ def _opt_float(value: Any) -> Optional[float]:
15
+ if value is None:
16
+ return None
17
+ return float(value)
18
+
19
+
20
+ def _opt_int(value: Any) -> Optional[int]:
21
+ if value is None:
22
+ return None
23
+ return int(value)
24
+
25
+
26
+ def _opt_str(value: Any) -> Optional[str]:
27
+ if value is None:
28
+ return None
29
+ return str(value)
30
+
31
+
32
+ def _methodology(value: Any) -> tuple[str, ...]:
33
+ """Methodology can arrive as a list of bullet strings or as a single
34
+ paragraph. Normalize to a tuple of non-empty strings either way."""
35
+ if value is None:
36
+ return ()
37
+ if isinstance(value, str):
38
+ return (value,) if value else ()
39
+ if isinstance(value, Sequence):
40
+ return tuple(str(item) for item in value if item)
41
+ return (str(value),)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Leader:
46
+ """The current top provider for a benchmark."""
47
+
48
+ name: str
49
+ slug: str
50
+ value: float
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: Mapping[str, Any]) -> "Leader":
54
+ return cls(
55
+ name=str(data["name"]),
56
+ slug=str(data["slug"]),
57
+ value=float(data["value"]),
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Source:
63
+ """Editorial source attribution for a benchmark.
64
+
65
+ The API surfaces ``source`` either as an object (``{type,url,label}``)
66
+ or as a bare URL string for benches whose only attribution is a link.
67
+ Both shapes parse into this dataclass.
68
+ """
69
+
70
+ type: Optional[str] = None
71
+ url: Optional[str] = None
72
+ label: Optional[str] = None
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: Any) -> Optional["Source"]:
76
+ if data is None:
77
+ return None
78
+ if isinstance(data, str):
79
+ return cls(url=data)
80
+ if isinstance(data, Mapping):
81
+ return cls(
82
+ type=_opt_str(data.get("type")),
83
+ url=_opt_str(data.get("url")),
84
+ label=_opt_str(data.get("label")),
85
+ )
86
+ return None
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class BenchmarkSummary:
91
+ """One row from ``GET /api/citable``.
92
+
93
+ The summary is intentionally flat so it can be consumed without
94
+ follow-up calls. ``value`` and ``leader`` are ``None`` when the bench
95
+ is in the ``insufficient`` state (live spec, no usable sample yet).
96
+ """
97
+
98
+ slug: str
99
+ title: str
100
+ category: str
101
+ metric: str
102
+ unit: str
103
+ status: str
104
+ value: Optional[float]
105
+ leader: Optional[Leader]
106
+ sample_size: int
107
+ as_of: Optional[str]
108
+ headline: Optional[str]
109
+ url: str
110
+ api_url: str
111
+ og_image: Optional[str]
112
+ source: Optional[Source]
113
+ license: str
114
+
115
+ @classmethod
116
+ def from_dict(cls, data: Mapping[str, Any]) -> "BenchmarkSummary":
117
+ leader = data.get("leader")
118
+ return cls(
119
+ slug=str(data["slug"]),
120
+ title=str(data["title"]),
121
+ category=str(data["category"]),
122
+ metric=str(data["metric"]),
123
+ unit=str(data["unit"]),
124
+ status=str(data["status"]),
125
+ value=_opt_float(data.get("value")),
126
+ leader=Leader.from_dict(leader) if leader else None,
127
+ sample_size=int(data.get("sampleSize", 0) or 0),
128
+ as_of=_opt_str(data.get("asOf")),
129
+ headline=_opt_str(data.get("headline")),
130
+ url=str(data.get("url", "")),
131
+ api_url=str(data.get("api", "")),
132
+ og_image=_opt_str(data.get("ogImage")),
133
+ source=Source.from_dict(data.get("source")),
134
+ license=str(data.get("license", "CC-BY-4.0")),
135
+ )
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Latency:
140
+ """Percentile breakdown for a provider result. Fields can be ``None``
141
+ when the benchmark is in the ``insufficient`` state."""
142
+
143
+ p50: Optional[float]
144
+ p90: Optional[float]
145
+ p99: Optional[float]
146
+ mean: Optional[float]
147
+
148
+ @classmethod
149
+ def from_dict(cls, data: Mapping[str, Any]) -> "Latency":
150
+ return cls(
151
+ p50=_opt_float(data.get("p50")),
152
+ p90=_opt_float(data.get("p90")),
153
+ p99=_opt_float(data.get("p99")),
154
+ mean=_opt_float(data.get("mean")),
155
+ )
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ProviderResult:
160
+ """One ranked provider inside a benchmark."""
161
+
162
+ name: str
163
+ slug: str
164
+ type: Optional[str]
165
+ layer: Optional[str]
166
+ tag: Optional[str]
167
+ ms: Latency
168
+ success_rate: Optional[float]
169
+ sample_size: Optional[int]
170
+
171
+ @classmethod
172
+ def from_dict(cls, data: Mapping[str, Any]) -> "ProviderResult":
173
+ return cls(
174
+ name=str(data["name"]),
175
+ slug=str(data["slug"]),
176
+ type=_opt_str(data.get("type")),
177
+ layer=_opt_str(data.get("layer")),
178
+ tag=_opt_str(data.get("tag")),
179
+ ms=Latency.from_dict(data.get("ms") or {}),
180
+ success_rate=_opt_float(data.get("successRate")),
181
+ sample_size=_opt_int(data.get("sampleSize")),
182
+ )
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class Benchmark:
187
+ """Full payload returned by ``GET /api/stat/<slug>``."""
188
+
189
+ slug: str
190
+ title: str
191
+ subtitle: Optional[str]
192
+ category: str
193
+ metric: str
194
+ unit: str
195
+ status: str
196
+ higher_is_better: bool
197
+ value: Optional[float]
198
+ leader: Optional[Leader]
199
+ rankings: Sequence[ProviderResult]
200
+ sparkline: Sequence[float]
201
+ sample_size: int
202
+ as_of: Optional[str]
203
+ headline: Optional[str]
204
+ quote: Optional[str]
205
+ page_url: str
206
+ og_image: Optional[str]
207
+ source: Optional[Source]
208
+ methodology: Sequence[str]
209
+ license: str
210
+ best_per_chain: Optional[Mapping[str, Any]]
211
+ worst_per_chain: Optional[Mapping[str, Any]]
212
+
213
+ @classmethod
214
+ def from_dict(cls, data: Mapping[str, Any]) -> "Benchmark":
215
+ leader = data.get("leader")
216
+ rankings = [ProviderResult.from_dict(r) for r in data.get("rankings", []) or []]
217
+ sparkline = [float(v) for v in (data.get("sparkline") or []) if v is not None]
218
+ return cls(
219
+ slug=str(data["slug"]),
220
+ title=str(data["title"]),
221
+ subtitle=_opt_str(data.get("subtitle")),
222
+ category=str(data["category"]),
223
+ metric=str(data["metric"]),
224
+ unit=str(data["unit"]),
225
+ status=str(data["status"]),
226
+ higher_is_better=bool(data.get("higherIsBetter", False)),
227
+ value=_opt_float(data.get("value")),
228
+ leader=Leader.from_dict(leader) if leader else None,
229
+ rankings=tuple(rankings),
230
+ sparkline=tuple(sparkline),
231
+ sample_size=int(data.get("sampleSize", 0) or 0),
232
+ as_of=_opt_str(data.get("asOf")),
233
+ headline=_opt_str(data.get("headline")),
234
+ quote=_opt_str(data.get("quote")),
235
+ page_url=str(data.get("pageUrl", "")),
236
+ og_image=_opt_str(data.get("ogImage")),
237
+ source=Source.from_dict(data.get("source")),
238
+ methodology=_methodology(data.get("methodology")),
239
+ license=str(data.get("license", "CC-BY-4.0")),
240
+ best_per_chain=data.get("bestPerChain"),
241
+ worst_per_chain=data.get("worstPerChain"),
242
+ )
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class SeriesProvider:
247
+ """One provider trace inside a ``Series``."""
248
+
249
+ slug: str
250
+ name: str
251
+ color: str
252
+ values: Sequence[float]
253
+ logo: Optional[str] = None
254
+
255
+ @classmethod
256
+ def from_dict(cls, data: Mapping[str, Any]) -> "SeriesProvider":
257
+ values = [float(v) for v in (data.get("values") or []) if v is not None]
258
+ return cls(
259
+ slug=str(data["slug"]),
260
+ name=str(data["name"]),
261
+ color=str(data.get("color", "#7f7f7f")),
262
+ values=tuple(values),
263
+ logo=_opt_str(data.get("logo")),
264
+ )
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class Series:
269
+ """Payload returned by ``GET /api/series/<slug>``.
270
+
271
+ ``timestamps`` and each provider's ``values`` are index-aligned.
272
+ """
273
+
274
+ slug: str
275
+ title: str
276
+ metric: str
277
+ unit: str
278
+ higher_is_better: bool
279
+ range: str
280
+ timestamps: Sequence[int]
281
+ providers: Sequence[SeriesProvider] = field(default_factory=tuple)
282
+
283
+ @classmethod
284
+ def from_dict(cls, data: Mapping[str, Any]) -> "Series":
285
+ return cls(
286
+ slug=str(data["slug"]),
287
+ title=str(data["title"]),
288
+ metric=str(data["metric"]),
289
+ unit=str(data["unit"]),
290
+ higher_is_better=bool(data.get("higherIsBetter", False)),
291
+ range=str(data["range"]),
292
+ timestamps=tuple(int(t) for t in (data.get("timestamps") or [])),
293
+ providers=tuple(
294
+ SeriesProvider.from_dict(p) for p in (data.get("providers") or [])
295
+ ),
296
+ )
297
+
298
+
299
+ @dataclass(frozen=True)
300
+ class CitableIndex:
301
+ """Top-level payload returned by ``GET /api/citable``."""
302
+
303
+ site_name: str
304
+ site_url: str
305
+ license: str
306
+ count: int
307
+ benchmarks: Sequence[BenchmarkSummary]
308
+
309
+ @classmethod
310
+ def from_dict(cls, data: Mapping[str, Any]) -> "CitableIndex":
311
+ site = data.get("site") or {}
312
+ benches = [BenchmarkSummary.from_dict(b) for b in data.get("benchmarks", [])]
313
+ return cls(
314
+ site_name=str(site.get("name", "OpenChainBench")),
315
+ site_url=str(site.get("url", "https://openchainbench.com")),
316
+ license=str(site.get("license", "CC-BY-4.0")),
317
+ count=int(data.get("count", len(benches))),
318
+ benchmarks=tuple(benches),
319
+ )
File without changes
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: openchainbench
3
+ Version: 0.1.0
4
+ Summary: Python client for OpenChainBench live crypto infrastructure benchmarks
5
+ Project-URL: Homepage, https://openchainbench.com
6
+ Project-URL: Documentation, https://openchainbench.com/docs
7
+ Project-URL: Repository, https://github.com/ChainBench/OpenChainBench
8
+ Project-URL: Issues, https://github.com/ChainBench/OpenChainBench/issues
9
+ Project-URL: Changelog, https://github.com/ChainBench/OpenChainBench/releases
10
+ Author-email: OpenChainBench <hello@openchainbench.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: benchmark,blockchain,crypto,ethereum,infrastructure,openchainbench,rpc,solana
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Internet
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: httpx>=0.27
30
+ Provides-Extra: dev
31
+ Requires-Dist: build>=1.2; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
33
+ Requires-Dist: pytest>=8; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # openchainbench
37
+
38
+ Official Python client for [OpenChainBench](https://openchainbench.com), the live, reproducible benchmark suite for crypto infrastructure (RPC latency, bridge fees, perp venues, oracle deviation, and more).
39
+
40
+ [![PyPI version](https://img.shields.io/pypi/v/openchainbench.svg)](https://pypi.org/project/openchainbench/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/openchainbench.svg)](https://pypi.org/project/openchainbench/)
42
+ [![Downloads](https://img.shields.io/pypi/dm/openchainbench.svg)](https://pypi.org/project/openchainbench/)
43
+ [![License](https://img.shields.io/pypi/l/openchainbench.svg)](https://github.com/ChainBench/OpenChainBench/blob/main/LICENSE)
44
+
45
+ The data served by openchainbench.com is published under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). Attribute with a link back to the benchmark page (`pageUrl` on every payload).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install openchainbench
51
+ ```
52
+
53
+ Requires Python 3.10+.
54
+
55
+ ## Quick start
56
+
57
+ ```python
58
+ from openchainbench import OpenChainBench
59
+
60
+ with OpenChainBench() as ocb:
61
+ for bench in ocb.list_benchmarks():
62
+ if bench.leader:
63
+ print(f"{bench.title}: {bench.leader.name} -> {bench.value}")
64
+ ```
65
+
66
+ ### Fetch one benchmark
67
+
68
+ ```python
69
+ from openchainbench import OpenChainBench
70
+
71
+ with OpenChainBench() as ocb:
72
+ bench = ocb.get_benchmark("bridge-fee")
73
+ print(bench.headline)
74
+ for row in bench.rankings[:3]:
75
+ print(row.slug, row.ms.p50, row.success_rate)
76
+ ```
77
+
78
+ ### Fetch a time series
79
+
80
+ ```python
81
+ from openchainbench import OpenChainBench
82
+
83
+ with OpenChainBench() as ocb:
84
+ series = ocb.get_series("bridge-fee", range="24h")
85
+ for provider in series.providers:
86
+ print(provider.name, provider.values[-1])
87
+ ```
88
+
89
+ ### Filter by chain or region
90
+
91
+ ```python
92
+ bench = ocb.get_benchmark("network-fees", chain="ethereum")
93
+ series = ocb.get_series("network-fees", range="7d", chain="ethereum", region="eu-west")
94
+ ```
95
+
96
+ ## Error handling
97
+
98
+ The client maps HTTP responses to a typed exception hierarchy so callers
99
+ can react to intent rather than status codes.
100
+
101
+ ```python
102
+ from openchainbench import (
103
+ OpenChainBench,
104
+ NotFoundError,
105
+ RateLimitError,
106
+ APIUnavailableError,
107
+ )
108
+
109
+ with OpenChainBench() as ocb:
110
+ try:
111
+ ocb.get_benchmark("not-a-real-slug")
112
+ except NotFoundError:
113
+ ...
114
+ except RateLimitError as exc:
115
+ print(f"retry after {exc.retry_after_sec}s")
116
+ except APIUnavailableError:
117
+ # cold cache or Prom blackout, retry later
118
+ ...
119
+ ```
120
+
121
+ ## API reference
122
+
123
+ | Method | Endpoint | Returns |
124
+ |---|---|---|
125
+ | `list_benchmarks()` | `GET /api/citable` | `list[BenchmarkSummary]` |
126
+ | `fetch_citable_index()` | `GET /api/citable` | `CitableIndex` |
127
+ | `get_benchmark(slug, *, chain=None, region=None)` | `GET /api/stat/<slug>` | `Benchmark` |
128
+ | `get_series(slug, *, range="24h", chain=None, region=None, providers=None)` | `GET /api/series/<slug>` | `Series` |
129
+
130
+ All models are immutable dataclasses (`frozen=True`).
131
+
132
+ ## Rate limits
133
+
134
+ The public API allows 60 requests per minute per IP. The client surfaces
135
+ HTTP 429 as a `RateLimitError` with a `retry_after_sec` attribute.
136
+
137
+ ## Citation
138
+
139
+ If you use the data in a paper, post, or product, please link the
140
+ benchmark page. The license is CC-BY-4.0. Every payload includes a
141
+ ready-to-paste `quote` field that already contains the attribution.
142
+
143
+ ## Links
144
+
145
+ - Site: <https://openchainbench.com>
146
+ - Docs: <https://openchainbench.com/docs>
147
+ - Repository: <https://github.com/ChainBench/OpenChainBench>
148
+ - Issues: <https://github.com/ChainBench/OpenChainBench/issues>
149
+
150
+ ## License
151
+
152
+ MIT. The data fetched from the API stays under CC-BY-4.0; this client
153
+ license only covers the code.
@@ -0,0 +1,9 @@
1
+ openchainbench/__init__.py,sha256=dG4mTaHoFxXIIiVO_PiWzdJ0pFmyvvlh0Fpf45bEhx4,1095
2
+ openchainbench/client.py,sha256=uMaTshp0gJ5S2UfLoDg8yYx1KOOLUMSrpiP3WA2xatk,6469
3
+ openchainbench/exceptions.py,sha256=gk4eKiHtpsGnjFIV6avo6wKJkrKH7FP_doSRG63xFug,1352
4
+ openchainbench/models.py,sha256=1gGMVU1kkkxy499wm2oZhPP9Ck48peFUUZhPThWaywk,9757
5
+ openchainbench/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ openchainbench-0.1.0.dist-info/METADATA,sha256=GqArUpbdl1PvR-jB3eaPrE3OPaKVOlekTD4xdOIC9xs,5228
7
+ openchainbench-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ openchainbench-0.1.0.dist-info/licenses/LICENSE,sha256=lIreIjLnw7yX0656BVlZeGKovyOIa7A6LbFVY53Bx4c,1084
9
+ openchainbench-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 OpenChainBench contributors
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.