Skip to content

Commit bdb0530

Browse files
1.9.0: rename CLI script hyp->hyperping; incident chunking/guard; partial-batch errors
BREAKING: the bundled console script is renamed hyp -> hyperping. 'hyp' collides with the hyperping-automation CLI and silently shadowed it when both were installed; use 'hyperping' for the SDK CLI now. - create_incidents() (sync+async): chunk a broadcast incident's status pages to <=51/incident (MAX_STATUSPAGES_PER_INCIDENT); create_incident() guards the cap. - create_maintenance_windows()/create_incidents() raise HyperpingPartialBatchError (carrying the already-created objects) on mid-batch failure instead of orphaning them silently. - CHANGELOG documents the breaking Integration/CLI changes that shipped mislabeled in 1.8.0/1.8.1; this is the corrective minor release. - Tests: incident chunk/guard/partial, maintenance exactly-51 boundary + partial-failure, async chunking. 613 pass, 95% cov, ruff+mypy clean.
1 parent cdb1126 commit bdb0530

12 files changed

Lines changed: 480 additions & 18 deletions

CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

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+
1062
## [1.8.1] - 2026-07-14
1163

1264
### Fixed

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "hyperping"
7-
version = "1.8.1"
7+
version = "1.9.0"
88
description = "Python SDK for the Hyperping uptime monitoring and incident management API"
99
readme = {file = "README.md", content-type = "text/markdown"}
1010
license = {text = "MIT"}
@@ -44,7 +44,10 @@ dev = [
4444
]
4545

4646
[project.scripts]
47-
hyp = "hyperping.cli._app:app"
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"
4851

4952
[project.urls]
5053
Homepage = "https://github.com/develeap/hyperping-python"

src/hyperping/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from hyperping._async_client import AsyncHyperpingClient
1818
from hyperping._async_mcp_client import AsyncHyperpingMcpClient
19+
from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
1920
from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
2021
from hyperping._version import __version__
2122
from hyperping.client import (
@@ -35,6 +36,7 @@
3536
HyperpingAPIError,
3637
HyperpingAuthError,
3738
HyperpingNotFoundError,
39+
HyperpingPartialBatchError,
3840
HyperpingRateLimitError,
3941
HyperpingValidationError,
4042
)
@@ -118,6 +120,7 @@
118120
"APIVersion",
119121
# Exceptions
120122
"HyperpingAPIError",
123+
"HyperpingPartialBatchError",
121124
"HyperpingAuthError",
122125
"HyperpingNotFoundError",
123126
"HyperpingRateLimitError",
@@ -151,6 +154,7 @@
151154
"IncidentStatus",
152155
"IncidentUpdateCreate",
153156
# Maintenance
157+
"MAX_STATUSPAGES_PER_INCIDENT",
154158
"MAX_STATUSPAGES_PER_MAINTENANCE",
155159
"Maintenance",
156160
"MaintenanceCreate",

src/hyperping/_async_incidents_mixin.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
import logging
1010
from datetime import UTC, datetime
1111

