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.
hyperping/__init__.py ADDED
@@ -0,0 +1,151 @@
1
+ """Hyperping API client.
2
+
3
+ A Python client for the `Hyperping <https://hyperping.io>`_ monitoring API,
4
+ providing typed models, automatic retries with exponential backoff, and a
5
+ circuit breaker for fault tolerance.
6
+
7
+ Quick start::
8
+
9
+ from hyperping import HyperpingClient
10
+
11
+ with HyperpingClient(api_key="sk_...") as client:
12
+ monitors = client.list_monitors()
13
+ for m in monitors:
14
+ print(f"{m.name}: {'down' if m.down else 'up'}")
15
+ """
16
+
17
+ from hyperping._version import __version__
18
+ from hyperping.client import (
19
+ CircuitBreaker,
20
+ CircuitBreakerConfig,
21
+ CircuitState,
22
+ HyperpingClient,
23
+ RetryConfig,
24
+ )
25
+ from hyperping.endpoints import (
26
+ API_BASE,
27
+ API_PATHS,
28
+ ENDPOINTS,
29
+ HYPERPING_API_BASE,
30
+ APIVersion,
31
+ Endpoint,
32
+ EndpointConfig,
33
+ get_endpoint_url,
34
+ get_version_for_endpoint,
35
+ )
36
+ from hyperping.exceptions import (
37
+ HyperpingAPIError,
38
+ HyperpingAuthError,
39
+ HyperpingNotFoundError,
40
+ HyperpingRateLimitError,
41
+ HyperpingValidationError,
42
+ )
43
+ from hyperping.models import (
44
+ DEFAULT_REGIONS,
45
+ AddIncidentUpdateRequest,
46
+ APIErrorResponse,
47
+ DnsRecordType,
48
+ HttpMethod,
49
+ Incident,
50
+ IncidentCreate,
51
+ IncidentStatus,
52
+ IncidentType,
53
+ IncidentUpdate,
54
+ IncidentUpdateCreate,
55
+ IncidentUpdateRequest,
56
+ IncidentUpdateType,
57
+ LocalizedText,
58
+ Maintenance,
59
+ MaintenanceCreate,
60
+ MaintenanceUpdate,
61
+ Monitor,
62
+ MonitorBase,
63
+ MonitorCreate,
64
+ MonitorFrequency,
65
+ MonitorListResponse,
66
+ MonitorProtocol,
67
+ MonitorReport,
68
+ MonitorTimeout,
69
+ MonitorUpdate,
70
+ NotificationOption,
71
+ OutageDetail,
72
+ OutageStats,
73
+ Region,
74
+ ReportPeriod,
75
+ RequestHeader,
76
+ StatusPage,
77
+ StatusPageCreate,
78
+ StatusPageSubscriber,
79
+ StatusPageUpdate,
80
+ )
81
+
82
+ __all__ = [
83
+ # Version
84
+ "__version__",
85
+ # Client
86
+ "HyperpingClient",
87
+ # Configuration
88
+ "RetryConfig",
89
+ "CircuitBreakerConfig",
90
+ "CircuitBreaker",
91
+ "CircuitState",
92
+ # Endpoints
93
+ "API_BASE",
94
+ "Endpoint",
95
+ "APIVersion",
96
+ "EndpointConfig",
97
+ "ENDPOINTS",
98
+ "get_endpoint_url",
99
+ "get_version_for_endpoint",
100
+ # Convenience aliases (also used in tests)
101
+ "HYPERPING_API_BASE",
102
+ "API_PATHS",
103
+ # Exceptions
104
+ "HyperpingAPIError",
105
+ "HyperpingAuthError",
106
+ "HyperpingNotFoundError",
107
+ "HyperpingRateLimitError",
108
+ "HyperpingValidationError",
109
+ # Monitor enums
110
+ "HttpMethod",
111
+ "MonitorFrequency",
112
+ "MonitorTimeout",
113
+ "Region",
114
+ "MonitorProtocol",
115
+ "DnsRecordType",
116
+ "DEFAULT_REGIONS",
117
+ # Monitors
118
+ "MonitorBase",
119
+ "Monitor",
120
+ "MonitorCreate",
121
+ "MonitorUpdate",
122
+ "MonitorReport",
123
+ "MonitorListResponse",
124
+ "RequestHeader",
125
+ # Incidents
126
+ "AddIncidentUpdateRequest",
127
+ "Incident",
128
+ "IncidentCreate",
129
+ "IncidentStatus",
130
+ "IncidentType",
131
+ "IncidentUpdate",
132
+ "IncidentUpdateCreate",
133
+ "IncidentUpdateRequest",
134
+ "IncidentUpdateType",
135
+ "LocalizedText",
136
+ # Maintenance
137
+ "Maintenance",
138
+ "MaintenanceCreate",
139
+ "MaintenanceUpdate",
140
+ "NotificationOption",
141
+ # Reports
142
+ "ReportPeriod",
143
+ "OutageDetail",
144
+ "OutageStats",
145
+ "APIErrorResponse",
146
+ # Status Pages
147
+ "StatusPage",
148
+ "StatusPageCreate",
149
+ "StatusPageUpdate",
150
+ "StatusPageSubscriber",
151
+ ]
@@ -0,0 +1,198 @@
1
+ """Incident operations mixin for HyperpingClient.
2
+
3
+ Provides CRUD methods for Hyperping incidents (v3 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 datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from hyperping.endpoints import Endpoint
16
+ from hyperping.models import (
17
+ AddIncidentUpdateRequest,
18
+ Incident,
19
+ IncidentCreate,
20
+ IncidentStatus,
21
+ IncidentUpdateCreate,
22
+ IncidentUpdateRequest,
23
+ LocalizedText,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class IncidentsMixin:
30
+ """Incident-related API operations."""
31
+
32
+ def _request( # type: ignore[empty-body]
33
+ self,
34
+ method: str,
35
+ path: str,
36
+ json: dict[str, Any] | None = None,
37
+ params: dict[str, Any] | None = None,
38
+ ) -> dict[str, Any]: ... # provided by HyperpingClient
39
+
40
+ def list_incidents(self, status: str | None = None) -> list[Incident]:
41
+ """List all incidents.
42
+
43
+ Args:
44
+ status: Filter by status (``investigating``, ``identified``,
45
+ ``monitoring``, ``resolved``).
46
+
47
+ Returns:
48
+ List of :class:`Incident` objects. Incidents that fail to parse
49
+ are silently skipped with a warning log.
50
+
51
+ Raises:
52
+ HyperpingAuthError: If the API key is invalid.
53
+ HyperpingAPIError: On unexpected API errors.
54
+ """
55
+ params = {}
56
+ if status:
57
+ params["status"] = status
58
+
59
+ response = self._request("GET", Endpoint.INCIDENTS, params=params if params else None)
60
+
61
+ # Handle different response formats
62
+ if isinstance(response, list):
63
+ incidents_data = response
64
+ elif "incidents" in response:
65
+ incidents_data = response["incidents"]
66
+ else:
67
+ incidents_data = response.get("data", [])
68
+
69
+ incidents = []
70
+ skipped = 0
71
+ for data in incidents_data:
72
+ try:
73
+ incidents.append(Incident.model_validate(data))
74
+ except (ValueError, ValidationError) as e:
75
+ skipped += 1
76
+ logger.warning(f"Failed to parse incident data: {e}", extra={"data": data})
77
+
78
+ if skipped:
79
+ logger.warning(
80
+ f"{skipped} of {len(incidents_data)} incidents could not be parsed and were skipped"
81
+ )
82
+
83
+ return incidents
84
+
85
+ def get_incident(self, incident_id: str) -> Incident:
86
+ """Get a single incident by ID.
87
+
88
+ Args:
89
+ incident_id: Incident ID
90
+
91
+ Returns:
92
+ Incident object
93
+
94
+ Raises:
95
+ HyperpingNotFoundError: If incident not found
96
+ """
97
+ response = self._request("GET", f"{Endpoint.INCIDENTS}/{incident_id}")
98
+ return Incident.model_validate(response)
99
+
100
+ def create_incident(self, incident: IncidentCreate) -> Incident:
101
+ """Create a new incident.
102
+
103
+ Args:
104
+ incident: Incident creation data.
105
+
106
+ Returns:
107
+ Created :class:`Incident` object.
108
+
109
+ Raises:
110
+ HyperpingValidationError: If the payload fails server-side validation.
111
+ HyperpingAPIError: On unexpected API errors.
112
+
113
+ Note:
114
+ v3 API returns {"message": "...", "uuid": "..."} on create,
115
+ not the full incident object. The full incident is fetched after creation.
116
+ """
117
+ payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
118
+ response = self._request("POST", Endpoint.INCIDENTS, json=payload)
119
+ # v3 API returns minimal response with just uuid
120
+ if "uuid" in response and "title" not in response:
121
+ # Fetch the full incident after creation
122
+ return self.get_incident(response["uuid"])
123
+ return Incident.model_validate(response)
124
+
125
+ def update_incident(self, incident_id: str, update: IncidentUpdateRequest) -> Incident:
126
+ """Update an existing incident.
127
+
128
+ Args:
129
+ incident_id: Incident UUID.
130
+ update: Fields to update.
131
+
132
+ Returns:
133
+ Updated :class:`Incident` object.
134
+
135
+ Raises:
136
+ HyperpingNotFoundError: If the incident does not exist.
137
+ HyperpingValidationError: If the payload fails server-side validation.
138
+ HyperpingAPIError: On unexpected API errors.
139
+ """
140
+ payload = update.model_dump(exclude_none=True, by_alias=True)
141
+ response = self._request("PUT", f"{Endpoint.INCIDENTS}/{incident_id}", json=payload)
142
+ return Incident.model_validate(response)
143
+
144
+ def add_incident_update(
145
+ self,
146
+ incident_id: str,
147
+ update: IncidentUpdateCreate,
148
+ ) -> Incident:
149
+ """Add an update to an incident.
150
+
151
+ Args:
152
+ incident_id: Incident UUID.
153
+ update: Update data with message and new status.
154
+
155
+ Returns:
156
+ Updated :class:`Incident` object (re-fetched after posting the update).
157
+
158
+ Raises:
159
+ HyperpingNotFoundError: If the incident does not exist.
160
+ HyperpingAPIError: On unexpected API errors.
161
+ """
162
+ payload = update.model_dump(exclude_none=True, by_alias=True)
163
+ url = f"{Endpoint.INCIDENTS}/{incident_id}/updates"
164
+ self._request("POST", url, json=payload) # Returns {"message": "..."} — not a full Incident
165
+ return self.get_incident(incident_id)
166
+
167
+ def resolve_incident(self, incident_id: str, message: str | None = None) -> Incident:
168
+ """Resolve an incident.
169
+
170
+ Args:
171
+ incident_id: Incident UUID.
172
+ message: Optional resolution message. Defaults to
173
+ ``"This incident has been resolved."``.
174
+
175
+ Returns:
176
+ Resolved :class:`Incident` object.
177
+
178
+ Raises:
179
+ HyperpingNotFoundError: If the incident does not exist.
180
+ HyperpingAPIError: On unexpected API errors.
181
+ """
182
+ update = AddIncidentUpdateRequest(
183
+ text=LocalizedText(en=message or "This incident has been resolved."),
184
+ type=IncidentStatus.RESOLVED,
185
+ date=datetime.now(UTC).isoformat(),
186
+ )
187
+ return self.add_incident_update(incident_id, update)
188
+
189
+ def delete_incident(self, incident_id: str) -> None:
190
+ """Delete an incident.
191
+
192
+ Args:
193
+ incident_id: Incident ID
194
+
195
+ Raises:
196
+ HyperpingNotFoundError: If incident not found
197
+ """
198
+ self._request("DELETE", f"{Endpoint.INCIDENTS}/{incident_id}")
@@ -0,0 +1,195 @@
1
+ """Maintenance operations mixin for HyperpingClient.
2
+
3
+ Provides CRUD methods for Hyperping maintenance windows (v1 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 datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from hyperping.endpoints import Endpoint
16
+ from hyperping.models import (
17
+ Maintenance,
18
+ MaintenanceCreate,
19
+ MaintenanceUpdate,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class MaintenanceMixin:
26
+ """Maintenance-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_maintenance(self, status: str | None = None) -> list[Maintenance]:
37
+ """List all maintenance windows.
38
+
39
+ Args:
40
+ status: Filter by status (``scheduled``, ``in_progress``, ``completed``).
41
+
42
+ Returns:
43
+ List of :class:`Maintenance` objects. Windows 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
+ params = {}
51
+ if status:
52
+ params["status"] = status
53
+
54
+ response = self._request("GET", Endpoint.MAINTENANCE, params=params if params else None)
55
+
56
+ # Handle different response formats
57
+ # API returns {"maintenanceWindows": [...]} as of current version
58
+ if isinstance(response, list):
59
+ maintenance_data = response
60
+ elif "maintenanceWindows" in response:
61
+ maintenance_data = response["maintenanceWindows"]
62
+ elif "maintenance" in response:
63
+ maintenance_data = response["maintenance"]
64
+ else:
65
+ maintenance_data = response.get("data", [])
66
+
67
+ windows = []
68
+ skipped = 0
69
+ for data in maintenance_data:
70
+ try:
71
+ windows.append(Maintenance.model_validate(data))
72
+ except (ValueError, ValidationError) as e:
73
+ skipped += 1
74
+ logger.warning(f"Failed to parse maintenance data: {e}", extra={"data": data})
75
+
76
+ if skipped:
77
+ logger.warning(
78
+ f"{skipped} of {len(maintenance_data)} maintenance windows "
79
+ "could not be parsed and were skipped"
80
+ )
81
+
82
+ return windows
83
+
84
+ def get_maintenance(self, maintenance_id: str) -> Maintenance:
85
+ """Get a single maintenance window by ID.
86
+
87
+ Args:
88
+ maintenance_id: Maintenance ID
89
+
90
+ Returns:
91
+ Maintenance object
92
+
93
+ Raises:
94
+ HyperpingNotFoundError: If maintenance not found
95
+ """
96
+ response = self._request("GET", f"{Endpoint.MAINTENANCE}/{maintenance_id}")
97
+ return Maintenance.model_validate(response)
98
+
99
+ def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenance:
100
+ """Create a new maintenance window.
101
+
102
+ Args:
103
+ maintenance: Maintenance creation data.
104
+
105
+ Returns:
106
+ Created :class:`Maintenance` object.
107
+
108
+ Raises:
109
+ HyperpingValidationError: If the payload fails server-side validation.
110
+ HyperpingAPIError: On unexpected API errors.
111
+
112
+ Note:
113
+ v1 API returns {"uuid": "..."} on create, not the full maintenance object.
114
+ The full maintenance window is fetched after creation.
115
+ """
116
+ payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
117
+ response = self._request("POST", Endpoint.MAINTENANCE, json=payload)
118
+ # v1 API returns minimal response with just uuid
119
+ if "uuid" in response and "name" not in response:
120
+ # Fetch the full maintenance after creation
121
+ return self.get_maintenance(response["uuid"])
122
+ return Maintenance.model_validate(response)
123
+
124
+ def update_maintenance(
125
+ self,
126
+ maintenance_id: str,
127
+ update: MaintenanceUpdate,
128
+ ) -> Maintenance:
129
+ """Update an existing maintenance window.
130
+
131
+ The v1 API PUT requires a full payload (partial updates return 401).
132
+ We fetch the current state and merge the supplied fields before sending.
133
+
134
+ Args:
135
+ maintenance_id: Maintenance ID
136
+ update: Fields to update (only non-None fields are applied)
137
+
138
+ Returns:
139
+ Updated Maintenance object
140
+ """
141
+ current = self.get_maintenance(maintenance_id)
142
+ partial = update.model_dump(exclude_none=True, by_alias=True, mode="json")
143
+
144
+ payload: dict[str, object] = {
145
+ "name": current.name,
146
+ "start_date": current.start_date,
147
+ "end_date": current.end_date,
148
+ "monitors": current.monitors,
149
+ }
150
+ payload.update(partial)
151
+
152
+ response = self._request("PUT", f"{Endpoint.MAINTENANCE}/{maintenance_id}", json=payload)
153
+ return Maintenance.model_validate(response)
154
+
155
+ def delete_maintenance(self, maintenance_id: str) -> None:
156
+ """Delete a maintenance window.
157
+
158
+ Args:
159
+ maintenance_id: Maintenance ID
160
+
161
+ Raises:
162
+ HyperpingNotFoundError: If maintenance not found
163
+ """
164
+ self._request("DELETE", f"{Endpoint.MAINTENANCE}/{maintenance_id}")
165
+
166
+ def get_active_maintenance(self) -> list[Maintenance]:
167
+ """Get currently active maintenance windows.
168
+
169
+ Returns:
170
+ List of active :class:`Maintenance` objects.
171
+
172
+ Raises:
173
+ HyperpingAuthError: If the API key is invalid.
174
+ HyperpingAPIError: On unexpected API errors.
175
+ """
176
+ all_maintenance = self.list_maintenance()
177
+ now = datetime.now(UTC)
178
+
179
+ return [m for m in all_maintenance if m.is_active(now)]
180
+
181
+ def is_monitor_in_maintenance(self, monitor_uuid: str) -> bool:
182
+ """Check if a monitor is currently in a maintenance window.
183
+
184
+ Args:
185
+ monitor_uuid: Monitor UUID to check.
186
+
187
+ Returns:
188
+ ``True`` if the monitor is in an active maintenance window.
189
+
190
+ Raises:
191
+ HyperpingAuthError: If the API key is invalid.
192
+ HyperpingAPIError: On unexpected API errors.
193
+ """
194
+ active = self.get_active_maintenance()
195
+ return any(m.affects_monitor(monitor_uuid) for m in active)