hyperping 1.8.0__tar.gz → 1.9.0__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.9.0}/CHANGELOG.md +75 -0
  2. {hyperping-1.8.0 → hyperping-1.9.0}/PKG-INFO +5 -1
  3. {hyperping-1.8.0 → hyperping-1.9.0}/pyproject.toml +10 -1
  4. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/__init__.py +8 -0
  5. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_incidents_mixin.py +49 -0
  6. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_maintenance_mixin.py +51 -0
  7. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_mcp_client.py +50 -1
  8. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_incidents_mixin.py +65 -1
  9. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_maintenance_mixin.py +79 -0
  10. hyperping-1.9.0/src/hyperping/_version.py +1 -0
  11. hyperping-1.9.0/src/hyperping/cli/_app.py +52 -0
  12. hyperping-1.9.0/src/hyperping/cli/_config.py +24 -0
  13. hyperping-1.9.0/src/hyperping/cli/_incidents.py +87 -0
  14. hyperping-1.9.0/src/hyperping/cli/_monitors.py +90 -0
  15. hyperping-1.9.0/src/hyperping/cli/_output.py +58 -0
  16. hyperping-1.9.0/src/hyperping/cli/_statuspages.py +57 -0
  17. hyperping-1.9.0/src/hyperping/cli/_tenant.py +75 -0
  18. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/exceptions.py +31 -0
  19. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/mcp_client.py +50 -1
  20. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/__init__.py +7 -1
  21. hyperping-1.9.0/src/hyperping/models/_integration_models.py +27 -0
  22. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_oncall_models.py +34 -3
  23. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_statuspage_models.py +12 -1
  24. hyperping-1.9.0/tests/unit/__init__.py +0 -0
  25. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_async_mcp_client.py +151 -10
  26. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_async_preexisting.py +71 -0
  27. hyperping-1.9.0/tests/unit/test_cli_config.py +28 -0
  28. hyperping-1.9.0/tests/unit/test_cli_incidents.py +121 -0
  29. hyperping-1.9.0/tests/unit/test_cli_monitors.py +95 -0
  30. hyperping-1.9.0/tests/unit/test_cli_statuspages.py +67 -0
  31. hyperping-1.9.0/tests/unit/test_cli_tenant.py +90 -0
  32. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_incidents.py +84 -1
  33. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_maintenance.py +150 -1
  34. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_mcp_client.py +145 -10
  35. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_statuspages.py +24 -0
  36. hyperping-1.8.0/src/hyperping/_version.py +0 -1
  37. hyperping-1.8.0/src/hyperping/models/_integration_models.py +0 -14
  38. {hyperping-1.8.0 → hyperping-1.9.0}/.gitignore +0 -0
  39. {hyperping-1.8.0 → hyperping-1.9.0}/CONTRIBUTING.md +0 -0
  40. {hyperping-1.8.0 → hyperping-1.9.0}/LICENSE +0 -0
  41. {hyperping-1.8.0 → hyperping-1.9.0}/README.md +0 -0
  42. {hyperping-1.8.0 → hyperping-1.9.0}/SECURITY.md +0 -0
  43. {hyperping-1.8.0 → hyperping-1.9.0}/scripts/verify_endpoints.py +0 -0
  44. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_client.py +0 -0
  45. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  46. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_mcp_transport.py +0 -0
  47. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  48. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_outages_mixin.py +0 -0
  49. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  50. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_circuit_breaker.py +0 -0
  51. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  52. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_internals.py +0 -0
  53. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_mcp_transport.py +0 -0
  54. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_monitor_constants.py +0 -0
  55. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_monitors_mixin.py +0 -0
  56. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_outages_mixin.py +0 -0
  57. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_protocols.py +0 -0
  58. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_statuspages_mixin.py +0 -0
  59. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/_utils.py +0 -0
  60. {hyperping-1.8.0/tests → hyperping-1.9.0/src/hyperping/cli}/__init__.py +0 -0
  61. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/client.py +0 -0
  62. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/endpoints.py +0 -0
  63. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  64. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_incident_models.py +0 -0
  65. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_maintenance_models.py +0 -0
  66. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_monitor_models.py +0 -0
  67. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_observability_models.py +0 -0
  68. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_outage_models.py +0 -0
  69. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/models/_reporting_models.py +0 -0
  70. {hyperping-1.8.0 → hyperping-1.9.0}/src/hyperping/py.typed +0 -0
  71. {hyperping-1.8.0/tests/unit → hyperping-1.9.0/tests}/__init__.py +0 -0
  72. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/conftest.py +0 -0
  73. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_async_client.py +0 -0
  74. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_async_mcp_transport.py +0 -0
  75. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_client_coverage.py +0 -0
  76. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_healthchecks.py +0 -0
  77. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_mcp_transport.py +0 -0
  78. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_monitors.py +0 -0
  79. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_outages.py +0 -0
  80. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_pagination.py +0 -0
  81. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  82. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_sdk_surface.py +0 -0
  83. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_security_base_url.py +0 -0
  84. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_security_breaker_cap.py +0 -0
  85. {hyperping-1.8.0 → hyperping-1.9.0}/tests/unit/test_security_exception_redaction.py +0 -0
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.9.0] - 2026-07-14
11
+
12
+ Corrective minor release. It renames the bundled console script (breaking) and
13
+ retroactively documents breaking changes that shipped mislabeled in 1.8.0/1.8.1
14
+ (see Upgrade Notes). If you pinned `~=1.8.0` or `~=1.8.1` you already received
15
+ those breaking changes silently; this entry explains them.
16
+
17
+ ### BREAKING
18
+
19
+ - **The bundled console script is renamed `hyp` → `hyperping`.** 1.8.1 introduced
20
+ a `hyp` entry point for the SDK's CLI. `hyp` is the long-standing command of the
21
+ separate `hyperping-automation` tool; when both are installed the SDK's script
22
+ silently shadowed it (last-writer-wins on `bin/hyp`), breaking that tool's
23
+ commands and exposing the SDK's unguarded write commands under a familiar name.
24
+ Invoke the SDK CLI as `hyperping …` now. (Removing/renaming a console script is
25
+ a breaking change; it is the reason this is 1.9.0, not 1.8.2.)
26
+
27
+ ### Fixed
28
+
29
+ - **`create_maintenance_windows` / `create_incidents` now surface partial failures.**
30
+ If a later chunk fails after earlier objects were created, they raise
31
+ `HyperpingPartialBatchError` carrying the already-created objects (`.created`,
32
+ `.completed`, `.total`) instead of discarding them, so callers can record or
33
+ clean up rather than orphaning windows/incidents silently.
34
+
35
+ ### Added
36
+
37
+ - **`create_incidents()`** (sync + async): splits a broadcast incident's status
38
+ pages into chunks of at most `MAX_STATUSPAGES_PER_INCIDENT` (51), mirroring
39
+ `create_maintenance_windows`. `create_incident()` now raises
40
+ `HyperpingValidationError` above the cap instead of silently failing to persist.
41
+ NOTE: the 51 cap for incidents is assumed identical to maintenance (same
42
+ status-page attachment path) and has not been independently measured against
43
+ the live API.
44
+ - **`HyperpingPartialBatchError`** and **`MAX_STATUSPAGES_PER_INCIDENT`** exported
45
+ from the package root.
46
+
47
+ ### Upgrade Notes (breaking changes that shipped mislabeled in 1.8.0 / 1.8.1)
48
+
49
+ These are not new in 1.9.0; they are documented here because 1.8.0/1.8.1 changed
50
+ them without an upgrade note, which is why consumers were caught out:
51
+
52
+ - **1.8.0** reconciled the `Integration`, `EscalationPolicy`, and `TeamMember`
53
+ models against the production API: `Integration.active` was **removed** and the
54
+ integration-type field key is now **`channel`** (was `type`); a new
55
+ `EscalationStep` shape was introduced. Any code reading `Integration.active` or
56
+ sending `type=` breaks.
57
+ - **1.8.1** added the `hyp` console script (renamed here) and the status-page /
58
+ maintenance fixes; it was released as a patch despite the CLI addition.
59
+
60
+ Guidance: pin `hyperping>=1.9.0,<2` and, if you consume the CLI, use `hyperping`.
61
+
62
+ ## [1.8.1] - 2026-07-14
63
+
64
+ ### Fixed
65
+
66
+ - **`list_status_pages()` returned an empty list against the live v2 API.** The
67
+ `StatusPage` model required a `subdomain` field, but the API returns the hosted
68
+ subdomain under the key `hostedsubdomain` (and omits it entirely for custom-domain
69
+ pages). Every record failed validation and was silently skipped. `subdomain` is now
70
+ optional and aliased to `hostedsubdomain`; `hostname` and `url` are also parsed.
71
+
72
+ ### Added
73
+
74
+ - **`create_maintenance()` now guards against the silent status-page overflow.**
75
+ Hyperping's v1 maintenance-windows API accepts a create with more than 51 status
76
+ pages (returns a uuid) but never persists the window. `create_maintenance` now raises
77
+ `HyperpingValidationError` when `statuspages` exceeds `MAX_STATUSPAGES_PER_MAINTENANCE`
78
+ (51), instead of returning a phantom window. Split larger sets across multiple windows.
79
+ - **`create_maintenance_windows()`** (sync + async): convenience helper that splits a
80
+ large `statuspages` list into consecutive windows of at most 51 pages, so callers can
81
+ cover more pages than one window allows. Each window carries the full monitor set (the
82
+ API requires at least one monitor per window). `MAX_STATUSPAGES_PER_MAINTENANCE` is
83
+ exported for callers that chunk themselves (e.g. a broadcast-to-all-tenants command).
84
+
10
85
  ## [1.8.0] - 2026-05-31
