hookbase 2.6.0__tar.gz → 2.8.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.
- {hookbase-2.6.0 → hookbase-2.8.0}/PKG-INFO +2 -2
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/_client.py +8 -2
- hookbase-2.8.0/src/hookbase/_version.py +1 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/errors.py +9 -2
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/__init__.py +2 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/cron_jobs.py +29 -7
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/destinations.py +6 -3
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/cron_jobs.py +11 -0
- hookbase-2.8.0/tests/resources/test_cron_jobs.py +160 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_destinations.py +15 -1
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_errors.py +47 -9
- hookbase-2.6.0/src/hookbase/_version.py +0 -1
- {hookbase-2.6.0 → hookbase-2.8.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/.gitignore +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/LICENSE +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/README.md +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/examples/async_usage.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/examples/basic_inbound.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/examples/error_handling.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/examples/send_webhooks.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/examples/verify_webhooks.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/pyproject.toml +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/__init__.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/_constants.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/_pagination.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/client.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/applications.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/common.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/deliveries.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/dlq.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/endpoints.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/event_types.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/events.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/messages.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/sources.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/subscriptions.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/transforms.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/py.typed +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/_base.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/deliveries.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/destinations.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/dlq.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/endpoints.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/event_types.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/events.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/message_log.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/messages.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/sources.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/subscriptions.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/src/hookbase/webhook.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/__init__.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/conftest.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/__init__.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_applications.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_dlq.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_messages.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/resources/test_sources.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_client.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_pagination.py +0 -0
- {hookbase-2.6.0 → hookbase-2.8.0}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hookbase
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Official Python SDK for the Hookbase webhook management platform
|
|
5
5
|
Project-URL: Homepage, https://hookbase.app
|
|
6
6
|
Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
|
|
@@ -27,9 +27,15 @@ logger = logging.getLogger("hookbase")
|
|
|
27
27
|
def _parse_error(status_code: int, body: dict[str, Any], request_id: str | None) -> APIError:
|
|
28
28
|
error = body.get("error", body)
|
|
29
29
|
if isinstance(error, str):
|
|
30
|
+
# This is the shape the real API actually sends: `error` is a plain string, with
|
|
31
|
+
# `code` and `details` (Zod's `.flatten()` shape: `{ fieldErrors, formErrors }`) as
|
|
32
|
+
# siblings at the top level of the body, not nested inside `error`.
|
|
30
33
|
message = error
|
|
31
|
-
code = "unknown_error"
|
|
32
|
-
|
|
34
|
+
code = body.get("code", "unknown_error")
|
|
35
|
+
details = body.get("details")
|
|
36
|
+
validation_errors = (
|
|
37
|
+
details.get("fieldErrors") if isinstance(details, dict) else None
|
|
38
|
+
)
|
|
33
39
|
elif isinstance(error, dict):
|
|
34
40
|
message = error.get("message") or body.get("message") or f"API error: {status_code}"
|
|
35
41
|
code = error.get("code", "unknown_error")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.8.0"
|
|
@@ -38,8 +38,11 @@ class APIError(HookbaseError):
|
|
|
38
38
|
) -> APIError:
|
|
39
39
|
error = body.get("error", body)
|
|
40
40
|
if isinstance(error, str):
|
|
41
|
+
# The real API always sends `error` as a plain string, with `code` and `details`
|
|
42
|
+
# (Zod's `.flatten()` shape: `{ fieldErrors, formErrors }`) as siblings of `error`
|
|
43
|
+
# at the top level of the body — never nested inside it.
|
|
41
44
|
message = error
|
|
42
|
-
code = "unknown_error"
|
|
45
|
+
code = body.get("code", "unknown_error")
|
|
43
46
|
elif isinstance(error, dict):
|
|
44
47
|
message = error.get("message", f"API error: {status_code}")
|
|
45
48
|
code = error.get("code", "unknown_error")
|
|
@@ -49,7 +52,11 @@ class APIError(HookbaseError):
|
|
|
49
52
|
|
|
50
53
|
if status_code in (400, 422):
|
|
51
54
|
validation_errors = None
|
|
52
|
-
if isinstance(error,
|
|
55
|
+
if isinstance(error, str):
|
|
56
|
+
details = body.get("details")
|
|
57
|
+
if isinstance(details, dict):
|
|
58
|
+
validation_errors = details.get("fieldErrors")
|
|
59
|
+
elif isinstance(error, dict):
|
|
53
60
|
validation_errors = error.get("validationErrors")
|
|
54
61
|
return ValidationError(
|
|
55
62
|
message=message,
|
|
@@ -7,6 +7,7 @@ from .cron_jobs import (
|
|
|
7
7
|
CreateCronJobParams,
|
|
8
8
|
CronGroup,
|
|
9
9
|
CronJob,
|
|
10
|
+
CronJobExecution,
|
|
10
11
|
UpdateCronJobParams,
|
|
11
12
|
)
|
|
12
13
|
from .deliveries import (
|
|
@@ -123,6 +124,7 @@ __all__ = [
|
|
|
123
124
|
"CreateCronJobParams",
|
|
124
125
|
"CronGroup",
|
|
125
126
|
"CronJob",
|
|
127
|
+
"CronJobExecution",
|
|
126
128
|
"UpdateCronJobParams",
|
|
127
129
|
# Deliveries
|
|
128
130
|
"BulkReplayResult",
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from typing import Literal
|
|
4
5
|
|
|
5
6
|
from pydantic import field_validator
|
|
6
7
|
|
|
7
8
|
from ._base import HookbaseModel
|
|
8
9
|
|
|
10
|
+
CronJobExecutionStatus = Literal["success", "failed"]
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class CronJob(HookbaseModel):
|
|
11
14
|
id: str
|
|
@@ -15,15 +18,19 @@ class CronJob(HookbaseModel):
|
|
|
15
18
|
url: str = ""
|
|
16
19
|
method: str = "GET"
|
|
17
20
|
headers: dict[str, str] | None = None
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
payload: str | None = None
|
|
22
|
+
cron_expression: str = ""
|
|
20
23
|
timezone: str = "UTC"
|
|
24
|
+
timeout_ms: int = 30000
|
|
21
25
|
is_active: bool = True
|
|
22
26
|
use_static_ip: bool = True
|
|
23
27
|
group_id: str | None = None
|
|
24
28
|
last_run_at: str | None = None
|
|
25
29
|
next_run_at: str | None = None
|
|
26
|
-
|
|
30
|
+
consecutive_failures: int = 0
|
|
31
|
+
notify_on_failure: bool = False
|
|
32
|
+
notify_on_success: bool = False
|
|
33
|
+
notify_emails: str | None = None
|
|
27
34
|
created_at: str = ""
|
|
28
35
|
updated_at: str = ""
|
|
29
36
|
|
|
@@ -40,18 +47,29 @@ class CronJob(HookbaseModel):
|
|
|
40
47
|
return v
|
|
41
48
|
|
|
42
49
|
|
|
50
|
+
class CronJobExecution(HookbaseModel):
|
|
51
|
+
id: str
|
|
52
|
+
status: CronJobExecutionStatus = "success"
|
|
53
|
+
response_status: int | None = None
|
|
54
|
+
latency_ms: int = 0
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
class CreateCronJobParams(HookbaseModel):
|
|
44
58
|
name: str
|
|
45
59
|
description: str | None = None
|
|
46
60
|
url: str
|
|
47
61
|
method: str | None = None
|
|
48
62
|
headers: dict[str, str] | None = None
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
payload: str | None = None
|
|
64
|
+
cron_expression: str
|
|
51
65
|
timezone: str | None = None
|
|
66
|
+
timeout_ms: int | None = None
|
|
52
67
|
is_active: bool | None = None
|
|
53
68
|
use_static_ip: bool | None = None
|
|
54
69
|
group_id: str | None = None
|
|
70
|
+
notify_on_failure: bool | None = None
|
|
71
|
+
notify_on_success: bool | None = None
|
|
72
|
+
notify_emails: str | None = None
|
|
55
73
|
|
|
56
74
|
|
|
57
75
|
class UpdateCronJobParams(HookbaseModel):
|
|
@@ -60,12 +78,16 @@ class UpdateCronJobParams(HookbaseModel):
|
|
|
60
78
|
url: str | None = None
|
|
61
79
|
method: str | None = None
|
|
62
80
|
headers: dict[str, str] | None = None
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
payload: str | None = None
|
|
82
|
+
cron_expression: str | None = None
|
|
65
83
|
timezone: str | None = None
|
|
84
|
+
timeout_ms: int | None = None
|
|
66
85
|
is_active: bool | None = None
|
|
67
86
|
use_static_ip: bool | None = None
|
|
68
87
|
group_id: str | None = None
|
|
88
|
+
notify_on_failure: bool | None = None
|
|
89
|
+
notify_on_success: bool | None = None
|
|
90
|
+
notify_emails: str | None = None
|
|
69
91
|
|
|
70
92
|
|
|
71
93
|
class CronGroup(HookbaseModel):
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
from typing import Any, Literal
|
|
5
5
|
|
|
6
|
-
from pydantic import field_validator, model_validator
|
|
6
|
+
from pydantic import Field, field_validator, model_validator
|
|
7
7
|
|
|
8
8
|
from ._base import HookbaseModel
|
|
9
9
|
|
|
@@ -209,7 +209,10 @@ class UpdateDestinationParams(HookbaseModel):
|
|
|
209
209
|
|
|
210
210
|
class TestResult(HookbaseModel):
|
|
211
211
|
success: bool
|
|
212
|
-
|
|
213
|
-
duration
|
|
212
|
+
# The API answers with `status`/`latencyMs` (api/src/routes/destinations.ts), not the
|
|
213
|
+
# `statusCode`/`duration` that HookbaseModel's to_camel alias_generator would otherwise
|
|
214
|
+
# expect for these field names — override the alias explicitly rather than rely on it.
|
|
215
|
+
status_code: int | None = Field(default=None, alias="status")
|
|
216
|
+
duration: float | None = Field(default=None, alias="latencyMs")
|
|
214
217
|
response_body: str | None = None
|
|
215
218
|
error: str | None = None
|
|
@@ -13,6 +13,7 @@ from ..models.cron_jobs import (
|
|
|
13
13
|
CreateCronJobParams,
|
|
14
14
|
CronGroup,
|
|
15
15
|
CronJob,
|
|
16
|
+
CronJobExecution,
|
|
16
17
|
UpdateCronJobParams,
|
|
17
18
|
)
|
|
18
19
|
from ._base import AsyncResource, SyncResource, _to_body
|
|
@@ -49,6 +50,11 @@ class CronJobs(SyncResource):
|
|
|
49
50
|
def delete(self, id: str) -> None:
|
|
50
51
|
self._request("DELETE", f"/api/cron/{id}")
|
|
51
52
|
|
|
53
|
+
def trigger(self, id: str) -> CronJobExecution:
|
|
54
|
+
resp = self._request("POST", f"/api/cron/{id}/trigger")
|
|
55
|
+
data = resp.get("execution", resp)
|
|
56
|
+
return self._parse(CronJobExecution, data)
|
|
57
|
+
|
|
52
58
|
def list_groups(self) -> list[CronGroup]:
|
|
53
59
|
resp = self._request("GET", "/api/cron-groups")
|
|
54
60
|
items = resp.get("groups", resp.get("data", []))
|
|
@@ -92,6 +98,11 @@ class AsyncCronJobs(AsyncResource):
|
|
|
92
98
|
async def delete(self, id: str) -> None:
|
|
93
99
|
await self._request("DELETE", f"/api/cron/{id}")
|
|
94
100
|
|
|
101
|
+
async def trigger(self, id: str) -> CronJobExecution:
|
|
102
|
+
resp = await self._request("POST", f"/api/cron/{id}/trigger")
|
|
103
|
+
data = resp.get("execution", resp)
|
|
104
|
+
return self._parse(CronJobExecution, data)
|
|
105
|
+
|
|
95
106
|
async def list_groups(self) -> list[CronGroup]:
|
|
96
107
|
resp = await self._request("GET", "/api/cron-groups")
|
|
97
108
|
items = resp.get("groups", resp.get("data", []))
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import respx
|
|
5
|
+
|
|
6
|
+
from hookbase import Hookbase
|
|
7
|
+
from hookbase.models import CronJob, CronJobExecution
|
|
8
|
+
|
|
9
|
+
from ..conftest import make_paginated_response
|
|
10
|
+
|
|
11
|
+
CRON_JOB_DATA = {
|
|
12
|
+
"id": "cron_1",
|
|
13
|
+
"organizationId": "org_1",
|
|
14
|
+
"groupId": None,
|
|
15
|
+
"name": "Nightly sync",
|
|
16
|
+
"description": None,
|
|
17
|
+
"cronExpression": "0 0 * * *",
|
|
18
|
+
"timezone": "UTC",
|
|
19
|
+
"url": "https://example.com/sync",
|
|
20
|
+
"method": "POST",
|
|
21
|
+
"headers": None,
|
|
22
|
+
"payload": None,
|
|
23
|
+
"timeoutMs": 30000,
|
|
24
|
+
"useStaticIp": False,
|
|
25
|
+
"isActive": True,
|
|
26
|
+
"notifyOnFailure": True,
|
|
27
|
+
"notifyOnSuccess": False,
|
|
28
|
+
"notifyEmails": None,
|
|
29
|
+
"consecutiveFailures": 0,
|
|
30
|
+
"lastRunAt": "2026-09-01T00:00:00Z",
|
|
31
|
+
"nextRunAt": "2026-09-02T00:00:00Z",
|
|
32
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
33
|
+
"updatedAt": "2026-09-01T00:00:00Z",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def mock_api():
|
|
39
|
+
with respx.mock(base_url="https://api.hookbase.app") as mock:
|
|
40
|
+
yield mock
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def client(mock_api):
|
|
45
|
+
c = Hookbase(api_key="whr_test")
|
|
46
|
+
yield c
|
|
47
|
+
c.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_list_cron_jobs_uses_real_field_names(mock_api, client):
|
|
51
|
+
# GET /api/cron sends cronExpression, timezone, nextRunAt, lastRunAt and
|
|
52
|
+
# consecutiveFailures -- not the old, fabricated "schedule"/"lastStatus" fields.
|
|
53
|
+
mock_api.get("/api/cron").respond(200, json=make_paginated_response(
|
|
54
|
+
[CRON_JOB_DATA], data_key="cronJobs", total=1,
|
|
55
|
+
))
|
|
56
|
+
page = client.cron_jobs.list()
|
|
57
|
+
assert len(page) == 1
|
|
58
|
+
job = page.data[0]
|
|
59
|
+
assert isinstance(job, CronJob)
|
|
60
|
+
assert job.cron_expression == "0 0 * * *"
|
|
61
|
+
assert job.next_run_at == "2026-09-02T00:00:00Z"
|
|
62
|
+
assert job.last_run_at == "2026-09-01T00:00:00Z"
|
|
63
|
+
assert job.consecutive_failures == 0
|
|
64
|
+
assert job.payload is None
|
|
65
|
+
assert not hasattr(job, "last_status")
|
|
66
|
+
assert not hasattr(job, "schedule")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_cron_job_uses_real_field_names(mock_api, client):
|
|
70
|
+
mock_api.get("/api/cron/cron_1").respond(200, json={"cronJob": CRON_JOB_DATA})
|
|
71
|
+
job = client.cron_jobs.get("cron_1")
|
|
72
|
+
assert job.cron_expression == "0 0 * * *"
|
|
73
|
+
assert job.timeout_ms == 30000
|
|
74
|
+
assert job.notify_on_failure is True
|
|
75
|
+
assert job.notify_on_success is False
|
|
76
|
+
assert job.consecutive_failures == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_create_cron_job(mock_api, client):
|
|
80
|
+
mock_api.post("/api/cron").respond(200, json={"cronJob": CRON_JOB_DATA})
|
|
81
|
+
job = client.cron_jobs.create({
|
|
82
|
+
"name": "Nightly sync",
|
|
83
|
+
"url": "https://example.com/sync",
|
|
84
|
+
"cron_expression": "0 0 * * *",
|
|
85
|
+
"payload": '{"foo": "bar"}',
|
|
86
|
+
})
|
|
87
|
+
assert isinstance(job, CronJob)
|
|
88
|
+
assert job.cron_expression == "0 0 * * *"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_update_cron_job(mock_api, client):
|
|
92
|
+
mock_api.patch("/api/cron/cron_1").respond(200, json={"success": True})
|
|
93
|
+
client.cron_jobs.update("cron_1", {"cron_expression": "*/5 * * * *", "payload": "{}"})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_delete_cron_job(mock_api, client):
|
|
97
|
+
mock_api.delete("/api/cron/cron_1").respond(200, json={"success": True})
|
|
98
|
+
client.cron_jobs.delete("cron_1")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_trigger_cron_job_calls_trigger_endpoint(mock_api, client):
|
|
102
|
+
route = mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
|
|
103
|
+
"execution": {
|
|
104
|
+
"id": "exec_1",
|
|
105
|
+
"status": "success",
|
|
106
|
+
"responseStatus": 200,
|
|
107
|
+
"latencyMs": 142,
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
result = client.cron_jobs.trigger("cron_1")
|
|
111
|
+
assert route.called
|
|
112
|
+
assert isinstance(result, CronJobExecution)
|
|
113
|
+
assert result.id == "exec_1"
|
|
114
|
+
assert result.status == "success"
|
|
115
|
+
assert result.response_status == 200
|
|
116
|
+
assert result.latency_ms == 142
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_trigger_cron_job_failed_execution(mock_api, client):
|
|
120
|
+
mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
|
|
121
|
+
"execution": {
|
|
122
|
+
"id": "exec_2",
|
|
123
|
+
"status": "failed",
|
|
124
|
+
"responseStatus": None,
|
|
125
|
+
"latencyMs": 30000,
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
result = client.cron_jobs.trigger("cron_1")
|
|
129
|
+
assert result.status == "failed"
|
|
130
|
+
assert result.response_status is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_async_list_cron_jobs(mock_api):
|
|
134
|
+
from hookbase import AsyncHookbase
|
|
135
|
+
|
|
136
|
+
mock_api.get("/api/cron").respond(200, json=make_paginated_response(
|
|
137
|
+
[CRON_JOB_DATA], data_key="cronJobs", total=1,
|
|
138
|
+
))
|
|
139
|
+
client = AsyncHookbase(api_key="whr_test")
|
|
140
|
+
page = await client.cron_jobs.list()
|
|
141
|
+
assert page.data[0].cron_expression == "0 0 * * *"
|
|
142
|
+
await client.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def test_async_trigger_cron_job(mock_api):
|
|
146
|
+
from hookbase import AsyncHookbase
|
|
147
|
+
|
|
148
|
+
mock_api.post("/api/cron/cron_1/trigger").respond(200, json={
|
|
149
|
+
"execution": {
|
|
150
|
+
"id": "exec_1",
|
|
151
|
+
"status": "success",
|
|
152
|
+
"responseStatus": 200,
|
|
153
|
+
"latencyMs": 99,
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
client = AsyncHookbase(api_key="whr_test")
|
|
157
|
+
result = await client.cron_jobs.trigger("cron_1")
|
|
158
|
+
assert isinstance(result, CronJobExecution)
|
|
159
|
+
assert result.latency_ms == 99
|
|
160
|
+
await client.close()
|
|
@@ -272,10 +272,24 @@ def test_update_destination_params_throttle_serializes_camelcase():
|
|
|
272
272
|
|
|
273
273
|
|
|
274
274
|
def test_test_destination(mock_api, client):
|
|
275
|
+
# The API answers with `status`/`latencyMs` (api/src/routes/destinations.ts), not
|
|
276
|
+
# `statusCode`/`duration` — the model aliases onto those field names explicitly.
|
|
275
277
|
mock_api.post("/api/destinations/dst_1/test").respond(200, json={
|
|
276
|
-
"success": True, "
|
|
278
|
+
"success": True, "status": 200, "latencyMs": 150.5, "responseBody": "OK",
|
|
277
279
|
})
|
|
278
280
|
result = client.destinations.test("dst_1")
|
|
279
281
|
assert isinstance(result, TestResult)
|
|
280
282
|
assert result.success is True
|
|
281
283
|
assert result.status_code == 200
|
|
284
|
+
assert result.duration == 150.5
|
|
285
|
+
assert result.response_body == "OK"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_test_destination_failure_surfaces_error(mock_api, client):
|
|
289
|
+
mock_api.post("/api/destinations/dst_1/test").respond(200, json={
|
|
290
|
+
"success": False, "error": "Network error",
|
|
291
|
+
})
|
|
292
|
+
result = client.destinations.test("dst_1")
|
|
293
|
+
assert result.success is False
|
|
294
|
+
assert result.error == "Network error"
|
|
295
|
+
assert result.status_code is None
|
|
@@ -30,16 +30,17 @@ def client(mock_api):
|
|
|
30
30
|
|
|
31
31
|
def test_401_raises_authentication_error(mock_api, client):
|
|
32
32
|
mock_api.get("/api/sources/src_1").respond(
|
|
33
|
-
401, json={"error":
|
|
33
|
+
401, json={"error": "Invalid API key", "code": "authentication_error"}
|
|
34
34
|
)
|
|
35
35
|
with pytest.raises(AuthenticationError) as exc_info:
|
|
36
36
|
client.sources.get("src_1")
|
|
37
37
|
assert exc_info.value.status_code == 401
|
|
38
|
+
assert str(exc_info.value) == "Invalid API key"
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def test_403_raises_forbidden_error(mock_api, client):
|
|
41
42
|
mock_api.get("/api/sources/src_1").respond(
|
|
42
|
-
403, json={"error":
|
|
43
|
+
403, json={"error": "Forbidden", "code": "forbidden"}
|
|
43
44
|
)
|
|
44
45
|
with pytest.raises(ForbiddenError):
|
|
45
46
|
client.sources.get("src_1")
|
|
@@ -47,7 +48,7 @@ def test_403_raises_forbidden_error(mock_api, client):
|
|
|
47
48
|
|
|
48
49
|
def test_404_raises_not_found_error(mock_api, client):
|
|
49
50
|
mock_api.get("/api/sources/src_1").respond(
|
|
50
|
-
404, json={"error":
|
|
51
|
+
404, json={"error": "Not found", "code": "not_found"}
|
|
51
52
|
)
|
|
52
53
|
with pytest.raises(NotFoundError):
|
|
53
54
|
client.sources.get("src_1")
|
|
@@ -56,17 +57,24 @@ def test_404_raises_not_found_error(mock_api, client):
|
|
|
56
57
|
def test_400_raises_validation_error(mock_api, client):
|
|
57
58
|
mock_api.post("/api/sources").respond(
|
|
58
59
|
400,
|
|
59
|
-
json={
|
|
60
|
-
|
|
60
|
+
json={
|
|
61
|
+
"error": "Name is required",
|
|
62
|
+
"code": "validation_error",
|
|
63
|
+
"details": {
|
|
64
|
+
"fieldErrors": {"name": ["required"]},
|
|
65
|
+
"formErrors": [],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
61
68
|
)
|
|
62
69
|
with pytest.raises(ValidationError) as exc_info:
|
|
63
70
|
client.sources.create({"name": ""})
|
|
64
71
|
assert exc_info.value.validation_errors == {"name": ["required"]}
|
|
72
|
+
assert str(exc_info.value) == "Name is required"
|
|
65
73
|
|
|
66
74
|
|
|
67
75
|
def test_409_raises_conflict_error(mock_api, client):
|
|
68
76
|
mock_api.post("/api/sources").respond(
|
|
69
|
-
409, json={"error":
|
|
77
|
+
409, json={"error": "Slug already exists", "code": "conflict"}
|
|
70
78
|
)
|
|
71
79
|
with pytest.raises(ConflictError):
|
|
72
80
|
client.sources.create({"name": "test", "slug": "existing"})
|
|
@@ -75,7 +83,7 @@ def test_409_raises_conflict_error(mock_api, client):
|
|
|
75
83
|
def test_429_raises_rate_limit_error(mock_api, client):
|
|
76
84
|
mock_api.get("/api/sources/src_1").respond(
|
|
77
85
|
429,
|
|
78
|
-
json={"error":
|
|
86
|
+
json={"error": "Too many requests", "code": "rate_limit_exceeded"},
|
|
79
87
|
headers={"retry-after": "30"},
|
|
80
88
|
)
|
|
81
89
|
with pytest.raises(RateLimitError) as exc_info:
|
|
@@ -85,16 +93,45 @@ def test_429_raises_rate_limit_error(mock_api, client):
|
|
|
85
93
|
|
|
86
94
|
def test_500_raises_api_error(mock_api, client):
|
|
87
95
|
mock_api.get("/api/sources/src_1").respond(
|
|
88
|
-
500, json={"error":
|
|
96
|
+
500, json={"error": "Internal error", "code": "internal_error"}
|
|
89
97
|
)
|
|
90
98
|
with pytest.raises(APIError) as exc_info:
|
|
91
99
|
client.sources.get("src_1")
|
|
92
100
|
assert exc_info.value.status_code == 500
|
|
101
|
+
# The real API's `code` is a body-level sibling of `error`, not nested inside it —
|
|
102
|
+
# make sure it's actually read rather than falling back to the generic default.
|
|
103
|
+
assert exc_info.value.code == "internal_error"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_error_code_falls_back_to_default_when_absent(mock_api, client):
|
|
107
|
+
mock_api.get("/api/sources/src_1").respond(500, json={"error": "Internal error"})
|
|
108
|
+
with pytest.raises(APIError) as exc_info:
|
|
109
|
+
client.sources.get("src_1")
|
|
110
|
+
assert exc_info.value.code == "unknown_error"
|
|
93
111
|
|
|
94
112
|
|
|
95
113
|
def test_error_from_response_string_error():
|
|
96
|
-
err = APIError.from_response(500, {"error": "Server error"})
|
|
114
|
+
err = APIError.from_response(500, {"error": "Server error", "code": "server_error"})
|
|
97
115
|
assert str(err) == "Server error"
|
|
116
|
+
assert err.code == "server_error"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_error_from_response_string_error_default_code():
|
|
120
|
+
err = APIError.from_response(500, {"error": "Server error"})
|
|
121
|
+
assert err.code == "unknown_error"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_error_from_response_validation_details():
|
|
125
|
+
err = APIError.from_response(
|
|
126
|
+
400,
|
|
127
|
+
{
|
|
128
|
+
"error": "Name is required",
|
|
129
|
+
"code": "validation_error",
|
|
130
|
+
"details": {"fieldErrors": {"name": ["required"]}},
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
assert isinstance(err, ValidationError)
|
|
134
|
+
assert err.validation_errors == {"name": ["required"]}
|
|
98
135
|
|
|
99
136
|
|
|
100
137
|
def test_error_from_response_dict_error():
|
|
@@ -102,3 +139,4 @@ def test_error_from_response_dict_error():
|
|
|
102
139
|
500, {"error": {"message": "Detailed error", "code": "server_error"}},
|
|
103
140
|
)
|
|
104
141
|
assert str(err) == "Detailed error"
|
|
142
|
+
assert err.code == "server_error"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.6.0"
|
|
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
|