audiokit-ai 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,6 @@
1
+ node_modules/
2
+ .venv/
3
+ **/dist/
4
+ **/__pycache__/
5
+ .mypy_cache/
6
+ **/*.tgz
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: audiokit-ai
3
+ Version: 0.1.0
4
+ Summary: Generated Python client and webhook verification for the AudioKit API
5
+ Project-URL: Homepage, https://audiokit.ai/docs/sdks/
6
+ Project-URL: Repository, https://github.com/epsilon-records/audiokit
7
+ License-Expression: LicenseRef-Proprietary
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: httpx<1,>=0.28.1
10
+ Requires-Dist: typing-extensions<5,>=4.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # AudioKit for Python
14
+
15
+ Generated from AudioKit's OpenAPI contract; includes raw-body webhook verification. Python 3.11+.
16
+
17
+ **0.1.0 release candidate — not yet on PyPI.** After publication, install with pip install audiokit-ai. For local testing, install the wheel built from this directory.
18
+
19
+ ```python
20
+ import os
21
+ from audiokit_ai import AudioKit, ApiError, verify_webhook
22
+ from audiokit_ai.models import JobRequest
23
+
24
+ with AudioKit(api_key=os.environ['AUDIOKIT_API_KEY']) as client:
25
+ request: JobRequest = {
26
+ 'job_id': 'my-first-job',
27
+ 'operation': 'probe',
28
+ 'input': {'url': signed_input_url, 'sha256': input_sha256},
29
+ 'webhook': {'url': 'https://example.com/audiokit/webhook'},
30
+ }
31
+ job = client.submit_job(request)
32
+ status = client.get_job(job['provider_job_id'])
33
+
34
+ # In a webhook handler, before parsing JSON:
35
+ if not verify_webhook(raw_body, signature_header, webhook_secret):
36
+ # Return 401. Do not use the event.
37
+ raise ValueError('Invalid webhook signature')
38
+ # After verification, parse the body and deduplicate event_id.
39
+ ```
40
+
41
+ Set base_url for another API instance. Methods include get_health, get_capabilities, get_open_api, submit_job, get_job and cancel_job. Generated models are TypedDicts, so requests and responses are ordinary dictionaries. Use the context manager or call close() to release connections.
42
+
43
+ HTTP failures raise ApiError with status and code. Network failures are native httpx exceptions. Unknown API codes are preserved; treat unfamiliar ones as internal failures. There are no automatic retries or redirects; requests have a 30-second timeout. The verifier takes raw bytes and returns a boolean with a 300-second tolerance in both time directions. Its optional now argument is Unix seconds for deterministic tests. It does not deduplicate events.
44
+
45
+ [API documentation](https://audiokit.ai/docs/) · [SDK guide](https://audiokit.ai/docs/sdks/)
@@ -0,0 +1,33 @@
1
+ # AudioKit for Python
2
+
3
+ Generated from AudioKit's OpenAPI contract; includes raw-body webhook verification. Python 3.11+.
4
+
5
+ **0.1.0 release candidate — not yet on PyPI.** After publication, install with pip install audiokit-ai. For local testing, install the wheel built from this directory.
6
+
7
+ ```python
8
+ import os
9
+ from audiokit_ai import AudioKit, ApiError, verify_webhook
10
+ from audiokit_ai.models import JobRequest
11
+
12
+ with AudioKit(api_key=os.environ['AUDIOKIT_API_KEY']) as client:
13
+ request: JobRequest = {
14
+ 'job_id': 'my-first-job',
15
+ 'operation': 'probe',
16
+ 'input': {'url': signed_input_url, 'sha256': input_sha256},
17
+ 'webhook': {'url': 'https://example.com/audiokit/webhook'},
18
+ }
19
+ job = client.submit_job(request)
20
+ status = client.get_job(job['provider_job_id'])
21
+
22
+ # In a webhook handler, before parsing JSON:
23
+ if not verify_webhook(raw_body, signature_header, webhook_secret):
24
+ # Return 401. Do not use the event.
25
+ raise ValueError('Invalid webhook signature')
26
+ # After verification, parse the body and deduplicate event_id.
27
+ ```
28
+
29
+ Set base_url for another API instance. Methods include get_health, get_capabilities, get_open_api, submit_job, get_job and cancel_job. Generated models are TypedDicts, so requests and responses are ordinary dictionaries. Use the context manager or call close() to release connections.
30
+
31
+ HTTP failures raise ApiError with status and code. Network failures are native httpx exceptions. Unknown API codes are preserved; treat unfamiliar ones as internal failures. There are no automatic retries or redirects; requests have a 30-second timeout. The verifier takes raw bytes and returns a boolean with a 300-second tolerance in both time directions. Its optional now argument is Unix seconds for deterministic tests. It does not deduplicate events.
32
+
33
+ [API documentation](https://audiokit.ai/docs/) · [SDK guide](https://audiokit.ai/docs/sdks/)
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.27.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "audiokit-ai"
7
+ version = "0.1.0"
8
+ description = "Generated Python client and webhook verification for the AudioKit API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "LicenseRef-Proprietary"
12
+ dependencies = ["httpx>=0.28.1,<1", "typing-extensions>=4.13,<5"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://audiokit.ai/docs/sdks/"
16
+ Repository = "https://github.com/epsilon-records/audiokit"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/audiokit_ai"]
@@ -0,0 +1,7 @@
1
+ """AudioKit API client and raw-body webhook verification."""
2
+
3
+ from .client import AudioKit
4
+ from .transport import ApiError
5
+ from .webhook import verify_webhook
6
+
7
+ __all__ = ["AudioKit", "ApiError", "verify_webhook"]
@@ -0,0 +1,32 @@
1
+ # Generated from openapi.yaml by sdks/generate.mjs. Do not edit.
2
+ from urllib.parse import quote
3
+ from typing import cast
4
+
5
+ from . import models
6
+ from .transport import Transport
7
+
8
+
9
+ class AudioKit(Transport):
10
+ def get_health(self) -> models.Health:
11
+ """Liveness and version."""
12
+ return cast(models.Health, self._request("GET", "/v1/health", None, False, True))
13
+
14
+ def get_capabilities(self) -> models.Capabilities:
15
+ """Operations that work end to end on this instance."""
16
+ return cast(models.Capabilities, self._request("GET", "/v1/capabilities", None, False, True))
17
+
18
+ def get_open_api(self) -> str:
19
+ """This contract."""
20
+ return cast(str, self._request("GET", "/v1/openapi.yaml", None, False, False))
21
+
22
+ def submit_job(self, body: models.JobRequest) -> models.Job:
23
+ """Submit a job."""
24
+ return cast(models.Job, self._request("POST", "/v1/jobs", body, True, True))
25
+
26
+ def get_job(self, provider_job_id: str) -> models.Job:
27
+ """Job status and result."""
28
+ return cast(models.Job, self._request("GET", f"/v1/jobs/{quote(provider_job_id, safe='')}", None, True, True))
29
+
30
+ def cancel_job(self, provider_job_id: str) -> models.Job:
31
+ """Cancel a job."""
32
+ return cast(models.Job, self._request("POST", f"/v1/jobs/{quote(provider_job_id, safe='')}/cancel", None, True, True))
@@ -0,0 +1,449 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: openapi.yaml
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Literal, NotRequired, TypedDict
7
+
8
+ from typing_extensions import TypeAliasType
9
+
10
+
11
+ class Health(TypedDict):
12
+ status: Literal['ok']
13
+ version: str
14
+
15
+
16
+ ApiErrorCode = TypeAliasType(
17
+ "ApiErrorCode",
18
+ Literal[
19
+ 'invalid_request',
20
+ 'operation_unavailable',
21
+ 'unauthorized',
22
+ 'not_found',
23
+ 'idempotency_conflict',
24
+ 'client_bootstrap',
25
+ 'rate_limited',
26
+ 'unavailable',
27
+ ]
28
+ | str,
29
+ )
30
+
31
+
32
+ class ClientUpsert(TypedDict):
33
+ id: str
34
+ api_key_sha256: str
35
+ webhook_secret: str
36
+
37
+
38
+ JobErrorCode = TypeAliasType(
39
+ "JobErrorCode",
40
+ Literal[
41
+ 'input_url_rejected',
42
+ 'input_unreachable',
43
+ 'input_too_large',
44
+ 'input_hash_mismatch',
45
+ 'unsupported_format',
46
+ 'unsupported_codec',
47
+ 'corrupt_input',
48
+ 'invalid_params',
49
+ 'operation_unavailable',
50
+ 'encode_failed',
51
+ 'output_url_rejected',
52
+ 'output_upload_failed',
53
+ 'stems_unavailable',
54
+ 'stems_failed',
55
+ 'timeout',
56
+ 'canceled',
57
+ 'internal',
58
+ ]
59
+ | str,
60
+ )
61
+
62
+
63
+ class JobError(TypedDict):
64
+ code: JobErrorCode
65
+ message: str
66
+
67
+
68
+ Operation = TypeAliasType(
69
+ "Operation",
70
+ Literal[
71
+ 'probe',
72
+ 'loudness',
73
+ 'waveform',
74
+ 'transcode',
75
+ 'preview',
76
+ 'normalize',
77
+ 'tempo',
78
+ 'key',
79
+ 'fingerprint',
80
+ 'analyze',
81
+ 'tags',
82
+ 'stems',
83
+ ],
84
+ )
85
+
86
+
87
+ OutputName = TypeAliasType(
88
+ "OutputName", Literal['audio', 'vocals', 'drums', 'bass', 'other']
89
+ )
90
+
91
+
92
+ Sha256 = TypeAliasType("Sha256", str)
93
+
94
+
95
+ HttpsUrl = TypeAliasType("HttpsUrl", str)
96
+
97
+
98
+ class InputFile(TypedDict):
99
+ url: HttpsUrl
100
+ sha256: Sha256
101
+ filename: NotRequired[str]
102
+
103
+
104
+ class OutputTarget(TypedDict):
105
+ url: HttpsUrl
106
+ headers: NotRequired[dict[str, str]]
107
+
108
+
109
+ class WebhookTarget(TypedDict):
110
+ url: HttpsUrl
111
+
112
+
113
+ class AuthorizationRef(TypedDict):
114
+ decision_id: str
115
+ purpose: NotRequired[str]
116
+
117
+
118
+ class NoParams(TypedDict):
119
+ pass
120
+
121
+
122
+ class WaveformParams(TypedDict):
123
+ points: NotRequired[int]
124
+
125
+
126
+ class TempoParams(TypedDict):
127
+ min_bpm: NotRequired[float]
128
+ max_bpm: NotRequired[float]
129
+
130
+
131
+ class TagsParams(TypedDict):
132
+ embedding: NotRequired[bool]
133
+
134
+
135
+ StemsModel = TypeAliasType("StemsModel", Literal['htdemucs_ft', 'htdemucs'])
136
+
137
+
138
+ class StemsParams(TypedDict):
139
+ model: NotRequired[StemsModel]
140
+
141
+
142
+ Format = TypeAliasType("Format", Literal['wav', 'flac', 'mp3', 'aac'])
143
+
144
+
145
+ SampleRate = TypeAliasType(
146
+ "SampleRate", Literal[22050, 24000, 32000, 44100, 48000, 96000]
147
+ )
148
+
149
+
150
+ class TranscodeParams(TypedDict):
151
+ format: Format
152
+ sample_rate: NotRequired[SampleRate]
153
+ channels: NotRequired[Literal[1, 2]]
154
+ bit_depth: NotRequired[Literal[16, 24]]
155
+ bitrate_kbps: NotRequired[int]
156
+
157
+
158
+ class PreviewParams(TypedDict):
159
+ start_seconds: NotRequired[float]
160
+ duration_seconds: NotRequired[float]
161
+ fade_seconds: NotRequired[float]
162
+ format: NotRequired[Format]
163
+ bitrate_kbps: NotRequired[int]
164
+
165
+
166
+ class NormalizeParams(TypedDict):
167
+ target_lufs: float
168
+ true_peak_dbtp: NotRequired[float]
169
+ format: NotRequired[Format]
170
+ sample_rate: NotRequired[SampleRate]
171
+ channels: NotRequired[Literal[1, 2]]
172
+ bit_depth: NotRequired[Literal[16, 24]]
173
+ bitrate_kbps: NotRequired[int]
174
+
175
+
176
+ JobStatus = TypeAliasType(
177
+ "JobStatus", Literal['queued', 'running', 'succeeded', 'failed', 'canceled']
178
+ )
179
+
180
+
181
+ class Provenance(TypedDict):
182
+ input_sha256: Sha256
183
+ audiokit_version: str
184
+ operation: Operation
185
+ params: dict[str, Any]
186
+
187
+
188
+ Decibels = TypeAliasType("Decibels", float | None)
189
+
190
+
191
+ class ProbeResult(TypedDict):
192
+ container: str
193
+ codec: str
194
+ sample_rate: int
195
+ channels: int
196
+ bits_per_sample: int | None
197
+ frames: int
198
+ duration_seconds: float
199
+ size_bytes: int
200
+ decode_errors: int
201
+ valid: bool
202
+
203
+
204
+ class StreamFields(TypedDict):
205
+ sample_rate: int
206
+ channels: int
207
+ duration_seconds: float
208
+
209
+
210
+ class LoudnessFields(TypedDict):
211
+ integrated_lufs: Decibels | None
212
+ loudness_range_lu: Decibels | None
213
+ true_peak_dbtp: Decibels | None
214
+ sample_peak_dbfs: Decibels | None
215
+
216
+
217
+ class LoudnessResult(LoudnessFields, StreamFields):
218
+ pass
219
+
220
+
221
+ class TempoFields(TypedDict):
222
+ bpm: float | None
223
+ confidence: float | None
224
+ steady: bool
225
+ beat_count: int
226
+ beats: list[float]
227
+
228
+
229
+ class TempoResult(TempoFields, StreamFields):
230
+ pass
231
+
232
+
233
+ KeyName = TypeAliasType("KeyName", str)
234
+
235
+
236
+ Camelot = TypeAliasType("Camelot", str)
237
+
238
+
239
+ class Candidate(TypedDict):
240
+ key: KeyName
241
+ camelot: Camelot
242
+ score: float
243
+
244
+
245
+ class KeyFields(TypedDict):
246
+ key: KeyName | None
247
+ tonic: (
248
+ Literal['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'] | None
249
+ )
250
+ mode: Literal['major', 'minor'] | None
251
+ camelot: Camelot | None
252
+ confidence: float | None
253
+ tuning_cents: float | None
254
+ candidates: list[Candidate]
255
+ chroma: list[float] | None
256
+
257
+
258
+ class KeyResult(KeyFields, StreamFields):
259
+ pass
260
+
261
+
262
+ class FingerprintFields(TypedDict):
263
+ fingerprint: str | None
264
+ algorithm: Literal['chromaprint-2']
265
+ fingerprint_seconds: float
266
+
267
+
268
+ class FingerprintResult(FingerprintFields, StreamFields):
269
+ pass
270
+
271
+
272
+ class TagScore(TypedDict):
273
+ tag: str
274
+ score: float
275
+
276
+
277
+ class TagsFields(TypedDict):
278
+ model: str
279
+ windows: int
280
+ embedding: NotRequired[list[float]]
281
+
282
+
283
+ class TagsResult(TypedDict):
284
+ model: str
285
+ windows: int
286
+ embedding: NotRequired[list[float]]
287
+ sample_rate: int
288
+ channels: int
289
+ duration_seconds: float
290
+
291
+
292
+ class WaveformResult(TypedDict):
293
+ points: int
294
+ peaks: list[float]
295
+ sample_rate: int
296
+ channels: int
297
+ duration_seconds: float
298
+
299
+
300
+ class AudioOutputFields1(TypedDict):
301
+ format: Format
302
+ content_type: Literal['audio/wav', 'audio/flac', 'audio/mpeg', 'audio/mp4']
303
+ sample_rate: int
304
+ channels: int
305
+ duration_seconds: float
306
+ bit_depth: int
307
+ bitrate_kbps: NotRequired[int]
308
+ sha256: Sha256
309
+ size_bytes: int
310
+
311
+
312
+ class AudioOutputFields2(TypedDict):
313
+ format: Format
314
+ content_type: Literal['audio/wav', 'audio/flac', 'audio/mpeg', 'audio/mp4']
315
+ sample_rate: int
316
+ channels: int
317
+ duration_seconds: float
318
+ bit_depth: NotRequired[int]
319
+ bitrate_kbps: int
320
+ sha256: Sha256
321
+ size_bytes: int
322
+
323
+
324
+ AudioOutputFields = TypeAliasType(
325
+ "AudioOutputFields", AudioOutputFields1 | AudioOutputFields2
326
+ )
327
+
328
+
329
+ class AudioOutput(TypedDict):
330
+ pass
331
+
332
+
333
+ class PreviewAudioOutput(TypedDict):
334
+ start_seconds: float
335
+ fade_seconds: float
336
+
337
+
338
+ class Outputs(TypedDict):
339
+ audio: AudioOutput
340
+
341
+
342
+ class TranscodeResult(TypedDict):
343
+ outputs: Outputs
344
+
345
+
346
+ class Outputs1(TypedDict):
347
+ audio: PreviewAudioOutput
348
+
349
+
350
+ class PreviewResult(TypedDict):
351
+ outputs: Outputs1
352
+
353
+
354
+ class Outputs2(TypedDict):
355
+ audio: AudioOutput
356
+
357
+
358
+ class NormalizeResult(TypedDict):
359
+ target_lufs: float
360
+ true_peak_ceiling_dbtp: float
361
+ target_reached: bool
362
+ gain_db: float
363
+ before: LoudnessFields
364
+ after: LoudnessFields
365
+ outputs: Outputs2
366
+
367
+
368
+ class StemOutput(TypedDict):
369
+ sha256: Sha256
370
+ size_bytes: int
371
+ format: NotRequired[str]
372
+ content_type: NotRequired[str]
373
+ sample_rate: NotRequired[int]
374
+ channels: NotRequired[int]
375
+ duration_seconds: NotRequired[float]
376
+
377
+
378
+ class Outputs3(TypedDict):
379
+ vocals: StemOutput
380
+ drums: StemOutput
381
+ bass: StemOutput
382
+ other: StemOutput
383
+
384
+
385
+ class StemsResult(TypedDict):
386
+ model: StemsModel
387
+ outputs: Outputs3
388
+
389
+
390
+ Capability = TypedDict(
391
+ 'Capability',
392
+ {
393
+ 'operation': Operation,
394
+ 'outputs': list[OutputName],
395
+ 'async': bool,
396
+ 'requires_consent': bool,
397
+ },
398
+ )
399
+
400
+
401
+ class Error1(TypedDict):
402
+ code: ApiErrorCode
403
+ message: str
404
+
405
+
406
+ class Error(TypedDict):
407
+ error: Error1
408
+
409
+
410
+ class JobRequest(TypedDict):
411
+ job_id: str
412
+ operation: Operation
413
+ input: InputFile
414
+ params: NotRequired[dict[str, Any] | None]
415
+ outputs: NotRequired[dict[str, OutputTarget]]
416
+ webhook: NotRequired[WebhookTarget]
417
+ authorization: NotRequired[AuthorizationRef]
418
+
419
+
420
+ class Job(TypedDict):
421
+ provider_job_id: str
422
+ job_id: str
423
+ operation: Operation
424
+ status: JobStatus
425
+ result: NotRequired[dict[str, Any]]
426
+ error: NotRequired[JobError]
427
+ provenance: NotRequired[Provenance]
428
+ created_at: str
429
+ started_at: NotRequired[str]
430
+ finished_at: NotRequired[str]
431
+
432
+
433
+ class WebhookEvent(TypedDict):
434
+ event_id: str
435
+ type: Literal['job.succeeded', 'job.failed', 'job.canceled']
436
+ created_at: str
437
+ data: Job
438
+
439
+
440
+ class AnalyzeResult(StreamFields):
441
+ loudness: LoudnessFields
442
+ tempo: TempoFields
443
+ key: KeyFields
444
+ fingerprint: FingerprintFields
445
+ tags: NotRequired[TagsFields | None]
446
+
447
+
448
+ class Capabilities(TypedDict):
449
+ operations: list[Capability]
File without changes
@@ -0,0 +1,49 @@
1
+ """HTTP transport. No retries or redirects; HTTP errors preserve API codes."""
2
+ from __future__ import annotations
3
+
4
+ from types import TracebackType
5
+ from typing import Any, Self
6
+
7
+ import httpx
8
+
9
+
10
+ class ApiError(Exception):
11
+ def __init__(self, status: int, code: str, message: str) -> None:
12
+ super().__init__(message)
13
+ self.status = status
14
+ self.code = code
15
+
16
+
17
+ class Transport:
18
+ def __init__(self, api_key: str, base_url: str = "https://api.audiokit.ai") -> None:
19
+ self._api_key = api_key
20
+ self._base_url = base_url.rstrip("/")
21
+ self._client = httpx.Client(timeout=30, follow_redirects=False)
22
+
23
+ def close(self) -> None:
24
+ self._client.close()
25
+
26
+ def __enter__(self) -> Self:
27
+ return self
28
+
29
+ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
30
+ traceback: TracebackType | None) -> None:
31
+ self.close()
32
+
33
+ def _request(self, method: str, path: str, body: Any, auth: bool, json: bool) -> Any:
34
+ headers = {"Accept": "application/json" if json else "application/yaml"}
35
+ if auth:
36
+ headers["Authorization"] = f"Bearer {self._api_key}"
37
+ response = self._client.request(method, self._base_url + path, headers=headers, json=body)
38
+ if not response.is_success:
39
+ try:
40
+ payload = response.json()
41
+ except ValueError:
42
+ payload = None
43
+ error = payload.get("error") if isinstance(payload, dict) else None
44
+ error = error if isinstance(error, dict) else {}
45
+ code = error.get("code")
46
+ message = error.get("message")
47
+ raise ApiError(response.status_code, code if isinstance(code, str) else "internal",
48
+ message if isinstance(message, str) else f"HTTP {response.status_code}")
49
+ return response.json() if json else response.text
@@ -0,0 +1,22 @@
1
+ """Verify the existing AudioKit v1 signature using Python's HMAC primitives."""
2
+ import hashlib
3
+ import hmac
4
+ import math
5
+ import re
6
+ import time
7
+
8
+
9
+ def verify_webhook(raw_body: bytes, signature: str | None, secret: str,
10
+ now: float | None = None) -> bool:
11
+ """Check raw bytes before JSON parsing; deduplicate event_id separately."""
12
+ if not signature:
13
+ return False
14
+ match = re.fullmatch(r"t=([0-9]{1,16}),v1=([0-9a-f]{64})", signature)
15
+ if not match:
16
+ return False
17
+ timestamp = int(match[1])
18
+ current = time.time() if now is None else now
19
+ if timestamp > 9007199254740991 or not math.isfinite(current) or abs(current - timestamp) > 300:
20
+ return False
21
+ expected = hmac.new(secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256).digest()
22
+ return hmac.compare_digest(expected, bytes.fromhex(match[2]))
@@ -0,0 +1,71 @@
1
+ import json
2
+ from pathlib import Path
3
+ import unittest
4
+
5
+ import httpx
6
+
7
+ from audiokit_ai import AudioKit, ApiError, verify_webhook
8
+
9
+ VECTOR = json.loads((Path(__file__).parents[2] / "signature-vector.json").read_text())
10
+ NOW = int(VECTOR["header"].split(",")[0][2:])
11
+
12
+
13
+ class SdkTests(unittest.TestCase):
14
+ def test_signatures(self):
15
+ body = VECTOR["body"].encode()
16
+ header, secret = VECTOR["header"], VECTOR["secret"]
17
+ for delta in [0, -300, 300]:
18
+ self.assertTrue(verify_webhook(body, header, secret, NOW + delta))
19
+ for delta in [-301, 301]:
20
+ self.assertFalse(verify_webhook(body, header, secret, NOW + delta))
21
+ self.assertFalse(verify_webhook(body + b" ", header, secret, NOW))
22
+ self.assertFalse(verify_webhook(body, header, "wrong", NOW))
23
+ self.assertFalse(verify_webhook(body, header, secret, float("nan")))
24
+ for bad in [None, "", "garbage", "t=1,v1=abcd", header + "\n", header + ",t=1",
25
+ header.replace("t=", "t=-"), header.replace("1793440800", "9" * 300),
26
+ header.replace("27d3", "zzzz")]:
27
+ self.assertFalse(verify_webhook(body, bad, secret, NOW), bad)
28
+
29
+ def test_transport(self):
30
+ seen = []
31
+ def handle(req):
32
+ seen.append(req)
33
+ path = req.url.path
34
+ if path.endswith("/conflict"):
35
+ return httpx.Response(409, json={"error": {"code": "idempotency_conflict", "message": "Different request"}})
36
+ if path.endswith("/unknown"):
37
+ return httpx.Response(429, json={"error": {"code": "future_code", "message": "Limited"}})
38
+ if path.endswith("/gateway"):
39
+ return httpx.Response(502, text="Bad gateway")
40
+ if path.endswith("/redirect"):
41
+ return httpx.Response(307, headers={"Location": "/v1/jobs/leaked"})
42
+ if path.endswith("openapi.yaml"):
43
+ return httpx.Response(200, text="openapi: 3.1.0")
44
+ return httpx.Response(200, json={"provider_job_id": "id", "job_id": "sdk-test"})
45
+ with AudioKit("test-key", "https://example.com/") as client:
46
+ client._client.close()
47
+ client._client = httpx.Client(transport=httpx.MockTransport(handle))
48
+ client.submit_job({"job_id": "sdk-test", "operation": "probe", "input": {"url": "https://example.com/test.wav", "sha256": "a" * 64}})
49
+ self.assertEqual(seen[-1].headers["Authorization"], "Bearer test-key")
50
+ self.assertEqual(seen[-1].method, "POST")
51
+ self.assertEqual(json.loads(seen[-1].content)["job_id"], "sdk-test")
52
+ client.get_job("a/b?c")
53
+ self.assertEqual(seen[-1].url.raw_path, b"/v1/jobs/a%2Fb%3Fc")
54
+ client.cancel_job("id")
55
+ self.assertEqual(seen[-1].url.path, "/v1/jobs/id/cancel")
56
+ self.assertEqual(seen[-1].method, "POST")
57
+ for method in [client.get_health, client.get_capabilities, client.get_open_api]:
58
+ method()
59
+ self.assertNotIn("Authorization", seen[-1].headers)
60
+ self.assertEqual(client.get_open_api(), "openapi: 3.1.0")
61
+ for name, status, code in [("conflict", 409, "idempotency_conflict"), ("unknown", 429, "future_code"), ("gateway", 502, "internal"), ("redirect", 307, "internal")]:
62
+ before = len(seen)
63
+ with self.assertRaises(ApiError) as error:
64
+ client.get_job(name)
65
+ self.assertEqual(error.exception.status, status)
66
+ self.assertEqual(error.exception.code, code)
67
+ self.assertEqual(len(seen), before + 1)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()