11
86
 
12
87
  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.9.0
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.9.0"
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,15 @@ 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
+ # Console script is 'hyperping' (NOT 'hyp'): 'hyp' collides with the long-lived
48
+ # hyperping-automation CLI (hyp_status) and, when both are installed, silently
49
+ # shadows it. See CHANGELOG 1.9.0 BREAKING notes.
50
+ hyperping = "hyperping.cli._app:app"
51
+
43
52
  [project.urls]
44
53
  Homepage = "https://github.com/develeap/hyperping-python"
45
54
  Documentation = "https://github.com/develeap/hyperping-python#readme"
@@ -16,6 +16,8 @@ Quick start::
16
16
 
17
17
  from hyperping._async_client import AsyncHyperpingClient
18
18
  from hyperping._async_mcp_client import AsyncHyperpingMcpClient
19
+ from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
20
+ from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
19
21
  from hyperping._version import __version__
20
22
  from hyperping.client import (
21
23
  CircuitBreaker,
@@ -34,6 +36,7 @@ from hyperping.exceptions import (
34
36
  HyperpingAPIError,
35
37
  HyperpingAuthError,
36
38
  HyperpingNotFoundError,
39
+ HyperpingPartialBatchError,
37
40
  HyperpingRateLimitError,
38
41
  HyperpingValidationError,
39
42
  )
@@ -44,6 +47,7 @@ from hyperping.models import (
44
47
  AlertHistory,
45
48
  DnsRecordType,
46
49
  EscalationPolicy,
50
+ EscalationStep,
47
51
  Healthcheck,
48
52
  HealthcheckCreate,
49
53
  HealthcheckUpdate,
@@ -116,6 +120,7 @@ __all__ = [
116
120
  "APIVersion",
117
121
  # Exceptions
118
122
  "HyperpingAPIError",
123
+ "HyperpingPartialBatchError",
119
124
  "HyperpingAuthError",
120
125
  "HyperpingNotFoundError",
121
126
  "HyperpingRateLimitError",
@@ -149,6 +154,8 @@ __all__ = [
149
154
  "IncidentStatus",
150
155
  "IncidentUpdateCreate",
151
156
  # Maintenance
157
+ "MAX_STATUSPAGES_PER_INCIDENT",
158
+ "MAX_STATUSPAGES_PER_MAINTENANCE",
152
159
  "Maintenance",
153
160
  "MaintenanceCreate",
154
161
  "MaintenanceUpdate",
@@ -168,6 +175,7 @@ __all__ = [
168
175
  "ProbeLogResponse",
169
176
  # On-call
170
177
  "OnCallSchedule",
178
+ "EscalationStep",
171
179
  "EscalationPolicy",
172
180
  "TeamMember",
173
181
  # Integrations
@@ -9,9 +9,15 @@ from __future__ import annotations
9
9
  import logging
10
10
  from datetime import UTC, datetime
11
11
 
12
+ from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
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 (
17
+ HyperpingAPIError,
18
+ HyperpingPartialBatchError,
19
+ HyperpingValidationError,
20
+ )
15
21
  from hyperping.models import (
16
22
  AddIncidentUpdateRequest,
17
23
  Incident,
@@ -81,6 +87,15 @@ class AsyncIncidentsMixin(_AsyncClientProtocol):
81
87
  v3 API returns {"message": "...", "uuid": "..."} on create,
82
88
  not the full incident object. The full incident is fetched after creation.
83
89
  """
90
+ n_statuspages = len(incident.statuspages or [])
91
+ if n_statuspages > MAX_STATUSPAGES_PER_INCIDENT:
92
+ raise HyperpingValidationError(
93
+ f"An incident can reference at most {MAX_STATUSPAGES_PER_INCIDENT} "
94
+ f"status pages, but {n_statuspages} were supplied. Above this limit "
95
+ f"Hyperping's API is expected to accept the create (returns a uuid) but "
96
+ f"silently fail to persist it. Use create_incidents() to split the "
97
+ f"status pages across multiple incidents."
98
+ )
84
99
  payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
85
100
  response = expect_dict(
86
101
  await self._request("POST", Endpoint.INCIDENTS, json=payload),
@@ -90,6 +105,40 @@ class AsyncIncidentsMixin(_AsyncClientProtocol):
90
105
  return await self.get_incident(response["uuid"])
91
106
  return Incident.model_validate(response)
92
107
 
108
+ async def create_incidents(
109
+ self,
110
+ incident: IncidentCreate,
111
+ *,
112
+ chunk_size: int = MAX_STATUSPAGES_PER_INCIDENT,
113
+ ) -> list[Incident]:
114
+ """Async mirror of
115
+ :meth:`~hyperping._incidents_mixin.IncidentsMixin.create_incidents`.
116
+ """
117
+ if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_INCIDENT:
118
+ raise HyperpingValidationError(
119
+ f"chunk_size must be between 1 and "
120
+ f"{MAX_STATUSPAGES_PER_INCIDENT}, got {chunk_size}."
121
+ )
122
+ pages = list(incident.statuspages or [])
123
+ if len(pages) <= chunk_size:
124
+ return [await self.create_incident(incident)]
125
+ chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
126
+ created: list[Incident] = []
127
+ for idx, chunk_pages in enumerate(chunks):
128
+ chunk = incident.model_copy(update={"statuspages": chunk_pages})
129
+ try:
130
+ created.append(await self.create_incident(chunk))
131
+ except HyperpingAPIError as exc:
132
+ raise HyperpingPartialBatchError(
133
+ f"create_incidents failed on incident {idx + 1} of "
134
+ f"{len(chunks)}: {exc}. {len(created)} incident(s) were already "
135
+ f"created and remain live.",
136
+ created=created,
137
+ completed=len(created),
138
+ total=len(chunks),
139
+ ) from exc
140
+ return created
141
+
93
142
  async def update_incident(
94
143
  self,
95
144
  incident_id: str,
@@ -9,9 +9,15 @@ 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 (
17
+ HyperpingAPIError,
18
+ HyperpingPartialBatchError,
19
+ HyperpingValidationError,
20
+ )
15
21
  from hyperping.models import (
16
22
  Maintenance,
17
23
  MaintenanceCreate,
@@ -82,6 +88,15 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
82
88
  v1 API returns {"uuid": "..."} on create, not the full maintenance object.
83
89
  The full maintenance window is fetched after creation.
84
90
  """
91
+ n_statuspages = len(maintenance.statuspages or [])
92
+ if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
93
+ raise HyperpingValidationError(
94
+ f"A maintenance window can reference at most "
95
+ f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
96
+ f"were supplied. Above this limit Hyperping's API accepts the create "
97
+ f"(returns a uuid) but silently fails to persist the window. Split the "
98
+ f"status pages across multiple maintenance windows."
99
+ )
85
100
  payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
86
101
  response = expect_dict(
87
102
  await self._request("POST", Endpoint.MAINTENANCE, json=payload),
@@ -91,6 +106,42 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
91
106
  return await self.get_maintenance(response["uuid"])
92
107
  return Maintenance.model_validate(response)
93
108
 
109
+ async def create_maintenance_windows(
110
+ self,
111
+ maintenance: MaintenanceCreate,
112
+ *,
113
+ chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
114
+ ) -> list[Maintenance]:
115
+ """Create one or more windows, splitting status pages into chunks.
116
+
117
+ Async mirror of
118
+ :meth:`~hyperping._maintenance_mixin.MaintenanceMixin.create_maintenance_windows`.
119
+ """
120
+ if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
121
+ raise HyperpingValidationError(
122
+ f"chunk_size must be between 1 and "
123
+ f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
124
+ )
125
+ pages = list(maintenance.statuspages or [])
126
+ if len(pages) <= chunk_size:
127
+ return [await self.create_maintenance(maintenance)]
128
+ chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
129
+ windows: list[Maintenance] = []
130
+ for idx, chunk_pages in enumerate(chunks):
131
+ chunk = maintenance.model_copy(update={"statuspages": chunk_pages})
132
+ try:
133
+ windows.append(await self.create_maintenance(chunk))
134
+ except HyperpingAPIError as exc:
135
+ raise HyperpingPartialBatchError(
136
+ f"create_maintenance_windows failed on window {idx + 1} of "
137
+ f"{len(chunks)}: {exc}. {len(windows)} window(s) were already "
138
+ f"created and remain live.",
139
+ created=windows,
140
+ completed=len(windows),
141
+ total=len(chunks),
142
+ ) from exc
143
+ return windows
144
+
94
145
  async def update_maintenance(
95
146
  self,
96
147
  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,7 +12,11 @@ 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 HyperpingAPIError
15
+ from hyperping.exceptions import (
16
+ HyperpingAPIError,
17
+ HyperpingPartialBatchError,
18
+ HyperpingValidationError,
19
+ )
16
20
  from hyperping.models import (
17
21
  AddIncidentUpdateRequest, # canonical name (M18)
18
22
  Incident,
@@ -24,6 +28,14 @@ from hyperping.models import (
24
28
 
25
29
  logger = logging.getLogger(__name__)
26
30
 
31
+ # Incidents attach status pages via the same mechanism as maintenance windows,
32
+ # which the API caps at 51 per request (see MAX_STATUSPAGES_PER_MAINTENANCE):
33
+ # beyond the cap the create is accepted but silently not persisted. The
34
+ # incident cap has NOT been independently verified against the live API; it is
35
+ # assumed identical because the status-page attachment is the same backend
36
+ # path. Adjust if the incident endpoint is later measured to differ.
37
+ MAX_STATUSPAGES_PER_INCIDENT = 51
38
+
27
39
 
28
40
  class IncidentsMixin(_ClientProtocol):
29
41
  """Incident-related API operations."""
@@ -87,6 +99,15 @@ class IncidentsMixin(_ClientProtocol):
87
99
  v3 API returns {"message": "...", "uuid": "..."} on create,
88
100
  not the full incident object. The full incident is fetched after creation.
89
101
  """
102
+ n_statuspages = len(incident.statuspages or [])
103
+ if n_statuspages > MAX_STATUSPAGES_PER_INCIDENT:
104
+ raise HyperpingValidationError(
105
+ f"An incident can reference at most {MAX_STATUSPAGES_PER_INCIDENT} "
106
+ f"status pages, but {n_statuspages} were supplied. Above this limit "
107
+ f"Hyperping's API is expected to accept the create (returns a uuid) but "
108
+ f"silently fail to persist it. Use create_incidents() to split the "
109
+ f"status pages across multiple incidents."
110
+ )
90
111
  payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
91
112
  response = expect_dict(
92
113
  self._request("POST", Endpoint.INCIDENTS, json=payload),
@@ -98,6 +119,49 @@ class IncidentsMixin(_ClientProtocol):
98
119
  return self.get_incident(response["uuid"])
99
120
  return Incident.model_validate(response)
100
121
 
122
+ def create_incidents(
123
+ self,
124
+ incident: IncidentCreate,
125
+ *,
126
+ chunk_size: int = MAX_STATUSPAGES_PER_INCIDENT,
127
+ ) -> list[Incident]:
128
+ """Create one or more incidents, splitting status pages into chunks.
129
+
130
+ Mirrors :meth:`create_maintenance_windows`: a broadcast incident that
131
+ targets more status pages than the per-request cap is split into
132
+ consecutive incidents of at most ``chunk_size`` pages. Because the page
133
+ sets are disjoint, each status page still shows exactly one incident.
134
+
135
+ Returns the created incidents in page order (a single incident when the
136
+ pages fit in one chunk). Raises :class:`HyperpingValidationError` for a
137
+ bad ``chunk_size`` and :class:`HyperpingPartialBatchError` if a later
138
+ chunk fails after earlier incidents were already created.
139
+ """
140
+ if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_INCIDENT:
141
+ raise HyperpingValidationError(
142
+ f"chunk_size must be between 1 and "
143
+ f"{MAX_STATUSPAGES_PER_INCIDENT}, got {chunk_size}."
144
+ )
145
+ pages = list(incident.statuspages or [])
146
+ if len(pages) <= chunk_size:
147
+ return [self.create_incident(incident)]
148
+ chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
149
+ created: list[Incident] = []
150
+ for idx, chunk_pages in enumerate(chunks):
151
+ chunk = incident.model_copy(update={"statuspages": chunk_pages})
152
+ try:
153
+ created.append(self.create_incident(chunk))
154
+ except HyperpingAPIError as exc:
155
+ raise HyperpingPartialBatchError(
156
+ f"create_incidents failed on incident {idx + 1} of "
157
+ f"{len(chunks)}: {exc}. {len(created)} incident(s) were already "
158
+ f"created and remain live.",
159
+ created=created,
160
+ completed=len(created),
161
+ total=len(chunks),
162
+ ) from exc
163
+ return created
164
+
101
165
  def update_incident(
102
166
  self,
103
167
  incident_id: str,
@@ -12,12 +12,26 @@ 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 (
16
+ HyperpingAPIError,
17
+ HyperpingPartialBatchError,
18
+ HyperpingValidationError,
19
+ )
15
20
  from hyperping.models import (
16
21
  Maintenance,
17
22
  MaintenanceCreate,
18
23
  MaintenanceUpdate,
19
24
  )
20
25
 
26
+ # Hyperping's v1 maintenance-windows API silently drops a create when the
27
+ # ``statuspages`` array exceeds this many entries: the POST still returns a
28
+ # ``{"uuid": ...}`` but the window is never persisted (the follow-up GET 404s
29
+ # and it is absent from the list). Empirically the cutoff is 51 (51 persists,
30
+ # 52+ vanishes), verified against the live API on 2026-07-14. Guard the create
31
+ # so callers get a clear error instead of a phantom window; split larger sets
32
+ # across multiple windows.
33
+ MAX_STATUSPAGES_PER_MAINTENANCE = 51
34
+
21
35
  logger = logging.getLogger(__name__)
22
36
 
23
37
 
@@ -88,6 +102,15 @@ class MaintenanceMixin(_ClientProtocol):
88
102
  v1 API returns {"uuid": "..."} on create, not the full maintenance object.
89
103
  The full maintenance window is fetched after creation.
90
104
  """
105
+ n_statuspages = len(maintenance.statuspages or [])
106
+ if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
107
+ raise HyperpingValidationError(
108
+ f"A maintenance window can reference at most "
109
+ f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
110
+ f"were supplied. Above this limit Hyperping's API accepts the create "
111
+ f"(returns a uuid) but silently fails to persist the window. Split the "
112
+ f"status pages across multiple maintenance windows."
113
+ )
91
114
  payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
92
115
  response = expect_dict(
93
116
  self._request("POST", Endpoint.MAINTENANCE, json=payload),
@@ -99,6 +122,62 @@ class MaintenanceMixin(_ClientProtocol):
99
122
  return self.get_maintenance(response["uuid"])
100
123
  return Maintenance.model_validate(response)
101
124
 
125
+ def create_maintenance_windows(
126
+ self,
127
+ maintenance: MaintenanceCreate,
128
+ *,
129
+ chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
130
+ ) -> list[Maintenance]:
131
+ """Create one or more windows, splitting status pages into chunks.
132
+
133
+ Hyperping silently fails to persist a window with more than
134
+ :data:`MAX_STATUSPAGES_PER_MAINTENANCE` status pages (see
135
+ :meth:`create_maintenance`). This helper splits a large ``statuspages``
136
+ list into consecutive windows of at most ``chunk_size`` pages so every
137
+ page is covered. Each window carries the full ``monitors`` set (the API
138
+ rejects a window with no monitors); overlapping monitor coverage across
139
+ the windows is harmless.
140
+
141
+ Args:
142
+ maintenance: Creation data; ``statuspages`` may exceed the limit.
143
+ chunk_size: Max status pages per window (1..
144
+ :data:`MAX_STATUSPAGES_PER_MAINTENANCE`).
145
+
146
+ Returns:
147
+ The created windows in page order (a single window when the pages
148
+ fit in one chunk).
149
+
150
+ Raises:
151
+ HyperpingValidationError: If ``chunk_size`` is out of range.
152
+ """
153
+ if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
154
+ raise HyperpingValidationError(
155
+ f"chunk_size must be between 1 and "
156
+ f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
157
+ )
158
+ pages = list(maintenance.statuspages or [])
159
+ if len(pages) <= chunk_size:
160
+ return [self.create_maintenance(maintenance)]
161
+ chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
162
+ windows: list[Maintenance] = []
163
+ for idx, chunk_pages in enumerate(chunks):
164
+ chunk = maintenance.model_copy(update={"statuspages": chunk_pages})
165
+ try:
166
+ windows.append(self.create_maintenance(chunk))
167
+ except HyperpingAPIError as exc:
168
+ # Earlier windows are already live and are NOT rolled back; hand
169
+ # them back so the caller can record or clean them up rather than
170
+ # orphaning them silently.
171
+ raise HyperpingPartialBatchError(
172
+ f"create_maintenance_windows failed on window {idx + 1} of "
173
+ f"{len(chunks)}: {exc}. {len(windows)} window(s) were already "
174
+ f"created and remain live.",
175
+ created=windows,
176
+ completed=len(windows),
177
+ total=len(chunks),
178
+ ) from exc
179
+ return windows
180
+
102
181
  def update_maintenance(
103
182
  self,
104
183
  maintenance_id: str,
@@ -0,0 +1 @@
1
+ __version__ = "1.9.0"
@@ -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