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,78 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import TYPE_CHECKING, Final
5
+
6
+ from hushscript._errors import AuthenticationError, ConflictError, HushscriptError
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Awaitable, Callable
10
+
11
+ from hushscript._clock import Clock
12
+ from hushscript._log import RequestLog
13
+
14
+ HEARTBEAT_INTERVAL: Final = 120.0
15
+ WRONG_STATE: Final = "wrong_state"
16
+
17
+
18
+ class Heartbeat:
19
+ """Keeps an upload alive: a ping every 120 s from create until complete (spec 8.5).
20
+
21
+ A failed ping was already retried by the transport and is otherwise ignored, whatever it
22
+ raised: only a clean `wrong_state` or `unauthorized` ends the loop. `wrong_state`
23
+ ends the loop (the job has moved on); `unauthorized` ends it and is kept in `error` for the
24
+ upload to raise.
25
+ """
26
+
27
+ def __init__(self, clock: Clock, log: RequestLog | None = None) -> None:
28
+ self._clock = clock
29
+ self._log = log
30
+ self._stop = threading.Event()
31
+ self._thread: threading.Thread | None = None
32
+ self.error: AuthenticationError | None = None
33
+
34
+ def _keep_going(self, failure: HushscriptError) -> bool:
35
+ if isinstance(failure, ConflictError) and failure.code == WRONG_STATE:
36
+ return False
37
+ if isinstance(failure, AuthenticationError):
38
+ self.error = failure
39
+ return False
40
+ return True
41
+
42
+ def _unexpected(self, failure: Exception) -> None:
43
+ if self._log is not None:
44
+ self._log.note(f"heartbeat ping failed ({type(failure).__name__}: {failure})")
45
+
46
+ def run(self, ping: Callable[[], None]) -> None:
47
+ while not self._clock.wait(self._stop, HEARTBEAT_INTERVAL):
48
+ try:
49
+ ping()
50
+ except HushscriptError as failure: # noqa: PERF203 # recover per ping
51
+ if not self._keep_going(failure):
52
+ return
53
+ except Exception as failure: # noqa: BLE001 # a ping that breaks must not end the beat
54
+ self._unexpected(failure)
55
+
56
+ async def arun(self, ping: Callable[[], Awaitable[None]]) -> None:
57
+ while True:
58
+ await self._clock.asleep(HEARTBEAT_INTERVAL)
59
+ try:
60
+ await ping()
61
+ except HushscriptError as failure:
62
+ if not self._keep_going(failure):
63
+ return
64
+ except Exception as failure: # noqa: BLE001 # a ping that breaks must not end the beat
65
+ self._unexpected(failure)
66
+
67
+ def start(self, ping: Callable[[], None]) -> None:
68
+ self._thread = threading.Thread(target=self.run, args=(ping,), daemon=True)
69
+ self._thread.start()
70
+
71
+ def stop(self) -> None:
72
+ self._stop.set()
73
+ if self._thread is not None:
74
+ self._thread.join(timeout=5)
75
+
76
+ def raise_if_failed(self) -> None:
77
+ if self.error is not None:
78
+ raise self.error
@@ -0,0 +1,312 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Final, TypeVar
6
+
7
+ from hushscript._errors import (
8
+ HushscriptError,
9
+ NotFoundError,
10
+ add_note,
11
+ raise_for_response,
12
+ unexpected_error,
13
+ )
14
+ from hushscript._models import Job, UploadingProgress
15
+ from hushscript._response import json_body, parsed
16
+
17
+ from .heartbeat import Heartbeat
18
+ from .preflight import Media, Options, check_account, check_media, create_body
19
+ from .resume import (
20
+ ResumeState,
21
+ decode_token,
22
+ early_source,
23
+ encode_token,
24
+ file_required,
25
+ job_missing,
26
+ late_source,
27
+ )
28
+ from .segment import segment
29
+ from .source import FileInput, Source, make_source
30
+ from .upload import SyncUploader, parse_created, stream_size_mismatch
31
+ from .wait import finish, job_from, transcript_of, wait_for_job
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable, Mapping
35
+
36
+ from hushscript._models import Progress
37
+ from hushscript._redact import Credentials
38
+ from hushscript.transcript import Transcript
39
+
40
+ from .session import SyncSession
41
+ from .upload import Created
42
+
43
+ UPLOADING: Final = "uploading"
44
+ RESUME_NOTE: Final = "The job exists on the server. Resume it with this token: "
45
+ SENT_NOTE: Final = "The create request was sent, so the job may exist. Idempotency key: "
46
+ T = TypeVar("T")
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class Tracker:
51
+ """What an error raised after the create was sent has to carry (spec 7.3)."""
52
+
53
+ key: str
54
+ token: str | None = None
55
+ sent: bool = False
56
+ """True from the moment the create goes out: the server may have committed it."""
57
+ callers: list[BaseException] = field(default_factory=list)
58
+ """What the caller's own callbacks raised: those pass through unchanged (R63)."""
59
+ credentials: Credentials | None = None
60
+ """What an unexpected failure's cause is redacted with."""
61
+
62
+ def note(self) -> str:
63
+ if self.token is None:
64
+ return f"{SENT_NOTE}{self.key}"
65
+ return f"{RESUME_NOTE}{self.token} (idempotency key {self.key})"
66
+
67
+ def from_caller(self, failure: BaseException) -> bool:
68
+ return any(failure is raised for raised in self.callers)
69
+
70
+
71
+ def note_interrupt(tracker: Tracker, interrupted: BaseException) -> None:
72
+ """Spec 7.4 and R26: an interrupt is never converted (asyncio.timeout and task groups depend on
73
+ it); once the create is out, the way back to the job rides along as a note."""
74
+ if tracker.sent:
75
+ add_note(interrupted, tracker.note())
76
+
77
+
78
+ def annotate(tracker: Tracker, failure: Exception) -> HushscriptError:
79
+ """What an error raised after the create went out turns into. Called from the handler of
80
+ `failure`: what passes through unchanged is re-raised from here, and the error returned is for
81
+ the caller to raise once its handler has ended, so the unredacted original is not left on the
82
+ error's `__context__`."""
83
+ # R63 first: what the caller's own callbacks raised, an SDK error among it (a nested `buy()`
84
+ # failing with its own key), reaches the caller as it is.
85
+ if tracker.from_caller(failure):
86
+ raise failure
87
+ if isinstance(failure, HushscriptError):
88
+ failure.idempotency_key = tracker.key
89
+ if tracker.token is not None and failure.resume_token is None:
90
+ failure.resume_token = tracker.token
91
+ raise failure
92
+ # R61: once the create is out nothing but an SDK error leaves.
93
+ if not tracker.sent:
94
+ raise failure
95
+ failed = unexpected_error(failure, tracker.credentials, sent=True)
96
+ failed.idempotency_key = tracker.key
97
+ failed.resume_token = tracker.token
98
+ return failed
99
+
100
+
101
+ def annotated(tracker: Tracker, run: Callable[[], T]) -> T:
102
+ """`run()`, where every failure after the create went out is an SDK error that carries the key
103
+ and the token (spec 7.3)."""
104
+ try:
105
+ return run()
106
+ except (asyncio.CancelledError, KeyboardInterrupt) as interrupted:
107
+ note_interrupt(tracker, interrupted)
108
+ raise
109
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
110
+ failed = annotate(tracker, failure)
111
+ raise failed
112
+
113
+
114
+ def replay_body(state: ResumeState) -> Mapping[str, object]:
115
+ return {
116
+ "size_bytes": state.size,
117
+ "duration_seconds": state.duration_seconds,
118
+ "idempotency_key": state.idempotency_key,
119
+ }
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class Callbacks:
124
+ on_progress: Callable[[Progress], None] | None = None
125
+ on_resume_token: Callable[[str], None] | None = None
126
+ raised: list[BaseException] = field(default_factory=list)
127
+ """Whatever the callbacks raised, so the pipeline can tell it from a failure of its own."""
128
+
129
+ def progress(self, event: Progress) -> None:
130
+ if self.on_progress is not None:
131
+ try:
132
+ self.on_progress(event)
133
+ except Exception as failure:
134
+ self.raised.append(failure)
135
+ raise
136
+
137
+ def resume_token(self, token: str) -> None:
138
+ if self.on_resume_token is not None:
139
+ try:
140
+ self.on_resume_token(token)
141
+ except Exception as failure:
142
+ self.raised.append(failure)
143
+ raise
144
+
145
+
146
+ def percent(sent: int, total: int) -> int:
147
+ return min(100, sent * 100 // total) if total else 100
148
+
149
+
150
+ def create(session: SyncSession, body: Mapping[str, object]) -> Created:
151
+ """`POST /v1/uploads`; the transport paces it to the 4 per minute the API allows (spec 7.5)."""
152
+ response = session.http.post("/v1/uploads", json=body)
153
+ return parsed(response, session.credentials, parse_created)
154
+
155
+
156
+ def get_job(session: SyncSession, job_segment: str) -> Job:
157
+ return job_from(session.http.get(f"/v1/jobs/{job_segment}"), session.credentials)
158
+
159
+
160
+ def wait_job(
161
+ session: SyncSession,
162
+ job_segment: str,
163
+ wait_timeout: float,
164
+ callbacks: Callbacks,
165
+ token: str | None,
166
+ first: Job | None = None,
167
+ ) -> Job:
168
+ return wait_for_job(
169
+ lambda: get_job(session, job_segment),
170
+ session.clock,
171
+ wait_timeout,
172
+ callbacks.progress,
173
+ token,
174
+ first,
175
+ )
176
+
177
+
178
+ def fetch_transcript(session: SyncSession, job: Job) -> Transcript:
179
+ transcript_id = finish(job)
180
+ return transcript_of(
181
+ session.http.get(f"/v1/transcripts/{segment(transcript_id)}"), session.credentials
182
+ )
183
+
184
+
185
+ def _upload(
186
+ session: SyncSession,
187
+ created: Created,
188
+ source: Source,
189
+ concurrency: int,
190
+ callbacks: Callbacks,
191
+ ) -> None:
192
+ job_path = f"/v1/uploads/{segment(created.job_id)}"
193
+ uploader = SyncUploader(session, created.job_id, created.plan, source, concurrency)
194
+ heartbeat = Heartbeat(session.clock, session.log)
195
+
196
+ def ping() -> None:
197
+ raise_for_response(
198
+ session.http.post(f"{job_path}/heartbeat"), credentials=session.credentials
199
+ )
200
+
201
+ heartbeat.start(ping)
202
+ try:
203
+ total = source.size
204
+ uploader.upload(
205
+ lambda sent: callbacks.progress(UploadingProgress(percent(sent, total), sent, total)),
206
+ heartbeat,
207
+ )
208
+ heartbeat.raise_if_failed()
209
+ if not source.at_end():
210
+ raise stream_size_mismatch()
211
+ body = uploader.state.parts_body()
212
+ response = (
213
+ session.http.post(f"{job_path}/complete")
214
+ if body is None
215
+ else session.http.post(f"{job_path}/complete", json=body)
216
+ )
217
+ raise_for_response(response, credentials=session.credentials)
218
+ finally:
219
+ heartbeat.stop()
220
+
221
+
222
+ def _after_create( # the upload, wait and fetch that follow a create, for a new call and a resume
223
+ session: SyncSession,
224
+ created: Created,
225
+ tracker: Tracker,
226
+ state: ResumeState,
227
+ source: Source | None,
228
+ options: Options,
229
+ callbacks: Callbacks,
230
+ ) -> Transcript:
231
+ token = encode_token(
232
+ ResumeState(created.job_id, state.idempotency_key, state.size, state.duration_seconds)
233
+ )
234
+ tracker.token = token
235
+ callbacks.resume_token(token)
236
+ if created.state == UPLOADING:
237
+ if source is None:
238
+ raise file_required()
239
+ _upload(session, created, source, options.concurrency, callbacks)
240
+ job = wait_job(session, segment(created.job_id), options.wait_timeout, callbacks, token)
241
+ return fetch_transcript(session, job)
242
+
243
+
244
+ def run_transcribe(
245
+ session: SyncSession,
246
+ file: FileInput,
247
+ options: Options,
248
+ size: int | None,
249
+ callbacks: Callbacks,
250
+ ) -> Transcript:
251
+ options.validate()
252
+ source = make_source(file, size)
253
+ media: Media = check_media(source, options)
254
+ account = json_body(session.http.get("/v1/account"), session.credentials)
255
+ check_account(account, media)
256
+ key = options.new_key()
257
+ state = ResumeState("", key, media.size, media.duration_seconds)
258
+ body = create_body(media, options, key, title=options.title)
259
+ tracker = Tracker(key, callers=callbacks.raised, credentials=session.credentials)
260
+
261
+ def run() -> Transcript:
262
+ callbacks.progress(UploadingProgress(0, 0, state.size))
263
+ tracker.sent = True
264
+ created = create(session, body)
265
+ return _after_create(session, created, tracker, state, source, options, callbacks)
266
+
267
+ return annotated(tracker, run)
268
+
269
+
270
+ def read_job_to_resume(session: SyncSession, job_id: str) -> Job:
271
+ """The job a token names; a 404 means it was deleted or pruned (spec 7.3)."""
272
+ try:
273
+ return get_job(session, segment(job_id))
274
+ except NotFoundError as missing:
275
+ raise job_missing() from missing
276
+
277
+
278
+ def run_resume(
279
+ session: SyncSession,
280
+ token: str,
281
+ file: FileInput | None,
282
+ options: Options,
283
+ callbacks: Callbacks,
284
+ ) -> Transcript:
285
+ options.validate()
286
+ state = decode_token(token)
287
+ source = early_source(state, file)
288
+ tracker = Tracker(
289
+ state.idempotency_key,
290
+ token,
291
+ sent=True,
292
+ callers=callbacks.raised,
293
+ credentials=session.credentials,
294
+ )
295
+
296
+ def run() -> Transcript:
297
+ job = read_job_to_resume(session, state.job_id)
298
+ if job.state != UPLOADING:
299
+ return fetch_transcript(
300
+ session,
301
+ wait_job(
302
+ session, segment(state.job_id), options.wait_timeout, callbacks, token, job
303
+ ),
304
+ )
305
+ late = late_source(state, file, source)
306
+ callbacks.progress(UploadingProgress(0, 0, state.size))
307
+ created = create(session, replay_body(state))
308
+ if created.job_id != state.job_id:
309
+ raise job_missing()
310
+ return _after_create(session, created, tracker, state, late, options, callbacks)
311
+
312
+ return annotated(tracker, run)
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Final, cast
8
+
9
+ from hushscript._errors import (
10
+ DailyLimitError,
11
+ FileLimitError,
12
+ PreflightError,
13
+ UnsupportedMediaError,
14
+ describe_failure,
15
+ )
16
+ from hushscript._json import as_dict
17
+ from hushscript._shape import INT_LIMIT
18
+
19
+ from .probe import probe_duration
20
+ from .sniff import sniff
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Mapping
24
+
25
+ from .source import Source
26
+
27
+ MAX_BYTES: Final = 5_000_000_000
28
+ MIN_SECONDS: Final = 15
29
+ MAX_SECONDS: Final = 36_000
30
+ MAX_CONCURRENCY: Final = 8
31
+ MAX_KEYTERMS: Final = 1000
32
+ MAX_KEYTERM_WORDS: Final = 6
33
+ MAX_KEYTERM_CHARS: Final = 128
34
+ MAX_PROMPT_CHARS: Final = 2000
35
+ MAX_MATTER_CODE: Final = 64
36
+ MEDICAL_LANGUAGES: Final = frozenset({"en", "es", "fr", "de"})
37
+ KEY_MIN: Final = 16
38
+ KEY_MAX: Final = 128
39
+ _PRINTABLE_FIRST: Final = 0x21
40
+ _PRINTABLE_LAST: Final = 0x7E
41
+ # Unicode White_Space, the set of Rust's `char::is_whitespace`, which the API trims and splits by.
42
+ _WHITESPACE: Final = (
43
+ "\t\n\v\f\r \x85\xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008"
44
+ "\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
45
+ )
46
+ _WHITESPACE_RUN: Final = re.compile(f"[{_WHITESPACE}]+")
47
+ # The fixed text the API wraps around a prompt and key terms when both are set.
48
+ _COMPILED_FIXED_CHARS: Final = len(
49
+ "Transcription context:\n\nBehavior:\n"
50
+ "\n\nVocabulary:\n"
51
+ "\n\nInstructions:\n"
52
+ "- Prefer exact spellings when the audio supports them.\n"
53
+ "- Do not insert terms that are not audible.\n"
54
+ "- Preserve speaker turns and timestamps."
55
+ )
56
+
57
+
58
+ def invalid_option(message: str) -> PreflightError:
59
+ return PreflightError(message, code="invalid_option")
60
+
61
+
62
+ def _clean_keyterms(terms: tuple[str, ...]) -> list[tuple[int, str]]:
63
+ """Trimmed, non-blank, unique terms in order, each with its index in the given list."""
64
+ seen: set[str] = set()
65
+ kept: list[tuple[int, str]] = []
66
+ for index, term in enumerate(terms):
67
+ trimmed = term.strip(_WHITESPACE)
68
+ if trimmed and trimmed not in seen:
69
+ seen.add(trimmed)
70
+ kept.append((index, trimmed))
71
+ return kept
72
+
73
+
74
+ def _clean_prompt(prompt: str | None) -> str | None:
75
+ trimmed = None if prompt is None else prompt.strip(_WHITESPACE)
76
+ return trimmed or None
77
+
78
+
79
+ def _check_context(terms: list[tuple[int, str]], prompt: str | None) -> None:
80
+ if prompt is not None and len(prompt) > MAX_PROMPT_CHARS:
81
+ raise invalid_option(f"prompt is longer than {MAX_PROMPT_CHARS} characters.")
82
+ if len(terms) > MAX_KEYTERMS:
83
+ raise invalid_option(f"keyterms has more than {MAX_KEYTERMS} unique terms.")
84
+ for index, term in terms:
85
+ if len(term) > MAX_KEYTERM_CHARS:
86
+ raise invalid_option(
87
+ f"keyterms[{index}] is longer than {MAX_KEYTERM_CHARS} characters."
88
+ )
89
+ if len(_WHITESPACE_RUN.split(term)) > MAX_KEYTERM_WORDS:
90
+ raise invalid_option(f"keyterms[{index}] has more than {MAX_KEYTERM_WORDS} words.")
91
+ if prompt is not None and terms:
92
+ compiled = (
93
+ _COMPILED_FIXED_CHARS
94
+ + len(prompt)
95
+ + sum(len(term) + 2 for _, term in terms)
96
+ + len(terms)
97
+ - 1
98
+ )
99
+ if compiled > MAX_PROMPT_CHARS:
100
+ raise invalid_option(
101
+ f"prompt and keyterms together exceed {MAX_PROMPT_CHARS} characters ({compiled})."
102
+ )
103
+
104
+
105
+ def _is_number(value: object) -> bool:
106
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
107
+ return False
108
+ return isinstance(value, int) or math.isfinite(value)
109
+
110
+
111
+ def check_wait_timeout(wait_timeout: object) -> None:
112
+ if not _is_number(wait_timeout) or cast("float", wait_timeout) <= 0:
113
+ raise invalid_option("wait_timeout must be a positive number of seconds.")
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class Options:
118
+ """The options of `transcribe()` (spec 6.1), checked before any request."""
119
+
120
+ title: str | None = None
121
+ language: str | None = None
122
+ speakers: bool = True
123
+ translate_to: tuple[str, ...] = ()
124
+ keyterms: tuple[str, ...] = ()
125
+ prompt: str | None = None
126
+ medical: bool = False
127
+ multichannel: bool = False
128
+ auto_insights: bool | None = None
129
+ matter_code: str | None = None
130
+ duration_seconds: float | None = None
131
+ idempotency_key: str | None = None
132
+ concurrency: int = 4
133
+ wait_timeout: float = 3600
134
+
135
+ def validate(self) -> Options:
136
+ concurrency = cast("object", self.concurrency)
137
+ if (
138
+ not isinstance(concurrency, int)
139
+ or isinstance(concurrency, bool)
140
+ or not 1 <= concurrency <= MAX_CONCURRENCY
141
+ ):
142
+ raise invalid_option(f"concurrency must be a whole number from 1 to {MAX_CONCURRENCY}.")
143
+ check_wait_timeout(self.wait_timeout)
144
+ if self.duration_seconds is not None and (
145
+ not _is_number(self.duration_seconds) or self.duration_seconds <= 0
146
+ ):
147
+ raise invalid_option("duration_seconds must be a positive number of seconds.")
148
+ key = cast("object", self.idempotency_key)
149
+ if key is not None and not (
150
+ isinstance(key, str)
151
+ and KEY_MIN <= len(key) <= KEY_MAX
152
+ and all(_PRINTABLE_FIRST <= ord(char) <= _PRINTABLE_LAST for char in key)
153
+ ):
154
+ raise invalid_option(
155
+ f"idempotency_key must be {KEY_MIN} to {KEY_MAX} visible ASCII characters,"
156
+ " no spaces."
157
+ )
158
+ if self.medical and self.language not in MEDICAL_LANGUAGES:
159
+ raise invalid_option("medical needs language to be one of en, es, fr, de.")
160
+ _check_context(_clean_keyterms(self.keyterms), _clean_prompt(self.prompt))
161
+ if self.matter_code is not None and len(self.matter_code) > MAX_MATTER_CODE:
162
+ raise invalid_option(f"matter_code must be at most {MAX_MATTER_CODE} characters.")
163
+ return self
164
+
165
+ def new_key(self) -> str:
166
+ return self.idempotency_key or str(uuid.uuid4())
167
+
168
+ def transcription_options(self) -> dict[str, object]:
169
+ wire: dict[str, object] = {}
170
+ if self.language is not None:
171
+ wire["language_code"] = self.language
172
+ if not self.speakers and not self.translate_to:
173
+ wire["speaker_detection"] = False
174
+ if self.translate_to:
175
+ wire["translation_targets"] = list(self.translate_to)
176
+ terms = [term for _, term in _clean_keyterms(self.keyterms)]
177
+ if terms:
178
+ wire["keyterms_prompt"] = terms
179
+ prompt = _clean_prompt(self.prompt)
180
+ if prompt is not None:
181
+ wire["prompt"] = prompt
182
+ if self.medical:
183
+ wire["medical_mode"] = True
184
+ if self.multichannel:
185
+ wire["multichannel"] = True
186
+ if self.auto_insights is not None:
187
+ wire["auto_generate_insights"] = self.auto_insights
188
+ return wire
189
+
190
+
191
+ @dataclass(frozen=True, slots=True)
192
+ class Media:
193
+ size: int
194
+ duration_seconds: int
195
+ """The declared duration: `ceil` of the probe, so the hold never falls short of the file."""
196
+
197
+
198
+ def check_media(source: Source, options: Options) -> Media:
199
+ """Spec 8.1 steps 1 to 3: format by magic bytes, size, duration."""
200
+ if source.sequential and options.duration_seconds is None:
201
+ raise invalid_option("A stream that cannot seek needs duration_seconds and size.")
202
+ try:
203
+ head = source.head()
204
+ except OSError as failure:
205
+ raise PreflightError(
206
+ f"The file cannot be read ({describe_failure(failure)}).", code="file_unreadable"
207
+ ) from failure
208
+ if sniff(head) is None:
209
+ raise UnsupportedMediaError("The file is not a supported audio or video format.")
210
+ if not 1 <= source.size <= MAX_BYTES:
211
+ raise FileLimitError(
212
+ f"The file is {source.size} bytes; it must be between 1 byte and 5 GB.",
213
+ code="size_out_of_range",
214
+ )
215
+ seconds = options.duration_seconds
216
+ if seconds is None:
217
+ try:
218
+ seconds = probe_duration(source)
219
+ except OSError as failure:
220
+ raise PreflightError(
221
+ f"The file cannot be read ({describe_failure(failure)}).", code="file_unreadable"
222
+ ) from failure
223
+ declared = math.ceil(seconds)
224
+ if seconds < MIN_SECONDS or declared > MAX_SECONDS:
225
+ raise FileLimitError(
226
+ f"The file is {seconds:g} seconds long; it must be between 15 seconds and 10 hours.",
227
+ code="duration_out_of_range",
228
+ )
229
+ return Media(size=source.size, duration_seconds=declared)
230
+
231
+
232
+ def _whole(value: object) -> int | None:
233
+ return (
234
+ value
235
+ if isinstance(value, int) and not isinstance(value, bool) and abs(value) < INT_LIMIT
236
+ else None
237
+ )
238
+
239
+
240
+ def check_account(account: object, media: Media) -> None:
241
+ """Spec 8.1 step 5, from one `GET /v1/account`: the daily limit only.
242
+
243
+ The balance is the server's to judge at create (R77).
244
+ """
245
+ fields = as_dict(account) or {}
246
+ limits = as_dict(fields.get("limits")) or {}
247
+ daily = _whole(limits.get("max_daily_seconds"))
248
+ used = _whole(limits.get("used_today_seconds"))
249
+ if daily is not None and used is not None and used + media.duration_seconds > daily:
250
+ raise DailyLimitError(
251
+ "This upload would pass the daily transcription limit.",
252
+ code="daily_limit",
253
+ details={"used_today_seconds": used, "max_daily_seconds": daily},
254
+ )
255
+
256
+
257
+ def create_body(
258
+ media: Media, options: Options, key: str, *, title: str | None
259
+ ) -> Mapping[str, object]:
260
+ body: dict[str, object] = {
261
+ "size_bytes": media.size,
262
+ "duration_seconds": media.duration_seconds,
263
+ "idempotency_key": key,
264
+ }
265
+ if title is not None:
266
+ body["title"] = title
267
+ if options.matter_code is not None:
268
+ body["matter_code"] = options.matter_code
269
+ wire = options.transcription_options()
270
+ if wire:
271
+ body["transcription_options"] = wire
272
+ return body