Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "hyperping"
version = "1.2.1"
version = "1.3.0"
description = "Python SDK for the Hyperping uptime monitoring and incident management API"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "MIT"}
Expand Down
33 changes: 28 additions & 5 deletions src/hyperping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
from hyperping.models import (
DEFAULT_REGIONS,
AddIncidentUpdateRequest,
AlertNotification,
DnsRecordType,
EscalationPolicy,
Healthcheck,
HealthcheckCreate,
HealthcheckUpdate,
Expand All @@ -49,11 +51,13 @@
IncidentUpdate,
IncidentUpdateRequest,
IncidentUpdateType,
Integration,
LocalizedText,
Maintenance,
MaintenanceCreate,
MaintenanceUpdate,
Monitor,
MonitorAnomaly,
MonitorBase,
MonitorCreate,
MonitorFrequency,
Expand All @@ -63,17 +67,22 @@
MonitorTimeout,
MonitorUpdate,
NotificationOption,
OnCallSchedule,
Outage,
OutageAction,
OutageDetail,
OutageStats,
OutageTimeline,
OutageTimelineEvent,
ProbeLog,
Region,
ReportPeriod,
RequestHeader,
StatusPage,
StatusPageCreate,
StatusPageSubscriber,
StatusPageUpdate,
StatusSummary,
)

__all__ = [
Expand Down Expand Up @@ -137,6 +146,19 @@
# Outages
"Outage",
"OutageAction",
"OutageTimeline",
"OutageTimelineEvent",
# Observability
"MonitorAnomaly",
"ProbeLog",
"AlertNotification",
# On-call
"OnCallSchedule",
"EscalationPolicy",
# Integrations
"Integration",
# Reporting
"StatusSummary",
# Healthchecks
"Healthcheck",
"HealthcheckCreate",
Expand All @@ -161,17 +183,15 @@ def __getattr__(name: str) -> object:

if name == "HYPERPING_API_BASE":
warnings.warn(
"HYPERPING_API_BASE is deprecated and will be removed in v0.3.0. "
"Use API_BASE instead.",
"HYPERPING_API_BASE is deprecated and will be removed in v0.3.0. Use API_BASE instead.",
DeprecationWarning,
stacklevel=2,
)
return API_BASE

if name == "API_PATHS":
warnings.warn(
"API_PATHS is deprecated and will be removed in v0.3.0. "
"Use the Endpoint enum instead.",
"API_PATHS is deprecated and will be removed in v0.3.0. Use the Endpoint enum instead.",
DeprecationWarning,
stacklevel=2,
)
Expand Down Expand Up @@ -199,7 +219,10 @@ def __getattr__(name: str) -> object:

# Symbols removed from __all__ (H5) but still accessible for backward compat
_endpoint_helpers = {
"EndpointConfig", "ENDPOINTS", "get_endpoint_url", "get_version_for_endpoint",
"EndpointConfig",
"ENDPOINTS",
"get_endpoint_url",
"get_version_for_endpoint",
}
if name in _endpoint_helpers:
from hyperping import endpoints as _ep
Expand Down
13 changes: 10 additions & 3 deletions src/hyperping/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@

from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin
from hyperping._async_incidents_mixin import AsyncIncidentsMixin
from hyperping._async_integrations_mixin import AsyncIntegrationsMixin
from hyperping._async_maintenance_mixin import AsyncMaintenanceMixin
from hyperping._async_monitors_mixin import AsyncMonitorsMixin
from hyperping._async_observability_mixin import AsyncObservabilityMixin
from hyperping._async_oncall_mixin import AsyncOnCallMixin
from hyperping._async_outages_mixin import AsyncOutagesMixin
from hyperping._async_reporting_mixin import AsyncReportingMixin
from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin
from hyperping._circuit_breaker import (
CircuitBreaker,
Expand All @@ -48,6 +52,10 @@ class AsyncHyperpingClient(
AsyncOutagesMixin,
AsyncStatusPagesMixin,
AsyncHealthchecksMixin,
AsyncReportingMixin,
AsyncObservabilityMixin,
AsyncOnCallMixin,
AsyncIntegrationsMixin,
):
"""Async client for interacting with the Hyperping API.

Expand Down Expand Up @@ -178,6 +186,7 @@ def _handle_response_error(self, response: httpx.Response) -> None:
)
if status in (400, 422):
from hyperping.exceptions import HyperpingValidationError

raise HyperpingValidationError(
message=f"Validation error: {error_msg}",
status_code=status,
Expand Down Expand Up @@ -321,9 +330,7 @@ async def _request(
continue
self._circuit_breaker.record_failure()
if isinstance(e, httpx.TimeoutException):
raise HyperpingAPIError(
f"Request timeout after {max_attempts} attempts"
) from e
raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e
raise HyperpingAPIError(f"Request failed: {e}") from e

raise HyperpingAPIError( # pragma: no cover
Expand Down
26 changes: 26 additions & 0 deletions src/hyperping/_async_integrations_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""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"))
21 changes: 15 additions & 6 deletions src/hyperping/_async_monitors_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ async def update_monitor(
)
payload.update(update.model_dump(exclude_none=True))

response = await self._request(
"PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload
)
response = await self._request("PUT", f"{Endpoint.MONITORS}/{monitor_id}", json=payload)
return Monitor.model_validate(expect_dict(response, "update_monitor"))

async def delete_monitor(self, monitor_id: str) -> None:
Expand Down Expand Up @@ -156,9 +154,7 @@ async def get_all_reports(
HyperpingAPIError: On unexpected API errors.
"""
if period not in VALID_PERIODS:
raise ValueError(
f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}"
)
raise ValueError(f"Invalid period {period!r}. Valid values: {sorted(VALID_PERIODS)}")
response = expect_dict(
await self._request("GET", Endpoint.REPORTS, params={"period": period}),
"get_all_reports",
Expand Down Expand Up @@ -191,3 +187,16 @@ async def get_monitor_report(
if r.uuid == monitor_id:
return r
raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}")

async def search_monitors_by_name(self, query: str) -> list[Monitor]:
"""Search monitors by name (case-insensitive substring match)."""
if not query:
return []
try:
result = await self._request(
"GET", f"{Endpoint.MONITORS}/search", params={"query": query}
)
except HyperpingNotFoundError:
return []
items = result if isinstance(result, list) else []
return parse_list(items, Monitor, "monitor")
65 changes: 65 additions & 0 deletions src/hyperping/_async_observability_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""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")
53 changes: 53 additions & 0 deletions src/hyperping/_async_oncall_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""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 []
Loading