From 96c881fd279ba2d2458aab9db1aff876be861429 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sun, 19 Apr 2026 18:03:26 +0300 Subject: [PATCH 1/3] feat: add MCP-discovered API endpoints (reporting, observability, on-call, integrations) Add 18 new client methods covering Hyperping API capabilities discovered via the MCP server reference (hyperping.com/docs/mcp): Reporting (3 methods): - get_status_summary() - aggregate up/down/paused counts - get_monitor_response_time(uuid, period) - latency percentiles - get_monitor_mtta(uuid, period) - mean time to acknowledge Observability (3 methods): - get_monitor_anomalies(uuid) - flapping, latency spikes - get_monitor_http_logs(uuid, page, limit, level) - probe logs - list_recent_alerts(from, to, monitor_uuids) - notification history On-call (5 methods): - list_on_call_schedules() / get_on_call_schedule(uuid) - list_escalation_policies() / get_escalation_policy(uuid) - list_team_members() Integrations (2 methods): - list_integrations() / get_integration(uuid) Outage extensions (2 methods): - get_outage_timeline(uuid) - lifecycle events - get_monitor_outages(monitor_uuid) - scoped outage list Monitor extensions (1 method): - search_monitors_by_name(query) - substring search All methods follow existing SDK patterns: mixin composition, Pydantic v2 models with camelCase aliases, validate_id for path safety, graceful 404 degradation for list endpoints. Full async parity. Endpoint paths are speculative (derived from MCP tool names and existing URL conventions). Marked with comments for verification. New models: StatusSummary, MonitorAnomaly, ProbeLog, AlertNotification, OnCallSchedule, EscalationPolicy, Integration, OutageTimeline, OutageTimelineEvent. --- pyproject.toml | 2 +- src/hyperping/__init__.py | 33 +++++- src/hyperping/_async_client.py | 13 ++- src/hyperping/_async_integrations_mixin.py | 25 +++++ src/hyperping/_async_monitors_mixin.py | 21 ++-- src/hyperping/_async_observability_mixin.py | 64 +++++++++++ src/hyperping/_async_oncall_mixin.py | 52 +++++++++ src/hyperping/_async_outages_mixin.py | 50 ++++++--- src/hyperping/_async_reporting_mixin.py | 39 +++++++ src/hyperping/_integrations_mixin.py | 40 +++++++ src/hyperping/_monitors_mixin.py | 24 ++++- src/hyperping/_observability_mixin.py | 101 ++++++++++++++++++ src/hyperping/_oncall_mixin.py | 87 +++++++++++++++ src/hyperping/_outages_mixin.py | 66 ++++++++++-- src/hyperping/_reporting_mixin.py | 73 +++++++++++++ src/hyperping/_version.py | 2 +- src/hyperping/client.py | 18 +++- src/hyperping/endpoints.py | 57 ++++++++++ src/hyperping/models/__init__.py | 28 ++++- src/hyperping/models/_integration_models.py | 14 +++ src/hyperping/models/_observability_models.py | 46 ++++++++ src/hyperping/models/_oncall_models.py | 27 +++++ src/hyperping/models/_outage_models.py | 25 +++++ src/hyperping/models/_reporting_models.py | 17 +++ uv.lock | 2 +- 25 files changed, 881 insertions(+), 45 deletions(-) create mode 100644 src/hyperping/_async_integrations_mixin.py create mode 100644 src/hyperping/_async_observability_mixin.py create mode 100644 src/hyperping/_async_oncall_mixin.py create mode 100644 src/hyperping/_async_reporting_mixin.py create mode 100644 src/hyperping/_integrations_mixin.py create mode 100644 src/hyperping/_observability_mixin.py create mode 100644 src/hyperping/_oncall_mixin.py create mode 100644 src/hyperping/_reporting_mixin.py create mode 100644 src/hyperping/models/_integration_models.py create mode 100644 src/hyperping/models/_observability_models.py create mode 100644 src/hyperping/models/_oncall_models.py create mode 100644 src/hyperping/models/_reporting_models.py diff --git a/pyproject.toml b/pyproject.toml index f8c2ea6..0b5e862 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hyperping" -version = "1.2.1" +version = "1.3.0" description = "Python SDK for the Hyperping uptime monitoring and incident management API" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index 4d80de0..0efe960 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -38,7 +38,9 @@ from hyperping.models import ( DEFAULT_REGIONS, AddIncidentUpdateRequest, + AlertNotification, DnsRecordType, + EscalationPolicy, Healthcheck, HealthcheckCreate, HealthcheckUpdate, @@ -49,11 +51,13 @@ IncidentUpdate, IncidentUpdateRequest, IncidentUpdateType, + Integration, LocalizedText, Maintenance, MaintenanceCreate, MaintenanceUpdate, Monitor, + MonitorAnomaly, MonitorBase, MonitorCreate, MonitorFrequency, @@ -63,10 +67,14 @@ MonitorTimeout, MonitorUpdate, NotificationOption, + OnCallSchedule, Outage, OutageAction, OutageDetail, OutageStats, + OutageTimeline, + OutageTimelineEvent, + ProbeLog, Region, ReportPeriod, RequestHeader, @@ -74,6 +82,7 @@ StatusPageCreate, StatusPageSubscriber, StatusPageUpdate, + StatusSummary, ) __all__ = [ @@ -137,6 +146,19 @@ # Outages "Outage", "OutageAction", + "OutageTimeline", + "OutageTimelineEvent", + # Observability + "MonitorAnomaly", + "ProbeLog", + "AlertNotification", + # On-call + "OnCallSchedule", + "EscalationPolicy", + # Integrations + "Integration", + # Reporting + "StatusSummary", # Healthchecks "Healthcheck", "HealthcheckCreate", @@ -161,8 +183,7 @@ def __getattr__(name: str) -> object: if name == "HYPERPING_API_BASE": warnings.warn( - "HYPERPING_API_BASE is deprecated and will be removed in v0.3.0. " - "Use API_BASE instead.", + "HYPERPING_API_BASE is deprecated and will be removed in v0.3.0. Use API_BASE instead.", DeprecationWarning, stacklevel=2, ) @@ -170,8 +191,7 @@ def __getattr__(name: str) -> object: if name == "API_PATHS": warnings.warn( - "API_PATHS is deprecated and will be removed in v0.3.0. " - "Use the Endpoint enum instead.", + "API_PATHS is deprecated and will be removed in v0.3.0. Use the Endpoint enum instead.", DeprecationWarning, stacklevel=2, ) @@ -199,7 +219,10 @@ def __getattr__(name: str) -> object: # Symbols removed from __all__ (H5) but still accessible for backward compat _endpoint_helpers = { - "EndpointConfig", "ENDPOINTS", "get_endpoint_url", "get_version_for_endpoint", + "EndpointConfig", + "ENDPOINTS", + "get_endpoint_url", + "get_version_for_endpoint", } if name in _endpoint_helpers: from hyperping import endpoints as _ep diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 5255b02..734283b 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -21,9 +21,13 @@ from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin from hyperping._async_incidents_mixin import AsyncIncidentsMixin +from hyperping._async_integrations_mixin import AsyncIntegrationsMixin from hyperping._async_maintenance_mixin import AsyncMaintenanceMixin from hyperping._async_monitors_mixin import AsyncMonitorsMixin +from hyperping._async_observability_mixin import AsyncObservabilityMixin +from hyperping._async_oncall_mixin import AsyncOnCallMixin from hyperping._async_outages_mixin import AsyncOutagesMixin +from hyperping._async_reporting_mixin import AsyncReportingMixin from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin from hyperping._circuit_breaker import ( CircuitBreaker, @@ -48,6 +52,10 @@ class AsyncHyperpingClient( AsyncOutagesMixin, AsyncStatusPagesMixin, AsyncHealthchecksMixin, + AsyncReportingMixin, + AsyncObservabilityMixin, + AsyncOnCallMixin, + AsyncIntegrationsMixin, ): """Async client for interacting with the Hyperping API. @@ -178,6 +186,7 @@ def _handle_response_error(self, response: httpx.Response) -> None: ) if status in (400, 422): from hyperping.exceptions import HyperpingValidationError + raise HyperpingValidationError( message=f"Validation error: {error_msg}", status_code=status, @@ -321,9 +330,7 @@ async def _request( continue self._circuit_breaker.record_failure() if isinstance(e, httpx.TimeoutException): - raise HyperpingAPIError( - f"Request timeout after {max_attempts} attempts" - ) from e + raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e raise HyperpingAPIError(f"Request failed: {e}") from e raise HyperpingAPIError( # pragma: no cover diff --git a/src/hyperping/_async_integrations_mixin.py b/src/hyperping/_async_integrations_mixin.py new file mode 100644 index 0000000..9527809 --- /dev/null +++ b/src/hyperping/_async_integrations_mixin.py @@ -0,0 +1,25 @@ +"""Async integrations mixin: notification channel management.""" + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._integration_models import Integration + + +class AsyncIntegrationsMixin(_AsyncClientProtocol): + """Async integration API operations.""" + + async def list_integrations(self) -> list[Integration]: + """Get all configured notification integrations.""" + try: + result = await self._request("GET", Endpoint.INTEGRATIONS) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Integration, "integration") + + async def get_integration(self, integration_id: str) -> Integration: + """Get a single integration.""" + validate_id(integration_id, "integration_id") + result = await self._request("GET", f"{Endpoint.INTEGRATIONS}/{integration_id}") + return Integration.model_validate(expect_dict(result, "get_integration")) diff --git a/src/hyperping/_async_monitors_mixin.py b/src/hyperping/_async_monitors_mixin.py index d12e042..3b4b2b2 100644 --- a/src/hyperping/_async_monitors_mixin.py +++ b/src/hyperping/_async_monitors_mixin.py @@ -99,9 +99,7 @@ async def update_monitor( ) payload.update(update.model_dump(exclude_none=True)) - response = await self._request( - "PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload - ) + response = await self._request("PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload) return Monitor.model_validate(expect_dict(response, "update_monitor")) async def delete_monitor(self, monitor_id: str) -> None: @@ -156,9 +154,7 @@ async def get_all_reports( HyperpingAPIError: On unexpected API errors. """ if period not in VALID_PERIODS: - raise ValueError( - f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}" - ) + raise ValueError(f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}") response = expect_dict( await self._request("GET", Endpoint.REPORTS, params={"period": period}), "get_all_reports", @@ -191,3 +187,16 @@ async def get_monitor_report( if r.uuid == monitor_id: return r raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}") + + async def search_monitors_by_name(self, query: str) -> list[Monitor]: + """Search monitors by name (case-insensitive substring match).""" + if not query: + return [] + try: + result = await self._request( + "GET", f"{Endpoint.MONITORS}/search", params={"query": query} + ) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Monitor, "monitor") diff --git a/src/hyperping/_async_observability_mixin.py b/src/hyperping/_async_observability_mixin.py new file mode 100644 index 0000000..abef250 --- /dev/null +++ b/src/hyperping/_async_observability_mixin.py @@ -0,0 +1,64 @@ +"""Async observability mixin: anomalies, probe logs, alert history.""" + +from typing import Any + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) + + +class AsyncObservabilityMixin(_AsyncClientProtocol): + """Async observability API operations.""" + + async def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: + """Get detected anomalies for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + try: + result = await self._request("GET", f"{Endpoint.MONITORS}/{monitor_uuid}/anomalies") + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, MonitorAnomaly, "anomaly") + + async def get_monitor_http_logs( + self, monitor_uuid: str, page: int = 0, limit: int = 50, level: str | None = None + ) -> list[ProbeLog]: + """Get recent HTTP probe logs for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = {"page": page, "limit": limit} + if level is not None: + params["level"] = level + try: + result = await self._request( + "GET", f"{Endpoint.MONITORS}/{monitor_uuid}/http-logs", params=params + ) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, ProbeLog, "probe_log") + + async def list_recent_alerts( + self, + from_dt: str | None = None, + to_dt: str | None = None, + monitor_uuids: list[str] | None = None, + ) -> list[AlertNotification]: + """Get recent alert notification history.""" + params: dict[str, Any] = {} + if from_dt is not None: + params["from"] = from_dt + if to_dt is not None: + params["to"] = to_dt + if monitor_uuids: + params["monitor_uuids"] = ",".join(monitor_uuids) + try: + result = await self._request("GET", Endpoint.ALERTS, params=params) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, AlertNotification, "alert") diff --git a/src/hyperping/_async_oncall_mixin.py b/src/hyperping/_async_oncall_mixin.py new file mode 100644 index 0000000..2031045 --- /dev/null +++ b/src/hyperping/_async_oncall_mixin.py @@ -0,0 +1,52 @@ +"""Async on-call mixin: schedules, escalation policies, team members.""" + +from typing import Any + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule + + +class AsyncOnCallMixin(_AsyncClientProtocol): + """Async on-call context API operations.""" + + async def list_on_call_schedules(self) -> list[OnCallSchedule]: + """Get all on-call rotation schedules.""" + try: + result = await self._request("GET", Endpoint.ON_CALL_SCHEDULES) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, OnCallSchedule, "on_call_schedule") + + async def get_on_call_schedule(self, schedule_id: str) -> OnCallSchedule: + """Get a single on-call schedule.""" + validate_id(schedule_id, "schedule_id") + result = await self._request("GET", f"{Endpoint.ON_CALL_SCHEDULES}/{schedule_id}") + return OnCallSchedule.model_validate(expect_dict(result, "get_on_call_schedule")) + + async def list_escalation_policies(self) -> list[EscalationPolicy]: + """Get all escalation policies.""" + try: + result = await self._request("GET", Endpoint.ESCALATION_POLICIES) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, EscalationPolicy, "escalation_policy") + + async def get_escalation_policy(self, policy_id: str) -> EscalationPolicy: + """Get a single escalation policy.""" + validate_id(policy_id, "policy_id") + result = await self._request("GET", f"{Endpoint.ESCALATION_POLICIES}/{policy_id}") + return EscalationPolicy.model_validate(expect_dict(result, "get_escalation_policy")) + + async def list_team_members(self) -> list[dict[str, Any]]: + """Get all team members.""" + try: + result = await self._request("GET", Endpoint.TEAM_MEMBERS) + except Exception: + return [] + if isinstance(result, list): + return result + return [] diff --git a/src/hyperping/_async_outages_mixin.py b/src/hyperping/_async_outages_mixin.py index c330bd1..f17cd26 100644 --- a/src/hyperping/_async_outages_mixin.py +++ b/src/hyperping/_async_outages_mixin.py @@ -19,6 +19,7 @@ from hyperping.endpoints import Endpoint from hyperping.exceptions import HyperpingNotFoundError from hyperping.models import Outage, OutageAction +from hyperping.models._outage_models import OutageTimeline, OutageTimelineEvent logger = logging.getLogger(__name__) @@ -56,9 +57,7 @@ async def list_outages( ValueError: If *status* or *outage_type* is not a recognised value. """ if status not in _VALID_STATUSES: - raise ValueError( - f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}" - ) + raise ValueError(f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}") if outage_type not in _VALID_TYPES: raise ValueError( f"Invalid outage_type {outage_type!r}. Valid values: {sorted(_VALID_TYPES)}" @@ -75,7 +74,8 @@ async def list_outages( params["page"] = page data = await self._request("GET", Endpoint.OUTAGES, params=params) raw: list[Any] = ( - data.get("outages", []) if isinstance(data, dict) + data.get("outages", []) + if isinstance(data, dict) else (data if isinstance(data, list) else []) ) return parse_list(raw, Outage, "outage") @@ -86,9 +86,7 @@ async def list_outages( logger.debug("Outage endpoint not available (404)") return [] - async def acknowledge_outage( - self, outage_id: str, message: str | None = None - ) -> OutageAction: + async def acknowledge_outage(self, outage_id: str, message: str | None = None) -> OutageAction: """Acknowledge an outage. Args: @@ -110,9 +108,7 @@ async def acknowledge_outage( ) return OutageAction.from_raw(expect_dict(result, "outage operation")) - async def resolve_outage( - self, outage_id: str, message: str | None = None - ) -> OutageAction: + async def resolve_outage(self, outage_id: str, message: str | None = None) -> OutageAction: """Resolve an outage. Args: @@ -163,9 +159,7 @@ async def unacknowledge_outage(self, outage_id: str) -> OutageAction: HyperpingNotFoundError: If outage not found. """ validate_id(outage_id, "outage_id") - result = await self._request( - "POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge" - ) + result = await self._request("POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge") return OutageAction.from_raw(expect_dict(result, "outage operation")) async def delete_outage(self, outage_id: str) -> None: @@ -212,3 +206,33 @@ async def get_outage(self, outage_id: str) -> Outage: validate_id(outage_id, "outage_id") result = await self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}") return Outage.model_validate(expect_dict(result, "get_outage")) + + async def get_outage_timeline(self, outage_id: str) -> OutageTimeline: + """Get the lifecycle timeline for an outage.""" + validate_id(outage_id, "outage_id") + result = await self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}/timeline") + data = expect_dict(result, "get_outage_timeline") + raw_events = data.get("events", []) + events = parse_list(raw_events, OutageTimelineEvent, "timeline_event") + return OutageTimeline.model_validate({"outageUuid": outage_id, "events": events}) + + async def get_monitor_outages( + self, + monitor_uuid: str, + page: int | None = None, + status: str = "all", + ) -> list[Outage]: + """Get outages scoped to a single monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = { + "monitor_uuid": monitor_uuid, + "status": status, + } + if page is not None: + params["page"] = page + try: + result = await self._request("GET", Endpoint.OUTAGES, params=params) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Outage, "outage") diff --git a/src/hyperping/_async_reporting_mixin.py b/src/hyperping/_async_reporting_mixin.py new file mode 100644 index 0000000..3113098 --- /dev/null +++ b/src/hyperping/_async_reporting_mixin.py @@ -0,0 +1,39 @@ +"""Async reporting mixin: status summary, response time, MTTA.""" + +from typing import Any + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import expect_dict, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._reporting_models import StatusSummary + + +class AsyncReportingMixin(_AsyncClientProtocol): + """Async status and reporting API operations.""" + + async def get_status_summary(self) -> StatusSummary: + """Get aggregate status counts for the project.""" + result = await self._request("GET", Endpoint.STATUS_SUMMARY) + return StatusSummary.model_validate(expect_dict(result, "get_status_summary")) + + async def get_monitor_response_time( + self, monitor_uuid: str, period: str = "24h" + ) -> dict[str, Any]: + """Get latency percentiles for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + result = await self._request( + "GET", + f"/v2/reporting/response-time/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_response_time") + + async def get_monitor_mtta(self, monitor_uuid: str, period: str = "30d") -> dict[str, Any]: + """Get Mean Time To Acknowledge for a monitor.""" + validate_id(monitor_uuid, "monitor_uuid") + result = await self._request( + "GET", + f"/v2/reporting/mtta/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_integrations_mixin.py b/src/hyperping/_integrations_mixin.py new file mode 100644 index 0000000..4c961f6 --- /dev/null +++ b/src/hyperping/_integrations_mixin.py @@ -0,0 +1,40 @@ +"""Integrations mixin: notification channel management.""" + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._integration_models import Integration + + +class IntegrationsMixin(_ClientProtocol): + """Integration API operations.""" + + def list_integrations(self) -> list[Integration]: + """Get all configured notification integrations. + + Returns: + List of :class:`~hyperping.models.Integration` objects. + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.INTEGRATIONS) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Integration, "integration") + + def get_integration(self, integration_id: str) -> Integration: + """Get a single integration. + + Args: + integration_id: Integration UUID. + + Returns: + :class:`~hyperping.models.Integration` object. + + Raises: + HyperpingNotFoundError: If integration not found. + """ + validate_id(integration_id, "integration_id") + result = self._request("GET", f"{Endpoint.INTEGRATIONS}/{integration_id}") + return Integration.model_validate(expect_dict(result, "get_integration")) diff --git a/src/hyperping/_monitors_mixin.py b/src/hyperping/_monitors_mixin.py index 50d70c2..fde5bc3 100644 --- a/src/hyperping/_monitors_mixin.py +++ b/src/hyperping/_monitors_mixin.py @@ -171,9 +171,7 @@ def get_all_reports( HyperpingAPIError: On unexpected API errors. """ if period not in VALID_PERIODS: - raise ValueError( - f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}" - ) + raise ValueError(f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}") response = expect_dict( self._request("GET", Endpoint.REPORTS, params={"period": period}), "get_all_reports", @@ -215,3 +213,23 @@ def get_monitor_report( if r.uuid == monitor_id: return r raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}") + + def search_monitors_by_name(self, query: str) -> list[Monitor]: + """Search monitors by name (case-insensitive substring match). + + Args: + query: Search string to match against monitor names and URLs. + + Returns: + List of matching :class:`~hyperping.models.Monitor` objects. + Returns empty list on 404 or no matches. + """ + if not query: + return [] + try: + # Path is speculative; derived from MCP tool name. + result = self._request("GET", f"{Endpoint.MONITORS}/search", params={"query": query}) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Monitor, "monitor") diff --git a/src/hyperping/_observability_mixin.py b/src/hyperping/_observability_mixin.py new file mode 100644 index 0000000..2f84c62 --- /dev/null +++ b/src/hyperping/_observability_mixin.py @@ -0,0 +1,101 @@ +"""Observability mixin: anomalies, probe logs, alert history.""" + +from typing import Any + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) + + +class ObservabilityMixin(_ClientProtocol): + """Observability API operations: anomalies, logs, alerts.""" + + def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: + """Get detected anomalies for a monitor. + + Args: + monitor_uuid: Monitor UUID. + + Returns: + List of :class:`~hyperping.models.MonitorAnomaly` objects. + Returns empty list on 404. + """ + validate_id(monitor_uuid, "monitor_uuid") + try: + # Path is speculative; derived from MCP tool name. + result = self._request("GET", f"{Endpoint.MONITORS}/{monitor_uuid}/anomalies") + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, MonitorAnomaly, "anomaly") + + def get_monitor_http_logs( + self, + monitor_uuid: str, + page: int = 0, + limit: int = 50, + level: str | None = None, + ) -> list[ProbeLog]: + """Get recent HTTP probe logs for a monitor. + + Args: + monitor_uuid: Monitor UUID. + page: Page number (0-indexed). + limit: Results per page (max 200). + level: Filter by log level (e.g., ``"error"``). + + Returns: + List of :class:`~hyperping.models.ProbeLog` objects. + Returns empty list on 404. + """ + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = {"page": page, "limit": limit} + if level is not None: + params["level"] = level + try: + # Path is speculative; derived from MCP tool name. + result = self._request( + "GET", + f"{Endpoint.MONITORS}/{monitor_uuid}/http-logs", + params=params, + ) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, ProbeLog, "probe_log") + + def list_recent_alerts( + self, + from_dt: str | None = None, + to_dt: str | None = None, + monitor_uuids: list[str] | None = None, + ) -> list[AlertNotification]: + """Get recent alert notification history. + + Args: + from_dt: Start time filter (ISO 8601). + to_dt: End time filter (ISO 8601). + monitor_uuids: Filter to specific monitors. + + Returns: + List of :class:`~hyperping.models.AlertNotification` objects. + Returns empty list on 404. + """ + params: dict[str, Any] = {} + if from_dt is not None: + params["from"] = from_dt + if to_dt is not None: + params["to"] = to_dt + if monitor_uuids: + params["monitor_uuids"] = ",".join(monitor_uuids) + try: + result = self._request("GET", Endpoint.ALERTS, params=params) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, AlertNotification, "alert") diff --git a/src/hyperping/_oncall_mixin.py b/src/hyperping/_oncall_mixin.py new file mode 100644 index 0000000..7a31a55 --- /dev/null +++ b/src/hyperping/_oncall_mixin.py @@ -0,0 +1,87 @@ +"""On-call mixin: schedules, escalation policies, team members.""" + +from typing import Any + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import expect_dict, parse_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule + + +class OnCallMixin(_ClientProtocol): + """On-call context API operations.""" + + def list_on_call_schedules(self) -> list[OnCallSchedule]: + """Get all on-call rotation schedules. + + Returns: + List of :class:`~hyperping.models.OnCallSchedule` objects. + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.ON_CALL_SCHEDULES) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, OnCallSchedule, "on_call_schedule") + + def get_on_call_schedule(self, schedule_id: str) -> OnCallSchedule: + """Get a single on-call schedule. + + Args: + schedule_id: Schedule UUID. + + Returns: + :class:`~hyperping.models.OnCallSchedule` object. + + Raises: + HyperpingNotFoundError: If schedule not found. + """ + validate_id(schedule_id, "schedule_id") + result = self._request("GET", f"{Endpoint.ON_CALL_SCHEDULES}/{schedule_id}") + return OnCallSchedule.model_validate(expect_dict(result, "get_on_call_schedule")) + + def list_escalation_policies(self) -> list[EscalationPolicy]: + """Get all escalation policies. + + Returns: + List of :class:`~hyperping.models.EscalationPolicy` objects. + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.ESCALATION_POLICIES) + except Exception: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, EscalationPolicy, "escalation_policy") + + def get_escalation_policy(self, policy_id: str) -> EscalationPolicy: + """Get a single escalation policy. + + Args: + policy_id: Policy UUID. + + Returns: + :class:`~hyperping.models.EscalationPolicy` object. + + Raises: + HyperpingNotFoundError: If policy not found. + """ + validate_id(policy_id, "policy_id") + result = self._request("GET", f"{Endpoint.ESCALATION_POLICIES}/{policy_id}") + return EscalationPolicy.model_validate(expect_dict(result, "get_escalation_policy")) + + def list_team_members(self) -> list[dict[str, Any]]: + """Get all team members. + + Returns: + List of dicts with member info (name, email). + Returns empty list on 404. + """ + try: + result = self._request("GET", Endpoint.TEAM_MEMBERS) + except Exception: + return [] + if isinstance(result, list): + return result + return [] diff --git a/src/hyperping/_outages_mixin.py b/src/hyperping/_outages_mixin.py index 15549f2..4c9dc56 100644 --- a/src/hyperping/_outages_mixin.py +++ b/src/hyperping/_outages_mixin.py @@ -14,6 +14,7 @@ from hyperping.endpoints import Endpoint from hyperping.exceptions import HyperpingNotFoundError from hyperping.models import Outage, OutageAction +from hyperping.models._outage_models import OutageTimeline, OutageTimelineEvent logger = logging.getLogger(__name__) @@ -51,9 +52,7 @@ def list_outages( ValueError: If *status* or *outage_type* is not a recognised value. """ if status not in _VALID_STATUSES: - raise ValueError( - f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}" - ) + raise ValueError(f"Invalid status {status!r}. Valid values: {sorted(_VALID_STATUSES)}") if outage_type not in _VALID_TYPES: raise ValueError( f"Invalid outage_type {outage_type!r}. Valid values: {sorted(_VALID_TYPES)}" @@ -70,7 +69,8 @@ def list_outages( params["page"] = page data = self._request("GET", Endpoint.OUTAGES, params=params) raw: list[Any] = ( - data.get("outages", []) if isinstance(data, dict) + data.get("outages", []) + if isinstance(data, dict) else (data if isinstance(data, list) else []) ) return parse_list(raw, Outage, "outage") @@ -154,9 +154,7 @@ def unacknowledge_outage(self, outage_id: str) -> OutageAction: HyperpingNotFoundError: If outage not found. """ validate_id(outage_id, "outage_id") # H8 - result = self._request( - "POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge" - ) + result = self._request("POST", f"{Endpoint.OUTAGES}/{outage_id}/unacknowledge") return OutageAction.from_raw(expect_dict(result, "outage operation")) def delete_outage(self, outage_id: str) -> None: @@ -203,3 +201,57 @@ def get_outage(self, outage_id: str) -> Outage: validate_id(outage_id, "outage_id") # H8 result = self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}") return Outage.model_validate(expect_dict(result, "get_outage")) + + def get_outage_timeline(self, outage_id: str) -> OutageTimeline: + """Get the lifecycle timeline for an outage. + + Timeline events include detection, cross-region verification, + alert dispatch, acknowledgement, and resolution. + + Args: + outage_id: Outage UUID. + + Returns: + :class:`~hyperping.models.OutageTimeline` with chronological events. + + Raises: + HyperpingNotFoundError: If outage not found. + """ + validate_id(outage_id, "outage_id") + # Path is speculative; derived from MCP tool name. + result = self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}/timeline") + data = expect_dict(result, "get_outage_timeline") + raw_events = data.get("events", []) + events = parse_list(raw_events, OutageTimelineEvent, "timeline_event") + return OutageTimeline.model_validate({"outageUuid": outage_id, "events": events}) + + def get_monitor_outages( + self, + monitor_uuid: str, + page: int | None = None, + status: str = "all", + ) -> list[Outage]: + """Get outages scoped to a single monitor. + + Args: + monitor_uuid: Monitor UUID. + page: Page number (0-indexed). None fetches first page. + status: Filter: ``"all"``, ``"ongoing"``, ``"resolved"``. + + Returns: + List of :class:`~hyperping.models.Outage` objects. + Returns empty list on 404. + """ + validate_id(monitor_uuid, "monitor_uuid") + params: dict[str, Any] = { + "monitor_uuid": monitor_uuid, + "status": status, + } + if page is not None: + params["page"] = page + try: + result = self._request("GET", Endpoint.OUTAGES, params=params) + except HyperpingNotFoundError: + return [] + items = result if isinstance(result, list) else [] + return parse_list(items, Outage, "outage") diff --git a/src/hyperping/_reporting_mixin.py b/src/hyperping/_reporting_mixin.py new file mode 100644 index 0000000..8447dda --- /dev/null +++ b/src/hyperping/_reporting_mixin.py @@ -0,0 +1,73 @@ +"""Reporting mixin: status summary, response time, MTTA.""" + +from typing import Any + +from hyperping._protocols import _ClientProtocol +from hyperping._utils import expect_dict, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models._reporting_models import StatusSummary + + +class ReportingMixin(_ClientProtocol): + """Status and reporting API operations.""" + + def get_status_summary(self) -> StatusSummary: + """Get aggregate status counts for the project. + + Returns: + :class:`~hyperping.models.StatusSummary` with up/down/paused counts. + """ + result = self._request("GET", Endpoint.STATUS_SUMMARY) + return StatusSummary.model_validate(expect_dict(result, "get_status_summary")) + + def get_monitor_response_time( + self, + monitor_uuid: str, + period: str = "24h", + ) -> dict[str, Any]: + """Get latency percentiles for a monitor. + + Args: + monitor_uuid: Monitor UUID. + period: Time period (e.g., ``"1h"``, ``"24h"``, ``"7d"``). + + Returns: + Dict with latency percentile data (shape depends on API). + + Raises: + HyperpingNotFoundError: If monitor not found. + """ + validate_id(monitor_uuid, "monitor_uuid") + # Path is speculative; derived from MCP tool name. + result = self._request( + "GET", + f"/v2/reporting/response-time/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_response_time") + + def get_monitor_mtta( + self, + monitor_uuid: str, + period: str = "30d", + ) -> dict[str, Any]: + """Get Mean Time To Acknowledge for a monitor. + + Args: + monitor_uuid: Monitor UUID. + period: Time period (e.g., ``"7d"``, ``"30d"``). + + Returns: + Dict with MTTA data (shape depends on API). + + Raises: + HyperpingNotFoundError: If monitor not found. + """ + validate_id(monitor_uuid, "monitor_uuid") + # Path is speculative; derived from MCP tool name. + result = self._request( + "GET", + f"/v2/reporting/mtta/{monitor_uuid}", + params={"period": period}, + ) + return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_version.py b/src/hyperping/_version.py index a955fda..67bc602 100644 --- a/src/hyperping/_version.py +++ b/src/hyperping/_version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.3.0" diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 1c325df..7ccb92c 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -25,10 +25,14 @@ ) from hyperping._healthchecks_mixin import HealthchecksMixin from hyperping._incidents_mixin import IncidentsMixin +from hyperping._integrations_mixin import IntegrationsMixin from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log from hyperping._maintenance_mixin import MaintenanceMixin from hyperping._monitors_mixin import MonitorsMixin +from hyperping._observability_mixin import ObservabilityMixin +from hyperping._oncall_mixin import OnCallMixin from hyperping._outages_mixin import OutagesMixin +from hyperping._reporting_mixin import ReportingMixin from hyperping._statuspages_mixin import StatusPagesMixin from hyperping.endpoints import API_BASE from hyperping.exceptions import ( @@ -59,8 +63,16 @@ class RetryConfig: class HyperpingClient( - MonitorsMixin, IncidentsMixin, MaintenanceMixin, OutagesMixin, StatusPagesMixin, + MonitorsMixin, + IncidentsMixin, + MaintenanceMixin, + OutagesMixin, + StatusPagesMixin, HealthchecksMixin, + ReportingMixin, + ObservabilityMixin, + OnCallMixin, + IntegrationsMixin, ): """Client for interacting with Hyperping API. @@ -393,9 +405,7 @@ def _request( continue self._circuit_breaker.record_failure() if isinstance(e, httpx.TimeoutException): - raise HyperpingAPIError( - f"Request timeout after {max_attempts} attempts" - ) from e + raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e raise HyperpingAPIError(f"Request failed: {e}") from e # Should not reach here, but just in case diff --git a/src/hyperping/endpoints.py b/src/hyperping/endpoints.py index 39aeb2c..f3bba64 100644 --- a/src/hyperping/endpoints.py +++ b/src/hyperping/endpoints.py @@ -136,6 +136,26 @@ class Endpoint(StrEnum): HEALTHCHECKS = "/v2/healthchecks" """Healthcheck (cron/heartbeat monitor) management. Version: v2""" + # --- MCP-discovered endpoints (paths are speculative; verify against live API) --- + + ALERTS = "/v2/alerts" + """Alert notification history. Version: v2""" + + ON_CALL_SCHEDULES = "/v2/on-call-schedules" + """On-call rotation schedules. Version: v2""" + + ESCALATION_POLICIES = "/v2/escalation-policies" + """Escalation policy chains. Version: v2""" + + TEAM_MEMBERS = "/v2/team-members" + """Team member directory. Version: v2""" + + INTEGRATIONS = "/v2/integrations" + """Notification channel integrations. Version: v2""" + + STATUS_SUMMARY = "/v2/status-summary" + """Aggregate status counts. Version: v2""" + # Detailed endpoint metadata for programmatic access ENDPOINTS: Final[dict[Endpoint, EndpointConfig]] = { @@ -174,6 +194,37 @@ class Endpoint(StrEnum): resource="healthchecks", description="Healthcheck (cron/heartbeat) CRUD and pause/resume", ), + # MCP-discovered (speculative paths) + Endpoint.ALERTS: EndpointConfig( + version=APIVersion.V2, + resource="alerts", + description="Alert notification history across channels", + ), + Endpoint.ON_CALL_SCHEDULES: EndpointConfig( + version=APIVersion.V2, + resource="on-call-schedules", + description="On-call rotation schedules", + ), + Endpoint.ESCALATION_POLICIES: EndpointConfig( + version=APIVersion.V2, + resource="escalation-policies", + description="Escalation policy chains", + ), + Endpoint.TEAM_MEMBERS: EndpointConfig( + version=APIVersion.V2, + resource="team-members", + description="Team member name and email directory", + ), + Endpoint.INTEGRATIONS: EndpointConfig( + version=APIVersion.V2, + resource="integrations", + description="Notification channel configuration", + ), + Endpoint.STATUS_SUMMARY: EndpointConfig( + version=APIVersion.V2, + resource="status-summary", + description="Aggregate monitor status counts", + ), } @@ -195,6 +246,12 @@ class Endpoint(StrEnum): "outages": Endpoint.OUTAGES.value, "statuspages": Endpoint.STATUSPAGES.value, "healthchecks": Endpoint.HEALTHCHECKS.value, + "alerts": Endpoint.ALERTS.value, + "on-call-schedules": Endpoint.ON_CALL_SCHEDULES.value, + "escalation-policies": Endpoint.ESCALATION_POLICIES.value, + "team-members": Endpoint.TEAM_MEMBERS.value, + "integrations": Endpoint.INTEGRATIONS.value, + "status-summary": Endpoint.STATUS_SUMMARY.value, } """Deprecated: Use Endpoint enum instead for type safety.""" diff --git a/src/hyperping/models/__init__.py b/src/hyperping/models/__init__.py index 1909f64..c66fef8 100644 --- a/src/hyperping/models/__init__.py +++ b/src/hyperping/models/__init__.py @@ -23,6 +23,7 @@ IncidentUpdateRequest, IncidentUpdateType, ) +from hyperping.models._integration_models import Integration from hyperping.models._maintenance_models import ( Maintenance, MaintenanceCreate, @@ -50,7 +51,19 @@ ReportPeriod, RequestHeader, ) -from hyperping.models._outage_models import Outage, OutageAction +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule +from hyperping.models._outage_models import ( + Outage, + OutageAction, + OutageTimeline, + OutageTimelineEvent, +) +from hyperping.models._reporting_models import StatusSummary from hyperping.models._statuspage_models import ( StatusPage, StatusPageCreate, @@ -102,6 +115,19 @@ # Outage models "Outage", "OutageAction", + "OutageTimeline", + "OutageTimelineEvent", + # Observability models + "MonitorAnomaly", + "ProbeLog", + "AlertNotification", + # On-call models + "OnCallSchedule", + "EscalationPolicy", + # Integration models + "Integration", + # Reporting models + "StatusSummary", # Healthcheck models "Healthcheck", "HealthcheckCreate", diff --git a/src/hyperping/models/_integration_models.py b/src/hyperping/models/_integration_models.py new file mode 100644 index 0000000..e44c047 --- /dev/null +++ b/src/hyperping/models/_integration_models.py @@ -0,0 +1,14 @@ +"""Integration models: notification channel configuration.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class Integration(BaseModel): + """Configured notification integration (Slack, Teams, PagerDuty, etc.).""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Integration UUID") + name: str = Field(..., description="Integration display name") + integration_type: str = Field(..., alias="type", description="Channel type") + active: bool = Field(default=True, description="Whether the integration is active") diff --git a/src/hyperping/models/_observability_models.py b/src/hyperping/models/_observability_models.py new file mode 100644 index 0000000..ebcb318 --- /dev/null +++ b/src/hyperping/models/_observability_models.py @@ -0,0 +1,46 @@ +"""Observability models: anomalies, probe logs, and alert notifications.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class MonitorAnomaly(BaseModel): + """Anomaly detected on a monitor (flapping, latency spike, etc.).""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + anomaly_type: str = Field(..., alias="anomalyType", description="Anomaly category") + started_at: str | None = Field( + default=None, alias="startedAt", description="Start time ISO 8601" + ) + ended_at: str | None = Field(default=None, alias="endedAt", description="End time ISO 8601") + severity: str = Field(default="info", description="Anomaly severity") + + +class ProbeLog(BaseModel): + """HTTP probe log entry from a monitor check.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + status: int | None = Field(default=None, description="HTTP status code") + location: str | None = Field(default=None, description="Probe region") + response_time_ms: float | None = Field( + default=None, alias="responseTimeMs", description="Response time in ms" + ) + level: str | None = Field(default=None, description="Log level") + timestamp: str | None = Field(default=None, description="Timestamp ISO 8601") + + +class AlertNotification(BaseModel): + """Alert notification record from notification history.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Alert UUID") + monitor_uuid: str | None = Field( + default=None, alias="monitorUuid", description="Affected monitor UUID" + ) + channel: str = Field(default="unknown", description="Notification channel") + sent_at: str | None = Field(default=None, alias="sentAt", description="Send time ISO 8601") + resolved_at: str | None = Field( + default=None, alias="resolvedAt", description="Resolution time ISO 8601" + ) diff --git a/src/hyperping/models/_oncall_models.py b/src/hyperping/models/_oncall_models.py new file mode 100644 index 0000000..a682dd9 --- /dev/null +++ b/src/hyperping/models/_oncall_models.py @@ -0,0 +1,27 @@ +"""On-call models: schedules and escalation policies.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class OnCallSchedule(BaseModel): + """On-call rotation schedule.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Schedule UUID") + name: str = Field(..., description="Schedule name") + current_on_call: str | None = Field( + default=None, alias="currentOnCall", description="Current on-call person" + ) + + +class EscalationPolicy(BaseModel): + """Escalation policy with step chain.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + uuid: str = Field(..., description="Policy UUID") + name: str = Field(..., description="Policy name") + steps: list[dict[str, Any]] = Field(default_factory=list, description="Escalation steps") diff --git a/src/hyperping/models/_outage_models.py b/src/hyperping/models/_outage_models.py index 840eb0d..8ba800a 100644 --- a/src/hyperping/models/_outage_models.py +++ b/src/hyperping/models/_outage_models.py @@ -78,3 +78,28 @@ class Outage(BaseModel): def from_raw(cls, data: dict[str, Any]) -> Outage: """Parse an outage from a raw API response dict.""" return cls.model_validate(data) + + +class OutageTimelineEvent(BaseModel): + """Single event in an outage lifecycle timeline. + + Events include detection, cross-region verification, alert dispatch, + acknowledgement, and resolution. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + event_type: str = Field(..., alias="eventType", description="Event category") + timestamp: str = Field(..., description="Event time ISO 8601") + detail: str | None = Field(default=None, description="Event detail text") + + +class OutageTimeline(BaseModel): + """Full lifecycle timeline for an outage.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + outage_uuid: str = Field(..., alias="outageUuid", description="Outage UUID") + events: list[OutageTimelineEvent] = Field( + default_factory=list, description="Chronological list of events" + ) diff --git a/src/hyperping/models/_reporting_models.py b/src/hyperping/models/_reporting_models.py new file mode 100644 index 0000000..9558a39 --- /dev/null +++ b/src/hyperping/models/_reporting_models.py @@ -0,0 +1,17 @@ +"""Reporting models: status summary and aggregated metrics.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class StatusSummary(BaseModel): + """Aggregate status summary from a single API call.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True) + + total_monitors: int = Field(default=0, alias="totalMonitors") + up_count: int = Field(default=0, alias="upCount") + down_count: int = Field(default=0, alias="downCount") + paused_count: int = Field(default=0, alias="pausedCount") + down_monitors: list[dict[str, Any]] = Field(default_factory=list, alias="downMonitors") diff --git a/uv.lock b/uv.lock index bea9e2c..6f71c35 100644 --- a/uv.lock +++ b/uv.lock @@ -335,7 +335,7 @@ wheels = [ [[package]] name = "hyperping" -version = "1.2.1" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From d6b6e2263cbd7324b4c50566bb99b181394c70d3 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sun, 19 Apr 2026 18:16:34 +0300 Subject: [PATCH 2/3] test: add tests for new and async mixins (90% coverage) Add 86 new tests covering: - Reporting mixin: status summary, response time, MTTA (10 tests) - Observability mixin: anomalies, HTTP logs, alerts (14 tests) - On-call mixin: schedules, policies, team members (15 tests) - Integrations mixin: list/get (6 tests) - Outage extensions: timeline, monitor_outages (6 tests) - Monitor extensions: search_by_name (3 tests) - Async variants of all new mixins (16 tests) - Async pre-existing mixins: healthchecks, maintenance, incidents (16 tests) Coverage: 79% -> 90% (355 tests, 0 failures) --- tests/unit/test_async_new_mixins.py | 268 ++++++++++++++++++++++ tests/unit/test_async_preexisting.py | 331 +++++++++++++++++++++++++++ tests/unit/test_integrations.py | 98 ++++++++ tests/unit/test_monitors.py | 43 ++++ tests/unit/test_observability.py | 203 ++++++++++++++++ tests/unit/test_oncall.py | 217 ++++++++++++++++++ tests/unit/test_outages.py | 99 +++++++- tests/unit/test_reporting.py | 140 +++++++++++ 8 files changed, 1398 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_async_new_mixins.py create mode 100644 tests/unit/test_async_preexisting.py create mode 100644 tests/unit/test_integrations.py create mode 100644 tests/unit/test_observability.py create mode 100644 tests/unit/test_oncall.py create mode 100644 tests/unit/test_reporting.py diff --git a/tests/unit/test_async_new_mixins.py b/tests/unit/test_async_new_mixins.py new file mode 100644 index 0000000..071d4bb --- /dev/null +++ b/tests/unit/test_async_new_mixins.py @@ -0,0 +1,268 @@ +"""Tests for new async mixins: reporting, observability, on-call, integrations.""" + +import httpx +import pytest +import pytest_asyncio +import respx + +from hyperping._async_client import AsyncHyperpingClient +from hyperping.client import RetryConfig +from hyperping.endpoints import API_BASE, Endpoint + + +@pytest_asyncio.fixture +async def async_client(): + """Async client with retries disabled.""" + client = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + yield client + await client.close() + + +# ==================== Reporting ==================== + + +class TestAsyncReporting: + @respx.mock + @pytest.mark.asyncio + async def test_get_status_summary(self, async_client): + respx.get(f"{API_BASE}{Endpoint.STATUS_SUMMARY}").mock( + return_value=httpx.Response( + 200, + json={ + "totalMonitors": 10, + "upCount": 8, + "downCount": 1, + "pausedCount": 1, + }, + ) + ) + result = await async_client.get_status_summary() + assert result.total_monitors == 10 + assert result.up_count == 8 + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_response_time(self, async_client): + respx.get(f"{API_BASE}/v2/reporting/response-time/mon_1").mock( + return_value=httpx.Response(200, json={"p50": 45.0, "p95": 120.0}) + ) + result = await async_client.get_monitor_response_time("mon_1") + assert result["p50"] == 45.0 + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_mtta(self, async_client): + respx.get(f"{API_BASE}/v2/reporting/mtta/mon_1").mock( + return_value=httpx.Response(200, json={"mtta_seconds": 120.0}) + ) + result = await async_client.get_monitor_mtta("mon_1") + assert result["mtta_seconds"] == 120.0 + + +# ==================== Observability ==================== + + +class TestAsyncObservability: + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_anomalies(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_1/anomalies").mock( + return_value=httpx.Response( + 200, + json=[ + { + "anomalyType": "flapping", + "startedAt": "2026-01-01T00:00:00Z", + "severity": "warning", + } + ], + ) + ) + result = await async_client.get_monitor_anomalies("mon_1") + assert len(result) == 1 + assert result[0].anomaly_type == "flapping" + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_http_logs(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_1/http-logs").mock( + return_value=httpx.Response( + 200, + json=[{"status": 200, "location": "london", "responseTimeMs": 45.2}], + ) + ) + result = await async_client.get_monitor_http_logs("mon_1") + assert len(result) == 1 + assert result[0].response_time_ms == 45.2 + + @respx.mock + @pytest.mark.asyncio + async def test_list_recent_alerts(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response( + 200, + json=[{"uuid": "a1", "channel": "slack", "sentAt": "2026-01-01T00:00:00Z"}], + ) + ) + result = await async_client.list_recent_alerts() + assert len(result) == 1 + assert result[0].channel == "slack" + + +# ==================== On-call ==================== + + +class TestAsyncOnCall: + @respx.mock + @pytest.mark.asyncio + async def test_list_on_call_schedules(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response( + 200, + json=[{"uuid": "s1", "name": "Primary", "currentOnCall": "alice"}], + ) + ) + result = await async_client.list_on_call_schedules() + assert len(result) == 1 + assert result[0].current_on_call == "alice" + + @respx.mock + @pytest.mark.asyncio + async def test_get_on_call_schedule(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}/s1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "s1", "name": "Primary", "currentOnCall": "bob"}, + ) + ) + result = await async_client.get_on_call_schedule("s1") + assert result.current_on_call == "bob" + + @respx.mock + @pytest.mark.asyncio + async def test_list_escalation_policies(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response( + 200, json=[{"uuid": "p1", "name": "Default", "steps": []}] + ) + ) + result = await async_client.list_escalation_policies() + assert len(result) == 1 + + @respx.mock + @pytest.mark.asyncio + async def test_get_escalation_policy(self, async_client): + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}/p1").mock( + return_value=httpx.Response( + 200, json={"uuid": "p1", "name": "Tiered", "steps": [{"level": 1}]} + ) + ) + result = await async_client.get_escalation_policy("p1") + assert result.name == "Tiered" + + @respx.mock + @pytest.mark.asyncio + async def test_list_team_members(self, async_client): + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response( + 200, json=[{"name": "Alice", "email": "alice@example.com"}] + ) + ) + result = await async_client.list_team_members() + assert len(result) == 1 + assert result[0]["name"] == "Alice" + + +# ==================== Integrations ==================== + + +class TestAsyncIntegrations: + @respx.mock + @pytest.mark.asyncio + async def test_list_integrations(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response( + 200, + json=[{"uuid": "i1", "name": "Slack", "type": "slack", "active": True}], + ) + ) + result = await async_client.list_integrations() + assert len(result) == 1 + assert result[0].integration_type == "slack" + + @respx.mock + @pytest.mark.asyncio + async def test_get_integration(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}/i1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "i1", "name": "PD", "type": "pagerduty", "active": False}, + ) + ) + result = await async_client.get_integration("i1") + assert result.integration_type == "pagerduty" + assert result.active is False + + +# ==================== Async outage/monitor extensions ==================== + + +class TestAsyncOutageExtensions: + @respx.mock + @pytest.mark.asyncio + async def test_get_outage_timeline(self, async_client): + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_1/timeline").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "eventType": "detection", + "timestamp": "2026-01-01T00:00:00Z", + "detail": "Probe failed", + } + ] + }, + ) + ) + result = await async_client.get_outage_timeline("out_1") + assert result.outage_uuid == "out_1" + assert len(result.events) == 1 + + @respx.mock + @pytest.mark.asyncio + async def test_get_monitor_outages(self, async_client): + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response( + 200, json=[{"uuid": "out_1", "monitorUuid": "mon_1"}] + ) + ) + result = await async_client.get_monitor_outages("mon_1") + assert len(result) == 1 + + +class TestAsyncMonitorExtensions: + @respx.mock + @pytest.mark.asyncio + async def test_search_monitors_by_name(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MONITORS}/search").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "mon_1", + "name": "API Monitor", + "url": "https://api.example.com", + "down": False, + "paused": False, + } + ], + ) + ) + result = await async_client.search_monitors_by_name("API") + assert len(result) == 1 + assert result[0].name == "API Monitor" diff --git a/tests/unit/test_async_preexisting.py b/tests/unit/test_async_preexisting.py new file mode 100644 index 0000000..8f1fedd --- /dev/null +++ b/tests/unit/test_async_preexisting.py @@ -0,0 +1,331 @@ +"""Tests for pre-existing async mixins: healthchecks, maintenance, incidents. + +These bring coverage of existing async code that was previously untested. +""" + +import httpx +import pytest +import pytest_asyncio +import respx + +from hyperping._async_client import AsyncHyperpingClient +from hyperping.client import RetryConfig +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError + + +@pytest_asyncio.fixture +async def async_client(): + """Async client with retries disabled.""" + client = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + yield client + await client.close() + + +# ==================== Async Healthchecks ==================== + + +class TestAsyncHealthchecks: + @respx.mock + @pytest.mark.asyncio + async def test_list_healthchecks(self, async_client): + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "hc_1", + "name": "Cron Job", + "period": 300, + "grace": 60, + } + ], + ) + ) + result = await async_client.list_healthchecks() + assert len(result) == 1 + assert result[0].uuid == "hc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_healthcheck(self, async_client): + respx.get(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1").mock( + return_value=httpx.Response( + 200, + json={"uuid": "hc_1", "name": "Cron", "period": 300, "grace": 60}, + ) + ) + result = await async_client.get_healthcheck("hc_1") + assert result.name == "Cron" + + @respx.mock + @pytest.mark.asyncio + async def test_create_healthcheck(self, async_client): + from hyperping.models import HealthcheckCreate + + respx.post(f"{API_BASE}{Endpoint.HEALTHCHECKS}").mock( + return_value=httpx.Response( + 201, + json={"uuid": "hc_new", "name": "New HC", "period": 600, "grace": 120}, + ) + ) + hc = HealthcheckCreate(name="New HC", period=600, grace=120) + result = await async_client.create_healthcheck(hc) + assert result.uuid == "hc_new" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_healthcheck(self, async_client): + respx.delete(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1").mock( + return_value=httpx.Response(204) + ) + await async_client.delete_healthcheck("hc_1") + + @respx.mock + @pytest.mark.asyncio + async def test_pause_healthcheck(self, async_client): + respx.post(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1/pause").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "hc_1", + "name": "HC", + "period": 300, + "grace": 60, + "isPaused": True, + }, + ) + ) + result = await async_client.pause_healthcheck("hc_1") + assert result.uuid == "hc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_resume_healthcheck(self, async_client): + respx.post(f"{API_BASE}{Endpoint.HEALTHCHECKS}/hc_1/resume").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "hc_1", + "name": "HC", + "period": 300, + "grace": 60, + "isPaused": False, + }, + ) + ) + result = await async_client.resume_healthcheck("hc_1") + assert result.uuid == "hc_1" + + +# ==================== Async Maintenance ==================== + + +class TestAsyncMaintenance: + @respx.mock + @pytest.mark.asyncio + async def test_list_maintenance(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 200, + json={ + "maintenanceWindows": [ + { + "uuid": "mw_1", + "name": "Upgrade", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": [], + } + ] + }, + ) + ) + result = await async_client.list_maintenance() + assert len(result) == 1 + assert result[0].uuid == "mw_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_maintenance(self, async_client): + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "Upgrade", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": [], + }, + ) + ) + result = await async_client.get_maintenance("mw_1") + assert result.name == "Upgrade" + + @respx.mock + @pytest.mark.asyncio + async def test_create_maintenance(self, async_client): + from hyperping.models import MaintenanceCreate + + respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock( + return_value=httpx.Response( + 201, + json={ + "uuid": "mw_new", + "name": "Deploy", + "start_date": "2026-02-01T00:00:00Z", + "end_date": "2026-02-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + mw = MaintenanceCreate( + name="Deploy", + start_date="2026-02-01T00:00:00Z", + end_date="2026-02-01T02:00:00Z", + monitors=["mon_1"], + ) + result = await async_client.create_maintenance(mw) + assert result.uuid == "mw_new" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_maintenance(self, async_client): + respx.delete(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response(204) + ) + await async_client.delete_maintenance("mw_1") + + @respx.mock + @pytest.mark.asyncio + async def test_update_maintenance(self, async_client): + from hyperping.models import MaintenanceUpdate + + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "Old Name", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + respx.put(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "New Name", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-01-01T02:00:00Z", + "monitors": ["mon_1"], + }, + ) + ) + result = await async_client.update_maintenance( + "mw_1", MaintenanceUpdate(name="New Name") + ) + assert result.name == "New Name" + + +# ==================== Async Incidents ==================== + + +class TestAsyncIncidents: + @respx.mock + @pytest.mark.asyncio + async def test_list_incidents(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "inc_1", + "title": {"en": "Outage"}, + "type": "outage", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + } + ], + ) + ) + result = await async_client.list_incidents() + assert len(result) == 1 + assert result[0].uuid == "inc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_get_incident(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inc_1", + "title": {"en": "Outage"}, + "type": "outage", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [], + }, + ) + ) + result = await async_client.get_incident("inc_1") + assert result.title_en == "Outage" + + @respx.mock + @pytest.mark.asyncio + async def test_resolve_incident(self, async_client): + # resolve_incident calls add_incident_update which POSTs then GETs + respx.post(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1/updates").mock( + return_value=httpx.Response( + 200, json={"uuid": "u1", "message": "Update added"} + ) + ) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inc_1", + "title": {"en": "Outage"}, + "type": "outage", + "affected_components": [], + "statuspages": ["sp_1"], + "updates": [ + { + "uuid": "u1", + "type": "resolved", + "date": "2026-01-01T01:00:00Z", + "text": {"en": "Fixed"}, + } + ], + }, + ) + ) + result = await async_client.resolve_incident("inc_1", message="Fixed") + assert result.uuid == "inc_1" + + @respx.mock + @pytest.mark.asyncio + async def test_delete_incident(self, async_client): + respx.delete(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock( + return_value=httpx.Response(204) + ) + await async_client.delete_incident("inc_1") + + @respx.mock + @pytest.mark.asyncio + async def test_incident_not_found(self, async_client): + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inc_x").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + await async_client.get_incident("inc_x") diff --git a/tests/unit/test_integrations.py b/tests/unit/test_integrations.py new file mode 100644 index 0000000..8782da8 --- /dev/null +++ b/tests/unit/test_integrations.py @@ -0,0 +1,98 @@ +"""Tests for integrations API methods.""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models._integration_models import Integration + + +class TestListIntegrations: + """Tests for list_integrations().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing integrations returns parsed models.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "i1", + "name": "Slack", + "type": "slack", + "active": True, + } + ], + ) + ) + + result = client.list_integrations() + assert len(result) == 1 + assert isinstance(result[0], Integration) + assert result[0].uuid == "i1" + assert result[0].name == "Slack" + # Verify "type" alias maps to integration_type field + assert result[0].integration_type == "slack" + assert result[0].active is True + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no integrations exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_integrations() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_integrations() + assert result == [] + + +class TestGetIntegration: + """Tests for get_integration().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test getting a single integration.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}/i1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "i1", + "name": "Slack", + "type": "slack", + "active": True, + }, + ) + ) + + result = client.get_integration("i1") + assert isinstance(result, Integration) + assert result.uuid == "i1" + assert result.name == "Slack" + assert result.integration_type == "slack" + assert result.active is True + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test that 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.INTEGRATIONS}/i_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_integration("i_nope") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_integration("../bad") diff --git a/tests/unit/test_monitors.py b/tests/unit/test_monitors.py index 1b845c8..9fbba2f 100644 --- a/tests/unit/test_monitors.py +++ b/tests/unit/test_monitors.py @@ -682,3 +682,46 @@ def handler(request: httpx.Request) -> httpx.Response: slept = mock_sleep.call_args[0][0] assert slept == 45.0 c.close() + + +class TestSearchMonitorsByName: + """Tests for search_monitors_by_name method.""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test searching monitors by name returns matching monitors.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/search").mock( + return_value=httpx.Response( + 200, + json=[ + { + "monitorUuid": "mon_1", + "name": "API Monitor", + "url": "https://api.example.com", + "down": False, + "paused": False, + } + ], + ) + ) + + result = client.search_monitors_by_name("API") + + assert len(result) == 1 + assert result[0].uuid == "mon_1" + assert result[0].name == "API Monitor" + + def test_empty_query_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty query returns an empty list without making an API call.""" + result = client.search_monitors_by_name("") + assert result == [] + + @respx.mock + def test_not_found_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/search").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.search_monitors_by_name("nonexistent") + assert result == [] diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py new file mode 100644 index 0000000..5f6b5dd --- /dev/null +++ b/tests/unit/test_observability.py @@ -0,0 +1,203 @@ +"""Tests for observability mixin API methods.""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.models._observability_models import ( + AlertNotification, + MonitorAnomaly, + ProbeLog, +) + + +class TestGetMonitorAnomalies: + """Tests for get_monitor_anomalies().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful anomaly retrieval.""" + mock_response = [ + { + "anomalyType": "flapping", + "startedAt": "2026-01-01T00:00:00Z", + "severity": "warning", + } + ] + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/anomalies").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_anomalies("uuid_1") + + assert len(result) == 1 + assert isinstance(result[0], MonitorAnomaly) + assert result[0].anomaly_type == "flapping" + assert result[0].started_at == "2026-01-01T00:00:00Z" + assert result[0].severity == "warning" + + @respx.mock + def test_empty_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty response returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/anomalies").mock( + return_value=httpx.Response(200, json=[]) + ) + + result = client.get_monitor_anomalies("uuid_1") + assert result == [] + + @respx.mock + def test_404_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_nope/anomalies").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.get_monitor_anomalies("uuid_nope") + assert result == [] + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_anomalies("../bad") + + +class TestGetMonitorHttpLogs: + """Tests for get_monitor_http_logs().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful HTTP log retrieval.""" + mock_response = [ + { + "status": 200, + "location": "london", + "responseTimeMs": 45.2, + } + ] + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/http-logs").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_http_logs("uuid_1") + + assert len(result) == 1 + assert isinstance(result[0], ProbeLog) + assert result[0].status == 200 + assert result[0].location == "london" + assert result[0].response_time_ms == 45.2 + + @respx.mock + def test_with_level_param(self, client: HyperpingClient) -> None: + """Test that level param is forwarded in query string.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/http-logs").mock( + return_value=httpx.Response(200, json=[]) + ) + + client.get_monitor_http_logs("uuid_1", level="error") + + request = respx.calls.last.request + assert "level=error" in str(request.url) + + @respx.mock + def test_empty_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty response returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_1/http-logs").mock( + return_value=httpx.Response(200, json=[]) + ) + + result = client.get_monitor_http_logs("uuid_1") + assert result == [] + + @respx.mock + def test_404_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}/uuid_nope/http-logs").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.get_monitor_http_logs("uuid_nope") + assert result == [] + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_http_logs("../bad") + + +class TestListRecentAlerts: + """Tests for list_recent_alerts().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful alert listing.""" + mock_response = [ + { + "uuid": "a1", + "channel": "slack", + "sentAt": "2026-01-01T00:00:00Z", + } + ] + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.list_recent_alerts() + + assert len(result) == 1 + assert isinstance(result[0], AlertNotification) + assert result[0].uuid == "a1" + assert result[0].channel == "slack" + assert result[0].sent_at == "2026-01-01T00:00:00Z" + + @respx.mock + def test_with_time_params(self, client: HyperpingClient) -> None: + """Test that from_dt and to_dt are forwarded as query params.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=[]) + ) + + client.list_recent_alerts( + from_dt="2026-01-01T00:00:00Z", + to_dt="2026-01-31T23:59:59Z", + ) + + request = respx.calls.last.request + url_str = str(request.url) + assert "from=2026-01-01" in url_str + assert "to=2026-01-31" in url_str + + @respx.mock + def test_with_monitor_uuids(self, client: HyperpingClient) -> None: + """Test that monitor_uuids are comma-joined in query params.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=[]) + ) + + client.list_recent_alerts(monitor_uuids=["mon_1", "mon_2"]) + + request = respx.calls.last.request + url_str = str(request.url) + assert "monitor_uuids=mon_1" in url_str or "monitor_uuids=mon_1%2Cmon_2" in url_str + + @respx.mock + def test_empty_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that an empty response returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(200, json=[]) + ) + + result = client.list_recent_alerts() + assert result == [] + + @respx.mock + def test_404_returns_empty_list(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.ALERTS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.list_recent_alerts() + assert result == [] diff --git a/tests/unit/test_oncall.py b/tests/unit/test_oncall.py new file mode 100644 index 0000000..ec52fa5 --- /dev/null +++ b/tests/unit/test_oncall.py @@ -0,0 +1,217 @@ +"""Tests for on-call API methods (schedules, escalation policies, team members).""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule + + +class TestListOnCallSchedules: + """Tests for list_on_call_schedules().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing on-call schedules returns parsed models.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "s1", + "name": "Primary", + "currentOnCall": "alice", + } + ], + ) + ) + + result = client.list_on_call_schedules() + assert len(result) == 1 + assert isinstance(result[0], OnCallSchedule) + assert result[0].uuid == "s1" + assert result[0].name == "Primary" + # Verify camelCase alias is correctly mapped to snake_case field + assert result[0].current_on_call == "alice" + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no schedules exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_on_call_schedules() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_on_call_schedules() + assert result == [] + + +class TestGetOnCallSchedule: + """Tests for get_on_call_schedule().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test getting a single on-call schedule.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}/s1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "s1", + "name": "Primary", + "currentOnCall": "bob", + }, + ) + ) + + result = client.get_on_call_schedule("s1") + assert isinstance(result, OnCallSchedule) + assert result.uuid == "s1" + assert result.name == "Primary" + assert result.current_on_call == "bob" + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test that 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.ON_CALL_SCHEDULES}/s_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_on_call_schedule("s_nope") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_on_call_schedule("../bad") + + +class TestListEscalationPolicies: + """Tests for list_escalation_policies().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing escalation policies returns parsed models.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "p1", + "name": "Default", + "steps": [{"level": 1}], + } + ], + ) + ) + + result = client.list_escalation_policies() + assert len(result) == 1 + assert isinstance(result[0], EscalationPolicy) + assert result[0].uuid == "p1" + assert result[0].name == "Default" + assert result[0].steps == [{"level": 1}] + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no policies exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_escalation_policies() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_escalation_policies() + assert result == [] + + +class TestGetEscalationPolicy: + """Tests for get_escalation_policy().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test getting a single escalation policy.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}/p1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "p1", + "name": "Default", + "steps": [{"level": 1}, {"level": 2}], + }, + ) + ) + + result = client.get_escalation_policy("p1") + assert isinstance(result, EscalationPolicy) + assert result.uuid == "p1" + assert result.name == "Default" + assert len(result.steps) == 2 + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test that 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}/p_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + with pytest.raises(HyperpingNotFoundError): + client.get_escalation_policy("p_nope") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_escalation_policy("../bad") + + +class TestListTeamMembers: + """Tests for list_team_members().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test listing team members returns raw dicts.""" + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response( + 200, + json=[ + {"name": "Alice", "email": "alice@example.com"}, + ], + ) + ) + + result = client.list_team_members() + assert len(result) == 1 + assert isinstance(result[0], dict) + assert result[0]["name"] == "Alice" + assert result[0]["email"] == "alice@example.com" + + @respx.mock + def test_empty(self, client: HyperpingClient) -> None: + """Test listing when no team members exist returns empty list.""" + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response(200, json=[]) + ) + result = client.list_team_members() + assert result == [] + + @respx.mock + def test_returns_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns empty list instead of raising.""" + respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + result = client.list_team_members() + assert result == [] diff --git a/tests/unit/test_outages.py b/tests/unit/test_outages.py index a170b77..d1dc388 100644 --- a/tests/unit/test_outages.py +++ b/tests/unit/test_outages.py @@ -7,7 +7,7 @@ from hyperping.client import HyperpingClient from hyperping.endpoints import API_BASE, Endpoint from hyperping.exceptions import HyperpingNotFoundError -from hyperping.models import OutageAction +from hyperping.models import Outage, OutageAction, OutageTimeline class TestOutageAPIClient: @@ -138,3 +138,100 @@ def test_escalate_outage_not_found(self, client: HyperpingClient) -> None: ) with pytest.raises(HyperpingNotFoundError): client.escalate_outage("out_nope") + + +class TestGetOutageTimeline: + """Tests for get_outage_timeline method.""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test retrieving an outage timeline with events.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_1/timeline").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "eventType": "detection", + "timestamp": "2026-01-01T00:00:00Z", + "detail": "Probe failed", + } + ] + }, + ) + ) + + result = client.get_outage_timeline("out_1") + + assert isinstance(result, OutageTimeline) + assert result.outage_uuid == "out_1" + assert len(result.events) == 1 + assert result.events[0].event_type == "detection" + assert result.events[0].timestamp == "2026-01-01T00:00:00Z" + assert result.events[0].detail == "Probe failed" + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}/out_x/timeline").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + with pytest.raises(HyperpingNotFoundError): + client.get_outage_timeline("out_x") + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that a path-traversal ID raises ValueError.""" + with pytest.raises(ValueError): + client.get_outage_timeline("../bad") + + +class TestGetMonitorOutages: + """Tests for get_monitor_outages method.""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test retrieving outages scoped to a monitor.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "out_10", + "monitorUuid": "mon_1", + "status": "active", + "acknowledged": False, + "resolved": False, + }, + { + "uuid": "out_11", + "monitorUuid": "mon_1", + "status": "resolved", + "acknowledged": True, + "resolved": True, + }, + ], + ) + ) + + result = client.get_monitor_outages("mon_1") + + assert len(result) == 2 + assert all(isinstance(o, Outage) for o in result) + assert result[0].uuid == "out_10" + assert result[1].uuid == "out_11" + + @respx.mock + def test_empty_on_404(self, client: HyperpingClient) -> None: + """Test that 404 returns an empty list.""" + respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + result = client.get_monitor_outages("mon_1") + assert result == [] + + def test_invalid_id(self, client: HyperpingClient) -> None: + """Test that a path-traversal monitor UUID raises ValueError.""" + with pytest.raises(ValueError): + client.get_monitor_outages("../bad") diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py new file mode 100644 index 0000000..c25138f --- /dev/null +++ b/tests/unit/test_reporting.py @@ -0,0 +1,140 @@ +"""Tests for reporting mixin API methods.""" + +import httpx +import pytest +import respx + +from hyperping.client import HyperpingClient +from hyperping.endpoints import API_BASE, Endpoint +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models._reporting_models import StatusSummary + + +class TestGetStatusSummary: + """Tests for get_status_summary().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful status summary retrieval.""" + mock_response = { + "totalMonitors": 10, + "upCount": 8, + "downCount": 1, + "pausedCount": 1, + "downMonitors": [], + } + respx.get(f"{API_BASE}{Endpoint.STATUS_SUMMARY}").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_status_summary() + + assert isinstance(result, StatusSummary) + assert result.total_monitors == 10 + assert result.up_count == 8 + assert result.down_count == 1 + assert result.paused_count == 1 + assert result.down_monitors == [] + + @respx.mock + def test_not_found(self, client: HyperpingClient) -> None: + """Test 404 raises HyperpingNotFoundError.""" + respx.get(f"{API_BASE}{Endpoint.STATUS_SUMMARY}").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + with pytest.raises(HyperpingNotFoundError): + client.get_status_summary() + + +class TestGetMonitorResponseTime: + """Tests for get_monitor_response_time().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful response time retrieval.""" + mock_response = {"p50": 45.0, "p95": 120.0} + respx.get(f"{API_BASE}/v2/reporting/response-time/uuid_1").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_response_time("uuid_1") + + assert isinstance(result, dict) + assert result["p50"] == 45.0 + assert result["p95"] == 120.0 + + @respx.mock + def test_default_period_is_24h(self, client: HyperpingClient) -> None: + """Test that the default period query param is 24h.""" + respx.get(f"{API_BASE}/v2/reporting/response-time/uuid_1").mock( + return_value=httpx.Response(200, json={"p50": 45.0}) + ) + + client.get_monitor_response_time("uuid_1") + + request = respx.calls.last.request + assert "period=24h" in str(request.url) + + @respx.mock + def test_custom_period(self, client: HyperpingClient) -> None: + """Test that a custom period is forwarded as a query param.""" + respx.get(f"{API_BASE}/v2/reporting/response-time/uuid_1").mock( + return_value=httpx.Response(200, json={"p50": 45.0}) + ) + + client.get_monitor_response_time("uuid_1", period="7d") + + request = respx.calls.last.request + assert "period=7d" in str(request.url) + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID with path traversal raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_response_time("../bad") + + +class TestGetMonitorMtta: + """Tests for get_monitor_mtta().""" + + @respx.mock + def test_success(self, client: HyperpingClient) -> None: + """Test successful MTTA retrieval.""" + mock_response = {"mtta_seconds": 120.0} + respx.get(f"{API_BASE}/v2/reporting/mtta/uuid_1").mock( + return_value=httpx.Response(200, json=mock_response) + ) + + result = client.get_monitor_mtta("uuid_1") + + assert isinstance(result, dict) + assert result["mtta_seconds"] == 120.0 + + @respx.mock + def test_default_period_is_30d(self, client: HyperpingClient) -> None: + """Test that the default period query param is 30d.""" + respx.get(f"{API_BASE}/v2/reporting/mtta/uuid_1").mock( + return_value=httpx.Response(200, json={"mtta_seconds": 120.0}) + ) + + client.get_monitor_mtta("uuid_1") + + request = respx.calls.last.request + assert "period=30d" in str(request.url) + + @respx.mock + def test_custom_period(self, client: HyperpingClient) -> None: + """Test that a custom period is forwarded as a query param.""" + respx.get(f"{API_BASE}/v2/reporting/mtta/uuid_1").mock( + return_value=httpx.Response(200, json={"mtta_seconds": 60.0}) + ) + + client.get_monitor_mtta("uuid_1", period="7d") + + request = respx.calls.last.request + assert "period=7d" in str(request.url) + + def test_invalid_id_raises_value_error(self, client: HyperpingClient) -> None: + """Test that an invalid ID with path traversal raises ValueError.""" + with pytest.raises(ValueError, match="Invalid monitor_uuid"): + client.get_monitor_mtta("../bad") From 4d02aa937ddbde0a95c28b8a2154bd9838cc7688 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sun, 19 Apr 2026 18:36:00 +0300 Subject: [PATCH 3/3] fix: address review findings on SDK PR - Narrow all 14 bare `except Exception` catches in sync and async mixins to `except (HyperpingNotFoundError, HyperpingAPIError)`, matching the established pattern in _outages_mixin.py. Auth errors and rate limit errors now propagate correctly. - Add Endpoint.MONITOR_RESPONSE_TIME and Endpoint.MONITOR_MTTA constants to endpoints.py, replacing hardcoded path strings in _reporting_mixin.py and _async_reporting_mixin.py. - Update test URLs to use Endpoint constants instead of hardcoded paths. --- src/hyperping/_async_integrations_mixin.py | 3 ++- src/hyperping/_async_observability_mixin.py | 7 ++++--- src/hyperping/_async_oncall_mixin.py | 7 ++++--- src/hyperping/_async_reporting_mixin.py | 4 ++-- src/hyperping/_integrations_mixin.py | 3 ++- src/hyperping/_observability_mixin.py | 7 ++++--- src/hyperping/_oncall_mixin.py | 7 ++++--- src/hyperping/_reporting_mixin.py | 6 ++---- src/hyperping/endpoints.py | 18 ++++++++++++++++++ tests/unit/test_async_new_mixins.py | 16 +++++----------- tests/unit/test_reporting.py | 12 ++++++------ 11 files changed, 53 insertions(+), 37 deletions(-) diff --git a/src/hyperping/_async_integrations_mixin.py b/src/hyperping/_async_integrations_mixin.py index 9527809..4e90a46 100644 --- a/src/hyperping/_async_integrations_mixin.py +++ b/src/hyperping/_async_integrations_mixin.py @@ -3,6 +3,7 @@ from hyperping._protocols import _AsyncClientProtocol from hyperping._utils import expect_dict, parse_list, validate_id from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError from hyperping.models._integration_models import Integration @@ -13,7 +14,7 @@ async def list_integrations(self) -> list[Integration]: """Get all configured notification integrations.""" try: result = await self._request("GET", Endpoint.INTEGRATIONS) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, Integration, "integration") diff --git a/src/hyperping/_async_observability_mixin.py b/src/hyperping/_async_observability_mixin.py index abef250..6a191b7 100644 --- a/src/hyperping/_async_observability_mixin.py +++ b/src/hyperping/_async_observability_mixin.py @@ -5,6 +5,7 @@ from hyperping._protocols import _AsyncClientProtocol from hyperping._utils import parse_list, validate_id from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError from hyperping.models._observability_models import ( AlertNotification, MonitorAnomaly, @@ -20,7 +21,7 @@ async def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly] validate_id(monitor_uuid, "monitor_uuid") try: result = await self._request("GET", f"{Endpoint.MONITORS}/{monitor_uuid}/anomalies") - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, MonitorAnomaly, "anomaly") @@ -37,7 +38,7 @@ async def get_monitor_http_logs( result = await self._request( "GET", f"{Endpoint.MONITORS}/{monitor_uuid}/http-logs", params=params ) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, ProbeLog, "probe_log") @@ -58,7 +59,7 @@ async def list_recent_alerts( params["monitor_uuids"] = ",".join(monitor_uuids) try: result = await self._request("GET", Endpoint.ALERTS, params=params) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, AlertNotification, "alert") diff --git a/src/hyperping/_async_oncall_mixin.py b/src/hyperping/_async_oncall_mixin.py index 2031045..3429e0b 100644 --- a/src/hyperping/_async_oncall_mixin.py +++ b/src/hyperping/_async_oncall_mixin.py @@ -5,6 +5,7 @@ from hyperping._protocols import _AsyncClientProtocol from hyperping._utils import expect_dict, parse_list, validate_id from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule @@ -15,7 +16,7 @@ async def list_on_call_schedules(self) -> list[OnCallSchedule]: """Get all on-call rotation schedules.""" try: result = await self._request("GET", Endpoint.ON_CALL_SCHEDULES) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, OnCallSchedule, "on_call_schedule") @@ -30,7 +31,7 @@ async def list_escalation_policies(self) -> list[EscalationPolicy]: """Get all escalation policies.""" try: result = await self._request("GET", Endpoint.ESCALATION_POLICIES) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, EscalationPolicy, "escalation_policy") @@ -45,7 +46,7 @@ async def list_team_members(self) -> list[dict[str, Any]]: """Get all team members.""" try: result = await self._request("GET", Endpoint.TEAM_MEMBERS) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] if isinstance(result, list): return result diff --git a/src/hyperping/_async_reporting_mixin.py b/src/hyperping/_async_reporting_mixin.py index 3113098..911df12 100644 --- a/src/hyperping/_async_reporting_mixin.py +++ b/src/hyperping/_async_reporting_mixin.py @@ -23,7 +23,7 @@ async def get_monitor_response_time( validate_id(monitor_uuid, "monitor_uuid") result = await self._request( "GET", - f"/v2/reporting/response-time/{monitor_uuid}", + f"{Endpoint.MONITOR_RESPONSE_TIME}/{monitor_uuid}", params={"period": period}, ) return expect_dict(result, "get_monitor_response_time") @@ -33,7 +33,7 @@ async def get_monitor_mtta(self, monitor_uuid: str, period: str = "30d") -> dict validate_id(monitor_uuid, "monitor_uuid") result = await self._request( "GET", - f"/v2/reporting/mtta/{monitor_uuid}", + f"{Endpoint.MONITOR_MTTA}/{monitor_uuid}", params={"period": period}, ) return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_integrations_mixin.py b/src/hyperping/_integrations_mixin.py index 4c961f6..3c8da3f 100644 --- a/src/hyperping/_integrations_mixin.py +++ b/src/hyperping/_integrations_mixin.py @@ -3,6 +3,7 @@ from hyperping._protocols import _ClientProtocol from hyperping._utils import expect_dict, parse_list, validate_id from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError from hyperping.models._integration_models import Integration @@ -18,7 +19,7 @@ def list_integrations(self) -> list[Integration]: """ try: result = self._request("GET", Endpoint.INTEGRATIONS) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, Integration, "integration") diff --git a/src/hyperping/_observability_mixin.py b/src/hyperping/_observability_mixin.py index 2f84c62..692c9e8 100644 --- a/src/hyperping/_observability_mixin.py +++ b/src/hyperping/_observability_mixin.py @@ -5,6 +5,7 @@ from hyperping._protocols import _ClientProtocol from hyperping._utils import parse_list, validate_id from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError from hyperping.models._observability_models import ( AlertNotification, MonitorAnomaly, @@ -29,7 +30,7 @@ def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]: try: # Path is speculative; derived from MCP tool name. result = self._request("GET", f"{Endpoint.MONITORS}/{monitor_uuid}/anomalies") - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, MonitorAnomaly, "anomaly") @@ -64,7 +65,7 @@ def get_monitor_http_logs( f"{Endpoint.MONITORS}/{monitor_uuid}/http-logs", params=params, ) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, ProbeLog, "probe_log") @@ -95,7 +96,7 @@ def list_recent_alerts( params["monitor_uuids"] = ",".join(monitor_uuids) try: result = self._request("GET", Endpoint.ALERTS, params=params) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, AlertNotification, "alert") diff --git a/src/hyperping/_oncall_mixin.py b/src/hyperping/_oncall_mixin.py index 7a31a55..0da868d 100644 --- a/src/hyperping/_oncall_mixin.py +++ b/src/hyperping/_oncall_mixin.py @@ -5,6 +5,7 @@ from hyperping._protocols import _ClientProtocol from hyperping._utils import expect_dict, parse_list, validate_id from hyperping.endpoints import Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingNotFoundError from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule @@ -20,7 +21,7 @@ def list_on_call_schedules(self) -> list[OnCallSchedule]: """ try: result = self._request("GET", Endpoint.ON_CALL_SCHEDULES) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, OnCallSchedule, "on_call_schedule") @@ -50,7 +51,7 @@ def list_escalation_policies(self) -> list[EscalationPolicy]: """ try: result = self._request("GET", Endpoint.ESCALATION_POLICIES) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] items = result if isinstance(result, list) else [] return parse_list(items, EscalationPolicy, "escalation_policy") @@ -80,7 +81,7 @@ def list_team_members(self) -> list[dict[str, Any]]: """ try: result = self._request("GET", Endpoint.TEAM_MEMBERS) - except Exception: + except (HyperpingNotFoundError, HyperpingAPIError): return [] if isinstance(result, list): return result diff --git a/src/hyperping/_reporting_mixin.py b/src/hyperping/_reporting_mixin.py index 8447dda..32fb079 100644 --- a/src/hyperping/_reporting_mixin.py +++ b/src/hyperping/_reporting_mixin.py @@ -38,10 +38,9 @@ def get_monitor_response_time( HyperpingNotFoundError: If monitor not found. """ validate_id(monitor_uuid, "monitor_uuid") - # Path is speculative; derived from MCP tool name. result = self._request( "GET", - f"/v2/reporting/response-time/{monitor_uuid}", + f"{Endpoint.MONITOR_RESPONSE_TIME}/{monitor_uuid}", params={"period": period}, ) return expect_dict(result, "get_monitor_response_time") @@ -64,10 +63,9 @@ def get_monitor_mtta( HyperpingNotFoundError: If monitor not found. """ validate_id(monitor_uuid, "monitor_uuid") - # Path is speculative; derived from MCP tool name. result = self._request( "GET", - f"/v2/reporting/mtta/{monitor_uuid}", + f"{Endpoint.MONITOR_MTTA}/{monitor_uuid}", params={"period": period}, ) return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/endpoints.py b/src/hyperping/endpoints.py index f3bba64..3b1e80b 100644 --- a/src/hyperping/endpoints.py +++ b/src/hyperping/endpoints.py @@ -156,6 +156,12 @@ class Endpoint(StrEnum): STATUS_SUMMARY = "/v2/status-summary" """Aggregate status counts. Version: v2""" + MONITOR_RESPONSE_TIME = "/v2/reporting/response-time" + """Monitor latency percentiles. Version: v2. Append /{uuid}.""" + + MONITOR_MTTA = "/v2/reporting/mtta" + """Monitor mean time to acknowledge. Version: v2. Append /{uuid}.""" + # Detailed endpoint metadata for programmatic access ENDPOINTS: Final[dict[Endpoint, EndpointConfig]] = { @@ -225,6 +231,16 @@ class Endpoint(StrEnum): resource="status-summary", description="Aggregate monitor status counts", ), + Endpoint.MONITOR_RESPONSE_TIME: EndpointConfig( + version=APIVersion.V2, + resource="reporting/response-time", + description="Monitor latency percentiles (append /{uuid})", + ), + Endpoint.MONITOR_MTTA: EndpointConfig( + version=APIVersion.V2, + resource="reporting/mtta", + description="Monitor mean time to acknowledge (append /{uuid})", + ), } @@ -252,6 +268,8 @@ class Endpoint(StrEnum): "team-members": Endpoint.TEAM_MEMBERS.value, "integrations": Endpoint.INTEGRATIONS.value, "status-summary": Endpoint.STATUS_SUMMARY.value, + "monitor-response-time": Endpoint.MONITOR_RESPONSE_TIME.value, + "monitor-mtta": Endpoint.MONITOR_MTTA.value, } """Deprecated: Use Endpoint enum instead for type safety.""" diff --git a/tests/unit/test_async_new_mixins.py b/tests/unit/test_async_new_mixins.py index 071d4bb..67d8c83 100644 --- a/tests/unit/test_async_new_mixins.py +++ b/tests/unit/test_async_new_mixins.py @@ -47,7 +47,7 @@ async def test_get_status_summary(self, async_client): @respx.mock @pytest.mark.asyncio async def test_get_monitor_response_time(self, async_client): - respx.get(f"{API_BASE}/v2/reporting/response-time/mon_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/mon_1").mock( return_value=httpx.Response(200, json={"p50": 45.0, "p95": 120.0}) ) result = await async_client.get_monitor_response_time("mon_1") @@ -56,7 +56,7 @@ async def test_get_monitor_response_time(self, async_client): @respx.mock @pytest.mark.asyncio async def test_get_monitor_mtta(self, async_client): - respx.get(f"{API_BASE}/v2/reporting/mtta/mon_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/mon_1").mock( return_value=httpx.Response(200, json={"mtta_seconds": 120.0}) ) result = await async_client.get_monitor_mtta("mon_1") @@ -146,9 +146,7 @@ async def test_get_on_call_schedule(self, async_client): @pytest.mark.asyncio async def test_list_escalation_policies(self, async_client): respx.get(f"{API_BASE}{Endpoint.ESCALATION_POLICIES}").mock( - return_value=httpx.Response( - 200, json=[{"uuid": "p1", "name": "Default", "steps": []}] - ) + return_value=httpx.Response(200, json=[{"uuid": "p1", "name": "Default", "steps": []}]) ) result = await async_client.list_escalation_policies() assert len(result) == 1 @@ -168,9 +166,7 @@ async def test_get_escalation_policy(self, async_client): @pytest.mark.asyncio async def test_list_team_members(self, async_client): respx.get(f"{API_BASE}{Endpoint.TEAM_MEMBERS}").mock( - return_value=httpx.Response( - 200, json=[{"name": "Alice", "email": "alice@example.com"}] - ) + return_value=httpx.Response(200, json=[{"name": "Alice", "email": "alice@example.com"}]) ) result = await async_client.list_team_members() assert len(result) == 1 @@ -237,9 +233,7 @@ async def test_get_outage_timeline(self, async_client): @pytest.mark.asyncio async def test_get_monitor_outages(self, async_client): respx.get(f"{API_BASE}{Endpoint.OUTAGES}").mock( - return_value=httpx.Response( - 200, json=[{"uuid": "out_1", "monitorUuid": "mon_1"}] - ) + return_value=httpx.Response(200, json=[{"uuid": "out_1", "monitorUuid": "mon_1"}]) ) result = await async_client.get_monitor_outages("mon_1") assert len(result) == 1 diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py index c25138f..0bcd141 100644 --- a/tests/unit/test_reporting.py +++ b/tests/unit/test_reporting.py @@ -54,7 +54,7 @@ class TestGetMonitorResponseTime: def test_success(self, client: HyperpingClient) -> None: """Test successful response time retrieval.""" mock_response = {"p50": 45.0, "p95": 120.0} - respx.get(f"{API_BASE}/v2/reporting/response-time/uuid_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/uuid_1").mock( return_value=httpx.Response(200, json=mock_response) ) @@ -67,7 +67,7 @@ def test_success(self, client: HyperpingClient) -> None: @respx.mock def test_default_period_is_24h(self, client: HyperpingClient) -> None: """Test that the default period query param is 24h.""" - respx.get(f"{API_BASE}/v2/reporting/response-time/uuid_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/uuid_1").mock( return_value=httpx.Response(200, json={"p50": 45.0}) ) @@ -79,7 +79,7 @@ def test_default_period_is_24h(self, client: HyperpingClient) -> None: @respx.mock def test_custom_period(self, client: HyperpingClient) -> None: """Test that a custom period is forwarded as a query param.""" - respx.get(f"{API_BASE}/v2/reporting/response-time/uuid_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_RESPONSE_TIME}/uuid_1").mock( return_value=httpx.Response(200, json={"p50": 45.0}) ) @@ -101,7 +101,7 @@ class TestGetMonitorMtta: def test_success(self, client: HyperpingClient) -> None: """Test successful MTTA retrieval.""" mock_response = {"mtta_seconds": 120.0} - respx.get(f"{API_BASE}/v2/reporting/mtta/uuid_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/uuid_1").mock( return_value=httpx.Response(200, json=mock_response) ) @@ -113,7 +113,7 @@ def test_success(self, client: HyperpingClient) -> None: @respx.mock def test_default_period_is_30d(self, client: HyperpingClient) -> None: """Test that the default period query param is 30d.""" - respx.get(f"{API_BASE}/v2/reporting/mtta/uuid_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/uuid_1").mock( return_value=httpx.Response(200, json={"mtta_seconds": 120.0}) ) @@ -125,7 +125,7 @@ def test_default_period_is_30d(self, client: HyperpingClient) -> None: @respx.mock def test_custom_period(self, client: HyperpingClient) -> None: """Test that a custom period is forwarded as a query param.""" - respx.get(f"{API_BASE}/v2/reporting/mtta/uuid_1").mock( + respx.get(f"{API_BASE}{Endpoint.MONITOR_MTTA}/uuid_1").mock( return_value=httpx.Response(200, json={"mtta_seconds": 60.0}) )