sendithq 0.1.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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
sendithq-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SendItWhenever
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendithq
3
+ Version: 0.1.0
4
+ Summary: SendItWhenever Python SDK — 초정밀 예약 웹훅을 3줄로.
5
+ Project-URL: Homepage, https://www.sendit-whenever.com
6
+ Project-URL: Documentation, https://www.sendit-whenever.com/docs/sdk-reference
7
+ Project-URL: Repository, https://github.com/soft37-git/senditwhenever
8
+ Project-URL: Issues, https://github.com/soft37-git/senditwhenever/issues
9
+ Author: SendItWhenever
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: cron,delayed-http,hmac,schedule,scheduler,sendit,sendithq,senditwhenever,webhook
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: httpx>=0.27
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest>=8; extra == 'dev'
25
+ Requires-Dist: ruff>=0.5; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # sendithq — SendItWhenever Python SDK
29
+
30
+ 초정밀 예약 웹훅(HTTP)을 3줄로. Node SDK([`@sendithq/sdk`](https://www.npmjs.com/package/@sendithq/sdk))와 동일한
31
+ 표면을 Python 관용구로 제공합니다. 동기(`SendIt`) + 비동기(`AsyncSendIt`), 의존성은 `httpx` 하나.
32
+
33
+ ```bash
34
+ pip install sendithq
35
+ ```
36
+
37
+ ## Quickstart (sync)
38
+
39
+ ```python
40
+ from sendithq import SendIt
41
+
42
+ sendit = SendIt("sw_live_xxx")
43
+ ref = sendit.schedule(
44
+ url="https://api.myapp.com/hook",
45
+ in_="2h", # 또는 fire_at=datetime(...) / "2026-01-01T00:00:00Z"
46
+ payload={"user_id": 42}, # dict → JSON + Content-Type 자동, str → 그대로
47
+ idempotency_key="trial-end:42",
48
+ )
49
+ print(ref.id, ref.fire_at, ref.status)
50
+ ```
51
+
52
+ > `in` 은 Python 예약어라 상대 시간 인자는 **`in_`** 을 씁니다(`in_="30s" | "15m" | "2h" | "1d"`).
53
+
54
+ ## Quickstart (async)
55
+
56
+ ```python
57
+ import asyncio
58
+ from sendithq import AsyncSendIt
59
+
60
+ async def main():
61
+ async with AsyncSendIt("sw_live_xxx") as sendit:
62
+ ref = await sendit.schedule(url="https://api.myapp.com/hook", in_="2h")
63
+ print(ref.id)
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ## Constructor
69
+
70
+ ```python
71
+ SendIt(
72
+ api_key, # "sw_live_…" / "sw_test_…" — 형식 오류 시 즉시 예외
73
+ base_url="https://api.sendit-whenever.com",
74
+ timeout=30.0, # 초
75
+ max_retries=2, # 멱등 경로 한정 재시도
76
+ signing_secret=None, # verify_signature 기본 시크릿
77
+ )
78
+ ```
79
+
80
+ ## Methods
81
+
82
+ | 메서드 | 반환 | 비고 |
83
+ |--------|------|------|
84
+ | `schedule(url, *, fire_at\|in_, payload, method, headers, idempotency_key)` | `ScheduleRef` | `idempotency_key` 있을 때만 자동 재시도 |
85
+ | `schedule_many(items)` | `BulkScheduleResult` | 부분성공. `items` 는 schedule() 키의 dict 리스트 |
86
+ | `list(*, status, q, limit, offset)` | `ListResult` | 멱등 재시도 |
87
+ | `get(id)` | `Schedule` | |
88
+ | `get_attempts(id)` | `list[DeliveryAttempt]` | 발송 시도 로그 |
89
+ | `reschedule(id, *, fire_at\|in_)` | `Schedule` | `scheduled` 상태에서만 |
90
+ | `replay(id)` | `ScheduleRef` | 동일 암호문 재발송(비멱등) |
91
+ | `clone(id, *, payload, fire_at\|in_)` | `ScheduleRef` | 새 페이로드(비멱등) |
92
+ | `cancel(id)` | `Schedule` | 멱등 |
93
+ | `verify_signature(body, headers, *, secrets\|secret, tolerance_sec)` | `bool` | 나가는 웹훅 HMAC 검증 |
94
+ | `signing_secrets.get()` / `.rotate()` | `SigningSecretPair` | 2키 회전 |
95
+
96
+ `AsyncSendIt` 은 동일 표면이며 메서드가 코루틴입니다(`await`).
97
+
98
+ ## Verifying incoming webhooks
99
+
100
+ SendItWhenever 가 보내는 웹훅에는 항상 `X-SendIt-Signature` 헤더(HMAC-SHA256)가 붙습니다.
101
+ **서명 대상은 raw 본문**이므로 파싱 전 원본 바이트를 넘기세요.
102
+
103
+ ```python
104
+ # Flask 예시
105
+ from flask import request
106
+ from sendithq import SendIt
107
+
108
+ sendit = SendIt("sw_live_xxx")
109
+
110
+ @app.post("/hook")
111
+ def hook():
112
+ secrets = sendit.signing_secrets.get() # 무중단 회전 대비 2키
113
+ ok = sendit.verify_signature(
114
+ request.get_data(), # raw bytes
115
+ request.headers,
116
+ secrets=[secrets.current.secret, secrets.next.secret],
117
+ )
118
+ if not ok:
119
+ return ("bad signature", 400)
120
+ ...
121
+ ```
122
+
123
+ ## Errors
124
+
125
+ 모든 실패는 `SendItError(code, message, status)` 를 던집니다.
126
+
127
+ ```python
128
+ from sendithq import SendItError
129
+
130
+ try:
131
+ sendit.get("missing")
132
+ except SendItError as err:
133
+ if err.code == "NOT_FOUND":
134
+ ...
135
+ ```
136
+
137
+ `code` ∈ `UNAUTHORIZED | FORBIDDEN | NOT_FOUND | VALIDATION | CONFLICT | RATE_LIMITED | INTERNAL | NETWORK`.
138
+
139
+ ## Links
140
+
141
+ - 문서: <https://www.sendit-whenever.com/docs/sdk-reference>
142
+ - 대시보드(API Key 발급): <https://www.sendit-whenever.com>
143
+
144
+ MIT License.
@@ -0,0 +1,117 @@
1
+ # sendithq — SendItWhenever Python SDK
2
+
3
+ 초정밀 예약 웹훅(HTTP)을 3줄로. Node SDK([`@sendithq/sdk`](https://www.npmjs.com/package/@sendithq/sdk))와 동일한
4
+ 표면을 Python 관용구로 제공합니다. 동기(`SendIt`) + 비동기(`AsyncSendIt`), 의존성은 `httpx` 하나.
5
+
6
+ ```bash
7
+ pip install sendithq
8
+ ```
9
+
10
+ ## Quickstart (sync)
11
+
12
+ ```python
13
+ from sendithq import SendIt
14
+
15
+ sendit = SendIt("sw_live_xxx")
16
+ ref = sendit.schedule(
17
+ url="https://api.myapp.com/hook",
18
+ in_="2h", # 또는 fire_at=datetime(...) / "2026-01-01T00:00:00Z"
19
+ payload={"user_id": 42}, # dict → JSON + Content-Type 자동, str → 그대로
20
+ idempotency_key="trial-end:42",
21
+ )
22
+ print(ref.id, ref.fire_at, ref.status)
23
+ ```
24
+
25
+ > `in` 은 Python 예약어라 상대 시간 인자는 **`in_`** 을 씁니다(`in_="30s" | "15m" | "2h" | "1d"`).
26
+
27
+ ## Quickstart (async)
28
+
29
+ ```python
30
+ import asyncio
31
+ from sendithq import AsyncSendIt
32
+
33
+ async def main():
34
+ async with AsyncSendIt("sw_live_xxx") as sendit:
35
+ ref = await sendit.schedule(url="https://api.myapp.com/hook", in_="2h")
36
+ print(ref.id)
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ ## Constructor
42
+
43
+ ```python
44
+ SendIt(
45
+ api_key, # "sw_live_…" / "sw_test_…" — 형식 오류 시 즉시 예외
46
+ base_url="https://api.sendit-whenever.com",
47
+ timeout=30.0, # 초
48
+ max_retries=2, # 멱등 경로 한정 재시도
49
+ signing_secret=None, # verify_signature 기본 시크릿
50
+ )
51
+ ```
52
+
53
+ ## Methods
54
+
55
+ | 메서드 | 반환 | 비고 |
56
+ |--------|------|------|
57
+ | `schedule(url, *, fire_at\|in_, payload, method, headers, idempotency_key)` | `ScheduleRef` | `idempotency_key` 있을 때만 자동 재시도 |
58
+ | `schedule_many(items)` | `BulkScheduleResult` | 부분성공. `items` 는 schedule() 키의 dict 리스트 |
59
+ | `list(*, status, q, limit, offset)` | `ListResult` | 멱등 재시도 |
60
+ | `get(id)` | `Schedule` | |
61
+ | `get_attempts(id)` | `list[DeliveryAttempt]` | 발송 시도 로그 |
62
+ | `reschedule(id, *, fire_at\|in_)` | `Schedule` | `scheduled` 상태에서만 |
63
+ | `replay(id)` | `ScheduleRef` | 동일 암호문 재발송(비멱등) |
64
+ | `clone(id, *, payload, fire_at\|in_)` | `ScheduleRef` | 새 페이로드(비멱등) |
65
+ | `cancel(id)` | `Schedule` | 멱등 |
66
+ | `verify_signature(body, headers, *, secrets\|secret, tolerance_sec)` | `bool` | 나가는 웹훅 HMAC 검증 |
67
+ | `signing_secrets.get()` / `.rotate()` | `SigningSecretPair` | 2키 회전 |
68
+
69
+ `AsyncSendIt` 은 동일 표면이며 메서드가 코루틴입니다(`await`).
70
+
71
+ ## Verifying incoming webhooks
72
+
73
+ SendItWhenever 가 보내는 웹훅에는 항상 `X-SendIt-Signature` 헤더(HMAC-SHA256)가 붙습니다.
74
+ **서명 대상은 raw 본문**이므로 파싱 전 원본 바이트를 넘기세요.
75
+
76
+ ```python
77
+ # Flask 예시
78
+ from flask import request
79
+ from sendithq import SendIt
80
+
81
+ sendit = SendIt("sw_live_xxx")
82
+
83
+ @app.post("/hook")
84
+ def hook():
85
+ secrets = sendit.signing_secrets.get() # 무중단 회전 대비 2키
86
+ ok = sendit.verify_signature(
87
+ request.get_data(), # raw bytes
88
+ request.headers,
89
+ secrets=[secrets.current.secret, secrets.next.secret],
90
+ )
91
+ if not ok:
92
+ return ("bad signature", 400)
93
+ ...
94
+ ```
95
+
96
+ ## Errors
97
+
98
+ 모든 실패는 `SendItError(code, message, status)` 를 던집니다.
99
+
100
+ ```python
101
+ from sendithq import SendItError
102
+
103
+ try:
104
+ sendit.get("missing")
105
+ except SendItError as err:
106
+ if err.code == "NOT_FOUND":
107
+ ...
108
+ ```
109
+
110
+ `code` ∈ `UNAUTHORIZED | FORBIDDEN | NOT_FOUND | VALIDATION | CONFLICT | RATE_LIMITED | INTERNAL | NETWORK`.
111
+
112
+ ## Links
113
+
114
+ - 문서: <https://www.sendit-whenever.com/docs/sdk-reference>
115
+ - 대시보드(API Key 발급): <https://www.sendit-whenever.com>
116
+
117
+ MIT License.
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sendithq"
7
+ version = "0.1.0"
8
+ description = "SendItWhenever Python SDK — 초정밀 예약 웹훅을 3줄로."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "SendItWhenever" }]
12
+ requires-python = ">=3.9"
13
+ keywords = [
14
+ "webhook",
15
+ "scheduler",
16
+ "schedule",
17
+ "delayed-http",
18
+ "cron",
19
+ "hmac",
20
+ "sendit",
21
+ "senditwhenever",
22
+ "sendithq",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3 :: Only",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = ["httpx>=0.27"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://www.sendit-whenever.com"
36
+ Documentation = "https://www.sendit-whenever.com/docs/sdk-reference"
37
+ Repository = "https://github.com/soft37-git/senditwhenever"
38
+ Issues = "https://github.com/soft37-git/senditwhenever/issues"
39
+
40
+ [project.optional-dependencies]
41
+ dev = [
42
+ "pytest>=8",
43
+ "pytest-asyncio>=0.23",
44
+ "mypy>=1.10",
45
+ "ruff>=0.5",
46
+ ]
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/sendithq"]
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
54
+
55
+ # mypy 는 3.10+ 만 타깃 가능(런타임은 3.9 지원 — typing.List/Optional + future annotations 사용).
56
+ [tool.mypy]
57
+ strict = true
58
+ python_version = "3.10"
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py39"
63
+
64
+ [tool.ruff.lint]
65
+ # UP(pyupgrade)는 3.9 호환 typing.List/Optional 을 builtin 제네릭으로 강제하므로 제외.
66
+ select = ["E", "F", "I", "B"]
@@ -0,0 +1,52 @@
1
+ """SendItWhenever Python SDK — 초정밀 예약 웹훅을 3줄로.
2
+
3
+ from sendithq import SendIt
4
+ sendit = SendIt("sw_live_xxx")
5
+ sendit.schedule(url="https://api.myapp.com/hook", in_="2h", payload={"id": 42})
6
+
7
+ 비동기는 ``AsyncSendIt`` 를 쓴다. HMAC 검증은 ``SendIt.verify_signature`` / 모듈 함수.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._async import AsyncSendIt
13
+ from ._signing import SIGNATURE_HEADER, verify_webhook
14
+ from ._sync import SendIt
15
+ from ._version import __version__
16
+ from .duration import parse_duration
17
+ from .errors import SendItError, SendItErrorCode
18
+ from .models import (
19
+ BulkAccepted,
20
+ BulkRejected,
21
+ BulkScheduleResult,
22
+ DeliveryAttempt,
23
+ HttpMethod,
24
+ ListResult,
25
+ Schedule,
26
+ ScheduleRef,
27
+ ScheduleStatus,
28
+ SigningSecret,
29
+ SigningSecretPair,
30
+ )
31
+
32
+ __all__ = [
33
+ "__version__",
34
+ "SendIt",
35
+ "AsyncSendIt",
36
+ "SendItError",
37
+ "SendItErrorCode",
38
+ "parse_duration",
39
+ "verify_webhook",
40
+ "SIGNATURE_HEADER",
41
+ "Schedule",
42
+ "ScheduleRef",
43
+ "DeliveryAttempt",
44
+ "SigningSecret",
45
+ "SigningSecretPair",
46
+ "BulkAccepted",
47
+ "BulkRejected",
48
+ "BulkScheduleResult",
49
+ "ListResult",
50
+ "HttpMethod",
51
+ "ScheduleStatus",
52
+ ]
@@ -0,0 +1,32 @@
1
+ # API Key 포맷 선검증(생성자에서 첫 요청 전에 빈/형식오류를 표면화). 값 자체의 유효성(폐기·오타)은
2
+ # 서버가 UNAUTHORIZED 로 최종 판정한다. 서버와 단일 규칙을 공유하기 위해 KEY_PATTERN 을 포팅한다.
3
+ # 미러: packages/shared/src/crypto/api-key.ts (parseApiKey)
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Literal, Optional, cast
10
+
11
+ ApiKeyEnv = Literal["live", "test"]
12
+
13
+ __all__ = ["ParsedApiKey", "parse_api_key"]
14
+
15
+ _KEY_PATTERN = re.compile(r"^sw_(live|test)_([A-Za-z0-9_-]{8,})$")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ParsedApiKey:
20
+ env: ApiKeyEnv
21
+ prefix: str
22
+ last4: str
23
+
24
+
25
+ def parse_api_key(key: str) -> Optional[ParsedApiKey]:
26
+ """키 포맷을 검증·파싱한다. 형식이 잘못되면 None."""
27
+ match = _KEY_PATTERN.match(key)
28
+ if match is None:
29
+ return None
30
+ env = cast(ApiKeyEnv, match.group(1))
31
+ token = match.group(2)
32
+ return ParsedApiKey(env=env, prefix=f"sw_{env}", last4=token[-4:])
@@ -0,0 +1,213 @@
1
+ # 비동기 클라이언트(AsyncSendIt) — httpx.AsyncClient 래퍼. 동기판(_sync.SendIt)과 표면 동일,
2
+ # transport 만 await. 미러: packages/sdk/src/index.ts
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from types import TracebackType
8
+ from typing import Any, List, Mapping, Optional, Sequence, Type, Union
9
+
10
+ import httpx
11
+
12
+ from ._base import _BaseSendIt, clean_query
13
+ from ._core import build_clone_body, build_schedule_body, is_retriable, resolve_fire_at
14
+ from .errors import SendItError
15
+ from .models import (
16
+ BulkScheduleResult,
17
+ DeliveryAttempt,
18
+ HttpMethod,
19
+ ListResult,
20
+ Schedule,
21
+ ScheduleRef,
22
+ ScheduleStatus,
23
+ SigningSecretPair,
24
+ )
25
+
26
+ __all__ = ["AsyncSendIt"]
27
+
28
+ FireAt = Union[str, Any]
29
+
30
+
31
+ class _AsyncSigningSecrets:
32
+ """signing_secrets.get()/rotate() 네임스페이스(코루틴)."""
33
+
34
+ def __init__(self, client: "AsyncSendIt") -> None:
35
+ self._client = client
36
+
37
+ async def get(self) -> SigningSecretPair:
38
+ raw = await self._client._request("GET", "v1/signing-secrets", retryable=True)
39
+ return SigningSecretPair.from_wire(raw)
40
+
41
+ async def rotate(self) -> SigningSecretPair:
42
+ raw = await self._client._request("POST", "v1/signing-secrets/rotate", retryable=False)
43
+ return SigningSecretPair.from_wire(raw)
44
+
45
+
46
+ class AsyncSendIt(_BaseSendIt):
47
+ """SendItWhenever 비동기 클라이언트.
48
+
49
+ from sendithq import AsyncSendIt
50
+ async with AsyncSendIt("sw_live_xxx") as sendit:
51
+ ref = await sendit.schedule(url="https://api.myapp.com/hook", in_="2h")
52
+ """
53
+
54
+ def __init__(self, api_key: str, **kwargs: Any) -> None:
55
+ super().__init__(api_key, **kwargs)
56
+ self._http = httpx.AsyncClient(timeout=self._timeout, follow_redirects=False)
57
+ self.signing_secrets = _AsyncSigningSecrets(self)
58
+
59
+ # ── lifecycle ────────────────────────────────────────────────────────────
60
+ async def aclose(self) -> None:
61
+ await self._http.aclose()
62
+
63
+ async def __aenter__(self) -> "AsyncSendIt":
64
+ return self
65
+
66
+ async def __aexit__(
67
+ self,
68
+ exc_type: Optional[Type[BaseException]],
69
+ exc: Optional[BaseException],
70
+ tb: Optional[TracebackType],
71
+ ) -> None:
72
+ await self.aclose()
73
+
74
+ # ── transport ──────────────────────────────────────────────────────────
75
+ async def _request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ *,
80
+ query: Optional[Mapping[str, Any]] = None,
81
+ body: Optional[Any] = None,
82
+ retryable: bool = False,
83
+ ) -> Any:
84
+ url = self._url(path)
85
+ headers = self._headers(body is not None)
86
+ content = self._encode(body)
87
+ max_attempts = self._max_retries + 1 if retryable else 1
88
+ last: Optional[SendItError] = None
89
+
90
+ for attempt in range(max_attempts):
91
+ try:
92
+ resp = await self._http.request(
93
+ method, url, params=clean_query(query), content=content, headers=headers
94
+ )
95
+ return self._process(resp.status_code, resp.text, resp.is_success)
96
+ except SendItError as err:
97
+ last = err
98
+ except httpx.RequestError as err:
99
+ last = SendItError("NETWORK", str(err) or "request failed")
100
+
101
+ if attempt < max_attempts - 1 and is_retriable(last):
102
+ await asyncio.sleep(self._backoff_seconds(attempt))
103
+ continue
104
+ raise last
105
+ raise last if last is not None else SendItError("NETWORK", "request failed")
106
+
107
+ # ── methods ──────────────────────────────────────────────────────────────
108
+ async def schedule(
109
+ self,
110
+ *,
111
+ url: str,
112
+ fire_at: Optional[FireAt] = None,
113
+ in_: Optional[str] = None,
114
+ payload: Any = None,
115
+ method: Optional[HttpMethod] = None,
116
+ headers: Optional[Mapping[str, str]] = None,
117
+ idempotency_key: Optional[str] = None,
118
+ ) -> ScheduleRef:
119
+ body = build_schedule_body(
120
+ url=url,
121
+ fire_at=fire_at,
122
+ in_=in_,
123
+ payload=payload,
124
+ method=method,
125
+ headers=headers,
126
+ idempotency_key=idempotency_key,
127
+ )
128
+ raw = await self._request(
129
+ "POST", "v1/schedules", body=body, retryable=idempotency_key is not None
130
+ )
131
+ return ScheduleRef.from_wire(raw)
132
+
133
+ async def schedule_many(self, items: Sequence[Mapping[str, Any]]) -> BulkScheduleResult:
134
+ if not items:
135
+ raise SendItError("VALIDATION", "schedule_many: items must not be empty")
136
+ bodies = [
137
+ build_schedule_body(
138
+ url=it["url"],
139
+ fire_at=it.get("fire_at"),
140
+ in_=it.get("in_"),
141
+ payload=it.get("payload"),
142
+ method=it.get("method"),
143
+ headers=it.get("headers"),
144
+ idempotency_key=it.get("idempotency_key"),
145
+ )
146
+ for it in items
147
+ ]
148
+ retryable = all(it.get("idempotency_key") is not None for it in items)
149
+ raw = await self._request(
150
+ "POST", "v1/schedules/bulk", body={"items": bodies}, retryable=retryable
151
+ )
152
+ return BulkScheduleResult.from_wire(raw)
153
+
154
+ async def list(
155
+ self,
156
+ *,
157
+ status: Optional[ScheduleStatus] = None,
158
+ q: Optional[str] = None,
159
+ limit: Optional[int] = None,
160
+ offset: Optional[int] = None,
161
+ ) -> ListResult:
162
+ raw = await self._request(
163
+ "GET",
164
+ "v1/schedules",
165
+ query={"status": status, "q": q, "limit": limit, "offset": offset},
166
+ retryable=True,
167
+ )
168
+ return ListResult.from_wire(raw)
169
+
170
+ async def get(self, schedule_id: str) -> Schedule:
171
+ raw = await self._request("GET", f"v1/schedules/{schedule_id}", retryable=True)
172
+ return Schedule.from_wire(raw)
173
+
174
+ async def get_attempts(self, schedule_id: str) -> List[DeliveryAttempt]:
175
+ raw = await self._request("GET", f"v1/schedules/{schedule_id}/attempts", retryable=True)
176
+ return [DeliveryAttempt.from_wire(r) for r in raw.get("items", [])]
177
+
178
+ async def reschedule(
179
+ self,
180
+ schedule_id: str,
181
+ *,
182
+ fire_at: Optional[FireAt] = None,
183
+ in_: Optional[str] = None,
184
+ ) -> Schedule:
185
+ raw = await self._request(
186
+ "PATCH",
187
+ f"v1/schedules/{schedule_id}",
188
+ body={"fireAt": resolve_fire_at(fire_at, in_)},
189
+ retryable=False,
190
+ )
191
+ return Schedule.from_wire(raw)
192
+
193
+ async def replay(self, schedule_id: str) -> ScheduleRef:
194
+ raw = await self._request("POST", f"v1/schedules/{schedule_id}/replay", retryable=False)
195
+ return ScheduleRef.from_wire(raw)
196
+
197
+ async def clone(
198
+ self,
199
+ schedule_id: str,
200
+ *,
201
+ payload: Any,
202
+ fire_at: Optional[FireAt] = None,
203
+ in_: Optional[str] = None,
204
+ ) -> ScheduleRef:
205
+ body = build_clone_body(payload=payload, fire_at=fire_at, in_=in_)
206
+ raw = await self._request(
207
+ "POST", f"v1/schedules/{schedule_id}/clone", body=body, retryable=False
208
+ )
209
+ return ScheduleRef.from_wire(raw)
210
+
211
+ async def cancel(self, schedule_id: str) -> Schedule:
212
+ raw = await self._request("DELETE", f"v1/schedules/{schedule_id}", retryable=True)
213
+ return Schedule.from_wire(raw)