diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 9b078fb..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,16 +0,0 @@ -# Copilot Instructions - -## Project Overview -- Python 3.12+ wrapper around the Sportradar DataCore REST API (Handball). -- Public API lives in `src/sportradar_datacore_api/` and is centered on `HandballAPI`. -- The generated OpenAPI client lives in `src/_vendor/datacore_client/` (mirrors `build/`). - -## Key Rules -- Do not edit anything in `src/_vendor/` or `build/` by hand. Regenerate via `scripts/codegen.sh` or `scripts/codegen.ps1`. -- Keep helpers in `HandballAPI` high-level and typed; prefer using generated models. -- Use `.env` or environment variables for credentials (see README for names). -- Tests are run with `pytest` (tests live under `test/`). - -## Development Notes -- Prefer `uv` for local installs: `uv pip install -e ".[dev]"`. -- When adding functionality, update README examples or notes if the public surface changes. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..faf8e3c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# AI Coding Agent Instructions + +Guidance for AI assistants (Claude Code, GitHub Copilot, Cursor, etc.) working in this repository. + +## Project Overview + +Python 3.12+ wrapper around the Sportradar DataCore REST API (Handball). + +- Public API: `src/sportradar_datacore_api/` — centered on `HandballAPI` +- Generated OpenAPI client: `src/_vendor/datacore_client/` (mirrors `build/`) +- Full architecture: [docs/architecture.md](docs/architecture.md) + +## Hard Rules + +- **Never edit `src/_vendor/` or `build/` by hand.** These are fully generated. Regenerate via `scripts/codegen.sh` (Linux/Mac) or `scripts/codegen.ps1` (Windows). +- **Never commit `.env` or credential files.** Credentials are managed through environment variables only. See [README.md](README.md#configuration) for required variable names. +- **Never add `# type: ignore` or `Any` casts in `src/sportradar_datacore_api/`.** Maintain full type safety in hand-written code. Vendor code is excluded from mypy via config. + +## Code Style + +- Target Python 3.12+. Use built-in generics (`list[X]`, `dict[K, V]`, `X | Y`) — no `from __future__ import annotations`. +- Formatter and linter: `ruff` (line length 88, config in `pyproject.toml`). Run `ruff check --fix` and `ruff format` before committing. +- Static typing: `mypy --strict` (applied to `src/sportradar_datacore_api/` only). +- Pre-commit hooks enforce all of the above automatically. Install with `pre-commit install`. + +## Adding Functionality + +1. Add high-level helpers to `HandballAPI` in `src/sportradar_datacore_api/handball.py`. +2. Use generated models from `datacore_client.models` for all typed returns. +3. Raise typed errors from `src/sportradar_datacore_api/errors.py` — never raise bare `Exception`. +4. Write tests in `test/` using `pytest`. Tests use live API calls and are skipped when env vars are absent. +5. Update [README.md](README.md) examples if the public surface changes. + +## Testing + +```bash +pytest +``` + +Live API calls require env vars. Tests are skipped automatically when credentials are not set. Do not mock the HTTP transport layer — tests validate real API contract behaviour. + +## Code Generation + +When the upstream OpenAPI spec changes, regenerate the vendor client: + +```bash +# Linux / Mac +./scripts/codegen.sh + +# Windows (PowerShell) +./scripts/codegen.ps1 +``` + +The generator fetches the spec from the Sportradar developer portal, runs `openapi-python-client`, and moves the output into `src/_vendor/datacore_client/`. + +## Repository Layout + +``` +src/ + sportradar_datacore_api/ # Hand-written public API (edit here) + api.py # DataCoreAPI base: OAuth2 auth, token refresh + handball.py # HandballAPI: high-level helpers + errors.py # Typed exception hierarchy + __init__.py + _vendor/ + datacore_client/ # Generated OpenAPI client (do not edit) +scripts/ + codegen.sh # Client regeneration (Linux/Mac) + codegen.ps1 # Client regeneration (Windows) +openapi/ + config.yaml # openapi-python-client generator config +test/ + test_handball.py # Integration tests (live API) +docs/ # Extended documentation +``` + +## Further Reading + +- [docs/architecture.md](docs/architecture.md) — design decisions, layering, auth flow +- [docs/api-reference.md](docs/api-reference.md) — `HandballAPI` public method reference +- [docs/development.md](docs/development.md) — setup, CI, releasing +- [docs/errors.md](docs/errors.md) — exception hierarchy diff --git a/README.md b/README.md index 86bb28e..39d25eb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A Python wrapper for the Sportradar DataCore REST API (Handball). +The package also includes a separate client for the DataCore Streaming API, keeping the REST and MQTT/WebSocket surfaces isolated. + This library simplifies interaction with the Sportradar API by handling OpenID Connect (OIDC) authentication automatically and providing a fully typed interface for all API endpoints. ## Features @@ -36,6 +38,12 @@ If you prefer an editable install instead of syncing a lockfile: uv pip install -e "." ``` +To enable the streaming client as well: + +```bash +uv pip install -e ".[stream]" +``` + ## Configuration The library uses **pydantic** and **python-dotenv** to manage configuration. You can provide credentials via a `.env` file in your project root or via environment variables. @@ -48,8 +56,13 @@ AUTH_URL=https://token.connect.sportradar.com/v1/oauth2/rest/token CLIENT_ID=your_client_id CLIENT_SECRET=your_client_secret CLIENT_ORGANIZATION_ID=your_org_id +STREAM_TOKEN_BASE_URL=https://token.connect.sportradar.com/v1 +STREAM_FIXTURE_ID=your_fixture_id +STREAM_VENUE_ID=your_venue_id ``` +`STREAM_TOKEN_BASE_URL` is the token service base for the streaming API. If you already have `AUTH_URL` set to `/oauth2/rest/token`, the streaming client can derive the base URL from it automatically. + ## Usage ### Basic Example @@ -114,11 +127,87 @@ if response.status_code == 200: print(data.data[0].name_local) ``` + ## Streaming API + + The streaming API is intentionally exposed via separate classes so the REST and MQTT clients stay independent. + + ### Connect To A Fixture Stream + + ```python + import os + + from sportradar_datacore_api.streaming import HandballStreamingAPI + + + def handle_message(message) -> None: + print(message.topic) + print(message.message_type) + print(message.payload) + + + stream_api = HandballStreamingAPI( + client_id=os.getenv("CLIENT_ID", ""), + client_secret=os.getenv("CLIENT_SECRET", ""), + sport="handball", + token_base_url=os.getenv("STREAM_TOKEN_BASE_URL"), + auth_url=os.getenv("AUTH_URL"), + ) + + client = stream_api.create_fixture_stream( + fixture_id=os.getenv("STREAM_FIXTURE_ID", ""), + scopes=[ + "read:stream_events", + "read:stream_status", + "read:stream_statistics", + "read:stream_play_by_play", + "read:stream_persons", + ], + on_message=handle_message, + ) + + with client: + input("Press Enter to stop listening...\n") + ``` + + ### Publish To A Granted Topic + + ```python + stream_api = HandballStreamingAPI( + client_id=os.getenv("CLIENT_ID", ""), + client_secret=os.getenv("CLIENT_SECRET", ""), + sport="handball", + token_base_url=os.getenv("STREAM_TOKEN_BASE_URL"), + ) + + client = stream_api.create_fixture_stream( + fixture_id=os.getenv("STREAM_FIXTURE_ID", ""), + scopes=["write:stream_events", "read:response"], + ) + + event_message = { + "type": "event", + "fixtureId": os.getenv("STREAM_FIXTURE_ID", ""), + "clientType": "MyApp:1.0.0", + "data": { + "eventId": "11111111-1111-1111-1111-111111111111", + "class": "heartbeat", + "eventType": "client", + }, + } + + with client: + result = client.publish_to_scope("write:stream_events", event_message) + print(result) + ``` + + Messages containing `compressedData` are decoded automatically and exposed as `decodedCompressedData` in the parsed payload. + ## Architecture This project uses a **Wrapper Pattern** around a generated OpenAPI client. - **`src/sportradar_datacore_api/`**: The public-facing code. Contains the `HandballAPI` class, authentication logic, and user-friendly helpers. +- **`src/sportradar_datacore_api/streaming.py`**: Separate streaming access and MQTT client. - **`src/_vendor/datacore_client/`**: The low-level client code generated from the Sportradar OpenAPI specification. - *Note*: This directory allows us to ship the generated code without external dependencies or versioning conflicts. - **Do not edit files in `_vendor` manually.** They are overwritten during code generation. @@ -132,8 +221,7 @@ This project uses a **Wrapper Pattern** around a generated OpenAPI client. ## AI Assistance -If you are using GitHub Copilot in this repo, see the project-specific guidance in -`.github/copilot-instructions.md`. +AI coding assistants should read [AGENTS.md](AGENTS.md) for project-specific guidance and rules. ## Development diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..3ff9496 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,316 @@ +# API Reference + +Public methods on `HandballAPI`. Import path: + +```python +from sportradar_datacore_api.handball import HandballAPI +``` + +Streaming is exposed separately: + +```python +from sportradar_datacore_api.streaming import HandballStreamingAPI +``` + +## Constructor + +```python +HandballAPI( + base_url: str, + auth_url: str, + client_id: str, + client_secret: str, + sport: str, + org_id: str | None = None, + scopes: list[str] | None = None, # default: ["read:organization"] + timeout: int = 5, + connect_on_init: bool = True, +) +``` + +Authenticates immediately on construction (unless `connect_on_init=False`). Tokens are refreshed automatically on expiry. + +### Context Manager + +```python +with HandballAPI(...) as api: + ... +# session is closed on exit +``` + +--- + +## Competition & Season Resolution + +### `get_competition_id_by_name` + +```python +api.get_competition_id_by_name( + competition_name: str, + *, + limit: int = 50, + offset: int | None = None, +) -> str +``` + +Resolves a competition UUID by human-readable name. Performs a case-insensitive exact match on `name_local`. Raises `NotFoundError` if no match is found. + +### `get_season_id_by_year` + +```python +api.get_season_id_by_year( + competition_id: str | UUID, + season_year: int, + *, + limit: int = 200, + offset: int | None = None, +) -> str +``` + +Resolves a season UUID by competition ID and year integer. Raises `NotFoundError` if no matching season exists. + +--- + +## Teams + +### `get_teams_by_season_id` + +```python +api.get_teams_by_season_id( + season_id: str | UUID, + *, + limit: int = 100, + offset: int | None = None, +) -> list[SeasonEntitiesModel] +``` + +Returns all teams registered for the given season. + +### `get_team_by_id` + +```python +api.get_team_by_id(entity_id: str | UUID) -> TeamModel +``` + +Returns full team metadata for a given entity UUID. Raises `NotFoundError` if the entity does not exist. + +--- + +## Players + +### `get_players_by_season_id` + +```python +api.get_players_by_season_id( + season_id: str | UUID, + *, + limit: int = 200, + offset: int | None = None, +) -> list[SeasonPersonsModel] +``` + +Returns all players registered for the given season. + +### `get_players_by_ids` + +```python +api.get_players_by_ids( + person_ids: str | Sequence[str | UUID], + *, + limit: int = 500, + offset: int | None = None, +) -> list[PersonModel] +``` + +Returns full player metadata for one or more person UUIDs. Accepts a comma-separated string, a list of strings, or a list of UUIDs. + +### `list_players_by_match` + +```python +api.list_players_by_match( + match_id: str | UUID, + *, + limit: int = 200, + offset: int | None = None, +) -> list[MatchPersonsModel] +``` + +Returns all players associated with a specific match. + +--- + +## Matches + +### `list_matches_by_season` + +```python +api.list_matches_by_season( + season_id: str | UUID, + *, + limit: int = 500, + offset: int | None = None, +) -> list[MatchModel] +``` + +Returns all matches for the given season. + +### `get_match_by_id` + +```python +api.get_match_by_id(match_id: str | UUID) -> MatchModel +``` + +Returns full match metadata for a given fixture UUID. Raises `NotFoundError` if not found. + +### `get_match_events` + +```python +api.get_match_events( + match_id: str | UUID, + setup_only: bool, + with_scores: bool, +) -> list[dict[str, Any]] +``` + +Returns play-by-play events for a match as a list of plain dicts. Set `setup_only=False` to include all events. Set `with_scores=True` to include running score in each event. + +--- + +## Low-Level Access + +For endpoints not covered by the helpers above, use `api.client` directly: + +```python +from datacore_client.api.competitions import competition_list + +response = competition_list.sync_detailed( + client=api.client, + organization_id=api.org_id, +) +``` + +`api.client` is an `AuthenticatedClient` instance. Tokens are refreshed automatically before each call via `_ensure_client()`. + +--- + +## Streaming API + +### `HandballStreamingAPI` + +```python +HandballStreamingAPI( + *, + client_id: str, + client_secret: str, + sport: str, + token_base_url: str | None = None, + auth_url: str | None = None, + timeout: int = 5, +) +``` + +Separate client for requesting streaming access grants and creating MQTT/WebSocket clients. + +### `get_fixture_access` + +```python +stream_api.get_fixture_access( + fixture_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, +) -> StreamAccessGrant +``` + +Retrieves a signed websocket URL, client identifier, and granted topics for a fixture stream. + +### `get_venue_access` + +```python +stream_api.get_venue_access( + venue_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, +) -> StreamAccessGrant +``` + +Retrieves a signed websocket URL, client identifier, and granted topics for a venue stream. + +### `get_specific_fixture_access` + +```python +stream_api.get_specific_fixture_access( + topics: Sequence[str], + scopes: Sequence[str], +) -> StreamAccessGrant +``` + +Requests access to specific fixture topics rather than the broader fixture grant. + +### `create_fixture_stream` + +```python +stream_api.create_fixture_stream( + fixture_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, + keepalive: int = 15, + auto_subscribe: bool = True, + include_catchup: bool = True, + include_response: bool = True, + on_message: Callable[[StreamMessage], None] | None = None, +) -> HandballStreamClient +``` + +Returns a configured MQTT/WebSocket client for fixture streams. + +### `create_venue_stream` + +```python +stream_api.create_venue_stream( + venue_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, + keepalive: int = 15, + auto_subscribe: bool = True, + include_catchup: bool = True, + include_response: bool = True, + on_message: Callable[[StreamMessage], None] | None = None, +) -> HandballStreamClient +``` + +Returns a configured MQTT/WebSocket client for venue streams. + +### `HandballStreamClient` + +Key operations: + +```python +client.connect() +client.loop_start() +client.subscribe_granted_topics() +client.publish_to_scope("write:stream_events", payload) +client.stop() +``` + +Incoming messages are exposed as `StreamMessage` objects. If the payload contains `compressedData`, it is decoded automatically and attached as `decodedCompressedData` to the parsed payload. + +--- + +## Return Model Packages + +All return types come from `src/_vendor/datacore_client/models`. Key models: + +| Model | Used by | +|---|---| +| `CompetitionModel` | `get_competition_id_by_name` (internal) | +| `SeasonModel` | `get_season_id_by_year` (internal) | +| `SeasonEntitiesModel` | `get_teams_by_season_id` | +| `SeasonPersonsModel` | `get_players_by_season_id` | +| `TeamModel` | `get_team_by_id` | +| `PersonModel` | `get_players_by_ids` | +| `MatchPersonsModel` | `list_players_by_match` | +| `MatchModel` | `list_matches_by_season`, `get_match_by_id` | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ab71183 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,123 @@ +# Architecture + +## Overview + +This project uses a **wrapper pattern** around a generated OpenAPI client. The goal is to expose a clean, fully-typed, high-level API while retaining full access to every raw endpoint when needed. + +``` +Your code + | + v +HandballAPI (src/sportradar_datacore_api/handball.py) + | + v +DataCoreAPI (src/sportradar_datacore_api/api.py) + | OAuth2 token management, httpx session + v +AuthenticatedClient (src/_vendor/datacore_client/) + | Generated from OpenAPI spec + v +Sportradar DataCore REST API +``` + +The streaming API is intentionally separate and uses a different transport and access flow: + +``` +Your code + | + v +HandballStreamingAPI (src/sportradar_datacore_api/streaming.py) + | + v +token.connect API (/stream/fixture/access, /stream/venue/access, /stream/fixture/specific) + | + v +HandballStreamClient (paho-mqtt over WebSocket/MQTT) + | + v +Sportradar DataCore Streaming API +``` + +## Layers + +### `DataCoreAPI` — Base Authentication Layer + +`src/sportradar_datacore_api/api.py` + +Responsible for: +- OAuth2 token acquisition via `POST` to the Sportradar token endpoint +- Thread-safe token caching with a 60-second expiry buffer (`_TOKEN_BUFFER`) +- Transparent token refresh on expiry +- Constructing and updating the `AuthenticatedClient` from the vendor package +- Providing shared utility methods (`_as_uuid`, `_as_offset`, `_unwrap_response`, etc.) + +The class is a context manager (`__enter__` / `__exit__`) and supports manual lifecycle via `connect()` and `close()`. + +### `HandballAPI` — High-Level Helper Layer + +`src/sportradar_datacore_api/handball.py` + +Subclass of `DataCoreAPI`. Provides domain-oriented methods that: +- Accept human-friendly arguments (names, years) and resolve them to UUIDs +- Call the generated `datacore_client` endpoints via `sync_detailed` +- Unwrap paginated responses and return typed lists of generated model objects +- Raise descriptive typed errors instead of raw HTTP status codes + +### `datacore_client` — Generated Vendor Client + +`src/_vendor/datacore_client/` + +Fully generated by [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client) from the official Sportradar handball OpenAPI spec. + +- **Do not edit manually.** Regenerate with `scripts/codegen.sh` / `scripts/codegen.ps1`. +- Provides `AuthenticatedClient`, all endpoint functions (`sync_detailed`), and all response models. +- Excluded from mypy and ruff checks via `pyproject.toml`. +- Vendored into `src/_vendor/` to ship without a separate package dependency. + +## Authentication Flow + +``` +HandballAPI.__init__ + -> connect() [if connect_on_init=True] + -> _ensure_client() + -> _ensure_token() + -> _authenticate() + POST auth_url {credentialId, credentialSecret, sport, scopes} + <- {data: {token, expires_in}} + -> AuthenticatedClient(base_url, token) +``` + +On subsequent calls: +- `_ensure_token()` checks `time.time() >= _expires_at` +- If stale, re-authenticates and updates `client.token` in place +- Protected by `threading.RLock` for thread safety + +## Error Handling + +All errors derive from `APIError` (see [errors.md](errors.md)). The wrapper never surfaces raw `httpx` exceptions beyond `TransportError` — callers only deal with the typed hierarchy. + +## Accessing Raw Endpoints + +For endpoints not covered by `HandballAPI` helpers, use `api.client` directly: + +```python +from datacore_client.api.competitions import competition_list + +response = competition_list.sync_detailed( + client=api.client, + organization_id=api.org_id, +) +``` + +`api.client` is an `AuthenticatedClient` instance with a valid, auto-refreshed token. + +## Code Generation + +The vendor client is generated from the handball OpenAPI spec hosted at the Sportradar developer portal. The `scripts/codegen.sh` script: + +1. Downloads the spec JSON (if not already cached in `openapi/`) +2. Runs `openapi-python-client generate` with `openapi/config.yaml` +3. Moves the `datacore_client` package into `src/_vendor/` +4. Removes the temporary generator output directory + +The generator config (`openapi/config.yaml`) controls package naming and output structure. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..9df5ec0 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,132 @@ +# Development Guide + +## Prerequisites + +- Python 3.12+ +- [`uv`](https://github.com/astral-sh/uv) (recommended) or `pip` + +## Setup + +```bash +git clone https://github.com/mad4ms/SportradarDatacoreAPI.git +cd SportradarDatacoreAPI + +# Install all dependencies including dev tools +uv pip install -e ".[dev]" + +# Install pre-commit hooks +pre-commit install +``` + +Create a `.env` file for credentials (see [README.md](../README.md#configuration)): + +```env +BASE_URL=https://api.dc.connect.sportradar.com/v1 +AUTH_URL=https://token.connect.sportradar.com/v1/oauth2/rest/token +CLIENT_ID=your_client_id +CLIENT_SECRET=your_client_secret +CLIENT_ORGANIZATION_ID=your_org_id +``` + +## Running Tests + +```bash +pytest +``` + +Tests use live API calls. They are skipped automatically when credentials are not set in the environment. Do not mock the HTTP transport — tests validate real API contract behaviour. + +Run with coverage: + +```bash +pytest --cov=sportradar_datacore_api +``` + +## Linting & Formatting + +```bash +# Lint and auto-fix +ruff check --fix src test + +# Format +ruff format src test + +# Type check +mypy src/sportradar_datacore_api +``` + +Pre-commit hooks run all of the above automatically on `git commit`. + +## Pre-commit Hooks + +The `.pre-commit-config.yaml` enforces: +- Trailing whitespace / end-of-file fixers +- YAML validity check +- Merge conflict marker detection +- `ruff` lint + format + import sort (excluding `src/_vendor/`) +- `mypy` static type checking (excluding `src/_vendor/`) +- `nbstripout` for Jupyter notebooks +- `pytest` on every commit + +## Code Generation + +When the upstream Sportradar OpenAPI spec changes, regenerate the vendor client: + +```bash +# Linux / Mac +./scripts/codegen.sh + +# Windows (PowerShell) +./scripts/codegen.ps1 +``` + +The script: +1. Downloads `handball_rest.json` from the Sportradar developer portal (cached in `openapi/`) +2. Runs `openapi-python-client generate` using `openapi/config.yaml` +3. Moves the generated `datacore_client` package into `src/_vendor/` +4. Cleans up the temporary output directory + +Never edit `src/_vendor/` manually — changes are overwritten on the next codegen run. + +## CI Pipeline + +GitHub Actions runs on every push and pull request to `main`: + +| Job | Steps | +|---|---| +| `lint-and-test` | `ruff check`, `mypy`, `pytest` — matrix: Python 3.12 + 3.13 | +| `build` | `uv build` (sdist + wheel), uploads `dist/` artifacts | + +Configuration: `.github/workflows/ci.yml` + +## Releasing + +See [RELEASING.md](../RELEASING.md) for the full release process. Summary: + +1. Bump `version` in `pyproject.toml` +2. Ensure `main` is green in CI +3. Push a `vX.Y.Z` tag — the `release.yml` workflow handles the rest (build, PyPI publish, GitHub Release, SBOM, provenance attestation) + +## Project Structure + +``` +src/sportradar_datacore_api/ + api.py # Base auth layer (DataCoreAPI) + handball.py # High-level helpers (HandballAPI) + errors.py # Typed exception hierarchy + __init__.py + +src/_vendor/datacore_client/ # Generated — do not edit +scripts/ + codegen.sh # Client regeneration (Linux/Mac) + codegen.ps1 # Client regeneration (Windows) +openapi/ + config.yaml # openapi-python-client config +test/ + test_handball.py +docs/ + architecture.md + api-reference.md + development.md (this file) + errors.md +``` diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..ca20e9d --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,126 @@ +# Error Reference + +All exceptions raised by the wrapper derive from `APIError`. + +``` +APIError +├── AuthenticationError +├── DependencyError +├── TransportError +├── UnexpectedResponseError +├── NotFoundError +├── StreamingError +└── ValidationError +``` + +Import path: + +```python +from sportradar_datacore_api.errors import ( + APIError, + AuthenticationError, + DependencyError, + TransportError, + UnexpectedResponseError, + NotFoundError, + StreamingError, + ValidationError, +) +``` + +--- + +## `APIError` + +Base class for all errors raised by this library. Catch this to handle any wrapper error generically. + +--- + +## `AuthenticationError` + +Raised when the OAuth2 token endpoint returns a response missing the `token` field. This indicates a credentials or configuration problem (wrong `CLIENT_ID`, `CLIENT_SECRET`, or `AUTH_URL`). + +```python +try: + api = HandballAPI(...) +except AuthenticationError as e: + print(f"Check credentials: {e}") +``` + +--- + +## `TransportError` + +Raised when the underlying HTTP request fails entirely (network error, timeout, DNS failure). Wraps `httpx.HTTPError`. + +--- + +## `DependencyError` + +Raised when an optional dependency required for a feature is not installed. The streaming client uses this when `paho-mqtt` is unavailable. + +--- + +## `UnexpectedResponseError` + +Raised when the API returns an HTTP 200 but the response body is: +- Not valid JSON +- A JSON shape the wrapper does not recognise +- A non-success envelope from the API + +Also raised by `_unwrap_response` when the parsed type does not match the expected model. + +--- + +## `NotFoundError` + +Raised when a lookup succeeds (HTTP 200) but the requested entity is absent in the response data. Examples: + +- `get_competition_id_by_name` — no competition matches the given name +- `get_season_id_by_year` — no season with the given year exists under the competition +- `get_match_by_id` — fixture ID not found in response + +--- + +## `ValidationError` + +Raised for invalid caller-supplied arguments before any network request is made. Examples: + +- Empty or blank string passed where an ID is required +- A string that is not a valid UUID passed where a UUID is expected +- `org_id` is `None` when a method requires it + +--- + +## `StreamingError` + +Raised for streaming-specific transport or protocol issues that are not plain HTTP failures. + +--- + +## Example: Full Error Handling + +```python +from sportradar_datacore_api.errors import ( + AuthenticationError, + NotFoundError, + TransportError, + UnexpectedResponseError, + ValidationError, +) + +try: + comp_id = api.get_competition_id_by_name("1. Handball-Bundesliga") + season_id = api.get_season_id_by_year(comp_id, 2024) + matches = api.list_matches_by_season(season_id) +except ValidationError as e: + print(f"Bad argument: {e}") +except AuthenticationError as e: + print(f"Auth failed: {e}") +except NotFoundError as e: + print(f"Not found: {e}") +except TransportError as e: + print(f"Network error: {e}") +except UnexpectedResponseError as e: + print(f"Unexpected API response: {e}") +``` diff --git a/pyproject.toml b/pyproject.toml index 370b30a..3c281f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,10 @@ dependencies = [ ] [project.optional-dependencies] +stream = [ + "paho-mqtt>=2.1", +] + dev = [ "openapi-python-client>=0.26", "openapi-spec-validator>=0.7", diff --git a/src/sportradar_datacore_api/__init__.py b/src/sportradar_datacore_api/__init__.py index 63abbb3..c1d6c90 100644 --- a/src/sportradar_datacore_api/__init__.py +++ b/src/sportradar_datacore_api/__init__.py @@ -18,3 +18,22 @@ _vendored = None else: sys.modules.setdefault("datacore_client", _vendored) + +from sportradar_datacore_api.handball import HandballAPI +from sportradar_datacore_api.stream_models import ( + StreamAccessGrant, + StreamMessage, + StreamPublishResult, + StreamTopicGrant, +) +from sportradar_datacore_api.streaming import HandballStreamClient, HandballStreamingAPI + +__all__ = [ + "HandballAPI", + "HandballStreamClient", + "HandballStreamingAPI", + "StreamAccessGrant", + "StreamMessage", + "StreamPublishResult", + "StreamTopicGrant", +] diff --git a/src/sportradar_datacore_api/errors.py b/src/sportradar_datacore_api/errors.py index 8b2d254..b2e0a0d 100644 --- a/src/sportradar_datacore_api/errors.py +++ b/src/sportradar_datacore_api/errors.py @@ -23,3 +23,11 @@ class NotFoundError(APIError): class ValidationError(APIError): """Invalid user input or parameters.""" + + +class StreamingError(APIError): + """Streaming transport or protocol errors.""" + + +class DependencyError(APIError): + """Optional dependency required for a feature is not installed.""" diff --git a/src/sportradar_datacore_api/stream_models.py b/src/sportradar_datacore_api/stream_models.py new file mode 100644 index 0000000..6628f4f --- /dev/null +++ b/src/sportradar_datacore_api/stream_models.py @@ -0,0 +1,87 @@ +"""Typed models used by the DataCore streaming API.""" + +from dataclasses import dataclass + +from sportradar_datacore_api.errors import NotFoundError + + +@dataclass(frozen=True, slots=True) +class StreamTopicGrant: + """A single topic permission returned by the streaming access API.""" + + topic: str + scope: str + permission: str + + @property + def is_read(self) -> bool: + return self.permission == "read" + + @property + def is_write(self) -> bool: + return self.permission == "write" + + @property + def is_catchup(self) -> bool: + return "catchup" in self.scope.casefold() + + @property + def is_response(self) -> bool: + return self.scope == "read:response" or self.topic.endswith("/response") + + +@dataclass(frozen=True, slots=True) +class StreamAccessGrant: + """Connection details for a streaming session.""" + + url: str + client_id: str + topics: tuple[StreamTopicGrant, ...] + + def read_topics( + self, + *, + include_catchup: bool = True, + include_response: bool = True, + ) -> list[str]: + selected: list[str] = [] + for topic in self.topics: + if not topic.is_read: + continue + if not include_catchup and topic.is_catchup: + continue + if not include_response and topic.is_response: + continue + selected.append(topic.topic) + return selected + + def write_topics(self) -> list[str]: + return [topic.topic for topic in self.topics if topic.is_write] + + def topic_for_scope(self, scope: str, *, permission: str | None = None) -> str: + for topic in self.topics: + if topic.scope != scope: + continue + if permission is not None and topic.permission != permission: + continue + return topic.topic + raise NotFoundError(f"No topic grant found for scope '{scope}'.") + + +@dataclass(frozen=True, slots=True) +class StreamMessage: + """A decoded MQTT message from the streaming API.""" + + topic: str + raw_payload: bytes + payload: object + message_type: str | None + decoded_compressed_data: object | None = None + + +@dataclass(frozen=True, slots=True) +class StreamPublishResult: + """Result returned by MQTT publish operations.""" + + mid: int | None + rc: int | None diff --git a/src/sportradar_datacore_api/stream_utils.py b/src/sportradar_datacore_api/stream_utils.py new file mode 100644 index 0000000..77e8b55 --- /dev/null +++ b/src/sportradar_datacore_api/stream_utils.py @@ -0,0 +1,67 @@ +"""Helpers for decoding DataCore streaming payloads.""" + +import base64 +import json +import zlib + +from sportradar_datacore_api.errors import UnexpectedResponseError +from sportradar_datacore_api.stream_models import StreamMessage + + +def decode_compressed_data(encoded_data: str) -> object: + """Decode a base64-encoded zlib-compressed JSON payload.""" + try: + compressed_data = base64.b64decode(encoded_data.encode("utf-8")) + except ValueError as exc: + raise UnexpectedResponseError("Invalid base64 compressedData payload.") from exc + + try: + decoded_text = zlib.decompress(compressed_data).decode("utf-8") + except (zlib.error, UnicodeDecodeError) as exc: + raise UnexpectedResponseError("Invalid zlib compressedData payload.") from exc + + try: + return json.loads(decoded_text) + except json.JSONDecodeError as exc: + raise UnexpectedResponseError( + "Decoded compressedData is not valid JSON." + ) from exc + + +def decode_stream_payload(payload: bytes | str) -> object: + """Decode an incoming MQTT payload into JSON when possible.""" + text = payload.decode("utf-8") if isinstance(payload, bytes) else payload + try: + return json.loads(text) + except json.JSONDecodeError: + return text + + +def decode_stream_message(topic: str, payload: bytes | str) -> StreamMessage: + """Decode an incoming MQTT message into a structured message object.""" + raw_payload = payload if isinstance(payload, bytes) else payload.encode("utf-8") + decoded_payload = decode_stream_payload(payload) + + message_type: str | None = None + decoded_compressed_data: object | None = None + + if isinstance(decoded_payload, dict): + message_type_value = decoded_payload.get("type") + if isinstance(message_type_value, str): + message_type = message_type_value + + compressed_data = decoded_payload.get("compressedData") + if isinstance(compressed_data, str): + decoded_compressed_data = decode_compressed_data(compressed_data) + decoded_payload = { + **decoded_payload, + "decodedCompressedData": decoded_compressed_data, + } + + return StreamMessage( + topic=topic, + raw_payload=raw_payload, + payload=decoded_payload, + message_type=message_type, + decoded_compressed_data=decoded_compressed_data, + ) diff --git a/src/sportradar_datacore_api/streaming.py b/src/sportradar_datacore_api/streaming.py new file mode 100644 index 0000000..a06b31d --- /dev/null +++ b/src/sportradar_datacore_api/streaming.py @@ -0,0 +1,562 @@ +"""Separate client for the Sportradar DataCore streaming API.""" + +import json +from collections.abc import Callable, Sequence +from importlib import import_module +from types import TracebackType +from typing import Protocol +from urllib.parse import urlparse + +import httpx + +from sportradar_datacore_api.errors import ( + DependencyError, + TransportError, + UnexpectedResponseError, + ValidationError, +) +from sportradar_datacore_api.stream_models import ( + StreamAccessGrant, + StreamMessage, + StreamPublishResult, + StreamTopicGrant, +) +from sportradar_datacore_api.stream_utils import decode_stream_message + + +def _load_mqtt_module() -> object | None: + try: + return import_module("paho.mqtt.client") + except ModuleNotFoundError: + return None + + +mqtt = _load_mqtt_module() + + +StreamMessageHandler = Callable[[StreamMessage], None] +StreamConnectHandler = Callable[[], None] +StreamDisconnectHandler = Callable[[int], None] + + +class MQTTPublishInfoProtocol(Protocol): + """Minimal publish result surface used by the wrapper.""" + + mid: int | None + rc: int | None + + +class MQTTClientProtocol(Protocol): + """Minimal MQTT client surface used by the wrapper.""" + + on_connect: Callable[..., None] | None + on_disconnect: Callable[..., None] | None + on_message: Callable[..., None] | None + + def tls_set(self) -> None: ... + + def ws_set_options(self, *, path: str, headers: dict[str, str]) -> None: ... + + def connect(self, host: str, port: int, keepalive: int) -> None: ... + + def disconnect(self) -> None: ... + + def loop_start(self) -> None: ... + + def loop_stop(self) -> None: ... + + def subscribe(self, topic: str, qos: int = 0) -> object: ... + + def publish( + self, + topic: str, + payload: bytes | str, + qos: int = 0, + retain: bool = False, + ) -> MQTTPublishInfoProtocol: ... + + +class HandballStreamClient: + """Websocket MQTT client for the DataCore streaming API.""" + + def __init__( # noqa: PLR0913 + self, + access_grant: StreamAccessGrant, + *, + keepalive: int = 15, + auto_subscribe: bool = True, + include_catchup: bool = True, + include_response: bool = True, + on_message: StreamMessageHandler | None = None, + on_connect: StreamConnectHandler | None = None, + on_disconnect: StreamDisconnectHandler | None = None, + ) -> None: + self.access_grant = access_grant + self.keepalive = keepalive + self.auto_subscribe = auto_subscribe + self.include_catchup = include_catchup + self.include_response = include_response + self._on_message_handler = on_message + self._on_connect_handler = on_connect + self._on_disconnect_handler = on_disconnect + + self._client: MQTTClientProtocol = self._build_client() + self._configure_websocket_options() + self._client.on_connect = self._handle_connect + self._client.on_disconnect = self._handle_disconnect + self._client.on_message = self._handle_message + self._loop_started = False + + def _build_client(self) -> MQTTClientProtocol: + if mqtt is None: + raise DependencyError( + "Streaming support requires paho-mqtt. Install the 'stream' extra." + ) + + client_factory = getattr(mqtt, "Client", None) + if client_factory is None: + raise DependencyError("Installed paho-mqtt package is missing Client.") + + callback_api_version = getattr(mqtt, "CallbackAPIVersion", None) + if callback_api_version is None: + client = client_factory( + client_id=self.access_grant.client_id, + transport="websockets", + ) + else: + version2 = getattr(callback_api_version, "VERSION2", None) + if version2 is None: + client = client_factory( + client_id=self.access_grant.client_id, + transport="websockets", + ) + else: + client = client_factory( + version2, + client_id=self.access_grant.client_id, + transport="websockets", + ) + + client.tls_set() + return client + + def _configure_websocket_options(self) -> None: + parsed_url = urlparse(self.access_grant.url) + host = parsed_url.hostname + if not host: + raise ValidationError("Streaming access URL must include a hostname.") + + request_path = parsed_url.path or "/mqtt" + if parsed_url.query: + request_path = f"{request_path}?{parsed_url.query}" + + headers = {"host": host, "Host": host} + self._client.ws_set_options(path=request_path, headers=headers) + + @property + def host(self) -> str: + parsed_url = urlparse(self.access_grant.url) + host = parsed_url.hostname + if not host: + raise ValidationError("Streaming access URL must include a hostname.") + return host + + @property + def port(self) -> int: + parsed_url = urlparse(self.access_grant.url) + return parsed_url.port or 443 + + def connect(self) -> None: + self._client.connect(self.host, self.port, self.keepalive) + + def disconnect(self) -> None: + self._client.disconnect() + + def loop_start(self) -> None: + if not self._loop_started: + self._client.loop_start() + self._loop_started = True + + def loop_stop(self) -> None: + if self._loop_started: + self._client.loop_stop() + self._loop_started = False + + def start(self) -> "HandballStreamClient": + self.connect() + self.loop_start() + return self + + def stop(self) -> None: + self.loop_stop() + self.disconnect() + + def subscribe(self, topics: Sequence[str], *, qos: int = 0) -> list[str]: + subscribed: list[str] = [] + for topic in topics: + self._client.subscribe(topic, qos=qos) + subscribed.append(topic) + return subscribed + + def subscribe_granted_topics(self, *, qos: int = 0) -> list[str]: + topics = self.access_grant.read_topics( + include_catchup=self.include_catchup, + include_response=self.include_response, + ) + return self.subscribe(topics, qos=qos) + + def publish( + self, + topic: str, + payload: bytes | str | dict[str, object] | list[object], + *, + qos: int = 0, + retain: bool = False, + ) -> StreamPublishResult: + message_payload: bytes | str + if isinstance(payload, dict | list): + message_payload = json.dumps(payload) + else: + message_payload = payload + + info = self._client.publish( + topic, + payload=message_payload, + qos=qos, + retain=retain, + ) + mid = getattr(info, "mid", None) + rc = getattr(info, "rc", None) + return StreamPublishResult(mid=mid, rc=rc) + + def publish_to_scope( + self, + scope: str, + payload: bytes | str | dict[str, object] | list[object], + *, + qos: int = 0, + retain: bool = False, + permission: str = "write", + ) -> StreamPublishResult: + topic = self.access_grant.topic_for_scope(scope, permission=permission) + return self.publish(topic, payload, qos=qos, retain=retain) + + def set_on_message(self, handler: StreamMessageHandler | None) -> None: + self._on_message_handler = handler + + def set_on_connect(self, handler: StreamConnectHandler | None) -> None: + self._on_connect_handler = handler + + def set_on_disconnect(self, handler: StreamDisconnectHandler | None) -> None: + self._on_disconnect_handler = handler + + def _handle_connect( + self, + client: object, + userdata: object, + flags: object, + reason_code: object, + properties: object = None, + ) -> None: + del client, userdata, flags, reason_code, properties + if self.auto_subscribe: + self.subscribe_granted_topics() + if self._on_connect_handler is not None: + self._on_connect_handler() + + def _handle_disconnect( + self, + client: object, + userdata: object, + disconnect_flags: object, + reason_code: object, + properties: object = None, + ) -> None: + del client, userdata, disconnect_flags, properties + if self._on_disconnect_handler is not None: + self._on_disconnect_handler( + int(reason_code) if isinstance(reason_code, int) else 0 + ) + + def _handle_message( + self, + client: object, + userdata: object, + message: object, + ) -> None: + del client, userdata + topic = getattr(message, "topic", None) + payload = getattr(message, "payload", None) + if not isinstance(topic, str) or not isinstance(payload, bytes): + raise UnexpectedResponseError("Invalid MQTT message received.") + + decoded_message = decode_stream_message(topic, payload) + if self._on_message_handler is not None: + self._on_message_handler(decoded_message) + + def __enter__(self) -> "HandballStreamClient": + return self.start() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type, exc, traceback + self.stop() + + +class HandballStreamingAPI: + """Separate access client for the DataCore streaming API.""" + + def __init__( # noqa: PLR0913 + self, + *, + client_id: str, + client_secret: str, + sport: str, + token_base_url: str | None = None, + auth_url: str | None = None, + timeout: int = 5, + session: httpx.Client | None = None, + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.sport = sport + self.timeout = timeout + + self.token_base_url = self._resolve_token_base_url( + token_base_url=token_base_url, + auth_url=auth_url, + ) + + self.session = session or httpx.Client( + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + } + ) + self._owns_session = session is None + + @staticmethod + def _resolve_token_base_url( + *, token_base_url: str | None, auth_url: str | None + ) -> str: + if token_base_url: + return token_base_url.rstrip("/") + if not auth_url: + raise ValidationError("token_base_url or auth_url must be provided.") + + normalized_auth_url = auth_url.rstrip("/") + suffix = "/oauth2/rest/token" + if normalized_auth_url.endswith(suffix): + return normalized_auth_url.removesuffix(suffix) + return normalized_auth_url + + def _post_stream_access( + self, + path: str, + payload: dict[str, object], + ) -> StreamAccessGrant: + url = f"{self.token_base_url}{path}" + try: + response = self.session.post(url, json=payload, timeout=self.timeout) + response.raise_for_status() + except httpx.HTTPError as exc: + raise TransportError(f"Streaming access request failed: {exc}") from exc + + try: + body = response.json() + except ValueError as exc: + raise UnexpectedResponseError( + "Streaming access response is not valid JSON." + ) from exc + + data = body.get("data") + if not isinstance(data, dict): + raise UnexpectedResponseError("Streaming access response is missing data.") + + url_value = data.get("url") + client_id_value = data.get("clientId") + topics_value = data.get("topics", []) + + if not isinstance(url_value, str) or not isinstance(client_id_value, str): + raise UnexpectedResponseError( + "Streaming access response is missing url or clientId." + ) + if not isinstance(topics_value, list): + raise UnexpectedResponseError( + "Streaming access response topics field must be a list." + ) + + topics: list[StreamTopicGrant] = [] + for topic_entry in topics_value: + if not isinstance(topic_entry, dict): + raise UnexpectedResponseError( + "Streaming access response contains an invalid topic entry." + ) + + topic_name = topic_entry.get("topic") + scope = topic_entry.get("scope") + permission = topic_entry.get("permission") + if ( + not isinstance(topic_name, str) + or not isinstance(scope, str) + or not isinstance(permission, str) + ): + raise UnexpectedResponseError( + "Streaming access response contains an incomplete topic entry." + ) + + topics.append( + StreamTopicGrant( + topic=topic_name, + scope=scope, + permission=permission, + ) + ) + + return StreamAccessGrant( + url=url_value, + client_id=client_id_value, + topics=tuple(topics), + ) + + def get_fixture_access( + self, + fixture_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, + ) -> StreamAccessGrant: + if not fixture_id: + raise ValidationError("fixture_id must be provided.") + if not scopes: + raise ValidationError("At least one stream scope must be provided.") + + payload: dict[str, object] = { + "credentialId": self.client_id, + "credentialSecret": self.client_secret, + "fixtureId": fixture_id, + "sport": self.sport, + "scopes": list(scopes), + "includePort": include_port, + } + return self._post_stream_access("/stream/fixture/access", payload) + + def get_venue_access( + self, + venue_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, + ) -> StreamAccessGrant: + if not venue_id: + raise ValidationError("venue_id must be provided.") + if not scopes: + raise ValidationError("At least one stream scope must be provided.") + + payload: dict[str, object] = { + "credentialId": self.client_id, + "credentialSecret": self.client_secret, + "venueId": venue_id, + "sport": self.sport, + "scopes": list(scopes), + "includePort": include_port, + } + return self._post_stream_access("/stream/venue/access", payload) + + def get_specific_fixture_access( + self, + topics: Sequence[str], + scopes: Sequence[str], + ) -> StreamAccessGrant: + if not topics: + raise ValidationError("At least one topic must be provided.") + if not scopes: + raise ValidationError("At least one stream scope must be provided.") + + payload: dict[str, object] = { + "credentialId": self.client_id, + "credentialSecret": self.client_secret, + "topics": list(topics), + "scopes": list(scopes), + } + return self._post_stream_access("/stream/fixture/specific", payload) + + def create_fixture_stream( # noqa: PLR0913 + self, + fixture_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, + keepalive: int = 15, + auto_subscribe: bool = True, + include_catchup: bool = True, + include_response: bool = True, + on_message: StreamMessageHandler | None = None, + on_connect: StreamConnectHandler | None = None, + on_disconnect: StreamDisconnectHandler | None = None, + ) -> HandballStreamClient: + access_grant = self.get_fixture_access( + fixture_id, + scopes, + include_port=include_port, + ) + return HandballStreamClient( + access_grant, + keepalive=keepalive, + auto_subscribe=auto_subscribe, + include_catchup=include_catchup, + include_response=include_response, + on_message=on_message, + on_connect=on_connect, + on_disconnect=on_disconnect, + ) + + def create_venue_stream( # noqa: PLR0913 + self, + venue_id: str, + scopes: Sequence[str], + *, + include_port: bool = False, + keepalive: int = 15, + auto_subscribe: bool = True, + include_catchup: bool = True, + include_response: bool = True, + on_message: StreamMessageHandler | None = None, + on_connect: StreamConnectHandler | None = None, + on_disconnect: StreamDisconnectHandler | None = None, + ) -> HandballStreamClient: + access_grant = self.get_venue_access( + venue_id, + scopes, + include_port=include_port, + ) + return HandballStreamClient( + access_grant, + keepalive=keepalive, + auto_subscribe=auto_subscribe, + include_catchup=include_catchup, + include_response=include_response, + on_message=on_message, + on_connect=on_connect, + on_disconnect=on_disconnect, + ) + + def close(self) -> None: + if self._owns_session: + self.session.close() + + def __enter__(self) -> "HandballStreamingAPI": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type, exc, traceback + self.close() diff --git a/test/test_streaming.py b/test/test_streaming.py new file mode 100644 index 0000000..47f5d3a --- /dev/null +++ b/test/test_streaming.py @@ -0,0 +1,199 @@ +"""Tests for the separate DataCore streaming client.""" + +import os +import types + +import pytest +from dotenv import load_dotenv + +from sportradar_datacore_api.errors import ValidationError +from sportradar_datacore_api.stream_models import StreamAccessGrant, StreamTopicGrant +from sportradar_datacore_api.stream_utils import ( + decode_compressed_data, + decode_stream_message, +) +from sportradar_datacore_api.streaming import HandballStreamClient, HandballStreamingAPI + +DEFAULT_KEEPALIVE = 15 +DEFAULT_TLS_PORT = 443 + +COMPRESSED_SAMPLE = "eJyrVkrLz1eyUkpKLFKqBQAdegQ0" + + +def test_decode_compressed_data_roundtrip() -> None: + decoded = decode_compressed_data(COMPRESSED_SAMPLE) + assert decoded == {"foo": "bar"} + + +def test_decode_stream_message_includes_decoded_compressed_data() -> None: + payload = '{"type":"statistics","compressedData":"' + COMPRESSED_SAMPLE + '"}' + + message = decode_stream_message("topic/one", payload) + + assert message.topic == "topic/one" + assert message.message_type == "statistics" + assert message.decoded_compressed_data == {"foo": "bar"} + assert isinstance(message.payload, dict) + assert message.payload["decodedCompressedData"] == {"foo": "bar"} + + +def test_stream_access_read_topics_filter_catchup_and_response() -> None: + grant = StreamAccessGrant( + url="wss://example.com/mqtt", + client_id="stream-client", + topics=( + StreamTopicGrant("topic/read", "read:stream_events", "read"), + StreamTopicGrant("topic/catchup", "read:stream_persons_catchup", "read"), + StreamTopicGrant("topic/response", "read:response", "read"), + StreamTopicGrant("topic/write", "write:stream_events", "write"), + ), + ) + + selected = grant.read_topics(include_catchup=False, include_response=False) + assert selected == ["topic/read"] + + +def test_streaming_api_derives_token_base_from_auth_url() -> None: + stream_api = HandballStreamingAPI( + client_id="client", + client_secret="secret", + sport="handball", + auth_url="https://token.connect.sportradar.com/v1/oauth2/rest/token", + ) + + assert stream_api.token_base_url == "https://token.connect.sportradar.com/v1" + stream_api.close() + + +def test_streaming_api_requires_token_base_or_auth_url() -> None: + with pytest.raises(ValidationError): + HandballStreamingAPI( + client_id="client", + client_secret="secret", + sport="handball", + ) + + +def test_handball_stream_client_subscribes_only_selected_topics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + subscribed: list[tuple[str, int]] = [] + + class FakeClient: + def __init__(self, *args: object, **kwargs: object) -> None: + self.args = args + self.kwargs = kwargs + self.ws_options: tuple[str, dict[str, str]] | None = None + self.on_connect = None + self.on_disconnect = None + self.on_message = None + + def tls_set(self) -> None: + return None + + def ws_set_options(self, *, path: str, headers: dict[str, str]) -> None: + self.ws_options = (path, headers) + + def subscribe(self, topic: str, qos: int = 0) -> None: + subscribed.append((topic, qos)) + + def connect(self, host: str, port: int, keepalive: int) -> None: + assert host == "example.com" + assert port == DEFAULT_TLS_PORT + assert keepalive == DEFAULT_KEEPALIVE + + def disconnect(self) -> None: + return None + + def loop_start(self) -> None: + return None + + def loop_stop(self) -> None: + return None + + def publish( + self, + topic: str, + payload: object, + qos: int = 0, + retain: bool = False, + ) -> object: + del topic, payload, qos, retain + return types.SimpleNamespace(mid=1, rc=0) + + fake_mqtt = types.SimpleNamespace( + CallbackAPIVersion=types.SimpleNamespace(VERSION2=object()), + Client=FakeClient, + ) + + monkeypatch.setattr( + "sportradar_datacore_api.streaming.mqtt", + fake_mqtt, + ) + + grant = StreamAccessGrant( + url="wss://example.com/mqtt?token=abc", + client_id="stream-client", + topics=( + StreamTopicGrant("topic/read", "read:stream_events", "read"), + StreamTopicGrant( + "topic/catchup", + "read:stream_persons_catchup", + "read", + ), + StreamTopicGrant("topic/response", "read:response", "read"), + StreamTopicGrant("topic/write", "write:stream_events", "write"), + ), + ) + + client = HandballStreamClient( + grant, + include_catchup=False, + include_response=False, + ) + + client.connect() + client.subscribe_granted_topics(qos=1) + + assert subscribed == [("topic/read", 1)] + + +@pytest.fixture(scope="module") +def live_stream_api() -> HandballStreamingAPI: + load_dotenv(".env", override=True) + + required = ["CLIENT_ID", "CLIENT_SECRET"] + missing = [key for key in required if not os.getenv(key)] + if missing: + pytest.skip( + "Missing required environment variables for streaming tests: " + + ", ".join(missing) + ) + + stream_token_base_url = os.getenv("STREAM_TOKEN_BASE_URL") + auth_url = os.getenv("AUTH_URL") + if not stream_token_base_url and not auth_url: + pytest.skip("Missing STREAM_TOKEN_BASE_URL or AUTH_URL for streaming tests.") + + return HandballStreamingAPI( + client_id=os.getenv("CLIENT_ID", ""), + client_secret=os.getenv("CLIENT_SECRET", ""), + sport="handball", + token_base_url=stream_token_base_url, + auth_url=auth_url, + ) + + +def test_fixture_stream_access_live(live_stream_api: HandballStreamingAPI) -> None: + fixture_id = os.getenv("STREAM_FIXTURE_ID") + if not fixture_id: + pytest.skip("Missing STREAM_FIXTURE_ID for live streaming access test.") + assert fixture_id is not None + + access = live_stream_api.get_fixture_access( + fixture_id, + ["read:stream_events", "read:stream_status"], + ) + + assert access.url.startswith("wss://") + assert access.client_id diff --git a/uv.lock b/uv.lock index 0a08d26..1877bdd 100644 --- a/uv.lock +++ b/uv.lock @@ -579,6 +579,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "pandas" version = "2.3.2" @@ -1105,6 +1114,9 @@ dev = [ { name = "respx" }, { name = "ruff" }, ] +stream = [ + { name = "paho-mqtt" }, +] [package.metadata] requires-dist = [ @@ -1113,6 +1125,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "openapi-python-client", marker = "extra == 'dev'", specifier = ">=0.26" }, { name = "openapi-spec-validator", marker = "extra == 'dev'", specifier = ">=0.7" }, + { name = "paho-mqtt", marker = "extra == 'stream'", specifier = ">=2.1" }, { name = "pandas", specifier = ">=2.2" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, { name = "pydantic", specifier = ">=2.6" }, @@ -1125,7 +1138,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.5.7" }, { name = "typing-extensions", specifier = ">=4.8" }, ] -provides-extras = ["dev"] +provides-extras = ["stream", "dev"] [[package]] name = "typer"