From 06be321927ec6379d49a065e32420f556613cfb6 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Mon, 20 Apr 2026 00:20:05 +0300 Subject: [PATCH] feat: add MCP transport client, remove broken speculative REST methods Add HyperpingMcpClient for features only accessible via the MCP server (JSON-RPC 2.0 over HTTP). Verified against live API: all 12 speculative REST endpoints return 404/401; the same features work via MCP transport. New: - McpTransport: JSON-RPC 2.0 client with auto-init handshake - HyperpingMcpClient: 16 typed methods for on-call, alerts, anomalies, integrations, probe logs, response time, MTTA/MTTR, status summary, outage timeline, and monitor search - MCP_URL constant exported from package - 29 new tests (9 transport + 20 client) - scripts/verify_endpoints.py for live API verification Removed: - 8 speculative mixin files (sync + async) that called nonexistent REST paths - Speculative methods from outages/monitors mixins - 8 speculative Endpoint enum entries - 5 speculative test files Models kept intact for use by MCP client. --- pyproject.toml | 2 +- scripts/verify_endpoints.py | 166 +++++++++++++ src/hyperping/__init__.py | 5 + src/hyperping/_async_client.py | 8 - src/hyperping/_async_incidents_mixin.py | 12 +- src/hyperping/_async_integrations_mixin.py | 26 -- src/hyperping/_async_maintenance_mixin.py | 12 +- src/hyperping/_async_monitors_mixin.py | 13 - src/hyperping/_async_observability_mixin.py | 65 ----- src/hyperping/_async_oncall_mixin.py | 53 ---- src/hyperping/_async_outages_mixin.py | 31 --- src/hyperping/_async_reporting_mixin.py | 39 --- src/hyperping/_async_statuspages_mixin.py | 28 ++- src/hyperping/_incidents_mixin.py | 4 +- src/hyperping/_integrations_mixin.py | 41 --- src/hyperping/_internals.py | 5 +- src/hyperping/_maintenance_mixin.py | 4 +- src/hyperping/_mcp_transport.py | 145 +++++++++++ src/hyperping/_monitors_mixin.py | 20 -- src/hyperping/_observability_mixin.py | 102 -------- src/hyperping/_oncall_mixin.py | 88 ------- src/hyperping/_outages_mixin.py | 55 ---- src/hyperping/_reporting_mixin.py | 71 ------ src/hyperping/_statuspages_mixin.py | 16 +- src/hyperping/_utils.py | 7 +- src/hyperping/_version.py | 2 +- src/hyperping/client.py | 8 - src/hyperping/endpoints.py | 78 +----- src/hyperping/mcp_client.py | 215 ++++++++++++++++ src/hyperping/models/_monitor_models.py | 11 +- tests/unit/test_async_client.py | 56 ++--- tests/unit/test_async_new_mixins.py | 262 -------------------- tests/unit/test_async_preexisting.py | 12 +- tests/unit/test_healthchecks.py | 29 +-- tests/unit/test_incidents.py | 4 +- tests/unit/test_integrations.py | 98 -------- tests/unit/test_mcp_client.py | 161 ++++++++++++ tests/unit/test_mcp_transport.py | 161 ++++++++++++ tests/unit/test_monitors.py | 63 +---- tests/unit/test_observability.py | 203 --------------- tests/unit/test_oncall.py | 217 ---------------- tests/unit/test_outages.py | 105 +------- tests/unit/test_pagination.py | 4 +- tests/unit/test_reporting.py | 140 ----------- tests/unit/test_statuspages.py | 26 +- uv.lock | 2 +- 46 files changed, 951 insertions(+), 1924 deletions(-) create mode 100644 scripts/verify_endpoints.py delete mode 100644 src/hyperping/_async_integrations_mixin.py delete mode 100644 src/hyperping/_async_observability_mixin.py delete mode 100644 src/hyperping/_async_oncall_mixin.py delete mode 100644 src/hyperping/_async_reporting_mixin.py delete mode 100644 src/hyperping/_integrations_mixin.py create mode 100644 src/hyperping/_mcp_transport.py delete mode 100644 src/hyperping/_observability_mixin.py delete mode 100644 src/hyperping/_oncall_mixin.py delete mode 100644 src/hyperping/_reporting_mixin.py create mode 100644 src/hyperping/mcp_client.py delete mode 100644 tests/unit/test_async_new_mixins.py delete mode 100644 tests/unit/test_integrations.py create mode 100644 tests/unit/test_mcp_client.py create mode 100644 tests/unit/test_mcp_transport.py delete mode 100644 tests/unit/test_observability.py delete mode 100644 tests/unit/test_oncall.py delete mode 100644 tests/unit/test_reporting.py diff --git a/pyproject.toml b/pyproject.toml index 0b5e862..253140f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hyperping" -version = "1.3.0" +version = "1.4.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/scripts/verify_endpoints.py b/scripts/verify_endpoints.py new file mode 100644 index 0000000..9c4a2d6 --- /dev/null +++ b/scripts/verify_endpoints.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Verify speculative MCP-discovered endpoint paths against the live Hyperping API. + +Usage: + export HYPERPING_API_KEY=sk_... + python scripts/verify_endpoints.py + +All calls are read-only (GET only). Safe to run repeatedly. +""" + +import os +import sys +from dataclasses import dataclass + +import httpx + +API_BASE = "https://api.hyperping.io" + + +@dataclass +class Result: + method: str + path: str + status: int + ok: bool + snippet: str + + +def probe( + client: httpx.Client, + method_name: str, + path: str, + params: dict | None = None, +) -> Result: + """Send a GET request and return a Result.""" + url = f"{API_BASE}{path}" + try: + resp = client.get(url, params=params or {}) + body = resp.text[:120].replace("\n", " ") + return Result( + method=method_name, + path=path, + status=resp.status_code, + ok=resp.status_code < 400, + snippet=body, + ) + except httpx.HTTPError as exc: + return Result( + method=method_name, + path=path, + status=0, + ok=False, + snippet=f"Connection error: {exc}", + ) + + +def main() -> int: + api_key = os.environ.get("HYPERPING_API_KEY", "") + if not api_key: + print("ERROR: Set HYPERPING_API_KEY environment variable.") + return 1 + + headers = {"Authorization": f"Bearer {api_key}"} + client = httpx.Client(headers=headers, timeout=15.0) + + # -- Fetch real UUIDs for sub-resource endpoints -- + print("Fetching monitor and outage UUIDs for sub-resource tests...") + monitor_uuid: str | None = None + outage_uuid: str | None = None + + resp = client.get(f"{API_BASE}/v1/monitors") + if resp.status_code == 200: + data = resp.json() + monitors = data if isinstance(data, list) else data.get("monitors", []) + if monitors: + monitor_uuid = monitors[0].get("uuid") or monitors[0].get("id") + print(f" Monitor UUID: {monitor_uuid}") + + resp = client.get(f"{API_BASE}/v2/outages") + if resp.status_code == 200: + data = resp.json() + outages = data if isinstance(data, list) else data.get("outages", []) + if outages: + outage_uuid = outages[0].get("uuid") or outages[0].get("id") + print(f" Outage UUID: {outage_uuid}") + + print() + + # -- Define all speculative endpoints to verify -- + checks: list[tuple[str, str, dict | None]] = [ + # Endpoint enum speculative paths + ("get_status_summary", "/v2/status-summary", None), + ("list_recent_alerts", "/v2/alerts", None), + ("list_on_call_schedules", "/v2/on-call-schedules", None), + ("list_escalation_policies", "/v2/escalation-policies", None), + ("list_team_members", "/v2/team-members", None), + ("list_integrations", "/v2/integrations", None), + # Monitor search + ("search_monitors", "/v1/monitors/search", {"query": "test"}), + ] + + # Sub-resource paths needing a real UUID + if monitor_uuid: + checks.extend([ + ( + "get_monitor_response_time", + f"/v2/reporting/response-time/{monitor_uuid}", + {"period": "24h"}, + ), + ( + "get_monitor_mtta", + f"/v2/reporting/mtta/{monitor_uuid}", + {"period": "30d"}, + ), + ( + "get_monitor_anomalies", + f"/v1/monitors/{monitor_uuid}/anomalies", + None, + ), + ( + "get_monitor_http_logs", + f"/v1/monitors/{monitor_uuid}/http-logs", + {"page": "0", "limit": "5"}, + ), + ]) + else: + print("WARNING: No monitors found; skipping monitor sub-resource checks.\n") + + if outage_uuid: + checks.append(( + "get_outage_timeline", + f"/v2/outages/{outage_uuid}/timeline", + None, + )) + else: + print("WARNING: No outages found; skipping outage sub-resource checks.\n") + + # -- Run probes -- + results: list[Result] = [] + for method_name, path, params in checks: + r = probe(client, method_name, path, params) + results.append(r) + + client.close() + + # -- Print results table -- + print(f"{'Method':<30} {'Path':<50} {'Status':<8} {'Result'}") + print("-" * 120) + for r in results: + tag = "OK" if r.ok else ("404" if r.status == 404 else "ERROR") + print(f"{r.method:<30} {r.path:<50} {r.status:<8} {tag}") + if not r.ok: + print(f" {r.snippet}") + + # -- Summary -- + ok_count = sum(1 for r in results if r.ok) + fail_count = sum(1 for r in results if not r.ok) + not_found = sum(1 for r in results if r.status == 404) + print() + print(f"Total: {len(results)} | OK: {ok_count} | 404: {not_found} | Other errors: {fail_count - not_found}") + + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index 0efe960..bb5ee4c 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -15,6 +15,7 @@ """ from hyperping._async_client import AsyncHyperpingClient +from hyperping._mcp_transport import MCP_URL from hyperping._version import __version__ from hyperping.client import ( CircuitBreaker, @@ -35,6 +36,7 @@ HyperpingRateLimitError, HyperpingValidationError, ) +from hyperping.mcp_client import HyperpingMcpClient from hyperping.models import ( DEFAULT_REGIONS, AddIncidentUpdateRequest, @@ -91,6 +93,9 @@ # Clients "AsyncHyperpingClient", "HyperpingClient", + "HyperpingMcpClient", + # MCP + "MCP_URL", # Configuration "RetryConfig", "CircuitBreakerConfig", diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 734283b..6474f4f 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -21,13 +21,9 @@ 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, @@ -52,10 +48,6 @@ class AsyncHyperpingClient( AsyncOutagesMixin, AsyncStatusPagesMixin, AsyncHealthchecksMixin, - AsyncReportingMixin, - AsyncObservabilityMixin, - AsyncOnCallMixin, - AsyncIntegrationsMixin, ): """Async client for interacting with the Hyperping API. diff --git a/src/hyperping/_async_incidents_mixin.py b/src/hyperping/_async_incidents_mixin.py index ee936e1..59006f8 100644 --- a/src/hyperping/_async_incidents_mixin.py +++ b/src/hyperping/_async_incidents_mixin.py @@ -45,9 +45,7 @@ async def list_incidents(self, status: str | None = None) -> list[Incident]: if status: params["status"] = status - response = await self._request( - "GET", Endpoint.INCIDENTS, params=params or None - ) + response = await self._request("GET", Endpoint.INCIDENTS, params=params or None) return parse_list(unwrap_list(response, "incidents"), Incident, "incident") async def get_incident(self, incident_id: str) -> Incident: @@ -114,9 +112,7 @@ async def update_incident( validate_id(incident_id, "incident_id") payload = update.model_dump(exclude_none=True, by_alias=True) response = expect_dict( - await self._request( - "PUT", f"{Endpoint.INCIDENTS}/{incident_id}", json=payload - ), + await self._request("PUT", f"{Endpoint.INCIDENTS}/{incident_id}", json=payload), "update_incident", ) return Incident.model_validate(response) @@ -145,9 +141,7 @@ async def add_incident_update( await self._request("POST", url, json=payload) return await self.get_incident(incident_id) - async def resolve_incident( - self, incident_id: str, message: str | None = None - ) -> Incident: + async def resolve_incident(self, incident_id: str, message: str | None = None) -> Incident: """Resolve an incident. Args: diff --git a/src/hyperping/_async_integrations_mixin.py b/src/hyperping/_async_integrations_mixin.py deleted file mode 100644 index 4e90a46..0000000 --- a/src/hyperping/_async_integrations_mixin.py +++ /dev/null @@ -1,26 +0,0 @@ -"""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.exceptions import HyperpingAPIError, HyperpingNotFoundError -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 (HyperpingNotFoundError, HyperpingAPIError): - 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_maintenance_mixin.py b/src/hyperping/_async_maintenance_mixin.py index 11c17db..7cbc4fc 100644 --- a/src/hyperping/_async_maintenance_mixin.py +++ b/src/hyperping/_async_maintenance_mixin.py @@ -41,9 +41,7 @@ async def list_maintenance(self, status: str | None = None) -> list[Maintenance] if status: params["status"] = status - response = await self._request( - "GET", Endpoint.MAINTENANCE, params=params or None - ) + response = await self._request("GET", Endpoint.MAINTENANCE, params=params or None) raw = unwrap_list(response, "maintenanceWindows") if not raw and isinstance(response, dict) and "maintenance" in response: @@ -64,9 +62,7 @@ async def get_maintenance(self, maintenance_id: str) -> Maintenance: HyperpingNotFoundError: If maintenance not found """ validate_id(maintenance_id, "maintenance_id") - response = await self._request( - "GET", f"{Endpoint.MAINTENANCE}/{maintenance_id}" - ) + response = await self._request("GET", f"{Endpoint.MAINTENANCE}/{maintenance_id}") return Maintenance.model_validate(expect_dict(response, "get_maintenance")) async def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenance: @@ -127,9 +123,7 @@ async def update_maintenance( payload.update(partial) response = expect_dict( - await self._request( - "PUT", f"{Endpoint.MAINTENANCE}/{maintenance_id}", json=payload - ), + await self._request("PUT", f"{Endpoint.MAINTENANCE}/{maintenance_id}", json=payload), "update_maintenance", ) return Maintenance.model_validate(response) diff --git a/src/hyperping/_async_monitors_mixin.py b/src/hyperping/_async_monitors_mixin.py index 3b4b2b2..e5a4b79 100644 --- a/src/hyperping/_async_monitors_mixin.py +++ b/src/hyperping/_async_monitors_mixin.py @@ -187,16 +187,3 @@ 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 deleted file mode 100644 index 6a191b7..0000000 --- a/src/hyperping/_async_observability_mixin.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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.exceptions import HyperpingAPIError, HyperpingNotFoundError -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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (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 deleted file mode 100644 index 3429e0b..0000000 --- a/src/hyperping/_async_oncall_mixin.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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.exceptions import HyperpingAPIError, HyperpingNotFoundError -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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (HyperpingNotFoundError, HyperpingAPIError): - 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 f17cd26..18435c1 100644 --- a/src/hyperping/_async_outages_mixin.py +++ b/src/hyperping/_async_outages_mixin.py @@ -19,7 +19,6 @@ 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__) @@ -206,33 +205,3 @@ 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 deleted file mode 100644 index 911df12..0000000 --- a/src/hyperping/_async_reporting_mixin.py +++ /dev/null @@ -1,39 +0,0 @@ -"""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"{Endpoint.MONITOR_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"{Endpoint.MONITOR_MTTA}/{monitor_uuid}", - params={"period": period}, - ) - return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_async_statuspages_mixin.py b/src/hyperping/_async_statuspages_mixin.py index 072375a..9516237 100644 --- a/src/hyperping/_async_statuspages_mixin.py +++ b/src/hyperping/_async_statuspages_mixin.py @@ -69,8 +69,12 @@ async def list_status_pages( try: return await collect_all_pages_async( - self._request, Endpoint.STATUSPAGES, "statuspages", - params or None, StatusPage, "status page", + self._request, + Endpoint.STATUSPAGES, + "statuspages", + params or None, + StatusPage, + "status page", ) except HyperpingNotFoundError: logger.debug("Status pages endpoint not available (404)") @@ -131,9 +135,7 @@ async def update_status_page( validate_id(status_page_id, "status_page_id") payload = update.model_dump(exclude_none=True, by_alias=True) response = expect_dict( - await self._request( - "PUT", f"{Endpoint.STATUSPAGES}/{status_page_id}", json=payload - ), + await self._request("PUT", f"{Endpoint.STATUSPAGES}/{status_page_id}", json=payload), "update_status_page", ) return StatusPage.model_validate(response) @@ -189,13 +191,15 @@ async def list_subscribers( ) return await collect_all_pages_async( - self._request, endpoint, "subscribers", - params or None, StatusPageSubscriber, "subscriber", + self._request, + endpoint, + "subscribers", + params or None, + StatusPageSubscriber, + "subscriber", ) - async def add_subscriber( - self, status_page_id: str, email: str - ) -> StatusPageSubscriber: + async def add_subscriber(self, status_page_id: str, email: str) -> StatusPageSubscriber: """Add a subscriber to a status page. Args: @@ -225,9 +229,7 @@ async def add_subscriber( ) return StatusPageSubscriber.model_validate(response) - async def remove_subscriber( - self, status_page_id: str, subscriber_id: str - ) -> None: + async def remove_subscriber(self, status_page_id: str, subscriber_id: str) -> None: """Remove a subscriber from a status page. Args: diff --git a/src/hyperping/_incidents_mixin.py b/src/hyperping/_incidents_mixin.py index dd88486..af796ed 100644 --- a/src/hyperping/_incidents_mixin.py +++ b/src/hyperping/_incidents_mixin.py @@ -48,7 +48,9 @@ def list_incidents(self, status: str | None = None) -> list[Incident]: params["status"] = status response = self._request( - "GET", Endpoint.INCIDENTS, params=params or None # M20 + "GET", + Endpoint.INCIDENTS, + params=params or None, # M20 ) return parse_list(unwrap_list(response, "incidents"), Incident, "incident") diff --git a/src/hyperping/_integrations_mixin.py b/src/hyperping/_integrations_mixin.py deleted file mode 100644 index 3c8da3f..0000000 --- a/src/hyperping/_integrations_mixin.py +++ /dev/null @@ -1,41 +0,0 @@ -"""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.exceptions import HyperpingAPIError, HyperpingNotFoundError -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 (HyperpingNotFoundError, HyperpingAPIError): - 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/_internals.py b/src/hyperping/_internals.py index 35b5e3a..93c62b0 100644 --- a/src/hyperping/_internals.py +++ b/src/hyperping/_internals.py @@ -28,7 +28,4 @@ def sanitize_for_log(data: dict[str, Any] | None) -> dict[str, Any] | None: """ if data is None: return None - return { - k: "[REDACTED]" if k.lower() in _SENSITIVE_LOG_KEYS else v - for k, v in data.items() - } + return {k: "[REDACTED]" if k.lower() in _SENSITIVE_LOG_KEYS else v for k, v in data.items()} diff --git a/src/hyperping/_maintenance_mixin.py b/src/hyperping/_maintenance_mixin.py index fde15f3..839c6b4 100644 --- a/src/hyperping/_maintenance_mixin.py +++ b/src/hyperping/_maintenance_mixin.py @@ -43,7 +43,9 @@ def list_maintenance(self, status: str | None = None) -> list[Maintenance]: params["status"] = status response = self._request( - "GET", Endpoint.MAINTENANCE, params=params or None # M20 + "GET", + Endpoint.MAINTENANCE, + params=params or None, # M20 ) # API returns {"maintenanceWindows": [...]} as of current version diff --git a/src/hyperping/_mcp_transport.py b/src/hyperping/_mcp_transport.py new file mode 100644 index 0000000..916182a --- /dev/null +++ b/src/hyperping/_mcp_transport.py @@ -0,0 +1,145 @@ +"""JSON-RPC 2.0 transport for the Hyperping MCP server.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +from pydantic import SecretStr + +from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError + +MCP_URL = "https://api.hyperping.io/v1/mcp" +_PROTOCOL_VERSION = "2025-03-26" + + +class McpTransport: + """Low-level JSON-RPC 2.0 client for the Hyperping MCP server. + + The MCP server exposes tools not available via the REST API: on-call + schedules, anomalies, alerts, integrations, probe logs, and more. + + Uses the same Bearer token API key as the REST client. + """ + + def __init__( + self, + api_key: str | SecretStr, + base_url: str = MCP_URL, + timeout: float = 30.0, + ) -> None: + token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key + self._url = base_url.rstrip("/") + self._client = httpx.Client( + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + timeout=timeout, + ) + self._initialized = False + self._request_id = 0 + + def _next_id(self) -> int: + self._request_id += 1 + return self._request_id + + def _send_rpc( + self, + method: str, + params: dict[str, Any] | None = None, + *, + is_notification: bool = False, + ) -> dict[str, Any] | None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + if not is_notification: + payload["id"] = self._next_id() + + resp = self._client.post(self._url, content=json.dumps(payload)) + + if resp.status_code == 401: + raise HyperpingAuthError("Invalid or expired API key") + if resp.status_code == 202: + return None # Notification accepted + if resp.status_code != 200: + raise HyperpingAPIError( + f"MCP server returned HTTP {resp.status_code}", + status_code=resp.status_code, + response_body=resp.text[:500], + ) + if is_notification: + return None + + data = resp.json() + if "error" in data: + err = data["error"] + raise HyperpingAPIError( + f"MCP error {err.get('code', '?')}: {err.get('message', 'unknown')}", + status_code=resp.status_code, + response_body=json.dumps(err), + ) + return data + + def initialize(self) -> dict[str, Any]: + """Perform MCP handshake. Called automatically on first tool call.""" + result = self._send_rpc( + "initialize", + { + "protocolVersion": _PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "hyperping-python", "version": "1.4.0"}, + }, + ) + self._send_rpc("notifications/initialized", is_notification=True) + self._initialized = True + return result.get("result", {}) if result else {} + + def call_tool( + self, + tool_name: str, + arguments: dict[str, Any] | None = None, + ) -> Any: + """Call an MCP tool and return parsed response data. + + Auto-initializes on first call. Extracts and parses the JSON + string from ``result.content[0].text``. + """ + if not self._initialized: + self.initialize() + + result = self._send_rpc( + "tools/call", + {"name": tool_name, "arguments": arguments or {}}, + ) + if result is None: + return None + + content = result.get("result", {}).get("content", []) + if not content: + return None + + text = content[0].get("text", "") + if not text: + return None + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise HyperpingAPIError( + f"Failed to parse MCP tool response: {exc}", + status_code=200, + response_body=text[:500], + ) from exc + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> McpTransport: + return self + + def __exit__(self, *args: object) -> None: + self.close() diff --git a/src/hyperping/_monitors_mixin.py b/src/hyperping/_monitors_mixin.py index fde5bc3..8ba0d5c 100644 --- a/src/hyperping/_monitors_mixin.py +++ b/src/hyperping/_monitors_mixin.py @@ -213,23 +213,3 @@ 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 deleted file mode 100644 index 692c9e8..0000000 --- a/src/hyperping/_observability_mixin.py +++ /dev/null @@ -1,102 +0,0 @@ -"""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.exceptions import HyperpingAPIError, HyperpingNotFoundError -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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (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 deleted file mode 100644 index 0da868d..0000000 --- a/src/hyperping/_oncall_mixin.py +++ /dev/null @@ -1,88 +0,0 @@ -"""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.exceptions import HyperpingAPIError, HyperpingNotFoundError -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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (HyperpingNotFoundError, HyperpingAPIError): - 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 (HyperpingNotFoundError, HyperpingAPIError): - return [] - if isinstance(result, list): - return result - return [] diff --git a/src/hyperping/_outages_mixin.py b/src/hyperping/_outages_mixin.py index 4c9dc56..41519d6 100644 --- a/src/hyperping/_outages_mixin.py +++ b/src/hyperping/_outages_mixin.py @@ -14,7 +14,6 @@ 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__) @@ -201,57 +200,3 @@ 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 deleted file mode 100644 index 32fb079..0000000 --- a/src/hyperping/_reporting_mixin.py +++ /dev/null @@ -1,71 +0,0 @@ -"""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") - result = self._request( - "GET", - f"{Endpoint.MONITOR_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") - result = self._request( - "GET", - f"{Endpoint.MONITOR_MTTA}/{monitor_uuid}", - params={"period": period}, - ) - return expect_dict(result, "get_monitor_mtta") diff --git a/src/hyperping/_statuspages_mixin.py b/src/hyperping/_statuspages_mixin.py index 2412e27..c689a9b 100644 --- a/src/hyperping/_statuspages_mixin.py +++ b/src/hyperping/_statuspages_mixin.py @@ -64,8 +64,12 @@ def list_status_pages( try: return collect_all_pages( - self._request, Endpoint.STATUSPAGES, "statuspages", - params or None, StatusPage, "status page", + self._request, + Endpoint.STATUSPAGES, + "statuspages", + params or None, + StatusPage, + "status page", ) except HyperpingNotFoundError: logger.debug("Status pages endpoint not available (404)") @@ -182,8 +186,12 @@ def list_subscribers( ) return collect_all_pages( - self._request, endpoint, "subscribers", - params or None, StatusPageSubscriber, "subscriber", + self._request, + endpoint, + "subscribers", + params or None, + StatusPageSubscriber, + "subscriber", ) def add_subscriber(self, status_page_id: str, email: str) -> StatusPageSubscriber: diff --git a/src/hyperping/_utils.py b/src/hyperping/_utils.py index e89035c..6819bfe 100644 --- a/src/hyperping/_utils.py +++ b/src/hyperping/_utils.py @@ -38,9 +38,7 @@ def expect_dict(response: Any, context: str = "API response") -> dict[str, Any]: TypeError: If *response* is not a dict. """ if not isinstance(response, dict): - raise TypeError( - f"Expected dict from {context}, got {type(response).__name__}" - ) + raise TypeError(f"Expected dict from {context}, got {type(response).__name__}") return response @@ -63,8 +61,7 @@ def validate_id(value: str, name: str = "id") -> str: """ if not value or not _RESOURCE_ID_RE.match(value): raise ValueError( - f"Invalid {name} {value!r}: must contain only letters, digits, " - "hyphens, and underscores" + f"Invalid {name} {value!r}: must contain only letters, digits, hyphens, and underscores" ) return value diff --git a/src/hyperping/_version.py b/src/hyperping/_version.py index 67bc602..3e8d9f9 100644 --- a/src/hyperping/_version.py +++ b/src/hyperping/_version.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.4.0" diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 7ccb92c..dfbcbe9 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -25,14 +25,10 @@ ) 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 ( @@ -69,10 +65,6 @@ class HyperpingClient( OutagesMixin, StatusPagesMixin, HealthchecksMixin, - ReportingMixin, - ObservabilityMixin, - OnCallMixin, - IntegrationsMixin, ): """Client for interacting with Hyperping API. diff --git a/src/hyperping/endpoints.py b/src/hyperping/endpoints.py index 3b1e80b..ddea65b 100644 --- a/src/hyperping/endpoints.py +++ b/src/hyperping/endpoints.py @@ -28,6 +28,9 @@ API_BASE: Final[str] = "https://api.hyperping.io" """Base URL for all Hyperping API requests. Never includes trailing slash.""" +MCP_URL: Final[str] = "https://api.hyperping.io/v1/mcp" +"""URL for the Hyperping MCP (JSON-RPC 2.0) server.""" + # ============================================================================= # API Versions @@ -136,32 +139,6 @@ 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""" - - 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]] = { @@ -200,47 +177,6 @@ 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", - ), - 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})", - ), } @@ -262,14 +198,6 @@ 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, - "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/src/hyperping/mcp_client.py b/src/hyperping/mcp_client.py new file mode 100644 index 0000000..d3df8bb --- /dev/null +++ b/src/hyperping/mcp_client.py @@ -0,0 +1,215 @@ +"""High-level typed MCP client for the Hyperping MCP server. + +Wraps :class:`~hyperping._mcp_transport.McpTransport` with typed convenience +methods that mirror the MCP tool names exposed by the server. + +Example:: + + from hyperping import HyperpingMcpClient + + with HyperpingMcpClient(api_key="sk_...") as mcp: + summary = mcp.get_status_summary() + schedules = mcp.list_on_call_schedules() +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import SecretStr + +from hyperping._mcp_transport import MCP_URL, McpTransport + + +class HyperpingMcpClient: + """High-level client for Hyperping MCP server tools. + + Provides typed convenience methods for every MCP tool. All methods + return plain dicts or lists of dicts; callers may validate further + with Pydantic models if desired. + + Supports the same ``api_key`` formats (``str`` or ``SecretStr``) and + context-manager pattern as :class:`~hyperping.client.HyperpingClient`. + """ + + def __init__( + self, + api_key: str | SecretStr, + base_url: str = MCP_URL, + timeout: float = 30.0, + ) -> None: + self._transport = McpTransport( + api_key=api_key, + base_url=base_url, + timeout=timeout, + ) + + # ==================== Context Manager ==================== + + def close(self) -> None: + """Close the underlying HTTP transport.""" + self._transport.close() + + def __enter__(self) -> HyperpingMcpClient: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + # ==================== Status & Reporting ==================== + + def get_status_summary(self) -> dict[str, Any]: + """Get aggregate monitor status counts.""" + return self._transport.call_tool("get_status_summary", {}) + + def get_monitor_response_time( + self, + monitor_uuid: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Get response time metrics for a monitor. + + Args: + monitor_uuid: Monitor UUID. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + return self._transport.call_tool( + "get_monitor_response_time", + {"uuid": monitor_uuid, **kwargs}, + ) + + def get_monitor_mtta( + self, + monitor_uuid: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Get mean time to acknowledge metrics. + + Args: + monitor_uuid: Optional monitor UUID to scope the query. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + args: dict[str, Any] = {**kwargs} + if monitor_uuid is not None: + args["uuid"] = monitor_uuid + return self._transport.call_tool("get_monitor_mtta", args) + + def get_monitor_mttr( + self, + monitor_uuid: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Get mean time to resolve metrics. + + Args: + monitor_uuid: Optional monitor UUID to scope the query. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + args: dict[str, Any] = {**kwargs} + if monitor_uuid is not None: + args["uuid"] = monitor_uuid + return self._transport.call_tool("get_monitor_mttr", args) + + # ==================== Observability ==================== + + def get_monitor_anomalies(self, monitor_uuid: str) -> list[dict[str, Any]]: + """Get anomalies detected for a monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + return self._transport.call_tool("get_monitor_anomalies", {"uuid": monitor_uuid}) + + def get_monitor_http_logs( + self, + monitor_uuid: str, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """Get HTTP probe logs for a monitor. + + Args: + monitor_uuid: Monitor UUID. + **kwargs: Additional arguments forwarded to the MCP tool. + """ + return self._transport.call_tool( + "get_monitor_http_logs", + {"uuid": monitor_uuid, **kwargs}, + ) + + # ==================== Alerts ==================== + + def list_recent_alerts(self, **kwargs: Any) -> dict[str, Any]: + """List recent alert notifications. + + Args: + **kwargs: Additional arguments forwarded to the MCP tool. + """ + return self._transport.call_tool("list_recent_alerts", {**kwargs}) + + # ==================== On-Call ==================== + + def list_on_call_schedules(self) -> list[dict[str, Any]]: + """List all on-call schedules.""" + return self._transport.call_tool("list_on_call_schedules", {}) + + def get_on_call_schedule(self, uuid: str) -> dict[str, Any]: + """Get a single on-call schedule by UUID. + + Args: + uuid: Schedule UUID. + """ + return self._transport.call_tool("get_on_call_schedule", {"uuid": uuid}) + + # ==================== Escalation Policies ==================== + + def list_escalation_policies(self) -> list[dict[str, Any]]: + """List all escalation policies.""" + return self._transport.call_tool("list_escalation_policies", {}) + + def get_escalation_policy(self, uuid: str) -> dict[str, Any]: + """Get a single escalation policy by UUID. + + Args: + uuid: Escalation policy UUID. + """ + return self._transport.call_tool("get_escalation_policy", {"uuid": uuid}) + + # ==================== Team ==================== + + def list_team_members(self) -> list[dict[str, Any]]: + """List all team members.""" + return self._transport.call_tool("list_team_members", {}) + + # ==================== Integrations ==================== + + def list_integrations(self) -> list[dict[str, Any]]: + """List all notification channel integrations.""" + return self._transport.call_tool("list_integrations", {}) + + def get_integration(self, uuid: str) -> dict[str, Any]: + """Get a single integration by UUID. + + Args: + uuid: Integration UUID. + """ + return self._transport.call_tool("get_integration", {"uuid": uuid}) + + # ==================== Outages ==================== + + def get_outage_timeline(self, outage_uuid: str) -> dict[str, Any]: + """Get the lifecycle timeline for an outage. + + Args: + outage_uuid: Outage UUID. + """ + return self._transport.call_tool("get_outage_timeline", {"uuid": outage_uuid}) + + # ==================== Monitors ==================== + + def search_monitors_by_name(self, query: str) -> list[dict[str, Any]]: + """Search monitors by name. + + Args: + query: Search string to match against monitor names. + """ + return self._transport.call_tool("search_monitors_by_name", {"query": query}) diff --git a/src/hyperping/models/_monitor_models.py b/src/hyperping/models/_monitor_models.py index 6ac21d5..405e086 100644 --- a/src/hyperping/models/_monitor_models.py +++ b/src/hyperping/models/_monitor_models.py @@ -272,9 +272,7 @@ def _remap_legacy_fields(data: dict[str, Any]) -> dict[str, Any]: if "headers" in remapped and "request_headers" not in remapped: headers = remapped.pop("headers") if isinstance(headers, dict): - remapped["request_headers"] = [ - {"name": k, "value": v} for k, v in headers.items() - ] + remapped["request_headers"] = [{"name": k, "value": v} for k, v in headers.items()] else: remapped["request_headers"] = headers if "expected_status" in remapped and "expected_status_code" not in remapped: @@ -307,9 +305,7 @@ def validate_dns_fields(self) -> MonitorCreate: set_dns_fields = [k for k, v in dns_fields.items() if v is not None] protocol = self.protocol if set_dns_fields and str(protocol) != MonitorProtocol.DNS.value: - raise ValueError( - f"DNS fields {set_dns_fields} are only valid when protocol='dns'" - ) + raise ValueError(f"DNS fields {set_dns_fields} are only valid when protocol='dns'") return self @@ -370,8 +366,7 @@ def normalize_monitor_response(cls, data: Any) -> Any: # Handle API returning headers as dict sometimes if "request_headers" in remapped and isinstance(remapped["request_headers"], dict): remapped["request_headers"] = [ - {"name": k, "value": v} - for k, v in remapped["request_headers"].items() + {"name": k, "value": v} for k, v in remapped["request_headers"].items() ] # Handle API returning null for optional fields diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index 4689fcf..c6dee53 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -201,9 +201,7 @@ async def test_ping_wraps_api_error(self) -> None: api_key="sk_test", retry_config=RetryConfig(max_retries=0), ) as client: - client._client.request = AsyncMock( - side_effect=httpx.ConnectError("connection refused") - ) + client._client.request = AsyncMock(side_effect=httpx.ConnectError("connection refused")) with pytest.raises(HyperpingAPIError, match="API connectivity test failed"): await client.ping() @@ -443,9 +441,7 @@ async def test_get_monitor_not_found(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_err(404, {"error": "Not found"}) - ) + client._client.request = AsyncMock(return_value=_mock_err(404, {"error": "Not found"})) with pytest.raises(HyperpingNotFoundError): await client.get_monitor("mon_missing") @@ -514,9 +510,7 @@ class TestAsyncOutagesMixin: @pytest.mark.asyncio async def test_list_outages_success(self) -> None: """list_outages returns Outage objects from dict response.""" - payload = { - "outages": [{"uuid": "out_1", "monitor_uuid": "mon_1", "status": "active"}] - } + payload = {"outages": [{"uuid": "out_1", "monitor_uuid": "mon_1", "status": "active"}]} async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: @@ -542,9 +536,7 @@ async def test_list_outages_empty_on_404(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_err(404, {"error": "Not found"}) - ) + client._client.request = AsyncMock(return_value=_mock_err(404, {"error": "Not found"})) outages = await client.list_outages() assert outages == [] @@ -554,9 +546,7 @@ async def test_acknowledge_outage(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_ok({"status": "acknowledged"}) - ) + client._client.request = AsyncMock(return_value=_mock_ok({"status": "acknowledged"})) result = await client.acknowledge_outage("out_1") assert isinstance(result, OutageAction) assert result.status == "acknowledged" @@ -567,9 +557,7 @@ async def test_resolve_outage(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_ok({"status": "resolved"}) - ) + client._client.request = AsyncMock(return_value=_mock_ok({"status": "resolved"})) result = await client.resolve_outage("out_1") assert result.status == "resolved" @@ -579,9 +567,7 @@ async def test_escalate_outage(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_ok({"status": "escalated"}) - ) + client._client.request = AsyncMock(return_value=_mock_ok({"status": "escalated"})) result = await client.escalate_outage("out_1") assert result.status == "escalated" @@ -610,9 +596,7 @@ async def test_404_raises_not_found(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_err(404, {"error": "Not found"}) - ) + client._client.request = AsyncMock(return_value=_mock_err(404, {"error": "Not found"})) with pytest.raises(HyperpingNotFoundError): await client.get_monitor("mon_nope") @@ -659,9 +643,7 @@ async def test_timeout_exception_raises_api_error(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - side_effect=httpx.TimeoutException("timed out") - ) + client._client.request = AsyncMock(side_effect=httpx.TimeoutException("timed out")) with pytest.raises(HyperpingAPIError, match="timeout"): await client.list_monitors() @@ -671,9 +653,7 @@ async def test_request_error_raises_api_error(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - side_effect=httpx.ConnectError("connection refused") - ) + client._client.request = AsyncMock(side_effect=httpx.ConnectError("connection refused")) with pytest.raises(HyperpingAPIError, match="Request failed"): await client.list_monitors() @@ -739,9 +719,7 @@ async def test_list_outages_invalid_type(self) -> None: @pytest.mark.asyncio async def test_list_outages_explicit_page(self) -> None: """list_outages with explicit page returns single-page results.""" - payload = { - "outages": [{"uuid": "out_p1", "monitor_uuid": "mon_1", "status": "active"}] - } + payload = {"outages": [{"uuid": "out_p1", "monitor_uuid": "mon_1", "status": "active"}]} async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: @@ -778,9 +756,7 @@ async def test_list_outages_multipage(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - side_effect=[_mock_ok(page1), _mock_ok(page2)] - ) + client._client.request = AsyncMock(side_effect=[_mock_ok(page1), _mock_ok(page2)]) outages = await client.list_outages() assert len(outages) == 2 @@ -841,9 +817,7 @@ async def test_list_status_pages_404_returns_empty(self) -> None: async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: - client._client.request = AsyncMock( - return_value=_mock_err(404, {"error": "Not found"}) - ) + client._client.request = AsyncMock(return_value=_mock_err(404, {"error": "Not found"})) pages = await client.list_status_pages() assert pages == [] @@ -923,9 +897,7 @@ async def test_list_subscribers(self) -> None: @pytest.mark.asyncio async def test_list_subscribers_explicit_page(self) -> None: """list_subscribers with explicit page returns single-page results.""" - payload = { - "subscribers": [{"id": "sub_2", "email": "a@b.com", "type": "email"}] - } + payload = {"subscribers": [{"id": "sub_2", "email": "a@b.com", "type": "email"}]} async with AsyncHyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=0) ) as client: diff --git a/tests/unit/test_async_new_mixins.py b/tests/unit/test_async_new_mixins.py deleted file mode 100644 index 67d8c83..0000000 --- a/tests/unit/test_async_new_mixins.py +++ /dev/null @@ -1,262 +0,0 @@ -"""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}{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") - assert result["p50"] == 45.0 - - @respx.mock - @pytest.mark.asyncio - async def test_get_monitor_mtta(self, async_client): - 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") - 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 index 8f1fedd..f52606e 100644 --- a/tests/unit/test_async_preexisting.py +++ b/tests/unit/test_async_preexisting.py @@ -230,9 +230,7 @@ async def test_update_maintenance(self, async_client): }, ) ) - result = await async_client.update_maintenance( - "mw_1", MaintenanceUpdate(name="New Name") - ) + result = await async_client.update_maintenance("mw_1", MaintenanceUpdate(name="New Name")) assert result.name == "New Name" @@ -286,9 +284,7 @@ async def test_get_incident(self, async_client): 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"} - ) + 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( @@ -316,9 +312,7 @@ async def test_resolve_incident(self, async_client): @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) - ) + respx.delete(f"{API_BASE}{Endpoint.INCIDENTS}/inc_1").mock(return_value=httpx.Response(204)) await async_client.delete_incident("inc_1") @respx.mock diff --git a/tests/unit/test_healthchecks.py b/tests/unit/test_healthchecks.py index 2280da4..bab952d 100644 --- a/tests/unit/test_healthchecks.py +++ b/tests/unit/test_healthchecks.py @@ -70,22 +70,16 @@ def test_returns_list_from_healthchecks_key(self, client: HyperpingClient) -> No @respx.mock def test_returns_empty_list_on_404(self, client: HyperpingClient) -> None: """Returns empty list instead of raising on 404 (consistent with list_outages).""" - respx.get(_HC_ENDPOINT).mock( - return_value=httpx.Response(404, json={"error": "Not found"}) - ) + respx.get(_HC_ENDPOINT).mock(return_value=httpx.Response(404, json={"error": "Not found"})) result = client.list_healthchecks() assert result == [] @respx.mock - def test_returns_empty_list_when_response_is_empty_list( - self, client: HyperpingClient - ) -> None: + def test_returns_empty_list_when_response_is_empty_list(self, client: HyperpingClient) -> None: """Returns empty list when API returns an empty array.""" - respx.get(_HC_ENDPOINT).mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get(_HC_ENDPOINT).mock(return_value=httpx.Response(200, json=[])) result = client.list_healthchecks() @@ -132,9 +126,7 @@ class TestCreateHealthcheck: @respx.mock def test_returns_created_healthcheck(self, client: HyperpingClient) -> None: """Returns the created Healthcheck from the API response.""" - respx.post(_HC_ENDPOINT).mock( - return_value=httpx.Response(201, json=_make_hc()) - ) + respx.post(_HC_ENDPOINT).mock(return_value=httpx.Response(201, json=_make_hc())) payload = HealthcheckCreate(name="Daily job", period=86400, grace=3600) result = client.create_healthcheck(payload) @@ -146,15 +138,14 @@ def test_returns_created_healthcheck(self, client: HyperpingClient) -> None: @respx.mock def test_excludes_none_fields_from_payload(self, client: HyperpingClient) -> None: """Payload sent to the API omits None optional fields.""" - route = respx.post(_HC_ENDPOINT).mock( - return_value=httpx.Response(201, json=_make_hc()) - ) + route = respx.post(_HC_ENDPOINT).mock(return_value=httpx.Response(201, json=_make_hc())) payload = HealthcheckCreate(name="Hourly job", period=3600, grace=300) client.create_healthcheck(payload) sent_body = route.calls[0].request import json as json_module + body = json_module.loads(sent_body.content) assert "escalation_policy" not in body assert "project_uuid" not in body @@ -167,9 +158,7 @@ class TestUpdateHealthcheck: def test_returns_updated_healthcheck(self, client: HyperpingClient) -> None: """Returns the updated Healthcheck from the API response.""" updated = _make_hc(name="Updated job", period=3600) - respx.put(f"{_HC_ENDPOINT}/hc_abc123").mock( - return_value=httpx.Response(200, json=updated) - ) + respx.put(f"{_HC_ENDPOINT}/hc_abc123").mock(return_value=httpx.Response(200, json=updated)) update = HealthcheckUpdate(name="Updated job", period=3600) result = client.update_healthcheck("hc_abc123", update) @@ -200,9 +189,7 @@ class TestDeleteHealthcheck: @respx.mock def test_returns_none_on_success(self, client: HyperpingClient) -> None: """Returns None after a successful delete.""" - respx.delete(f"{_HC_ENDPOINT}/hc_abc123").mock( - return_value=httpx.Response(204) - ) + respx.delete(f"{_HC_ENDPOINT}/hc_abc123").mock(return_value=httpx.Response(204)) result = client.delete_healthcheck("hc_abc123") diff --git a/tests/unit/test_incidents.py b/tests/unit/test_incidents.py index d98be58..91b686f 100644 --- a/tests/unit/test_incidents.py +++ b/tests/unit/test_incidents.py @@ -249,9 +249,7 @@ def test_delete_incident(self, client: HyperpingClient) -> None: @respx.mock def test_list_incidents_with_status_filter(self, client: HyperpingClient) -> None: """Test listing incidents with status filter.""" - respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(return_value=httpx.Response(200, json=[])) incidents = client.list_incidents(status="investigating") assert incidents == [] diff --git a/tests/unit/test_integrations.py b/tests/unit/test_integrations.py deleted file mode 100644 index 8782da8..0000000 --- a/tests/unit/test_integrations.py +++ /dev/null @@ -1,98 +0,0 @@ -"""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_mcp_client.py b/tests/unit/test_mcp_client.py new file mode 100644 index 0000000..fc28483 --- /dev/null +++ b/tests/unit/test_mcp_client.py @@ -0,0 +1,161 @@ +"""Tests for the high-level MCP client.""" + +from unittest.mock import MagicMock + +from hyperping.mcp_client import HyperpingMcpClient + + +def make_client() -> HyperpingMcpClient: + client = HyperpingMcpClient(api_key="sk_test") + client._transport = MagicMock() + return client + + +def test_get_status_summary(): + client = make_client() + client._transport.call_tool.return_value = {"total": 5, "up": 3, "down": 1, "paused": 1} + result = client.get_status_summary() + assert result["total"] == 5 + client._transport.call_tool.assert_called_once_with("get_status_summary", {}) + + +def test_list_on_call_schedules(): + client = make_client() + client._transport.call_tool.return_value = [{"uuid": "s1", "name": "Primary"}] + result = client.list_on_call_schedules() + assert len(result) == 1 + assert result[0]["uuid"] == "s1" + client._transport.call_tool.assert_called_once_with("list_on_call_schedules", {}) + + +def test_list_team_members_bare_array(): + client = make_client() + client._transport.call_tool.return_value = [{"uuid": "u1", "email": "a@b.com"}] + result = client.list_team_members() + assert len(result) == 1 + client._transport.call_tool.assert_called_once_with("list_team_members", {}) + + +def test_search_monitors(): + client = make_client() + client._transport.call_tool.return_value = [{"uuid": "m1", "name": "API"}] + result = client.search_monitors_by_name("API") + assert len(result) == 1 + client._transport.call_tool.assert_called_once_with("search_monitors_by_name", {"query": "API"}) + + +def test_get_outage_timeline(): + client = make_client() + client._transport.call_tool.return_value = { + "events": [{"type": "detected", "timestamp": "2026-01-01T00:00:00Z"}] + } + result = client.get_outage_timeline("outage_123") + assert "events" in result + client._transport.call_tool.assert_called_once_with( + "get_outage_timeline", {"uuid": "outage_123"} + ) + + +def test_get_monitor_anomalies(): + client = make_client() + client._transport.call_tool.return_value = [{"type": "flapping", "startedAt": "2026-01-01"}] + result = client.get_monitor_anomalies("mon_123") + assert len(result) == 1 + client._transport.call_tool.assert_called_once_with( + "get_monitor_anomalies", {"uuid": "mon_123"} + ) + + +def test_context_manager(): + with HyperpingMcpClient(api_key="sk_test") as client: + assert client is not None + + +def test_get_monitor_response_time(): + client = make_client() + client._transport.call_tool.return_value = {"p50": 120, "p95": 350} + result = client.get_monitor_response_time("mon_1") + assert result["p50"] == 120 + client._transport.call_tool.assert_called_once_with( + "get_monitor_response_time", {"uuid": "mon_1"} + ) + + +def test_get_monitor_mtta_with_uuid(): + client = make_client() + client._transport.call_tool.return_value = {"mtta": 45} + result = client.get_monitor_mtta(monitor_uuid="mon_1") + assert result["mtta"] == 45 + client._transport.call_tool.assert_called_once_with("get_monitor_mtta", {"uuid": "mon_1"}) + + +def test_get_monitor_mtta_without_uuid(): + client = make_client() + client._transport.call_tool.return_value = {"mtta": 60} + result = client.get_monitor_mtta() + assert result["mtta"] == 60 + client._transport.call_tool.assert_called_once_with("get_monitor_mtta", {}) + + +def test_get_monitor_mttr(): + client = make_client() + client._transport.call_tool.return_value = {"mttr": 90} + result = client.get_monitor_mttr(monitor_uuid="mon_1") + assert result["mttr"] == 90 + client._transport.call_tool.assert_called_once_with("get_monitor_mttr", {"uuid": "mon_1"}) + + +def test_get_monitor_http_logs(): + client = make_client() + client._transport.call_tool.return_value = [{"status": 200, "latency": 123}] + result = client.get_monitor_http_logs("mon_1") + assert len(result) == 1 + client._transport.call_tool.assert_called_once_with("get_monitor_http_logs", {"uuid": "mon_1"}) + + +def test_list_recent_alerts(): + client = make_client() + client._transport.call_tool.return_value = {"alerts": [{"uuid": "a1"}]} + result = client.list_recent_alerts() + assert "alerts" in result + client._transport.call_tool.assert_called_once_with("list_recent_alerts", {}) + + +def test_get_on_call_schedule(): + client = make_client() + client._transport.call_tool.return_value = {"uuid": "s1", "name": "Primary"} + result = client.get_on_call_schedule("s1") + assert result["uuid"] == "s1" + client._transport.call_tool.assert_called_once_with("get_on_call_schedule", {"uuid": "s1"}) + + +def test_list_escalation_policies(): + client = make_client() + client._transport.call_tool.return_value = [{"uuid": "ep1"}] + result = client.list_escalation_policies() + assert len(result) == 1 + client._transport.call_tool.assert_called_once_with("list_escalation_policies", {}) + + +def test_get_escalation_policy(): + client = make_client() + client._transport.call_tool.return_value = {"uuid": "ep1", "name": "Default"} + result = client.get_escalation_policy("ep1") + assert result["uuid"] == "ep1" + client._transport.call_tool.assert_called_once_with("get_escalation_policy", {"uuid": "ep1"}) + + +def test_list_integrations(): + client = make_client() + client._transport.call_tool.return_value = [{"uuid": "int1", "type": "slack"}] + result = client.list_integrations() + assert len(result) == 1 + client._transport.call_tool.assert_called_once_with("list_integrations", {}) + + +def test_get_integration(): + client = make_client() + client._transport.call_tool.return_value = {"uuid": "int1", "type": "slack"} + result = client.get_integration("int1") + assert result["uuid"] == "int1" + client._transport.call_tool.assert_called_once_with("get_integration", {"uuid": "int1"}) diff --git a/tests/unit/test_mcp_transport.py b/tests/unit/test_mcp_transport.py new file mode 100644 index 0000000..93d7e96 --- /dev/null +++ b/tests/unit/test_mcp_transport.py @@ -0,0 +1,161 @@ +"""Tests for the MCP JSON-RPC 2.0 transport layer.""" + +import json + +import httpx +import pytest +import respx + +from hyperping._mcp_transport import MCP_URL, McpTransport +from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError + + +@respx.mock +def test_initialize(): + respx.post(MCP_URL).mock( + side_effect=[ + httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": {"name": "hyperping"}, + }, + }, + ), + httpx.Response(202), # notifications/initialized + ] + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + result = transport.initialize() + assert result["protocolVersion"] == "2025-03-26" + transport.close() + + +@respx.mock +def test_call_tool_auto_initializes(): + respx.post(MCP_URL).mock( + side_effect=[ + httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"protocolVersion": "2025-03-26"}, + }, + ), + httpx.Response(202), + httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [{"type": "text", "text": json.dumps({"schedules": []})}] + }, + }, + ), + ] + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + result = transport.call_tool("list_on_call_schedules") + assert result == {"schedules": []} + transport.close() + + +@respx.mock +def test_call_tool_http_401(): + respx.post(MCP_URL).mock(return_value=httpx.Response(401, text="Invalid Token")) + transport = McpTransport(api_key="sk_bad", base_url=MCP_URL) + transport._initialized = True # skip init + with pytest.raises(HyperpingAuthError): + transport.call_tool("list_team_members") + transport.close() + + +@respx.mock +def test_call_tool_jsonrpc_error(): + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32601, "message": "Method not found"}, + }, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="Method not found"): + transport.call_tool("nonexistent_tool") + transport.close() + + +@respx.mock +def test_call_tool_empty_content(): + """Test that empty content returns None.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"content": []}, + }, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + result = transport.call_tool("some_tool") + assert result is None + transport.close() + + +@respx.mock +def test_call_tool_invalid_json_response(): + """Test that invalid JSON in tool response raises HyperpingAPIError.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": {"content": [{"type": "text", "text": "not valid json{"}]}, + }, + ) + ) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="Failed to parse"): + transport.call_tool("some_tool") + transport.close() + + +@respx.mock +def test_call_tool_http_500(): + """Test that HTTP 500 raises HyperpingAPIError.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(500, text="Internal Server Error")) + transport = McpTransport(api_key="sk_test", base_url=MCP_URL) + transport._initialized = True + with pytest.raises(HyperpingAPIError, match="HTTP 500"): + transport.call_tool("some_tool") + transport.close() + + +def test_context_manager(): + """Test that McpTransport supports context manager protocol.""" + with McpTransport(api_key="sk_test", base_url=MCP_URL) as transport: + assert transport is not None + + +def test_secretstr_api_key(): + """Test that McpTransport accepts SecretStr api_key.""" + from pydantic import SecretStr + + transport = McpTransport(api_key=SecretStr("sk_secret"), base_url=MCP_URL) + assert transport._client.headers["Authorization"] == "Bearer sk_secret" + transport.close() diff --git a/tests/unit/test_monitors.py b/tests/unit/test_monitors.py index 9fbba2f..bc96b0d 100644 --- a/tests/unit/test_monitors.py +++ b/tests/unit/test_monitors.py @@ -53,9 +53,7 @@ def test_list_monitors_success(self, client: HyperpingClient) -> None: @respx.mock def test_list_monitors_empty(self, client: HyperpingClient) -> None: """Test list monitors with empty result.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) monitors = client.list_monitors() assert monitors == [] @@ -183,9 +181,7 @@ def test_rate_limit_error(self, client: HyperpingClient) -> None: @respx.mock def test_ping_success(self, client: HyperpingClient) -> None: """Test ping connectivity check.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) assert client.ping() is True @@ -452,9 +448,7 @@ class TestContextManager: @respx.mock def test_context_manager(self) -> None: """Test client works as context manager.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) with HyperpingClient(api_key="sk_test") as c: monitors = c.list_monitors() @@ -474,9 +468,7 @@ def test_api_key_not_in_repr(self) -> None: @respx.mock def test_api_key_used_in_auth_header(self) -> None: """Authorization header contains the actual key.""" - respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) c = HyperpingClient(api_key="sk_test_auth") c.list_monitors() assert c._client.headers["Authorization"] == "Bearer sk_test_auth" @@ -639,9 +631,7 @@ def capture_sleep(duration: float) -> None: sleep_values.append(duration) with patch("hyperping.client.time.sleep", side_effect=capture_sleep): - with patch( - "hyperping.client.random.uniform", wraps=__import__("random").uniform - ): + with patch("hyperping.client.random.uniform", wraps=__import__("random").uniform): c = HyperpingClient( api_key="sk_test", retry_config=RetryConfig(max_retries=2, initial_delay=1.0, backoff_factor=2.0), @@ -682,46 +672,3 @@ 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 deleted file mode 100644 index 5f6b5dd..0000000 --- a/tests/unit/test_observability.py +++ /dev/null @@ -1,203 +0,0 @@ -"""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 deleted file mode 100644 index ec52fa5..0000000 --- a/tests/unit/test_oncall.py +++ /dev/null @@ -1,217 +0,0 @@ -"""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 d1dc388..1edd4a9 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 Outage, OutageAction, OutageTimeline +from hyperping.models import OutageAction class TestOutageAPIClient: @@ -85,9 +85,9 @@ def test_acknowledge_outage_with_message(self, client: HyperpingClient) -> None: @respx.mock def test_acknowledge_outage_not_found(self, client: HyperpingClient) -> None: """Test acknowledging a non-existent outage.""" - respx.post( - f"{API_BASE}{Endpoint.OUTAGES}/out_nope/acknowledge" - ).mock(return_value=httpx.Response(404, json={"error": "Not found"})) + respx.post(f"{API_BASE}{Endpoint.OUTAGES}/out_nope/acknowledge").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) with pytest.raises(HyperpingNotFoundError): client.acknowledge_outage("out_nope") @@ -138,100 +138,3 @@ 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_pagination.py b/tests/unit/test_pagination.py index 03e65ac..c8bb360 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -199,9 +199,7 @@ def test_list_subscribers_auto_paginate(self, client: HyperpingClient) -> None: }, ), ] - respx.get(f"{API_BASE}{Endpoint.STATUSPAGES}/{sp_id}/subscribers").mock( - side_effect=calls - ) + respx.get(f"{API_BASE}{Endpoint.STATUSPAGES}/{sp_id}/subscribers").mock(side_effect=calls) subs = client.list_subscribers(sp_id) assert len(subs) == 2 assert respx.calls.call_count == 2 diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py deleted file mode 100644 index 0bcd141..0000000 --- a/tests/unit/test_reporting.py +++ /dev/null @@ -1,140 +0,0 @@ -"""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}{Endpoint.MONITOR_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}{Endpoint.MONITOR_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}{Endpoint.MONITOR_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}{Endpoint.MONITOR_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}{Endpoint.MONITOR_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}{Endpoint.MONITOR_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") diff --git a/tests/unit/test_statuspages.py b/tests/unit/test_statuspages.py index 1e34020..26d7213 100644 --- a/tests/unit/test_statuspages.py +++ b/tests/unit/test_statuspages.py @@ -327,9 +327,7 @@ def test_list_subscribers_empty(self, client: HyperpingClient) -> None: @respx.mock def test_list_subscribers_wrapped_response(self, client: HyperpingClient) -> None: """Test listing when API returns wrapped response.""" - mock_response = { - "subscribers": [{"id": "sub_1", "email": "carol@example.com"}] - } + mock_response = {"subscribers": [{"id": "sub_1", "email": "carol@example.com"}]} respx.get(f"{API_BASE}{Endpoint.STATUSPAGES}/sp_1/subscribers").mock( return_value=httpx.Response(200, json=mock_response) ) @@ -339,9 +337,9 @@ def test_list_subscribers_wrapped_response(self, client: HyperpingClient) -> Non @respx.mock def test_list_subscribers_status_page_not_found(self, client: HyperpingClient) -> None: """Test listing subscribers when status page doesn't exist.""" - respx.get( - f"{API_BASE}{Endpoint.STATUSPAGES}/sp_nope/subscribers" - ).mock(return_value=httpx.Response(404, json={"error": "Not found"})) + respx.get(f"{API_BASE}{Endpoint.STATUSPAGES}/sp_nope/subscribers").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) with pytest.raises(HyperpingNotFoundError): client.list_subscribers("sp_nope") @@ -359,9 +357,7 @@ def test_add_subscriber(self, client: HyperpingClient) -> None: assert sub.id == "sub_new" assert sub.email == "dave@example.com" - def test_add_subscriber_invalid_email_raises_value_error( - self, client: HyperpingClient - ) -> None: + def test_add_subscriber_invalid_email_raises_value_error(self, client: HyperpingClient) -> None: """Test adding a subscriber with an invalid email raises ValueError (M10).""" with pytest.raises(ValueError, match="Invalid email"): client.add_subscriber("sp_1", "not-an-email") @@ -371,17 +367,17 @@ def test_add_subscriber_invalid_email_raises_value_error( @respx.mock def test_remove_subscriber(self, client: HyperpingClient) -> None: """Test removing a subscriber from a status page.""" - respx.delete( - f"{API_BASE}{Endpoint.STATUSPAGES}/sp_1/subscribers/sub_1" - ).mock(return_value=httpx.Response(204)) + respx.delete(f"{API_BASE}{Endpoint.STATUSPAGES}/sp_1/subscribers/sub_1").mock( + return_value=httpx.Response(204) + ) client.remove_subscriber("sp_1", "sub_1") # Should not raise @respx.mock def test_remove_subscriber_not_found(self, client: HyperpingClient) -> None: """Test removing a non-existent subscriber.""" - respx.delete( - f"{API_BASE}{Endpoint.STATUSPAGES}/sp_1/subscribers/sub_nope" - ).mock(return_value=httpx.Response(404, json={"error": "Not found"})) + respx.delete(f"{API_BASE}{Endpoint.STATUSPAGES}/sp_1/subscribers/sub_nope").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) with pytest.raises(HyperpingNotFoundError): client.remove_subscriber("sp_1", "sub_nope") diff --git a/uv.lock b/uv.lock index 6f71c35..083e6cd 100644 --- a/uv.lock +++ b/uv.lock @@ -335,7 +335,7 @@ wheels = [ [[package]] name = "hyperping" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "httpx" },