Skip to content

Latest commit

 

History

History
662 lines (529 loc) · 37.6 KB

File metadata and controls

662 lines (529 loc) · 37.6 KB

Openstatus Python SDK — Implementation Plan

Build an official Python SDK for the Openstatus API, mirroring the ergonomics of @openstatusHQ/sdk-node and the architecture of 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: <token>
  • Path shape: POST /rpc/{package.Service}/{Method} with application/json body (Connect RPC's JSON mode).
  • Error envelope (Connect-style):
    { "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.10match, 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/<sdk-version> python/<python-version>
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:

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:

#!/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

[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)

# 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:

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:

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)

# 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/<sdk-version> python/<python-version>
  • x-openstatus-key: <api-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)

# 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 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. Bootstrappyproject.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 sugarOpenstatusClient 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. CIci.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.mddocs/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

  • Initialise git repo and push main to github.com/openstatusHQ/sdk-python
  • Add LICENSE (MIT, matching sdk-php and sdk-node)
  • Add .gitignore (Python: __pycache__, .venv, dist, build, *.egg-info, .pytest_cache, .ruff_cache)
  • Add .editorconfig (4-space indent for .py, LF line endings, trim trailing whitespace)
  • Add README.md skeleton (title, one-line description, "Status: pre-alpha" badge, placeholder TOC)
  • Add empty CHANGELOG.md with ## [Unreleased] header (Keep a Changelog format)
  • Add CONTRIBUTING.md skeleton (sections to fill in as decisions land: dev setup, regen, tests, PR conventions)

Phase 1 — Tooling & project metadata

  • Write pyproject.toml per §5 (project metadata, deps, dependency-groups, hatchling build, tool configs)
  • Add ruff.toml (or inline under [tool.ruff] in pyproject.toml)
  • Add pyrightconfig.json (strict on src/openstatus/, exclude _gen/)
  • Create src/openstatus/__init__.py (empty, to be populated in Phase 5)
  • Create src/openstatus/py.typed (empty marker file, PEP 561)
  • Run uv sync to install dev deps, commit uv.lock
  • Verify uv run ruff check . and uv run pyright both pass on empty package

Phase 2 — Codegen pipeline

  • 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)
  • Make scripts/regen.sh executable (chmod +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
  • Verify from openstatus._gen.openstatus.monitor.v1 import monitor_pb2 works from a python REPL
  • Verify MessageToJson(monitor_pb2.ListMonitorsRequest()) and Parse('{}', monitor_pb2.ListMonitorsResponse()) round-trip without errors
  • Commit src/openstatus/_gen/ tree and VERSION file
  • 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

  • Write src/openstatus/exceptions.py:
    • OpenstatusError(Exception) base with connect_code, http_status, details, raw_body attributes
    • AuthenticationError(OpenstatusError)
    • NotFoundError(OpenstatusError)
    • InvalidArgumentError(OpenstatusError)
    • PermissionDeniedError(OpenstatusError)
    • RateLimitError(OpenstatusError)
    • ServiceUnavailableError(OpenstatusError)
  • Write src/openstatus/_errors.py with _ConnectErrorMapper.rethrow(httpx.Response):
    • Decode connect.error envelope (code, message, details)
    • Map code → exception class per §10 table
    • Fallback to ServiceUnavailableError when body is not valid JSON envelope
    • Preserve raw_body, http_status, connect_code on every raised exception
  • Add unit tests tests/unit/test_errors.py:
    • Each Connect code maps to its expected exception class
    • Missing envelope → ServiceUnavailableError with connect_code="unknown"
    • HTML 502 body → ServiceUnavailableError with raw_body preserved
    • OpenstatusError.__str__ returns envelope message or HTTP reason phrase

Phase 4 — Transport layer

  • Write src/openstatus/_client_options.py (frozen kw-only slotted dataclass per §6)
  • Write src/openstatus/_transport.py:
    • _JsonTransport.__init__(client, base_url, default_headers)
    • _JsonTransport.call(service_fqn, method, request, response_cls, extra_headers) per §8
    • _AsyncJsonTransport with async def call(...) mirroring sync surface
  • Add unit tests tests/unit/test_transport.py using httpx.MockTransport:
    • URL is /rpc/openstatus.monitor.v1.MonitorService/ListMonitors
    • Default headers (Content-Type, Accept, User-Agent, x-openstatus-key) sent on every request
    • Per-call extra_headers override defaults
    • Request body is valid JSON, deserialisable into the original message
    • Response body is parsed into the expected message type
    • 4xx response triggers _ConnectErrorMapper.rethrow
  • Parametrise test fixtures so the same cases run against sync and async transports

Phase 5 — Root client & namespace sugar

  • Resolve _resolve_options(opts: ClientOptions) -> _ResolvedOptions helper:
    • Read OPENSTATUS_API_KEY env var as api_key fallback
    • Read OPENSTATUS_API_URL env var as base_url fallback, default https://api.openstatus.dev
    • Build default header dict including User-Agent from importlib.metadata.version("openstatus") and platform.python_version()
  • Write src/openstatus/client.py:
    • OpenstatusClient.__init__(options: ClientOptions | None = None) — constructs httpx.Client if not user-supplied, builds _JsonTransport, instantiates namespace holders
    • __enter__ / __exit__ for context-manager use
    • .close() — closes owned httpx.Client (no-op if user supplied one)
    • Nested holder classes for monitor, health, status_report, status_page, maintenance, notification; each has a .v1 attribute holding the service client(s)
    • AsyncOpenstatusClient with async __aenter__ / __aexit__ / aclose() mirror
  • Populate src/openstatus/__init__.py:
    • Re-export OpenstatusClient, AsyncOpenstatusClient, ClientOptions
    • Re-export all exception classes
    • Set __all__ and __version__
  • Add unit tests tests/unit/test_client.py:
    • Env-var fallbacks (OPENSTATUS_API_KEY, OPENSTATUS_API_URL)
    • User-supplied httpx.Client is reused, not replaced
    • Context-manager closes only SDK-owned clients
    • 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.

  • src/openstatus/services/health.pyHealthServiceClient.check + async variant (1 RPC)
  • src/openstatus/services/monitor.py — 14 RPCs × 2 (sync + async):
    • create_http_monitor, create_tcp_monitor, create_dns_monitor
    • update_http_monitor, update_tcp_monitor, update_dns_monitor
    • get_monitor, list_monitors, trigger_monitor, delete_monitor
    • get_monitor_status, get_monitor_summary
    • list_monitor_http_response_logs, get_monitor_http_response_log
  • src/openstatus/services/status_report.py — 6 RPCs × 2
  • src/openstatus/services/status_page.py — 18 RPCs × 2
  • src/openstatus/services/maintenance.py — 5 RPCs × 2
  • src/openstatus/services/notification.py — 7 RPCs × 2
  • src/openstatus/services/__init__.py re-exports all service client classes
  • Unit tests tests/unit/services/test_<service>.py for each service (one parametrised test covering all RPCs is acceptable)

Phase 7 — Integration tests (local-only)

  • tests/integration/conftest.py with pytest.skip if OPENSTATUS_API_KEY env var absent
  • tests/integration/test_health.py — real HealthService.check round-trip
  • tests/integration/test_monitor_list.py — real MonitorService.list_monitors against authenticated account
  • Document in CONTRIBUTING.md how to run: OPENSTATUS_API_KEY=... uv run pytest tests/integration

Phase 8 — Examples

  • examples/basic.py — sync port of sdk-node/example.ts (health, list, logs, log detail)
  • examples/basic_async.py — async equivalent of basic.py
  • examples/create_monitor.py — full HTTPMonitor with Periodicity, Region, HTTPMethod, StatusCodeAssertion
  • Each example: standalone, runnable with uv run python examples/<name>.py, no extra deps

Phase 9 — README & docs

  • Port full TOC from sdk-node README
  • "Installation" — pip install openstatus / uv add openstatus
  • "Quickstart" — sync + async snippets side by side
  • "Authentication" — env var + explicit api_key arg
  • "Custom HTTP client" — supplying httpx.Client with custom timeouts/transport
  • Per-service sections (Health, Monitor, StatusReport, StatusPage, Maintenance, Notification) with one example per RPC
  • "Error handling" — exception hierarchy, catching OpenstatusError, inspecting connect_code / http_status / details
  • "Regions / Enums" reference — how to import from _gen and reference enum constants
  • "FastAPI integration" recipe (~5 lines, Depends(get_client))
  • "Django integration" recipe (~5 lines, settings-backed singleton)
  • "Migration from Node SDK" note — camelCase → snake_case mapping
  • Move plan.mddocs/decisions.md after v0.1.0 tag (not before — keep planning artifact at root during build-out)

Phase 10 — CI

  • .github/workflows/ci.yml:
    • Trigger on push to main and pull_request
    • Matrix: Python 3.10, 3.11, 3.12, 3.13 × ubuntu-latest
    • Steps: checkout → install uv → uv syncuv run ruff check .uv run ruff format --check .uv run pyrightuv run pytest tests/unit
  • .github/workflows/regen.yml:
    • Weekly cron + workflow_dispatch
    • Fetch latest protocolbuffers/python archive version from buf.build/openstatus/api
    • If newer than pinned version: edit BUF_PY_VERSION in scripts/regen.sh, run regen, open PR with the diff
  • .github/workflows/release.yml:
    • Trigger on v* tags
    • Steps: checkout → install uv → uv build → publish to PyPI via Trusted Publishers (pypa/gh-action-pypi-publish)
  • Add status badges to README.md (CI, PyPI version, supported Python versions)

Phase 11 — Release v0.1.0

  • Set up PyPI Trusted Publisher for openstatusHQ/sdk-python → project openstatus
  • Final CHANGELOG.md pass: move [Unreleased] content under [0.1.0] — YYYY-MM-DD
  • Verify uv build produces clean sdist + wheel; inspect wheel contents include _gen/, py.typed, *.pyi
  • Smoke-test the built wheel in a fresh venv: pip install dist/openstatus-0.1.0-*.whl && python -c "from openstatus import OpenstatusClient; ..."
  • Tag v0.1.0, push tag, verify release.yml publishes to PyPI
  • Verify install from PyPI: pip install openstatus in a fresh venv works end-to-end
  • Move plan.mddocs/decisions.md; update README link
  • Announce on Openstatus channels (blog post, X, Discord — coordinated with team)