hyperping 1.8.0__tar.gz → 1.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hyperping-1.8.0 → hyperping-1.8.1}/CHANGELOG.md +23 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/PKG-INFO +5 -1
- {hyperping-1.8.0 → hyperping-1.8.1}/pyproject.toml +7 -1
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/__init__.py +4 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_maintenance_mixin.py +38 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_client.py +50 -1
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_maintenance_mixin.py +63 -0
- hyperping-1.8.1/src/hyperping/_version.py +1 -0
- hyperping-1.8.1/src/hyperping/cli/_app.py +52 -0
- hyperping-1.8.1/src/hyperping/cli/_config.py +24 -0
- hyperping-1.8.1/src/hyperping/cli/_incidents.py +87 -0
- hyperping-1.8.1/src/hyperping/cli/_monitors.py +90 -0
- hyperping-1.8.1/src/hyperping/cli/_output.py +58 -0
- hyperping-1.8.1/src/hyperping/cli/_statuspages.py +57 -0
- hyperping-1.8.1/src/hyperping/cli/_tenant.py +75 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/mcp_client.py +50 -1
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/__init__.py +7 -1
- hyperping-1.8.1/src/hyperping/models/_integration_models.py +27 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_oncall_models.py +34 -3
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_statuspage_models.py +12 -1
- hyperping-1.8.1/tests/unit/__init__.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_client.py +151 -10
- hyperping-1.8.1/tests/unit/test_cli_config.py +28 -0
- hyperping-1.8.1/tests/unit/test_cli_incidents.py +121 -0
- hyperping-1.8.1/tests/unit/test_cli_monitors.py +95 -0
- hyperping-1.8.1/tests/unit/test_cli_statuspages.py +67 -0
- hyperping-1.8.1/tests/unit/test_cli_tenant.py +90 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_maintenance.py +74 -1
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_mcp_client.py +145 -10
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_statuspages.py +24 -0
- hyperping-1.8.0/src/hyperping/_version.py +0 -1
- hyperping-1.8.0/src/hyperping/models/_integration_models.py +0 -14
- {hyperping-1.8.0 → hyperping-1.8.1}/.gitignore +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/CONTRIBUTING.md +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/LICENSE +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/README.md +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/SECURITY.md +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_client.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_transport.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_internals.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_mcp_transport.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_utils.py +0 -0
- {hyperping-1.8.0/tests → hyperping-1.8.1/src/hyperping/cli}/__init__.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/client.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/exceptions.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/py.typed +0 -0
- {hyperping-1.8.0/tests/unit → hyperping-1.8.1/tests}/__init__.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/conftest.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_transport.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_mcp_transport.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_outages.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_sdk_surface.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_security_base_url.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_security_breaker_cap.py +0 -0
- {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_security_exception_redaction.py +0 -0
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.8.1] - 2026-07-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`list_status_pages()` returned an empty list against the live v2 API.** The
|
|
15
|
+
`StatusPage` model required a `subdomain` field, but the API returns the hosted
|
|
16
|
+
subdomain under the key `hostedsubdomain` (and omits it entirely for custom-domain
|
|
17
|
+
pages). Every record failed validation and was silently skipped. `subdomain` is now
|
|
18
|
+
optional and aliased to `hostedsubdomain`; `hostname` and `url` are also parsed.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`create_maintenance()` now guards against the silent status-page overflow.**
|
|
23
|
+
Hyperping's v1 maintenance-windows API accepts a create with more than 51 status
|
|
24
|
+
pages (returns a uuid) but never persists the window. `create_maintenance` now raises
|
|
25
|
+
`HyperpingValidationError` when `statuspages` exceeds `MAX_STATUSPAGES_PER_MAINTENANCE`
|
|
26
|
+
(51), instead of returning a phantom window. Split larger sets across multiple windows.
|
|
27
|
+
- **`create_maintenance_windows()`** (sync + async): convenience helper that splits a
|
|
28
|
+
large `statuspages` list into consecutive windows of at most 51 pages, so callers can
|
|
29
|
+
cover more pages than one window allows. Each window carries the full monitor set (the
|
|
30
|
+
API requires at least one monitor per window). `MAX_STATUSPAGES_PER_MAINTENANCE` is
|
|
31
|
+
exported for callers that chunk themselves (e.g. a broadcast-to-all-tenants command).
|
|
32
|
+
|
|
10
33
|
## [1.8.0] - 2026-05-31
|
|
11
34
|
|
|
12
35
|
This is a security-focused release. It closes several credential-leak and validation gaps surfaced by an independent audit. One change is breaking for local-development workflows; see Upgrade Notes below.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperping
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.1
|
|
4
4
|
Summary: Python SDK for the Hyperping uptime monitoring and incident management API
|
|
5
5
|
Project-URL: Homepage, https://github.com/develeap/hyperping-python
|
|
6
6
|
Project-URL: Documentation, https://github.com/develeap/hyperping-python#readme
|
|
@@ -12,6 +12,7 @@ License: MIT
|
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Keywords: api-client,httpx,hyperping,incident,monitoring,pydantic,python,sdk,status-page,uptime
|
|
14
14
|
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
15
16
|
Classifier: Intended Audience :: Developers
|
|
16
17
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
18
|
Classifier: Operating System :: OS Independent
|
|
@@ -25,6 +26,8 @@ Classifier: Typing :: Typed
|
|
|
25
26
|
Requires-Python: >=3.11
|
|
26
27
|
Requires-Dist: httpx<1.0,>=0.27
|
|
27
28
|
Requires-Dist: pydantic<3.0,>=2.0
|
|
29
|
+
Provides-Extra: cli
|
|
30
|
+
Requires-Dist: typer[all]<1.0,>=0.12; extra == 'cli'
|
|
28
31
|
Provides-Extra: dev
|
|
29
32
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
30
33
|
Requires-Dist: pip-audit>=2.7; extra == 'dev'
|
|
@@ -33,6 +36,7 @@ Requires-Dist: pytest-cov; extra == 'dev'
|
|
|
33
36
|
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
34
37
|
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
35
38
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
39
|
+
Requires-Dist: typer[all]<1.0,>=0.12; extra == 'dev'
|
|
36
40
|
Description-Content-Type: text/markdown
|
|
37
41
|
|
|
38
42
|
# hyperping
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hyperping"
|
|
7
|
-
version = "1.8.
|
|
7
|
+
version = "1.8.1"
|
|
8
8
|
description = "Python SDK for the Hyperping uptime monitoring and incident management API"
|
|
9
9
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -23,6 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
24
|
"Topic :: Internet :: WWW/HTTP",
|
|
25
25
|
"Typing :: Typed",
|
|
26
|
+
"Environment :: Console",
|
|
26
27
|
]
|
|
27
28
|
dependencies = [
|
|
28
29
|
"httpx>=0.27,<1.0",
|
|
@@ -30,6 +31,7 @@ dependencies = [
|
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
[project.optional-dependencies]
|
|
34
|
+
cli = ["typer[all]>=0.12,<1.0"]
|
|
33
35
|
dev = [
|
|
34
36
|
"pytest>=9.0.3",
|
|
35
37
|
"pytest-cov",
|
|
@@ -38,8 +40,12 @@ dev = [
|
|
|
38
40
|
"mypy>=1.10",
|
|
39
41
|
"pydantic",
|
|
40
42
|
"pip-audit>=2.7",
|
|
43
|
+
"typer[all]>=0.12,<1.0",
|
|
41
44
|
]
|
|
42
45
|
|
|
46
|
+
[project.scripts]
|
|
47
|
+
hyp = "hyperping.cli._app:app"
|
|
48
|
+
|
|
43
49
|
[project.urls]
|
|
44
50
|
Homepage = "https://github.com/develeap/hyperping-python"
|
|
45
51
|
Documentation = "https://github.com/develeap/hyperping-python#readme"
|
|
@@ -16,6 +16,7 @@ Quick start::
|
|
|
16
16
|
|
|
17
17
|
from hyperping._async_client import AsyncHyperpingClient
|
|
18
18
|
from hyperping._async_mcp_client import AsyncHyperpingMcpClient
|
|
19
|
+
from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
|
|
19
20
|
from hyperping._version import __version__
|
|
20
21
|
from hyperping.client import (
|
|
21
22
|
CircuitBreaker,
|
|
@@ -44,6 +45,7 @@ from hyperping.models import (
|
|
|
44
45
|
AlertHistory,
|
|
45
46
|
DnsRecordType,
|
|
46
47
|
EscalationPolicy,
|
|
48
|
+
EscalationStep,
|
|
47
49
|
Healthcheck,
|
|
48
50
|
HealthcheckCreate,
|
|
49
51
|
HealthcheckUpdate,
|
|
@@ -149,6 +151,7 @@ __all__ = [
|
|
|
149
151
|
"IncidentStatus",
|
|
150
152
|
"IncidentUpdateCreate",
|
|
151
153
|
# Maintenance
|
|
154
|
+
"MAX_STATUSPAGES_PER_MAINTENANCE",
|
|
152
155
|
"Maintenance",
|
|
153
156
|
"MaintenanceCreate",
|
|
154
157
|
"MaintenanceUpdate",
|
|
@@ -168,6 +171,7 @@ __all__ = [
|
|
|
168
171
|
"ProbeLogResponse",
|
|
169
172
|
# On-call
|
|
170
173
|
"OnCallSchedule",
|
|
174
|
+
"EscalationStep",
|
|
171
175
|
"EscalationPolicy",
|
|
172
176
|
"TeamMember",
|
|
173
177
|
# Integrations
|
|
@@ -9,9 +9,11 @@ from __future__ import annotations
|
|
|
9
9
|
import logging
|
|
10
10
|
from datetime import UTC, datetime
|
|
11
11
|
|
|
12
|
+
from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
|
|
12
13
|
from hyperping._protocols import _AsyncClientProtocol
|
|
13
14
|
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
|
|
14
15
|
from hyperping.endpoints import Endpoint
|
|
16
|
+
from hyperping.exceptions import HyperpingValidationError
|
|
15
17
|
from hyperping.models import (
|
|
16
18
|
Maintenance,
|
|
17
19
|
MaintenanceCreate,
|
|
@@ -82,6 +84,15 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
|
|
|
82
84
|
v1 API returns {"uuid": "..."} on create, not the full maintenance object.
|
|
83
85
|
The full maintenance window is fetched after creation.
|
|
84
86
|
"""
|
|
87
|
+
n_statuspages = len(maintenance.statuspages or [])
|
|
88
|
+
if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
|
|
89
|
+
raise HyperpingValidationError(
|
|
90
|
+
f"A maintenance window can reference at most "
|
|
91
|
+
f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
|
|
92
|
+
f"were supplied. Above this limit Hyperping's API accepts the create "
|
|
93
|
+
f"(returns a uuid) but silently fails to persist the window. Split the "
|
|
94
|
+
f"status pages across multiple maintenance windows."
|
|
95
|
+
)
|
|
85
96
|
payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
|
|
86
97
|
response = expect_dict(
|
|
87
98
|
await self._request("POST", Endpoint.MAINTENANCE, json=payload),
|
|
@@ -91,6 +102,33 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
|
|
|
91
102
|
return await self.get_maintenance(response["uuid"])
|
|
92
103
|
return Maintenance.model_validate(response)
|
|
93
104
|
|
|
105
|
+
async def create_maintenance_windows(
|
|
106
|
+
self,
|
|
107
|
+
maintenance: MaintenanceCreate,
|
|
108
|
+
*,
|
|
109
|
+
chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
|
|
110
|
+
) -> list[Maintenance]:
|
|
111
|
+
"""Create one or more windows, splitting status pages into chunks.
|
|
112
|
+
|
|
113
|
+
Async mirror of
|
|
114
|
+
:meth:`~hyperping._maintenance_mixin.MaintenanceMixin.create_maintenance_windows`.
|
|
115
|
+
"""
|
|
116
|
+
if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
|
|
117
|
+
raise HyperpingValidationError(
|
|
118
|
+
f"chunk_size must be between 1 and "
|
|
119
|
+
f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
|
|
120
|
+
)
|
|
121
|
+
pages = list(maintenance.statuspages or [])
|
|
122
|
+
if len(pages) <= chunk_size:
|
|
123
|
+
return [await self.create_maintenance(maintenance)]
|
|
124
|
+
windows: list[Maintenance] = []
|
|
125
|
+
for start in range(0, len(pages), chunk_size):
|
|
126
|
+
chunk = maintenance.model_copy(
|
|
127
|
+
update={"statuspages": pages[start : start + chunk_size]}
|
|
128
|
+
)
|
|
129
|
+
windows.append(await self.create_maintenance(chunk))
|
|
130
|
+
return windows
|
|
131
|
+
|
|
94
132
|
async def update_maintenance(
|
|
95
133
|
self,
|
|
96
134
|
maintenance_id: str,
|
|
@@ -21,7 +21,7 @@ from pydantic import SecretStr
|
|
|
21
21
|
from hyperping._async_mcp_transport import AsyncMcpTransport
|
|
22
22
|
from hyperping.endpoints import MCP_URL
|
|
23
23
|
from hyperping.models._integration_models import Integration
|
|
24
|
-
from hyperping.models._monitor_models import Monitor
|
|
24
|
+
from hyperping.models._monitor_models import Monitor, MonitorCreate
|
|
25
25
|
from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse
|
|
26
26
|
from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember
|
|
27
27
|
from hyperping.models._outage_models import OutageTimeline
|
|
@@ -270,3 +270,52 @@ class AsyncHyperpingMcpClient:
|
|
|
270
270
|
data = await self._call("search_monitors_by_name", {"query": query})
|
|
271
271
|
raw = data if isinstance(data, list) else []
|
|
272
272
|
return [Monitor.model_validate(m) for m in raw]
|
|
273
|
+
|
|
274
|
+
async def create_monitor(self, monitor: MonitorCreate) -> Monitor:
|
|
275
|
+
"""Create a new monitor.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
monitor: Monitor configuration.
|
|
279
|
+
"""
|
|
280
|
+
return Monitor.model_validate(
|
|
281
|
+
await self._call("create_monitor", monitor.model_dump(exclude_none=True))
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
async def update_monitor(self, monitor_uuid: str, **kwargs: Any) -> Monitor:
|
|
285
|
+
"""Update an existing monitor.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
monitor_uuid: Monitor UUID.
|
|
289
|
+
**kwargs: Fields to update.
|
|
290
|
+
"""
|
|
291
|
+
return Monitor.model_validate(
|
|
292
|
+
await self._call("update_monitor", {"uuid": monitor_uuid, **kwargs})
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def pause_monitor(self, monitor_uuid: str) -> Monitor:
|
|
296
|
+
"""Pause a monitor.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
monitor_uuid: Monitor UUID.
|
|
300
|
+
"""
|
|
301
|
+
return Monitor.model_validate(
|
|
302
|
+
await self._call("pause_monitor", {"uuid": monitor_uuid})
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def resume_monitor(self, monitor_uuid: str) -> Monitor:
|
|
306
|
+
"""Resume a paused monitor.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
monitor_uuid: Monitor UUID.
|
|
310
|
+
"""
|
|
311
|
+
return Monitor.model_validate(
|
|
312
|
+
await self._call("resume_monitor", {"uuid": monitor_uuid})
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def delete_monitor(self, monitor_uuid: str) -> None:
|
|
316
|
+
"""Delete a monitor.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
monitor_uuid: Monitor UUID.
|
|
320
|
+
"""
|
|
321
|
+
await self._call("delete_monitor", {"uuid": monitor_uuid})
|
|
@@ -12,12 +12,22 @@ from datetime import UTC, datetime
|
|
|
12
12
|
from hyperping._protocols import _ClientProtocol
|
|
13
13
|
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
|
|
14
14
|
from hyperping.endpoints import Endpoint
|
|
15
|
+
from hyperping.exceptions import HyperpingValidationError
|
|
15
16
|
from hyperping.models import (
|
|
16
17
|
Maintenance,
|
|
17
18
|
MaintenanceCreate,
|
|
18
19
|
MaintenanceUpdate,
|
|
19
20
|
)
|
|
20
21
|
|
|
22
|
+
# Hyperping's v1 maintenance-windows API silently drops a create when the
|
|
23
|
+
# ``statuspages`` array exceeds this many entries: the POST still returns a
|
|
24
|
+
# ``{"uuid": ...}`` but the window is never persisted (the follow-up GET 404s
|
|
25
|
+
# and it is absent from the list). Empirically the cutoff is 51 (51 persists,
|
|
26
|
+
# 52+ vanishes), verified against the live API on 2026-07-14. Guard the create
|
|
27
|
+
# so callers get a clear error instead of a phantom window; split larger sets
|
|
28
|
+
# across multiple windows.
|
|
29
|
+
MAX_STATUSPAGES_PER_MAINTENANCE = 51
|
|
30
|
+
|
|
21
31
|
logger = logging.getLogger(__name__)
|
|
22
32
|
|
|
23
33
|
|
|
@@ -88,6 +98,15 @@ class MaintenanceMixin(_ClientProtocol):
|
|
|
88
98
|
v1 API returns {"uuid": "..."} on create, not the full maintenance object.
|
|
89
99
|
The full maintenance window is fetched after creation.
|
|
90
100
|
"""
|
|
101
|
+
n_statuspages = len(maintenance.statuspages or [])
|
|
102
|
+
if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
|
|
103
|
+
raise HyperpingValidationError(
|
|
104
|
+
f"A maintenance window can reference at most "
|
|
105
|
+
f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
|
|
106
|
+
f"were supplied. Above this limit Hyperping's API accepts the create "
|
|
107
|
+
f"(returns a uuid) but silently fails to persist the window. Split the "
|
|
108
|
+
f"status pages across multiple maintenance windows."
|
|
109
|
+
)
|
|
91
110
|
payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
|
|
92
111
|
response = expect_dict(
|
|
93
112
|
self._request("POST", Endpoint.MAINTENANCE, json=payload),
|
|
@@ -99,6 +118,50 @@ class MaintenanceMixin(_ClientProtocol):
|
|
|
99
118
|
return self.get_maintenance(response["uuid"])
|
|
100
119
|
return Maintenance.model_validate(response)
|
|
101
120
|
|
|
121
|
+
def create_maintenance_windows(
|
|
122
|
+
self,
|
|
123
|
+
maintenance: MaintenanceCreate,
|
|
124
|
+
*,
|
|
125
|
+
chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
|
|
126
|
+
) -> list[Maintenance]:
|
|
127
|
+
"""Create one or more windows, splitting status pages into chunks.
|
|
128
|
+
|
|
129
|
+
Hyperping silently fails to persist a window with more than
|
|
130
|
+
:data:`MAX_STATUSPAGES_PER_MAINTENANCE` status pages (see
|
|
131
|
+
:meth:`create_maintenance`). This helper splits a large ``statuspages``
|
|
132
|
+
list into consecutive windows of at most ``chunk_size`` pages so every
|
|
133
|
+
page is covered. Each window carries the full ``monitors`` set (the API
|
|
134
|
+
rejects a window with no monitors); overlapping monitor coverage across
|
|
135
|
+
the windows is harmless.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
maintenance: Creation data; ``statuspages`` may exceed the limit.
|
|
139
|
+
chunk_size: Max status pages per window (1..
|
|
140
|
+
:data:`MAX_STATUSPAGES_PER_MAINTENANCE`).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The created windows in page order (a single window when the pages
|
|
144
|
+
fit in one chunk).
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
HyperpingValidationError: If ``chunk_size`` is out of range.
|
|
148
|
+
"""
|
|
149
|
+
if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
|
|
150
|
+
raise HyperpingValidationError(
|
|
151
|
+
f"chunk_size must be between 1 and "
|
|
152
|
+
f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
|
|
153
|
+
)
|
|
154
|
+
pages = list(maintenance.statuspages or [])
|
|
155
|
+
if len(pages) <= chunk_size:
|
|
156
|
+
return [self.create_maintenance(maintenance)]
|
|
157
|
+
windows: list[Maintenance] = []
|
|
158
|
+
for start in range(0, len(pages), chunk_size):
|
|
159
|
+
chunk = maintenance.model_copy(
|
|
160
|
+
update={"statuspages": pages[start : start + chunk_size]}
|
|
161
|
+
)
|
|
162
|
+
windows.append(self.create_maintenance(chunk))
|
|
163
|
+
return windows
|
|
164
|
+
|
|
102
165
|
def update_maintenance(
|
|
103
166
|
self,
|
|
104
167
|
maintenance_id: str,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.8.1"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Main hyp CLI application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from hyperping._version import __version__
|
|
10
|
+
from hyperping.cli._incidents import incident_app
|
|
11
|
+
from hyperping.cli._monitors import monitor_app
|
|
12
|
+
from hyperping.cli._statuspages import statuspage_app
|
|
13
|
+
from hyperping.cli._tenant import tenant_app
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="hyp",
|
|
17
|
+
help="Hyperping CLI: manage monitors, incidents, and status pages.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app.add_typer(monitor_app, name="monitor")
|
|
22
|
+
app.add_typer(incident_app, name="incident")
|
|
23
|
+
app.add_typer(statuspage_app, name="statuspage")
|
|
24
|
+
app.add_typer(tenant_app, name="tenant")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version_callback(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(f"hyp {__version__}")
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def main(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
api_key: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
typer.Option("--api-key", envvar="HYPERPING_API_KEY", help="Hyperping API key."),
|
|
39
|
+
] = None,
|
|
40
|
+
json_output: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option("--json", help="Output as JSON."),
|
|
43
|
+
] = False,
|
|
44
|
+
version: Annotated[
|
|
45
|
+
bool | None,
|
|
46
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
|
|
47
|
+
] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Hyperping CLI."""
|
|
50
|
+
ctx.ensure_object(dict)
|
|
51
|
+
ctx.obj["api_key"] = api_key
|
|
52
|
+
ctx.obj["json"] = json_output
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""CLI client factory and API key resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from hyperping.client import HyperpingClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_client(api_key: str | None) -> HyperpingClient:
|
|
13
|
+
"""Resolve API key and return a configured client.
|
|
14
|
+
|
|
15
|
+
Resolution order: explicit flag value, then HYPERPING_API_KEY env var.
|
|
16
|
+
Raises typer.BadParameter if no key is available.
|
|
17
|
+
"""
|
|
18
|
+
key = api_key or os.environ.get("HYPERPING_API_KEY")
|
|
19
|
+
if not key:
|
|
20
|
+
raise typer.BadParameter(
|
|
21
|
+
"API key required. Pass --api-key or set HYPERPING_API_KEY.",
|
|
22
|
+
param_hint="--api-key",
|
|
23
|
+
)
|
|
24
|
+
return HyperpingClient(api_key=key)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""hyp incident subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from hyperping.cli._config import get_client
|
|
10
|
+
from hyperping.cli._output import print_detail, print_error, print_success, print_table
|
|
11
|
+
from hyperping.exceptions import HyperpingAPIError
|
|
12
|
+
from hyperping.models import Incident, IncidentCreate, IncidentType, LocalizedText
|
|
13
|
+
|
|
14
|
+
incident_app = typer.Typer(name="incident", help="Manage incidents.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _incident_row(i: Incident) -> list[object]:
|
|
18
|
+
return [i.uuid, i.title_en, i.type, str(i.is_resolved), i.date or ""]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@incident_app.command("list")
|
|
22
|
+
def incident_list(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
status: Annotated[str | None, typer.Option("--status", help="Filter by status")] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List incidents."""
|
|
27
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
28
|
+
json_mode: bool = ctx.obj.get("json", False)
|
|
29
|
+
client = get_client(api_key)
|
|
30
|
+
incidents = client.list_incidents(status=status)
|
|
31
|
+
columns = ["uuid", "title", "type", "resolved", "date"]
|
|
32
|
+
rows = [_incident_row(i) for i in incidents]
|
|
33
|
+
print_table("Incidents", columns, rows, json_mode)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@incident_app.command("create")
|
|
37
|
+
def incident_create(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
title: Annotated[str, typer.Option("--title", help="Incident title (English)")],
|
|
40
|
+
text: Annotated[str, typer.Option("--text", help="Incident message (English)")],
|
|
41
|
+
statuspage: Annotated[str, typer.Option("--statuspage", help="Status page UUID")],
|
|
42
|
+
incident_type: Annotated[
|
|
43
|
+
str | None, typer.Option("--type", help="Incident type: incident or outage")
|
|
44
|
+
] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Create a new incident."""
|
|
47
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
48
|
+
json_mode: bool = ctx.obj.get("json", False)
|
|
49
|
+
client = get_client(api_key)
|
|
50
|
+
payload = IncidentCreate(
|
|
51
|
+
title=LocalizedText.from_string(title),
|
|
52
|
+
text=LocalizedText.from_string(text),
|
|
53
|
+
type=IncidentType(incident_type) if incident_type else IncidentType.INCIDENT,
|
|
54
|
+
statuspages=[statuspage],
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
incident = client.create_incident(payload)
|
|
58
|
+
except HyperpingAPIError as exc:
|
|
59
|
+
print_error(str(exc))
|
|
60
|
+
raise typer.Exit(code=1) from exc
|
|
61
|
+
fields = {
|
|
62
|
+
"uuid": incident.uuid,
|
|
63
|
+
"title": incident.title_en,
|
|
64
|
+
"type": incident.type,
|
|
65
|
+
"resolved": incident.is_resolved,
|
|
66
|
+
"date": incident.date or "",
|
|
67
|
+
}
|
|
68
|
+
print_detail("Incident", fields, json_mode)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@incident_app.command("resolve")
|
|
72
|
+
def incident_resolve(
|
|
73
|
+
ctx: typer.Context,
|
|
74
|
+
incident_id: Annotated[str, typer.Argument(help="Incident UUID")],
|
|
75
|
+
message: Annotated[
|
|
76
|
+
str | None, typer.Option("--message", help="Resolution message")
|
|
77
|
+
] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Resolve an incident."""
|
|
80
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
81
|
+
client = get_client(api_key)
|
|
82
|
+
try:
|
|
83
|
+
incident = client.resolve_incident(incident_id, message=message)
|
|
84
|
+
except HyperpingAPIError as exc:
|
|
85
|
+
print_error(str(exc))
|
|
86
|
+
raise typer.Exit(code=1) from exc
|
|
87
|
+
print_success(f"Incident {incident.uuid} resolved.")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""hyp monitor subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from hyperping.cli._config import get_client
|
|
10
|
+
from hyperping.cli._output import print_detail, print_error, print_success, print_table
|
|
11
|
+
from hyperping.exceptions import HyperpingAPIError
|
|
12
|
+
from hyperping.models import Monitor
|
|
13
|
+
|
|
14
|
+
monitor_app = typer.Typer(name="monitor", help="Manage monitors.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _monitor_status(m: Monitor) -> str:
|
|
18
|
+
if m.paused:
|
|
19
|
+
return "paused"
|
|
20
|
+
return "down" if m.down else "up"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@monitor_app.command("list")
|
|
24
|
+
def monitor_list(ctx: typer.Context) -> None:
|
|
25
|
+
"""List all monitors."""
|
|
26
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
27
|
+
json_mode: bool = ctx.obj.get("json", False)
|
|
28
|
+
client = get_client(api_key)
|
|
29
|
+
monitors = client.list_monitors()
|
|
30
|
+
columns = ["uuid", "name", "url", "protocol", "status"]
|
|
31
|
+
rows = [[m.uuid, m.name, m.url, m.protocol, _monitor_status(m)] for m in monitors]
|
|
32
|
+
print_table("Monitors", columns, rows, json_mode)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@monitor_app.command("get")
|
|
36
|
+
def monitor_get(
|
|
37
|
+
ctx: typer.Context,
|
|
38
|
+
monitor_id: Annotated[str, typer.Argument(help="Monitor UUID")],
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Show a single monitor."""
|
|
41
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
42
|
+
json_mode: bool = ctx.obj.get("json", False)
|
|
43
|
+
client = get_client(api_key)
|
|
44
|
+
try:
|
|
45
|
+
m = client.get_monitor(monitor_id)
|
|
46
|
+
except HyperpingAPIError as exc:
|
|
47
|
+
print_error(str(exc))
|
|
48
|
+
raise typer.Exit(code=1) from exc
|
|
49
|
+
fields = {
|
|
50
|
+
"uuid": m.uuid,
|
|
51
|
+
"name": m.name,
|
|
52
|
+
"url": m.url,
|
|
53
|
+
"protocol": m.protocol,
|
|
54
|
+
"status": _monitor_status(m),
|
|
55
|
+
"check_frequency": m.check_frequency,
|
|
56
|
+
"regions": ", ".join(m.regions),
|
|
57
|
+
}
|
|
58
|
+
print_detail("Monitor", fields, json_mode)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@monitor_app.command("pause")
|
|
62
|
+
def monitor_pause(
|
|
63
|
+
ctx: typer.Context,
|
|
64
|
+
monitor_id: Annotated[str, typer.Argument(help="Monitor UUID")],
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Pause a monitor."""
|
|
67
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
68
|
+
client = get_client(api_key)
|
|
69
|
+
try:
|
|
70
|
+
m = client.pause_monitor(monitor_id)
|
|
71
|
+
except HyperpingAPIError as exc:
|
|
72
|
+
print_error(str(exc))
|
|
73
|
+
raise typer.Exit(code=1) from exc
|
|
74
|
+
print_success(f"Monitor {m.uuid} paused (status: {_monitor_status(m)})")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@monitor_app.command("resume")
|
|
78
|
+
def monitor_resume(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
monitor_id: Annotated[str, typer.Argument(help="Monitor UUID")],
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Resume a paused monitor."""
|
|
83
|
+
api_key: str | None = ctx.obj.get("api_key")
|
|
84
|
+
client = get_client(api_key)
|
|
85
|
+
try:
|
|
86
|
+
m = client.resume_monitor(monitor_id)
|
|
87
|
+
except HyperpingAPIError as exc:
|
|
88
|
+
print_error(str(exc))
|
|
89
|
+
raise typer.Exit(code=1) from exc
|
|
90
|
+
print_success(f"Monitor {m.uuid} resumed (status: {_monitor_status(m)})")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CLI output formatters: rich tables, panels, and JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
_console = Console()
|
|
14
|
+
_err_console = Console(stderr=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def print_table(
|
|
18
|
+
title: str,
|
|
19
|
+
columns: list[str],
|
|
20
|
+
rows: list[list[Any]],
|
|
21
|
+
json_mode: bool,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Render a list of rows as a rich table or a JSON array."""
|
|
24
|
+
if json_mode:
|
|
25
|
+
data = [dict(zip(columns, row)) for row in rows]
|
|
26
|
+
typer.echo(json.dumps(data, indent=2, default=str))
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
table = Table(title=title, show_header=True, header_style="bold")
|
|
30
|
+
for col in columns:
|
|
31
|
+
table.add_column(col)
|
|
32
|
+
for row in rows:
|
|
33
|
+
table.add_row(*[str(v) if v is not None else "" for v in row])
|
|
34
|
+
_console.print(table)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def print_detail(
|
|
38
|
+
title: str,
|
|
39
|
+
fields: dict[str, Any],
|
|
40
|
+
json_mode: bool,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Render a single record as a rich panel or a JSON object."""
|
|
43
|
+
if json_mode:
|
|
44
|
+
typer.echo(json.dumps(fields, indent=2, default=str))
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
lines = "\n".join(f"[bold]{k}[/bold]: {v}" for k, v in fields.items())
|
|
48
|
+
_console.print(Panel(lines, title=title))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def print_success(message: str) -> None:
|
|
52
|
+
"""Print a success message."""
|
|
53
|
+
_console.print(f"[green]{message}[/green]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_error(message: str) -> None:
|
|
57
|
+
"""Print an error message to stderr."""
|
|
58
|
+
_err_console.print(f"[red]{message}[/red]")
|