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.
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.
| 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 |
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.
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.zipWhat 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.
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.
- 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.
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
[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.
# 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 callEnv fallbacks match Node and PHP:
OPENSTATUS_API_KEY— API keyOPENSTATUS_API_URL— base URL (defaulthttps://api.openstatus.dev)
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).
# 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.
# 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.
_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.
- 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.
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).
- 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.
- 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.
| 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.
- 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.
Phases map to the milestones in §14. Each task is independently reviewable; ✅ when done. Tasks within a phase are roughly ordered; phases run sequentially.
- 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)
- 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
- 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
- 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
- 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
- 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 (
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)
-
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
-
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
- 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)
-
.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)
- 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)