posthook-python 1.0.0__py3-none-any.whl
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.
- posthook/__init__.py +73 -0
- posthook/_client.py +124 -0
- posthook/_errors.py +120 -0
- posthook/_http.py +224 -0
- posthook/_models.py +162 -0
- posthook/_resources/__init__.py +10 -0
- posthook/_resources/_hooks.py +517 -0
- posthook/_resources/_signatures.py +169 -0
- posthook/_version.py +1 -0
- posthook/py.typed +0 -0
- posthook_python-1.0.0.dist-info/METADATA +513 -0
- posthook_python-1.0.0.dist-info/RECORD +14 -0
- posthook_python-1.0.0.dist-info/WHEEL +4 -0
- posthook_python-1.0.0.dist-info/licenses/LICENSE +21 -0
posthook/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Posthook Python SDK — schedule, manage, and verify webhooks."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncPosthook, Posthook
|
|
4
|
+
from ._errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
ForbiddenError,
|
|
8
|
+
InternalServerError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PayloadTooLargeError,
|
|
11
|
+
PosthookConnectionError,
|
|
12
|
+
PosthookError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
SignatureVerificationError,
|
|
15
|
+
)
|
|
16
|
+
from ._models import (
|
|
17
|
+
SORT_BY_CREATED_AT,
|
|
18
|
+
SORT_BY_POST_AT,
|
|
19
|
+
SORT_ORDER_ASC,
|
|
20
|
+
SORT_ORDER_DESC,
|
|
21
|
+
STATUS_COMPLETED,
|
|
22
|
+
STATUS_FAILED,
|
|
23
|
+
STATUS_PENDING,
|
|
24
|
+
STATUS_RETRY,
|
|
25
|
+
STRATEGY_EXPONENTIAL,
|
|
26
|
+
STRATEGY_FIXED,
|
|
27
|
+
BulkActionResult,
|
|
28
|
+
Delivery,
|
|
29
|
+
Hook,
|
|
30
|
+
HookRetryOverride,
|
|
31
|
+
QuotaInfo,
|
|
32
|
+
)
|
|
33
|
+
from ._resources._signatures import SignaturesService, create_signatures
|
|
34
|
+
from ._version import VERSION as __version__
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Clients
|
|
38
|
+
"Posthook",
|
|
39
|
+
"AsyncPosthook",
|
|
40
|
+
# Models
|
|
41
|
+
"Hook",
|
|
42
|
+
"HookRetryOverride",
|
|
43
|
+
"QuotaInfo",
|
|
44
|
+
"BulkActionResult",
|
|
45
|
+
"Delivery",
|
|
46
|
+
# Resources
|
|
47
|
+
"SignaturesService",
|
|
48
|
+
"create_signatures",
|
|
49
|
+
# Constants
|
|
50
|
+
"STATUS_PENDING",
|
|
51
|
+
"STATUS_RETRY",
|
|
52
|
+
"STATUS_COMPLETED",
|
|
53
|
+
"STATUS_FAILED",
|
|
54
|
+
"SORT_BY_POST_AT",
|
|
55
|
+
"SORT_BY_CREATED_AT",
|
|
56
|
+
"SORT_ORDER_ASC",
|
|
57
|
+
"SORT_ORDER_DESC",
|
|
58
|
+
"STRATEGY_FIXED",
|
|
59
|
+
"STRATEGY_EXPONENTIAL",
|
|
60
|
+
# Errors
|
|
61
|
+
"PosthookError",
|
|
62
|
+
"BadRequestError",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"ForbiddenError",
|
|
65
|
+
"NotFoundError",
|
|
66
|
+
"PayloadTooLargeError",
|
|
67
|
+
"RateLimitError",
|
|
68
|
+
"InternalServerError",
|
|
69
|
+
"PosthookConnectionError",
|
|
70
|
+
"SignatureVerificationError",
|
|
71
|
+
# Version
|
|
72
|
+
"__version__",
|
|
73
|
+
]
|
posthook/_client.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._errors import AuthenticationError
|
|
9
|
+
from ._http import (
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
DEFAULT_TIMEOUT,
|
|
12
|
+
AsyncHttpClient,
|
|
13
|
+
SyncHttpClient,
|
|
14
|
+
)
|
|
15
|
+
from ._resources._hooks import AsyncHooksService, HooksService
|
|
16
|
+
from ._resources._signatures import SignaturesService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Posthook:
|
|
20
|
+
"""Synchronous Posthook API client.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: Your Posthook API key. Falls back to the ``POSTHOOK_API_KEY``
|
|
24
|
+
environment variable if not provided.
|
|
25
|
+
base_url: Override the API base URL.
|
|
26
|
+
timeout: Request timeout in seconds.
|
|
27
|
+
signing_key: Key for webhook signature verification. Falls back to
|
|
28
|
+
``POSTHOOK_SIGNING_KEY`` env var.
|
|
29
|
+
http_client: A custom ``httpx.Client`` instance.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
hooks: HooksService
|
|
33
|
+
signatures: SignaturesService
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str | None = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
signing_key: str | None = None,
|
|
42
|
+
http_client: httpx.Client | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
resolved_key = api_key or os.environ.get("POSTHOOK_API_KEY", "")
|
|
45
|
+
if not resolved_key:
|
|
46
|
+
raise AuthenticationError(
|
|
47
|
+
"No API key provided. Pass api_key to the Posthook constructor "
|
|
48
|
+
"or set the POSTHOOK_API_KEY environment variable."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
resolved_signing_key = signing_key or os.environ.get("POSTHOOK_SIGNING_KEY")
|
|
52
|
+
|
|
53
|
+
self._http = SyncHttpClient(
|
|
54
|
+
resolved_key,
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
http_client=http_client,
|
|
58
|
+
)
|
|
59
|
+
self.hooks = HooksService(self._http)
|
|
60
|
+
self.signatures = SignaturesService(resolved_signing_key)
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Close the underlying HTTP client."""
|
|
64
|
+
self._http.close()
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> Posthook:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *args: Any) -> None:
|
|
70
|
+
self.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AsyncPosthook:
|
|
74
|
+
"""Asynchronous Posthook API client.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
api_key: Your Posthook API key. Falls back to the ``POSTHOOK_API_KEY``
|
|
78
|
+
environment variable if not provided.
|
|
79
|
+
base_url: Override the API base URL.
|
|
80
|
+
timeout: Request timeout in seconds.
|
|
81
|
+
signing_key: Key for webhook signature verification. Falls back to
|
|
82
|
+
``POSTHOOK_SIGNING_KEY`` env var.
|
|
83
|
+
http_client: A custom ``httpx.AsyncClient`` instance.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
hooks: AsyncHooksService
|
|
87
|
+
signatures: SignaturesService
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
api_key: str | None = None,
|
|
92
|
+
*,
|
|
93
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
94
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
95
|
+
signing_key: str | None = None,
|
|
96
|
+
http_client: httpx.AsyncClient | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
resolved_key = api_key or os.environ.get("POSTHOOK_API_KEY", "")
|
|
99
|
+
if not resolved_key:
|
|
100
|
+
raise AuthenticationError(
|
|
101
|
+
"No API key provided. Pass api_key to the AsyncPosthook constructor "
|
|
102
|
+
"or set the POSTHOOK_API_KEY environment variable."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
resolved_signing_key = signing_key or os.environ.get("POSTHOOK_SIGNING_KEY")
|
|
106
|
+
|
|
107
|
+
self._http = AsyncHttpClient(
|
|
108
|
+
resolved_key,
|
|
109
|
+
base_url=base_url,
|
|
110
|
+
timeout=timeout,
|
|
111
|
+
http_client=http_client,
|
|
112
|
+
)
|
|
113
|
+
self.hooks = AsyncHooksService(self._http)
|
|
114
|
+
self.signatures = SignaturesService(resolved_signing_key)
|
|
115
|
+
|
|
116
|
+
async def close(self) -> None:
|
|
117
|
+
"""Close the underlying HTTP client."""
|
|
118
|
+
await self._http.close()
|
|
119
|
+
|
|
120
|
+
async def __aenter__(self) -> AsyncPosthook:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
124
|
+
await self.close()
|
posthook/_errors.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PosthookError(Exception):
|
|
5
|
+
"""Base error class for all Posthook SDK errors."""
|
|
6
|
+
|
|
7
|
+
status_code: int | None
|
|
8
|
+
code: str
|
|
9
|
+
message: str
|
|
10
|
+
headers: dict[str, str] | None
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
status_code: int | None = None,
|
|
17
|
+
code: str = "",
|
|
18
|
+
headers: dict[str, str] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.code = code
|
|
24
|
+
self.headers = headers
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
cls = self.__class__.__name__
|
|
28
|
+
return f"{cls}(message={self.message!r}, status_code={self.status_code})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BadRequestError(PosthookError):
|
|
32
|
+
"""Raised for HTTP 400 responses."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
|
|
35
|
+
super().__init__(message, status_code=400, code="bad_request", headers=headers)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(PosthookError):
|
|
39
|
+
"""Raised for HTTP 401 responses."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
|
|
42
|
+
super().__init__(message, status_code=401, code="authentication_error", headers=headers)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ForbiddenError(PosthookError):
|
|
46
|
+
"""Raised for HTTP 403 responses."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
|
|
49
|
+
super().__init__(message, status_code=403, code="forbidden", headers=headers)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NotFoundError(PosthookError):
|
|
53
|
+
"""Raised for HTTP 404 responses."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
|
|
56
|
+
super().__init__(message, status_code=404, code="not_found", headers=headers)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PayloadTooLargeError(PosthookError):
|
|
60
|
+
"""Raised for HTTP 413 responses."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
|
|
63
|
+
super().__init__(message, status_code=413, code="payload_too_large", headers=headers)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RateLimitError(PosthookError):
|
|
67
|
+
"""Raised for HTTP 429 responses."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
|
|
70
|
+
super().__init__(message, status_code=429, code="rate_limit_exceeded", headers=headers)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InternalServerError(PosthookError):
|
|
74
|
+
"""Raised for HTTP 5xx responses."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self, message: str, status_code: int = 500, headers: dict[str, str] | None = None
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(message, status_code=status_code, code="internal_error", headers=headers)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PosthookConnectionError(PosthookError):
|
|
83
|
+
"""Raised for network or timeout errors.
|
|
84
|
+
|
|
85
|
+
Named ``PosthookConnectionError`` to avoid shadowing the builtin
|
|
86
|
+
``ConnectionError``.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str) -> None:
|
|
90
|
+
super().__init__(message, code="connection_error")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SignatureVerificationError(PosthookError):
|
|
94
|
+
"""Raised when webhook signature verification fails."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, message: str) -> None:
|
|
97
|
+
super().__init__(message, code="signature_verification_error")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _create_error(
|
|
101
|
+
status_code: int,
|
|
102
|
+
message: str,
|
|
103
|
+
headers: dict[str, str] | None = None,
|
|
104
|
+
) -> PosthookError:
|
|
105
|
+
"""Create the appropriate error subclass for the given HTTP status code."""
|
|
106
|
+
if status_code == 400:
|
|
107
|
+
return BadRequestError(message, headers)
|
|
108
|
+
if status_code == 401:
|
|
109
|
+
return AuthenticationError(message, headers)
|
|
110
|
+
if status_code == 403:
|
|
111
|
+
return ForbiddenError(message, headers)
|
|
112
|
+
if status_code == 404:
|
|
113
|
+
return NotFoundError(message, headers)
|
|
114
|
+
if status_code == 413:
|
|
115
|
+
return PayloadTooLargeError(message, headers)
|
|
116
|
+
if status_code == 429:
|
|
117
|
+
return RateLimitError(message, headers)
|
|
118
|
+
if status_code >= 500:
|
|
119
|
+
return InternalServerError(message, status_code, headers)
|
|
120
|
+
return PosthookError(message, status_code=status_code, code="unknown_error", headers=headers)
|
posthook/_http.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import platform
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._errors import PosthookConnectionError, PosthookError, _create_error
|
|
12
|
+
from ._models import QuotaInfo
|
|
13
|
+
from ._version import VERSION
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("posthook")
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.posthook.io"
|
|
18
|
+
DEFAULT_TIMEOUT = 30.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_quota(headers: httpx.Headers) -> QuotaInfo | None:
|
|
22
|
+
limit = headers.get("posthook-hookquota-limit")
|
|
23
|
+
if not limit:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
resets_at: datetime | None = None
|
|
27
|
+
raw_resets = headers.get("posthook-hookquota-resets-at", "")
|
|
28
|
+
if raw_resets:
|
|
29
|
+
try:
|
|
30
|
+
resets_at = datetime.fromisoformat(raw_resets.replace("Z", "+00:00"))
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
return QuotaInfo(
|
|
35
|
+
limit=int(limit),
|
|
36
|
+
usage=int(headers.get("posthook-hookquota-usage", "0")),
|
|
37
|
+
remaining=int(headers.get("posthook-hookquota-remaining", "0")),
|
|
38
|
+
resets_at=resets_at,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_USER_AGENT = (
|
|
43
|
+
f"posthook-python/{VERSION}"
|
|
44
|
+
f" (Python {platform.python_version()}; {platform.system()})"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _headers(api_key: str) -> dict[str, str]:
|
|
49
|
+
return {
|
|
50
|
+
"X-API-Key": api_key,
|
|
51
|
+
"User-Agent": _USER_AGENT,
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unwrap_data(body: dict[str, Any]) -> Any:
|
|
57
|
+
"""Extract 'data' from the API response envelope."""
|
|
58
|
+
return body.get("data")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_error_message(response: httpx.Response) -> str:
|
|
62
|
+
try:
|
|
63
|
+
body = response.json()
|
|
64
|
+
return body.get("error", f"HTTP {response.status_code}")
|
|
65
|
+
except Exception:
|
|
66
|
+
return f"HTTP {response.status_code}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SyncHttpClient:
|
|
70
|
+
"""Synchronous HTTP client wrapping httpx.Client."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: str,
|
|
75
|
+
*,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
78
|
+
http_client: httpx.Client | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._api_key = api_key
|
|
81
|
+
self._base_url = base_url.rstrip("/")
|
|
82
|
+
self._owns_client = http_client is None
|
|
83
|
+
self._client = http_client or httpx.Client(
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
headers=_headers(api_key),
|
|
86
|
+
)
|
|
87
|
+
if http_client is not None:
|
|
88
|
+
self._client.headers.update(_headers(api_key))
|
|
89
|
+
|
|
90
|
+
def request(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
*,
|
|
95
|
+
json: Any = None,
|
|
96
|
+
params: dict[str, Any] | None = None,
|
|
97
|
+
timeout: float | None = None,
|
|
98
|
+
) -> tuple[Any, httpx.Headers]:
|
|
99
|
+
url = f"{self._base_url}{path}"
|
|
100
|
+
|
|
101
|
+
# Filter out None values from params
|
|
102
|
+
if params:
|
|
103
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
104
|
+
|
|
105
|
+
start = time.monotonic()
|
|
106
|
+
try:
|
|
107
|
+
kwargs: dict[str, Any] = {"json": json, "params": params}
|
|
108
|
+
if timeout is not None:
|
|
109
|
+
kwargs["timeout"] = timeout
|
|
110
|
+
response = self._client.request(
|
|
111
|
+
method, url, **kwargs
|
|
112
|
+
)
|
|
113
|
+
elapsed = time.monotonic() - start
|
|
114
|
+
logger.debug("%s %s -> %d (%.3fs)", method, path, response.status_code, elapsed)
|
|
115
|
+
|
|
116
|
+
if response.status_code >= 400:
|
|
117
|
+
msg = _extract_error_message(response)
|
|
118
|
+
hdrs = dict(response.headers)
|
|
119
|
+
raise _create_error(response.status_code, msg, hdrs)
|
|
120
|
+
|
|
121
|
+
body = response.json()
|
|
122
|
+
return body, response.headers
|
|
123
|
+
|
|
124
|
+
except PosthookError:
|
|
125
|
+
raise
|
|
126
|
+
except httpx.TimeoutException as exc:
|
|
127
|
+
raise PosthookConnectionError(f"Request timed out: {exc}") from exc
|
|
128
|
+
except httpx.HTTPError as exc:
|
|
129
|
+
raise PosthookConnectionError(f"Network error: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
def request_data(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
path: str,
|
|
135
|
+
*,
|
|
136
|
+
json: Any = None,
|
|
137
|
+
params: dict[str, Any] | None = None,
|
|
138
|
+
timeout: float | None = None,
|
|
139
|
+
) -> tuple[Any, httpx.Headers]:
|
|
140
|
+
"""Make a request and return (unwrapped_data, headers)."""
|
|
141
|
+
body, headers = self.request(method, path, json=json, params=params, timeout=timeout)
|
|
142
|
+
return _unwrap_data(body), headers
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
if self._owns_client:
|
|
146
|
+
self._client.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class AsyncHttpClient:
|
|
150
|
+
"""Asynchronous HTTP client wrapping httpx.AsyncClient."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
api_key: str,
|
|
155
|
+
*,
|
|
156
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
157
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
158
|
+
http_client: httpx.AsyncClient | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
self._api_key = api_key
|
|
161
|
+
self._base_url = base_url.rstrip("/")
|
|
162
|
+
self._owns_client = http_client is None
|
|
163
|
+
self._client = http_client or httpx.AsyncClient(
|
|
164
|
+
timeout=timeout,
|
|
165
|
+
headers=_headers(api_key),
|
|
166
|
+
)
|
|
167
|
+
if http_client is not None:
|
|
168
|
+
self._client.headers.update(_headers(api_key))
|
|
169
|
+
|
|
170
|
+
async def request(
|
|
171
|
+
self,
|
|
172
|
+
method: str,
|
|
173
|
+
path: str,
|
|
174
|
+
*,
|
|
175
|
+
json: Any = None,
|
|
176
|
+
params: dict[str, Any] | None = None,
|
|
177
|
+
timeout: float | None = None,
|
|
178
|
+
) -> tuple[Any, httpx.Headers]:
|
|
179
|
+
url = f"{self._base_url}{path}"
|
|
180
|
+
|
|
181
|
+
if params:
|
|
182
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
183
|
+
|
|
184
|
+
start = time.monotonic()
|
|
185
|
+
try:
|
|
186
|
+
kwargs: dict[str, Any] = {"json": json, "params": params}
|
|
187
|
+
if timeout is not None:
|
|
188
|
+
kwargs["timeout"] = timeout
|
|
189
|
+
response = await self._client.request(
|
|
190
|
+
method, url, **kwargs
|
|
191
|
+
)
|
|
192
|
+
elapsed = time.monotonic() - start
|
|
193
|
+
logger.debug("%s %s -> %d (%.3fs)", method, path, response.status_code, elapsed)
|
|
194
|
+
|
|
195
|
+
if response.status_code >= 400:
|
|
196
|
+
msg = _extract_error_message(response)
|
|
197
|
+
hdrs = dict(response.headers)
|
|
198
|
+
raise _create_error(response.status_code, msg, hdrs)
|
|
199
|
+
|
|
200
|
+
body = response.json()
|
|
201
|
+
return body, response.headers
|
|
202
|
+
|
|
203
|
+
except PosthookError:
|
|
204
|
+
raise
|
|
205
|
+
except httpx.TimeoutException as exc:
|
|
206
|
+
raise PosthookConnectionError(f"Request timed out: {exc}") from exc
|
|
207
|
+
except httpx.HTTPError as exc:
|
|
208
|
+
raise PosthookConnectionError(f"Network error: {exc}") from exc
|
|
209
|
+
|
|
210
|
+
async def request_data(
|
|
211
|
+
self,
|
|
212
|
+
method: str,
|
|
213
|
+
path: str,
|
|
214
|
+
*,
|
|
215
|
+
json: Any = None,
|
|
216
|
+
params: dict[str, Any] | None = None,
|
|
217
|
+
timeout: float | None = None,
|
|
218
|
+
) -> tuple[Any, httpx.Headers]:
|
|
219
|
+
body, headers = await self.request(method, path, json=json, params=params, timeout=timeout)
|
|
220
|
+
return _unwrap_data(body), headers
|
|
221
|
+
|
|
222
|
+
async def close(self) -> None:
|
|
223
|
+
if self._owns_client:
|
|
224
|
+
await self._client.aclose()
|
posthook/_models.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Hook status constants.
|
|
8
|
+
STATUS_PENDING = "pending"
|
|
9
|
+
STATUS_RETRY = "retry"
|
|
10
|
+
STATUS_COMPLETED = "completed"
|
|
11
|
+
STATUS_FAILED = "failed"
|
|
12
|
+
|
|
13
|
+
# Sort field constants.
|
|
14
|
+
SORT_BY_POST_AT = "postAt"
|
|
15
|
+
SORT_BY_CREATED_AT = "createdAt"
|
|
16
|
+
|
|
17
|
+
# Sort order constants.
|
|
18
|
+
SORT_ORDER_ASC = "ASC"
|
|
19
|
+
SORT_ORDER_DESC = "DESC"
|
|
20
|
+
|
|
21
|
+
# Retry strategy constants.
|
|
22
|
+
STRATEGY_FIXED = "fixed"
|
|
23
|
+
STRATEGY_EXPONENTIAL = "exponential"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_dt(value: str) -> datetime:
|
|
27
|
+
"""Parse an RFC 3339 timestamp string into a datetime."""
|
|
28
|
+
if not value:
|
|
29
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
30
|
+
# Handle "Z" suffix which Python's fromisoformat doesn't support before 3.11
|
|
31
|
+
if value.endswith("Z"):
|
|
32
|
+
value = value[:-1] + "+00:00"
|
|
33
|
+
return datetime.fromisoformat(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class QuotaInfo:
|
|
38
|
+
"""Hook quota information parsed from response headers."""
|
|
39
|
+
|
|
40
|
+
limit: int
|
|
41
|
+
usage: int
|
|
42
|
+
remaining: int
|
|
43
|
+
resets_at: datetime | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class HookRetryOverride:
|
|
48
|
+
"""Per-hook retry configuration that overrides project defaults."""
|
|
49
|
+
|
|
50
|
+
min_retries: int
|
|
51
|
+
delay_secs: int
|
|
52
|
+
strategy: str
|
|
53
|
+
backoff_factor: float | None = None
|
|
54
|
+
max_delay_secs: int | None = None
|
|
55
|
+
jitter: bool = False
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict[str, Any]) -> HookRetryOverride:
|
|
59
|
+
return cls(
|
|
60
|
+
min_retries=data["minRetries"],
|
|
61
|
+
delay_secs=data["delaySecs"],
|
|
62
|
+
strategy=data["strategy"],
|
|
63
|
+
backoff_factor=data.get("backoffFactor"),
|
|
64
|
+
max_delay_secs=data.get("maxDelaySecs"),
|
|
65
|
+
jitter=data.get("jitter", False),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
d: dict[str, Any] = {
|
|
70
|
+
"minRetries": self.min_retries,
|
|
71
|
+
"delaySecs": self.delay_secs,
|
|
72
|
+
"strategy": self.strategy,
|
|
73
|
+
"jitter": self.jitter,
|
|
74
|
+
}
|
|
75
|
+
if self.backoff_factor is not None:
|
|
76
|
+
d["backoffFactor"] = self.backoff_factor
|
|
77
|
+
if self.max_delay_secs is not None:
|
|
78
|
+
d["maxDelaySecs"] = self.max_delay_secs
|
|
79
|
+
return d
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class HookSequenceData:
|
|
84
|
+
"""Sequence context for a hook that is part of a sequence."""
|
|
85
|
+
|
|
86
|
+
sequence_id: str
|
|
87
|
+
step_name: str
|
|
88
|
+
sequence_last_run_at: str
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict[str, Any]) -> HookSequenceData:
|
|
92
|
+
return cls(
|
|
93
|
+
sequence_id=data["sequenceID"],
|
|
94
|
+
step_name=data["stepName"],
|
|
95
|
+
sequence_last_run_at=data["sequenceLastRunAt"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Hook:
|
|
101
|
+
"""A scheduled webhook as returned by the Posthook API."""
|
|
102
|
+
|
|
103
|
+
id: str
|
|
104
|
+
path: str
|
|
105
|
+
data: Any
|
|
106
|
+
post_at: datetime
|
|
107
|
+
status: str
|
|
108
|
+
post_duration_seconds: float
|
|
109
|
+
created_at: datetime
|
|
110
|
+
updated_at: datetime
|
|
111
|
+
domain: str | None = None
|
|
112
|
+
attempts: int = 0
|
|
113
|
+
failure_error: str = ""
|
|
114
|
+
sequence_data: HookSequenceData | None = None
|
|
115
|
+
retry_override: HookRetryOverride | None = None
|
|
116
|
+
quota: QuotaInfo | None = field(default=None)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_dict(cls, data: dict[str, Any]) -> Hook:
|
|
120
|
+
seq = data.get("sequenceData")
|
|
121
|
+
retry = data.get("retryOverride")
|
|
122
|
+
return cls(
|
|
123
|
+
id=data["id"],
|
|
124
|
+
path=data["path"],
|
|
125
|
+
data=data.get("data"),
|
|
126
|
+
post_at=_parse_dt(data["postAt"]),
|
|
127
|
+
status=data["status"],
|
|
128
|
+
post_duration_seconds=data.get("postDurationSeconds", 0.0),
|
|
129
|
+
created_at=_parse_dt(data.get("createdAt", "")),
|
|
130
|
+
updated_at=_parse_dt(data.get("updatedAt", "")),
|
|
131
|
+
domain=data.get("domain"),
|
|
132
|
+
attempts=data.get("attempts", 0),
|
|
133
|
+
failure_error=data.get("failureError", ""),
|
|
134
|
+
sequence_data=HookSequenceData.from_dict(seq) if seq else None,
|
|
135
|
+
retry_override=HookRetryOverride.from_dict(retry) if retry else None,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class BulkActionResult:
|
|
141
|
+
"""Result of a bulk action on hooks."""
|
|
142
|
+
|
|
143
|
+
affected: int
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_dict(cls, data: dict[str, Any]) -> BulkActionResult:
|
|
147
|
+
return cls(affected=data.get("affected", 0))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Delivery:
|
|
152
|
+
"""A parsed and verified webhook delivery."""
|
|
153
|
+
|
|
154
|
+
hook_id: str
|
|
155
|
+
timestamp: int
|
|
156
|
+
path: str
|
|
157
|
+
data: Any
|
|
158
|
+
body: bytes
|
|
159
|
+
post_at: datetime
|
|
160
|
+
posted_at: datetime
|
|
161
|
+
created_at: datetime
|
|
162
|
+
updated_at: datetime
|