hyperping 1.8.1__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 (84) hide show
  1. {hyperping-1.8.1 → hyperping-1.9.0}/CHANGELOG.md +52 -0
  2. {hyperping-1.8.1 → hyperping-1.9.0}/PKG-INFO +1 -1
  3. {hyperping-1.8.1 → hyperping-1.9.0}/pyproject.toml +5 -2
  4. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/__init__.py +4 -0
  5. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_incidents_mixin.py +49 -0
  6. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_maintenance_mixin.py +19 -6
  7. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_incidents_mixin.py +65 -1
  8. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_maintenance_mixin.py +22 -6
  9. hyperping-1.9.0/src/hyperping/_version.py +1 -0
  10. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/exceptions.py +31 -0
  11. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_preexisting.py +71 -0
  12. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_incidents.py +84 -1
  13. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_maintenance.py +77 -1
  14. hyperping-1.8.1/src/hyperping/_version.py +0 -1
  15. {hyperping-1.8.1 → hyperping-1.9.0}/.gitignore +0 -0
  16. {hyperping-1.8.1 → hyperping-1.9.0}/CONTRIBUTING.md +0 -0
  17. {hyperping-1.8.1 → hyperping-1.9.0}/LICENSE +0 -0
  18. {hyperping-1.8.1 → hyperping-1.9.0}/README.md +0 -0
  19. {hyperping-1.8.1 → hyperping-1.9.0}/SECURITY.md +0 -0
  20. {hyperping-1.8.1 → hyperping-1.9.0}/scripts/verify_endpoints.py +0 -0
  21. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_client.py +0 -0
  22. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  23. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_mcp_client.py +0 -0
  24. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_mcp_transport.py +0 -0
  25. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  26. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_outages_mixin.py +0 -0
  27. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  28. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_circuit_breaker.py +0 -0
  29. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  30. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_internals.py +0 -0
  31. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_mcp_transport.py +0 -0
  32. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_monitor_constants.py +0 -0
  33. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_monitors_mixin.py +0 -0
  34. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_outages_mixin.py +0 -0
  35. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_protocols.py +0 -0
  36. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_statuspages_mixin.py +0 -0
  37. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_utils.py +0 -0
  38. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/__init__.py +0 -0
  39. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_app.py +0 -0
  40. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_config.py +0 -0
  41. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_incidents.py +0 -0
  42. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_monitors.py +0 -0
  43. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_output.py +0 -0
  44. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_statuspages.py +0 -0
  45. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_tenant.py +0 -0
  46. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/client.py +0 -0
  47. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/endpoints.py +0 -0
  48. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/mcp_client.py +0 -0
  49. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/__init__.py +0 -0
  50. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  51. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_incident_models.py +0 -0
  52. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_integration_models.py +0 -0
  53. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_maintenance_models.py +0 -0
  54. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_monitor_models.py +0 -0
  55. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_observability_models.py +0 -0
  56. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_oncall_models.py +0 -0
  57. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_outage_models.py +0 -0
  58. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_reporting_models.py +0 -0
  59. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_statuspage_models.py +0 -0
  60. {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/py.typed +0 -0
  61. {hyperping-1.8.1 → hyperping-1.9.0}/tests/__init__.py +0 -0
  62. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/__init__.py +0 -0
  63. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/conftest.py +0 -0
  64. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_client.py +0 -0
  65. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_mcp_client.py +0 -0
  66. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_mcp_transport.py +0 -0
  67. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_config.py +0 -0
  68. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_incidents.py +0 -0
  69. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_monitors.py +0 -0
  70. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_statuspages.py +0 -0
  71. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_tenant.py +0 -0
  72. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_client_coverage.py +0 -0
  73. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_healthchecks.py +0 -0
  74. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_mcp_client.py +0 -0
  75. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_mcp_transport.py +0 -0
  76. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_monitors.py +0 -0
  77. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_outages.py +0 -0
  78. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_pagination.py +0 -0
  79. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  80. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_sdk_surface.py +0 -0
  81. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_security_base_url.py +0 -0
  82. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_security_breaker_cap.py +0 -0
  83. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_security_exception_redaction.py +0 -0
  84. {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_statuspages.py +0 -0
@@ -7,6 +7,58 @@ 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
+
10
62
  ## [1.8.1] - 2026-07-14
11
63
 
12
64
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.8.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.8.1"
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"}
@@ -44,7 +44,10 @@ dev = [
44
44
  ]
45
45
 
46
46
  [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"
48
51
 
49
52
  [project.urls]
50
53
  Homepage = "https://github.com/develeap/hyperping-python"
@@ -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._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT
19
20
  from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
20
21
  from hyperping._version import __version__
21
22
  from hyperping.client import (
@@ -35,6 +36,7 @@ from hyperping.exceptions import (
35
36
  HyperpingAPIError,
36
37
  HyperpingAuthError,
37
38
  HyperpingNotFoundError,
39
+ HyperpingPartialBatchError,
38
40
  HyperpingRateLimitError,
39
41
  HyperpingValidationError,
40
42
  )
@@ -118,6 +120,7 @@ __all__ = [
118
120
  "APIVersion",
119
121
  # Exceptions
120
122
  "HyperpingAPIError",
123
+ "HyperpingPartialBatchError",
121
124
  "HyperpingAuthError",
122
125
  "HyperpingNotFoundError",
123
126
  "HyperpingRateLimitError",
@@ -151,6 +154,7 @@ __all__ = [
151
154
  "IncidentStatus",
152
155
  "IncidentUpdateCreate",
153
156
  # Maintenance
157
+ "MAX_STATUSPAGES_PER_INCIDENT",
154
158
  "MAX_STATUSPAGES_PER_MAINTENANCE",
155
159
  "Maintenance",
156
160
  "MaintenanceCreate",
@@ -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,
@@ -13,7 +13,11 @@ from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
13
13
  from hyperping._protocols import _AsyncClientProtocol
14
14
  from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
15
15
  from hyperping.endpoints import Endpoint
16
- from hyperping.exceptions import HyperpingValidationError
16
+ from hyperping.exceptions import (
17
+ HyperpingAPIError,
18
+ HyperpingPartialBatchError,
19
+ HyperpingValidationError,
20
+ )
17
21
  from hyperping.models import (
18
22
  Maintenance,
19
23
  MaintenanceCreate,
@@ -121,12 +125,21 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
121
125
  pages = list(maintenance.statuspages or [])
122
126
  if len(pages) <= chunk_size:
123
127
  return [await self.create_maintenance(maintenance)]
128
+ chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
124
129
  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
130
143
  return windows
131
144
 
132
145
  async def update_maintenance(
@@ -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,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 HyperpingValidationError
15
+ from hyperping.exceptions import (
16
+ HyperpingAPIError,
17
+ HyperpingPartialBatchError,
18
+ HyperpingValidationError,
19
+ )
16
20
  from hyperping.models import (
17
21
  Maintenance,
18
22
  MaintenanceCreate,
@@ -154,12 +158,24 @@ class MaintenanceMixin(_ClientProtocol):
154
158
  pages = list(maintenance.statuspages or [])
155
159
  if len(pages) <= chunk_size:
156
160
  return [self.create_maintenance(maintenance)]
161
+ chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)]
157
162
  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
163
179
  return windows
164
180
 
165
181
  def update_maintenance(
@@ -0,0 +1 @@
1
+ __version__ = "1.9.0"
@@ -113,3 +113,34 @@ class HyperpingValidationError(HyperpingAPIError):
113
113
  ) -> None:
114
114
  super().__init__(message, **kwargs)
115
115
  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
@@ -714,3 +714,74 @@ class TestAsyncIncidents:
714
714
  )
715
715
  with pytest.raises(HyperpingNotFoundError):
716
716
  await async_client.get_incident("inc_x")
717
+
718
+
719
+ class TestAsyncBatchChunking:
720
+ """Async chunking helpers added in 1.9.0."""
721
+
722
+ @respx.mock
723
+ @pytest.mark.asyncio
724
+ async def test_create_maintenance_windows_chunks(self, async_client):
725
+ from hyperping.models import MaintenanceCreate
726
+
727
+ route = respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock(
728
+ return_value=httpx.Response(201, json={"uuid": "mw_x"})
729
+ )
730
+ respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_x").mock(
731
+ return_value=httpx.Response(
732
+ 200,
733
+ json={
734
+ "uuid": "mw_x",
735
+ "name": "B",
736
+ "start_date": "2024-01-20T00:00:00Z",
737
+ "end_date": "2024-01-20T02:00:00Z",
738
+ "monitors": ["mon_1"],
739
+ "statuspages": [],
740
+ },
741
+ )
742
+ )
743
+ result = await async_client.create_maintenance_windows(
744
+ MaintenanceCreate(
745
+ name="B",
746
+ start_date="2024-01-20T00:00:00Z",
747
+ end_date="2024-01-20T02:00:00Z",
748
+ monitors=["mon_1"],
749
+ statuspages=[f"sp_{i}" for i in range(60)],
750
+ )
751
+ )
752
+ assert len(result) == 2
753
+ assert route.call_count == 2
754
+
755
+ @respx.mock
756
+ @pytest.mark.asyncio
757
+ async def test_create_incidents_chunks(self, async_client):
758
+ from hyperping.models import IncidentCreate, IncidentType, LocalizedText
759
+
760
+ route = respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
761
+ return_value=httpx.Response(201, json={"message": "ok", "uuid": "inci_x"})
762
+ )
763
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inci_x").mock(
764
+ return_value=httpx.Response(
765
+ 200,
766
+ json={
767
+ "uuid": "inci_x",
768
+ "date": "2024-01-15T10:00:00Z",
769
+ "title": {"en": "X"},
770
+ "text": {"en": "Y"},
771
+ "type": "incident",
772
+ "affectedComponents": [],
773
+ "statuspages": [],
774
+ "updates": [],
775
+ },
776
+ )
777
+ )
778
+ result = await async_client.create_incidents(
779
+ IncidentCreate(
780
+ title=LocalizedText(en="X"),
781
+ text=LocalizedText(en="Y"),
782
+ type=IncidentType.INCIDENT,
783
+ statuspages=[f"sp_{i}" for i in range(60)],
784
+ )
785
+ )
786
+ assert len(result) == 2
787
+ assert route.call_count == 2
@@ -8,7 +8,11 @@ import respx
8
8
 
9
9
  from hyperping.client import HyperpingClient
10
10
  from hyperping.endpoints import API_BASE, Endpoint
11
- from hyperping.exceptions import HyperpingNotFoundError
11
+ from hyperping.exceptions import (
12
+ HyperpingNotFoundError,
13
+ HyperpingPartialBatchError,
14
+ HyperpingValidationError,
15
+ )
12
16
  from hyperping.models import (
13
17
  AddIncidentUpdateRequest,
14
18
  Incident,
@@ -292,3 +296,82 @@ class TestIncidentAPIClient:
292
296
  "inci_missing",
293
297
  IncidentUpdateRequest(title=LocalizedText(en="Title")),
294
298
  )
299
+
300
+
301
+ class TestCreateIncidents:
302
+ """create_incidents() chunking, guard, and partial-failure behavior."""
303
+
304
+ def _incident(self, n_pages: int) -> IncidentCreate:
305
+ return IncidentCreate(
306
+ title=LocalizedText(en="X"),
307
+ text=LocalizedText(en="Y"),
308
+ type=IncidentType.INCIDENT,
309
+ statuspages=[f"sp_{i}" for i in range(n_pages)],
310
+ )
311
+
312
+ def test_create_incident_rejects_over_cap(self, client: HyperpingClient) -> None:
313
+ with pytest.raises(HyperpingValidationError, match="at most 51 status pages"):
314
+ client.create_incident(self._incident(52))
315
+
316
+ def test_create_incidents_rejects_bad_chunk_size(self, client: HyperpingClient) -> None:
317
+ with pytest.raises(HyperpingValidationError, match="chunk_size"):
318
+ client.create_incidents(self._incident(1), chunk_size=99)
319
+
320
+ @respx.mock
321
+ def test_create_incidents_chunks_statuspages(self, client: HyperpingClient) -> None:
322
+ import json
323
+
324
+ route = respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
325
+ return_value=httpx.Response(201, json={"message": "ok", "uuid": "inci_x"})
326
+ )
327
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inci_x").mock(
328
+ return_value=httpx.Response(
329
+ 200,
330
+ json={
331
+ "uuid": "inci_x",
332
+ "date": "2024-01-15T10:00:00Z",
333
+ "title": {"en": "X"},
334
+ "text": {"en": "Y"},
335
+ "type": "incident",
336
+ "affectedComponents": [],
337
+ "statuspages": [],
338
+ "updates": [],
339
+ },
340
+ )
341
+ )
342
+ result = client.create_incidents(self._incident(60))
343
+ assert len(result) == 2
344
+ assert route.call_count == 2
345
+ sizes = [len(json.loads(c.request.content)["statuspages"]) for c in route.calls]
346
+ assert sizes == [51, 9]
347
+
348
+ @respx.mock
349
+ def test_create_incidents_partial_failure(self, client: HyperpingClient) -> None:
350
+ posts = {"n": 0}
351
+
352
+ def post_side(request: httpx.Request) -> httpx.Response:
353
+ posts["n"] += 1
354
+ if posts["n"] == 1:
355
+ return httpx.Response(201, json={"message": "ok", "uuid": "inci_1"})
356
+ return httpx.Response(500, json={"error": "boom"})
357
+
358
+ respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock(side_effect=post_side)
359
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inci_1").mock(
360
+ return_value=httpx.Response(
361
+ 200,
362
+ json={
363
+ "uuid": "inci_1",
364
+ "date": "2024-01-15T10:00:00Z",
365
+ "title": {"en": "X"},
366
+ "text": {"en": "Y"},
367
+ "type": "incident",
368
+ "affectedComponents": [],
369
+ "statuspages": [],
370
+ "updates": [],
371
+ },
372
+ )
373
+ )
374
+ with pytest.raises(HyperpingPartialBatchError) as ei:
375
+ client.create_incidents(self._incident(60))
376
+ assert len(ei.value.created) == 1
377
+ assert ei.value.total == 2
@@ -8,7 +8,11 @@ import respx
8
8
 
