hyperping 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,250 @@
1
+ """Monitor operations mixin for HyperpingClient.
2
+
3
+ Provides CRUD and reporting methods for Hyperping monitors. Mixed into
4
+ :class:`~hyperping.client.HyperpingClient` at class definition time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from hyperping.endpoints import Endpoint
15
+ from hyperping.exceptions import HyperpingNotFoundError
16
+ from hyperping.models import (
17
+ Monitor,
18
+ MonitorCreate,
19
+ MonitorReport,
20
+ MonitorUpdate,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class MonitorsMixin:
27
+ """Monitor-related API operations."""
28
+
29
+ def _request( # type: ignore[empty-body]
30
+ self,
31
+ method: str,
32
+ path: str,
33
+ json: dict[str, Any] | None = None,
34
+ params: dict[str, Any] | None = None,
35
+ ) -> dict[str, Any]: ... # provided by HyperpingClient
36
+
37
+ def list_monitors(self) -> list[Monitor]:
38
+ """List all monitors in the account.
39
+
40
+ Returns:
41
+ List of :class:`Monitor` objects. Monitors that fail to parse
42
+ are silently skipped with a warning log.
43
+
44
+ Raises:
45
+ HyperpingAuthError: If the API key is invalid.
46
+ HyperpingAPIError: On unexpected API errors.
47
+ """
48
+ response = self._request("GET", Endpoint.MONITORS)
49
+
50
+ # Handle different response formats
51
+ if isinstance(response, list):
52
+ monitors_data = response
53
+ elif "monitors" in response:
54
+ monitors_data = response["monitors"]
55
+ else:
56
+ monitors_data = response.get("data", [])
57
+
58
+ monitors = []
59
+ skipped = 0
60
+ for data in monitors_data:
61
+ try:
62
+ monitors.append(Monitor.model_validate(data))
63
+ except (ValueError, ValidationError) as e:
64
+ skipped += 1
65
+ logger.warning(f"Failed to parse monitor data: {e}", extra={"data": data})
66
+
67
+ if skipped:
68
+ logger.warning(
69
+ f"{skipped} of {len(monitors_data)} monitors could not be parsed and were skipped"
70
+ )
71
+
72
+ return monitors
73
+
74
+ def get_monitor(self, monitor_id: str) -> Monitor:
75
+ """Get a single monitor by ID.
76
+
77
+ Args:
78
+ monitor_id: Monitor UUID
79
+
80
+ Returns:
81
+ Monitor object
82
+
83
+ Raises:
84
+ HyperpingNotFoundError: If monitor not found
85
+ """
86
+ response = self._request("GET", f"{Endpoint.MONITORS}/{monitor_id}")
87
+ return Monitor.model_validate(response)
88
+
89
+ def create_monitor(self, monitor: MonitorCreate) -> Monitor:
90
+ """Create a new monitor.
91
+
92
+ Args:
93
+ monitor: Monitor creation data.
94
+
95
+ Returns:
96
+ Created :class:`Monitor` object.
97
+
98
+ Raises:
99
+ HyperpingValidationError: If the payload fails server-side validation.
100
+ HyperpingAPIError: On unexpected API errors.
101
+ """
102
+ payload = monitor.model_dump(exclude_none=True)
103
+ response = self._request("POST", Endpoint.MONITORS, json=payload)
104
+ return Monitor.model_validate(response)
105
+
106
+ # Writable fields for the Hyperping monitor PUT endpoint
107
+ _MONITOR_WRITABLE_FIELDS: frozenset[str] = frozenset(
108
+ {
109
+ "name",
110
+ "url",
111
+ "protocol",
112
+ "http_method",
113
+ "check_frequency",
114
+ "regions",
115
+ "request_headers",
116
+ "request_body",
117
+ "follow_redirects",
118
+ "expected_status_code",
119
+ "required_keyword",
120
+ "paused",
121
+ "port",
122
+ "alerts_wait",
123
+ "escalation_policy",
124
+ "dns_record_type",
125
+ "dns_nameserver",
126
+ "dns_expected_answer",
127
+ }
128
+ )
129
+
130
+ def update_monitor(self, monitor_id: str, update: MonitorUpdate) -> Monitor:
131
+ """Update an existing monitor using read-modify-write.
132
+
133
+ The Hyperping v1 PUT endpoint requires a full payload. We fetch the
134
+ current state first and apply the update on top to avoid clobbering
135
+ fields not included in the partial update.
136
+
137
+ Args:
138
+ monitor_id: Monitor UUID
139
+ update: Fields to update
140
+
141
+ Returns:
142
+ Updated Monitor object
143
+ """
144
+ current = self.get_monitor(monitor_id)
145
+
146
+ # Build full payload from current writable state
147
+ payload: dict[str, Any] = current.model_dump(
148
+ mode="json",
149
+ exclude_none=True,
150
+ include=set(self._MONITOR_WRITABLE_FIELDS),
151
+ )
152
+
153
+ # Apply the requested changes on top of current state
154
+ payload.update(update.model_dump(exclude_none=True))
155
+
156
+ response = self._request("PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload)
157
+ return Monitor.model_validate(response)
158
+
159
+ def delete_monitor(self, monitor_id: str) -> None:
160
+ """Delete a monitor.
161
+
162
+ Args:
163
+ monitor_id: Monitor UUID
164
+
165
+ Raises:
166
+ HyperpingNotFoundError: If monitor not found
167
+ """
168
+ self._request("DELETE", f"{Endpoint.MONITORS}/{monitor_id}")
169
+
170
+ def pause_monitor(self, monitor_id: str) -> Monitor:
171
+ """Pause a monitor.
172
+
173
+ Args:
174
+ monitor_id: Monitor UUID
175
+
176
+ Returns:
177
+ Updated Monitor object
178
+ """
179
+ return self.update_monitor(monitor_id, MonitorUpdate(paused=True))
180
+
181
+ def resume_monitor(self, monitor_id: str) -> Monitor:
182
+ """Resume a paused monitor.
183
+
184
+ Args:
185
+ monitor_id: Monitor UUID
186
+
187
+ Returns:
188
+ Updated Monitor object
189
+ """
190
+ return self.update_monitor(monitor_id, MonitorUpdate(paused=False))
191
+
192
+ def get_all_reports(self, period: str = "30d") -> list[MonitorReport]:
193
+ """Get uptime reports for all monitors in a single batch call.
194
+
195
+ Uses the v2 batch endpoint -- one API call for all monitors.
196
+
197
+ Args:
198
+ period: Report period (``1h``, ``24h``, ``7d``, ``30d``, ``90d``).
199
+
200
+ Returns:
201
+ List of :class:`MonitorReport` objects. Reports that fail to parse
202
+ are silently skipped with a warning log.
203
+
204
+ Raises:
205
+ HyperpingAuthError: If the API key is invalid.
206
+ HyperpingAPIError: On unexpected API errors.
207
+ """
208
+ response = self._request("GET", Endpoint.REPORTS, params={"period": period})
209
+ period_info = response.get("period", {})
210
+ monitors_data = response.get("monitors", [])
211
+
212
+ reports = []
213
+ skipped = 0
214
+ for m in monitors_data:
215
+ try:
216
+ reports.append(MonitorReport.model_validate({**m, "period": period_info}))
217
+ except (ValueError, ValidationError) as e:
218
+ skipped += 1
219
+ logger.warning(f"Failed to parse monitor report: {e}", extra={"data": m})
220
+
221
+ if skipped:
222
+ logger.warning(
223
+ f"{skipped} of {len(monitors_data)} reports could not be parsed and were skipped"
224
+ )
225
+
226
+ return reports
227
+
228
+ def get_monitor_report(
229
+ self,
230
+ monitor_id: str,
231
+ period: str = "30d",
232
+ ) -> MonitorReport:
233
+ """Get uptime report for a single monitor.
234
+
235
+ Fetches the batch report and filters by monitor UUID.
236
+
237
+ Args:
238
+ monitor_id: Monitor UUID
239
+ period: Report period (1h, 24h, 7d, 30d, 90d)
240
+
241
+ Returns:
242
+ MonitorReport object
243
+
244
+ Raises:
245
+ HyperpingNotFoundError: If no report found for the monitor
246
+ """
247
+ for r in self.get_all_reports(period):
248
+ if r.uuid == monitor_id:
249
+ return r
250
+ raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}")
@@ -0,0 +1,102 @@
1
+ """Outage operations mixin for HyperpingClient.
2
+
3
+ Provides methods for managing auto-detected outages (v2 API). Mixed into
4
+ :class:`~hyperping.client.HyperpingClient` at class definition time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, cast
11
+
12
+ from hyperping.endpoints import Endpoint
13
+ from hyperping.exceptions import HyperpingNotFoundError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class OutagesMixin:
19
+ """Outage-related API operations."""
20
+
21
+ def _request( # type: ignore[empty-body]
22
+ self,
23
+ method: str,
24
+ path: str,
25
+ json: dict[str, Any] | None = None,
26
+ params: dict[str, Any] | None = None,
27
+ ) -> dict[str, Any]: ... # provided by HyperpingClient
28
+
29
+ def list_outages(self) -> list[dict[str, Any]]:
30
+ """List auto-detected outages.
31
+
32
+ Returns raw dicts since the exact API response shape is not fully
33
+ documented. The command layer normalizes the response defensively.
34
+
35
+ Returns:
36
+ List of outage dicts from the API.
37
+ Empty list if the endpoint is not available (404).
38
+ """
39
+ try:
40
+ data = self._request("GET", Endpoint.OUTAGES)
41
+ if isinstance(data, list):
42
+ return data
43
+ if isinstance(data, dict) and "outages" in data:
44
+ return cast(list[dict[str, Any]], data["outages"])
45
+ return []
46
+ except HyperpingNotFoundError:
47
+ logger.debug("Outage endpoint not available (404)")
48
+ return []
49
+
50
+ def acknowledge_outage(self, outage_id: str, message: str | None = None) -> dict[str, Any]:
51
+ """Acknowledge an outage.
52
+
53
+ Args:
54
+ outage_id: Outage UUID.
55
+ message: Optional acknowledgement message.
56
+
57
+ Returns:
58
+ API response dict.
59
+
60
+ Raises:
61
+ HyperpingNotFoundError: If outage not found.
62
+ """
63
+ json_body = {"message": message} if message else None
64
+ return self._request(
65
+ "POST",
66
+ f"{Endpoint.OUTAGES}/{outage_id}/acknowledge",
67
+ json=json_body,
68
+ )
69
+
70
+ def resolve_outage(self, outage_id: str, message: str | None = None) -> dict[str, Any]:
71
+ """Resolve an outage.
72
+
73
+ Args:
74
+ outage_id: Outage UUID.
75
+ message: Optional resolution message.
76
+
77
+ Returns:
78
+ API response dict.
79
+
80
+ Raises:
81
+ HyperpingNotFoundError: If outage not found.
82
+ """
83
+ json_body = {"message": message} if message else None
84
+ return self._request(
85
+ "POST",
86
+ f"{Endpoint.OUTAGES}/{outage_id}/resolve",
87
+ json=json_body,
88
+ )
89
+
90
+ def escalate_outage(self, outage_id: str) -> dict[str, Any]:
91
+ """Escalate an outage.
92
+
93
+ Args:
94
+ outage_id: Outage UUID.
95
+
96
+ Returns:
97
+ API response dict.
98
+
99
+ Raises:
100
+ HyperpingNotFoundError: If outage not found.
101
+ """
102
+ return self._request("POST", f"{Endpoint.OUTAGES}/{outage_id}/escalate")
@@ -0,0 +1,226 @@
1
+ """Status page operations mixin for HyperpingClient.
2
+
3
+ Provides CRUD and subscriber methods for Hyperping status pages (v2 API). Mixed into
4
+ :class:`~hyperping.client.HyperpingClient` at class definition time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from hyperping.endpoints import Endpoint
15
+ from hyperping.models import (
16
+ StatusPage,
17
+ StatusPageCreate,
18
+ StatusPageSubscriber,
19
+ StatusPageUpdate,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class StatusPagesMixin:
26
+ """Status page-related API operations."""
27
+
28
+ def _request( # type: ignore[empty-body]
29
+ self,
30
+ method: str,
31
+ path: str,
32
+ json: dict[str, Any] | None = None,
33
+ params: dict[str, Any] | None = None,
34
+ ) -> dict[str, Any]: ... # provided by HyperpingClient
35
+
36
+ def list_status_pages(self, search: str | None = None) -> list[StatusPage]:
37
+ """List all status pages.
38
+
39
+ Args:
40
+ search: Optional search query to filter by name.
41
+
42
+ Returns:
43
+ List of :class:`StatusPage` objects. Pages that fail to parse
44
+ are silently skipped with a warning log.
45
+
46
+ Raises:
47
+ HyperpingAuthError: If the API key is invalid.
48
+ HyperpingAPIError: On unexpected API errors.
49
+ """
50
+ query_params: dict[str, Any] = {}
51
+ if search:
52
+ query_params["search"] = search
53
+
54
+ response = self._request(
55
+ "GET",
56
+ Endpoint.STATUSPAGES,
57
+ params=query_params if query_params else None,
58
+ )
59
+
60
+ if isinstance(response, list):
61
+ pages_data = response
62
+ elif "statuspages" in response:
63
+ pages_data = response["statuspages"]
64
+ else:
65
+ pages_data = response.get("data", [])
66
+
67
+ pages = []
68
+ skipped = 0
69
+ for data in pages_data:
70
+ try:
71
+ pages.append(StatusPage.model_validate(data))
72
+ except (ValueError, ValidationError) as e:
73
+ skipped += 1
74
+ logger.warning(f"Failed to parse status page data: {e}", extra={"data": data})
75
+
76
+ if skipped:
77
+ logger.warning(
78
+ f"{skipped} of {len(pages_data)} status pages could not be parsed and were skipped"
79
+ )
80
+
81
+ return pages
82
+
83
+ def get_status_page(self, status_page_id: str) -> StatusPage:
84
+ """Get a single status page by ID.
85
+
86
+ Args:
87
+ status_page_id: Status page UUID.
88
+
89
+ Returns:
90
+ :class:`StatusPage` object.
91
+
92
+ Raises:
93
+ HyperpingNotFoundError: If status page not found.
94
+ """
95
+ response = self._request("GET", f"{Endpoint.STATUSPAGES}/{status_page_id}")
96
+ return StatusPage.model_validate(response)
97
+
98
+ def create_status_page(self, status_page: StatusPageCreate) -> StatusPage:
99
+ """Create a new status page.
100
+
101
+ Args:
102
+ status_page: Status page creation data.
103
+
104
+ Returns:
105
+ Created :class:`StatusPage` object.
106
+
107
+ Raises:
108
+ HyperpingValidationError: If the payload fails server-side validation.
109
+ HyperpingAPIError: On unexpected API errors.
110
+ """
111
+ payload = status_page.model_dump(exclude_none=True, by_alias=True)
112
+ response = self._request("POST", Endpoint.STATUSPAGES, json=payload)
113
+ return StatusPage.model_validate(response)
114
+
115
+ def update_status_page(
116
+ self,
117
+ status_page_id: str,
118
+ update: StatusPageUpdate,
119
+ ) -> StatusPage:
120
+ """Update an existing status page.
121
+
122
+ Args:
123
+ status_page_id: Status page UUID.
124
+ update: Fields to update.
125
+
126
+ Returns:
127
+ Updated :class:`StatusPage` object.
128
+
129
+ Raises:
130
+ HyperpingNotFoundError: If status page not found.
131
+ HyperpingValidationError: If the payload fails server-side validation.
132
+ HyperpingAPIError: On unexpected API errors.
133
+ """
134
+ payload = update.model_dump(exclude_none=True, by_alias=True)
135
+ response = self._request("PUT", f"{Endpoint.STATUSPAGES}/{status_page_id}", json=payload)
136
+ return StatusPage.model_validate(response)
137
+
138
+ def delete_status_page(self, status_page_id: str) -> None:
139
+ """Delete a status page.
140
+
141
+ Args:
142
+ status_page_id: Status page UUID.
143
+
144
+ Raises:
145
+ HyperpingNotFoundError: If status page not found.
146
+ """
147
+ self._request("DELETE", f"{Endpoint.STATUSPAGES}/{status_page_id}")
148
+
149
+ def list_subscribers(self, status_page_id: str) -> list[StatusPageSubscriber]:
150
+ """List subscribers for a status page.
151
+
152
+ Args:
153
+ status_page_id: Status page UUID.
154
+
155
+ Returns:
156
+ List of :class:`StatusPageSubscriber` objects.
157
+
158
+ Raises:
159
+ HyperpingNotFoundError: If status page not found.
160
+ HyperpingAPIError: On unexpected API errors.
161
+ """
162
+ response = self._request(
163
+ "GET", f"{Endpoint.STATUSPAGES}/{status_page_id}/subscribers"
164
+ )
165
+
166
+ if isinstance(response, list):
167
+ subscribers_data = response
168
+ elif "subscribers" in response:
169
+ subscribers_data = response["subscribers"]
170
+ else:
171
+ subscribers_data = response.get("data", [])
172
+
173
+ subscribers = []
174
+ skipped = 0
175
+ for data in subscribers_data:
176
+ try:
177
+ subscribers.append(StatusPageSubscriber.model_validate(data))
178
+ except (ValueError, ValidationError) as e:
179
+ skipped += 1
180
+ logger.warning(f"Failed to parse subscriber data: {e}", extra={"data": data})
181
+
182
+ if skipped:
183
+ logger.warning(
184
+ f"{skipped} of {len(subscribers_data)} subscribers could not be parsed "
185
+ "and were skipped"
186
+ )
187
+
188
+ return subscribers
189
+
190
+ def add_subscriber(self, status_page_id: str, email: str) -> StatusPageSubscriber:
191
+ """Add a subscriber to a status page.
192
+
193
+ Args:
194
+ status_page_id: Status page UUID.
195
+ email: Subscriber email address.
196
+
197
+ Returns:
198
+ Created :class:`StatusPageSubscriber` object.
199
+
200
+ Raises:
201
+ HyperpingNotFoundError: If status page not found.
202
+ HyperpingValidationError: If the email is invalid.
203
+ HyperpingAPIError: On unexpected API errors.
204
+ """
205
+ payload = {"email": email}
206
+ response = self._request(
207
+ "POST",
208
+ f"{Endpoint.STATUSPAGES}/{status_page_id}/subscribers",
209
+ json=payload,
210
+ )
211
+ return StatusPageSubscriber.model_validate(response)
212
+
213
+ def remove_subscriber(self, status_page_id: str, subscriber_id: str) -> None:
214
+ """Remove a subscriber from a status page.
215
+
216
+ Args:
217
+ status_page_id: Status page UUID.
218
+ subscriber_id: Subscriber ID.
219
+
220
+ Raises:
221
+ HyperpingNotFoundError: If status page or subscriber not found.
222
+ """
223
+ self._request(
224
+ "DELETE",
225
+ f"{Endpoint.STATUSPAGES}/{status_page_id}/subscribers/{subscriber_id}",
226
+ )
hyperping/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"