[READ-ONLY] Mirror of https://github.com/openstatusHQ/sdk-python. Official Python SDK for openstatus
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