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.
- sendithq-0.1.0/.gitignore +9 -0
- sendithq-0.1.0/LICENSE +21 -0
- sendithq-0.1.0/PKG-INFO +144 -0
- sendithq-0.1.0/README.md +117 -0
- sendithq-0.1.0/pyproject.toml +66 -0
- sendithq-0.1.0/src/sendithq/__init__.py +52 -0
- sendithq-0.1.0/src/sendithq/_apikey.py +32 -0
- sendithq-0.1.0/src/sendithq/_async.py +213 -0
- sendithq-0.1.0/src/sendithq/_base.py +112 -0
- sendithq-0.1.0/src/sendithq/_core.py +165 -0
- sendithq-0.1.0/src/sendithq/_signing.py +89 -0
- sendithq-0.1.0/src/sendithq/_sync.py +226 -0
- sendithq-0.1.0/src/sendithq/_verify.py +43 -0
- sendithq-0.1.0/src/sendithq/_version.py +2 -0
- sendithq-0.1.0/src/sendithq/duration.py +27 -0
- sendithq-0.1.0/src/sendithq/errors.py +38 -0
- sendithq-0.1.0/src/sendithq/models.py +211 -0
- sendithq-0.1.0/src/sendithq/py.typed +0 -0
- sendithq-0.1.0/tests/test_apikey.py +28 -0
- sendithq-0.1.0/tests/test_client.py +211 -0
- sendithq-0.1.0/tests/test_core.py +145 -0
- sendithq-0.1.0/tests/test_duration.py +29 -0
- sendithq-0.1.0/tests/test_signing.py +85 -0
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.
|
sendithq-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
sendithq-0.1.0/README.md
ADDED
|
@@ -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)
|