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.
Files changed (187) hide show
  1. hushscript/__init__.py +97 -0
  2. hushscript/_client.py +687 -0
  3. hushscript/_clock.py +36 -0
  4. hushscript/_config.py +139 -0
  5. hushscript/_errors.py +563 -0
  6. hushscript/_guard.py +61 -0
  7. hushscript/_http.py +624 -0
  8. hushscript/_json.py +55 -0
  9. hushscript/_log.py +155 -0
  10. hushscript/_models.py +144 -0
  11. hushscript/_pacing.py +51 -0
  12. hushscript/_redact.py +140 -0
  13. hushscript/_response.py +67 -0
  14. hushscript/_retry.py +205 -0
  15. hushscript/_shape.py +197 -0
  16. hushscript/_shapes.py +248 -0
  17. hushscript/_version.py +1 -0
  18. hushscript/format.py +224 -0
  19. hushscript/generated/__init__.py +8 -0
  20. hushscript/generated/api/__init__.py +1 -0
  21. hushscript/generated/api/default/__init__.py +1 -0
  22. hushscript/generated/api/default/buy_minutes.py +248 -0
  23. hushscript/generated/api/default/claim_agent_account.py +238 -0
  24. hushscript/generated/api/default/complete_upload.py +238 -0
  25. hushscript/generated/api/default/create_agent_account_signup.py +261 -0
  26. hushscript/generated/api/default/create_setup_session.py +244 -0
  27. hushscript/generated/api/default/create_upload.py +225 -0
  28. hushscript/generated/api/default/get_account.py +169 -0
  29. hushscript/generated/api/default/get_job.py +198 -0
  30. hushscript/generated/api/default/get_org.py +169 -0
  31. hushscript/generated/api/default/get_transcript.py +226 -0
  32. hushscript/generated/api/default/get_transcription_context.py +173 -0
  33. hushscript/generated/api/default/get_upload_part_urls.py +246 -0
  34. hushscript/generated/api/default/heartbeat_upload.py +197 -0
  35. hushscript/generated/api/default/lift_transcript_hold.py +206 -0
  36. hushscript/generated/api/default/list_credit_ledger.py +229 -0
  37. hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
  38. hushscript/generated/api/default/list_holds.py +233 -0
  39. hushscript/generated/api/default/list_jobs.py +249 -0
  40. hushscript/generated/api/default/list_org_audit.py +221 -0
  41. hushscript/generated/api/default/list_org_invoices.py +221 -0
  42. hushscript/generated/api/default/list_org_ledger.py +221 -0
  43. hushscript/generated/api/default/list_org_matters.py +247 -0
  44. hushscript/generated/api/default/list_org_members.py +221 -0
  45. hushscript/generated/api/default/list_org_transcripts.py +233 -0
  46. hushscript/generated/api/default/list_packs.py +169 -0
  47. hushscript/generated/api/default/list_saved_cards.py +169 -0
  48. hushscript/generated/api/default/list_transactions.py +225 -0
  49. hushscript/generated/api/default/list_transcripts.py +225 -0
  50. hushscript/generated/api/default/org_audit_csv.py +163 -0
  51. hushscript/generated/api/default/org_ledger_csv.py +163 -0
  52. hushscript/generated/api/default/org_usage_report.py +247 -0
  53. hushscript/generated/api/default/org_usage_report_csv.py +229 -0
  54. hushscript/generated/api/default/patch_org_member.py +237 -0
  55. hushscript/generated/api/default/place_transcript_hold.py +206 -0
  56. hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
  57. hushscript/generated/api/default/schedule_org_custody.py +215 -0
  58. hushscript/generated/api/default/set_default_card.py +223 -0
  59. hushscript/generated/api/default/upload_part.py +249 -0
  60. hushscript/generated/catalog.py +138 -0
  61. hushscript/generated/client.py +282 -0
  62. hushscript/generated/errors.py +16 -0
  63. hushscript/generated/hints.py +28 -0
  64. hushscript/generated/models/__init__.py +193 -0
  65. hushscript/generated/models/account_response.py +114 -0
  66. hushscript/generated/models/active_hold.py +88 -0
  67. hushscript/generated/models/active_hold_kind.py +18 -0
  68. hushscript/generated/models/agent_claim_request.py +61 -0
  69. hushscript/generated/models/agent_claim_response.py +85 -0
  70. hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
  71. hushscript/generated/models/agent_signup_request.py +118 -0
  72. hushscript/generated/models/agent_signup_request_agent.py +80 -0
  73. hushscript/generated/models/agent_signup_response.py +111 -0
  74. hushscript/generated/models/buy_minutes_body.py +89 -0
  75. hushscript/generated/models/card.py +132 -0
  76. hushscript/generated/models/cards_response.py +90 -0
  77. hushscript/generated/models/complete_upload_body.py +107 -0
  78. hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
  79. hushscript/generated/models/context_dictionary.py +143 -0
  80. hushscript/generated/models/context_prompt_preset.py +118 -0
  81. hushscript/generated/models/create_setup_session_body.py +62 -0
  82. hushscript/generated/models/create_upload_body.py +254 -0
  83. hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
  84. hushscript/generated/models/credit_ledger_entry.py +113 -0
  85. hushscript/generated/models/credit_ledger_response.py +90 -0
  86. hushscript/generated/models/deleted_transcript.py +148 -0
  87. hushscript/generated/models/deleted_transcript_cause.py +18 -0
  88. hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
  89. hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
  90. hushscript/generated/models/deleted_transcripts_response.py +90 -0
  91. hushscript/generated/models/dictionary_entry.py +123 -0
  92. hushscript/generated/models/dictionary_entry_category.py +37 -0
  93. hushscript/generated/models/error.py +155 -0
  94. hushscript/generated/models/error_details.py +94 -0
  95. hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
  96. hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
  97. hushscript/generated/models/holds_response.py +90 -0
  98. hushscript/generated/models/job.py +309 -0
  99. hushscript/generated/models/job_failure_kind_type_1.py +16 -0
  100. hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
  101. hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
  102. hushscript/generated/models/job_list.py +90 -0
  103. hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
  104. hushscript/generated/models/list_jobs_active.py +17 -0
  105. hushscript/generated/models/list_org_matters_status.py +16 -0
  106. hushscript/generated/models/options_summary.py +135 -0
  107. hushscript/generated/models/org_audit_entry.py +105 -0
  108. hushscript/generated/models/org_audit_response.py +90 -0
  109. hushscript/generated/models/org_invoice.py +175 -0
  110. hushscript/generated/models/org_invoices_response.py +90 -0
  111. hushscript/generated/models/org_ledger_entry.py +155 -0
  112. hushscript/generated/models/org_ledger_response.py +90 -0
  113. hushscript/generated/models/org_matter.py +79 -0
  114. hushscript/generated/models/org_matter_status.py +16 -0
  115. hushscript/generated/models/org_matters_response.py +75 -0
  116. hushscript/generated/models/org_member.py +123 -0
  117. hushscript/generated/models/org_members_response.py +90 -0
  118. hushscript/generated/models/org_summary_response.py +482 -0
  119. hushscript/generated/models/org_transcript.py +103 -0
  120. hushscript/generated/models/org_transcripts_response.py +90 -0
  121. hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
  122. hushscript/generated/models/org_usage_report_group_by.py +17 -0
  123. hushscript/generated/models/org_usage_response.py +112 -0
  124. hushscript/generated/models/org_usage_response_group_by.py +17 -0
  125. hushscript/generated/models/org_usage_row.py +86 -0
  126. hushscript/generated/models/pack.py +107 -0
  127. hushscript/generated/models/packs_response.py +75 -0
  128. hushscript/generated/models/patch_org_member_body.py +102 -0
  129. hushscript/generated/models/patch_org_member_response_200.py +61 -0
  130. hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
  131. hushscript/generated/models/presigned_part_url.py +69 -0
  132. hushscript/generated/models/purchase_response.py +76 -0
  133. hushscript/generated/models/schedule_org_custody_body.py +66 -0
  134. hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
  135. hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
  136. hushscript/generated/models/set_default_card_body.py +61 -0
  137. hushscript/generated/models/set_default_card_response_200.py +61 -0
  138. hushscript/generated/models/setup_session_response.py +77 -0
  139. hushscript/generated/models/transaction.py +183 -0
  140. hushscript/generated/models/transactions_response.py +98 -0
  141. hushscript/generated/models/transcript_detail.py +194 -0
  142. hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
  143. hushscript/generated/models/transcript_envelope.py +89 -0
  144. hushscript/generated/models/transcript_envelope_translations.py +62 -0
  145. hushscript/generated/models/transcript_language.py +75 -0
  146. hushscript/generated/models/transcript_list.py +90 -0
  147. hushscript/generated/models/transcript_list_row.py +152 -0
  148. hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
  149. hushscript/generated/models/transcription_context_response.py +95 -0
  150. hushscript/generated/models/upload_complete_response.py +100 -0
  151. hushscript/generated/models/upload_create_response.py +148 -0
  152. hushscript/generated/models/upload_create_response_funding.py +16 -0
  153. hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
  154. hushscript/generated/models/upload_limits.py +86 -0
  155. hushscript/generated/models/upload_part_response_200.py +69 -0
  156. hushscript/generated/models/utterance.py +85 -0
  157. hushscript/generated/scopes.py +42 -0
  158. hushscript/generated/types.py +54 -0
  159. hushscript/money.py +79 -0
  160. hushscript/paginate.py +78 -0
  161. hushscript/py.typed +0 -0
  162. hushscript/resources/__init__.py +1 -0
  163. hushscript/resources/_params.py +33 -0
  164. hushscript/resources/account.py +121 -0
  165. hushscript/resources/agent.py +421 -0
  166. hushscript/resources/billing.py +567 -0
  167. hushscript/resources/jobs.py +105 -0
  168. hushscript/resources/transcripts.py +209 -0
  169. hushscript/transcribe/__init__.py +1 -0
  170. hushscript/transcribe/apipeline.py +234 -0
  171. hushscript/transcribe/aupload.py +197 -0
  172. hushscript/transcribe/heartbeat.py +78 -0
  173. hushscript/transcribe/pipeline.py +312 -0
  174. hushscript/transcribe/preflight.py +272 -0
  175. hushscript/transcribe/probe.py +185 -0
  176. hushscript/transcribe/resume.py +115 -0
  177. hushscript/transcribe/segment.py +23 -0
  178. hushscript/transcribe/session.py +34 -0
  179. hushscript/transcribe/sniff.py +30 -0
  180. hushscript/transcribe/source.py +218 -0
  181. hushscript/transcribe/upload.py +498 -0
  182. hushscript/transcribe/wait.py +124 -0
  183. hushscript/transcript.py +197 -0
  184. hushscript-1.0.0.dist-info/METADATA +502 -0
  185. hushscript-1.0.0.dist-info/RECORD +187 -0
  186. hushscript-1.0.0.dist-info/WHEEL +4 -0
  187. 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)