hushscript 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hushscript/__init__.py +97 -0
- hushscript/_client.py +687 -0
- hushscript/_clock.py +36 -0
- hushscript/_config.py +139 -0
- hushscript/_errors.py +563 -0
- hushscript/_guard.py +61 -0
- hushscript/_http.py +624 -0
- hushscript/_json.py +55 -0
- hushscript/_log.py +155 -0
- hushscript/_models.py +144 -0
- hushscript/_pacing.py +51 -0
- hushscript/_redact.py +140 -0
- hushscript/_response.py +67 -0
- hushscript/_retry.py +205 -0
- hushscript/_shape.py +197 -0
- hushscript/_shapes.py +248 -0
- hushscript/_version.py +1 -0
- hushscript/format.py +224 -0
- hushscript/generated/__init__.py +8 -0
- hushscript/generated/api/__init__.py +1 -0
- hushscript/generated/api/default/__init__.py +1 -0
- hushscript/generated/api/default/buy_minutes.py +248 -0
- hushscript/generated/api/default/claim_agent_account.py +238 -0
- hushscript/generated/api/default/complete_upload.py +238 -0
- hushscript/generated/api/default/create_agent_account_signup.py +261 -0
- hushscript/generated/api/default/create_setup_session.py +244 -0
- hushscript/generated/api/default/create_upload.py +225 -0
- hushscript/generated/api/default/get_account.py +169 -0
- hushscript/generated/api/default/get_job.py +198 -0
- hushscript/generated/api/default/get_org.py +169 -0
- hushscript/generated/api/default/get_transcript.py +226 -0
- hushscript/generated/api/default/get_transcription_context.py +173 -0
- hushscript/generated/api/default/get_upload_part_urls.py +246 -0
- hushscript/generated/api/default/heartbeat_upload.py +197 -0
- hushscript/generated/api/default/lift_transcript_hold.py +206 -0
- hushscript/generated/api/default/list_credit_ledger.py +229 -0
- hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
- hushscript/generated/api/default/list_holds.py +233 -0
- hushscript/generated/api/default/list_jobs.py +249 -0
- hushscript/generated/api/default/list_org_audit.py +221 -0
- hushscript/generated/api/default/list_org_invoices.py +221 -0
- hushscript/generated/api/default/list_org_ledger.py +221 -0
- hushscript/generated/api/default/list_org_matters.py +247 -0
- hushscript/generated/api/default/list_org_members.py +221 -0
- hushscript/generated/api/default/list_org_transcripts.py +233 -0
- hushscript/generated/api/default/list_packs.py +169 -0
- hushscript/generated/api/default/list_saved_cards.py +169 -0
- hushscript/generated/api/default/list_transactions.py +225 -0
- hushscript/generated/api/default/list_transcripts.py +225 -0
- hushscript/generated/api/default/org_audit_csv.py +163 -0
- hushscript/generated/api/default/org_ledger_csv.py +163 -0
- hushscript/generated/api/default/org_usage_report.py +247 -0
- hushscript/generated/api/default/org_usage_report_csv.py +229 -0
- hushscript/generated/api/default/patch_org_member.py +237 -0
- hushscript/generated/api/default/place_transcript_hold.py +206 -0
- hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
- hushscript/generated/api/default/schedule_org_custody.py +215 -0
- hushscript/generated/api/default/set_default_card.py +223 -0
- hushscript/generated/api/default/upload_part.py +249 -0
- hushscript/generated/catalog.py +138 -0
- hushscript/generated/client.py +282 -0
- hushscript/generated/errors.py +16 -0
- hushscript/generated/hints.py +28 -0
- hushscript/generated/models/__init__.py +193 -0
- hushscript/generated/models/account_response.py +114 -0
- hushscript/generated/models/active_hold.py +88 -0
- hushscript/generated/models/active_hold_kind.py +18 -0
- hushscript/generated/models/agent_claim_request.py +61 -0
- hushscript/generated/models/agent_claim_response.py +85 -0
- hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
- hushscript/generated/models/agent_signup_request.py +118 -0
- hushscript/generated/models/agent_signup_request_agent.py +80 -0
- hushscript/generated/models/agent_signup_response.py +111 -0
- hushscript/generated/models/buy_minutes_body.py +89 -0
- hushscript/generated/models/card.py +132 -0
- hushscript/generated/models/cards_response.py +90 -0
- hushscript/generated/models/complete_upload_body.py +107 -0
- hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
- hushscript/generated/models/context_dictionary.py +143 -0
- hushscript/generated/models/context_prompt_preset.py +118 -0
- hushscript/generated/models/create_setup_session_body.py +62 -0
- hushscript/generated/models/create_upload_body.py +254 -0
- hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
- hushscript/generated/models/credit_ledger_entry.py +113 -0
- hushscript/generated/models/credit_ledger_response.py +90 -0
- hushscript/generated/models/deleted_transcript.py +148 -0
- hushscript/generated/models/deleted_transcript_cause.py +18 -0
- hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
- hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
- hushscript/generated/models/deleted_transcripts_response.py +90 -0
- hushscript/generated/models/dictionary_entry.py +123 -0
- hushscript/generated/models/dictionary_entry_category.py +37 -0
- hushscript/generated/models/error.py +155 -0
- hushscript/generated/models/error_details.py +94 -0
- hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
- hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
- hushscript/generated/models/holds_response.py +90 -0
- hushscript/generated/models/job.py +309 -0
- hushscript/generated/models/job_failure_kind_type_1.py +16 -0
- hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
- hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
- hushscript/generated/models/job_list.py +90 -0
- hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
- hushscript/generated/models/list_jobs_active.py +17 -0
- hushscript/generated/models/list_org_matters_status.py +16 -0
- hushscript/generated/models/options_summary.py +135 -0
- hushscript/generated/models/org_audit_entry.py +105 -0
- hushscript/generated/models/org_audit_response.py +90 -0
- hushscript/generated/models/org_invoice.py +175 -0
- hushscript/generated/models/org_invoices_response.py +90 -0
- hushscript/generated/models/org_ledger_entry.py +155 -0
- hushscript/generated/models/org_ledger_response.py +90 -0
- hushscript/generated/models/org_matter.py +79 -0
- hushscript/generated/models/org_matter_status.py +16 -0
- hushscript/generated/models/org_matters_response.py +75 -0
- hushscript/generated/models/org_member.py +123 -0
- hushscript/generated/models/org_members_response.py +90 -0
- hushscript/generated/models/org_summary_response.py +482 -0
- hushscript/generated/models/org_transcript.py +103 -0
- hushscript/generated/models/org_transcripts_response.py +90 -0
- hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
- hushscript/generated/models/org_usage_report_group_by.py +17 -0
- hushscript/generated/models/org_usage_response.py +112 -0
- hushscript/generated/models/org_usage_response_group_by.py +17 -0
- hushscript/generated/models/org_usage_row.py +86 -0
- hushscript/generated/models/pack.py +107 -0
- hushscript/generated/models/packs_response.py +75 -0
- hushscript/generated/models/patch_org_member_body.py +102 -0
- hushscript/generated/models/patch_org_member_response_200.py +61 -0
- hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
- hushscript/generated/models/presigned_part_url.py +69 -0
- hushscript/generated/models/purchase_response.py +76 -0
- hushscript/generated/models/schedule_org_custody_body.py +66 -0
- hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
- hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
- hushscript/generated/models/set_default_card_body.py +61 -0
- hushscript/generated/models/set_default_card_response_200.py +61 -0
- hushscript/generated/models/setup_session_response.py +77 -0
- hushscript/generated/models/transaction.py +183 -0
- hushscript/generated/models/transactions_response.py +98 -0
- hushscript/generated/models/transcript_detail.py +194 -0
- hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
- hushscript/generated/models/transcript_envelope.py +89 -0
- hushscript/generated/models/transcript_envelope_translations.py +62 -0
- hushscript/generated/models/transcript_language.py +75 -0
- hushscript/generated/models/transcript_list.py +90 -0
- hushscript/generated/models/transcript_list_row.py +152 -0
- hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
- hushscript/generated/models/transcription_context_response.py +95 -0
- hushscript/generated/models/upload_complete_response.py +100 -0
- hushscript/generated/models/upload_create_response.py +148 -0
- hushscript/generated/models/upload_create_response_funding.py +16 -0
- hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
- hushscript/generated/models/upload_limits.py +86 -0
- hushscript/generated/models/upload_part_response_200.py +69 -0
- hushscript/generated/models/utterance.py +85 -0
- hushscript/generated/scopes.py +42 -0
- hushscript/generated/types.py +54 -0
- hushscript/money.py +79 -0
- hushscript/paginate.py +78 -0
- hushscript/py.typed +0 -0
- hushscript/resources/__init__.py +1 -0
- hushscript/resources/_params.py +33 -0
- hushscript/resources/account.py +121 -0
- hushscript/resources/agent.py +421 -0
- hushscript/resources/billing.py +567 -0
- hushscript/resources/jobs.py +105 -0
- hushscript/resources/transcripts.py +209 -0
- hushscript/transcribe/__init__.py +1 -0
- hushscript/transcribe/apipeline.py +234 -0
- hushscript/transcribe/aupload.py +197 -0
- hushscript/transcribe/heartbeat.py +78 -0
- hushscript/transcribe/pipeline.py +312 -0
- hushscript/transcribe/preflight.py +272 -0
- hushscript/transcribe/probe.py +185 -0
- hushscript/transcribe/resume.py +115 -0
- hushscript/transcribe/segment.py +23 -0
- hushscript/transcribe/session.py +34 -0
- hushscript/transcribe/sniff.py +30 -0
- hushscript/transcribe/source.py +218 -0
- hushscript/transcribe/upload.py +498 -0
- hushscript/transcribe/wait.py +124 -0
- hushscript/transcript.py +197 -0
- hushscript-1.0.0.dist-info/METADATA +502 -0
- hushscript-1.0.0.dist-info/RECORD +187 -0
- hushscript-1.0.0.dist-info/WHEEL +4 -0
- hushscript-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import struct
|
|
6
|
+
from typing import TYPE_CHECKING, BinaryIO, Final, Protocol, cast
|
|
7
|
+
|
|
8
|
+
from mutagen import MutagenError
|
|
9
|
+
from mutagen.flac import FLAC
|
|
10
|
+
from mutagen.mp3 import MP3, BitrateMode
|
|
11
|
+
from mutagen.mp4 import MP4
|
|
12
|
+
from mutagen.oggopus import OggOpus
|
|
13
|
+
from mutagen.oggvorbis import OggVorbis
|
|
14
|
+
from mutagen.wave import WAVE
|
|
15
|
+
|
|
16
|
+
from hushscript._errors import UnsupportedMediaError, describe_failure
|
|
17
|
+
|
|
18
|
+
from .sniff import MediaFormat, sniff
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
from .source import Source
|
|
24
|
+
|
|
25
|
+
_OPUS_RATE: Final = 48000
|
|
26
|
+
_OPUS_PRE_SKIP_OFFSET: Final = 10
|
|
27
|
+
_MPEG1: Final = 3
|
|
28
|
+
_MPEG2: Final = 2
|
|
29
|
+
_MPEG25: Final = 0
|
|
30
|
+
_LAYER3: Final = 1
|
|
31
|
+
_BITRATES: Final = {
|
|
32
|
+
_MPEG1: (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320),
|
|
33
|
+
_MPEG2: (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160),
|
|
34
|
+
}
|
|
35
|
+
_SAMPLE_RATES: Final = {
|
|
36
|
+
_MPEG1: (44100, 48000, 32000),
|
|
37
|
+
_MPEG2: (22050, 24000, 16000),
|
|
38
|
+
_MPEG25: (11025, 12000, 8000),
|
|
39
|
+
}
|
|
40
|
+
_ID3_HEADER: Final = 10
|
|
41
|
+
_RESYNC_LIMIT: Final = 64 * 1024
|
|
42
|
+
_FRAME_HEADER: Final = 4
|
|
43
|
+
_SYNC_BYTE: Final = 0xFF
|
|
44
|
+
_SYNC_BITS: Final = 0xE0
|
|
45
|
+
_FIELD_MASK: Final = 3
|
|
46
|
+
_RESERVED_RATE: Final = 3
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _unreadable(reason: str) -> UnsupportedMediaError:
|
|
50
|
+
return UnsupportedMediaError(
|
|
51
|
+
f"Could not read the duration ({reason}); pass duration_seconds.",
|
|
52
|
+
code="duration_unreadable",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _id3_size(handle: BinaryIO) -> int:
|
|
57
|
+
handle.seek(0)
|
|
58
|
+
head = handle.read(_ID3_HEADER)
|
|
59
|
+
if len(head) < _ID3_HEADER or not head.startswith(b"ID3"):
|
|
60
|
+
return 0
|
|
61
|
+
size = sum(
|
|
62
|
+
(byte & 0x7F) << shift for byte, shift in zip(head[6:10], (21, 14, 7, 0), strict=True)
|
|
63
|
+
)
|
|
64
|
+
footer = _ID3_HEADER if head[5] & 0x10 else 0
|
|
65
|
+
return _ID3_HEADER + size + footer
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _frame(header: bytes) -> tuple[int, int, int] | None:
|
|
69
|
+
"""(frame length, samples, sample rate) of a Layer III frame header, or None."""
|
|
70
|
+
if (
|
|
71
|
+
len(header) < _FRAME_HEADER
|
|
72
|
+
or header[0] != _SYNC_BYTE
|
|
73
|
+
or header[1] & _SYNC_BITS != _SYNC_BITS
|
|
74
|
+
):
|
|
75
|
+
return None
|
|
76
|
+
version = (header[1] >> 3) & _FIELD_MASK
|
|
77
|
+
layer = (header[1] >> 1) & _FIELD_MASK
|
|
78
|
+
bitrate_index = header[2] >> 4
|
|
79
|
+
rate_index = (header[2] >> 2) & _FIELD_MASK
|
|
80
|
+
if version == 1 or layer != _LAYER3 or bitrate_index in {0, 15} or rate_index == _RESERVED_RATE:
|
|
81
|
+
return None
|
|
82
|
+
table = _BITRATES[_MPEG1 if version == _MPEG1 else _MPEG2]
|
|
83
|
+
rate = _SAMPLE_RATES[version][rate_index]
|
|
84
|
+
samples = 1152 if version == _MPEG1 else 576
|
|
85
|
+
length = samples // 8 * table[bitrate_index] * 1000 // rate + ((header[2] >> 1) & 1)
|
|
86
|
+
return length, samples, rate
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def scan_mp3_duration(handle: BinaryIO) -> float:
|
|
90
|
+
"""The exact duration of an MP3 from its frames, for a file with no Xing, VBRI or Info header.
|
|
91
|
+
|
|
92
|
+
mutagen estimates such a file from the first frame's bitrate, which under-reads a VBR file by
|
|
93
|
+
up to ten times; declaring that would leave the hold short of the billed duration.
|
|
94
|
+
"""
|
|
95
|
+
handle.seek(_id3_size(handle))
|
|
96
|
+
total = 0.0
|
|
97
|
+
frames = 0
|
|
98
|
+
skipped = 0
|
|
99
|
+
while True:
|
|
100
|
+
header = handle.read(_FRAME_HEADER)
|
|
101
|
+
if len(header) < _FRAME_HEADER:
|
|
102
|
+
break
|
|
103
|
+
frame = _frame(header)
|
|
104
|
+
if frame is None:
|
|
105
|
+
skipped += 1
|
|
106
|
+
if skipped > _RESYNC_LIMIT and frames == 0:
|
|
107
|
+
break
|
|
108
|
+
handle.seek(-(_FRAME_HEADER - 1), os.SEEK_CUR)
|
|
109
|
+
continue
|
|
110
|
+
length, samples, rate = frame
|
|
111
|
+
total += samples / rate
|
|
112
|
+
frames += 1
|
|
113
|
+
handle.seek(length - _FRAME_HEADER, os.SEEK_CUR)
|
|
114
|
+
if frames == 0:
|
|
115
|
+
raise _unreadable("no MPEG frames")
|
|
116
|
+
return total
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _Info(Protocol):
|
|
120
|
+
length: float
|
|
121
|
+
bitrate_mode: object
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Parsed(Protocol):
|
|
125
|
+
info: _Info
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _info(parser: Callable[[BinaryIO], object], handle: BinaryIO) -> _Info:
|
|
129
|
+
"""The stream info of a mutagen file; mutagen's own annotations leave it untyped."""
|
|
130
|
+
return cast("_Parsed", parser(handle)).info
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _mp3(handle: BinaryIO) -> float:
|
|
134
|
+
handle.seek(0)
|
|
135
|
+
info = _info(MP3, handle)
|
|
136
|
+
if info.bitrate_mode == BitrateMode.UNKNOWN:
|
|
137
|
+
return scan_mp3_duration(handle)
|
|
138
|
+
return float(info.length)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ogg(handle: BinaryIO) -> float:
|
|
142
|
+
handle.seek(0)
|
|
143
|
+
head = handle.read(512)
|
|
144
|
+
handle.seek(0)
|
|
145
|
+
marker = head.find(b"OpusHead")
|
|
146
|
+
if marker < 0:
|
|
147
|
+
return float(_info(OggVorbis, handle).length)
|
|
148
|
+
# mutagen takes the pre-skip off the end; counting it keeps the declared value at or above
|
|
149
|
+
# the container's own duration.
|
|
150
|
+
(pre_skip,) = struct.unpack_from("<H", head, marker + _OPUS_PRE_SKIP_OFFSET)
|
|
151
|
+
return float(_info(OggOpus, handle).length) + float(pre_skip) / _OPUS_RATE
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _read(kind: MediaFormat | None, handle: BinaryIO) -> float:
|
|
155
|
+
handle.seek(0)
|
|
156
|
+
if kind == "mp3":
|
|
157
|
+
return _mp3(handle)
|
|
158
|
+
if kind == "isobmff":
|
|
159
|
+
return float(_info(MP4, handle).length)
|
|
160
|
+
if kind == "wav":
|
|
161
|
+
return float(_info(WAVE, handle).length)
|
|
162
|
+
if kind == "flac":
|
|
163
|
+
return float(_info(FLAC, handle).length)
|
|
164
|
+
if kind == "ogg":
|
|
165
|
+
return _ogg(handle)
|
|
166
|
+
raise _unreadable("not a container the SDK reads")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def probe_duration(source: Source) -> float:
|
|
170
|
+
"""The file's duration in seconds (spec 8.2), read with mutagen.
|
|
171
|
+
|
|
172
|
+
Raises UnsupportedMediaError (`duration_unreadable`) when it cannot be read; the caller then
|
|
173
|
+
needs to pass the duration. The declared value is `ceil` of this, so the probe errs high.
|
|
174
|
+
"""
|
|
175
|
+
kind = sniff(source.head())
|
|
176
|
+
with source.probe_file() as handle:
|
|
177
|
+
if handle is None:
|
|
178
|
+
raise _unreadable("the input is a stream")
|
|
179
|
+
try:
|
|
180
|
+
seconds = _read(kind, handle)
|
|
181
|
+
except (MutagenError, ValueError, struct.error, EOFError, OSError) as failure:
|
|
182
|
+
raise _unreadable(describe_failure(failure)) from failure
|
|
183
|
+
if not math.isfinite(seconds) or seconds <= 0:
|
|
184
|
+
raise _unreadable("the file reports no length")
|
|
185
|
+
return seconds
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from hushscript._errors import ConfigurationError, ResumeMismatchError, describe_failure
|
|
11
|
+
from hushscript._json import as_dict, loads
|
|
12
|
+
|
|
13
|
+
from .segment import usable_segment
|
|
14
|
+
from .source import FileInput, Source, make_source, path_size
|
|
15
|
+
|
|
16
|
+
TOKEN_VERSION: Final = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ResumeState:
|
|
21
|
+
job_id: str
|
|
22
|
+
idempotency_key: str
|
|
23
|
+
size: int
|
|
24
|
+
duration_seconds: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_token(state: ResumeState) -> str:
|
|
28
|
+
"""The opaque resume token: base64url of `{v, jobId, idempotencyKey, size, durationSeconds}`
|
|
29
|
+
(spec 6.1), the same bytes in every SDK."""
|
|
30
|
+
payload = {
|
|
31
|
+
"v": TOKEN_VERSION,
|
|
32
|
+
"jobId": state.job_id,
|
|
33
|
+
"idempotencyKey": state.idempotency_key,
|
|
34
|
+
"size": state.size,
|
|
35
|
+
"durationSeconds": state.duration_seconds,
|
|
36
|
+
}
|
|
37
|
+
raw = json.dumps(payload, separators=(",", ":")).encode()
|
|
38
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _whole(value: object) -> int | None:
|
|
42
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode_token(token: object) -> ResumeState:
|
|
46
|
+
invalid = ConfigurationError("This is not a resume token from this SDK.")
|
|
47
|
+
if not isinstance(token, str):
|
|
48
|
+
raise invalid
|
|
49
|
+
try:
|
|
50
|
+
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
|
51
|
+
fields = as_dict(loads(raw))
|
|
52
|
+
except (ValueError, binascii.Error) as failure:
|
|
53
|
+
raise ConfigurationError(
|
|
54
|
+
f"This is not a resume token from this SDK ({describe_failure(failure)})."
|
|
55
|
+
) from failure
|
|
56
|
+
if fields is None or fields.get("v") != TOKEN_VERSION:
|
|
57
|
+
raise invalid
|
|
58
|
+
job_id = fields.get("jobId")
|
|
59
|
+
key = fields.get("idempotencyKey")
|
|
60
|
+
size = _whole(fields.get("size"))
|
|
61
|
+
duration = _whole(fields.get("durationSeconds"))
|
|
62
|
+
if not isinstance(job_id, str) or not isinstance(key, str) or size is None or duration is None:
|
|
63
|
+
raise invalid
|
|
64
|
+
if not usable_segment(job_id):
|
|
65
|
+
raise invalid
|
|
66
|
+
return ResumeState(job_id=job_id, idempotency_key=key, size=size, duration_seconds=duration)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_same_file(state: ResumeState, size: int) -> None:
|
|
70
|
+
"""A replay with a different file silently returns the old job (spec 7.3), so it is refused
|
|
71
|
+
before any request."""
|
|
72
|
+
if size != state.size:
|
|
73
|
+
raise ResumeMismatchError(
|
|
74
|
+
f"The file is {size} bytes; the upload started with {state.size}.",
|
|
75
|
+
code="resume_file_mismatch",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def early_source(state: ResumeState, file: FileInput | None) -> Source | None:
|
|
80
|
+
"""The source of a resume's file, when it can be had before any request. A path is only sized
|
|
81
|
+
(a different size is refused now) and opened later, once the job is known to be `uploading`
|
|
82
|
+
(spec 7.3)."""
|
|
83
|
+
if file is None:
|
|
84
|
+
return None
|
|
85
|
+
if isinstance(file, (str, os.PathLike)):
|
|
86
|
+
size = path_size(file)
|
|
87
|
+
if size is not None:
|
|
88
|
+
check_same_file(state, size)
|
|
89
|
+
return None
|
|
90
|
+
source = make_source(file, default_size=state.size)
|
|
91
|
+
check_same_file(state, source.size)
|
|
92
|
+
return source
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def late_source(state: ResumeState, file: FileInput | None, source: Source | None) -> Source:
|
|
96
|
+
"""The source for the upload a resume has to finish."""
|
|
97
|
+
if source is not None:
|
|
98
|
+
return source
|
|
99
|
+
if file is None:
|
|
100
|
+
raise file_required()
|
|
101
|
+
opened = make_source(file, default_size=state.size)
|
|
102
|
+
check_same_file(state, opened.size)
|
|
103
|
+
return opened
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def file_required() -> ResumeMismatchError:
|
|
107
|
+
return ResumeMismatchError(
|
|
108
|
+
"The upload was not finished and no file was given.", code="resume_file_required"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def job_missing() -> ResumeMismatchError:
|
|
113
|
+
return ResumeMismatchError(
|
|
114
|
+
"The job behind this resume token no longer exists.", code="resume_job_missing"
|
|
115
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
_DOT_SEGMENTS = frozenset({"", ".", ".."})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def usable_segment(value: str) -> bool:
|
|
9
|
+
"""Whether an id the server or a resume token gave can stand as one path segment: not empty,
|
|
10
|
+
not a dot segment (`GET /v1/jobs/..` is `GET /v1`), no slash or backslash, valid UTF-8."""
|
|
11
|
+
if value in _DOT_SEGMENTS or "/" in value or "\\" in value:
|
|
12
|
+
return False
|
|
13
|
+
try:
|
|
14
|
+
value.encode()
|
|
15
|
+
except UnicodeEncodeError:
|
|
16
|
+
return False
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def segment(value: str) -> str:
|
|
21
|
+
"""`value` percent-encoded as one path segment; callers check `usable_segment` first where the
|
|
22
|
+
id comes from outside."""
|
|
23
|
+
return quote(value, safe="")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from hushscript._clock import Clock
|
|
10
|
+
from hushscript._log import RequestLog
|
|
11
|
+
from hushscript._redact import Credentials
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Session:
|
|
16
|
+
"""What the pipeline needs from a client. `http` talks to the API (the retrying transport adds
|
|
17
|
+
the token); `r2` is a second client with no token and no base URL, for the signed direct part
|
|
18
|
+
URLs, which are never API requests."""
|
|
19
|
+
|
|
20
|
+
clock: Clock
|
|
21
|
+
credentials: Credentials
|
|
22
|
+
log: RequestLog
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class SyncSession(Session):
|
|
27
|
+
http: httpx.Client
|
|
28
|
+
r2: httpx.Client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class AsyncSession(Session):
|
|
33
|
+
http: httpx.AsyncClient
|
|
34
|
+
r2: httpx.AsyncClient
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Final, Literal
|
|
4
|
+
|
|
5
|
+
MediaFormat = Literal["mp3", "isobmff", "wav", "flac", "ogg"]
|
|
6
|
+
|
|
7
|
+
HEAD_BYTES: Final = 4096
|
|
8
|
+
_RIFF: Final = (b"RIFF", b"RF64", b"BW64")
|
|
9
|
+
_FRAME_SYNC_MASK: Final = 0xE0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sniff(head: bytes) -> MediaFormat | None:
|
|
13
|
+
"""The container of `head` by its magic bytes (spec 8.1), never by the file name.
|
|
14
|
+
|
|
15
|
+
Matches `backend/src/audio_probe.rs`: MP3 (an ID3 tag or a frame sync), ISO-BMFF (`ftyp` at
|
|
16
|
+
offset 4: m4a, mp4, mov), WAV, FLAC and Ogg. Anything else is None.
|
|
17
|
+
"""
|
|
18
|
+
if head.startswith(b"ID3") or (
|
|
19
|
+
len(head) >= 2 and head[0] == 0xFF and head[1] & _FRAME_SYNC_MASK == _FRAME_SYNC_MASK # noqa: PLR2004
|
|
20
|
+
):
|
|
21
|
+
return "mp3"
|
|
22
|
+
if len(head) >= 12 and head[4:8] == b"ftyp": # noqa: PLR2004
|
|
23
|
+
return "isobmff"
|
|
24
|
+
if len(head) >= 12 and head[:4] in _RIFF and head[8:12] == b"WAVE": # noqa: PLR2004
|
|
25
|
+
return "wav"
|
|
26
|
+
if head.startswith(b"fLaC"):
|
|
27
|
+
return "flac"
|
|
28
|
+
if head.startswith(b"OggS"):
|
|
29
|
+
return "ogg"
|
|
30
|
+
return None
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import threading
|
|
7
|
+
from contextlib import contextmanager, nullcontext
|
|
8
|
+
from typing import TYPE_CHECKING, BinaryIO, Final
|
|
9
|
+
|
|
10
|
+
from hushscript._errors import PreflightError, UploadError, describe_failure
|
|
11
|
+
|
|
12
|
+
from .sniff import HEAD_BYTES
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Generator
|
|
16
|
+
from contextlib import AbstractContextManager
|
|
17
|
+
|
|
18
|
+
FileInput = str | os.PathLike[str] | bytes | bytearray | memoryview | BinaryIO
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unreadable(path: str, reason: str) -> PreflightError:
|
|
22
|
+
return PreflightError(f"The file {path} cannot be read: {reason}.", code="file_unreadable")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def path_size(file: FileInput) -> int | None:
|
|
26
|
+
"""The size of a path input, without opening it; None for any other input or when the path
|
|
27
|
+
cannot be sized (the error is raised later, when the file is opened)."""
|
|
28
|
+
if not isinstance(file, (str, os.PathLike)):
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
status = os.stat(file) # noqa: PTH116
|
|
32
|
+
except OSError:
|
|
33
|
+
return None
|
|
34
|
+
return status.st_size if stat.S_ISREG(status.st_mode) else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_full(stream: BinaryIO, length: int) -> bytes:
|
|
38
|
+
"""Up to `length` bytes, reading on until they are all there or the stream ends. A raw
|
|
39
|
+
unbuffered stream (a pipe, `io.FileIO`) may return fewer bytes than asked without being at
|
|
40
|
+
its end, so one short read is not EOF."""
|
|
41
|
+
data = stream.read(length)
|
|
42
|
+
if len(data) >= length or not data:
|
|
43
|
+
return data
|
|
44
|
+
chunks = [data]
|
|
45
|
+
remaining = length - len(data)
|
|
46
|
+
while remaining > 0:
|
|
47
|
+
chunk = stream.read(remaining)
|
|
48
|
+
if not chunk:
|
|
49
|
+
break
|
|
50
|
+
chunks.append(chunk)
|
|
51
|
+
remaining -= len(chunk)
|
|
52
|
+
return b"".join(chunks)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Source:
|
|
56
|
+
"""A file the pipeline reads in byte ranges: from a path, from memory or from a stream."""
|
|
57
|
+
|
|
58
|
+
size: int
|
|
59
|
+
sequential: bool = False
|
|
60
|
+
"""True for a stream that cannot seek: its parts must be read one after the other."""
|
|
61
|
+
is_stream: bool = False
|
|
62
|
+
|
|
63
|
+
def read_range(self, offset: int, length: int) -> bytes:
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def head(self) -> bytes:
|
|
67
|
+
return self.read_range(0, min(HEAD_BYTES, self.size))
|
|
68
|
+
|
|
69
|
+
def probe_file(self) -> AbstractContextManager[BinaryIO | None]:
|
|
70
|
+
"""A seekable binary file for the duration probe, or None when there is none."""
|
|
71
|
+
return nullcontext(None)
|
|
72
|
+
|
|
73
|
+
def at_end(self) -> bool:
|
|
74
|
+
"""True when nothing is left past `size`; a stream that holds more than it declared is
|
|
75
|
+
not (spec 7.3)."""
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PathSource(Source):
|
|
80
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
81
|
+
self._path = os.fspath(path)
|
|
82
|
+
try:
|
|
83
|
+
status = os.stat(self._path) # noqa: PTH116 # the plain os API keeps str paths
|
|
84
|
+
if not stat.S_ISREG(status.st_mode):
|
|
85
|
+
raise unreadable(self._path, "it is not a regular file")
|
|
86
|
+
with open(self._path, "rb"): # noqa: PTH123 # proves the file can be opened
|
|
87
|
+
pass
|
|
88
|
+
except OSError as failure:
|
|
89
|
+
raise PreflightError(
|
|
90
|
+
f"The file {self._path} cannot be read ({describe_failure(failure)}).",
|
|
91
|
+
code="file_unreadable",
|
|
92
|
+
) from failure
|
|
93
|
+
self.size = status.st_size
|
|
94
|
+
|
|
95
|
+
def read_range(self, offset: int, length: int) -> bytes:
|
|
96
|
+
with open(self._path, "rb") as handle: # noqa: PTH123
|
|
97
|
+
handle.seek(offset)
|
|
98
|
+
return handle.read(length)
|
|
99
|
+
|
|
100
|
+
def probe_file(self) -> AbstractContextManager[BinaryIO | None]:
|
|
101
|
+
return open(self._path, "rb") # noqa: PTH123 # the caller enters the context
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BytesSource(Source):
|
|
105
|
+
def __init__(self, data: bytes) -> None:
|
|
106
|
+
self._data = data
|
|
107
|
+
self.size = len(data)
|
|
108
|
+
|
|
109
|
+
def read_range(self, offset: int, length: int) -> bytes:
|
|
110
|
+
return self._data[offset : offset + length]
|
|
111
|
+
|
|
112
|
+
def probe_file(self) -> AbstractContextManager[BinaryIO | None]:
|
|
113
|
+
return nullcontext(io.BytesIO(self._data))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SeekableSource(Source):
|
|
117
|
+
"""A seekable stream. Reads take a lock, so parts can be read from several threads."""
|
|
118
|
+
|
|
119
|
+
is_stream = True
|
|
120
|
+
|
|
121
|
+
def __init__(self, stream: BinaryIO, size: int | None) -> None:
|
|
122
|
+
self._stream = stream
|
|
123
|
+
self._lock = threading.Lock()
|
|
124
|
+
if size is None:
|
|
125
|
+
with self._lock:
|
|
126
|
+
size = stream.seek(0, os.SEEK_END)
|
|
127
|
+
self.size = size
|
|
128
|
+
|
|
129
|
+
def read_range(self, offset: int, length: int) -> bytes:
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._stream.seek(offset)
|
|
132
|
+
return read_full(self._stream, length)
|
|
133
|
+
|
|
134
|
+
def at_end(self) -> bool:
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._stream.seek(self.size)
|
|
137
|
+
return self._stream.read(1) == b""
|
|
138
|
+
|
|
139
|
+
def probe_file(self) -> AbstractContextManager[BinaryIO | None]:
|
|
140
|
+
@contextmanager
|
|
141
|
+
def locked() -> Generator[BinaryIO]:
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._stream.seek(0)
|
|
144
|
+
yield self._stream
|
|
145
|
+
|
|
146
|
+
return locked()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SequentialSource(Source):
|
|
150
|
+
"""A stream that cannot seek. It is read once, in order, with at most one part buffered."""
|
|
151
|
+
|
|
152
|
+
sequential = True
|
|
153
|
+
is_stream = True
|
|
154
|
+
|
|
155
|
+
def __init__(self, stream: BinaryIO, size: int) -> None:
|
|
156
|
+
self._stream = stream
|
|
157
|
+
self.size = size
|
|
158
|
+
self._position = 0
|
|
159
|
+
self._head = b""
|
|
160
|
+
self._head_taken = False
|
|
161
|
+
|
|
162
|
+
def head(self) -> bytes:
|
|
163
|
+
if not self._head_taken:
|
|
164
|
+
self._head = read_full(self._stream, min(HEAD_BYTES, self.size))
|
|
165
|
+
self._head_taken = True
|
|
166
|
+
return self._head
|
|
167
|
+
|
|
168
|
+
def read_range(self, offset: int, length: int) -> bytes:
|
|
169
|
+
if offset != self._position:
|
|
170
|
+
raise UploadError(
|
|
171
|
+
"A stream can only be uploaded in order, one part at a time.",
|
|
172
|
+
code="stream_not_seekable",
|
|
173
|
+
)
|
|
174
|
+
head = self.head()
|
|
175
|
+
if offset >= len(head):
|
|
176
|
+
data = read_full(self._stream, length)
|
|
177
|
+
else:
|
|
178
|
+
data = head[offset : offset + length]
|
|
179
|
+
if len(data) < length:
|
|
180
|
+
data += read_full(self._stream, length - len(data))
|
|
181
|
+
self._position += len(data)
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
def at_end(self) -> bool:
|
|
185
|
+
return self._stream.read(1) == b""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
NEEDS_SIZE: Final = (
|
|
189
|
+
"A stream that cannot seek needs `size` (its length in bytes) and `duration_seconds`."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check_size(size: object) -> None:
|
|
194
|
+
"""A stream `size` is a positive whole number of bytes (spec 7.3)."""
|
|
195
|
+
if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size < 1):
|
|
196
|
+
raise PreflightError(
|
|
197
|
+
"size must be a positive whole number of bytes.", code="invalid_option"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def make_source(
|
|
202
|
+
file: FileInput, size: int | None = None, *, default_size: int | None = None
|
|
203
|
+
) -> Source:
|
|
204
|
+
"""Wraps a path, bytes or a binary stream. `size` is required only for a non-seekable stream;
|
|
205
|
+
`default_size` stands in for it there (a resume knows the size from its token) and is ignored
|
|
206
|
+
for a stream that can seek, which is measured."""
|
|
207
|
+
if isinstance(file, (str, os.PathLike)):
|
|
208
|
+
return PathSource(file)
|
|
209
|
+
if isinstance(file, (bytes, bytearray, memoryview)):
|
|
210
|
+
return BytesSource(bytes(file))
|
|
211
|
+
check_size(size)
|
|
212
|
+
stream = file
|
|
213
|
+
if stream.seekable():
|
|
214
|
+
return SeekableSource(stream, size)
|
|
215
|
+
size = default_size if size is None else size
|
|
216
|
+
if size is None:
|
|
217
|
+
raise PreflightError(NEEDS_SIZE, code="invalid_option")
|
|
218
|
+
return SequentialSource(stream, size)
|