From 7d1704c30405c391ccc21d23bfc45c3a1061850a Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:05:10 +0200 Subject: [PATCH] feat(packages): python client (openchainbench) for PyPI Wraps /api/citable, /api/stat/, /api/series/ as a typed synchronous client with frozen dataclass models and a small exception hierarchy (NotFound, RateLimit, APIUnavailable). Ships: - src layout under packages/python-client/, hatchling backend - httpx-based OpenChainBench client, list/get/series methods - 15 tests: 12 mocked, 3 live integration (skip via OCB_SKIP_INTEGRATION=1) - .github/workflows/python-client-ci.yml for PR tests on 3.10 to 3.13 - .github/workflows/pypi-publish.yml triggered on python-v* tags, publishes via PyPI Trusted Publisher (OIDC, no token in repo) - PUBLISHING.md with the one-time PyPI Trusted Publisher setup steps --- .github/workflows/pypi-publish.yml | 76 +++++ .github/workflows/python-client-ci.yml | 37 ++ packages/python-client/.gitignore | 9 + packages/python-client/LICENSE | 21 ++ packages/python-client/PUBLISHING.md | 57 ++++ packages/python-client/README.md | 118 +++++++ packages/python-client/pyproject.toml | 74 ++++ .../src/openchainbench/__init__.py | 48 +++ .../src/openchainbench/client.py | 202 +++++++++++ .../src/openchainbench/exceptions.py | 41 +++ .../src/openchainbench/models.py | 319 ++++++++++++++++++ .../python-client/src/openchainbench/py.typed | 0 packages/python-client/tests/__init__.py | 0 .../python-client/tests/fixtures/citable.json | 46 +++ .../python-client/tests/fixtures/series.json | 24 ++ .../python-client/tests/fixtures/stat.json | 49 +++ packages/python-client/tests/test_client.py | 184 ++++++++++ packages/python-client/tests/test_models.py | 133 ++++++++ 18 files changed, 1438 insertions(+) create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 .github/workflows/python-client-ci.yml create mode 100644 packages/python-client/.gitignore create mode 100644 packages/python-client/LICENSE create mode 100644 packages/python-client/PUBLISHING.md create mode 100644 packages/python-client/README.md create mode 100644 packages/python-client/pyproject.toml create mode 100644 packages/python-client/src/openchainbench/__init__.py create mode 100644 packages/python-client/src/openchainbench/client.py create mode 100644 packages/python-client/src/openchainbench/exceptions.py create mode 100644 packages/python-client/src/openchainbench/models.py create mode 100644 packages/python-client/src/openchainbench/py.typed create mode 100644 packages/python-client/tests/__init__.py create mode 100644 packages/python-client/tests/fixtures/citable.json create mode 100644 packages/python-client/tests/fixtures/series.json create mode 100644 packages/python-client/tests/fixtures/stat.json create mode 100644 packages/python-client/tests/test_client.py create mode 100644 packages/python-client/tests/test_models.py diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 00000000..98c1083e --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,76 @@ +name: PyPI Publish + +on: + push: + tags: + - "python-v*" + workflow_dispatch: + inputs: + ref: + description: "Git ref to build (branch, tag or sha)" + required: false + default: "main" + +jobs: + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install build tooling + run: python -m pip install --upgrade pip build + - name: Build distributions + working-directory: packages/python-client + run: python -m build + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: python-dist + path: packages/python-client/dist/ + + smoke-test: + name: Install + import smoke test + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/download-artifact@v4 + with: + name: python-dist + path: dist + - name: Install built wheel + run: | + python -m pip install --upgrade pip + python -m pip install dist/openchainbench-*.whl + - name: Import smoke test + run: | + python -c "import openchainbench; print(openchainbench.__version__)" + + publish: + name: Publish to PyPI (Trusted Publisher) + needs: [build, smoke-test] + if: startsWith(github.ref, 'refs/tags/python-v') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/openchainbench/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-dist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/.github/workflows/python-client-ci.yml b/.github/workflows/python-client-ci.yml new file mode 100644 index 00000000..a73d58e9 --- /dev/null +++ b/.github/workflows/python-client-ci.yml @@ -0,0 +1,37 @@ +name: Python Client CI + +on: + pull_request: + paths: + - "packages/python-client/**" + - ".github/workflows/python-client-ci.yml" + push: + branches: [main, dev] + paths: + - "packages/python-client/**" + - ".github/workflows/python-client-ci.yml" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + env: + OCB_SKIP_INTEGRATION: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + dev deps + working-directory: packages/python-client + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Run unit tests + working-directory: packages/python-client + run: python -m pytest tests -m "not integration" + - name: Build sdist + wheel + working-directory: packages/python-client + run: python -m build diff --git a/packages/python-client/.gitignore b/packages/python-client/.gitignore new file mode 100644 index 00000000..b672ea8a --- /dev/null +++ b/packages/python-client/.gitignore @@ -0,0 +1,9 @@ +.venv/ +dist/ +build/ +*.egg-info/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ diff --git a/packages/python-client/LICENSE b/packages/python-client/LICENSE new file mode 100644 index 00000000..5b5390a5 --- /dev/null +++ b/packages/python-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenChainBench contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/python-client/PUBLISHING.md b/packages/python-client/PUBLISHING.md new file mode 100644 index 00000000..f5132d09 --- /dev/null +++ b/packages/python-client/PUBLISHING.md @@ -0,0 +1,57 @@ +# Publishing `openchainbench` to PyPI + +Releases are fully automated through GitHub Actions and PyPI Trusted +Publishers. No long-lived API token is stored anywhere. + +## One-time setup (PyPI Trusted Publisher) + +1. Create an account on https://pypi.org if you do not have one. +2. Reserve the project name by uploading the first release manually + **or** request the project via the PyPI account settings. We do the + pending publisher flow below so the very first release is automated. +3. Go to https://pypi.org/manage/account/publishing/ and click + **Add a new pending publisher**. +4. Fill in: + - PyPI project name: `openchainbench` + - Owner: `ChainBench` + - Repository name: `OpenChainBench` + - Workflow name: `pypi-publish.yml` + - Environment name: `pypi` +5. Save. PyPI will accept the first upload from the matching GitHub + workflow with no token. +6. In GitHub, go to **Settings > Environments** for the repo and create + the `pypi` environment. Add required reviewers if you want a manual + gate before publish. + +## Cutting a release + +1. Bump the version in two places: + - `packages/python-client/pyproject.toml` (`project.version`) + - `packages/python-client/src/openchainbench/__init__.py` (`__version__`) + - `packages/python-client/src/openchainbench/client.py` (`USER_AGENT`) +2. Commit the bump on `main`. +3. Tag and push: + ```bash + git tag python-v0.1.0 + git push origin python-v0.1.0 + ``` +4. The `PyPI Publish` workflow runs: build, smoke test on 3.10-3.13, + then publish via the Trusted Publisher OIDC flow. +5. Verify the release at https://pypi.org/project/openchainbench/. + +## Local smoke build + +```bash +cd packages/python-client +python -m pip install --upgrade build +python -m build +ls dist/ +``` + +This produces an `sdist` (`.tar.gz`) and a pure-Python `wheel` (`.whl`). +The Trusted Publisher workflow runs the exact same command in CI. + +## Tag namespace + +We deliberately use `python-v*` (not bare `v*`) so OCB site tags such as +`v1.0-dataset` never trigger a Python publish. diff --git a/packages/python-client/README.md b/packages/python-client/README.md new file mode 100644 index 00000000..19c6b356 --- /dev/null +++ b/packages/python-client/README.md @@ -0,0 +1,118 @@ +# openchainbench + +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). + +[![PyPI version](https://img.shields.io/pypi/v/openchainbench.svg)](https://pypi.org/project/openchainbench/) +[![Python versions](https://img.shields.io/pypi/pyversions/openchainbench.svg)](https://pypi.org/project/openchainbench/) +[![Downloads](https://img.shields.io/pypi/dm/openchainbench.svg)](https://pypi.org/project/openchainbench/) +[![License](https://img.shields.io/pypi/l/openchainbench.svg)](https://github.com/ChainBench/OpenChainBench/blob/main/LICENSE) + +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). + +## Install + +```bash +pip install openchainbench +``` + +Requires Python 3.10+. + +## Quick start + +```python +from openchainbench import OpenChainBench + +with OpenChainBench() as ocb: + for bench in ocb.list_benchmarks(): + if bench.leader: + print(f"{bench.title}: {bench.leader.name} -> {bench.value}") +``` + +### Fetch one benchmark + +```python +from openchainbench import OpenChainBench + +with OpenChainBench() as ocb: + bench = ocb.get_benchmark("bridge-fee") + print(bench.headline) + for row in bench.rankings[:3]: + print(row.slug, row.ms.p50, row.success_rate) +``` + +### Fetch a time series + +```python +from openchainbench import OpenChainBench + +with OpenChainBench() as ocb: + series = ocb.get_series("bridge-fee", range="24h") + for provider in series.providers: + print(provider.name, provider.values[-1]) +``` + +### Filter by chain or region + +```python +bench = ocb.get_benchmark("network-fees", chain="ethereum") +series = ocb.get_series("network-fees", range="7d", chain="ethereum", region="eu-west") +``` + +## Error handling + +The client maps HTTP responses to a typed exception hierarchy so callers +can react to intent rather than status codes. + +```python +from openchainbench import ( + OpenChainBench, + NotFoundError, + RateLimitError, + APIUnavailableError, +) + +with OpenChainBench() as ocb: + try: + ocb.get_benchmark("not-a-real-slug") + except NotFoundError: + ... + except RateLimitError as exc: + print(f"retry after {exc.retry_after_sec}s") + except APIUnavailableError: + # cold cache or Prom blackout, retry later + ... +``` + +## API reference + +| Method | Endpoint | Returns | +|---|---|---| +| `list_benchmarks()` | `GET /api/citable` | `list[BenchmarkSummary]` | +| `fetch_citable_index()` | `GET /api/citable` | `CitableIndex` | +| `get_benchmark(slug, *, chain=None, region=None)` | `GET /api/stat/` | `Benchmark` | +| `get_series(slug, *, range="24h", chain=None, region=None, providers=None)` | `GET /api/series/` | `Series` | + +All models are immutable dataclasses (`frozen=True`). + +## Rate limits + +The public API allows 60 requests per minute per IP. The client surfaces +HTTP 429 as a `RateLimitError` with a `retry_after_sec` attribute. + +## Citation + +If you use the data in a paper, post, or product, please link the +benchmark page. The license is CC-BY-4.0. Every payload includes a +ready-to-paste `quote` field that already contains the attribution. + +## Links + +- Site: +- Docs: +- Repository: +- Issues: + +## License + +MIT. The data fetched from the API stays under CC-BY-4.0; this client +license only covers the code. diff --git a/packages/python-client/pyproject.toml b/packages/python-client/pyproject.toml new file mode 100644 index 00000000..adc3cecc --- /dev/null +++ b/packages/python-client/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "openchainbench" +version = "0.1.0" +description = "Python client for OpenChainBench live crypto infrastructure benchmarks" +authors = [{ name = "OpenChainBench", email = "hello@openchainbench.com" }] +license = { text = "MIT" } +requires-python = ">=3.10" +readme = "README.md" +keywords = [ + "openchainbench", + "blockchain", + "benchmark", + "rpc", + "crypto", + "ethereum", + "solana", + "infrastructure", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "build>=1.2", +] + +[project.urls] +Homepage = "https://openchainbench.com" +Documentation = "https://openchainbench.com/docs" +Repository = "https://github.com/ChainBench/OpenChainBench" +Issues = "https://github.com/ChainBench/OpenChainBench/issues" +Changelog = "https://github.com/ChainBench/OpenChainBench/releases" + +[tool.hatch.build.targets.wheel] +packages = ["src/openchainbench"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/openchainbench", + "tests", + "README.md", + "LICENSE", + "pyproject.toml", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q" +markers = [ + "integration: hits the live openchainbench.com API (skipped when OCB_SKIP_INTEGRATION=1)", +] diff --git a/packages/python-client/src/openchainbench/__init__.py b/packages/python-client/src/openchainbench/__init__.py new file mode 100644 index 00000000..8d519c70 --- /dev/null +++ b/packages/python-client/src/openchainbench/__init__.py @@ -0,0 +1,48 @@ +"""Official Python client for OpenChainBench. + +OpenChainBench (https://openchainbench.com) publishes live, reproducible +benchmarks of crypto infrastructure: RPC latency, bridge fees, perp venue +performance, oracle deviation, and more. This package wraps the public, +CC-BY-4.0 licensed JSON API so that Python applications, notebooks, and +agents can cite live numbers without scraping HTML. +""" + +from .client import DEFAULT_BASE_URL, OpenChainBench +from .exceptions import ( + APIUnavailableError, + NotFoundError, + OpenChainBenchError, + RateLimitError, +) +from .models import ( + Benchmark, + BenchmarkSummary, + CitableIndex, + Latency, + Leader, + ProviderResult, + Series, + SeriesProvider, + Source, +) + +__version__ = "0.1.0" + +__all__ = [ + "DEFAULT_BASE_URL", + "OpenChainBench", + "OpenChainBenchError", + "NotFoundError", + "RateLimitError", + "APIUnavailableError", + "Benchmark", + "BenchmarkSummary", + "CitableIndex", + "Latency", + "Leader", + "ProviderResult", + "Series", + "SeriesProvider", + "Source", + "__version__", +] diff --git a/packages/python-client/src/openchainbench/client.py b/packages/python-client/src/openchainbench/client.py new file mode 100644 index 00000000..cceb05be --- /dev/null +++ b/packages/python-client/src/openchainbench/client.py @@ -0,0 +1,202 @@ +"""Synchronous HTTP client for the public OpenChainBench API. + +Wraps three endpoints: + +* ``GET /api/citable`` -> :meth:`OpenChainBench.list_benchmarks` +* ``GET /api/stat/`` -> :meth:`OpenChainBench.get_benchmark` +* ``GET /api/series/?range=...`` -> :meth:`OpenChainBench.get_series` + +The client keeps a long-lived ``httpx.Client`` and supports the context +manager protocol. Errors are mapped to a typed hierarchy in +:mod:`openchainbench.exceptions` so callers can ``except`` on intent rather +than HTTP status codes. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + +import httpx + +from .exceptions import ( + APIUnavailableError, + NotFoundError, + OpenChainBenchError, + RateLimitError, +) +from .models import Benchmark, BenchmarkSummary, CitableIndex, Series + +DEFAULT_BASE_URL = "https://openchainbench.com" +DEFAULT_TIMEOUT = 30.0 +USER_AGENT = "openchainbench-python/0.1.0" + +_SUPPORTED_RANGES = ("24h", "7d", "30d") + + +def _parse_retry_after(value: Optional[str]) -> Optional[int]: + if not value: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _raise_for_status(response: httpx.Response) -> None: + if response.status_code < 400: + return + + body: Any + try: + body = response.json() + except ValueError: + body = {} + + message = ( + body.get("error") + if isinstance(body, dict) and body.get("error") + else f"HTTP {response.status_code} from {response.request.url}" + ) + + if response.status_code == 404: + raise NotFoundError(str(message)) + if response.status_code == 429: + retry = _parse_retry_after(response.headers.get("retry-after")) + if retry is None and isinstance(body, dict): + retry = body.get("retryAfterSec") + raise RateLimitError(str(message), retry_after_sec=retry) + if response.status_code == 503: + retry = _parse_retry_after(response.headers.get("retry-after")) + if retry is None and isinstance(body, dict): + retry = body.get("retryAfterSec") + raise APIUnavailableError(str(message), retry_after_sec=retry) + + raise OpenChainBenchError(str(message), status_code=response.status_code) + + +class OpenChainBench: + """Synchronous client for openchainbench.com. + + Example: + >>> from openchainbench import OpenChainBench + >>> with OpenChainBench() as ocb: + ... for bench in ocb.list_benchmarks(): + ... print(bench.slug, bench.value) + """ + + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + *, + timeout: float = DEFAULT_TIMEOUT, + user_agent: str = USER_AGENT, + client: Optional[httpx.Client] = None, + ) -> None: + self._owns_client = client is None + self._client = client or httpx.Client( + base_url=base_url.rstrip("/"), + timeout=timeout, + headers={ + "User-Agent": user_agent, + "Accept": "application/json", + }, + ) + + def list_benchmarks(self) -> List[BenchmarkSummary]: + """Return every live benchmark with its current headline figure. + + Wraps the ``CitableIndex`` envelope and returns the list directly + for ergonomic iteration. Use :meth:`fetch_citable_index` if you + need the site metadata as well. + """ + return list(self.fetch_citable_index().benchmarks) + + def fetch_citable_index(self) -> CitableIndex: + """Return the full ``/api/citable`` payload including site metadata.""" + data = self._get("/api/citable") + return CitableIndex.from_dict(data) + + def get_benchmark( + self, + slug: str, + *, + chain: Optional[str] = None, + region: Optional[str] = None, + ) -> Benchmark: + """Return the full benchmark detail. + + Args: + slug: Benchmark slug, e.g. ``"bridge-fee"``. + chain: Optional chain filter (e.g. ``"ethereum"``). + region: Optional region filter (e.g. ``"eu-west"``). + + Raises: + NotFoundError: The slug does not exist or is not live. + """ + params: dict[str, str] = {} + if chain: + params["chain"] = chain + if region: + params["region"] = region + data = self._get(f"/api/stat/{slug}", params=params or None) + return Benchmark.from_dict(data) + + def get_series( + self, + slug: str, + *, + range: str = "24h", + chain: Optional[str] = None, + region: Optional[str] = None, + providers: Optional[List[str]] = None, + ) -> Series: + """Return the per-provider time series for a benchmark. + + Args: + slug: Benchmark slug. + range: One of ``"24h"``, ``"7d"``, ``"30d"``. + chain: Optional chain filter. + region: Optional region filter. + providers: Optional list of provider slugs to restrict the result to. + + Raises: + ValueError: ``range`` is not supported. + NotFoundError: The slug does not exist or has no data for ``range``. + """ + if range not in _SUPPORTED_RANGES: + raise ValueError( + f"range must be one of {_SUPPORTED_RANGES}, got {range!r}" + ) + params: dict[str, str] = {"range": range} + if chain: + params["chain"] = chain + if region: + params["region"] = region + if providers: + params["providers"] = ",".join(providers) + data = self._get(f"/api/series/{slug}", params=params) + return Series.from_dict(data) + + def close(self) -> None: + """Close the underlying HTTP client (if owned).""" + if self._owns_client: + self._client.close() + + def __enter__(self) -> "OpenChainBench": + return self + + def __exit__(self, *exc_info: Any) -> None: + self.close() + + def _get(self, path: str, *, params: Optional[dict[str, str]] = None) -> Any: + try: + response = self._client.get(path, params=params) + except httpx.HTTPError as exc: + raise OpenChainBenchError(f"HTTP request failed: {exc}") from exc + _raise_for_status(response) + try: + return response.json() + except ValueError as exc: + raise OpenChainBenchError( + f"Response was not valid JSON: {exc}" + ) from exc diff --git a/packages/python-client/src/openchainbench/exceptions.py b/packages/python-client/src/openchainbench/exceptions.py new file mode 100644 index 00000000..e7a2afdd --- /dev/null +++ b/packages/python-client/src/openchainbench/exceptions.py @@ -0,0 +1,41 @@ +"""Exception hierarchy for the OpenChainBench client.""" + +from __future__ import annotations + +from typing import Optional + + +class OpenChainBenchError(Exception): + """Base error for every failure surfaced by this client.""" + + def __init__(self, message: str, *, status_code: Optional[int] = None) -> None: + super().__init__(message) + self.status_code = status_code + + +class NotFoundError(OpenChainBenchError): + """Raised when a benchmark slug or range does not exist.""" + + def __init__(self, message: str) -> None: + super().__init__(message, status_code=404) + + +class RateLimitError(OpenChainBenchError): + """Raised when the API returns HTTP 429. + + Attributes: + retry_after_sec: Seconds the caller should wait before retrying, + parsed from the ``Retry-After`` header or the response body. + """ + + def __init__(self, message: str, *, retry_after_sec: Optional[int] = None) -> None: + super().__init__(message, status_code=429) + self.retry_after_sec = retry_after_sec + + +class APIUnavailableError(OpenChainBenchError): + """Raised when the API returns HTTP 503 (no live snapshot available).""" + + def __init__(self, message: str, *, retry_after_sec: Optional[int] = None) -> None: + super().__init__(message, status_code=503) + self.retry_after_sec = retry_after_sec diff --git a/packages/python-client/src/openchainbench/models.py b/packages/python-client/src/openchainbench/models.py new file mode 100644 index 00000000..31c9bc53 --- /dev/null +++ b/packages/python-client/src/openchainbench/models.py @@ -0,0 +1,319 @@ +"""Dataclass models that mirror the public OpenChainBench API payloads. + +Every model is ``frozen=True`` so instances are safe to share across threads +and cache. Parsing is permissive on unknown fields (the API may add new keys +without bumping a version), but strict on the fields the client documents. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional, Sequence + + +def _opt_float(value: Any) -> Optional[float]: + if value is None: + return None + return float(value) + + +def _opt_int(value: Any) -> Optional[int]: + if value is None: + return None + return int(value) + + +def _opt_str(value: Any) -> Optional[str]: + if value is None: + return None + return str(value) + + +def _methodology(value: Any) -> tuple[str, ...]: + """Methodology can arrive as a list of bullet strings or as a single + paragraph. Normalize to a tuple of non-empty strings either way.""" + if value is None: + return () + if isinstance(value, str): + return (value,) if value else () + if isinstance(value, Sequence): + return tuple(str(item) for item in value if item) + return (str(value),) + + +@dataclass(frozen=True) +class Leader: + """The current top provider for a benchmark.""" + + name: str + slug: str + value: float + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Leader": + return cls( + name=str(data["name"]), + slug=str(data["slug"]), + value=float(data["value"]), + ) + + +@dataclass(frozen=True) +class Source: + """Editorial source attribution for a benchmark. + + The API surfaces ``source`` either as an object (``{type,url,label}``) + or as a bare URL string for benches whose only attribution is a link. + Both shapes parse into this dataclass. + """ + + type: Optional[str] = None + url: Optional[str] = None + label: Optional[str] = None + + @classmethod + def from_dict(cls, data: Any) -> Optional["Source"]: + if data is None: + return None + if isinstance(data, str): + return cls(url=data) + if isinstance(data, Mapping): + return cls( + type=_opt_str(data.get("type")), + url=_opt_str(data.get("url")), + label=_opt_str(data.get("label")), + ) + return None + + +@dataclass(frozen=True) +class BenchmarkSummary: + """One row from ``GET /api/citable``. + + The summary is intentionally flat so it can be consumed without + follow-up calls. ``value`` and ``leader`` are ``None`` when the bench + is in the ``insufficient`` state (live spec, no usable sample yet). + """ + + slug: str + title: str + category: str + metric: str + unit: str + status: str + value: Optional[float] + leader: Optional[Leader] + sample_size: int + as_of: Optional[str] + headline: Optional[str] + url: str + api_url: str + og_image: Optional[str] + source: Optional[Source] + license: str + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "BenchmarkSummary": + leader = data.get("leader") + return cls( + slug=str(data["slug"]), + title=str(data["title"]), + category=str(data["category"]), + metric=str(data["metric"]), + unit=str(data["unit"]), + status=str(data["status"]), + value=_opt_float(data.get("value")), + leader=Leader.from_dict(leader) if leader else None, + sample_size=int(data.get("sampleSize", 0) or 0), + as_of=_opt_str(data.get("asOf")), + headline=_opt_str(data.get("headline")), + url=str(data.get("url", "")), + api_url=str(data.get("api", "")), + og_image=_opt_str(data.get("ogImage")), + source=Source.from_dict(data.get("source")), + license=str(data.get("license", "CC-BY-4.0")), + ) + + +@dataclass(frozen=True) +class Latency: + """Percentile breakdown for a provider result. Fields can be ``None`` + when the benchmark is in the ``insufficient`` state.""" + + p50: Optional[float] + p90: Optional[float] + p99: Optional[float] + mean: Optional[float] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Latency": + return cls( + p50=_opt_float(data.get("p50")), + p90=_opt_float(data.get("p90")), + p99=_opt_float(data.get("p99")), + mean=_opt_float(data.get("mean")), + ) + + +@dataclass(frozen=True) +class ProviderResult: + """One ranked provider inside a benchmark.""" + + name: str + slug: str + type: Optional[str] + layer: Optional[str] + tag: Optional[str] + ms: Latency + success_rate: Optional[float] + sample_size: Optional[int] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ProviderResult": + return cls( + name=str(data["name"]), + slug=str(data["slug"]), + type=_opt_str(data.get("type")), + layer=_opt_str(data.get("layer")), + tag=_opt_str(data.get("tag")), + ms=Latency.from_dict(data.get("ms") or {}), + success_rate=_opt_float(data.get("successRate")), + sample_size=_opt_int(data.get("sampleSize")), + ) + + +@dataclass(frozen=True) +class Benchmark: + """Full payload returned by ``GET /api/stat/``.""" + + slug: str + title: str + subtitle: Optional[str] + category: str + metric: str + unit: str + status: str + higher_is_better: bool + value: Optional[float] + leader: Optional[Leader] + rankings: Sequence[ProviderResult] + sparkline: Sequence[float] + sample_size: int + as_of: Optional[str] + headline: Optional[str] + quote: Optional[str] + page_url: str + og_image: Optional[str] + source: Optional[Source] + methodology: Sequence[str] + license: str + best_per_chain: Optional[Mapping[str, Any]] + worst_per_chain: Optional[Mapping[str, Any]] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Benchmark": + leader = data.get("leader") + rankings = [ProviderResult.from_dict(r) for r in data.get("rankings", []) or []] + sparkline = [float(v) for v in (data.get("sparkline") or []) if v is not None] + return cls( + slug=str(data["slug"]), + title=str(data["title"]), + subtitle=_opt_str(data.get("subtitle")), + category=str(data["category"]), + metric=str(data["metric"]), + unit=str(data["unit"]), + status=str(data["status"]), + higher_is_better=bool(data.get("higherIsBetter", False)), + value=_opt_float(data.get("value")), + leader=Leader.from_dict(leader) if leader else None, + rankings=tuple(rankings), + sparkline=tuple(sparkline), + sample_size=int(data.get("sampleSize", 0) or 0), + as_of=_opt_str(data.get("asOf")), + headline=_opt_str(data.get("headline")), + quote=_opt_str(data.get("quote")), + page_url=str(data.get("pageUrl", "")), + og_image=_opt_str(data.get("ogImage")), + source=Source.from_dict(data.get("source")), + methodology=_methodology(data.get("methodology")), + license=str(data.get("license", "CC-BY-4.0")), + best_per_chain=data.get("bestPerChain"), + worst_per_chain=data.get("worstPerChain"), + ) + + +@dataclass(frozen=True) +class SeriesProvider: + """One provider trace inside a ``Series``.""" + + slug: str + name: str + color: str + values: Sequence[float] + logo: Optional[str] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "SeriesProvider": + values = [float(v) for v in (data.get("values") or []) if v is not None] + return cls( + slug=str(data["slug"]), + name=str(data["name"]), + color=str(data.get("color", "#7f7f7f")), + values=tuple(values), + logo=_opt_str(data.get("logo")), + ) + + +@dataclass(frozen=True) +class Series: + """Payload returned by ``GET /api/series/``. + + ``timestamps`` and each provider's ``values`` are index-aligned. + """ + + slug: str + title: str + metric: str + unit: str + higher_is_better: bool + range: str + timestamps: Sequence[int] + providers: Sequence[SeriesProvider] = field(default_factory=tuple) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Series": + return cls( + slug=str(data["slug"]), + title=str(data["title"]), + metric=str(data["metric"]), + unit=str(data["unit"]), + higher_is_better=bool(data.get("higherIsBetter", False)), + range=str(data["range"]), + timestamps=tuple(int(t) for t in (data.get("timestamps") or [])), + providers=tuple( + SeriesProvider.from_dict(p) for p in (data.get("providers") or []) + ), + ) + + +@dataclass(frozen=True) +class CitableIndex: + """Top-level payload returned by ``GET /api/citable``.""" + + site_name: str + site_url: str + license: str + count: int + benchmarks: Sequence[BenchmarkSummary] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "CitableIndex": + site = data.get("site") or {} + benches = [BenchmarkSummary.from_dict(b) for b in data.get("benchmarks", [])] + return cls( + site_name=str(site.get("name", "OpenChainBench")), + site_url=str(site.get("url", "https://openchainbench.com")), + license=str(site.get("license", "CC-BY-4.0")), + count=int(data.get("count", len(benches))), + benchmarks=tuple(benches), + ) diff --git a/packages/python-client/src/openchainbench/py.typed b/packages/python-client/src/openchainbench/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/python-client/tests/__init__.py b/packages/python-client/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/python-client/tests/fixtures/citable.json b/packages/python-client/tests/fixtures/citable.json new file mode 100644 index 00000000..712b6f58 --- /dev/null +++ b/packages/python-client/tests/fixtures/citable.json @@ -0,0 +1,46 @@ +{ + "site": { + "name": "OpenChainBench", + "url": "https://openchainbench.com", + "license": "CC-BY-4.0" + }, + "count": 2, + "benchmarks": [ + { + "slug": "bridge-fee", + "title": "Cheapest cross-chain bridge for USDC at $300 notional", + "category": "Bridges", + "metric": "Effective fee", + "unit": "pct", + "status": "live", + "value": 0.00916, + "leader": { "name": "Near Intents", "slug": "near-intents", "value": 0.00916 }, + "sampleSize": 11520, + "asOf": "2026-06-22T10:00:00.000Z", + "headline": "Near Intents leads at 0.92 percent effective fee", + "url": "https://openchainbench.com/benchmarks/bridge-fee", + "api": "https://openchainbench.com/api/stat/bridge-fee", + "ogImage": "https://openchainbench.com/api/og/bridge-fee", + "source": { "type": "harness", "url": "https://github.com/ChainBench/OpenChainBench", "label": "OpenChainBench harness" }, + "license": "CC-BY-4.0" + }, + { + "slug": "metadata-coverage", + "title": "Token metadata coverage", + "category": "Data", + "metric": "Coverage", + "unit": "pct", + "status": "insufficient", + "value": null, + "leader": null, + "sampleSize": 0, + "asOf": null, + "headline": "Insufficient sample to declare a leader.", + "url": "https://openchainbench.com/benchmarks/metadata-coverage", + "api": "https://openchainbench.com/api/stat/metadata-coverage", + "ogImage": "https://openchainbench.com/api/og/metadata-coverage", + "source": null, + "license": "CC-BY-4.0" + } + ] +} diff --git a/packages/python-client/tests/fixtures/series.json b/packages/python-client/tests/fixtures/series.json new file mode 100644 index 00000000..9d5b847a --- /dev/null +++ b/packages/python-client/tests/fixtures/series.json @@ -0,0 +1,24 @@ +{ + "slug": "bridge-fee", + "title": "Cheapest cross-chain bridge for USDC at $300 notional", + "metric": "Effective fee", + "unit": "pct", + "higherIsBetter": false, + "range": "24h", + "timestamps": [1782392316676, 1782393500238, 1782394683799, 1782395867361], + "providers": [ + { + "slug": "mobula", + "name": "Mobula", + "color": "#FF6B35", + "logo": "https://openchainbench.com/logos/mobula.svg", + "values": [0.2747, 0.274, 0.2802, 0.3053] + }, + { + "slug": "near-intents", + "name": "Near Intents", + "color": "#000000", + "values": [0.009, 0.0092, 0.0091, 0.0091] + } + ] +} diff --git a/packages/python-client/tests/fixtures/stat.json b/packages/python-client/tests/fixtures/stat.json new file mode 100644 index 00000000..8bbf657e --- /dev/null +++ b/packages/python-client/tests/fixtures/stat.json @@ -0,0 +1,49 @@ +{ + "slug": "bridge-fee", + "title": "Cheapest cross-chain bridge for USDC at $300 notional", + "subtitle": "Total cost as a percent of notional.", + "category": "Bridges", + "metric": "Effective fee", + "unit": "pct", + "status": "live", + "higherIsBetter": false, + "value": 0.00916, + "leader": { "name": "Near Intents", "slug": "near-intents", "value": 0.00916 }, + "rankings": [ + { + "name": "Near Intents", + "slug": "near-intents", + "type": "intent", + "layer": null, + "tag": "Intent layer (NEAR)", + "ms": { "p50": 0.00916, "p90": 0.011, "p99": 0.011, "mean": 0.01006 }, + "successRate": 89.06, + "sampleSize": 11520 + }, + { + "name": "Mobula", + "slug": "mobula", + "type": "intent", + "layer": null, + "tag": "Aggregator + intent layer", + "ms": { "p50": 0.0126, "p90": 0.01313, "p99": 0.1222, "mean": 0.01626 }, + "successRate": 100.0, + "sampleSize": 28800 + } + ], + "sparkline": [0.0571, 0.0572, 0.0573, 0.0571], + "sampleSize": 11520, + "asOf": "2026-06-22T10:00:00.000Z", + "headline": "Near Intents leads at 0.92 percent effective fee", + "quote": "On openchainbench.com, Near Intents leads at 0.92 percent effective fee.", + "pageUrl": "https://openchainbench.com/benchmarks/bridge-fee", + "ogImage": "https://openchainbench.com/api/og/bridge-fee", + "source": { "type": "harness", "url": "https://github.com/ChainBench/OpenChainBench", "label": "OpenChainBench harness" }, + "methodology": [ + "Sampled at $300 USDC across Solana, Base and Arbitrum corridors.", + "Costs include fees, slippage, and destination gas." + ], + "license": "CC-BY-4.0", + "bestPerChain": null, + "worstPerChain": null +} diff --git a/packages/python-client/tests/test_client.py b/packages/python-client/tests/test_client.py new file mode 100644 index 00000000..92dd0f5f --- /dev/null +++ b/packages/python-client/tests/test_client.py @@ -0,0 +1,184 @@ +"""Client tests. + +The ``test_client_*`` cases mock the HTTP layer so they run offline. +The ``test_integration_*`` cases hit the live openchainbench.com API and +are skipped when the ``OCB_SKIP_INTEGRATION`` environment variable is set +(use this for offline CI or air-gapped builds). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import httpx +import pytest + +from openchainbench import ( + APIUnavailableError, + Benchmark, + BenchmarkSummary, + NotFoundError, + OpenChainBench, + OpenChainBenchError, + RateLimitError, + Series, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _fixture(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + + +def _client_with_handler(handler): + transport = httpx.MockTransport(handler) + inner = httpx.Client( + base_url="https://example.test", + transport=transport, + headers={"User-Agent": "openchainbench-python/test"}, + ) + return OpenChainBench(client=inner) + + +def test_list_benchmarks_returns_typed_summaries() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/citable" + return httpx.Response(200, json=_fixture("citable.json")) + + with _client_with_handler(handler) as ocb: + rows = ocb.list_benchmarks() + assert len(rows) == 2 + assert all(isinstance(r, BenchmarkSummary) for r in rows) + assert rows[0].slug == "bridge-fee" + + +def test_get_benchmark_forwards_filters() -> None: + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + captured["params"] = dict(request.url.params) + return httpx.Response(200, json=_fixture("stat.json")) + + with _client_with_handler(handler) as ocb: + bench = ocb.get_benchmark("bridge-fee", chain="ethereum", region="eu-west") + + assert isinstance(bench, Benchmark) + assert captured["path"] == "/api/stat/bridge-fee" + assert captured["params"] == {"chain": "ethereum", "region": "eu-west"} + + +def test_get_series_validates_range() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_fixture("series.json")) + + with _client_with_handler(handler) as ocb: + with pytest.raises(ValueError): + ocb.get_series("bridge-fee", range="bogus") + + +def test_get_series_forwards_provider_filter() -> None: + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["params"] = dict(request.url.params) + return httpx.Response(200, json=_fixture("series.json")) + + with _client_with_handler(handler) as ocb: + series = ocb.get_series( + "bridge-fee", range="24h", providers=["mobula", "near-intents"] + ) + + assert isinstance(series, Series) + assert captured["params"]["range"] == "24h" + assert captured["params"]["providers"] == "mobula,near-intents" + + +def test_not_found_raises_typed_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json={"error": "unknown_slug", "slug": "nope"}) + + with _client_with_handler(handler) as ocb: + with pytest.raises(NotFoundError): + ocb.get_benchmark("nope") + + +def test_rate_limit_surfaces_retry_after() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"error": "rate_limited", "retryAfterSec": 42}, + headers={"retry-after": "42"}, + ) + + with _client_with_handler(handler) as ocb: + with pytest.raises(RateLimitError) as exc: + ocb.list_benchmarks() + assert exc.value.retry_after_sec == 42 + + +def test_unavailable_503_raises_typed_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 503, + json={"error": "benchmarks_unavailable", "retryAfterSec": 60}, + headers={"retry-after": "60"}, + ) + + with _client_with_handler(handler) as ocb: + with pytest.raises(APIUnavailableError) as exc: + ocb.list_benchmarks() + assert exc.value.retry_after_sec == 60 + + +def test_generic_5xx_raises_base_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"error": "boom"}) + + with _client_with_handler(handler) as ocb: + with pytest.raises(OpenChainBenchError) as exc: + ocb.list_benchmarks() + assert exc.value.status_code == 500 + + +_SKIP_INTEGRATION = os.environ.get("OCB_SKIP_INTEGRATION") == "1" + + +@pytest.mark.integration +@pytest.mark.skipif(_SKIP_INTEGRATION, reason="OCB_SKIP_INTEGRATION=1") +def test_integration_list_benchmarks() -> None: + with OpenChainBench() as ocb: + index = ocb.fetch_citable_index() + assert index.site_url == "https://openchainbench.com" + assert index.license == "CC-BY-4.0" + # count may be 0 on a cold cache (503 is also acceptable, see next test). + assert index.count >= 0 + + +@pytest.mark.integration +@pytest.mark.skipif(_SKIP_INTEGRATION, reason="OCB_SKIP_INTEGRATION=1") +def test_integration_get_benchmark_known_slug() -> None: + with OpenChainBench() as ocb: + try: + bench = ocb.get_benchmark("bridge-fee") + except (NotFoundError, APIUnavailableError) as exc: + pytest.skip(f"upstream not serving bridge-fee right now: {exc}") + assert bench.slug == "bridge-fee" + assert bench.license == "CC-BY-4.0" + assert bench.unit # non-empty + + +@pytest.mark.integration +@pytest.mark.skipif(_SKIP_INTEGRATION, reason="OCB_SKIP_INTEGRATION=1") +def test_integration_get_series_known_slug() -> None: + with OpenChainBench() as ocb: + try: + series = ocb.get_series("bridge-fee", range="24h") + except (NotFoundError, APIUnavailableError) as exc: + pytest.skip(f"upstream not serving bridge-fee series: {exc}") + assert series.range == "24h" + assert len(series.timestamps) > 0 + assert len(series.providers) > 0 diff --git a/packages/python-client/tests/test_models.py b/packages/python-client/tests/test_models.py new file mode 100644 index 00000000..8af441e9 --- /dev/null +++ b/packages/python-client/tests/test_models.py @@ -0,0 +1,133 @@ +"""Unit tests for the dataclass parsers. + +The fixtures under ``tests/fixtures/`` are hand-crafted minimal payloads +that mirror the public API shape. They are intentionally small so the +tests stay fast and so a shape change in the API forces a deliberate +fixture update. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from openchainbench.models import ( + Benchmark, + CitableIndex, + Series, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + + +def test_citable_index_parses() -> None: + index = CitableIndex.from_dict(_load("citable.json")) + assert index.site_name == "OpenChainBench" + assert index.site_url == "https://openchainbench.com" + assert index.count == 2 + assert len(index.benchmarks) == 2 + + live = index.benchmarks[0] + assert live.slug == "bridge-fee" + assert live.status == "live" + assert live.leader is not None + assert live.leader.slug == "near-intents" + assert live.value == pytest.approx(0.00916) + assert live.source is not None + assert live.source.type == "harness" + assert live.license == "CC-BY-4.0" + + insufficient = index.benchmarks[1] + assert insufficient.status == "insufficient" + assert insufficient.value is None + assert insufficient.leader is None + assert insufficient.source is None + + +def test_benchmark_parses() -> None: + bench = Benchmark.from_dict(_load("stat.json")) + assert bench.slug == "bridge-fee" + assert bench.higher_is_better is False + assert bench.value == pytest.approx(0.00916) + assert bench.leader is not None + assert bench.leader.name == "Near Intents" + assert len(bench.rankings) == 2 + + top = bench.rankings[0] + assert top.slug == "near-intents" + assert top.ms.p50 == pytest.approx(0.00916) + assert top.ms.p99 == pytest.approx(0.011) + assert top.sample_size == 11520 + assert top.success_rate == pytest.approx(89.06) + + assert len(bench.sparkline) == 4 + assert bench.methodology + assert any("USDC" in line for line in bench.methodology) + + +def test_series_parses() -> None: + series = Series.from_dict(_load("series.json")) + assert series.slug == "bridge-fee" + assert series.range == "24h" + assert len(series.timestamps) == 4 + assert len(series.providers) == 2 + + mobula = series.providers[0] + assert mobula.slug == "mobula" + assert mobula.logo and mobula.logo.startswith("https://") + assert len(mobula.values) == len(series.timestamps) + + near = series.providers[1] + assert near.logo is None + assert near.color == "#000000" + + +def test_benchmark_handles_insufficient_payload() -> None: + payload = { + "slug": "metadata-coverage", + "title": "Token metadata coverage", + "subtitle": None, + "category": "Data", + "metric": "Coverage", + "unit": "pct", + "status": "insufficient", + "higherIsBetter": True, + "value": None, + "leader": None, + "rankings": [ + { + "name": "Provider A", + "slug": "provider-a", + "type": None, + "layer": None, + "tag": None, + "ms": {"p50": None, "p90": None, "p99": None, "mean": None}, + "successRate": None, + "sampleSize": None, + } + ], + "sparkline": [], + "sampleSize": 0, + "asOf": None, + "headline": "Insufficient sample.", + "quote": None, + "pageUrl": "https://openchainbench.com/benchmarks/metadata-coverage", + "ogImage": None, + "source": None, + "methodology": None, + "license": "CC-BY-4.0", + "bestPerChain": None, + "worstPerChain": None, + } + bench = Benchmark.from_dict(payload) + assert bench.value is None + assert bench.leader is None + assert bench.sparkline == () + assert bench.rankings[0].ms.p50 is None + assert bench.rankings[0].sample_size is None