9
9
  from hyperping.client import HyperpingClient
10
10
  from hyperping.endpoints import API_BASE, Endpoint
11
- from hyperping.exceptions import HyperpingNotFoundError, HyperpingValidationError
11
+ from hyperping.exceptions import (
12
+ HyperpingNotFoundError,
13
+ HyperpingPartialBatchError,
14
+ HyperpingValidationError,
15
+ )
12
16
  from hyperping.models import (
13
17
  Maintenance,
14
18
  MaintenanceCreate,
@@ -356,3 +360,75 @@ class TestMaintenanceAPIClient:
356
360
 
357
361
  assert client.is_monitor_in_maintenance("mon_1") is True
358
362
  assert client.is_monitor_in_maintenance("mon_other") is False
363
+
364
+ @respx.mock
365
+ def test_create_maintenance_windows_exactly_51_is_single_window(
366
+ self, client: HyperpingClient
367
+ ) -> None:
368
+ """51 pages is the accepted boundary — one window, not rejected, not split."""
369
+ route = respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock(
370
+ return_value=httpx.Response(201, json={"uuid": "mw_x"})
371
+ )
372
+ respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_x").mock(
373
+ return_value=httpx.Response(
374
+ 200,
375
+ json={
376
+ "uuid": "mw_x",
377
+ "name": "B",
378
+ "start_date": "2024-01-20T00:00:00Z",
379
+ "end_date": "2024-01-20T02:00:00Z",
380
+ "monitors": ["mon_1"],
381
+ "statuspages": [],
382
+ },
383
+ )
384
+ )
385
+ windows = client.create_maintenance_windows(
386
+ MaintenanceCreate(
387
+ name="B",
388
+ start_date="2024-01-20T00:00:00Z",
389
+ end_date="2024-01-20T02:00:00Z",
390
+ monitors=["mon_1"],
391
+ statuspages=[f"sp_{i}" for i in range(51)],
392
+ )
393
+ )
394
+ assert len(windows) == 1
395
+ assert route.call_count == 1
396
+
397
+ @respx.mock
398
+ def test_create_maintenance_windows_partial_failure(self, client: HyperpingClient) -> None:
399
+ """A later-chunk failure raises HyperpingPartialBatchError with the created ones."""
400
+ posts = {"n": 0}
401
+
402
+ def post_side(request: httpx.Request) -> httpx.Response:
403
+ posts["n"] += 1
404
+ if posts["n"] == 1:
405
+ return httpx.Response(201, json={"uuid": "mw_1"})
406
+ return httpx.Response(500, json={"error": "boom"})
407
+
408
+ respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock(side_effect=post_side)
409
+ respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock(
410
+ return_value=httpx.Response(
411
+ 200,
412
+ json={
413
+ "uuid": "mw_1",
414
+ "name": "B",
415
+ "start_date": "2024-01-20T00:00:00Z",
416
+ "end_date": "2024-01-20T02:00:00Z",
417
+ "monitors": ["mon_1"],
418
+ "statuspages": [],
419
+ },
420
+ )
421
+ )
422
+ with pytest.raises(HyperpingPartialBatchError) as ei:
423
+ client.create_maintenance_windows(
424
+ MaintenanceCreate(
425
+ name="B",
426
+ start_date="2024-01-20T00:00:00Z",
427
+ end_date="2024-01-20T02:00:00Z",
428
+ monitors=["mon_1"],
429
+ statuspages=[f"sp_{i}" for i in range(60)],
430
+ )
431
+ )
432
+ assert len(ei.value.created) == 1
433
+ assert ei.value.completed == 1
434
+ assert ei.value.total == 2
@@ -1 +0,0 @@
1
- __version__ = "1.8.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes