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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.9.0] - 2026-07-14

Corrective minor release. It renames the bundled console script (breaking) and
retroactively documents breaking changes that shipped mislabeled in 1.8.0/1.8.1
(see Upgrade Notes). If you pinned `~=1.8.0` or `~=1.8.1` you already received
those breaking changes silently; this entry explains them.

### BREAKING

- **The bundled console script is renamed `hyp` → `hyperping`.** 1.8.1 introduced
a `hyp` entry point for the SDK's CLI. `hyp` is the long-standing command of the
separate `hyperping-automation` tool; when both are installed the SDK's script
silently shadowed it (last-writer-wins on `bin/hyp`), breaking that tool's
commands and exposing the SDK's unguarded write commands under a familiar name.
Invoke the SDK CLI as `hyperping …` now. (Removing/renaming a console script is
a breaking change; it is the reason this is 1.9.0, not 1.8.2.)

### Fixed

- **`create_maintenance_windows` / `create_incidents` now surface partial failures.**
If a later chunk fails after earlier objects were created, they raise
`HyperpingPartialBatchError` carrying the already-created objects (`.created`,
`.completed`, `.total`) instead of discarding them, so callers can record or
clean up rather than orphaning windows/incidents silently.

### Added

- **`create_incidents()`** (sync + async): splits a broadcast incident's status
pages into chunks of at most `MAX_STATUSPAGES_PER_INCIDENT` (51), mirroring
`create_maintenance_windows`. `create_incident()` now raises
`HyperpingValidationError` above the cap instead of silently failing to persist.
NOTE: the 51 cap for incidents is assumed identical to maintenance (same
status-page attachment path) and has not been independently measured against
the live API.
- **`HyperpingPartialBatchError`** and **`MAX_STATUSPAGES_PER_INCIDENT`** exported
from the package root.

### Upgrade Notes (breaking changes that shipped mislabeled in 1.8.0 / 1.8.1)

These are not new in 1.9.0; they are documented here because 1.8.0/1.8.1 changed
them without an upgrade note, which is why consumers were caught out:

- **1.8.0** reconciled the `Integration`, `EscalationPolicy`, and `TeamMember`
models against the production API: `Integration.active` was **removed** and the
integration-type field key is now **`channel`** (was `type`); a new
`EscalationStep` shape was introduced. Any code reading `Integration.active` or
sending `type=` breaks.
- **1.8.1** added the `hyp` console script (renamed here) and the status-page /
maintenance fixes; it was released as a patch despite the CLI addition.

Guidance: pin `hyperping>=1.9.0,<2` and, if you consume the CLI, use `hyperping`.

## [1.8.1] - 2026-07-14

### Fixed
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

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

[project.scripts]
hyp = "hyperping.cli._app:app"
# Console script is 'hyperping' (NOT 'hyp'): 'hyp' collides with the long-lived
# hyperping-automation CLI (hyp_status) and, when both are installed, silently
# shadows it. See CHANGELOG 1.9.0 BREAKING notes.
hyperping = "hyperping.cli._app:app"

[project.urls]
Homepage = "https://github.com/develeap/hyperping-python"
Expand Down
4 changes: 4 additions & 0 deletions src/hyperping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from hyperping._async_client import AsyncHyperpingClient
from hyperping._async_mcp_client import AsyncHyperpingMcpClient
from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
from hyperping._version import __version__
from hyperping.client import (
Expand All @@ -35,6 +36,7 @@
HyperpingAPIError,
HyperpingAuthError,
HyperpingNotFoundError,
HyperpingPartialBatchError,
HyperpingRateLimitError,
HyperpingValidationError,
)
Expand Down Expand Up @@ -118,6 +120,7 @@
"APIVersion",
# Exceptions
"HyperpingAPIError",
"HyperpingPartialBatchError",
"HyperpingAuthError",
"HyperpingNotFoundError",
"HyperpingRateLimitError",
Expand Down Expand Up @@ -151,6 +154,7 @@
"IncidentStatus",
"IncidentUpdateCreate",
# Maintenance
"MAX_STATUSPAGES_PER_INCIDENT",
"MAX_STATUSPAGES_PER_MAINTENANCE",
"Maintenance",
"MaintenanceCreate",
Expand Down
49 changes: 49 additions & 0 deletions src/hyperping/_async_incidents_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@
import logging
from datetime import UTC, datetime

