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.
- {hyperping-1.8.1 → hyperping-1.9.0}/CHANGELOG.md +52 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/PKG-INFO +1 -1
- {hyperping-1.8.1 → hyperping-1.9.0}/pyproject.toml +5 -2
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/__init__.py +4 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_incidents_mixin.py +49 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_maintenance_mixin.py +19 -6
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_incidents_mixin.py +65 -1
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_maintenance_mixin.py +22 -6
- hyperping-1.9.0/src/hyperping/_version.py +1 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/exceptions.py +31 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_preexisting.py +71 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_incidents.py +84 -1
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_maintenance.py +77 -1
- hyperping-1.8.1/src/hyperping/_version.py +0 -1
- {hyperping-1.8.1 → hyperping-1.9.0}/.gitignore +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/LICENSE +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/README.md +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/SECURITY.md +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_mcp_client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_mcp_transport.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_internals.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_mcp_transport.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/_utils.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/__init__.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_app.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_config.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_incidents.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_monitors.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_output.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_statuspages.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/cli/_tenant.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/mcp_client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/__init__.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_mcp_client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_async_mcp_transport.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_config.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_incidents.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_monitors.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_statuspages.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_cli_tenant.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_mcp_client.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_mcp_transport.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_outages.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_sdk_surface.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_security_base_url.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_security_breaker_cap.py +0 -0
- {hyperping-1.8.1 → hyperping-1.9.0}/tests/unit/test_security_exception_redaction.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
126
|
-
chunk = maintenance.model_copy(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
159
|
-
chunk = maintenance.model_copy(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|