hookbase 2.5.0__tar.gz → 2.7.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.5.0 → hookbase-2.7.0}/PKG-INFO +2 -2
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/_client.py +8 -2
- hookbase-2.7.0/src/hookbase/_version.py +1 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/errors.py +9 -2
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/__init__.py +8 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/cron_jobs.py +29 -7
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/destinations.py +38 -7
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/cron_jobs.py +11 -0
- hookbase-2.7.0/tests/resources/test_cron_jobs.py +160 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_destinations.py +89 -1
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_errors.py +47 -9
- hookbase-2.5.0/src/hookbase/_version.py +0 -1
- {hookbase-2.5.0 → hookbase-2.7.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/.gitignore +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/LICENSE +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/README.md +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/examples/async_usage.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/examples/basic_inbound.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/examples/error_handling.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/examples/send_webhooks.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/examples/verify_webhooks.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/pyproject.toml +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/__init__.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/_constants.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/_pagination.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/client.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/applications.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/common.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/deliveries.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/dlq.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/endpoints.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/event_types.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/events.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/messages.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/sources.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/subscriptions.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/transforms.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/py.typed +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/_base.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/deliveries.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/destinations.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/dlq.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/endpoints.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/event_types.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/events.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/message_log.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/messages.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/sources.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/subscriptions.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/src/hookbase/webhook.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/__init__.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/conftest.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/__init__.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_applications.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_dlq.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_messages.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/resources/test_sources.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_client.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.0}/tests/test_pagination.py +0 -0
- {hookbase-2.5.0 → hookbase-2.7.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.7.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.7.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 (
|
|
@@ -26,8 +27,11 @@ from .destinations import (
|
|
|
26
27
|
GCSConfig,
|
|
27
28
|
HttpMethod,
|
|
28
29
|
R2Config,
|
|
30
|
+
RateUnit,
|
|
29
31
|
S3Config,
|
|
30
32
|
TestResult,
|
|
33
|
+
Throttle,
|
|
34
|
+
ThrottleMode,
|
|
31
35
|
UpdateDestinationParams,
|
|
32
36
|
)
|
|
33
37
|
from .dlq import (
|
|
@@ -120,6 +124,7 @@ __all__ = [
|
|
|
120
124
|
"CreateCronJobParams",
|
|
121
125
|
"CronGroup",
|
|
122
126
|
"CronJob",
|
|
127
|
+
"CronJobExecution",
|
|
123
128
|
"UpdateCronJobParams",
|
|
124
129
|
# Deliveries
|
|
125
130
|
"BulkReplayResult",
|
|
@@ -137,8 +142,11 @@ __all__ = [
|
|
|
137
142
|
"GCSConfig",
|
|
138
143
|
"HttpMethod",
|
|
139
144
|
"R2Config",
|
|
145
|
+
"RateUnit",
|
|
140
146
|
"S3Config",
|
|
141
147
|
"TestResult",
|
|
148
|
+
"Throttle",
|
|
149
|
+
"ThrottleMode",
|
|
142
150
|
"UpdateDestinationParams",
|
|
143
151
|
# DLQ
|
|
144
152
|
"DlqBulkDeleteResult",
|
|
@@ -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
|
|
6
|
+
from pydantic import field_validator, model_validator
|
|
7
7
|
|
|
8
8
|
from ._base import HookbaseModel
|
|
9
9
|
|
|
@@ -13,6 +13,8 @@ DestinationType = Literal["http", "s3", "r2", "gcs", "azure_blob"]
|
|
|
13
13
|
FileFormat = Literal["json", "jsonl"]
|
|
14
14
|
PartitionBy = Literal["date", "hour", "source"]
|
|
15
15
|
FieldMappingType = Literal["string", "number", "boolean", "timestamp", "json"]
|
|
16
|
+
ThrottleMode = Literal["off", "rate", "concurrency"]
|
|
17
|
+
RateUnit = Literal["second", "minute", "hour"]
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
class FieldMapping(HookbaseModel):
|
|
@@ -57,6 +59,14 @@ class AzureBlobConfig(HookbaseModel):
|
|
|
57
59
|
partition_by: PartitionBy | None = None
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
class Throttle(HookbaseModel):
|
|
63
|
+
mode: ThrottleMode = "off"
|
|
64
|
+
rate_limit: int | None = None
|
|
65
|
+
rate_unit: RateUnit | None = None
|
|
66
|
+
max_concurrency: int | None = None
|
|
67
|
+
queue_limit: int | None = None
|
|
68
|
+
|
|
69
|
+
|
|
60
70
|
class Destination(HookbaseModel):
|
|
61
71
|
id: str
|
|
62
72
|
organization_id: str | None = None
|
|
@@ -72,8 +82,7 @@ class Destination(HookbaseModel):
|
|
|
72
82
|
timeout: int = 30
|
|
73
83
|
retry_count: int = 3
|
|
74
84
|
retry_interval: int = 60
|
|
75
|
-
|
|
76
|
-
rate_limit_window: int | None = None
|
|
85
|
+
throttle: Throttle | None = None
|
|
77
86
|
is_active: bool = True
|
|
78
87
|
use_static_ip: bool = True
|
|
79
88
|
config: dict[str, Any] | None = None
|
|
@@ -85,6 +94,30 @@ class Destination(HookbaseModel):
|
|
|
85
94
|
created_at: str = ""
|
|
86
95
|
updated_at: str = ""
|
|
87
96
|
|
|
97
|
+
@model_validator(mode="before")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _normalize_throttle(cls, data: Any) -> Any:
|
|
100
|
+
"""Normalize the two response shapes the API uses for throttle data.
|
|
101
|
+
|
|
102
|
+
POST /api/destinations (create) and GET /api/destinations/export nest
|
|
103
|
+
throttle fields under a `throttle` object, but GET /api/destinations
|
|
104
|
+
(list) and GET /api/destinations/:id return them as flat top-level
|
|
105
|
+
`throttleMode`/`throttleRateLimit`/`throttleRateUnit`/
|
|
106
|
+
`throttleMaxConcurrency`/`throttleQueueLimit` fields (spread directly
|
|
107
|
+
from the DB row). Fold the flat shape into a nested `throttle` dict
|
|
108
|
+
so both response shapes populate the same field.
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(data, dict) and "throttle" not in data and "throttleMode" in data:
|
|
111
|
+
data = dict(data)
|
|
112
|
+
data["throttle"] = {
|
|
113
|
+
"mode": data.pop("throttleMode", None) or "off",
|
|
114
|
+
"rateLimit": data.pop("throttleRateLimit", None),
|
|
115
|
+
"rateUnit": data.pop("throttleRateUnit", None),
|
|
116
|
+
"maxConcurrency": data.pop("throttleMaxConcurrency", None),
|
|
117
|
+
"queueLimit": data.pop("throttleQueueLimit", None),
|
|
118
|
+
}
|
|
119
|
+
return data
|
|
120
|
+
|
|
88
121
|
@field_validator("headers", mode="before")
|
|
89
122
|
@classmethod
|
|
90
123
|
def parse_headers(cls, v: Any) -> Any:
|
|
@@ -146,8 +179,7 @@ class CreateDestinationParams(HookbaseModel):
|
|
|
146
179
|
timeout: int | None = None
|
|
147
180
|
retry_count: int | None = None
|
|
148
181
|
retry_interval: int | None = None
|
|
149
|
-
|
|
150
|
-
rate_limit_window: int | None = None
|
|
182
|
+
throttle: Throttle | None = None
|
|
151
183
|
config: dict[str, Any] | None = None
|
|
152
184
|
field_mapping: list[FieldMapping] | None = None
|
|
153
185
|
use_static_ip: bool | None = None
|
|
@@ -166,8 +198,7 @@ class UpdateDestinationParams(HookbaseModel):
|
|
|
166
198
|
timeout: int | None = None
|
|
167
199
|
retry_count: int | None = None
|
|
168
200
|
retry_interval: int | None = None
|
|
169
|
-
|
|
170
|
-
rate_limit_window: int | None = None
|
|
201
|
+
throttle: Throttle | None = None
|
|
171
202
|
is_active: bool | None = None
|
|
172
203
|
config: dict[str, Any] | None = None
|
|
173
204
|
field_mapping: list[FieldMapping] | 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()
|
|
@@ -4,7 +4,14 @@ import pytest
|
|
|
4
4
|
import respx
|
|
5
5
|
|
|
6
6
|
from hookbase import Hookbase
|
|
7
|
-
from hookbase.models import
|
|
7
|
+
from hookbase.models import (
|
|
8
|
+
CreateDestinationParams,
|
|
9
|
+
Destination,
|
|
10
|
+
FieldMapping,
|
|
11
|
+
TestResult,
|
|
12
|
+
Throttle,
|
|
13
|
+
UpdateDestinationParams,
|
|
14
|
+
)
|
|
8
15
|
|
|
9
16
|
from ..conftest import make_paginated_response
|
|
10
17
|
|
|
@@ -183,6 +190,87 @@ def test_destination_without_type_defaults():
|
|
|
183
190
|
assert dest.field_mapping is None
|
|
184
191
|
|
|
185
192
|
|
|
193
|
+
def test_throttle_model():
|
|
194
|
+
throttle = Throttle(mode="rate", rate_limit=10, rate_unit="minute")
|
|
195
|
+
assert throttle.mode == "rate"
|
|
196
|
+
assert throttle.rate_limit == 10
|
|
197
|
+
assert throttle.rate_unit == "minute"
|
|
198
|
+
assert throttle.max_concurrency is None
|
|
199
|
+
assert throttle.queue_limit is None
|
|
200
|
+
|
|
201
|
+
default_throttle = Throttle()
|
|
202
|
+
assert default_throttle.mode == "off"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_create_destination_response_nests_throttle(mock_api, client):
|
|
206
|
+
"""POST /api/destinations nests throttle fields under `throttle`."""
|
|
207
|
+
data = {
|
|
208
|
+
**DEST_DATA,
|
|
209
|
+
"throttle": {
|
|
210
|
+
"mode": "rate",
|
|
211
|
+
"rateLimit": 100,
|
|
212
|
+
"rateUnit": "minute",
|
|
213
|
+
"maxConcurrency": None,
|
|
214
|
+
"queueLimit": 50,
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
mock_api.post("/api/destinations").respond(200, json={"destination": data})
|
|
218
|
+
dest = client.destinations.create({"name": "Backend", "url": "https://example.com/webhooks"})
|
|
219
|
+
assert dest.throttle is not None
|
|
220
|
+
assert dest.throttle.mode == "rate"
|
|
221
|
+
assert dest.throttle.rate_limit == 100
|
|
222
|
+
assert dest.throttle.rate_unit == "minute"
|
|
223
|
+
assert dest.throttle.max_concurrency is None
|
|
224
|
+
assert dest.throttle.queue_limit == 50
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_get_destination_response_has_flat_throttle_fields(mock_api, client):
|
|
228
|
+
"""GET /api/destinations/:id returns throttle fields flat (spread from the
|
|
229
|
+
DB row) rather than nested under `throttle`; the model must normalize
|
|
230
|
+
this into the same `throttle` field used by the create/export shape."""
|
|
231
|
+
data = {
|
|
232
|
+
**DEST_DATA,
|
|
233
|
+
"throttleMode": "concurrency",
|
|
234
|
+
"throttleRateLimit": None,
|
|
235
|
+
"throttleRateUnit": None,
|
|
236
|
+
"throttleMaxConcurrency": 5,
|
|
237
|
+
"throttleQueueLimit": None,
|
|
238
|
+
}
|
|
239
|
+
mock_api.get("/api/destinations/dst_1").respond(200, json={"destination": data})
|
|
240
|
+
dest = client.destinations.get("dst_1")
|
|
241
|
+
assert dest.throttle is not None
|
|
242
|
+
assert dest.throttle.mode == "concurrency"
|
|
243
|
+
assert dest.throttle.max_concurrency == 5
|
|
244
|
+
assert dest.throttle.rate_limit is None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_destination_without_throttle_data():
|
|
248
|
+
dest = Destination(**DEST_DATA)
|
|
249
|
+
assert dest.throttle is None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_create_destination_params_throttle_serializes_camelcase():
|
|
253
|
+
params = CreateDestinationParams(
|
|
254
|
+
name="Backend",
|
|
255
|
+
url="https://example.com/webhooks",
|
|
256
|
+
throttle=Throttle(mode="rate", rate_limit=10, rate_unit="minute"),
|
|
257
|
+
)
|
|
258
|
+
body = params.model_dump(by_alias=True, exclude_none=True)
|
|
259
|
+
assert body["throttle"] == {"mode": "rate", "rateLimit": 10, "rateUnit": "minute"}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_update_destination_params_throttle_serializes_camelcase():
|
|
263
|
+
params = UpdateDestinationParams(
|
|
264
|
+
throttle=Throttle(mode="concurrency", max_concurrency=25, queue_limit=100),
|
|
265
|
+
)
|
|
266
|
+
body = params.model_dump(by_alias=True, exclude_none=True)
|
|
267
|
+
assert body["throttle"] == {
|
|
268
|
+
"mode": "concurrency",
|
|
269
|
+
"maxConcurrency": 25,
|
|
270
|
+
"queueLimit": 100,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
186
274
|
def test_test_destination(mock_api, client):
|
|
187
275
|
mock_api.post("/api/destinations/dst_1/test").respond(200, json={
|
|
188
276
|
"success": True, "statusCode": 200, "duration": 150.5,
|
|
@@ -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.5.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
|