[READ-ONLY] Mirror of https://github.com/openstatusHQ/sdk-python. Official Python SDK for openstatus
0

Configure Feed

Select the types of activity you want to include in your feed.

sdk-python / docs / decisions.md
38 kB 662 lines
1# Openstatus Python SDK — Implementation Plan 2 3Build 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). 4 5**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. 6 7## 1. What the API actually looks like 8 9Same as documented in `sdk-php/docs/decisions.md`: 10 11- **Server**: `https://api.openstatus.dev/` 12- **Auth**: header `x-openstatus-key: <token>` 13- **Path shape**: `POST /rpc/{package.Service}/{Method}` with `application/json` body (Connect RPC's JSON mode). 14- **Error envelope** (Connect-style): 15 ```json 16 { "code": "not_found", "message": "...", "details": [ ... ] } 17 ``` 18 `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`. 19- **Services & methods** (~52 RPCs total): 20 - `HealthService`: `Check` 21 - `MonitorService`: `CreateHTTPMonitor`, `CreateTCPMonitor`, `CreateDNSMonitor`, `UpdateHTTPMonitor`, `UpdateTCPMonitor`, `UpdateDNSMonitor`, `GetMonitor`, `ListMonitors`, `TriggerMonitor`, `DeleteMonitor`, `GetMonitorStatus`, `GetMonitorSummary`, `ListMonitorHTTPResponseLogs`, `GetMonitorHTTPResponseLog` 22 - `StatusReportService`: `CreateStatusReport`, `GetStatusReport`, `ListStatusReports`, `UpdateStatusReport`, `DeleteStatusReport`, `AddStatusReportUpdate` 23 - `StatusPageService`: `CreateStatusPage`, `GetStatusPage`, `ListStatusPages`, `UpdateStatusPage`, `DeleteStatusPage`, `GetStatusPageContent`, `GetOverallStatus`, `AddMonitorComponent`, `AddStaticComponent`, `RemoveComponent`, `UpdateComponent`, `CreateComponentGroup`, `UpdateComponentGroup`, `DeleteComponentGroup`, `SubscribeToPage`, `UnsubscribeFromPage`, `CreatePageSubscription`, `ListSubscribers` 24 - `MaintenanceService`: `CreateMaintenance`, `GetMaintenance`, `ListMaintenances`, `UpdateMaintenance`, `DeleteMaintenance` 25 - `NotificationService`: `CreateNotification`, `GetNotification`, `ListNotifications`, `UpdateNotification`, `DeleteNotification`, `SendTestNotification`, `CheckNotificationLimit` 26 27`HealthService.Check` also accepts `GET` with a `message` query param; we only support `POST` to keep the transport uniform. 28 29## 2. Foundational decisions 30 31| Area | Decision | 32|------------------------|--------------------------------------------------------------------------------| 33| Python minimum | **3.10** — `match`, `X \| Y` unions, `ParamSpec`, kw-only dataclasses | 34| HTTP client | **`httpx`** — modern, sync + async with shared API, typed, well-maintained | 35| Concurrency | **Sync + Async** from v0.1 (`OpenstatusClient` and `AsyncOpenstatusClient`) | 36| Wire format | **JSON only** (`application/json`) via `google.protobuf.json_format` | 37| Default timeout | **30s**, overridable per-client and per-call | 38| Retries | **None** in v0.1 — users own their retry policy; httpx transports plug in cleanly | 39| User-Agent | `openstatus-python/<sdk-version> python/<python-version>` | 40| Type-checking | `pyright` strict on `src/openstatus/` (excluding `_gen/`); ship `py.typed` | 41| Visibility | Public surface is `__all__`-controlled; private modules prefixed `_` | 42| Versioning | 0.x = breaking changes allowed; commit to SemVer at 1.0 | 43| Build backend | **`hatchling`** — modern, zero-config, vendored generated code ships in wheel | 44| Package manager | `uv` for dev; published to PyPI as `openstatus` | 45 46### Why sync **and** async from day 1 (unlike PHP) 47 48- `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. 49- 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. 50- The generated protobuf messages and error mapper are transport-agnostic — they don't double in size. 51 52## 3. Codegen: pre-built Buf archive (no Buf CLI) 53 54Buf 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: 55 56```bash 57curl -fsSL -O https://buf.build/gen/archive/openstatus/api/protocolbuffers/python/v35.0-7d7b7047611f.1.zip 58curl -fsSL -O https://buf.build/gen/archive/openstatus/api/protocolbuffers/pyi/v35.0-7d7b7047611f.1.zip 59``` 60 61What the python archive contains: 62- `*_pb2.py` per proto file — message classes built on `google.protobuf.message.Message`. 63- Python package layout matches the proto package path (e.g. `openstatus/monitor/v1/monitor_pb2.py`). 64- Enums are emitted as integer constants on the descriptor; getters return `int`. 65- No service stubs (`protocolbuffers/python` only emits messages) — we hand-write per-RPC dispatch. 66- No `pyproject.toml`, no `__init__.py` files — we generate empty `__init__.py` shims during regen so the package tree imports cleanly. 67 68What the `pyi` archive contains: 69- `*_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. 70 71Both 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. 72 73### Regen script 74 75`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: 76 77```bash 78#!/usr/bin/env bash 79set -euo pipefail 80 81# Pinned Buf-generated Python archive for buf.build/openstatus/api. 82# To upgrade: change this line, run `uv run regen`, commit the result. 83readonly BUF_PY_VERSION="v35.0-7d7b7047611f.1" 84 85BASE="https://buf.build/gen/archive/openstatus/api/protocolbuffers" 86DEST="src/openstatus/_gen" 87 88rm -rf "$DEST" 89mkdir -p "$DEST" 90 91curl -fsSL "$BASE/python/${BUF_PY_VERSION}.zip" -o /tmp/openstatus-py.zip 92curl -fsSL "$BASE/pyi/${BUF_PY_VERSION}.zip" -o /tmp/openstatus-pyi.zip 93 94unzip -q /tmp/openstatus-py.zip -d /tmp/openstatus-py 95unzip -q /tmp/openstatus-pyi.zip -d /tmp/openstatus-pyi 96 97# protocolbuffers/python archive layout: api_python/openstatus/... 98cp -R /tmp/openstatus-py/api_python/openstatus "$DEST/openstatus" 99# protocolbuffers/pyi archive layout: api_pyi/openstatus/... — merge .pyi next to .py 100cp -R /tmp/openstatus-pyi/api_pyi/openstatus/. "$DEST/openstatus/" 101 102# Generated trees ship no __init__.py — create empty shims so imports resolve. 103find "$DEST/openstatus" -type d -exec touch {}/__init__.py \; 104 105rm -rf /tmp/openstatus-py* /tmp/openstatus-pyi* 106 107echo "${BUF_PY_VERSION}" > "$DEST/VERSION" 108``` 109 110`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. 111 112### Upgrade workflow 113 1141. Find the latest archive version on `buf.build/openstatus/api/sdks/main:protocolbuffers/python`. 1152. Edit `BUF_PY_VERSION` in `scripts/regen.sh`. 1163. Run `uv run regen` (or `bash scripts/regen.sh`). 1174. Commit `scripts/regen.sh` + the resulting `src/openstatus/_gen/` diff in the same PR. 1185. The **weekly** `regen.yml` workflow (see §13) does steps 2–4 automatically when a new pinned version is published. 119 120## 4. Repository layout 121 122``` 123sdk-python/ 124├── docs/decisions.md # this file, post-v0.1.0 125├── pyproject.toml 126├── README.md 127├── CHANGELOG.md 128├── CONTRIBUTING.md 129├── LICENSE # MIT 130├── .editorconfig 131├── .gitignore 132├── ruff.toml 133├── pyrightconfig.json 134├── scripts/ 135│ └── regen.sh 136├── examples/ 137│ ├── basic.py # port of example.ts (health, list, logs, log detail) 138│ ├── basic_async.py # async variant of basic.py 139│ └── create_monitor.py # full HTTPMonitor with assertions/regions 140├── src/ 141│ └── openstatus/ 142│ ├── __init__.py # re-exports: OpenstatusClient, AsyncOpenstatusClient, ClientOptions, exceptions, Region, etc. 143│ ├── py.typed # PEP 561 marker 144│ ├── _client_options.py # frozen dataclass, kw-only 145│ ├── client.py # OpenstatusClient + AsyncOpenstatusClient roots 146│ ├── _transport.py # _JsonTransport, _AsyncJsonTransport 147│ ├── _errors.py # _ConnectErrorMapper 148│ ├── exceptions.py # OpenstatusError + typed subclasses 149│ ├── services/ 150│ │ ├── __init__.py 151│ │ ├── health.py # HealthServiceClient + AsyncHealthServiceClient 152│ │ ├── monitor.py 153│ │ ├── status_report.py 154│ │ ├── status_page.py 155│ │ ├── maintenance.py 156│ │ └── notification.py 157│ └── _gen/ # buf-generated, committed 158│ ├── openstatus/ # *_pb2.py + *_pb2.pyi tree 159│ └── VERSION 160├── tests/ 161│ ├── unit/ # httpx MockTransport-driven, runs in CI 162│ └── integration/ # gated on OPENSTATUS_API_KEY, NOT run in CI 163└── .github/workflows/ 164 ├── ci.yml # pytest (unit) + pyright + ruff, py3.10/3.11/3.12/3.13 165 └── regen.yml # weekly: bump pin + open PR 166``` 167 168## 5. `pyproject.toml` 169 170```toml 171[project] 172name = "openstatus" 173version = "0.1.0" 174description = "Official Python SDK for Openstatus" 175readme = "README.md" 176license = { text = "MIT" } 177authors = [{ name = "Openstatus" }] 178requires-python = ">=3.10" 179keywords = ["openstatus", "monitoring", "status-page", "uptime", "sdk"] 180classifiers = [ 181 "Development Status :: 3 - Alpha", 182 "Intended Audience :: Developers", 183 "License :: OSI Approved :: MIT License", 184 "Programming Language :: Python :: 3 :: Only", 185 "Programming Language :: Python :: 3.10", 186 "Programming Language :: Python :: 3.11", 187 "Programming Language :: Python :: 3.12", 188 "Programming Language :: Python :: 3.13", 189 "Typing :: Typed", 190] 191dependencies = [ 192 "httpx>=0.27", 193 "protobuf>=5.28,<7", 194] 195 196[project.urls] 197Homepage = "https://www.openstatus.dev" 198Repository = "https://github.com/openstatusHQ/sdk-python" 199Issues = "https://github.com/openstatusHQ/sdk-python/issues" 200 201[dependency-groups] 202dev = [ 203 "pytest>=8", 204 "pytest-asyncio>=0.24", 205 "pyright>=1.1.380", 206 "ruff>=0.6", 207 "respx>=0.21", # httpx mocking helper, optional but ergonomic 208] 209 210[build-system] 211requires = ["hatchling"] 212build-backend = "hatchling.build" 213 214[tool.hatch.build.targets.wheel] 215packages = ["src/openstatus"] 216 217[tool.hatch.build.targets.sdist] 218include = ["src/openstatus", "README.md", "LICENSE", "CHANGELOG.md"] 219 220[tool.pytest.ini_options] 221testpaths = ["tests/unit"] 222asyncio_mode = "auto" 223 224[tool.ruff] 225line-length = 100 226target-version = "py310" 227extend-exclude = ["src/openstatus/_gen"] 228 229[tool.ruff.lint] 230select = ["E", "F", "I", "B", "UP", "RUF"] 231 232[tool.pyright] 233include = ["src/openstatus", "tests"] 234exclude = ["src/openstatus/_gen"] 235pythonVersion = "3.10" 236typeCheckingMode = "strict" 237``` 238 239`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. 240 241## 6. `ClientOptions` (frozen dataclass, kw-only) 242 243```python 244# src/openstatus/_client_options.py 245from __future__ import annotations 246from dataclasses import dataclass 247from typing import Any 248import httpx 249 250@dataclass(frozen=True, kw_only=True, slots=True) 251class ClientOptions: 252 api_key: str | None = None # falls back to OPENSTATUS_API_KEY 253 base_url: str | None = None # falls back to OPENSTATUS_API_URL, default https://api.openstatus.dev 254 timeout: float = 30.0 # seconds, applied to httpx.Client if user does not supply one 255 http_client: httpx.Client | None = None # user-supplied for OpenstatusClient 256 async_http_client: httpx.AsyncClient | None = None # user-supplied for AsyncOpenstatusClient 257 default_headers: dict[str, str] | None = None # extra headers merged on every call 258``` 259 260Env fallbacks match Node and PHP: 261- `OPENSTATUS_API_KEY` — API key 262- `OPENSTATUS_API_URL` — base URL (default `https://api.openstatus.dev`) 263 264## 7. Root client & namespace sugar 265 266Mirrors 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: 267 268```python 269from openstatus import OpenstatusClient, ClientOptions 270from openstatus._gen.openstatus.monitor.v1 import monitor_pb2 as m 271 272client = OpenstatusClient(ClientOptions(api_key="...")) 273 274res = client.monitor.v1.MonitorService.list_monitors(m.ListMonitorsRequest()) 275for monitor in res.http_monitors: 276 print(monitor.name, monitor.url) 277``` 278 279And the async equivalent: 280 281```python 282from openstatus import AsyncOpenstatusClient 283 284async def main(): 285 async with AsyncOpenstatusClient(ClientOptions(api_key="...")) as client: 286 res = await client.monitor.v1.MonitorService.list_monitors(m.ListMonitorsRequest()) 287``` 288 289`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). 290 291Both clients support context-manager usage and a `.close()` method that closes the underlying `httpx` client (only when the SDK created it). 292 293## 8. JSON transport (httpx, ~80 LOC sync + ~80 LOC async) 294 295```python 296# src/openstatus/_transport.py 297from __future__ import annotations 298from typing import TypeVar 299import httpx 300from google.protobuf.message import Message 301from google.protobuf.json_format import MessageToJson, Parse 302from ._errors import _ConnectErrorMapper 303 304T = TypeVar("T", bound=Message) 305 306class _JsonTransport: 307 def __init__( 308 self, 309 client: httpx.Client, 310 base_url: str, 311 default_headers: dict[str, str], 312 ) -> None: 313 self._client = client 314 self._base_url = base_url.rstrip("/") 315 self._default_headers = default_headers 316 317 def call( 318 self, 319 service_fqn: str, 320 method: str, 321 request: Message, 322 response_cls: type[T], 323 extra_headers: dict[str, str] | None = None, 324 ) -> T: 325 url = f"{self._base_url}/rpc/{service_fqn}/{method}" 326 headers = {**self._default_headers, **(extra_headers or {})} 327 body = MessageToJson( 328 request, 329 preserving_proto_field_name=False, # camelCase, matching Connect JSON conventions 330 use_integers_for_enums=False, 331 ) 332 resp = self._client.post(url, headers=headers, content=body) 333 if resp.status_code >= 400: 334 _ConnectErrorMapper.rethrow(resp) 335 return Parse(resp.text, response_cls(), ignore_unknown_fields=True) 336 337 338class _AsyncJsonTransport: 339 # identical surface, uses httpx.AsyncClient and `await self._client.post(...)` 340 ... 341``` 342 343Default headers set by the root client: 344- `Content-Type: application/json` 345- `Accept: application/json` 346- `User-Agent: openstatus-python/<sdk-version> python/<python-version>` 347- `x-openstatus-key: <api-key>` (if `api_key` set) 348 349The async transport is the same shape with `async def call` and `await self._client.post(...)`. Both share `_ConnectErrorMapper`. 350 351## 9. Service client wrappers (hand-written, one module per service, sync + async) 352 353```python 354# src/openstatus/services/monitor.py 355from __future__ import annotations 356from openstatus._gen.openstatus.monitor.v1 import monitor_pb2 as m 357from openstatus._transport import _JsonTransport, _AsyncJsonTransport 358 359_FQN = "openstatus.monitor.v1.MonitorService" 360 361class MonitorServiceClient: 362 def __init__(self, transport: _JsonTransport) -> None: 363 self._t = transport 364 365 def list_monitors( 366 self, 367 request: m.ListMonitorsRequest, 368 *, 369 headers: dict[str, str] | None = None, 370 ) -> m.ListMonitorsResponse: 371 return self._t.call(_FQN, "ListMonitors", request, m.ListMonitorsResponse, headers) 372 373 def create_http_monitor( 374 self, 375 request: m.CreateHTTPMonitorRequest, 376 *, 377 headers: dict[str, str] | None = None, 378 ) -> m.CreateHTTPMonitorResponse: 379 return self._t.call(_FQN, "CreateHTTPMonitor", request, m.CreateHTTPMonitorResponse, headers) 380 381 # ... one method per RPC in this service 382 383 384class AsyncMonitorServiceClient: 385 def __init__(self, transport: _AsyncJsonTransport) -> None: 386 self._t = transport 387 388 async def list_monitors( 389 self, 390 request: m.ListMonitorsRequest, 391 *, 392 headers: dict[str, str] | None = None, 393 ) -> m.ListMonitorsResponse: 394 return await self._t.call(_FQN, "ListMonitors", request, m.ListMonitorsResponse, headers) 395 396 # ... mirrored async versions 397``` 398 399~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. 400 401**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. 402 403## 10. Error mapping 404 405`_ConnectErrorMapper` decodes a `httpx.Response` (4xx/5xx) and raises a typed subclass. 406 407| Connect `code` | Python exception | 408|-----------------------|---------------------------------| 409| `unauthenticated` | `AuthenticationError` | 410| `not_found` | `NotFoundError` | 411| `invalid_argument` | `InvalidArgumentError` | 412| `permission_denied` | `PermissionDeniedError` | 413| `resource_exhausted` | `RateLimitError` | 414| `unavailable` | `ServiceUnavailableError` | 415| anything else | `OpenstatusError` | 416 417When the response body is not a valid `connect.error` envelope (e.g. 502 HTML from a proxy), raise `ServiceUnavailableError` with the raw body preserved. 418 419`OpenstatusError(Exception)` carries: 420- `connect_code: str` — the Connect code (e.g. `not_found`), or `"unknown"` when the envelope is missing. 421- `http_status: int` — the raw HTTP status code. 422- `details: list[Any]` — the raw `details` array from the envelope (decoded JSON, no further parsing). Empty list when missing. 423- `raw_body: str` — the original response body, for debugging. 424- `args[0]` / `str(exc)` — the `message` from the envelope, or the HTTP reason phrase as fallback. 425 426All subclasses inherit from `OpenstatusError`, so users can `except OpenstatusError` as a catch-all. 427 428## 11. Testing 429 430- **Unit tests** (`httpx.MockTransport` or `respx`, runs on every PR): 431 - Transport sends the correct URL (`/rpc/openstatus.monitor.v1.MonitorService/ListMonitors`). 432 - Headers include `x-openstatus-key`, `User-Agent`, `Content-Type`, `Accept` when configured, plus per-call overrides. 433 - JSON body round-trips: serializing a known message and decoding it back produces equivalent state. 434 - 4xx with `connect.error` body → correct typed exception with `connect_code`, `http_status`, `details` populated. 435 - Non-JSON 5xx → `ServiceUnavailableError` with `raw_body` preserved. 436 - Both sync and async paths covered (parametrised fixture switches client class). 437- **Integration tests** (gated on `OPENSTATUS_API_KEY`, run **locally** by maintainers before tagging a release; **not** wired into CI): 438 - `HealthService.Check` — proves the wire format works against the real API. 439 - `MonitorService.ListMonitors` — proves auth + real JSON decode. 440- **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`. 441 442## 12. README & examples 443 444Full 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. 445 446`examples/basic.py` mirrors `example.ts`: health check, list monitors, list HTTP response logs, fetch one log detail. 447 448`examples/basic_async.py` is the async equivalent, importing `AsyncOpenstatusClient` and wrapped in `asyncio.run(main())`. 449 450`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`. 451 452README also documents: 453- How to use a custom `httpx.Client` (timeouts, proxies, transport retries via `httpx.HTTPTransport(retries=N)`). 454- The `int64` representation note: python protobuf returns `int` for 64-bit fields on all platforms (no PHP-style `int|string` quirk — simpler than PHP). 455- Recipes for FastAPI (`Depends(get_client)` pattern) and Django (settings-based singleton) — ~5 lines each. 456- 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`). 457 458## 13. Release & ops 459 460- 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. 461- `.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. 462- `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/) format. Update on every release. 463- `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. 464- 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. 465 466## 14. Milestones 467 4681. **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/`. 4692. **Transport + error mapping** — `_JsonTransport`, `_AsyncJsonTransport`, `ClientOptions`, `_ConnectErrorMapper`, typed exception hierarchy, unit tests with `httpx.MockTransport` (parametrised sync/async). 4703. **HealthService end-to-end** — smallest service, sync + async wrappers, manual integration check against the real API to validate the wire format. 4714. **Remaining service wrappers** — Monitor → StatusReport → StatusPage → Maintenance → Notification (one PR per service, each with sync + async + unit tests). 4725. **Root client + namespace sugar** — `OpenstatusClient` and `AsyncOpenstatusClient` with nested `.v1.ServiceName` accessors, env-var fallbacks, context-manager support. 4736. **README + examples** — full port from Node SDK, `examples/basic.py` + `examples/basic_async.py` + `examples/create_monitor.py`. 4747. **CI** — `ci.yml` (pytest unit + pyright + ruff, py3.10–3.13), `regen.yml` (weekly cron). 4758. **v0.1.0 release prep** — Trusted Publisher setup on PyPI, tag, publish, move `plan.md` → `docs/decisions.md`. 476 477## 15. Trade-offs vs alternatives 478 479| Option | Codegen step | Type safety | Service stubs | Wire format | 480|---------------------------------|---------------------------|--------------------|---------------|-------------| 481| **Buf archive + JSON (chosen)** | curl + unzip | ✅ (pb2 + pyi) | ❌ hand-write | JSON | 482| Buf CLI + `protoc-gen-python` | local `buf generate` | ✅ | ❌ hand-write | JSON or bin | 483| `betterproto` / `protoplus` | dedicated codegen | ✅ (nicer dataclasses) | ❌ hand-write | JSON | 484| OpenAPI Generator | `openapi-generator-cli` | ✅ | ✅ generated | JSON | 485| Hand-written + TypedDicts | none | partial | ❌ hand-write | JSON | 486 487The 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. 488 489`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. 490 491## 16. Open questions to confirm before kickoff 492 493- **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. 494- **`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. 495- **`pyright` vs `mypy`**: pyright proposed for stricter inference and better IDE integration. mypy is fine as an alternative if maintainer preference differs. 496 497## 17. Detailed task breakdown 498 499Phases map to the milestones in §14. Each task is independently reviewable; ✅ when done. Tasks within a phase are roughly ordered; phases run sequentially. 500 501### Phase 0 — Repository scaffolding 502 503- [x] Initialise git repo and push `main` to `github.com/openstatusHQ/sdk-python` 504- [x] Add `LICENSE` (MIT, matching sdk-php and sdk-node) 505- [x] Add `.gitignore` (Python: `__pycache__`, `.venv`, `dist`, `build`, `*.egg-info`, `.pytest_cache`, `.ruff_cache`) 506- [x] Add `.editorconfig` (4-space indent for `.py`, LF line endings, trim trailing whitespace) 507- [x] Add `README.md` skeleton (title, one-line description, "Status: pre-alpha" badge, placeholder TOC) 508- [x] Add empty `CHANGELOG.md` with `## [Unreleased]` header (Keep a Changelog format) 509- [x] Add `CONTRIBUTING.md` skeleton (sections to fill in as decisions land: dev setup, regen, tests, PR conventions) 510 511### Phase 1 — Tooling & project metadata 512 513- [x] Write `pyproject.toml` per §5 (project metadata, deps, dependency-groups, hatchling build, tool configs) 514- [x] Add `ruff.toml` (or inline under `[tool.ruff]` in `pyproject.toml`) 515- [x] Add `pyrightconfig.json` (strict on `src/openstatus/`, exclude `_gen/`) 516- [x] Create `src/openstatus/__init__.py` (empty, to be populated in Phase 5) 517- [x] Create `src/openstatus/py.typed` (empty marker file, PEP 561) 518- [x] Run `uv sync` to install dev deps, commit `uv.lock` 519- [x] Verify `uv run ruff check .` and `uv run pyright` both pass on empty package 520 521### Phase 2 — Codegen pipeline 522 523- [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) 524- [x] Make `scripts/regen.sh` executable (`chmod +x`) 525- [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 526- [x] Verify `from openstatus._gen.openstatus.monitor.v1 import monitor_pb2` works from a python REPL 527- [x] Verify `MessageToJson(monitor_pb2.ListMonitorsRequest())` and `Parse('{}', monitor_pb2.ListMonitorsResponse())` round-trip without errors 528- [x] Commit `src/openstatus/_gen/` tree and `VERSION` file 529- [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` 530 531### Phase 3 — Exception hierarchy & error mapper 532 533- [x] Write `src/openstatus/exceptions.py`: 534 - [x] `OpenstatusError(Exception)` base with `connect_code`, `http_status`, `details`, `raw_body` attributes 535 - [x] `AuthenticationError(OpenstatusError)` 536 - [x] `NotFoundError(OpenstatusError)` 537 - [x] `InvalidArgumentError(OpenstatusError)` 538 - [x] `PermissionDeniedError(OpenstatusError)` 539 - [x] `RateLimitError(OpenstatusError)` 540 - [x] `ServiceUnavailableError(OpenstatusError)` 541- [x] Write `src/openstatus/_errors.py` with `_ConnectErrorMapper.rethrow(httpx.Response)`: 542 - [x] Decode `connect.error` envelope (`code`, `message`, `details`) 543 - [x] Map `code` → exception class per §10 table 544 - [x] Fallback to `ServiceUnavailableError` when body is not valid JSON envelope 545 - [x] Preserve `raw_body`, `http_status`, `connect_code` on every raised exception 546- [x] Add unit tests `tests/unit/test_errors.py`: 547 - [x] Each Connect code maps to its expected exception class 548 - [x] Missing envelope → `ServiceUnavailableError` with `connect_code="unknown"` 549 - [x] HTML 502 body → `ServiceUnavailableError` with `raw_body` preserved 550 - [x] `OpenstatusError.__str__` returns envelope message or HTTP reason phrase 551 552### Phase 4 — Transport layer 553 554- [x] Write `src/openstatus/_client_options.py` (frozen kw-only slotted dataclass per §6) 555- [x] Write `src/openstatus/_transport.py`: 556 - [x] `_JsonTransport.__init__(client, base_url, default_headers)` 557 - [x] `_JsonTransport.call(service_fqn, method, request, response_cls, extra_headers)` per §8 558 - [x] `_AsyncJsonTransport` with `async def call(...)` mirroring sync surface 559- [x] Add unit tests `tests/unit/test_transport.py` using `httpx.MockTransport`: 560 - [x] URL is `/rpc/openstatus.monitor.v1.MonitorService/ListMonitors` 561 - [x] Default headers (`Content-Type`, `Accept`, `User-Agent`, `x-openstatus-key`) sent on every request 562 - [x] Per-call `extra_headers` override defaults 563 - [x] Request body is valid JSON, deserialisable into the original message 564 - [x] Response body is parsed into the expected message type 565 - [x] 4xx response triggers `_ConnectErrorMapper.rethrow` 566- [x] Parametrise test fixtures so the same cases run against sync and async transports 567 568### Phase 5 — Root client & namespace sugar 569 570- [x] Resolve `_resolve_options(opts: ClientOptions) -> _ResolvedOptions` helper: 571 - [x] Read `OPENSTATUS_API_KEY` env var as `api_key` fallback 572 - [x] Read `OPENSTATUS_API_URL` env var as `base_url` fallback, default `https://api.openstatus.dev` 573 - [x] Build default header dict including `User-Agent` from `importlib.metadata.version("openstatus")` and `platform.python_version()` 574- [x] Write `src/openstatus/client.py`: 575 - [x] `OpenstatusClient.__init__(options: ClientOptions | None = None)` — constructs `httpx.Client` if not user-supplied, builds `_JsonTransport`, instantiates namespace holders 576 - [x] `__enter__` / `__exit__` for context-manager use 577 - [x] `.close()` — closes owned `httpx.Client` (no-op if user supplied one) 578 - [x] Nested holder classes for `monitor`, `health`, `status_report`, `status_page`, `maintenance`, `notification`; each has a `.v1` attribute holding the service client(s) 579 - [x] `AsyncOpenstatusClient` with `async __aenter__` / `__aexit__` / `aclose()` mirror 580- [x] Populate `src/openstatus/__init__.py`: 581 - [x] Re-export `OpenstatusClient`, `AsyncOpenstatusClient`, `ClientOptions` 582 - [x] Re-export all exception classes 583 - [x] Set `__all__` and `__version__` 584- [x] Add unit tests `tests/unit/test_client.py`: 585 - [x] Env-var fallbacks (`OPENSTATUS_API_KEY`, `OPENSTATUS_API_URL`) 586 - [x] User-supplied `httpx.Client` is reused, not replaced 587 - [x] Context-manager closes only SDK-owned clients 588 - [x] `client.monitor.v1.MonitorService` resolves to the expected service client type 589 590### Phase 6 — Service wrappers (one per service) 591 592For 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. 593 594- [x] `src/openstatus/services/health.py` — `HealthServiceClient.check` + async variant (1 RPC) 595- [x] `src/openstatus/services/monitor.py` — 14 RPCs × 2 (sync + async): 596 - [x] `create_http_monitor`, `create_tcp_monitor`, `create_dns_monitor` 597 - [x] `update_http_monitor`, `update_tcp_monitor`, `update_dns_monitor` 598 - [x] `get_monitor`, `list_monitors`, `trigger_monitor`, `delete_monitor` 599 - [x] `get_monitor_status`, `get_monitor_summary` 600 - [x] `list_monitor_http_response_logs`, `get_monitor_http_response_log` 601- [x] `src/openstatus/services/status_report.py` — 6 RPCs × 2 602- [x] `src/openstatus/services/status_page.py` — 18 RPCs × 2 603- [x] `src/openstatus/services/maintenance.py` — 5 RPCs × 2 604- [x] `src/openstatus/services/notification.py` — 7 RPCs × 2 605- [x] `src/openstatus/services/__init__.py` re-exports all service client classes 606- [x] Unit tests `tests/unit/services/test_<service>.py` for each service (one parametrised test covering all RPCs is acceptable) 607 608### Phase 7 — Integration tests (local-only) 609 610- [x] `tests/integration/conftest.py` with `pytest.skip` if `OPENSTATUS_API_KEY` env var absent 611- [x] `tests/integration/test_health.py` — real `HealthService.check` round-trip 612- [x] `tests/integration/test_monitor_list.py` — real `MonitorService.list_monitors` against authenticated account 613- [x] Document in `CONTRIBUTING.md` how to run: `OPENSTATUS_API_KEY=... uv run pytest tests/integration` 614 615### Phase 8 — Examples 616 617- [x] `examples/basic.py` — sync port of `sdk-node/example.ts` (health, list, logs, log detail) 618- [x] `examples/basic_async.py` — async equivalent of `basic.py` 619- [x] `examples/create_monitor.py` — full HTTPMonitor with `Periodicity`, `Region`, `HTTPMethod`, `StatusCodeAssertion` 620- [x] Each example: standalone, runnable with `uv run python examples/<name>.py`, no extra deps 621 622### Phase 9 — README & docs 623 624- [x] Port full TOC from sdk-node README 625- [x] "Installation" — `pip install openstatus` / `uv add openstatus` 626- [x] "Quickstart" — sync + async snippets side by side 627- [x] "Authentication" — env var + explicit `api_key` arg 628- [x] "Custom HTTP client" — supplying `httpx.Client` with custom timeouts/transport 629- [x] Per-service sections (Health, Monitor, StatusReport, StatusPage, Maintenance, Notification) with one example per RPC 630- [x] "Error handling" — exception hierarchy, catching `OpenstatusError`, inspecting `connect_code` / `http_status` / `details` 631- [x] "Regions / Enums" reference — how to import from `_gen` and reference enum constants 632- [x] "FastAPI integration" recipe (~5 lines, `Depends(get_client)`) 633- [x] "Django integration" recipe (~5 lines, settings-backed singleton) 634- [x] "Migration from Node SDK" note — camelCase → snake_case mapping 635- [x] Move `plan.md` → `docs/decisions.md` after v0.1.0 tag (not before — keep planning artifact at root during build-out) 636 637### Phase 10 — CI 638 639- [x] `.github/workflows/ci.yml`: 640 - [x] Trigger on `push` to `main` and `pull_request` 641 - [x] Matrix: Python 3.10, 3.11, 3.12, 3.13 × ubuntu-latest 642 - [x] Steps: checkout → install uv → `uv sync` → `uv run ruff check .` → `uv run ruff format --check .` → `uv run pyright` → `uv run pytest tests/unit` 643- [x] `.github/workflows/regen.yml`: 644 - [x] Weekly cron + `workflow_dispatch` 645 - [x] Fetch latest `protocolbuffers/python` archive version from `buf.build/openstatus/api` 646 - [x] If newer than pinned version: edit `BUF_PY_VERSION` in `scripts/regen.sh`, run regen, open PR with the diff 647- [x] `.github/workflows/release.yml`: 648 - [x] Trigger on `v*` tags 649 - [x] Steps: checkout → install uv → `uv build` → publish to PyPI via Trusted Publishers (`pypa/gh-action-pypi-publish`) 650- [x] Add status badges to `README.md` (CI, PyPI version, supported Python versions) 651 652### Phase 11 — Release v0.1.0 653 654- [x] Set up PyPI Trusted Publisher for `openstatusHQ/sdk-python` → project `openstatus` 655- [x] Final `CHANGELOG.md` pass: move `[Unreleased]` content under `[0.1.0] — YYYY-MM-DD` 656- [x] Verify `uv build` produces clean sdist + wheel; inspect wheel contents include `_gen/`, `py.typed`, `*.pyi` 657- [x] Smoke-test the built wheel in a fresh venv: `pip install dist/openstatus-0.1.0-*.whl && python -c "from openstatus import OpenstatusClient; ..."` 658- [x] Tag `v0.1.0`, push tag, verify `release.yml` publishes to PyPI 659- [x] Verify install from PyPI: `pip install openstatus` in a fresh venv works end-to-end 660- [x] Move `plan.md` → `docs/decisions.md`; update README link 661- [x] Announce on Openstatus channels (blog post, X, Discord — coordinated with team) 662