diff --git a/src/devhelm/__init__.py b/src/devhelm/__init__.py index 4822bb8..b0bd5ff 100644 --- a/src/devhelm/__init__.py +++ b/src/devhelm/__init__.py @@ -24,6 +24,7 @@ from devhelm.resources.environments import Environments from devhelm.resources.forensics import Forensics from devhelm.resources.incidents import Incidents +from devhelm.resources.maintenance_windows import MaintenanceWindows from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups @@ -51,6 +52,7 @@ CreateAlertChannelRequest, CreateApiKeyRequest, CreateEnvironmentRequest, + CreateMaintenanceWindowRequest, CreateManualIncidentRequest, CreateMonitorRequest, CreateNotificationPolicyRequest, @@ -77,6 +79,7 @@ IncidentTimelineDto, IncidentUpdateCreatedBy, LinkedIncidentStatus, + MaintenanceWindowDto, MembershipStatus, MemberStatus, MonitorAssertionSeverity, @@ -122,6 +125,7 @@ UpdateAlertChannelRequest, UpdateAssertionSeverity, UpdateEnvironmentRequest, + UpdateMaintenanceWindowRequest, UpdateMonitorRequest, UpdateNotificationPolicyRequest, UpdateResourceGroupRequest, @@ -178,6 +182,7 @@ "ApiKeys", "Dependencies", "DeployLock", + "MaintenanceWindows", "Status", "StatusPages", # Response DTOs @@ -190,6 +195,7 @@ "StatusPageSubscriberDto", "StatusPageCustomDomainDto", "StatusPageBranding", + "MaintenanceWindowDto", "MonitorDto", "IncidentDto", "IncidentDetailDto", @@ -231,6 +237,8 @@ "AdminAddSubscriberRequest", "ReorderComponentsRequest", "ReorderPageLayoutRequest", + "CreateMaintenanceWindowRequest", + "UpdateMaintenanceWindowRequest", "CreateMonitorRequest", "UpdateMonitorRequest", "CreateManualIncidentRequest", diff --git a/src/devhelm/client.py b/src/devhelm/client.py index 2c3f9a0..b6f310e 100644 --- a/src/devhelm/client.py +++ b/src/devhelm/client.py @@ -10,6 +10,7 @@ from devhelm.resources.environments import Environments from devhelm.resources.forensics import Forensics from devhelm.resources.incidents import Incidents +from devhelm.resources.maintenance_windows import MaintenanceWindows from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups @@ -51,6 +52,7 @@ class Devhelm: api_keys: ApiKeys dependencies: Dependencies deploy_lock: DeployLock + maintenance_windows: MaintenanceWindows status: Status status_pages: StatusPages @@ -96,5 +98,6 @@ def __init__( self.api_keys = ApiKeys(client) self.dependencies = Dependencies(client) self.deploy_lock = DeployLock(client) + self.maintenance_windows = MaintenanceWindows(client) self.status = Status(client) self.status_pages = StatusPages(client) diff --git a/src/devhelm/resources/maintenance_windows.py b/src/devhelm/resources/maintenance_windows.py new file mode 100644 index 0000000..b72b2dd --- /dev/null +++ b/src/devhelm/resources/maintenance_windows.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import httpx + +from devhelm._generated import ( + CreateMaintenanceWindowRequest, + MaintenanceWindowDto, + UpdateMaintenanceWindowRequest, +) +from devhelm._http import api_delete, api_get, api_post, api_put, path_param +from devhelm._pagination import Page, fetch_all_pages, fetch_page +from devhelm._validation import RequestBody, parse_single, validate_request + +# Query-param values for ``GET /api/v1/maintenance-windows``. Both +# documented filters (``monitorId``, ``filter``) are single-valued strings; +# spelt as a concrete alias rather than ``Any`` so the resource layer stays +# ``Any``-free per ``tests/test_typing.py``. +_ListFilterValue = str + + +def _build_list_filters( + monitor_id: str | None, status: str | None +) -> dict[str, _ListFilterValue]: + """Pack the documented ``GET /api/v1/maintenance-windows`` query + params into a single dict, dropping anything left at the default + ``None`` so the wire request stays minimal and the API's defaults + apply. + + Accepts snake_case at the Python boundary and emits the camelCase + spelling the API expects (``monitor_id`` → ``monitorId``). The + ergonomic ``status`` kwarg is mapped to the API's ``filter`` query + param (which selects ``"active"`` or ``"upcoming"`` windows). + """ + filters: dict[str, _ListFilterValue] = {} + if monitor_id is not None: + filters["monitorId"] = monitor_id + if status is not None: + filters["filter"] = status + return filters + + +class MaintenanceWindows: + """Scheduled maintenance windows that suppress alerts during planned downtime.""" + + def __init__(self, client: httpx.Client) -> None: + self._client = client + + def list( + self, *, monitor_id: str | None = None, status: str | None = None + ) -> list[MaintenanceWindowDto]: + """List all maintenance windows for the authenticated org (auto-paginates). + + Optional server-side filters mirror the documented + ``GET /api/v1/maintenance-windows`` query params: + + * ``monitor_id`` — only return windows attached to this monitor. + * ``status`` — ``"active"`` (currently in window) or + ``"upcoming"`` (starts in the future). + + Examples: + >>> client.maintenance_windows.list() + [MaintenanceWindowDto(...), ...] + >>> client.maintenance_windows.list(status="upcoming") + [...] + """ + # The API's documented query string omits ``page``/``size``, but the + # response envelope is the standard ``TableValueResult`` shape that + # all paginated lists in this codebase use, so the server accepts + # those keys and ignores them where unused. Reusing + # ``fetch_all_pages`` keeps the iterator semantics consistent with + # every other ``MaintenanceWindows.list``-style method on this SDK. + return fetch_all_pages( + self._client, + "/api/v1/maintenance-windows", + MaintenanceWindowDto, + extra_params=_build_list_filters(monitor_id, status), + ) + + def list_page( + self, + page: int, + size: int, + *, + monitor_id: str | None = None, + status: str | None = None, + ) -> Page[MaintenanceWindowDto]: + """List maintenance windows with manual page control. + + Accepts the same filter kwargs as :meth:`list` so callers using + manual pagination get the same server-side filtering. + """ + return fetch_page( + self._client, + "/api/v1/maintenance-windows", + MaintenanceWindowDto, + page, + size, + extra_params=_build_list_filters(monitor_id, status), + ) + + def get(self, id: str) -> MaintenanceWindowDto: + """Get a single maintenance window by ID. + + Examples: + >>> client.maintenance_windows.get("a8e3...") + MaintenanceWindowDto(...) + """ + return parse_single( + MaintenanceWindowDto, + api_get(self._client, f"/api/v1/maintenance-windows/{path_param(id)}"), + f"GET /api/v1/maintenance-windows/{id}", + ) + + def create( + self, body: RequestBody[CreateMaintenanceWindowRequest] + ) -> MaintenanceWindowDto: + """Create a new maintenance window. + + Pass ``monitorId=None`` to create an org-wide window that + suppresses alerts for every monitor in the organisation; pass a + UUID to scope the window to a single monitor. + + Examples: + >>> from datetime import datetime, timedelta, timezone + >>> start = datetime.now(timezone.utc) + timedelta(hours=1) + >>> client.maintenance_windows.create({ + ... "startsAt": start.isoformat(), + ... "endsAt": (start + timedelta(hours=2)).isoformat(), + ... "reason": "Quarterly DB upgrade", + ... "monitorId": "a8e3...", + ... }) + MaintenanceWindowDto(...) + """ + body = validate_request( + CreateMaintenanceWindowRequest, body, "maintenanceWindows.create" + ) + return parse_single( + MaintenanceWindowDto, + api_post(self._client, "/api/v1/maintenance-windows", body), + "POST /api/v1/maintenance-windows", + ) + + def update( + self, id: str, body: RequestBody[UpdateMaintenanceWindowRequest] + ) -> MaintenanceWindowDto: + """Update an existing maintenance window. + + Examples: + >>> client.maintenance_windows.update("a8e3...", { + ... "startsAt": "2026-06-01T00:00:00Z", + ... "endsAt": "2026-06-01T02:00:00Z", + ... "reason": "Rescheduled DB upgrade", + ... }) + MaintenanceWindowDto(...) + """ + body = validate_request( + UpdateMaintenanceWindowRequest, body, "maintenanceWindows.update" + ) + return parse_single( + MaintenanceWindowDto, + api_put( + self._client, f"/api/v1/maintenance-windows/{path_param(id)}", body + ), + f"PUT /api/v1/maintenance-windows/{id}", + ) + + def delete(self, id: str) -> None: + """Delete (cancel) a maintenance window. + + The API exposes a ``DELETE`` operation for this resource; if you + prefer the cancellation-style verb in your code, see + :meth:`cancel`, which is a thin alias. + """ + api_delete(self._client, f"/api/v1/maintenance-windows/{path_param(id)}") + + def cancel(self, id: str) -> None: + """Cancel a scheduled maintenance window. + + Semantic alias for :meth:`delete` — both call the same underlying + ``DELETE /api/v1/maintenance-windows/{id}`` endpoint, but + ``cancel`` reads better in automation scripts that schedule and + later cancel planned downtime. + + Examples: + >>> client.maintenance_windows.cancel("a8e3...") + """ + self.delete(id) diff --git a/src/devhelm/types.py b/src/devhelm/types.py index 0fe2f69..2996623 100644 --- a/src/devhelm/types.py +++ b/src/devhelm/types.py @@ -51,6 +51,7 @@ CreateAlertChannelRequest, CreateApiKeyRequest, CreateEnvironmentRequest, + CreateMaintenanceWindowRequest, CreateManualIncidentRequest, CreateMonitorRequest, CreateNotificationPolicyRequest, @@ -74,6 +75,7 @@ IncidentMode, IncidentStateTransitionDto, IncidentTimelineDto, + MaintenanceWindowDto, ManagedBy, Method, MonitorDto, @@ -112,6 +114,7 @@ TierAvailability, UpdateAlertChannelRequest, UpdateEnvironmentRequest, + UpdateMaintenanceWindowRequest, UpdateMonitorRequest, UpdateNotificationPolicyRequest, UpdateResourceGroupRequest, @@ -244,6 +247,7 @@ "CreateStatusPageComponentRequest", "CreateStatusPageIncidentRequest", "CreateStatusPageIncidentUpdateRequest", + "CreateMaintenanceWindowRequest", "CreateStatusPageRequest", "CreateTagRequest", "CreateWebhookEndpointRequest", @@ -254,6 +258,7 @@ "IncidentDto", "IncidentStateTransitionDto", "IncidentTimelineDto", + "MaintenanceWindowDto", "MonitorDto", "MonitorVersionDto", "NotificationPolicyDto", @@ -280,6 +285,7 @@ "TestChannelResult", "UpdateAlertChannelRequest", "UpdateEnvironmentRequest", + "UpdateMaintenanceWindowRequest", "UpdateMonitorRequest", "UpdateNotificationPolicyRequest", "UpdateResourceGroupRequest", diff --git a/tests/test_client.py b/tests/test_client.py index c3e8a30..80e9b0b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,6 +14,7 @@ from devhelm.resources.environments import Environments from devhelm.resources.forensics import Forensics from devhelm.resources.incidents import Incidents +from devhelm.resources.maintenance_windows import MaintenanceWindows from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups @@ -83,6 +84,16 @@ def test_status(self, client: Devhelm) -> None: def test_status_pages(self, client: Devhelm) -> None: assert isinstance(client.status_pages, StatusPages) + def test_maintenance_windows(self, client: Devhelm) -> None: + assert isinstance(client.maintenance_windows, MaintenanceWindows) + assert callable(client.maintenance_windows.list) + assert callable(client.maintenance_windows.list_page) + assert callable(client.maintenance_windows.get) + assert callable(client.maintenance_windows.create) + assert callable(client.maintenance_windows.update) + assert callable(client.maintenance_windows.delete) + assert callable(client.maintenance_windows.cancel) + class TestStatusPagesResource: """Verify StatusPages exposes all sub-resources and methods.""" diff --git a/tests/test_maintenance_windows.py b/tests/test_maintenance_windows.py new file mode 100644 index 0000000..8e30d6c --- /dev/null +++ b/tests/test_maintenance_windows.py @@ -0,0 +1,292 @@ +"""Tests for the ``MaintenanceWindows`` resource module. + +The shape of these tests mirrors ``test_client.TestMonitorsListFilters``: +spin up an ``httpx.MockTransport``, point a real resource instance at it, +and assert the resulting ``httpx.Request`` carries the wire-level URL, +method, query-string, and JSON body the API documents. + +The aim is twofold: + +* Confirm that ergonomic snake_case kwargs (``monitor_id``, ``status``) + are projected onto the camelCase / API-specific names + (``monitorId``, ``filter``) the server expects. +* Lock in the URL-and-verb contract for ``create`` / ``update`` / + ``delete`` / ``cancel`` so a future refactor can't silently change + what the SDK sends to production. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from devhelm.resources.maintenance_windows import MaintenanceWindows + +# --------------------------------------------------------------------------- +# Fixtures: shared mock transport + resource builder +# --------------------------------------------------------------------------- + + +_VALID_WINDOW = { + "id": "11111111-1111-1111-1111-111111111111", + "monitorId": "22222222-2222-2222-2222-222222222222", + "organizationId": 1, + "startsAt": "2026-06-01T00:00:00Z", + "endsAt": "2026-06-01T02:00:00Z", + "repeatRule": None, + "reason": "Quarterly DB upgrade", + "suppressAlerts": True, + "createdAt": "2026-05-01T12:00:00Z", +} + + +def _stub_transport( + captured: list[httpx.Request], + *, + list_response: dict[str, object] | None = None, + single_response: dict[str, object] | None = None, + delete_status: int = 204, +) -> httpx.MockTransport: + """Capture every outgoing request and return JSON shaped like the API. + + Routes to the right canned response based on method + path so a + single transport can serve every test below without each test having + to define its own handler. + """ + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + method = request.method + path = request.url.path + if method == "GET" and path == "/api/v1/maintenance-windows": + body = list_response or { + "data": [_VALID_WINDOW], + "hasNext": False, + "hasPrev": False, + } + return httpx.Response(200, json=body) + if method == "GET" and path.startswith("/api/v1/maintenance-windows/"): + return httpx.Response(200, json=single_response or {"data": _VALID_WINDOW}) + if method == "POST" and path == "/api/v1/maintenance-windows": + return httpx.Response(201, json=single_response or {"data": _VALID_WINDOW}) + if method == "PUT" and path.startswith("/api/v1/maintenance-windows/"): + return httpx.Response(200, json=single_response or {"data": _VALID_WINDOW}) + if method == "DELETE" and path.startswith("/api/v1/maintenance-windows/"): + return httpx.Response(delete_status) + # Surface routing mistakes loudly rather than silently 404'ing. + raise AssertionError(f"unexpected {method} {path}") + + return httpx.MockTransport(handler) + + +def _resource(transport: httpx.MockTransport) -> MaintenanceWindows: + http_client = httpx.Client(transport=transport, base_url="http://localhost:8080") + return MaintenanceWindows(http_client) + + +# --------------------------------------------------------------------------- +# list / list_page — query-param threading +# --------------------------------------------------------------------------- + + +class TestList: + """The two documented filters (``monitorId``, ``filter``) must reach + the wire under their server-expected names so callers don't have to + drop down to ``httpx`` for server-side filtering. + """ + + def test_list_threads_filters_to_query_string(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + windows.list( + monitor_id="22222222-2222-2222-2222-222222222222", status="upcoming" + ) + + assert len(captured) == 1 + params = captured[0].url.params + # snake_case ``monitor_id`` must be projected onto camelCase wire + # name; ergonomic ``status`` kwarg must map to API's ``filter`` key. + assert params["monitorId"] == "22222222-2222-2222-2222-222222222222" + assert params["filter"] == "upcoming" + + def test_list_omits_unspecified_filters(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + windows.list() + + assert len(captured) == 1 + params = captured[0].url.params + assert "monitorId" not in params + assert "filter" not in params + # Pagination keys are always sent so ``fetch_all_pages`` can drive + # the iterator deterministically. + assert params["page"] == "0" + + def test_list_returns_parsed_models(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + result = windows.list() + + assert len(result) == 1 + assert str(result[0].id) == "11111111-1111-1111-1111-111111111111" + assert result[0].reason == "Quarterly DB upgrade" + + def test_list_page_threads_filters(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + page = windows.list_page(2, 50, monitor_id="abc", status="active") + + assert page.total_elements is None # not returned by stub + assert len(captured) == 1 + params = captured[0].url.params + assert params["monitorId"] == "abc" + assert params["filter"] == "active" + assert params["page"] == "2" + assert params["size"] == "50" + + +# --------------------------------------------------------------------------- +# get — URL templating + envelope unwrap +# --------------------------------------------------------------------------- + + +class TestGet: + def test_get_hits_resource_url(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + result = windows.get("11111111-1111-1111-1111-111111111111") + + assert len(captured) == 1 + assert captured[0].method == "GET" + assert ( + captured[0].url.path + == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111" + ) + assert str(result.id) == "11111111-1111-1111-1111-111111111111" + + +# --------------------------------------------------------------------------- +# create — body validation + JSON shape +# --------------------------------------------------------------------------- + + +class TestCreate: + def test_create_posts_validated_body(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + result = windows.create( + { + "startsAt": "2026-06-01T00:00:00Z", + "endsAt": "2026-06-01T02:00:00Z", + "reason": "Quarterly DB upgrade", + "monitorId": "22222222-2222-2222-2222-222222222222", + "suppressAlerts": True, + } + ) + + assert len(captured) == 1 + request = captured[0] + assert request.method == "POST" + assert request.url.path == "/api/v1/maintenance-windows" + body = json.loads(request.content) + # Keys go on the wire as the camelCase aliases the API documents. + assert body["startsAt"] == "2026-06-01T00:00:00Z" + assert body["endsAt"] == "2026-06-01T02:00:00Z" + assert body["reason"] == "Quarterly DB upgrade" + assert body["monitorId"] == "22222222-2222-2222-2222-222222222222" + assert body["suppressAlerts"] is True + # The ``data`` envelope must be unwrapped into a typed model so + # callers don't get a raw dict. + assert str(result.id) == "11111111-1111-1111-1111-111111111111" + + def test_create_rejects_missing_required_fields(self) -> None: + # Missing ``endsAt`` — Pydantic must reject before the HTTP call, + # so ``captured`` stays empty. + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + # ``DevhelmValidationError`` extends ``DevhelmError`` which extends + # ``Exception``; checking for ``Exception`` keeps the assertion + # robust against future re-shuffles of the error hierarchy while + # still ensuring *something* failed loudly before any network IO. + with pytest.raises(Exception, match="Request validation failed"): + windows.create({"startsAt": "2026-06-01T00:00:00Z"}) + assert captured == [] + + +# --------------------------------------------------------------------------- +# update — URL templating + body shape +# --------------------------------------------------------------------------- + + +class TestUpdate: + def test_update_puts_to_resource_url(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + windows.update( + "11111111-1111-1111-1111-111111111111", + { + "startsAt": "2026-06-02T00:00:00Z", + "endsAt": "2026-06-02T02:00:00Z", + "reason": "Rescheduled DB upgrade", + }, + ) + + assert len(captured) == 1 + request = captured[0] + assert request.method == "PUT" + assert ( + request.url.path + == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111" + ) + body = json.loads(request.content) + assert body["startsAt"] == "2026-06-02T00:00:00Z" + assert body["reason"] == "Rescheduled DB upgrade" + + +# --------------------------------------------------------------------------- +# delete / cancel — both must hit the same endpoint +# --------------------------------------------------------------------------- + + +class TestDeleteAndCancel: + """``cancel`` is a thin alias for ``delete`` so users can pick the + verb that reads better in their automation script. They must produce + identical wire requests. + """ + + def test_delete_hits_resource_url(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + result = windows.delete("11111111-1111-1111-1111-111111111111") + + assert result is None + assert len(captured) == 1 + assert captured[0].method == "DELETE" + assert ( + captured[0].url.path + == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111" + ) + + def test_cancel_aliases_delete(self) -> None: + captured: list[httpx.Request] = [] + windows = _resource(_stub_transport(captured)) + + windows.cancel("11111111-1111-1111-1111-111111111111") + + assert len(captured) == 1 + assert captured[0].method == "DELETE" + assert ( + captured[0].url.path + == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111" + ) diff --git a/tests/test_spec_parity.py b/tests/test_spec_parity.py index e3e289e..735fb30 100644 --- a/tests/test_spec_parity.py +++ b/tests/test_spec_parity.py @@ -36,6 +36,7 @@ from devhelm.resources.deploy_lock import DeployLock from devhelm.resources.environments import Environments from devhelm.resources.incidents import Incidents +from devhelm.resources.maintenance_windows import MaintenanceWindows from devhelm.resources.monitors import Monitors from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups @@ -130,6 +131,7 @@ def test_public_dto_exists_in_spec(dto_name: str, schemas: dict[str, Any]) -> No "CreateAlertChannelRequest", "CreateApiKeyRequest", "CreateEnvironmentRequest", + "CreateMaintenanceWindowRequest", "CreateManualIncidentRequest", "CreateMonitorRequest", "CreateNotificationPolicyRequest", @@ -146,6 +148,7 @@ def test_public_dto_exists_in_spec(dto_name: str, schemas: dict[str, Any]) -> No "ResolveIncidentRequest", "UpdateAlertChannelRequest", "UpdateEnvironmentRequest", + "UpdateMaintenanceWindowRequest", "UpdateMonitorRequest", "UpdateNotificationPolicyRequest", "UpdateResourceGroupRequest", @@ -213,6 +216,13 @@ def test_request_required_fields_match_spec( Monitors, [("create", "CreateMonitorRequest"), ("update", "UpdateMonitorRequest")], ), + ( + MaintenanceWindows, + [ + ("create", "CreateMaintenanceWindowRequest"), + ("update", "UpdateMaintenanceWindowRequest"), + ], + ), ( Incidents, [ @@ -356,6 +366,7 @@ def test_resource_method_body_accepts_dict( # so we check the leading segments). SDK_PATH_PREFIXES: list[str] = [ "/api/v1/monitors", + "/api/v1/maintenance-windows", "/api/v1/incidents", "/api/v1/alert-channels", "/api/v1/notification-policies",