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.
Files changed (85) hide show
  1. {hyperping-1.8.0 → hyperping-1.8.1}/CHANGELOG.md +23 -0
  2. {hyperping-1.8.0 → hyperping-1.8.1}/PKG-INFO +5 -1
  3. {hyperping-1.8.0 → hyperping-1.8.1}/pyproject.toml +7 -1
  4. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/__init__.py +4 -0
  5. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_maintenance_mixin.py +38 -0
  6. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_client.py +50 -1
  7. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_maintenance_mixin.py +63 -0
  8. hyperping-1.8.1/src/hyperping/_version.py +1 -0
  9. hyperping-1.8.1/src/hyperping/cli/_app.py +52 -0
  10. hyperping-1.8.1/src/hyperping/cli/_config.py +24 -0
  11. hyperping-1.8.1/src/hyperping/cli/_incidents.py +87 -0
  12. hyperping-1.8.1/src/hyperping/cli/_monitors.py +90 -0
  13. hyperping-1.8.1/src/hyperping/cli/_output.py +58 -0
  14. hyperping-1.8.1/src/hyperping/cli/_statuspages.py +57 -0
  15. hyperping-1.8.1/src/hyperping/cli/_tenant.py +75 -0
  16. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/mcp_client.py +50 -1
  17. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/__init__.py +7 -1
  18. hyperping-1.8.1/src/hyperping/models/_integration_models.py +27 -0
  19. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_oncall_models.py +34 -3
  20. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_statuspage_models.py +12 -1
  21. hyperping-1.8.1/tests/unit/__init__.py +0 -0
  22. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_client.py +151 -10
  23. hyperping-1.8.1/tests/unit/test_cli_config.py +28 -0
  24. hyperping-1.8.1/tests/unit/test_cli_incidents.py +121 -0
  25. hyperping-1.8.1/tests/unit/test_cli_monitors.py +95 -0
  26. hyperping-1.8.1/tests/unit/test_cli_statuspages.py +67 -0
  27. hyperping-1.8.1/tests/unit/test_cli_tenant.py +90 -0
  28. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_maintenance.py +74 -1
  29. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_mcp_client.py +145 -10
  30. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_statuspages.py +24 -0
  31. hyperping-1.8.0/src/hyperping/_version.py +0 -1
  32. hyperping-1.8.0/src/hyperping/models/_integration_models.py +0 -14
  33. {hyperping-1.8.0 → hyperping-1.8.1}/.gitignore +0 -0
  34. {hyperping-1.8.0 → hyperping-1.8.1}/CONTRIBUTING.md +0 -0
  35. {hyperping-1.8.0 → hyperping-1.8.1}/LICENSE +0 -0
  36. {hyperping-1.8.0 → hyperping-1.8.1}/README.md +0 -0
  37. {hyperping-1.8.0 → hyperping-1.8.1}/SECURITY.md +0 -0
  38. {hyperping-1.8.0 → hyperping-1.8.1}/scripts/verify_endpoints.py +0 -0
  39. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_client.py +0 -0
  40. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  41. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_incidents_mixin.py +0 -0
  42. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_transport.py +0 -0
  43. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_monitors_mixin.py +0 -0
  44. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_outages_mixin.py +0 -0
  45. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_async_statuspages_mixin.py +0 -0
  46. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_circuit_breaker.py +0 -0
  47. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_healthchecks_mixin.py +0 -0
  48. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_incidents_mixin.py +0 -0
  49. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_internals.py +0 -0
  50. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_mcp_transport.py +0 -0
  51. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_monitor_constants.py +0 -0
  52. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_monitors_mixin.py +0 -0
  53. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_outages_mixin.py +0 -0
  54. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_protocols.py +0 -0
  55. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_statuspages_mixin.py +0 -0
  56. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/_utils.py +0 -0
  57. {hyperping-1.8.0/tests → hyperping-1.8.1/src/hyperping/cli}/__init__.py +0 -0
  58. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/client.py +0 -0
  59. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/endpoints.py +0 -0
  60. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/exceptions.py +0 -0
  61. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_healthcheck_models.py +0 -0
  62. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_incident_models.py +0 -0
  63. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_maintenance_models.py +0 -0
  64. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_monitor_models.py +0 -0
  65. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_observability_models.py +0 -0
  66. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_outage_models.py +0 -0
  67. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/models/_reporting_models.py +0 -0
  68. {hyperping-1.8.0 → hyperping-1.8.1}/src/hyperping/py.typed +0 -0
  69. {hyperping-1.8.0/tests/unit → hyperping-1.8.1/tests}/__init__.py +0 -0
  70. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/conftest.py +0 -0
  71. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_client.py +0 -0
  72. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_transport.py +0 -0
  73. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_async_preexisting.py +0 -0
  74. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_client_coverage.py +0 -0
  75. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_healthchecks.py +0 -0
  76. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_incidents.py +0 -0
  77. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_mcp_transport.py +0 -0
  78. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_monitors.py +0 -0
  79. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_outages.py +0 -0
  80. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_pagination.py +0 -0
  81. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  82. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_sdk_surface.py +0 -0
  83. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_security_base_url.py +0 -0
  84. {hyperping-1.8.0 → hyperping-1.8.1}/tests/unit/test_security_breaker_cap.py +0 -0
  85. {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.0
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.0"
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]")