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}withapplication/jsonbody (Connect RPC's JSON mode). - Error envelope (Connect-style):
{ "code": "not_found", "message": "...", "details": [ ... ] }codeis 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:CheckMonitorService:CreateHTTPMonitor,CreateTCPMonitor,CreateDNSMonitor,UpdateHTTPMonitor,UpdateTCPMonitor,UpdateDNSMonitor,GetMonitor,ListMonitors,TriggerMonitor,DeleteMonitor,GetMonitorStatus,GetMonitorSummary,ListMonitorHTTPResponseLogs,GetMonitorHTTPResponseLogStatusReportService:CreateStatusReport,GetStatusReport,ListStatusReports,UpdateStatusReport,DeleteStatusReport,AddStatusReportUpdateStatusPageService:CreateStatusPage,GetStatusPage,ListStatusPages,UpdateStatusPage,DeleteStatusPage,GetStatusPageContent,GetOverallStatus,AddMonitorComponent,AddStaticComponent,RemoveComponent,UpdateComponent,CreateComponentGroup,UpdateComponentGroup,DeleteComponentGroup,SubscribeToPage,UnsubscribeFromPage,CreatePageSubscription,ListSubscribersMaintenanceService:CreateMaintenance,GetMaintenance,ListMaintenances,UpdateMaintenance,DeleteMaintenanceNotificationService:CreateNotification,GetNotification,ListNotifications,UpdateNotification,DeleteNotification,SendTestNotification,CheckNotificationLimit
HealthService.Check also accepts GET with a message query param; we only support POST to keep the transport uniform.
2. Foundational decisions#
| Area | Decision |
|---|---|
| Python minimum | 3.10 — match, X | Y unions, ParamSpec, kw-only dataclasses |
| HTTP client | httpx — modern, sync + async with shared API, typed, well-maintained |
| Concurrency | Sync + Async from v0.1 (OpenstatusClient and AsyncOpenstatusClient) |
| Wire format | JSON only (application/json) via google.protobuf.json_format |
| Default timeout | 30s, overridable per-client and per-call |
| Retries | None in v0.1 — users own their retry policy; httpx transports plug in cleanly |
| User-Agent | openstatus-python/<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.Clientandhttpx.AsyncClientshare 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.pyper proto file — message classes built ongoogle.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/pythononly emits messages) — we hand-write per-RPC dispatch. - No
pyproject.toml, no__init__.pyfiles — we generate empty__init__.pyshims during regen so the package tree imports cleanly.
What the pyi archive contains:
*_pb2.pyinext to each*_pb2.py— full type stubs soMonitorService.list_monitorsarguments 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#
- Find the latest archive version on
buf.build/openstatus/api/sdks/main:protocolbuffers/python. - Edit
BUF_PY_VERSIONinscripts/regen.sh. - Run
uv run regen(orbash scripts/regen.sh). - Commit
scripts/regen.sh+ the resultingsrc/openstatus/_gen/diff in the same PR. - The weekly
regen.ymlworkflow (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 keyOPENSTATUS_API_URL— base URL (defaulthttps://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/jsonAccept: application/jsonUser-Agent: openstatus-python/<sdk-version> python/<python-version>x-openstatus-key: <api-key>(ifapi_keyset)
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 rawdetailsarray 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)— themessagefrom 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.MockTransportorrespx, runs on every PR):- Transport sends the correct URL (
/rpc/openstatus.monitor.v1.MonitorService/ListMonitors). - Headers include
x-openstatus-key,User-Agent,Content-Type,Acceptwhen configured, plus per-call overrides. - JSON body round-trips: serializing a known message and decoding it back produces equivalent state.
- 4xx with
connect.errorbody → correct typed exception withconnect_code,http_status,detailspopulated. - Non-JSON 5xx →
ServiceUnavailableErrorwithraw_bodypreserved. - Both sync and async paths covered (parametrised fixture switches client class).
- Transport sends the correct URL (
- 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 onsrc/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 viahttpx.HTTPTransport(retries=N)). - The
int64representation note: python protobuf returnsintfor 64-bit fields on all platforms (no PHP-styleint|stringquirk — 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_barnotmsg.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, editsBUF_PY_VERSIONinscripts/regen.sh, runs the script, and opens a PR with both changes. The pin never moves silently — every bump is a reviewable commit.CHANGELOG.mdin Keep a Changelog format. Update on every release.CONTRIBUTING.mddocuments: how to bumpBUF_PY_VERSION, how to run tests (uv run pytest,uv run pyright,uv run ruff check), code style rules (ruff defaults +from __future__ import annotationsat top of every hand-written file), PR conventions.- This document was the original
plan.md; it moves todocs/decisions.mdpost-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#
- Bootstrap —
pyproject.toml,scripts/regen.sh,.editorconfig,.gitignore,ruff.toml,pyrightconfig.json, LICENSE, emptyCHANGELOG.md+CONTRIBUTING.md,src/openstatus/__init__.py+py.typed, run regen, commitsrc/openstatus/_gen/. - Transport + error mapping —
_JsonTransport,_AsyncJsonTransport,ClientOptions,_ConnectErrorMapper, typed exception hierarchy, unit tests withhttpx.MockTransport(parametrised sync/async). - HealthService end-to-end — smallest service, sync + async wrappers, manual integration check against the real API to validate the wire format.
- Remaining service wrappers — Monitor → StatusReport → StatusPage → Maintenance → Notification (one PR per service, each with sync + async + unit tests).
- Root client + namespace sugar —
OpenstatusClientandAsyncOpenstatusClientwith nested.v1.ServiceNameaccessors, env-var fallbacks, context-manager support. - README + examples — full port from Node SDK,
examples/basic.py+examples/basic_async.py+examples/create_monitor.py. - CI —
ci.yml(pytest unit + pyright + ruff, py3.10–3.13),regen.yml(weekly cron). - v0.1.0 release prep — Trusted Publisher setup on PyPI, tag, publish, move
plan.md→docs/decisions.md.
15. Trade-offs vs alternatives#
| Option | Codegen step | Type safety | Service stubs | Wire format |
|---|---|---|---|---|
| Buf archive + JSON (chosen) | curl + unzip | ✅ (pb2 + pyi) | ❌ hand-write | JSON |
Buf CLI + protoc-gen-python |
local buf generate |
✅ | ❌ hand-write | JSON or bin |
betterproto / protoplus |
dedicated codegen | ✅ (nicer dataclasses) | ❌ hand-write | JSON |
| OpenAPI Generator | openapi-generator-cli |
✅ | ✅ generated | JSON |
| Hand-written + TypedDicts | none | partial | ❌ hand-write | JSON |
The archive approach is the leanest: no toolchain dependency for contributors (just curl + unzip), type-safe message classes that match the proto source of truth byte-for-byte (with .pyi stubs for IDE/pyright), and only ~160 lines of transport (sync + async) + ~52 × 2 one-liners of service dispatch to maintain ourselves.
betterproto was considered for nicer dataclass-style messages, but it introduces a codegen toolchain dependency and its JSON output is not guaranteed to match Connect's JSON conventions — risk not worth it for v0.1.
16. Open questions to confirm before kickoff#
- Async-from-day-1: confirmed worth it (§2). If we want to ship faster, sync-only v0.1 is a valid fallback — the async transport can land in v0.2 without breaking changes since the class names are distinct.
google-protobufruntime version pin:>=5.28,<7covers the current major + the announced 6.x line. Tighten once we observe wheel compatibility on the CI matrix.pyrightvsmypy: 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
maintogithub.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.mdskeleton (title, one-line description, "Status: pre-alpha" badge, placeholder TOC) - Add empty
CHANGELOG.mdwith## [Unreleased]header (Keep a Changelog format) - Add
CONTRIBUTING.mdskeleton (sections to fill in as decisions land: dev setup, regen, tests, PR conventions)
Phase 1 — Tooling & project metadata#
- Write
pyproject.tomlper §5 (project metadata, deps, dependency-groups, hatchling build, tool configs) - Add
ruff.toml(or inline under[tool.ruff]inpyproject.toml) - Add
pyrightconfig.json(strict onsrc/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 syncto install dev deps, commituv.lock - Verify
uv run ruff check .anduv run pyrightboth pass on empty package
Phase 2 — Codegen pipeline#
- Write
scripts/regen.shper §3 (pinnedBUF_PY_VERSION, downloads bothpythonandpyiarchives, merges intosrc/openstatus/_gen/openstatus/, creates empty__init__.pyshims, writesVERSIONfile) - Make
scripts/regen.shexecutable (chmod +x) - Run
bash scripts/regen.shand verify it producessrc/openstatus/_gen/openstatus/{monitor,health,status_report,status_page,maintenance,notification}/v1/*_pb2.pyand matching.pyifiles - Verify
from openstatus._gen.openstatus.monitor.v1 import monitor_pb2works from a python REPL - Verify
MessageToJson(monitor_pb2.ListMonitorsRequest())andParse('{}', monitor_pb2.ListMonitorsResponse())round-trip without errors - Commit
src/openstatus/_gen/tree andVERSIONfile - Add
[tool.hatch.build.targets.wheel].force-includerule if needed so_gen/ships in the wheel; verify withuv build+unzip -l dist/*.whl
Phase 3 — Exception hierarchy & error mapper#
- Write
src/openstatus/exceptions.py:-
OpenstatusError(Exception)base withconnect_code,http_status,details,raw_bodyattributes -
AuthenticationError(OpenstatusError) -
NotFoundError(OpenstatusError) -
InvalidArgumentError(OpenstatusError) -
PermissionDeniedError(OpenstatusError) -
RateLimitError(OpenstatusError) -
ServiceUnavailableError(OpenstatusError)
-
- Write
src/openstatus/_errors.pywith_ConnectErrorMapper.rethrow(httpx.Response):- Decode
connect.errorenvelope (code,message,details) - Map
code→ exception class per §10 table - Fallback to
ServiceUnavailableErrorwhen body is not valid JSON envelope - Preserve
raw_body,http_status,connect_codeon every raised exception
- Decode
- Add unit tests
tests/unit/test_errors.py:- Each Connect code maps to its expected exception class
- Missing envelope →
ServiceUnavailableErrorwithconnect_code="unknown" - HTML 502 body →
ServiceUnavailableErrorwithraw_bodypreserved -
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 -
_AsyncJsonTransportwithasync def call(...)mirroring sync surface
-
- Add unit tests
tests/unit/test_transport.pyusinghttpx.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_headersoverride 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
- URL is
- Parametrise test fixtures so the same cases run against sync and async transports
Phase 5 — Root client & namespace sugar#
- Resolve
_resolve_options(opts: ClientOptions) -> _ResolvedOptionshelper:- Read
OPENSTATUS_API_KEYenv var asapi_keyfallback - Read
OPENSTATUS_API_URLenv var asbase_urlfallback, defaulthttps://api.openstatus.dev - Build default header dict including
User-Agentfromimportlib.metadata.version("openstatus")andplatform.python_version()
- Read
- Write
src/openstatus/client.py:-
OpenstatusClient.__init__(options: ClientOptions | None = None)— constructshttpx.Clientif not user-supplied, builds_JsonTransport, instantiates namespace holders -
__enter__/__exit__for context-manager use -
.close()— closes ownedhttpx.Client(no-op if user supplied one) - Nested holder classes for
monitor,health,status_report,status_page,maintenance,notification; each has a.v1attribute holding the service client(s) -
AsyncOpenstatusClientwithasync __aenter__/__aexit__/aclose()mirror
-
- Populate
src/openstatus/__init__.py:- Re-export
OpenstatusClient,AsyncOpenstatusClient,ClientOptions - Re-export all exception classes
- Set
__all__and__version__
- Re-export
- Add unit tests
tests/unit/test_client.py:- Env-var fallbacks (
OPENSTATUS_API_KEY,OPENSTATUS_API_URL) - User-supplied
httpx.Clientis reused, not replaced - Context-manager closes only SDK-owned clients
-
client.monitor.v1.MonitorServiceresolves to the expected service client type
- Env-var fallbacks (
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.py—HealthServiceClient.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__.pyre-exports all service client classes - Unit tests
tests/unit/services/test_<service>.pyfor each service (one parametrised test covering all RPCs is acceptable)
Phase 7 — Integration tests (local-only)#
-
tests/integration/conftest.pywithpytest.skipifOPENSTATUS_API_KEYenv var absent -
tests/integration/test_health.py— realHealthService.checkround-trip -
tests/integration/test_monitor_list.py— realMonitorService.list_monitorsagainst authenticated account - Document in
CONTRIBUTING.mdhow to run:OPENSTATUS_API_KEY=... uv run pytest tests/integration
Phase 8 — Examples#
-
examples/basic.py— sync port ofsdk-node/example.ts(health, list, logs, log detail) -
examples/basic_async.py— async equivalent ofbasic.py -
examples/create_monitor.py— full HTTPMonitor withPeriodicity,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_keyarg - "Custom HTTP client" — supplying
httpx.Clientwith custom timeouts/transport - Per-service sections (Health, Monitor, StatusReport, StatusPage, Maintenance, Notification) with one example per RPC
- "Error handling" — exception hierarchy, catching
OpenstatusError, inspectingconnect_code/http_status/details - "Regions / Enums" reference — how to import from
_genand 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.md→docs/decisions.mdafter v0.1.0 tag (not before — keep planning artifact at root during build-out)
Phase 10 — CI#
-
.github/workflows/ci.yml:- Trigger on
pushtomainandpull_request - Matrix: Python 3.10, 3.11, 3.12, 3.13 × ubuntu-latest
- Steps: checkout → install uv →
uv sync→uv run ruff check .→uv run ruff format --check .→uv run pyright→uv run pytest tests/unit
- Trigger on
-
.github/workflows/regen.yml:- Weekly cron +
workflow_dispatch - Fetch latest
protocolbuffers/pythonarchive version frombuf.build/openstatus/api - If newer than pinned version: edit
BUF_PY_VERSIONinscripts/regen.sh, run regen, open PR with the diff
- Weekly cron +
-
.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)
- Trigger on
- 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→ projectopenstatus - Final
CHANGELOG.mdpass: move[Unreleased]content under[0.1.0] — YYYY-MM-DD - Verify
uv buildproduces 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, verifyrelease.ymlpublishes to PyPI - Verify install from PyPI:
pip install openstatusin a fresh venv works end-to-end - Move
plan.md→docs/decisions.md; update README link - Announce on Openstatus channels (blog post, X, Discord — coordinated with team)