from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
from hyperping._protocols import _AsyncClientProtocol
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
from hyperping.endpoints import Endpoint
from hyperping.exceptions import (
HyperpingAPIError,
HyperpingPartialBatchError,
HyperpingValidationError,
)
from hyperping.models import (
AddIncidentUpdateRequest,
Incident,
Expand Down Expand Up @@ -81,6 +87,15 @@ async def create_incident(self, incident: IncidentCreate) -> Incident:
v3 API returns {"message": "...", "uuid": "..."} on create,
not the full incident object. The full incident is fetched after creation.
"""
n_statuspages = len(incident.statuspages or [])
if n_statuspages > MAX_STATUSPAGES_PER_INCIDENT:
raise HyperpingValidationError(
f"An incident can reference at most {MAX_STATUSPAGES_PER_INCIDENT} "
f"status pages, but {n_statuspages} were supplied. Above this limit "
f"Hyperping's API is expected to accept the create (returns a uuid) but "
f"silently fail to persist it. Use create_incidents() to split the "
f"status pages across multiple incidents."
)
payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
response = expect_dict(
await self._request("POST", Endpoint.INCIDENTS, json=payload),
Expand All @@ -90,6 +105,40 @@ async def create_incident(self, incident: IncidentCreate) -> Incident:
return await self.get_incident(response["uuid"])
return Incident.model_validate(response)

async def create_incidents(
self,
incident: IncidentCreate,
*,
chunk_size: int = MAX_STATUSPAGES_PER_INCIDENT,
) -> list[Incident]:
"""Async mirror of
:meth:`~hyperping._incidents_mixin.IncidentsMixin.create_incidents`.
"""
if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_INCIDENT:
raise HyperpingValidationError(
f"chunk_size must be between 1 and "
f"{MAX_STATUSPAGES_PER_INCIDENT}, got {chunk_size}."
)
pages = list(incident.statuspages or [])
if len(pages) <= chunk_size:
return [await self.create_incident(incident)]
chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
created: list[Incident] = []
for idx, chunk_pages in enumerate(chunks):
chunk = incident.model_copy(update={"statuspages": chunk_pages})
try:
created.append(await self.create_incident(chunk))
except HyperpingAPIError as exc:
raise HyperpingPartialBatchError(
f"create_incidents failed on incident {idx + 1} of "
f"{len(chunks)}: {exc}. {len(created)} incident(s) were already "
f"created and remain live.",
created=created,
completed=len(created),
total=len(chunks),
) from exc
return created

async def update_incident(
self,
incident_id: str,
Expand Down
25 changes: 19 additions & 6 deletions src/hyperping/_async_maintenance_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
from hyperping._protocols import _AsyncClientProtocol
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
from hyperping.endpoints import Endpoint
from hyperping.exceptions import HyperpingValidationError
from hyperping.exceptions import (
HyperpingAPIError,
HyperpingPartialBatchError,
HyperpingValidationError,
)
from hyperping.models import (
Maintenance,
MaintenanceCreate,
Expand Down Expand Up @@ -121,12 +125,21 @@ async def create_maintenance_windows(
pages = list(maintenance.statuspages or [])
if len(pages) <= chunk_size:
return [await self.create_maintenance(maintenance)]
chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
windows: list[Maintenance] = []
for start in range(0, len(pages), chunk_size):
chunk = maintenance.model_copy(
update={"statuspages": pages[start : start + chunk_size]}
)
windows.append(await self.create_maintenance(chunk))
for idx, chunk_pages in enumerate(chunks):
chunk = maintenance.model_copy(update={"statuspages": chunk_pages})
try:
windows.append(await self.create_maintenance(chunk))
except HyperpingAPIError as exc:
raise HyperpingPartialBatchError(
f"create_maintenance_windows failed on window {idx + 1} of "
f"{len(chunks)}: {exc}. {len(windows)} window(s) were already "
f"created and remain live.",
created=windows,
completed=len(windows),
total=len(chunks),
) from exc
return windows

async def update_maintenance(
Expand Down
66 changes: 65 additions & 1 deletion src/hyperping/_incidents_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
from hyperping._protocols import _ClientProtocol
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
from hyperping.endpoints import Endpoint
from hyperping.exceptions import HyperpingAPIError
from hyperping.exceptions import (
HyperpingAPIError,
HyperpingPartialBatchError,
HyperpingValidationError,
)
from hyperping.models import (
AddIncidentUpdateRequest, # canonical name (M18)
Incident,
Expand All @@ -24,6 +28,14 @@

logger = logging.getLogger(__name__)

# Incidents attach status pages via the same mechanism as maintenance windows,
# which the API caps at 51 per request (see MAX_STATUSPAGES_PER_MAINTENANCE):
# beyond the cap the create is accepted but silently not persisted. The
# incident cap has NOT been independently verified against the live API; it is
# assumed identical because the status-page attachment is the same backend
# path. Adjust if the incident endpoint is later measured to differ.
MAX_STATUSPAGES_PER_INCIDENT = 51


class IncidentsMixin(_ClientProtocol):
"""Incident-related API operations."""
Expand Down Expand Up @@ -87,6 +99,15 @@ def create_incident(self, incident: IncidentCreate) -> Incident:
v3 API returns {"message": "...", "uuid": "..."} on create,
not the full incident object. The full incident is fetched after creation.
"""
n_statuspages = len(incident.statuspages or [])
if n_statuspages > MAX_STATUSPAGES_PER_INCIDENT:
raise HyperpingValidationError(
f"An incident can reference at most {MAX_STATUSPAGES_PER_INCIDENT} "
f"status pages, but {n_statuspages} were supplied. Above this limit "
f"Hyperping's API is expected to accept the create (returns a uuid) but "
f"silently fail to persist it. Use create_incidents() to split the "
f"status pages across multiple incidents."
)
payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json")
response = expect_dict(
self._request("POST", Endpoint.INCIDENTS, json=payload),
Expand All @@ -98,6 +119,49 @@ def create_incident(self, incident: IncidentCreate) -> Incident:
return self.get_incident(response["uuid"])
return Incident.model_validate(response)

def create_incidents(
self,
incident: IncidentCreate,
*,
chunk_size: int = MAX_STATUSPAGES_PER_INCIDENT,
) -> list[Incident]:
"""Create one or more incidents, splitting status pages into chunks.

Mirrors :meth:`create_maintenance_windows`: a broadcast incident that
targets more status pages than the per-request cap is split into
consecutive incidents of at most ``chunk_size`` pages. Because the page
sets are disjoint, each status page still shows exactly one incident.

Returns the created incidents in page order (a single incident when the
pages fit in one chunk). Raises :class:`HyperpingValidationError` for a
bad ``chunk_size`` and :class:`HyperpingPartialBatchError` if a later
chunk fails after earlier incidents were already created.
"""
if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_INCIDENT:
raise HyperpingValidationError(
f"chunk_size must be between 1 and "
f"{MAX_STATUSPAGES_PER_INCIDENT}, got {chunk_size}."
)
pages = list(incident.statuspages or [])
if len(pages) <= chunk_size:
return [self.create_incident(incident)]
chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
created: list[Incident] = []
for idx, chunk_pages in enumerate(chunks):
chunk = incident.model_copy(update={"statuspages": chunk_pages})
try:
created.append(self.create_incident(chunk))
except HyperpingAPIError as exc:
raise HyperpingPartialBatchError(
f"create_incidents failed on incident {idx + 1} of "
f"{len(chunks)}: {exc}. {len(created)} incident(s) were already "
f"created and remain live.",
created=created,
completed=len(created),
total=len(chunks),
) from exc
return created

def update_incident(
self,
incident_id: str,
Expand Down
28 changes: 22 additions & 6 deletions src/hyperping/_maintenance_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
from hyperping._protocols import _ClientProtocol
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
from hyperping.endpoints import Endpoint
from hyperping.exceptions import HyperpingValidationError
from hyperping.exceptions import (
HyperpingAPIError,
HyperpingPartialBatchError,
HyperpingValidationError,
)
from hyperping.models import (
Maintenance,
MaintenanceCreate,
Expand Down Expand Up @@ -154,12 +158,24 @@ def create_maintenance_windows(
pages = list(maintenance.statuspages or [])
if len(pages) <= chunk_size:
return [self.create_maintenance(maintenance)]
chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
windows: list[Maintenance] = []
for start in range(0, len(pages), chunk_size):
chunk = maintenance.model_copy(
update={"statuspages": pages[start : start + chunk_size]}
)
windows.append(self.create_maintenance(chunk))
for idx, chunk_pages in enumerate(chunks):
chunk = maintenance.model_copy(update={"statuspages": chunk_pages})
try:
windows.append(self.create_maintenance(chunk))
except HyperpingAPIError as exc:
# Earlier windows are already live and are NOT rolled back; hand
# them back so the caller can record or clean them up rather than
# orphaning them silently.
raise HyperpingPartialBatchError(
f"create_maintenance_windows failed on window {idx + 1} of "
f"{len(chunks)}: {exc}. {len(windows)} window(s) were already "
f"created and remain live.",
created=windows,
completed=len(windows),
total=len(chunks),
) from exc
return windows

def update_maintenance(
Expand Down
2 changes: 1 addition & 1 deletion src/hyperping/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.8.1"
__version__ = "1.9.0"
31 changes: 31 additions & 0 deletions src/hyperping/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,34 @@ def __init__(
) -> None:
super().__init__(message, **kwargs)
self.validation_errors = validation_errors or []


class HyperpingPartialBatchError(HyperpingAPIError):
"""Raised when a multi-item batch operation fails partway through.

Used by helpers that split one logical request into several API calls
(e.g. :meth:`create_maintenance_windows`, :meth:`create_incidents` when the
status-page list exceeds the per-request cap). If an item fails after
earlier ones succeeded, the already-created objects are NOT rolled back;
they are attached here so the caller can record or clean them up.

Args:
message: Human-readable error description.
created: The objects successfully created before the failure.
completed: How many items succeeded.
total: How many items were attempted in the batch.
**kwargs: Forwarded to :class:`HyperpingAPIError`.
"""

def __init__(
self,
message: str,
created: list[Any] | None = None,
completed: int | None = None,
total: int | None = None,
**kwargs: Any,
) -> None:
super().__init__(message, **kwargs)
self.created = created or []
self.completed = completed if completed is not None else len(self.created)
self.total = total
Loading