# Openstatus Python SDK — Implementation Plan Build an official Python SDK for the Openstatus API, mirroring the ergonomics of [`@openstatusHQ/sdk-node`](https://github.com/openstatusHQ/sdk-node) and the architecture of [`openstatusHQ/sdk-php`](https://github.com/openstatusHQ/sdk-php). **Approach**: pull the pre-generated Python message classes from Buf's CDN archive, then hand-write a thin **HTTP+JSON** transport on top using `httpx`. No Buf CLI, no protobuf binary wire format, no codegen toolchain dependency for contributors — just JSON over HTTP, type-safe via the generated message classes and `.pyi` stubs. ## 1. What the API actually looks like Same as documented in `sdk-php/docs/decisions.md`: - **Server**: `https://api.openstatus.dev/` - **Auth**: header `x-openstatus-key: ` - **Path shape**: `POST /rpc/{package.Service}/{Method}` with `application/json` body (Connect RPC's JSON mode). - **Error envelope** (Connect-style): ```json { "code": "not_found", "message": "...", "details": [ ... ] } ``` `code` is one of: `canceled`, `unknown`, `invalid_argument`, `deadline_exceeded`, `not_found`, `already_exists`, `permission_denied`, `resource_exhausted`, `failed_precondition`, `aborted`, `out_of_range`, `unimplemented`, `internal`, `unavailable`, `data_loss`, `unauthenticated`. - **Services & methods** (~52 RPCs total): - `HealthService`: `Check` - `MonitorService`: `CreateHTTPMonitor`, `CreateTCPMonitor`, `CreateDNSMonitor`, `UpdateHTTPMonitor`, `UpdateTCPMonitor`, `UpdateDNSMonitor`, `GetMonitor`, `ListMonitors`, `TriggerMonitor`, `DeleteMonitor`, `GetMonitorStatus`, `GetMonitorSummary`, `ListMonitorHTTPResponseLogs`, `GetMonitorHTTPResponseLog` - `StatusReportService`: `CreateStatusReport`, `GetStatusReport`, `ListStatusReports`, `UpdateStatusReport`, `DeleteStatusReport`, `AddStatusReportUpdate` - `StatusPageService`: `CreateStatusPage`, `GetStatusPage`, `ListStatusPages`, `UpdateStatusPage`, `DeleteStatusPage`, `GetStatusPageContent`, `GetOverallStatus`, `AddMonitorComponent`, `AddStaticComponent`, `RemoveComponent`, `UpdateComponent`, `CreateComponentGroup`, `UpdateComponentGroup`, `DeleteComponentGroup`, `SubscribeToPage`, `UnsubscribeFromPage`, `CreatePageSubscription`, `ListSubscribers` - `MaintenanceService`: `CreateMaintenance`, `GetMaintenance`, `ListMaintenances`, `UpdateMaintenance`, `DeleteMaintenance` - `NotificationService`: `CreateNotification`, `GetNotification`, `ListNotifications`, `UpdateNotification`, `DeleteNotification`, `SendTestNotification`, `CheckNotificationLimit` `HealthService.Check` also accepts `GET` with a `message` query param; we only support `POST` to keep the transport uniform. ## 2. Foundational decisions | Area | Decision | |------------------------|--------------------------------------------------------------------------------| | Python minimum | **3.10** — `match`, `X \| Y` unions, `ParamSpec`, kw-only dataclasses | | HTTP client | **`httpx`** — modern, sync + async with shared API, typed, well-maintained | | Concurrency | **Sync + Async** from v0.1 (`OpenstatusClient` and `AsyncOpenstatusClient`) | | Wire format | **JSON only** (`application/json`) via `google.protobuf.json_format` | | Default timeout | **30s**, overridable per-client and per-call | | Retries | **None** in v0.1 — users own their retry policy; httpx transports plug in cleanly | | User-Agent | `openstatus-python/ python/` | | Type-checking | `pyright` strict on `src/openstatus/` (excluding `_gen/`); ship `py.typed` | | Visibility | Public surface is `__all__`-controlled; private modules prefixed `_` | | Versioning | 0.x = breaking changes allowed; commit to SemVer at 1.0 | | Build backend | **`hatchling`** — modern, zero-config, vendored generated code ships in wheel | | Package manager | `uv` for dev; published to PyPI as `openstatus` | ### Why sync **and** async from day 1 (unlike PHP) - `httpx.Client` and `httpx.AsyncClient` share virtually the same method signatures, so a single transport core can be specialised twice with a thin wrapper. The duplication is ~50 LOC, not a parallel SDK. - Async is table-stakes for modern Python (FastAPI, ASGI, asyncio-native apps). Shipping sync-only would push users to wrap our calls in thread pools. - The generated protobuf messages and error mapper are transport-agnostic — they don't double in size. ## 3. Codegen: pre-built Buf archive (no Buf CLI) Buf hosts pre-generated zips of the `protocolbuffers/python` and `protocolbuffers/pyi` outputs for each pinned schema version. Both archives exist on `buf.build/gen/archive/...` (verified). We pull **both** so users get type stubs: ```bash curl -fsSL -O https://buf.build/gen/archive/openstatus/api/protocolbuffers/python/v35.0-7d7b7047611f.1.zip curl -fsSL -O https://buf.build/gen/archive/openstatus/api/protocolbuffers/pyi/v35.0-7d7b7047611f.1.zip ``` What the python archive contains: - `*_pb2.py` per proto file — message classes built on `google.protobuf.message.Message`. - Python package layout matches the proto package path (e.g. `openstatus/monitor/v1/monitor_pb2.py`). - Enums are emitted as integer constants on the descriptor; getters return `int`. - No service stubs (`protocolbuffers/python` only emits messages) — we hand-write per-RPC dispatch. - No `pyproject.toml`, no `__init__.py` files — we generate empty `__init__.py` shims during regen so the package tree imports cleanly. What the `pyi` archive contains: - `*_pb2.pyi` next to each `*_pb2.py` — full type stubs so `MonitorService.list_monitors` arguments and return types are typed in IDEs and mypy/pyright. Both archives reference transitive metadata for `buf.validate` and `gnostic.openapi.v3` annotations. Like the PHP build, these are descriptor-only annotations and don't affect the JSON wire format; the python protobuf runtime tolerates missing optional imports for these. ### Regen script `scripts/regen.sh` — the archive version is **pinned** in the script. Bumping it is an explicit edit + PR (no CLI override), so every schema change shows up as a reviewable diff: ```bash #!/usr/bin/env bash set -euo pipefail # Pinned Buf-generated Python archive for buf.build/openstatus/api. # To upgrade: change this line, run `uv run regen`, commit the result. readonly BUF_PY_VERSION="v35.0-7d7b7047611f.1" BASE="https://buf.build/gen/archive/openstatus/api/protocolbuffers" DEST="src/openstatus/_gen" rm -rf "$DEST" mkdir -p "$DEST" curl -fsSL "$BASE/python/${BUF_PY_VERSION}.zip" -o /tmp/openstatus-py.zip curl -fsSL "$BASE/pyi/${BUF_PY_VERSION}.zip" -o /tmp/openstatus-pyi.zip unzip -q /tmp/openstatus-py.zip -d /tmp/openstatus-py unzip -q /tmp/openstatus-pyi.zip -d /tmp/openstatus-pyi # protocolbuffers/python archive layout: api_python/openstatus/... cp -R /tmp/openstatus-py/api_python/openstatus "$DEST/openstatus" # protocolbuffers/pyi archive layout: api_pyi/openstatus/... — merge .pyi next to .py cp -R /tmp/openstatus-pyi/api_pyi/openstatus/. "$DEST/openstatus/" # Generated trees ship no __init__.py — create empty shims so imports resolve. find "$DEST/openstatus" -type d -exec touch {}/__init__.py \; rm -rf /tmp/openstatus-py* /tmp/openstatus-pyi* echo "${BUF_PY_VERSION}" > "$DEST/VERSION" ``` `src/openstatus/_gen/VERSION` is committed alongside the generated code so the current pin is always visible. `src/openstatus/_gen/` itself is also committed so end-users install from PyPI without needing to regen. ### Upgrade workflow 1. Find the latest archive version on `buf.build/openstatus/api/sdks/main:protocolbuffers/python`. 2. Edit `BUF_PY_VERSION` in `scripts/regen.sh`. 3. Run `uv run regen` (or `bash scripts/regen.sh`). 4. Commit `scripts/regen.sh` + the resulting `src/openstatus/_gen/` diff in the same PR. 5. The **weekly** `regen.yml` workflow (see §13) does steps 2–4 automatically when a new pinned version is published. ## 4. Repository layout ``` sdk-python/ ├── docs/decisions.md # this file, post-v0.1.0 ├── pyproject.toml ├── README.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE # MIT ├── .editorconfig ├── .gitignore ├── ruff.toml ├── pyrightconfig.json ├── scripts/ │ └── regen.sh ├── examples/ │ ├── basic.py # port of example.ts (health, list, logs, log detail) │ ├── basic_async.py # async variant of basic.py │ └── create_monitor.py # full HTTPMonitor with assertions/regions ├── src/ │ └── openstatus/ │ ├── __init__.py # re-exports: OpenstatusClient, AsyncOpenstatusClient, ClientOptions, exceptions, Region, etc. │ ├── py.typed # PEP 561 marker │ ├── _client_options.py # frozen dataclass, kw-only │ ├── client.py # OpenstatusClient + AsyncOpenstatusClient roots │ ├── _transport.py # _JsonTransport, _AsyncJsonTransport │ ├── _errors.py # _ConnectErrorMapper │ ├── exceptions.py # OpenstatusError + typed subclasses │ ├── services/ │ │ ├── __init__.py │ │ ├── health.py # HealthServiceClient + AsyncHealthServiceClient │ │ ├── monitor.py │ │ ├── status_report.py │ │ ├── status_page.py │ │ ├── maintenance.py │ │ └── notification.py │ └── _gen/ # buf-generated, committed │ ├── openstatus/ # *_pb2.py + *_pb2.pyi tree │ └── VERSION ├── tests/ │ ├── unit/ # httpx MockTransport-driven, runs in CI │ └── integration/ # gated on OPENSTATUS_API_KEY, NOT run in CI └── .github/workflows/ ├── ci.yml # pytest (unit) + pyright + ruff, py3.10/3.11/3.12/3.13 └── regen.yml # weekly: bump pin + open PR ``` ## 5. `pyproject.toml` ```toml [project] name = "openstatus" version = "0.1.0" description = "Official Python SDK for Openstatus" readme = "README.md" license = { text = "MIT" } authors = [{ name = "Openstatus" }] requires-python = ">=3.10" keywords = ["openstatus", "monitoring", "status-page", "uptime", "sdk"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "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", "Typing :: Typed", ] dependencies = [ "httpx>=0.27", "protobuf>=5.28,<7", ] [project.urls] Homepage = "https://www.openstatus.dev" Repository = "https://github.com/openstatusHQ/sdk-python" Issues = "https://github.com/openstatusHQ/sdk-python/issues" [dependency-groups] dev = [ "pytest>=8", "pytest-asyncio>=0.24", "pyright>=1.1.380", "ruff>=0.6", "respx>=0.21", # httpx mocking helper, optional but ergonomic ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/openstatus"] [tool.hatch.build.targets.sdist] include = ["src/openstatus", "README.md", "LICENSE", "CHANGELOG.md"] [tool.pytest.ini_options] testpaths = ["tests/unit"] asyncio_mode = "auto" [tool.ruff] line-length = 100 target-version = "py310" extend-exclude = ["src/openstatus/_gen"] [tool.ruff.lint] select = ["E", "F", "I", "B", "UP", "RUF"] [tool.pyright] include = ["src/openstatus", "tests"] exclude = ["src/openstatus/_gen"] pythonVersion = "3.10" typeCheckingMode = "strict" ``` `httpx` is a hard dependency, not optional — Python's `urllib` is too low-level and `requests` is sync-only. The transport core depends only on `httpx.Client` / `httpx.AsyncClient`, which users in FastAPI/Django apps can construct with their own configuration (timeouts, proxies, certs) and pass in. ## 6. `ClientOptions` (frozen dataclass, kw-only) ```python # src/openstatus/_client_options.py from __future__ import annotations from dataclasses import dataclass from typing import Any import httpx @dataclass(frozen=True, kw_only=True, slots=True) class ClientOptions: api_key: str | None = None # falls back to OPENSTATUS_API_KEY base_url: str | None = None # falls back to OPENSTATUS_API_URL, default https://api.openstatus.dev timeout: float = 30.0 # seconds, applied to httpx.Client if user does not supply one http_client: httpx.Client | None = None # user-supplied for OpenstatusClient async_http_client: httpx.AsyncClient | None = None # user-supplied for AsyncOpenstatusClient default_headers: dict[str, str] | None = None # extra headers merged on every call ``` Env fallbacks match Node and PHP: - `OPENSTATUS_API_KEY` — API key - `OPENSTATUS_API_URL` — base URL (default `https://api.openstatus.dev`) ## 7. Root client & namespace sugar Mirrors the Node/PHP shape: `client.monitor.v1.MonitorService.list_monitors(...)`. Method names are **snake_case** (PEP 8) while the namespace path matches the proto package and the `ServiceName` casing is preserved so the access path reads the same as in Node: ```python from openstatus import OpenstatusClient, ClientOptions from openstatus._gen.openstatus.monitor.v1 import monitor_pb2 as m client = OpenstatusClient(ClientOptions(api_key="...")) res = client.monitor.v1.MonitorService.list_monitors(m.ListMonitorsRequest()) for monitor in res.http_monitors: print(monitor.name, monitor.url) ``` And the async equivalent: ```python from openstatus import AsyncOpenstatusClient async def main(): async with AsyncOpenstatusClient(ClientOptions(api_key="...")) as client: res = await client.monitor.v1.MonitorService.list_monitors(m.ListMonitorsRequest()) ``` `OpenstatusClient.__init__` builds the `_JsonTransport` once, then constructs nested `_Monitor` / `_Health` / `_StatusReport` / `_StatusPage` / `_Maintenance` / `_Notification` holders. Each holder has a `.v1` attribute pointing to a holder that exposes the typed service client. Total sugar code: ~12 tiny dataclasses (~3 lines each). Both clients support context-manager usage and a `.close()` method that closes the underlying `httpx` client (only when the SDK created it). ## 8. JSON transport (httpx, ~80 LOC sync + ~80 LOC async) ```python # src/openstatus/_transport.py from __future__ import annotations from typing import TypeVar import httpx from google.protobuf.message import Message from google.protobuf.json_format import MessageToJson, Parse from ._errors import _ConnectErrorMapper T = TypeVar("T", bound=Message) class _JsonTransport: def __init__( self, client: httpx.Client, base_url: str, default_headers: dict[str, str], ) -> None: self._client = client self._base_url = base_url.rstrip("/") self._default_headers = default_headers def call( self, service_fqn: str, method: str, request: Message, response_cls: type[T], extra_headers: dict[str, str] | None = None, ) -> T: url = f"{self._base_url}/rpc/{service_fqn}/{method}" headers = {**self._default_headers, **(extra_headers or {})} body = MessageToJson( request, preserving_proto_field_name=False, # camelCase, matching Connect JSON conventions use_integers_for_enums=False, ) resp = self._client.post(url, headers=headers, content=body) if resp.status_code >= 400: _ConnectErrorMapper.rethrow(resp) return Parse(resp.text, response_cls(), ignore_unknown_fields=True) class _AsyncJsonTransport: # identical surface, uses httpx.AsyncClient and `await self._client.post(...)` ... ``` Default headers set by the root client: - `Content-Type: application/json` - `Accept: application/json` - `User-Agent: openstatus-python/ python/` - `x-openstatus-key: ` (if `api_key` set) The async transport is the same shape with `async def call` and `await self._client.post(...)`. Both share `_ConnectErrorMapper`. ## 9. Service client wrappers (hand-written, one module per service, sync + async) ```python # src/openstatus/services/monitor.py from __future__ import annotations from openstatus._gen.openstatus.monitor.v1 import monitor_pb2 as m from openstatus._transport import _JsonTransport, _AsyncJsonTransport _FQN = "openstatus.monitor.v1.MonitorService" class MonitorServiceClient: def __init__(self, transport: _JsonTransport) -> None: self._t = transport def list_monitors( self, request: m.ListMonitorsRequest, *, headers: dict[str, str] | None = None, ) -> m.ListMonitorsResponse: return self._t.call(_FQN, "ListMonitors", request, m.ListMonitorsResponse, headers) def create_http_monitor( self, request: m.CreateHTTPMonitorRequest, *, headers: dict[str, str] | None = None, ) -> m.CreateHTTPMonitorResponse: return self._t.call(_FQN, "CreateHTTPMonitor", request, m.CreateHTTPMonitorResponse, headers) # ... one method per RPC in this service class AsyncMonitorServiceClient: def __init__(self, transport: _AsyncJsonTransport) -> None: self._t = transport async def list_monitors( self, request: m.ListMonitorsRequest, *, headers: dict[str, str] | None = None, ) -> m.ListMonitorsResponse: return await self._t.call(_FQN, "ListMonitors", request, m.ListMonitorsResponse, headers) # ... mirrored async versions ``` ~52 sync + 52 async methods across 6 modules. Each pair is mechanical and ~5 LOC. Method names are `snake_case` (PEP 8). The duplication is intentional and worth it — it keeps the sync and async surfaces independently inspectable and avoids `if asyncio.iscoroutinefunction` introspection at call time. **Could codegen these:** if maintenance gets painful, a small script can emit `services/*.py` from a JSON spec of the RPC list. Out of scope for v0.1; revisit at v0.3 if churn justifies it. ## 10. Error mapping `_ConnectErrorMapper` decodes a `httpx.Response` (4xx/5xx) and raises a typed subclass. | Connect `code` | Python exception | |-----------------------|---------------------------------| | `unauthenticated` | `AuthenticationError` | | `not_found` | `NotFoundError` | | `invalid_argument` | `InvalidArgumentError` | | `permission_denied` | `PermissionDeniedError` | | `resource_exhausted` | `RateLimitError` | | `unavailable` | `ServiceUnavailableError` | | anything else | `OpenstatusError` | When the response body is not a valid `connect.error` envelope (e.g. 502 HTML from a proxy), raise `ServiceUnavailableError` with the raw body preserved. `OpenstatusError(Exception)` carries: - `connect_code: str` — the Connect code (e.g. `not_found`), or `"unknown"` when the envelope is missing. - `http_status: int` — the raw HTTP status code. - `details: list[Any]` — the raw `details` array from the envelope (decoded JSON, no further parsing). Empty list when missing. - `raw_body: str` — the original response body, for debugging. - `args[0]` / `str(exc)` — the `message` from the envelope, or the HTTP reason phrase as fallback. All subclasses inherit from `OpenstatusError`, so users can `except OpenstatusError` as a catch-all. ## 11. Testing - **Unit tests** (`httpx.MockTransport` or `respx`, runs on every PR): - Transport sends the correct URL (`/rpc/openstatus.monitor.v1.MonitorService/ListMonitors`). - Headers include `x-openstatus-key`, `User-Agent`, `Content-Type`, `Accept` when configured, plus per-call overrides. - JSON body round-trips: serializing a known message and decoding it back produces equivalent state. - 4xx with `connect.error` body → correct typed exception with `connect_code`, `http_status`, `details` populated. - Non-JSON 5xx → `ServiceUnavailableError` with `raw_body` preserved. - Both sync and async paths covered (parametrised fixture switches client class). - **Integration tests** (gated on `OPENSTATUS_API_KEY`, run **locally** by maintainers before tagging a release; **not** wired into CI): - `HealthService.Check` — proves the wire format works against the real API. - `MonitorService.ListMonitors` — proves auth + real JSON decode. - **CI matrix**: Python **3.10, 3.11, 3.12, 3.13** on ubuntu-latest. Steps: `ruff check` + `ruff format --check` + `pyright` (strict on `src/openstatus/`, excluding `_gen/`) + `pytest tests/unit`. ## 12. README & examples Full port of the Node SDK README — same TOC, per-service sections, one example per RPC, error-handling section, regions/enums reference. Side-by-side **sync and async** examples for the top-level snippets so readers can pick. Users compare SDKs by README completeness; parity matters. `examples/basic.py` mirrors `example.ts`: health check, list monitors, list HTTP response logs, fetch one log detail. `examples/basic_async.py` is the async equivalent, importing `AsyncOpenstatusClient` and wrapped in `asyncio.run(main())`. `examples/create_monitor.py` builds a full `HTTPMonitor` with `Periodicity`, `Region`, `HTTPMethod`, and a `StatusCodeAssertion` — the hardest first-time task, worth a worked example. Shows how to import enums and message types from `openstatus._gen.openstatus.monitor.v1`. README also documents: - How to use a custom `httpx.Client` (timeouts, proxies, transport retries via `httpx.HTTPTransport(retries=N)`). - The `int64` representation note: python protobuf returns `int` for 64-bit fields on all platforms (no PHP-style `int|string` quirk — simpler than PHP). - Recipes for FastAPI (`Depends(get_client)` pattern) and Django (settings-based singleton) — ~5 lines each. - Migration notes from the Node SDK structure to Python idioms (camelCase → snake_case for method names; message field access is `msg.foo_bar` not `msg.fooBar`). ## 13. Release & ops - Tag `v0.1.0` → GitHub Actions publishes to PyPI via Trusted Publishers (no long-lived token). Package name: `openstatus`. Wheel ships the generated `_gen/` tree. - `.github/workflows/regen.yml`: **weekly cron** that checks Buf for a newer archive version. If one exists, edits `BUF_PY_VERSION` in `scripts/regen.sh`, runs the script, and opens a PR with both changes. The pin never moves silently — every bump is a reviewable commit. - `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/) format. Update on every release. - `CONTRIBUTING.md` documents: how to bump `BUF_PY_VERSION`, how to run tests (`uv run pytest`, `uv run pyright`, `uv run ruff check`), code style rules (ruff defaults + `from __future__ import annotations` at top of every hand-written file), PR conventions. - This document was the original `plan.md`; it moves to `docs/decisions.md` post-v0.1.0 so the rationale (why JSON-over-HTTP, why pinned archive, why mirrored Node shape, why sync+async from day 1) survives as a reference. ## 14. Milestones 1. **Bootstrap** — `pyproject.toml`, `scripts/regen.sh`, `.editorconfig`, `.gitignore`, `ruff.toml`, `pyrightconfig.json`, LICENSE, empty `CHANGELOG.md` + `CONTRIBUTING.md`, `src/openstatus/__init__.py` + `py.typed`, run regen, commit `src/openstatus/_gen/`. 2. **Transport + error mapping** — `_JsonTransport`, `_AsyncJsonTransport`, `ClientOptions`, `_ConnectErrorMapper`, typed exception hierarchy, unit tests with `httpx.MockTransport` (parametrised sync/async). 3. **HealthService end-to-end** — smallest service, sync + async wrappers, manual integration check against the real API to validate the wire format. 4. **Remaining service wrappers** — Monitor → StatusReport → StatusPage → Maintenance → Notification (one PR per service, each with sync + async + unit tests). 5. **Root client + namespace sugar** — `OpenstatusClient` and `AsyncOpenstatusClient` with nested `.v1.ServiceName` accessors, env-var fallbacks, context-manager support. 6. **README + examples** — full port from Node SDK, `examples/basic.py` + `examples/basic_async.py` + `examples/create_monitor.py`. 7. **CI** — `ci.yml` (pytest unit + pyright + ruff, py3.10–3.13), `regen.yml` (weekly cron). 8. **v0.1.0 release prep** — Trusted Publisher setup on PyPI, tag, publish, move `plan.md` → `docs/decisions.md`. ## 15. Trade-offs vs alternatives | Option | Codegen step | Type safety | Service stubs | Wire format | |---------------------------------|---------------------------|--------------------|---------------|-------------| | **Buf archive + JSON (chosen)** | curl + unzip | ✅ (pb2 + pyi) | ❌ hand-write | JSON | | Buf CLI + `protoc-gen-python` | local `buf generate` | ✅ | ❌ hand-write | JSON or bin | | `betterproto` / `protoplus` | dedicated codegen | ✅ (nicer dataclasses) | ❌ hand-write | JSON | | OpenAPI Generator | `openapi-generator-cli` | ✅ | ✅ generated | JSON | | Hand-written + TypedDicts | none | partial | ❌ hand-write | JSON | The archive approach is the leanest: no toolchain dependency for contributors (just `curl` + `unzip`), type-safe message classes that match the proto source of truth byte-for-byte (with `.pyi` stubs for IDE/`pyright`), and only ~160 lines of transport (sync + async) + ~52 × 2 one-liners of service dispatch to maintain ourselves. `betterproto` was considered for nicer dataclass-style messages, but it introduces a codegen toolchain dependency and its JSON output is not guaranteed to match Connect's JSON conventions — risk not worth it for v0.1. ## 16. Open questions to confirm before kickoff - **Async-from-day-1**: confirmed worth it (§2). If we want to ship faster, sync-only v0.1 is a valid fallback — the async transport can land in v0.2 without breaking changes since the class names are distinct. - **`google-protobuf` runtime version pin**: `>=5.28,<7` covers the current major + the announced 6.x line. Tighten once we observe wheel compatibility on the CI matrix. - **`pyright` vs `mypy`**: pyright proposed for stricter inference and better IDE integration. mypy is fine as an alternative if maintainer preference differs. ## 17. Detailed task breakdown Phases map to the milestones in §14. Each task is independently reviewable; ✅ when done. Tasks within a phase are roughly ordered; phases run sequentially. ### Phase 0 — Repository scaffolding - [x] Initialise git repo and push `main` to `github.com/openstatusHQ/sdk-python` - [x] Add `LICENSE` (MIT, matching sdk-php and sdk-node) - [x] Add `.gitignore` (Python: `__pycache__`, `.venv`, `dist`, `build`, `*.egg-info`, `.pytest_cache`, `.ruff_cache`) - [x] Add `.editorconfig` (4-space indent for `.py`, LF line endings, trim trailing whitespace) - [x] Add `README.md` skeleton (title, one-line description, "Status: pre-alpha" badge, placeholder TOC) - [x] Add empty `CHANGELOG.md` with `## [Unreleased]` header (Keep a Changelog format) - [x] Add `CONTRIBUTING.md` skeleton (sections to fill in as decisions land: dev setup, regen, tests, PR conventions) ### Phase 1 — Tooling & project metadata - [x] Write `pyproject.toml` per §5 (project metadata, deps, dependency-groups, hatchling build, tool configs) - [x] Add `ruff.toml` (or inline under `[tool.ruff]` in `pyproject.toml`) - [x] Add `pyrightconfig.json` (strict on `src/openstatus/`, exclude `_gen/`) - [x] Create `src/openstatus/__init__.py` (empty, to be populated in Phase 5) - [x] Create `src/openstatus/py.typed` (empty marker file, PEP 561) - [x] Run `uv sync` to install dev deps, commit `uv.lock` - [x] Verify `uv run ruff check .` and `uv run pyright` both pass on empty package ### Phase 2 — Codegen pipeline - [x] Write `scripts/regen.sh` per §3 (pinned `BUF_PY_VERSION`, downloads both `python` and `pyi` archives, merges into `src/openstatus/_gen/openstatus/`, creates empty `__init__.py` shims, writes `VERSION` file) - [x] Make `scripts/regen.sh` executable (`chmod +x`) - [x] Run `bash scripts/regen.sh` and verify it produces `src/openstatus/_gen/openstatus/{monitor,health,status_report,status_page,maintenance,notification}/v1/*_pb2.py` and matching `.pyi` files - [x] Verify `from openstatus._gen.openstatus.monitor.v1 import monitor_pb2` works from a python REPL - [x] Verify `MessageToJson(monitor_pb2.ListMonitorsRequest())` and `Parse('{}', monitor_pb2.ListMonitorsResponse())` round-trip without errors - [x] Commit `src/openstatus/_gen/` tree and `VERSION` file - [x] Add `[tool.hatch.build.targets.wheel].force-include` rule if needed so `_gen/` ships in the wheel; verify with `uv build` + `unzip -l dist/*.whl` ### Phase 3 — Exception hierarchy & error mapper - [x] Write `src/openstatus/exceptions.py`: - [x] `OpenstatusError(Exception)` base with `connect_code`, `http_status`, `details`, `raw_body` attributes - [x] `AuthenticationError(OpenstatusError)` - [x] `NotFoundError(OpenstatusError)` - [x] `InvalidArgumentError(OpenstatusError)` - [x] `PermissionDeniedError(OpenstatusError)` - [x] `RateLimitError(OpenstatusError)` - [x] `ServiceUnavailableError(OpenstatusError)` - [x] Write `src/openstatus/_errors.py` with `_ConnectErrorMapper.rethrow(httpx.Response)`: - [x] Decode `connect.error` envelope (`code`, `message`, `details`) - [x] Map `code` → exception class per §10 table - [x] Fallback to `ServiceUnavailableError` when body is not valid JSON envelope - [x] Preserve `raw_body`, `http_status`, `connect_code` on every raised exception - [x] Add unit tests `tests/unit/test_errors.py`: - [x] Each Connect code maps to its expected exception class - [x] Missing envelope → `ServiceUnavailableError` with `connect_code="unknown"` - [x] HTML 502 body → `ServiceUnavailableError` with `raw_body` preserved - [x] `OpenstatusError.__str__` returns envelope message or HTTP reason phrase ### Phase 4 — Transport layer - [x] Write `src/openstatus/_client_options.py` (frozen kw-only slotted dataclass per §6) - [x] Write `src/openstatus/_transport.py`: - [x] `_JsonTransport.__init__(client, base_url, default_headers)` - [x] `_JsonTransport.call(service_fqn, method, request, response_cls, extra_headers)` per §8 - [x] `_AsyncJsonTransport` with `async def call(...)` mirroring sync surface - [x] Add unit tests `tests/unit/test_transport.py` using `httpx.MockTransport`: - [x] URL is `/rpc/openstatus.monitor.v1.MonitorService/ListMonitors` - [x] Default headers (`Content-Type`, `Accept`, `User-Agent`, `x-openstatus-key`) sent on every request - [x] Per-call `extra_headers` override defaults - [x] Request body is valid JSON, deserialisable into the original message - [x] Response body is parsed into the expected message type - [x] 4xx response triggers `_ConnectErrorMapper.rethrow` - [x] Parametrise test fixtures so the same cases run against sync and async transports ### Phase 5 — Root client & namespace sugar - [x] Resolve `_resolve_options(opts: ClientOptions) -> _ResolvedOptions` helper: - [x] Read `OPENSTATUS_API_KEY` env var as `api_key` fallback - [x] Read `OPENSTATUS_API_URL` env var as `base_url` fallback, default `https://api.openstatus.dev` - [x] Build default header dict including `User-Agent` from `importlib.metadata.version("openstatus")` and `platform.python_version()` - [x] Write `src/openstatus/client.py`: - [x] `OpenstatusClient.__init__(options: ClientOptions | None = None)` — constructs `httpx.Client` if not user-supplied, builds `_JsonTransport`, instantiates namespace holders - [x] `__enter__` / `__exit__` for context-manager use - [x] `.close()` — closes owned `httpx.Client` (no-op if user supplied one) - [x] Nested holder classes for `monitor`, `health`, `status_report`, `status_page`, `maintenance`, `notification`; each has a `.v1` attribute holding the service client(s) - [x] `AsyncOpenstatusClient` with `async __aenter__` / `__aexit__` / `aclose()` mirror - [x] Populate `src/openstatus/__init__.py`: - [x] Re-export `OpenstatusClient`, `AsyncOpenstatusClient`, `ClientOptions` - [x] Re-export all exception classes - [x] Set `__all__` and `__version__` - [x] Add unit tests `tests/unit/test_client.py`: - [x] Env-var fallbacks (`OPENSTATUS_API_KEY`, `OPENSTATUS_API_URL`) - [x] User-supplied `httpx.Client` is reused, not replaced - [x] Context-manager closes only SDK-owned clients - [x] `client.monitor.v1.MonitorService` resolves to the expected service client type ### Phase 6 — Service wrappers (one per service) For each service: hand-write sync + async client class with one method per RPC, then add unit tests verifying each method calls the transport with correct `(service_fqn, method, request_type, response_type)`. Single PR per service. - [x] `src/openstatus/services/health.py` — `HealthServiceClient.check` + async variant (1 RPC) - [x] `src/openstatus/services/monitor.py` — 14 RPCs × 2 (sync + async): - [x] `create_http_monitor`, `create_tcp_monitor`, `create_dns_monitor` - [x] `update_http_monitor`, `update_tcp_monitor`, `update_dns_monitor` - [x] `get_monitor`, `list_monitors`, `trigger_monitor`, `delete_monitor` - [x] `get_monitor_status`, `get_monitor_summary` - [x] `list_monitor_http_response_logs`, `get_monitor_http_response_log` - [x] `src/openstatus/services/status_report.py` — 6 RPCs × 2 - [x] `src/openstatus/services/status_page.py` — 18 RPCs × 2 - [x] `src/openstatus/services/maintenance.py` — 5 RPCs × 2 - [x] `src/openstatus/services/notification.py` — 7 RPCs × 2 - [x] `src/openstatus/services/__init__.py` re-exports all service client classes - [x] Unit tests `tests/unit/services/test_.py` for each service (one parametrised test covering all RPCs is acceptable) ### Phase 7 — Integration tests (local-only) - [x] `tests/integration/conftest.py` with `pytest.skip` if `OPENSTATUS_API_KEY` env var absent - [x] `tests/integration/test_health.py` — real `HealthService.check` round-trip - [x] `tests/integration/test_monitor_list.py` — real `MonitorService.list_monitors` against authenticated account - [x] Document in `CONTRIBUTING.md` how to run: `OPENSTATUS_API_KEY=... uv run pytest tests/integration` ### Phase 8 — Examples - [x] `examples/basic.py` — sync port of `sdk-node/example.ts` (health, list, logs, log detail) - [x] `examples/basic_async.py` — async equivalent of `basic.py` - [x] `examples/create_monitor.py` — full HTTPMonitor with `Periodicity`, `Region`, `HTTPMethod`, `StatusCodeAssertion` - [x] Each example: standalone, runnable with `uv run python examples/.py`, no extra deps ### Phase 9 — README & docs - [x] Port full TOC from sdk-node README - [x] "Installation" — `pip install openstatus` / `uv add openstatus` - [x] "Quickstart" — sync + async snippets side by side - [x] "Authentication" — env var + explicit `api_key` arg - [x] "Custom HTTP client" — supplying `httpx.Client` with custom timeouts/transport - [x] Per-service sections (Health, Monitor, StatusReport, StatusPage, Maintenance, Notification) with one example per RPC - [x] "Error handling" — exception hierarchy, catching `OpenstatusError`, inspecting `connect_code` / `http_status` / `details` - [x] "Regions / Enums" reference — how to import from `_gen` and reference enum constants - [x] "FastAPI integration" recipe (~5 lines, `Depends(get_client)`) - [x] "Django integration" recipe (~5 lines, settings-backed singleton) - [x] "Migration from Node SDK" note — camelCase → snake_case mapping - [x] Move `plan.md` → `docs/decisions.md` after v0.1.0 tag (not before — keep planning artifact at root during build-out) ### Phase 10 — CI - [x] `.github/workflows/ci.yml`: - [x] Trigger on `push` to `main` and `pull_request` - [x] Matrix: Python 3.10, 3.11, 3.12, 3.13 × ubuntu-latest - [x] Steps: checkout → install uv → `uv sync` → `uv run ruff check .` → `uv run ruff format --check .` → `uv run pyright` → `uv run pytest tests/unit` - [x] `.github/workflows/regen.yml`: - [x] Weekly cron + `workflow_dispatch` - [x] Fetch latest `protocolbuffers/python` archive version from `buf.build/openstatus/api` - [x] If newer than pinned version: edit `BUF_PY_VERSION` in `scripts/regen.sh`, run regen, open PR with the diff - [x] `.github/workflows/release.yml`: - [x] Trigger on `v*` tags - [x] Steps: checkout → install uv → `uv build` → publish to PyPI via Trusted Publishers (`pypa/gh-action-pypi-publish`) - [x] Add status badges to `README.md` (CI, PyPI version, supported Python versions) ### Phase 11 — Release v0.1.0 - [x] Set up PyPI Trusted Publisher for `openstatusHQ/sdk-python` → project `openstatus` - [x] Final `CHANGELOG.md` pass: move `[Unreleased]` content under `[0.1.0] — YYYY-MM-DD` - [x] Verify `uv build` produces clean sdist + wheel; inspect wheel contents include `_gen/`, `py.typed`, `*.pyi` - [x] Smoke-test the built wheel in a fresh venv: `pip install dist/openstatus-0.1.0-*.whl && python -c "from openstatus import OpenstatusClient; ..."` - [x] Tag `v0.1.0`, push tag, verify `release.yml` publishes to PyPI - [x] Verify install from PyPI: `pip install openstatus` in a fresh venv works end-to-end - [x] Move `plan.md` → `docs/decisions.md`; update README link - [x] Announce on Openstatus channels (blog post, X, Discord — coordinated with team)