12+
from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
1213
from hyperping._protocols import _AsyncClientProtocol
1314
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
1415
from hyperping.endpoints import Endpoint
16+
from hyperping.exceptions import (
17+
HyperpingAPIError,
18+
HyperpingPartialBatchError,
19+
HyperpingValidationError,
20+
)
1521
from hyperping.models import (
1622
AddIncidentUpdateRequest,
1723
Incident,
@@ -81,6 +87,15 @@ async def create_incident(self, incident: IncidentCreate) -> Incident:
8187
v3 API returns {"message": "...", "uuid": "..."} on create,
8288
not the full incident object. The full incident is fetched after creation.
8389
"""
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+
)
8499
payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
85100
response = expect_dict(
86101
await self._request("POST", Endpoint.INCIDENTS, json=payload),
@@ -90,6 +105,40 @@ async def create_incident(self, incident: IncidentCreate) -> Incident:
90105
return await self.get_incident(response["uuid"])
91106
return Incident.model_validate(response)
92107

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+
93142
async def update_incident(
94143
self,
95144
incident_id: str,

src/hyperping/_async_maintenance_mixin.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
from hyperping._protocols import _AsyncClientProtocol
1414
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
1515
from hyperping.endpoints import Endpoint
16-
from hyperping.exceptions import HyperpingValidationError
16+
from hyperping.exceptions import (
17+
HyperpingAPIError,
18+
HyperpingPartialBatchError,
19+
HyperpingValidationError,
20+
)
1721
from hyperping.models import (
1822
Maintenance,
1923
MaintenanceCreate,
@@ -121,12 +125,21 @@ async def create_maintenance_windows(
121125
pages = list(maintenance.statuspages or [])
122126
if len(pages) <= chunk_size:
123127
return [await self.create_maintenance(maintenance)]
128+
chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
124129
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+
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
130143
return windows
131144

132145
async def update_maintenance(

src/hyperping/_incidents_mixin.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from hyperping._protocols import _ClientProtocol
1313
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
1414
from hyperping.endpoints import Endpoint
15-
from hyperping.exceptions import HyperpingAPIError
15+
from hyperping.exceptions import (
16+
HyperpingAPIError,
17+
HyperpingPartialBatchError,
18+
HyperpingValidationError,
19+
)
1620
from hyperping.models import (
1721
AddIncidentUpdateRequest, # canonical name (M18)
1822
Incident,
@@ -24,6 +28,14 @@
2428

2529
logger = logging.getLogger(__name__)
2630

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+
2739

2840
class IncidentsMixin(_ClientProtocol):
2941
"""Incident-related API operations."""
@@ -87,6 +99,15 @@ def create_incident(self, incident: IncidentCreate) -> Incident:
8799
v3 API returns {"message": "...", "uuid": "..."} on create,
88100
not the full incident object. The full incident is fetched after creation.
89101
"""
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+
)
90111
payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
91112
response = expect_dict(
92113
self._request("POST", Endpoint.INCIDENTS, json=payload),
@@ -98,6 +119,49 @@ def create_incident(self, incident: IncidentCreate) -> Incident:
98119
return self.get_incident(response["uuid"])
99120
return Incident.model_validate(response)
100121

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+
101165
def update_incident(
102166
self,
103167
incident_id: str,

src/hyperping/_maintenance_mixin.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from hyperping._protocols import _ClientProtocol
1313
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
1414
from hyperping.endpoints import Endpoint
15-
from hyperping.exceptions import HyperpingValidationError
15+
from hyperping.exceptions import (
16+
HyperpingAPIError,
17+
HyperpingPartialBatchError,
18+
HyperpingValidationError,
19+
)
1620
from hyperping.models import (
1721
Maintenance,
1822
MaintenanceCreate,
@@ -154,12 +158,24 @@ def create_maintenance_windows(
154158
pages = list(maintenance.statuspages or [])
155159
if len(pages) <= chunk_size:
156160
return [self.create_maintenance(maintenance)]
161+
chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
157162
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+
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
163179
return windows
164180

165181
def update_maintenance(

src/hyperping/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.8.1"
1+
__version__ = "1.9.0"

src/hyperping/exceptions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,34 @@ def __init__(
113113
) -> None:
114114
super().__init__(message, **kwargs)
115115
self.validation_errors = validation_errors or []
116+
117+
118+
class HyperpingPartialBatchError(HyperpingAPIError):
119+
"""Raised when a multi-item batch operation fails partway through.
120+
121+
Used by helpers that split one logical request into several API calls
122+
(e.g. :meth:`create_maintenance_windows`, :meth:`create_incidents` when the
123+
status-page list exceeds the per-request cap). If an item fails after
124+
earlier ones succeeded, the already-created objects are NOT rolled back;
125+
they are attached here so the caller can record or clean them up.
126+
127+
Args:
128+
message: Human-readable error description.
129+
created: The objects successfully created before the failure.
130+
completed: How many items succeeded.
131+
total: How many items were attempted in the batch.
132+
**kwargs: Forwarded to :class:`HyperpingAPIError`.
133+
"""
134+
135+
def __init__(
136+
self,
137+
message: str,
138+
created: list[Any] | None = None,
139+
completed: int | None = None,
140+
total: int | None = None,
141+
**kwargs: Any,
142+
) -> None:
143+
super().__init__(message, **kwargs)
144+
self.created = created or []
145+
self.completed = completed if completed is not None else len(self.created)
146+
self.total = total

0 commit comments

Comments
 (0)