worklittle 1.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.
- worklittle-1.1.0/.gitignore +6 -0
- worklittle-1.1.0/PKG-INFO +47 -0
- worklittle-1.1.0/README.md +32 -0
- worklittle-1.1.0/pyproject.toml +32 -0
- worklittle-1.1.0/tests/test_webhooks.py +17 -0
- worklittle-1.1.0/worklittle/__init__.py +13 -0
- worklittle-1.1.0/worklittle/cli.py +275 -0
- worklittle-1.1.0/worklittle/client.py +462 -0
- worklittle-1.1.0/worklittle/errors.py +46 -0
- worklittle-1.1.0/worklittle/webhooks.py +28 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: worklittle
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official Worklittle Python SDK and CLI for api.worklittle.com
|
|
5
|
+
Project-URL: Homepage, https://docs.worklittle.com/libraries/sdk?lang=python
|
|
6
|
+
Project-URL: Documentation, https://docs.worklittle.com/libraries/overview
|
|
7
|
+
Author: Worklittle
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: api,ats,cli,jobs,sdk,worklittle
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# worklittle
|
|
17
|
+
|
|
18
|
+
Python SDK and CLI for [api.worklittle.com](https://api.worklittle.com).
|
|
19
|
+
|
|
20
|
+
Registry publish is not live yet. Install from this package:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd packages/sdk-python
|
|
24
|
+
pip install -e .
|
|
25
|
+
export WORKLITTLE_API_KEY=sk-wl-api01-...
|
|
26
|
+
worklittle jobs search --q="software engineer" --limit 1 --pretty
|
|
27
|
+
worklittle jobs search --title="software engineer, -senior" --company="-lucid-motors,-tesla" --limit 5 --pretty
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`--company` accepts bare slugs (include) or leading-dash slugs (exclude). Title negatives use `--title` / `--q`. Other `GET /jobs` flags pass through the same way (`--workplace_type`, `--posted_within_days`, …).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from worklittle import Worklittle
|
|
34
|
+
|
|
35
|
+
wl = Worklittle()
|
|
36
|
+
jobs = wl.jobs.search(q="software engineer", limit=5)
|
|
37
|
+
filtered = wl.jobs.search(
|
|
38
|
+
title="software engineer, -senior",
|
|
39
|
+
company="-lucid-motors,-tesla",
|
|
40
|
+
limit=5,
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
CLI output is JSON on stdout by default.
|
|
45
|
+
|
|
46
|
+
Docs: https://docs.worklittle.com/libraries/sdk?lang=python
|
|
47
|
+
OpenAPI: https://docs.worklittle.com/openapi/openapi.yaml
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# worklittle
|
|
2
|
+
|
|
3
|
+
Python SDK and CLI for [api.worklittle.com](https://api.worklittle.com).
|
|
4
|
+
|
|
5
|
+
Registry publish is not live yet. Install from this package:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd packages/sdk-python
|
|
9
|
+
pip install -e .
|
|
10
|
+
export WORKLITTLE_API_KEY=sk-wl-api01-...
|
|
11
|
+
worklittle jobs search --q="software engineer" --limit 1 --pretty
|
|
12
|
+
worklittle jobs search --title="software engineer, -senior" --company="-lucid-motors,-tesla" --limit 5 --pretty
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`--company` accepts bare slugs (include) or leading-dash slugs (exclude). Title negatives use `--title` / `--q`. Other `GET /jobs` flags pass through the same way (`--workplace_type`, `--posted_within_days`, …).
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from worklittle import Worklittle
|
|
19
|
+
|
|
20
|
+
wl = Worklittle()
|
|
21
|
+
jobs = wl.jobs.search(q="software engineer", limit=5)
|
|
22
|
+
filtered = wl.jobs.search(
|
|
23
|
+
title="software engineer, -senior",
|
|
24
|
+
company="-lucid-motors,-tesla",
|
|
25
|
+
limit=5,
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
CLI output is JSON on stdout by default.
|
|
30
|
+
|
|
31
|
+
Docs: https://docs.worklittle.com/libraries/sdk?lang=python
|
|
32
|
+
OpenAPI: https://docs.worklittle.com/openapi/openapi.yaml
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "worklittle"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Official Worklittle Python SDK and CLI for api.worklittle.com"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Worklittle" }]
|
|
13
|
+
keywords = ["worklittle", "jobs", "ats", "api", "sdk", "cli"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8.0"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
worklittle = "worklittle.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://docs.worklittle.com/libraries/sdk?lang=python"
|
|
26
|
+
Documentation = "https://docs.worklittle.com/libraries/overview"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["worklittle"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.sdist]
|
|
32
|
+
include = ["worklittle", "README.md", "pyproject.toml", "tests"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from worklittle.webhooks import verify_webhook_signature
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_verify_webhook_signature():
|
|
9
|
+
secret = "whsec_test"
|
|
10
|
+
timestamp = str(int(time.time()))
|
|
11
|
+
raw = '{"ok":true}'
|
|
12
|
+
sig = "v1=" + hmac.new(
|
|
13
|
+
secret.encode(), f"{timestamp}.{raw}".encode(), hashlib.sha256
|
|
14
|
+
).hexdigest()
|
|
15
|
+
assert verify_webhook_signature(
|
|
16
|
+
signature_header=sig, timestamp=timestamp, raw_body=raw, secret=secret
|
|
17
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .client import Worklittle, create_worklittle
|
|
2
|
+
from .errors import PaymentRequiredError, QuotaExceededError, WorklittleError
|
|
3
|
+
from .webhooks import verify_webhook_signature
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Worklittle",
|
|
7
|
+
"create_worklittle",
|
|
8
|
+
"WorklittleError",
|
|
9
|
+
"PaymentRequiredError",
|
|
10
|
+
"QuotaExceededError",
|
|
11
|
+
"verify_webhook_signature",
|
|
12
|
+
]
|
|
13
|
+
__version__ = "1.1.0"
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import Worklittle
|
|
10
|
+
from .errors import WorklittleError
|
|
11
|
+
|
|
12
|
+
# Reserved CLI flags — not forwarded as API query params.
|
|
13
|
+
_RESERVED = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"api-key",
|
|
16
|
+
"base-url",
|
|
17
|
+
"pretty",
|
|
18
|
+
"json",
|
|
19
|
+
"body",
|
|
20
|
+
"id",
|
|
21
|
+
"name",
|
|
22
|
+
"field",
|
|
23
|
+
"h",
|
|
24
|
+
"help",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> None:
|
|
30
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="worklittle",
|
|
33
|
+
description="Worklittle CLI. JSON on stdout by default.",
|
|
34
|
+
add_help=False,
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("group", nargs="?", default="help")
|
|
37
|
+
parser.add_argument("action", nargs="?", default="")
|
|
38
|
+
parser.add_argument("rest", nargs="*")
|
|
39
|
+
parser.add_argument("--api-key", default=os.environ.get("WORKLITTLE_API_KEY"))
|
|
40
|
+
parser.add_argument("--base-url", default="https://api.worklittle.com")
|
|
41
|
+
parser.add_argument("--pretty", action="store_true")
|
|
42
|
+
parser.add_argument("--body", default="{}")
|
|
43
|
+
parser.add_argument("--id")
|
|
44
|
+
parser.add_argument("--name")
|
|
45
|
+
parser.add_argument("--field")
|
|
46
|
+
# Declared for help / common flags; also merged into query params below.
|
|
47
|
+
parser.add_argument("--q")
|
|
48
|
+
parser.add_argument("--title")
|
|
49
|
+
parser.add_argument("--company")
|
|
50
|
+
parser.add_argument("--limit", type=int)
|
|
51
|
+
parser.add_argument("--cursor")
|
|
52
|
+
parser.add_argument("--include")
|
|
53
|
+
args, unknown = parser.parse_known_args(argv)
|
|
54
|
+
|
|
55
|
+
if args.group in ("help", "", None) or args.group in ("-h", "--help"):
|
|
56
|
+
_out(
|
|
57
|
+
{
|
|
58
|
+
"name": "worklittle",
|
|
59
|
+
"version": "1.1.0",
|
|
60
|
+
"usage": "worklittle <group> <action> [flags]",
|
|
61
|
+
"groups": [
|
|
62
|
+
"jobs",
|
|
63
|
+
"companies",
|
|
64
|
+
"stats",
|
|
65
|
+
"boards",
|
|
66
|
+
"apply",
|
|
67
|
+
"apply-with-ai",
|
|
68
|
+
"applied",
|
|
69
|
+
"candidates",
|
|
70
|
+
"employees",
|
|
71
|
+
"email",
|
|
72
|
+
"documents",
|
|
73
|
+
"attendance",
|
|
74
|
+
"surveys",
|
|
75
|
+
"webhooks",
|
|
76
|
+
"listings",
|
|
77
|
+
"resumes",
|
|
78
|
+
"usage",
|
|
79
|
+
"billing",
|
|
80
|
+
"limits",
|
|
81
|
+
"agent",
|
|
82
|
+
],
|
|
83
|
+
"note": (
|
|
84
|
+
"JSON stdout by default. Query flags match GET /jobs param names "
|
|
85
|
+
"(e.g. --company=-lucid-motors, --title='engineer, -senior'). "
|
|
86
|
+
"JS CLI (Homebrew) is canonical; Python CLI is a subset."
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
pretty=True,
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
client = Worklittle(api_key=args.api_key, base_url=args.base_url)
|
|
94
|
+
params = _query_params(args, unknown)
|
|
95
|
+
body = json.loads(args.body)
|
|
96
|
+
company = args.company or (args.rest[0] if args.rest else None)
|
|
97
|
+
id_ = args.id or (args.rest[0] if args.rest else None)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
result = _dispatch(
|
|
101
|
+
client, args.group, args.action or "", args.rest, params, body, company, id_, args
|
|
102
|
+
)
|
|
103
|
+
except WorklittleError as err:
|
|
104
|
+
print(
|
|
105
|
+
json.dumps({"error": {"code": err.code, "message": str(err), "status": err.status}}),
|
|
106
|
+
file=sys.stderr,
|
|
107
|
+
)
|
|
108
|
+
raise SystemExit(2 if err.status in (401, 403) else 1) from err
|
|
109
|
+
except Exception as err: # noqa: BLE001
|
|
110
|
+
print(json.dumps({"error": {"code": "CLI_ERROR", "message": str(err)}}), file=sys.stderr)
|
|
111
|
+
raise SystemExit(1) from err
|
|
112
|
+
|
|
113
|
+
_out(result, pretty=args.pretty)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _query_params(args: argparse.Namespace, unknown: list[str]) -> dict[str, Any]:
|
|
117
|
+
"""Forward declared + unknown --flags as API query params (JS CLI parity)."""
|
|
118
|
+
params: dict[str, Any] = {}
|
|
119
|
+
for key in ("q", "title", "company", "limit", "cursor", "include"):
|
|
120
|
+
val = getattr(args, key, None)
|
|
121
|
+
if val is not None and val != "":
|
|
122
|
+
params[key] = val
|
|
123
|
+
params.update(_parse_unknown_flags(unknown))
|
|
124
|
+
return params
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_unknown_flags(unknown: list[str]) -> dict[str, Any]:
|
|
128
|
+
out: dict[str, Any] = {}
|
|
129
|
+
i = 0
|
|
130
|
+
while i < len(unknown):
|
|
131
|
+
token = unknown[i]
|
|
132
|
+
if token in ("-h", "--help"):
|
|
133
|
+
i += 1
|
|
134
|
+
continue
|
|
135
|
+
if not token.startswith("--"):
|
|
136
|
+
i += 1
|
|
137
|
+
continue
|
|
138
|
+
raw = token[2:]
|
|
139
|
+
if "=" in raw:
|
|
140
|
+
key, value = raw.split("=", 1)
|
|
141
|
+
if key and key not in _RESERVED and value != "":
|
|
142
|
+
out[key] = _coerce(value)
|
|
143
|
+
i += 1
|
|
144
|
+
continue
|
|
145
|
+
key = raw
|
|
146
|
+
nxt = unknown[i + 1] if i + 1 < len(unknown) else None
|
|
147
|
+
if key in _RESERVED:
|
|
148
|
+
i += 1
|
|
149
|
+
continue
|
|
150
|
+
if nxt is None or nxt.startswith("--"):
|
|
151
|
+
out[key] = True
|
|
152
|
+
i += 1
|
|
153
|
+
else:
|
|
154
|
+
if nxt != "":
|
|
155
|
+
out[key] = _coerce(nxt)
|
|
156
|
+
i += 2
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _coerce(value: str) -> Any:
|
|
161
|
+
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
|
|
162
|
+
try:
|
|
163
|
+
return int(value)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return value
|
|
166
|
+
try:
|
|
167
|
+
if "." in value:
|
|
168
|
+
return float(value)
|
|
169
|
+
except ValueError:
|
|
170
|
+
pass
|
|
171
|
+
low = value.lower()
|
|
172
|
+
if low == "true":
|
|
173
|
+
return True
|
|
174
|
+
if low == "false":
|
|
175
|
+
return False
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _out(data: object, *, pretty: bool) -> None:
|
|
180
|
+
print(json.dumps(data, indent=2 if pretty else None, default=str))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _dispatch(client, group, action, rest, params, body, company, id_, args):
|
|
184
|
+
key = f"{group} {action}".strip()
|
|
185
|
+
# Public jobs index
|
|
186
|
+
if key == "jobs search":
|
|
187
|
+
return client.jobs.search(**params)
|
|
188
|
+
if key == "jobs get":
|
|
189
|
+
return client.jobs.get(id_ or rest[0])
|
|
190
|
+
if key == "jobs map":
|
|
191
|
+
return client.jobs.map(**params)
|
|
192
|
+
if key == "jobs salary-average":
|
|
193
|
+
return client.jobs.salary_average(**params)
|
|
194
|
+
if key == "companies search":
|
|
195
|
+
return client.companies.search(**params)
|
|
196
|
+
if key == "stats get":
|
|
197
|
+
return client.stats.get(**params)
|
|
198
|
+
# Public boards + apply
|
|
199
|
+
if key in ("boards get", "boards list"):
|
|
200
|
+
return client.boards.get(company or rest[0], **params)
|
|
201
|
+
if key == "boards jobs":
|
|
202
|
+
return client.boards.list_jobs(company or rest[0], **params)
|
|
203
|
+
if key == "boards job":
|
|
204
|
+
return client.boards.get_job(company or rest[0], id_ or rest[1], **params)
|
|
205
|
+
if key == "boards facets":
|
|
206
|
+
return client.boards.facets(company or rest[0], **params)
|
|
207
|
+
if key == "boards apply":
|
|
208
|
+
return client.boards.apply(company or rest[0], id_ or rest[1], body)
|
|
209
|
+
if key == "apply submit":
|
|
210
|
+
return client.apply.submit(id_ or rest[0], body)
|
|
211
|
+
if key == "apply-with-ai start":
|
|
212
|
+
return client.apply.start_with_ai(body)
|
|
213
|
+
if key in ("apply-with-ai status", "apply-with-ai get"):
|
|
214
|
+
return client.apply.get_with_ai_session(id_ or rest[0])
|
|
215
|
+
if key == "apply-with-ai continue":
|
|
216
|
+
answers = body.get("answers") if isinstance(body.get("answers"), dict) else body
|
|
217
|
+
return client.apply.continue_with_ai(
|
|
218
|
+
id_ or rest[0],
|
|
219
|
+
answers if isinstance(answers, dict) else {},
|
|
220
|
+
)
|
|
221
|
+
if key == "apply-with-ai approve":
|
|
222
|
+
return client.apply.approve_with_ai_submit(id_ or rest[0])
|
|
223
|
+
if key == "apply-with-ai stop":
|
|
224
|
+
return client.apply.stop_with_ai(id_ or rest[0])
|
|
225
|
+
if key in ("applied list", "applied-jobs list"):
|
|
226
|
+
return client.candidates.list(**params)
|
|
227
|
+
if key == "candidates get":
|
|
228
|
+
return client.candidates.get(id_ or rest[0])
|
|
229
|
+
if key == "employees list":
|
|
230
|
+
return client.employees.list(**params)
|
|
231
|
+
if key == "employees get":
|
|
232
|
+
return client.employees.get(id_ or rest[0])
|
|
233
|
+
if key == "email list":
|
|
234
|
+
return client.email.list_messages(**params)
|
|
235
|
+
if key == "email send":
|
|
236
|
+
return client.email.send(body)
|
|
237
|
+
if key == "email sends":
|
|
238
|
+
return client.email.list_sends(**params)
|
|
239
|
+
if key == "email send-get":
|
|
240
|
+
return client.email.get_send(id_ or rest[0])
|
|
241
|
+
if key == "email send-events":
|
|
242
|
+
return client.email.list_send_events(id_ or rest[0])
|
|
243
|
+
if key == "usage list":
|
|
244
|
+
return client.usage.list(**params)
|
|
245
|
+
if key == "billing balance":
|
|
246
|
+
return client.billing.balance()
|
|
247
|
+
if key == "billing invoices":
|
|
248
|
+
return client.billing.invoices(**params)
|
|
249
|
+
if key == "limits get":
|
|
250
|
+
return client.limits.get()
|
|
251
|
+
if key == "limits budget":
|
|
252
|
+
return client.limits.get_budget()
|
|
253
|
+
if key == "documents list":
|
|
254
|
+
return client.documents.list(**params)
|
|
255
|
+
if key == "attendance list":
|
|
256
|
+
return client.attendance.list(**params)
|
|
257
|
+
if key == "surveys list":
|
|
258
|
+
return client.surveys.list(**params)
|
|
259
|
+
if key == "surveys get":
|
|
260
|
+
return client.surveys.get(id_ or rest[0])
|
|
261
|
+
if key == "webhooks list":
|
|
262
|
+
return client.webhooks.list()
|
|
263
|
+
if key == "listings list":
|
|
264
|
+
return client.job_listings.list(**params)
|
|
265
|
+
if key == "resumes create":
|
|
266
|
+
return client.resumes.create(body)
|
|
267
|
+
if key == "agent tools":
|
|
268
|
+
return client.agent.list_tools()
|
|
269
|
+
if key == "agent tool":
|
|
270
|
+
return client.agent.run_tool(args.name or rest[0], body)
|
|
271
|
+
raise ValueError(f"Unknown command: {key}. Run: worklittle help")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
main()
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .errors import PaymentRequiredError, QuotaExceededError, WorklittleError
|
|
10
|
+
from .webhooks import verify_webhook_signature
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Http:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
api_key: str | None,
|
|
18
|
+
base_url: str,
|
|
19
|
+
max_retries: int = 3,
|
|
20
|
+
timeout: float = 60.0,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.api_key = api_key
|
|
23
|
+
self.base_url = base_url.rstrip("/")
|
|
24
|
+
self.max_retries = max_retries
|
|
25
|
+
self._client = httpx.Client(timeout=timeout)
|
|
26
|
+
|
|
27
|
+
def request(
|
|
28
|
+
self,
|
|
29
|
+
method: str,
|
|
30
|
+
path: str,
|
|
31
|
+
*,
|
|
32
|
+
params: dict[str, Any] | None = None,
|
|
33
|
+
json: Any = None,
|
|
34
|
+
auth: bool = True,
|
|
35
|
+
) -> Any:
|
|
36
|
+
url = f"{self.base_url}{path}"
|
|
37
|
+
headers: dict[str, str] = {}
|
|
38
|
+
if auth and self.api_key:
|
|
39
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
40
|
+
attempt = 0
|
|
41
|
+
while True:
|
|
42
|
+
res = self._client.request(method, url, params=_clean(params), json=json, headers=headers)
|
|
43
|
+
if res.status_code == 429 and attempt < self.max_retries:
|
|
44
|
+
retry_after = int(res.headers.get("retry-after", "1") or "1")
|
|
45
|
+
time.sleep(max(1, retry_after))
|
|
46
|
+
attempt += 1
|
|
47
|
+
continue
|
|
48
|
+
request_id = res.headers.get("x-request-id") or res.headers.get("cf-ray")
|
|
49
|
+
if res.status_code == 204:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
body = res.json() if res.content else None
|
|
53
|
+
except Exception:
|
|
54
|
+
body = {"raw": res.text}
|
|
55
|
+
if res.is_error:
|
|
56
|
+
err = (body or {}).get("error") if isinstance(body, dict) else None
|
|
57
|
+
message = (
|
|
58
|
+
(err or {}).get("message")
|
|
59
|
+
if isinstance(err, dict)
|
|
60
|
+
else f"{method} {path} failed ({res.status_code})"
|
|
61
|
+
)
|
|
62
|
+
code = (
|
|
63
|
+
(err or {}).get("code", str(res.status_code))
|
|
64
|
+
if isinstance(err, dict)
|
|
65
|
+
else str(res.status_code)
|
|
66
|
+
)
|
|
67
|
+
if res.status_code == 402 or code == "PAYMENT_REQUIRED":
|
|
68
|
+
raise PaymentRequiredError(
|
|
69
|
+
message,
|
|
70
|
+
code=code,
|
|
71
|
+
request_id=request_id,
|
|
72
|
+
body=body,
|
|
73
|
+
)
|
|
74
|
+
if code == "QUOTA_EXCEEDED":
|
|
75
|
+
raise QuotaExceededError(
|
|
76
|
+
message,
|
|
77
|
+
code=code,
|
|
78
|
+
request_id=request_id,
|
|
79
|
+
body=body,
|
|
80
|
+
)
|
|
81
|
+
raise WorklittleError(
|
|
82
|
+
message,
|
|
83
|
+
status=res.status_code,
|
|
84
|
+
code=code,
|
|
85
|
+
request_id=request_id,
|
|
86
|
+
body=body,
|
|
87
|
+
)
|
|
88
|
+
return body
|
|
89
|
+
|
|
90
|
+
def get(self, path: str, params: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
91
|
+
return self.request("GET", path, params=params, auth=auth)
|
|
92
|
+
|
|
93
|
+
def post(self, path: str, json: Any = None, params: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
94
|
+
return self.request("POST", path, params=params, json=json, auth=auth)
|
|
95
|
+
|
|
96
|
+
def put(self, path: str, json: Any = None, auth: bool = True) -> Any:
|
|
97
|
+
return self.request("PUT", path, json=json, auth=auth)
|
|
98
|
+
|
|
99
|
+
def patch(self, path: str, json: Any = None, params: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
100
|
+
return self.request("PATCH", path, params=params, json=json, auth=auth)
|
|
101
|
+
|
|
102
|
+
def delete(self, path: str, params: dict[str, Any] | None = None, auth: bool = True) -> Any:
|
|
103
|
+
return self.request("DELETE", path, params=params, auth=auth)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _clean(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
107
|
+
if not params:
|
|
108
|
+
return None
|
|
109
|
+
return {k: v for k, v in params.items() if v is not None and v != ""}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Worklittle:
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
api_key: str | None = None,
|
|
116
|
+
*,
|
|
117
|
+
base_url: str = "https://api.worklittle.com",
|
|
118
|
+
max_retries: int = 3,
|
|
119
|
+
) -> None:
|
|
120
|
+
self.http = _Http(
|
|
121
|
+
api_key=api_key or os.environ.get("WORKLITTLE_API_KEY"),
|
|
122
|
+
base_url=base_url,
|
|
123
|
+
max_retries=max_retries,
|
|
124
|
+
)
|
|
125
|
+
h = self.http
|
|
126
|
+
self.jobs = _Jobs(h)
|
|
127
|
+
self.companies = _Companies(h)
|
|
128
|
+
self.stats = _Stats(h)
|
|
129
|
+
self.boards = _Boards(h)
|
|
130
|
+
self.apply = _Apply(h)
|
|
131
|
+
self.applied_jobs = _AppliedJobs(h)
|
|
132
|
+
self.resumes = _Resumes(h)
|
|
133
|
+
self.cover_letters = _CoverLetters(h)
|
|
134
|
+
self.agent = _Agent(h)
|
|
135
|
+
self.job_listings = _JobListings(h)
|
|
136
|
+
self.employer_jobs = _EmployerJobs(h)
|
|
137
|
+
self.organization = _Organization(h)
|
|
138
|
+
self.candidates = _Candidates(h)
|
|
139
|
+
self.employees = _Employees(h)
|
|
140
|
+
self.email = _Email(h)
|
|
141
|
+
self.offers = _Offers(h)
|
|
142
|
+
self.webhooks = _Webhooks(h)
|
|
143
|
+
self.documents = _Documents(h)
|
|
144
|
+
self.attendance = _Attendance(h)
|
|
145
|
+
self.surveys = _Surveys(h)
|
|
146
|
+
self.usage = _Usage(h)
|
|
147
|
+
self.billing = _Billing(h)
|
|
148
|
+
self.limits = _Limits(h)
|
|
149
|
+
self.verify_webhook_signature = verify_webhook_signature
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_worklittle(**kwargs: Any) -> Worklittle:
|
|
153
|
+
return Worklittle(**kwargs)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Jobs:
|
|
157
|
+
def __init__(self, http: _Http) -> None:
|
|
158
|
+
self._h = http
|
|
159
|
+
|
|
160
|
+
def search(self, **params: Any) -> Any:
|
|
161
|
+
return self._h.get("/jobs", params)
|
|
162
|
+
|
|
163
|
+
def get(self, id: str) -> Any:
|
|
164
|
+
return self._h.get(f"/jobs/{id}")
|
|
165
|
+
|
|
166
|
+
def map(self, **params: Any) -> Any:
|
|
167
|
+
return self._h.get("/jobs/map", params)
|
|
168
|
+
|
|
169
|
+
def salary_average(self, **params: Any) -> Any:
|
|
170
|
+
return self._h.get("/jobs/salary-average", params)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _Companies:
|
|
174
|
+
def __init__(self, http: _Http) -> None:
|
|
175
|
+
self._h = http
|
|
176
|
+
|
|
177
|
+
def search(self, **params: Any) -> Any:
|
|
178
|
+
return self._h.get("/companies", params)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _Stats:
|
|
182
|
+
def __init__(self, http: _Http) -> None:
|
|
183
|
+
self._h = http
|
|
184
|
+
|
|
185
|
+
def get(self, **params: Any) -> Any:
|
|
186
|
+
return self._h.get("/stats", params)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _Boards:
|
|
190
|
+
def __init__(self, http: _Http) -> None:
|
|
191
|
+
self._h = http
|
|
192
|
+
|
|
193
|
+
def get(self, company: str, **params: Any) -> Any:
|
|
194
|
+
return self._h.get(f"/job-boards/{company}/board", params, auth=bool(self._h.api_key))
|
|
195
|
+
|
|
196
|
+
def list_jobs(self, company: str, **params: Any) -> Any:
|
|
197
|
+
return self._h.get(f"/job-boards/{company}/jobs", params, auth=bool(self._h.api_key))
|
|
198
|
+
|
|
199
|
+
def facets(self, company: str, **params: Any) -> Any:
|
|
200
|
+
return self._h.get(f"/job-boards/{company}/facets", params, auth=bool(self._h.api_key))
|
|
201
|
+
|
|
202
|
+
def get_job(self, company: str, id_or_slug: str, **params: Any) -> Any:
|
|
203
|
+
return self._h.get(
|
|
204
|
+
f"/job-boards/{company}/jobs/{id_or_slug}", params, auth=bool(self._h.api_key)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def apply(self, company: str, id_or_slug: str, body: dict[str, Any]) -> Any:
|
|
208
|
+
return self._h.post(f"/job-boards/{company}/jobs/{id_or_slug}/apply", body, auth=False)
|
|
209
|
+
|
|
210
|
+
def parse_resume(self, company: str, id_or_slug: str, resume_file: dict[str, Any]) -> Any:
|
|
211
|
+
return self._h.post(
|
|
212
|
+
f"/job-boards/{company}/jobs/{id_or_slug}/parse-resume",
|
|
213
|
+
{"resume_file": resume_file},
|
|
214
|
+
auth=False,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _Apply:
|
|
219
|
+
def __init__(self, http: _Http) -> None:
|
|
220
|
+
self._h = http
|
|
221
|
+
|
|
222
|
+
def submit(self, job_id: str, body: dict[str, Any]) -> Any:
|
|
223
|
+
return self._h.post(f"/jobs/{job_id}/apply", body)
|
|
224
|
+
|
|
225
|
+
def start_with_ai(self, body: dict[str, Any]) -> Any:
|
|
226
|
+
return self._h.post("/v1/apply-with-ai", body)
|
|
227
|
+
|
|
228
|
+
def get_with_ai_session(self, session_id: str) -> Any:
|
|
229
|
+
return self._h.get(f"/v1/apply-with-ai/{session_id}")
|
|
230
|
+
|
|
231
|
+
def approve_with_ai_submit(self, session_id: str) -> Any:
|
|
232
|
+
return self._h.post(f"/v1/apply-with-ai/{session_id}/approve-submit", {})
|
|
233
|
+
|
|
234
|
+
def continue_with_ai(self, session_id: str, answers: dict[str, str] | None = None) -> Any:
|
|
235
|
+
return self._h.post(
|
|
236
|
+
f"/v1/apply-with-ai/{session_id}/continue",
|
|
237
|
+
{"answers": answers or {}},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def stop_with_ai(self, session_id: str) -> Any:
|
|
241
|
+
return self._h.post(f"/v1/apply-with-ai/{session_id}/stop", {})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _AppliedJobs:
|
|
245
|
+
def __init__(self, http: _Http) -> None:
|
|
246
|
+
self._h = http
|
|
247
|
+
|
|
248
|
+
def list(self, **params: Any) -> Any:
|
|
249
|
+
return self._h.get("/applied-jobs", params)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _Resumes:
|
|
253
|
+
def __init__(self, http: _Http) -> None:
|
|
254
|
+
self._h = http
|
|
255
|
+
|
|
256
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
257
|
+
return self._h.post("/v1/resumes", body)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _CoverLetters:
|
|
261
|
+
def __init__(self, http: _Http) -> None:
|
|
262
|
+
self._h = http
|
|
263
|
+
|
|
264
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
265
|
+
return self._h.post("/v1/cover-letters", body)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class _Agent:
|
|
269
|
+
def __init__(self, http: _Http) -> None:
|
|
270
|
+
self._h = http
|
|
271
|
+
|
|
272
|
+
def list_tools(self) -> Any:
|
|
273
|
+
return self._h.get("/v1/agent/tools")
|
|
274
|
+
|
|
275
|
+
def run_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
276
|
+
return self._h.post("/v1/agent/tool", {"name": name, "arguments": arguments or {}})
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class _JobListings:
|
|
280
|
+
def __init__(self, http: _Http) -> None:
|
|
281
|
+
self._h = http
|
|
282
|
+
|
|
283
|
+
def list(self, **params: Any) -> Any:
|
|
284
|
+
return self._h.get("/job-listings", params)
|
|
285
|
+
|
|
286
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
287
|
+
return self._h.post("/job-listings", body)
|
|
288
|
+
|
|
289
|
+
def update(self, id: str, body: dict[str, Any]) -> Any:
|
|
290
|
+
return self._h.patch(f"/job-listings/{id}", body)
|
|
291
|
+
|
|
292
|
+
def delete(self, id: str) -> Any:
|
|
293
|
+
return self._h.delete(f"/job-listings/{id}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _EmployerJobs:
|
|
297
|
+
def __init__(self, http: _Http) -> None:
|
|
298
|
+
self._h = http
|
|
299
|
+
|
|
300
|
+
def list(self, **params: Any) -> Any:
|
|
301
|
+
return self._h.get("/jobs/post", params)
|
|
302
|
+
|
|
303
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
304
|
+
return self._h.post("/jobs/post", body)
|
|
305
|
+
|
|
306
|
+
def update(self, id: str, body: dict[str, Any]) -> Any:
|
|
307
|
+
return self._h.patch(f"/jobs/post/{id}", body)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _Organization:
|
|
311
|
+
def __init__(self, http: _Http) -> None:
|
|
312
|
+
self._h = http
|
|
313
|
+
|
|
314
|
+
def list_company_orgs(self, **params: Any) -> Any:
|
|
315
|
+
return self._h.get("/company-organizations", params)
|
|
316
|
+
|
|
317
|
+
def list_interview_questions(self, **params: Any) -> Any:
|
|
318
|
+
return self._h.get("/interview-questions", params)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class _Candidates:
|
|
322
|
+
def __init__(self, http: _Http) -> None:
|
|
323
|
+
self._h = http
|
|
324
|
+
|
|
325
|
+
def list(self, **params: Any) -> Any:
|
|
326
|
+
return self._h.get("/candidates", params)
|
|
327
|
+
|
|
328
|
+
def get(self, id: str) -> Any:
|
|
329
|
+
return self._h.get(f"/candidates/{id}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class _Employees:
|
|
333
|
+
def __init__(self, http: _Http) -> None:
|
|
334
|
+
self._h = http
|
|
335
|
+
|
|
336
|
+
def list(self, **params: Any) -> Any:
|
|
337
|
+
return self._h.get("/employees", params)
|
|
338
|
+
|
|
339
|
+
def get(self, id: str) -> Any:
|
|
340
|
+
return self._h.get(f"/employees/{id}")
|
|
341
|
+
|
|
342
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
343
|
+
return self._h.post("/employees", body)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class _Email:
|
|
347
|
+
def __init__(self, http: _Http) -> None:
|
|
348
|
+
self._h = http
|
|
349
|
+
|
|
350
|
+
def list_messages(self, **params: Any) -> Any:
|
|
351
|
+
return self._h.get("/email-messages", params)
|
|
352
|
+
|
|
353
|
+
def send(self, body: dict[str, Any]) -> Any:
|
|
354
|
+
return self._h.post("/email/send", body)
|
|
355
|
+
|
|
356
|
+
def list_sends(self, **params: Any) -> Any:
|
|
357
|
+
return self._h.get("/email/sends", params)
|
|
358
|
+
|
|
359
|
+
def get_send(self, id: str) -> Any:
|
|
360
|
+
return self._h.get(f"/email/sends/{id}")
|
|
361
|
+
|
|
362
|
+
def list_send_events(self, id: str) -> Any:
|
|
363
|
+
return self._h.get(f"/email/sends/{id}/events")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _Usage:
|
|
367
|
+
def __init__(self, http: _Http) -> None:
|
|
368
|
+
self._h = http
|
|
369
|
+
|
|
370
|
+
def list(self, **params: Any) -> Any:
|
|
371
|
+
"""GET /usage — org audit logs (metered + org activity).
|
|
372
|
+
|
|
373
|
+
Optional params: source (all|public_api|platform_ai|org_activity),
|
|
374
|
+
since, until, cursor, limit, api_key_id.
|
|
375
|
+
Rows include where_label / where_href (Chat, Voice, job title, Private, …;
|
|
376
|
+
Chat/Voice hrefs are absolute when a chat id is known).
|
|
377
|
+
Activity rows have ledger_type=org_activity and charged_usd=0.
|
|
378
|
+
"""
|
|
379
|
+
return self._h.get("/usage", params)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class _Billing:
|
|
383
|
+
def __init__(self, http: _Http) -> None:
|
|
384
|
+
self._h = http
|
|
385
|
+
|
|
386
|
+
def balance(self) -> Any:
|
|
387
|
+
return self._h.get("/billing/balance")
|
|
388
|
+
|
|
389
|
+
def invoices(self, **params: Any) -> Any:
|
|
390
|
+
return self._h.get("/billing/invoices", params)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class _Limits:
|
|
394
|
+
def __init__(self, http: _Http) -> None:
|
|
395
|
+
self._h = http
|
|
396
|
+
|
|
397
|
+
def get(self) -> Any:
|
|
398
|
+
return self._h.get("/limits")
|
|
399
|
+
|
|
400
|
+
def get_budget(self) -> Any:
|
|
401
|
+
return self._h.get("/limits/budget")
|
|
402
|
+
|
|
403
|
+
def set_budget(self, body: dict[str, Any]) -> Any:
|
|
404
|
+
return self._h.put("/limits/budget", body)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class _Offers:
|
|
408
|
+
def __init__(self, http: _Http) -> None:
|
|
409
|
+
self._h = http
|
|
410
|
+
|
|
411
|
+
def list(self, **params: Any) -> Any:
|
|
412
|
+
return self._h.get("/offers", params)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class _Webhooks:
|
|
416
|
+
def __init__(self, http: _Http) -> None:
|
|
417
|
+
self._h = http
|
|
418
|
+
|
|
419
|
+
def list(self) -> Any:
|
|
420
|
+
return self._h.get("/webhooks")
|
|
421
|
+
|
|
422
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
423
|
+
return self._h.post("/webhooks", body)
|
|
424
|
+
|
|
425
|
+
def test(self, id: str) -> Any:
|
|
426
|
+
return self._h.post(f"/webhooks/{id}/test", {})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class _Documents:
|
|
430
|
+
def __init__(self, http: _Http) -> None:
|
|
431
|
+
self._h = http
|
|
432
|
+
|
|
433
|
+
def list(self, **params: Any) -> Any:
|
|
434
|
+
return self._h.get("/platform/documents", params)
|
|
435
|
+
|
|
436
|
+
def templates(self, **params: Any) -> Any:
|
|
437
|
+
return self._h.get("/platform/documents/templates", params)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class _Attendance:
|
|
441
|
+
def __init__(self, http: _Http) -> None:
|
|
442
|
+
self._h = http
|
|
443
|
+
|
|
444
|
+
def list(self, **params: Any) -> Any:
|
|
445
|
+
return self._h.get("/attendance", params)
|
|
446
|
+
|
|
447
|
+
def me(self, **params: Any) -> Any:
|
|
448
|
+
return self._h.get("/attendance/me", params)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class _Surveys:
|
|
452
|
+
def __init__(self, http: _Http) -> None:
|
|
453
|
+
self._h = http
|
|
454
|
+
|
|
455
|
+
def list(self, **params: Any) -> Any:
|
|
456
|
+
return self._h.get("/platform/surveys", params)
|
|
457
|
+
|
|
458
|
+
def get(self, id: str) -> Any:
|
|
459
|
+
return self._h.get(f"/platform/surveys/{id}")
|
|
460
|
+
|
|
461
|
+
def create(self, body: dict[str, Any]) -> Any:
|
|
462
|
+
return self._h.post("/platform/surveys", body)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorklittleError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
message: str,
|
|
8
|
+
*,
|
|
9
|
+
status: int,
|
|
10
|
+
code: str = "UNKNOWN",
|
|
11
|
+
request_id: str | None = None,
|
|
12
|
+
body: object = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.code = code
|
|
17
|
+
self.request_id = request_id
|
|
18
|
+
self.body = body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaymentRequiredError(WorklittleError):
|
|
22
|
+
"""HTTP 402 — do not retry metered requests until billing is fixed."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
*,
|
|
28
|
+
request_id: str | None = None,
|
|
29
|
+
body: object = None,
|
|
30
|
+
code: str = "PAYMENT_REQUIRED",
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(message, status=402, code=code, request_id=request_id, body=body)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QuotaExceededError(WorklittleError):
|
|
36
|
+
"""HTTP 429 + QUOTA_EXCEEDED — monthly free quota exhausted; inquire for higher limits."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str,
|
|
41
|
+
*,
|
|
42
|
+
request_id: str | None = None,
|
|
43
|
+
body: object = None,
|
|
44
|
+
code: str = "QUOTA_EXCEEDED",
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message, status=429, code=code, request_id=request_id, body=body)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def verify_webhook_signature(
|
|
9
|
+
*,
|
|
10
|
+
signature_header: str,
|
|
11
|
+
timestamp: str,
|
|
12
|
+
raw_body: str,
|
|
13
|
+
secret: str,
|
|
14
|
+
tolerance_seconds: int = 300,
|
|
15
|
+
) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
ts = int(timestamp)
|
|
18
|
+
except ValueError:
|
|
19
|
+
return False
|
|
20
|
+
if abs(int(time.time()) - ts) > tolerance_seconds:
|
|
21
|
+
return False
|
|
22
|
+
received = signature_header.removeprefix("v1=").strip()
|
|
23
|
+
expected = hmac.new(
|
|
24
|
+
secret.encode("utf-8"),
|
|
25
|
+
f"{timestamp}.{raw_body}".encode("utf-8"),
|
|
26
|
+
hashlib.sha256,
|
|
27
|
+
).hexdigest()
|
|
28
|
+
return hmac.compare_digest(expected, received)
|