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.
- {devhelm-0.6.3 → devhelm-0.7.0}/PKG-INFO +1 -1
- {devhelm-0.6.3 → devhelm-0.7.0}/pyproject.toml +1 -1
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/__init__.py +8 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/client.py +3 -0
- devhelm-0.7.0/src/devhelm/resources/maintenance_windows.py +187 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/types.py +6 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_client.py +11 -0
- devhelm-0.7.0/tests/test_maintenance_windows.py +292 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_spec_parity.py +11 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/uv.lock +1 -1
- {devhelm-0.6.3 → devhelm-0.7.0}/.github/workflows/ci.yml +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/.github/workflows/release.yml +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/.github/workflows/spec-check.yml +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/.gitignore +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/LICENSE +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/Makefile +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/README.md +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/docs/openapi/monitoring-api.json +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/scripts/inject_strict_config.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/scripts/regen-from.sh +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/scripts/release.sh +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/scripts/typegen.sh +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_errors.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_generated.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_http.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_pagination.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/_validation.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/py.typed +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/__init__.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/alert_channels.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/api_keys.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/dependencies.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/deploy_lock.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/environments.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/forensics.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/incidents.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/monitors.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/notification_policies.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/resource_groups.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/secrets.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/status.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/status_pages.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/tags.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/src/devhelm/resources/webhooks.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/__init__.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/run_sdk.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_errors.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_http.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_negative_validation.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_schemas.py +0 -0
- {devhelm-0.6.3 → devhelm-0.7.0}/tests/test_typing.py +0 -0
- {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.
|
|
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
|
|
@@ -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",
|
|
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
|
|
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
|
|
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
|