[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

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)