devhelm 0.6.3__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {devhelm-0.6.3 → devhelm-0.7.0}/PKG-INFO +1 -1
  2. {devhelm-0.6.3 → devhelm-0.7.0}/pyproject.toml +1 -1
  3. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/__init__.py +8 -0
  4. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/client.py +3 -0
  5. devhelm-0.7.0/src/devhelm/resources/maintenance_windows.py +187 -0
  6. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/types.py +6 -0
  7. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_client.py +11 -0
  8. devhelm-0.7.0/tests/test_maintenance_windows.py +292 -0
  9. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_spec_parity.py +11 -0
  10. {devhelm-0.6.3 → devhelm-0.7.0}/uv.lock +1 -1
  11. {devhelm-0.6.3 → devhelm-0.7.0}/.github/workflows/ci.yml +0 -0
  12. {devhelm-0.6.3 → devhelm-0.7.0}/.github/workflows/release.yml +0 -0
  13. {devhelm-0.6.3 → devhelm-0.7.0}/.github/workflows/spec-check.yml +0 -0
  14. {devhelm-0.6.3 → devhelm-0.7.0}/.gitignore +0 -0
  15. {devhelm-0.6.3 → devhelm-0.7.0}/LICENSE +0 -0
  16. {devhelm-0.6.3 → devhelm-0.7.0}/Makefile +0 -0
  17. {devhelm-0.6.3 → devhelm-0.7.0}/README.md +0 -0
  18. {devhelm-0.6.3 → devhelm-0.7.0}/docs/openapi/monitoring-api.json +0 -0
  19. {devhelm-0.6.3 → devhelm-0.7.0}/scripts/inject_strict_config.py +0 -0
  20. {devhelm-0.6.3 → devhelm-0.7.0}/scripts/regen-from.sh +0 -0
  21. {devhelm-0.6.3 → devhelm-0.7.0}/scripts/release.sh +0 -0
  22. {devhelm-0.6.3 → devhelm-0.7.0}/scripts/typegen.sh +0 -0
  23. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_errors.py +0 -0
  24. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_generated.py +0 -0
  25. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_http.py +0 -0
  26. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_pagination.py +0 -0
  27. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_validation.py +0 -0
  28. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/py.typed +0 -0
  29. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/__init__.py +0 -0
  30. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/alert_channels.py +0 -0
  31. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/api_keys.py +0 -0
  32. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/dependencies.py +0 -0
  33. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/deploy_lock.py +0 -0
  34. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/environments.py +0 -0
  35. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/forensics.py +0 -0
  36. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/incidents.py +0 -0
  37. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/monitors.py +0 -0
  38. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/notification_policies.py +0 -0
  39. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/resource_groups.py +0 -0
  40. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/secrets.py +0 -0
  41. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/status.py +0 -0
  42. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/status_pages.py +0 -0
  43. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/tags.py +0 -0
  44. {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/webhooks.py +0 -0
  45. {devhelm-0.6.3 → devhelm-0.7.0}/tests/__init__.py +0 -0
  46. {devhelm-0.6.3 → devhelm-0.7.0}/tests/run_sdk.py +0 -0
  47. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_errors.py +0 -0
  48. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_http.py +0 -0
  49. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_negative_validation.py +0 -0
  50. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_schemas.py +0 -0
  51. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_typing.py +0 -0
  52. {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_validation_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devhelm
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more
5
5
  Project-URL: Homepage, https://github.com/devhelmhq/sdk-python
6
6
  Project-URL: Repository, https://github.com/devhelmhq/sdk-python.git
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devhelm"
3
- version = "0.6.3"
3
+ version = "0.7.0"
4
4
  description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more"
5
5
  authors = [{ name = "DevHelm", email = "hello@devhelm.io" }]
6
6
  license = "MIT"
@@ -24,6 +24,7 @@ from devhelm.resources.deploy_lock import DeployLock
24
24
  from devhelm.resources.environments import Environments
25
25
  from devhelm.resources.forensics import Forensics
26
26
  from devhelm.resources.incidents import Incidents
27
+ from devhelm.resources.maintenance_windows import MaintenanceWindows
27
28
  from devhelm.resources.monitors import Monitors
28
29
  from devhelm.resources.notification_policies import NotificationPolicies
29
30
  from devhelm.resources.resource_groups import ResourceGroups
@@ -51,6 +52,7 @@ from devhelm.types import (
51
52
  CreateAlertChannelRequest,
52
53
  CreateApiKeyRequest,
53
54
  CreateEnvironmentRequest,
55
+ CreateMaintenanceWindowRequest,
54
56
  CreateManualIncidentRequest,
55
57
  CreateMonitorRequest,
56
58
  CreateNotificationPolicyRequest,
@@ -77,6 +79,7 @@ from devhelm.types import (
77
79
  IncidentTimelineDto,
78
80
  IncidentUpdateCreatedBy,
79
81
  LinkedIncidentStatus,
82
+ MaintenanceWindowDto,
80
83
  MembershipStatus,
81
84
  MemberStatus,
82
85
  MonitorAssertionSeverity,
@@ -122,6 +125,7 @@ from devhelm.types import (
122
125
  UpdateAlertChannelRequest,
123
126
  UpdateAssertionSeverity,
124
127
  UpdateEnvironmentRequest,
128
+ UpdateMaintenanceWindowRequest,
125
129
  UpdateMonitorRequest,
126
130
  UpdateNotificationPolicyRequest,
127
131
  UpdateResourceGroupRequest,
@@ -178,6 +182,7 @@ __all__ = [
178
182
  "ApiKeys",
179
183
  "Dependencies",
180
184
  "DeployLock",
185
+ "MaintenanceWindows",
181
186
  "Status",
182
187
  "StatusPages",
183
188
  # Response DTOs
@@ -190,6 +195,7 @@ __all__ = [
190
195
  "StatusPageSubscriberDto",
191
196
  "StatusPageCustomDomainDto",
192
197
  "StatusPageBranding",
198
+ "MaintenanceWindowDto",
193
199
  "MonitorDto",
194
200
  "IncidentDto",
195
201
  "IncidentDetailDto",
@@ -231,6 +237,8 @@ __all__ = [
231
237
  "AdminAddSubscriberRequest",
232
238
  "ReorderComponentsRequest",
233
239
  "ReorderPageLayoutRequest",
240
+ "CreateMaintenanceWindowRequest",
241
+ "UpdateMaintenanceWindowRequest",
234
242
  "CreateMonitorRequest",
235
243
  "UpdateMonitorRequest",
236
244
  "CreateManualIncidentRequest",
@@ -10,6 +10,7 @@ from devhelm.resources.deploy_lock import DeployLock
10
10
  from devhelm.resources.environments import Environments
11
11
  from devhelm.resources.forensics import Forensics
12
12
  from devhelm.resources.incidents import Incidents
13
+ from devhelm.resources.maintenance_windows import MaintenanceWindows
13
14
  from devhelm.resources.monitors import Monitors
14
15
  from devhelm.resources.notification_policies import NotificationPolicies
15
16
  from devhelm.resources.resource_groups import ResourceGroups
@@ -51,6 +52,7 @@ class Devhelm:
51
52
  api_keys: ApiKeys
52
53
  dependencies: Dependencies
53
54
  deploy_lock: DeployLock
55
+ maintenance_windows: MaintenanceWindows
54
56
  status: Status
55
57
  status_pages: StatusPages
56
58
 
@@ -96,5 +98,6 @@ class Devhelm:
96
98
  self.api_keys = ApiKeys(client)
97
99
  self.dependencies = Dependencies(client)
98
100
  self.deploy_lock = DeployLock(client)
101
+ self.maintenance_windows = MaintenanceWindows(client)
99
102
  self.status = Status(client)
100
103
  self.status_pages = StatusPages(client)
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from devhelm._generated import (
6
+ CreateMaintenanceWindowRequest,
7
+ MaintenanceWindowDto,
8
+ UpdateMaintenanceWindowRequest,
9
+ )
10
+ from devhelm._http import api_delete, api_get, api_post, api_put, path_param
11
+ from devhelm._pagination import Page, fetch_all_pages, fetch_page
12
+ from devhelm._validation import RequestBody, parse_single, validate_request
13
+
14
+ # Query-param values for ``GET /api/v1/maintenance-windows``. Both
15
+ # documented filters (``monitorId``, ``filter``) are single-valued strings;
16
+ # spelt as a concrete alias rather than ``Any`` so the resource layer stays
17
+ # ``Any``-free per ``tests/test_typing.py``.
18
+ _ListFilterValue = str
19
+
20
+
21
+ def _build_list_filters(
22
+ monitor_id: str | None, status: str | None
23
+ ) -> dict[str, _ListFilterValue]:
24
+ """Pack the documented ``GET /api/v1/maintenance-windows`` query
25
+ params into a single dict, dropping anything left at the default
26
+ ``None`` so the wire request stays minimal and the API's defaults
27
+ apply.
28
+
29
+ Accepts snake_case at the Python boundary and emits the camelCase
30
+ spelling the API expects (``monitor_id`` → ``monitorId``). The
31
+ ergonomic ``status`` kwarg is mapped to the API's ``filter`` query
32
+ param (which selects ``"active"`` or ``"upcoming"`` windows).
33
+ """
34
+ filters: dict[str, _ListFilterValue] = {}
35
+ if monitor_id is not None:
36
+ filters["monitorId"] = monitor_id
37
+ if status is not None:
38
+ filters["filter"] = status
39
+ return filters
40
+
41
+
42
+ class MaintenanceWindows:
43
+ """Scheduled maintenance windows that suppress alerts during planned downtime."""
44
+
45
+ def __init__(self, client: httpx.Client) -> None:
46
+ self._client = client
47
+
48
+ def list(
49
+ self, *, monitor_id: str | None = None, status: str | None = None
50
+ ) -> list[MaintenanceWindowDto]:
51
+ """List all maintenance windows for the authenticated org (auto-paginates).
52
+
53
+ Optional server-side filters mirror the documented
54
+ ``GET /api/v1/maintenance-windows`` query params:
55
+
56
+ * ``monitor_id`` — only return windows attached to this monitor.
57
+ * ``status`` — ``"active"`` (currently in window) or
58
+ ``"upcoming"`` (starts in the future).
59
+
60
+ Examples:
61
+ >>> client.maintenance_windows.list()
62
+ [MaintenanceWindowDto(...), ...]
63
+ >>> client.maintenance_windows.list(status="upcoming")
64
+ [...]
65
+ """
66
+ # The API's documented query string omits ``page``/``size``, but the
67
+ # response envelope is the standard ``TableValueResult`` shape that
68
+ # all paginated lists in this codebase use, so the server accepts
69
+ # those keys and ignores them where unused. Reusing
70
+ # ``fetch_all_pages`` keeps the iterator semantics consistent with
71
+ # every other ``MaintenanceWindows.list``-style method on this SDK.
72
+ return fetch_all_pages(
73
+ self._client,
74
+ "/api/v1/maintenance-windows",
75
+ MaintenanceWindowDto,
76
+ extra_params=_build_list_filters(monitor_id, status),
77
+ )
78
+
79
+ def list_page(
80
+ self,
81
+ page: int,
82
+ size: int,
83
+ *,
84
+ monitor_id: str | None = None,
85
+ status: str | None = None,
86
+ ) -> Page[MaintenanceWindowDto]:
87
+ """List maintenance windows with manual page control.
88
+
89
+ Accepts the same filter kwargs as :meth:`list` so callers using
90
+ manual pagination get the same server-side filtering.
91
+ """
92
+ return fetch_page(
93
+ self._client,
94
+ "/api/v1/maintenance-windows",
95
+ MaintenanceWindowDto,
96
+ page,
97
+ size,
98
+ extra_params=_build_list_filters(monitor_id, status),
99
+ )
100
+
101
+ def get(self, id: str) -> MaintenanceWindowDto:
102
+ """Get a single maintenance window by ID.
103
+
104
+ Examples:
105
+ >>> client.maintenance_windows.get("a8e3...")
106
+ MaintenanceWindowDto(...)
107
+ """
108
+ return parse_single(
109
+ MaintenanceWindowDto,
110
+ api_get(self._client, f"/api/v1/maintenance-windows/{path_param(id)}"),
111
+ f"GET /api/v1/maintenance-windows/{id}",
112
+ )
113
+
114
+ def create(
115
+ self, body: RequestBody[CreateMaintenanceWindowRequest]
116
+ ) -> MaintenanceWindowDto:
117
+ """Create a new maintenance window.
118
+
119
+ Pass ``monitorId=None`` to create an org-wide window that
120
+ suppresses alerts for every monitor in the organisation; pass a
121
+ UUID to scope the window to a single monitor.
122
+
123
+ Examples:
124
+ >>> from datetime import datetime, timedelta, timezone
125
+ >>> start = datetime.now(timezone.utc) + timedelta(hours=1)
126
+ >>> client.maintenance_windows.create({
127
+ ... "startsAt": start.isoformat(),
128
+ ... "endsAt": (start + timedelta(hours=2)).isoformat(),
129
+ ... "reason": "Quarterly DB upgrade",
130
+ ... "monitorId": "a8e3...",
131
+ ... })
132
+ MaintenanceWindowDto(...)
133
+ """
134
+ body = validate_request(
135
+ CreateMaintenanceWindowRequest, body, "maintenanceWindows.create"
136
+ )
137
+ return parse_single(
138
+ MaintenanceWindowDto,
139
+ api_post(self._client, "/api/v1/maintenance-windows", body),
140
+ "POST /api/v1/maintenance-windows",
141
+ )
142
+
143
+ def update(
144
+ self, id: str, body: RequestBody[UpdateMaintenanceWindowRequest]
145
+ ) -> MaintenanceWindowDto:
146
+ """Update an existing maintenance window.
147
+
148
+ Examples:
149
+ >>> client.maintenance_windows.update("a8e3...", {
150
+ ... "startsAt": "2026-06-01T00:00:00Z",
151
+ ... "endsAt": "2026-06-01T02:00:00Z",
152
+ ... "reason": "Rescheduled DB upgrade",
153
+ ... })
154
+ MaintenanceWindowDto(...)
155
+ """
156
+ body = validate_request(
157
+ UpdateMaintenanceWindowRequest, body, "maintenanceWindows.update"
158
+ )
159
+ return parse_single(
160
+ MaintenanceWindowDto,
161
+ api_put(
162
+ self._client, f"/api/v1/maintenance-windows/{path_param(id)}", body
163
+ ),
164
+ f"PUT /api/v1/maintenance-windows/{id}",
165
+ )
166
+
167
+ def delete(self, id: str) -> None:
168
+ """Delete (cancel) a maintenance window.
169
+
170
+ The API exposes a ``DELETE`` operation for this resource; if you
171
+ prefer the cancellation-style verb in your code, see
172
+ :meth:`cancel`, which is a thin alias.
173
+ """
174
+ api_delete(self._client, f"/api/v1/maintenance-windows/{path_param(id)}")
175
+
176
+ def cancel(self, id: str) -> None:
177
+ """Cancel a scheduled maintenance window.
178
+
179
+ Semantic alias for :meth:`delete` — both call the same underlying
180
+ ``DELETE /api/v1/maintenance-windows/{id}`` endpoint, but
181
+ ``cancel`` reads better in automation scripts that schedule and
182
+ later cancel planned downtime.
183
+
184
+ Examples:
185
+ >>> client.maintenance_windows.cancel("a8e3...")
186
+ """
187
+ self.delete(id)
@@ -51,6 +51,7 @@ from devhelm._generated import (
51
51
  CreateAlertChannelRequest,
52
52
  CreateApiKeyRequest,
53
53
  CreateEnvironmentRequest,
54
+ CreateMaintenanceWindowRequest,
54
55
  CreateManualIncidentRequest,
55
56
  CreateMonitorRequest,
56
57
  CreateNotificationPolicyRequest,
@@ -74,6 +75,7 @@ from devhelm._generated import (
74
75
  IncidentMode,
75
76
  IncidentStateTransitionDto,
76
77
  IncidentTimelineDto,
78
+ MaintenanceWindowDto,
77
79
  ManagedBy,
78
80
  Method,
79
81
  MonitorDto,
@@ -112,6 +114,7 @@ from devhelm._generated import (
112
114
  TierAvailability,
113
115
  UpdateAlertChannelRequest,
114
116
  UpdateEnvironmentRequest,
117
+ UpdateMaintenanceWindowRequest,
115
118
  UpdateMonitorRequest,
116
119
  UpdateNotificationPolicyRequest,
117
120
  UpdateResourceGroupRequest,
@@ -244,6 +247,7 @@ __all__ = [
244
247
  "CreateStatusPageComponentRequest",
245
248
  "CreateStatusPageIncidentRequest",
246
249
  "CreateStatusPageIncidentUpdateRequest",
250
+ "CreateMaintenanceWindowRequest",
247
251
  "CreateStatusPageRequest",
248
252
  "CreateTagRequest",
249
253
  "CreateWebhookEndpointRequest",
@@ -254,6 +258,7 @@ __all__ = [
254
258
  "IncidentDto",
255
259
  "IncidentStateTransitionDto",
256
260
  "IncidentTimelineDto",
261
+ "MaintenanceWindowDto",
257
262
  "MonitorDto",
258
263
  "MonitorVersionDto",
259
264
  "NotificationPolicyDto",
@@ -280,6 +285,7 @@ __all__ = [
280
285
  "TestChannelResult",
281
286
  "UpdateAlertChannelRequest",
282
287
  "UpdateEnvironmentRequest",
288
+ "UpdateMaintenanceWindowRequest",
283
289
  "UpdateMonitorRequest",
284
290
  "UpdateNotificationPolicyRequest",
285
291
  "UpdateResourceGroupRequest",
@@ -14,6 +14,7 @@ from devhelm.resources.deploy_lock import DeployLock
14
14
  from devhelm.resources.environments import Environments
15
15
  from devhelm.resources.forensics import Forensics
16
16
  from devhelm.resources.incidents import Incidents
17
+ from devhelm.resources.maintenance_windows import MaintenanceWindows
17
18
  from devhelm.resources.monitors import Monitors
18
19
  from devhelm.resources.notification_policies import NotificationPolicies
19
20
  from devhelm.resources.resource_groups import ResourceGroups
@@ -83,6 +84,16 @@ class TestClientResources:
83
84
  def test_status_pages(self, client: Devhelm) -> None:
84
85
  assert isinstance(client.status_pages, StatusPages)
85
86
 
87
+ def test_maintenance_windows(self, client: Devhelm) -> None:
88
+ assert isinstance(client.maintenance_windows, MaintenanceWindows)
89
+ assert callable(client.maintenance_windows.list)
90
+ assert callable(client.maintenance_windows.list_page)
91
+ assert callable(client.maintenance_windows.get)
92
+ assert callable(client.maintenance_windows.create)
93
+ assert callable(client.maintenance_windows.update)
94
+ assert callable(client.maintenance_windows.delete)
95
+ assert callable(client.maintenance_windows.cancel)
96
+
86
97
 
87
98
  class TestStatusPagesResource:
88
99
  """Verify StatusPages exposes all sub-resources and methods."""
@@ -0,0 +1,292 @@
1
+ """Tests for the ``MaintenanceWindows`` resource module.
2
+
3
+ The shape of these tests mirrors ``test_client.TestMonitorsListFilters``:
4
+ spin up an ``httpx.MockTransport``, point a real resource instance at it,
5
+ and assert the resulting ``httpx.Request`` carries the wire-level URL,
6
+ method, query-string, and JSON body the API documents.
7
+
8
+ The aim is twofold:
9
+
10
+ * Confirm that ergonomic snake_case kwargs (``monitor_id``, ``status``)
11
+ are projected onto the camelCase / API-specific names
12
+ (``monitorId``, ``filter``) the server expects.
13
+ * Lock in the URL-and-verb contract for ``create`` / ``update`` /
14
+ ``delete`` / ``cancel`` so a future refactor can't silently change
15
+ what the SDK sends to production.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+
22
+ import httpx
23
+ import pytest
24
+
25
+ from devhelm.resources.maintenance_windows import MaintenanceWindows
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Fixtures: shared mock transport + resource builder
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ _VALID_WINDOW = {
33
+ "id": "11111111-1111-1111-1111-111111111111",
34
+ "monitorId": "22222222-2222-2222-2222-222222222222",
35
+ "organizationId": 1,
36
+ "startsAt": "2026-06-01T00:00:00Z",
37
+ "endsAt": "2026-06-01T02:00:00Z",
38
+ "repeatRule": None,
39
+ "reason": "Quarterly DB upgrade",
40
+ "suppressAlerts": True,
41
+ "createdAt": "2026-05-01T12:00:00Z",
42
+ }
43
+
44
+
45
+ def _stub_transport(
46
+ captured: list[httpx.Request],
47
+ *,
48
+ list_response: dict[str, object] | None = None,
49
+ single_response: dict[str, object] | None = None,
50
+ delete_status: int = 204,
51
+ ) -> httpx.MockTransport:
52
+ """Capture every outgoing request and return JSON shaped like the API.
53
+
54
+ Routes to the right canned response based on method + path so a
55
+ single transport can serve every test below without each test having
56
+ to define its own handler.
57
+ """
58
+
59
+ def handler(request: httpx.Request) -> httpx.Response:
60
+ captured.append(request)
61
+ method = request.method
62
+ path = request.url.path
63
+ if method == "GET" and path == "/api/v1/maintenance-windows":
64
+ body = list_response or {
65
+ "data": [_VALID_WINDOW],
66
+ "hasNext": False,
67
+ "hasPrev": False,
68
+ }
69
+ return httpx.Response(200, json=body)
70
+ if method == "GET" and path.startswith("/api/v1/maintenance-windows/"):
71
+ return httpx.Response(200, json=single_response or {"data": _VALID_WINDOW})
72
+ if method == "POST" and path == "/api/v1/maintenance-windows":
73
+ return httpx.Response(201, json=single_response or {"data": _VALID_WINDOW})
74
+ if method == "PUT" and path.startswith("/api/v1/maintenance-windows/"):
75
+ return httpx.Response(200, json=single_response or {"data": _VALID_WINDOW})
76
+ if method == "DELETE" and path.startswith("/api/v1/maintenance-windows/"):
77
+ return httpx.Response(delete_status)
78
+ # Surface routing mistakes loudly rather than silently 404'ing.
79
+ raise AssertionError(f"unexpected {method} {path}")
80
+
81
+ return httpx.MockTransport(handler)
82
+
83
+
84
+ def _resource(transport: httpx.MockTransport) -> MaintenanceWindows:
85
+ http_client = httpx.Client(transport=transport, base_url="http://localhost:8080")
86
+ return MaintenanceWindows(http_client)
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # list / list_page — query-param threading
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class TestList:
95
+ """The two documented filters (``monitorId``, ``filter``) must reach
96
+ the wire under their server-expected names so callers don't have to
97
+ drop down to ``httpx`` for server-side filtering.
98
+ """
99
+
100
+ def test_list_threads_filters_to_query_string(self) -> None:
101
+ captured: list[httpx.Request] = []
102
+ windows = _resource(_stub_transport(captured))
103
+
104
+ windows.list(
105
+ monitor_id="22222222-2222-2222-2222-222222222222", status="upcoming"
106
+ )
107
+
108
+ assert len(captured) == 1
109
+ params = captured[0].url.params
110
+ # snake_case ``monitor_id`` must be projected onto camelCase wire
111
+ # name; ergonomic ``status`` kwarg must map to API's ``filter`` key.
112
+ assert params["monitorId"] == "22222222-2222-2222-2222-222222222222"
113
+ assert params["filter"] == "upcoming"
114
+
115
+ def test_list_omits_unspecified_filters(self) -> None:
116
+ captured: list[httpx.Request] = []
117
+ windows = _resource(_stub_transport(captured))
118
+
119
+ windows.list()
120
+
121
+ assert len(captured) == 1
122
+ params = captured[0].url.params
123
+ assert "monitorId" not in params
124
+ assert "filter" not in params
125
+ # Pagination keys are always sent so ``fetch_all_pages`` can drive
126
+ # the iterator deterministically.
127
+ assert params["page"] == "0"
128
+
129
+ def test_list_returns_parsed_models(self) -> None:
130
+ captured: list[httpx.Request] = []
131
+ windows = _resource(_stub_transport(captured))
132
+
133
+ result = windows.list()
134
+
135
+ assert len(result) == 1
136
+ assert str(result[0].id) == "11111111-1111-1111-1111-111111111111"
137
+ assert result[0].reason == "Quarterly DB upgrade"
138
+
139
+ def test_list_page_threads_filters(self) -> None:
140
+ captured: list[httpx.Request] = []
141
+ windows = _resource(_stub_transport(captured))
142
+
143
+ page = windows.list_page(2, 50, monitor_id="abc", status="active")
144
+
145
+ assert page.total_elements is None # not returned by stub
146
+ assert len(captured) == 1
147
+ params = captured[0].url.params
148
+ assert params["monitorId"] == "abc"
149
+ assert params["filter"] == "active"
150
+ assert params["page"] == "2"
151
+ assert params["size"] == "50"
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # get — URL templating + envelope unwrap
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ class TestGet:
160
+ def test_get_hits_resource_url(self) -> None:
161
+ captured: list[httpx.Request] = []
162
+ windows = _resource(_stub_transport(captured))
163
+
164
+ result = windows.get("11111111-1111-1111-1111-111111111111")
165
+
166
+ assert len(captured) == 1
167
+ assert captured[0].method == "GET"
168
+ assert (
169
+ captured[0].url.path
170
+ == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111"
171
+ )
172
+ assert str(result.id) == "11111111-1111-1111-1111-111111111111"
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # create — body validation + JSON shape
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class TestCreate:
181
+ def test_create_posts_validated_body(self) -> None:
182
+ captured: list[httpx.Request] = []
183
+ windows = _resource(_stub_transport(captured))
184
+
185
+ result = windows.create(
186
+ {
187
+ "startsAt": "2026-06-01T00:00:00Z",
188
+ "endsAt": "2026-06-01T02:00:00Z",
189
+ "reason": "Quarterly DB upgrade",
190
+ "monitorId": "22222222-2222-2222-2222-222222222222",
191
+ "suppressAlerts": True,
192
+ }
193
+ )
194
+
195
+ assert len(captured) == 1
196
+ request = captured[0]
197
+ assert request.method == "POST"
198
+ assert request.url.path == "/api/v1/maintenance-windows"
199
+ body = json.loads(request.content)
200
+ # Keys go on the wire as the camelCase aliases the API documents.
201
+ assert body["startsAt"] == "2026-06-01T00:00:00Z"
202
+ assert body["endsAt"] == "2026-06-01T02:00:00Z"
203
+ assert body["reason"] == "Quarterly DB upgrade"
204
+ assert body["monitorId"] == "22222222-2222-2222-2222-222222222222"
205
+ assert body["suppressAlerts"] is True
206
+ # The ``data`` envelope must be unwrapped into a typed model so
207
+ # callers don't get a raw dict.
208
+ assert str(result.id) == "11111111-1111-1111-1111-111111111111"
209
+
210
+ def test_create_rejects_missing_required_fields(self) -> None:
211
+ # Missing ``endsAt`` — Pydantic must reject before the HTTP call,
212
+ # so ``captured`` stays empty.
213
+ captured: list[httpx.Request] = []
214
+ windows = _resource(_stub_transport(captured))
215
+
216
+ # ``DevhelmValidationError`` extends ``DevhelmError`` which extends
217
+ # ``Exception``; checking for ``Exception`` keeps the assertion
218
+ # robust against future re-shuffles of the error hierarchy while
219
+ # still ensuring *something* failed loudly before any network IO.
220
+ with pytest.raises(Exception, match="Request validation failed"):
221
+ windows.create({"startsAt": "2026-06-01T00:00:00Z"})
222
+ assert captured == []
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # update — URL templating + body shape
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ class TestUpdate:
231
+ def test_update_puts_to_resource_url(self) -> None:
232
+ captured: list[httpx.Request] = []
233
+ windows = _resource(_stub_transport(captured))
234
+
235
+ windows.update(
236
+ "11111111-1111-1111-1111-111111111111",
237
+ {
238
+ "startsAt": "2026-06-02T00:00:00Z",
239
+ "endsAt": "2026-06-02T02:00:00Z",
240
+ "reason": "Rescheduled DB upgrade",
241
+ },
242
+ )
243
+
244
+ assert len(captured) == 1
245
+ request = captured[0]
246
+ assert request.method == "PUT"
247
+ assert (
248
+ request.url.path
249
+ == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111"
250
+ )
251
+ body = json.loads(request.content)
252
+ assert body["startsAt"] == "2026-06-02T00:00:00Z"
253
+ assert body["reason"] == "Rescheduled DB upgrade"
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # delete / cancel — both must hit the same endpoint
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ class TestDeleteAndCancel:
262
+ """``cancel`` is a thin alias for ``delete`` so users can pick the
263
+ verb that reads better in their automation script. They must produce
264
+ identical wire requests.
265
+ """
266
+
267
+ def test_delete_hits_resource_url(self) -> None:
268
+ captured: list[httpx.Request] = []
269
+ windows = _resource(_stub_transport(captured))
270
+
271
+ result = windows.delete("11111111-1111-1111-1111-111111111111")
272
+
273
+ assert result is None
274
+ assert len(captured) == 1
275
+ assert captured[0].method == "DELETE"
276
+ assert (
277
+ captured[0].url.path
278
+ == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111"
279
+ )
280
+
281
+ def test_cancel_aliases_delete(self) -> None:
282
+ captured: list[httpx.Request] = []
283
+ windows = _resource(_stub_transport(captured))
284
+
285
+ windows.cancel("11111111-1111-1111-1111-111111111111")
286
+
287
+ assert len(captured) == 1
288
+ assert captured[0].method == "DELETE"
289
+ assert (
290
+ captured[0].url.path
291
+ == "/api/v1/maintenance-windows/11111111-1111-1111-1111-111111111111"
292
+ )
@@ -36,6 +36,7 @@ from devhelm.resources.api_keys import ApiKeys
36
36
  from devhelm.resources.deploy_lock import DeployLock
37
37
  from devhelm.resources.environments import Environments
38
38
  from devhelm.resources.incidents import Incidents
39
+ from devhelm.resources.maintenance_windows import MaintenanceWindows
39
40
  from devhelm.resources.monitors import Monitors
40
41
  from devhelm.resources.notification_policies import NotificationPolicies
41
42
  from devhelm.resources.resource_groups import ResourceGroups
@@ -130,6 +131,7 @@ REQUEST_DTO_NAMES: list[str] = sorted(
130
131
  "CreateAlertChannelRequest",
131
132
  "CreateApiKeyRequest",
132
133
  "CreateEnvironmentRequest",
134
+ "CreateMaintenanceWindowRequest",
133
135
  "CreateManualIncidentRequest",
134
136
  "CreateMonitorRequest",
135
137
  "CreateNotificationPolicyRequest",
@@ -146,6 +148,7 @@ REQUEST_DTO_NAMES: list[str] = sorted(
146
148
  "ResolveIncidentRequest",
147
149
  "UpdateAlertChannelRequest",
148
150
  "UpdateEnvironmentRequest",
151
+ "UpdateMaintenanceWindowRequest",
149
152
  "UpdateMonitorRequest",
150
153
  "UpdateNotificationPolicyRequest",
151
154
  "UpdateResourceGroupRequest",
@@ -213,6 +216,13 @@ RESOURCE_BODY_METHODS: list[tuple[type, list[tuple[str, str]]]] = [
213
216
  Monitors,
214
217
  [("create", "CreateMonitorRequest"), ("update", "UpdateMonitorRequest")],
215
218
  ),
219
+ (
220
+ MaintenanceWindows,
221
+ [
222
+ ("create", "CreateMaintenanceWindowRequest"),
223
+ ("update", "UpdateMaintenanceWindowRequest"),
224
+ ],
225
+ ),
216
226
  (
217
227
  Incidents,
218
228
  [
@@ -356,6 +366,7 @@ def test_resource_method_body_accepts_dict(
356
366
  # so we check the leading segments).
357
367
  SDK_PATH_PREFIXES: list[str] = [
358
368
  "/api/v1/monitors",
369
+ "/api/v1/maintenance-windows",
359
370
  "/api/v1/incidents",
360
371
  "/api/v1/alert-channels",
361
372
  "/api/v1/notification-policies",
@@ -315,7 +315,7 @@ wheels = [
315
315
 
316
316
  [[package]]
317
317
  name = "devhelm"
318
- version = "0.6.3"
318
+ version = "0.7.0"
319
319
  source = { editable = "." }
320
320
  dependencies = [
321
321
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes