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,498 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import math
5
+ import threading
6
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Final
9
+
10
+ import httpx
11
+
12
+ from hushscript._config import PART_TIMEOUT
13
+ from hushscript._errors import NetworkError, UploadError, describe_failure, raise_for_response
14
+ from hushscript._http import CLASS_EXTENSION, LABEL_EXTENSION, STREAM_EXTENSION, release
15
+ from hushscript._json import as_dict, as_list
16
+ from hushscript._log import hiding_signed_url
17
+ from hushscript._redact import Secret
18
+ from hushscript._response import BadBodyError, parsed
19
+ from hushscript._retry import RetryClass
20
+
21
+ from .segment import segment, usable_segment
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+
26
+ from hushscript._clock import Clock
27
+ from hushscript._redact import Credentials
28
+
29
+ from .heartbeat import Heartbeat
30
+ from .session import SyncSession
31
+ from .source import Source
32
+
33
+ MAX_PART_BYTES: Final = 32 * 1024 * 1024
34
+ URL_MAX_AGE: Final = 23 * 3600.0
35
+ PARTS_EXHAUSTED: Final = "parts_exhausted"
36
+ HTTP_FORBIDDEN: Final = 403
37
+ HTTP_NOT_FOUND: Final = 404
38
+ OCTET_STREAM: Final = {"Content-Type": "application/octet-stream"}
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Plan:
43
+ """The direct-to-R2 plan of a create response (spec 8.3)."""
44
+
45
+ part_size: int
46
+ total_parts: int
47
+ urls: dict[int, Secret]
48
+ """Signed URLs, held where no repr, copy or pickle shows them."""
49
+ next_part: int | None
50
+ direct: bool = True
51
+ """False when `part_urls` was `null`: direct upload is not available (R70) and every part goes
52
+ through the API, whatever `next_part` says."""
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Created:
57
+ job_id: str
58
+ state: str
59
+ plan: Plan | None
60
+ """None in proxied mode (`upload: null`), which a job that has left `uploading` also shows;
61
+ `state` tells them apart."""
62
+
63
+
64
+ def _whole(value: object) -> int | None:
65
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
66
+
67
+
68
+ def _unexpected(what: str) -> BadBodyError:
69
+ return BadBodyError(what)
70
+
71
+
72
+ def _urls(raw: object) -> dict[int, Secret]:
73
+ urls: dict[int, Secret] = {}
74
+ for item in as_list(raw) or []:
75
+ entry = as_dict(item) or {}
76
+ n = _whole(entry.get("n"))
77
+ url = entry.get("url")
78
+ if n is None or n < 1 or not isinstance(url, str):
79
+ raise _unexpected("part URL list")
80
+ urls[n] = Secret(url)
81
+ return urls
82
+
83
+
84
+ def parse_plan(raw: object) -> Plan:
85
+ fields = as_dict(raw)
86
+ if fields is None:
87
+ raise _unexpected("upload plan")
88
+ no_direct = "part_urls" in fields and fields["part_urls"] is None
89
+ part_size = _whole(fields.get("part_size_bytes"))
90
+ total = _whole(fields.get("total_parts"))
91
+ next_part = _whole(fields.get("next_part_number"))
92
+ if (
93
+ part_size is None
94
+ or total is None
95
+ or not 1 <= part_size <= MAX_PART_BYTES
96
+ or total < 1
97
+ or (next_part is not None and next_part < 1)
98
+ ):
99
+ raise _unexpected("upload plan")
100
+ return Plan(
101
+ part_size=part_size,
102
+ total_parts=total,
103
+ urls=_urls(fields.get("part_urls")),
104
+ next_part=next_part,
105
+ direct=not no_direct,
106
+ )
107
+
108
+
109
+ NULL_PAGE: Final = Plan(part_size=1, total_parts=1, urls={}, next_part=None, direct=False)
110
+ """What a `part-urls` page that answers HTTP 404 stands for: a `null` page (spec 8.3)."""
111
+
112
+
113
+ def parse_page(response: httpx.Response, credentials: Credentials) -> Plan:
114
+ """The plan of a `part-urls` page. HTTP 404 is read as a null page, never retried or raised."""
115
+ if response.status_code == HTTP_NOT_FOUND:
116
+ return NULL_PAGE
117
+ return parsed(response, credentials, parse_plan)
118
+
119
+
120
+ def bad_part_plan() -> UploadError:
121
+ return UploadError(
122
+ "The API's upload plan does not describe the file's parts.", code="bad_part_plan"
123
+ )
124
+
125
+
126
+ def parse_created(body: object) -> Created:
127
+ fields = as_dict(body)
128
+ job_id = None if fields is None else fields.get("job_id")
129
+ if fields is None or not isinstance(job_id, str) or not usable_segment(job_id):
130
+ raise _unexpected("create response")
131
+ upload = fields.get("upload")
132
+ state = fields.get("state")
133
+ return Created(
134
+ job_id=job_id,
135
+ state=state if isinstance(state, str) else "uploading",
136
+ plan=None if upload is None else parse_plan(upload),
137
+ )
138
+
139
+
140
+ def plan_for(plan: Plan | None, size: int) -> Plan:
141
+ """The plan the API gave, checked against the file; the 32 MiB cut when there is none."""
142
+ if plan is None:
143
+ return proxied_plan(size)
144
+ if plan.total_parts != math.ceil(size / plan.part_size):
145
+ raise bad_part_plan()
146
+ return plan
147
+
148
+
149
+ def proxied_plan(size: int) -> Plan:
150
+ return Plan(
151
+ part_size=MAX_PART_BYTES,
152
+ total_parts=max(1, math.ceil(size / MAX_PART_BYTES)),
153
+ urls={},
154
+ next_part=None,
155
+ )
156
+
157
+
158
+ class UrlBook:
159
+ """The presigned URLs fetched so far. A URL older than 23 h counts as missing (spec 8.4)."""
160
+
161
+ def __init__(self, clock: Clock) -> None:
162
+ self._clock = clock
163
+ self._urls: dict[int, tuple[Secret, float]] = {}
164
+ self.next_start: int | None = None
165
+
166
+ def store(self, urls: dict[int, Secret], next_part: int | None) -> None:
167
+ now = self._clock.now()
168
+ for n, url in urls.items():
169
+ self._urls[n] = (url, now)
170
+ self.next_start = next_part
171
+
172
+ def is_current(self, n: int, url: Secret) -> bool:
173
+ """Whether `url` is still the one stored for part `n`."""
174
+ entry = self._urls.get(n)
175
+ return entry is not None and entry[0].reveal() == url.reveal()
176
+
177
+ def get(self, n: int) -> Secret | None:
178
+ entry = self._urls.get(n)
179
+ if entry is None or self._clock.now() - entry[1] > URL_MAX_AGE:
180
+ return None
181
+ return entry[0]
182
+
183
+
184
+ class Action(enum.Enum):
185
+ REFRESH = "refresh"
186
+ FALLBACK = "fallback"
187
+ EXHAUSTED = "exhausted"
188
+
189
+
190
+ @dataclass(slots=True)
191
+ class PartAttempts:
192
+ expired_refreshes: int = 0
193
+ rounds: int = 0
194
+
195
+
196
+ class Mode(enum.Enum):
197
+ UNDECIDED = "undecided"
198
+ DIRECT = "direct"
199
+ PROXIED = "proxied"
200
+
201
+
202
+ @dataclass(slots=True)
203
+ class UploadState:
204
+ """Which way the parts go and what has been acknowledged, shared by every part's worker.
205
+
206
+ The way is one state under one lock: undecided, then direct (the first direct success) or
207
+ proxied (the first part that gives up on direct), and never both. A direct success that
208
+ arrives after the switch loses and its part is sent through the API again.
209
+ """
210
+
211
+ mode: Mode
212
+ etags: dict[int, str] = field(default_factory=dict)
213
+ _lock: threading.Lock = field(default_factory=threading.Lock)
214
+
215
+ @property
216
+ def proxied(self) -> bool:
217
+ return self.mode is Mode.PROXIED
218
+
219
+ def record(self, n: int, etag: str) -> bool:
220
+ """Notes a direct success; False when the upload went through the API in the meantime."""
221
+ with self._lock:
222
+ if self.mode is Mode.PROXIED:
223
+ return False
224
+ self.mode = Mode.DIRECT
225
+ self.etags[n] = etag
226
+ return True
227
+
228
+ def fall_back(self) -> bool:
229
+ """A part-urls page said direct upload is not available. True when the rest goes through
230
+ the API now; False once a direct PUT has succeeded, which keeps the direct retries (spec
231
+ 8.4, Fallback rule)."""
232
+ with self._lock:
233
+ if self.mode is Mode.DIRECT:
234
+ return False
235
+ self.mode = Mode.PROXIED
236
+ return True
237
+
238
+ def decide(self, attempts: PartAttempts, status: int | None) -> Action:
239
+ """What a part does after its direct PUT failed even with the transport's retries.
240
+
241
+ An R2 403 means an expired URL: refresh it, once. Otherwise, while no direct PUT has
242
+ succeeded, every remaining part goes through the API (the app's rule). After a success
243
+ the part keeps trying direct: its retries, then a re-fetched URL and the retries again
244
+ (spec 8.4), then `parts_exhausted`.
245
+ """
246
+ with self._lock:
247
+ if self.mode is Mode.PROXIED:
248
+ return Action.FALLBACK
249
+ if status == HTTP_FORBIDDEN and attempts.expired_refreshes < 1:
250
+ attempts.expired_refreshes += 1
251
+ return Action.REFRESH
252
+ if self.mode is Mode.UNDECIDED and attempts.rounds == 0:
253
+ self.mode = Mode.PROXIED
254
+ return Action.FALLBACK
255
+ attempts.rounds += 1
256
+ return Action.REFRESH if attempts.rounds == 1 else Action.EXHAUSTED
257
+
258
+ def parts_body(self) -> dict[str, object] | None:
259
+ """The body of `complete`: the sorted parts for a direct upload, none for a proxied one."""
260
+ if self.mode is Mode.PROXIED:
261
+ return None
262
+ return {"parts": [{"n": n, "etag": self.etags[n]} for n in sorted(self.etags)]}
263
+
264
+
265
+ def first_mode(plan: Plan | None) -> Mode:
266
+ return Mode.UNDECIDED if plan is not None and plan.direct else Mode.PROXIED
267
+
268
+
269
+ def exhausted(n: int) -> UploadError:
270
+ return UploadError(
271
+ f"Part {n} could not be uploaded after its retries and a fresh URL.", code=PARTS_EXHAUSTED
272
+ )
273
+
274
+
275
+ def stream_size_mismatch() -> UploadError:
276
+ return UploadError(
277
+ "The stream did not hold the number of bytes that was declared.",
278
+ code="stream_size_mismatch",
279
+ )
280
+
281
+
282
+ def part_url_missing(n: int) -> UploadError:
283
+ return UploadError(f"The API gave no upload URL for part {n}.", code="part_url_missing")
284
+
285
+
286
+ def etag_missing(n: int) -> UploadError:
287
+ return UploadError(f"Storage accepted part {n} but sent no ETag.", code="etag_missing")
288
+
289
+
290
+ def source_read_failed(n: int, failure: BaseException) -> UploadError:
291
+ return UploadError(
292
+ f"Part {n} could not be read from the file ({describe_failure(failure)}).",
293
+ code="source_read_failed",
294
+ )
295
+
296
+
297
+ def read_part(source: Source, plan: Plan, n: int) -> bytes:
298
+ """The bytes of part `n`; a read that fails or comes up short is an upload error."""
299
+ offset, length = part_range(plan, source.size, n)
300
+ try:
301
+ data = source.read_range(offset, length)
302
+ except (OSError, ValueError) as failure:
303
+ raise source_read_failed(n, failure) from failure
304
+ if len(data) != length:
305
+ raise short_read(n, source)
306
+ return data
307
+
308
+
309
+ def short_read(n: int, source: Source) -> UploadError:
310
+ if source.is_stream:
311
+ return stream_size_mismatch()
312
+ return UploadError(f"The file ended before part {n} was complete.", code="upload_incomplete")
313
+
314
+
315
+ def part_range(plan: Plan, size: int, n: int) -> tuple[int, int]:
316
+ offset = (n - 1) * plan.part_size
317
+ return offset, max(0, min(plan.part_size, size - offset))
318
+
319
+
320
+ def etag_of(response: httpx.Response) -> str | None:
321
+ if not response.is_success:
322
+ return None
323
+ etag = response.headers.get("etag")
324
+ return etag.strip('"') if etag else None
325
+
326
+
327
+ def r2_extensions(n: int) -> dict[str, object]:
328
+ return {
329
+ CLASS_EXTENSION: RetryClass.PART,
330
+ LABEL_EXTENSION: f"r2:part/{n}",
331
+ STREAM_EXTENSION: True,
332
+ }
333
+
334
+
335
+ PART_TIMEOUTS: Final = httpx.Timeout(PART_TIMEOUT)
336
+
337
+
338
+ class SyncUploader:
339
+ """Uploads the parts of one job on a thread pool: direct to R2, falling back to the API."""
340
+
341
+ def __init__(
342
+ self,
343
+ session: SyncSession,
344
+ job_id: str,
345
+ plan: Plan | None,
346
+ source: Source,
347
+ concurrency: int,
348
+ ) -> None:
349
+ self._session = session
350
+ self._job_id = job_id
351
+ self._source = source
352
+ self.plan = plan_for(plan, source.size)
353
+ self.state = UploadState(first_mode(plan))
354
+ self._book = UrlBook(session.clock)
355
+ self._book.store(self.plan.urls, self.plan.next_part)
356
+ self._pages = threading.Lock()
357
+ self._width = 1 if source.sequential else concurrency
358
+
359
+ def upload(self, on_bytes: Callable[[int], None], heartbeat: Heartbeat) -> None:
360
+ total = self.plan.total_parts
361
+ sent = 0
362
+ numbers = iter(range(1, total + 1))
363
+ pending: dict[Future[int], int] = {}
364
+ with ThreadPoolExecutor(max_workers=self._width) as pool:
365
+ try:
366
+ while True:
367
+ while len(pending) < self._width and (n := next(numbers, None)) is not None:
368
+ pending[pool.submit(self._upload_part, n)] = n
369
+ if not pending:
370
+ break
371
+ done, _ = wait(list(pending), return_when=FIRST_COMPLETED)
372
+ for future in done:
373
+ del pending[future]
374
+ sent += future.result()
375
+ on_bytes(sent)
376
+ heartbeat.raise_if_failed()
377
+ except BaseException:
378
+ for future in pending:
379
+ future.cancel()
380
+ raise
381
+
382
+ def _read(self, n: int) -> bytes:
383
+ return read_part(self._source, self.plan, n)
384
+
385
+ def _upload_part(self, n: int) -> int:
386
+ data = self._read(n)
387
+ attempts = PartAttempts()
388
+ while True:
389
+ if self.state.proxied:
390
+ self._put_proxied(n, data)
391
+ return len(data)
392
+ url = self._url_for(n)
393
+ if url is None:
394
+ continue
395
+ status, etag = self._put_direct(n, url, data)
396
+ if etag is not None:
397
+ if not self.state.record(n, etag):
398
+ self._put_proxied(n, data)
399
+ return len(data)
400
+ action = self.state.decide(attempts, status)
401
+ if action is Action.EXHAUSTED:
402
+ raise exhausted(n)
403
+ if action is Action.REFRESH:
404
+ self._refresh(n, url)
405
+
406
+ def _put_direct(self, n: int, url: Secret, data: bytes) -> tuple[int | None, str | None]:
407
+ client = self._session.r2
408
+ try:
409
+ with hiding_signed_url(f"r2:part/{n}"):
410
+ response = client.send(
411
+ client.build_request(
412
+ "PUT",
413
+ url.reveal(),
414
+ content=data,
415
+ timeout=PART_TIMEOUTS,
416
+ extensions=r2_extensions(n),
417
+ ),
418
+ stream=True,
419
+ )
420
+ try:
421
+ etag = etag_of(response)
422
+ if response.is_success and etag is None:
423
+ raise etag_missing(n)
424
+ return response.status_code, etag
425
+ finally:
426
+ try:
427
+ response.close()
428
+ finally:
429
+ release(response.request)
430
+ except NetworkError:
431
+ return None, None
432
+
433
+ def _put_proxied(self, n: int, data: bytes) -> None:
434
+ client = self._session.http
435
+ response = client.send(
436
+ client.build_request(
437
+ "PUT",
438
+ f"/v1/uploads/{segment(self._job_id)}/parts/{n}",
439
+ content=data,
440
+ headers=OCTET_STREAM,
441
+ timeout=PART_TIMEOUTS,
442
+ extensions={STREAM_EXTENSION: True},
443
+ ),
444
+ stream=True,
445
+ )
446
+ try:
447
+ if not response.is_success:
448
+ response.read()
449
+ raise_for_response(response, credentials=self._session.credentials)
450
+ finally:
451
+ try:
452
+ response.close()
453
+ finally:
454
+ release(response.request)
455
+
456
+ def _url_for(self, n: int) -> Secret | None:
457
+ """The URL of part `n`; None when a page said the rest goes through the API."""
458
+ with self._pages:
459
+ url = self._book.get(n)
460
+ if url is None:
461
+ self._fetch_for(n)
462
+ if self.state.proxied:
463
+ return None
464
+ url = self._book.get(n)
465
+ if url is None:
466
+ raise part_url_missing(n)
467
+ return url
468
+
469
+ def _fetch_for(self, n: int) -> None:
470
+ start = self._book.next_start
471
+ if start is None or n < start:
472
+ self._fetch(n, advance=False)
473
+ return
474
+ while (
475
+ self._book.get(n) is None
476
+ and self._book.next_start is not None
477
+ and not self.state.proxied
478
+ ):
479
+ self._fetch(self._book.next_start, advance=True)
480
+
481
+ def _refresh(self, n: int, failed_url: Secret) -> None:
482
+ with self._pages:
483
+ if self._book.is_current(n, failed_url):
484
+ self._fetch(n, advance=False)
485
+
486
+ def _fetch(self, start: int, *, advance: bool) -> None:
487
+ """One `part-urls` page. The pages after the first come in order (`advance`); a refresh
488
+ of an earlier page leaves the cursor of the pages still to come where it was."""
489
+ response = self._session.http.get(
490
+ f"/v1/uploads/{segment(self._job_id)}/part-urls", params={"start": start}
491
+ )
492
+ plan = parse_page(response, self._session.credentials)
493
+ if not plan.direct and self.state.fall_back():
494
+ return
495
+ cursor = plan.next_part if advance else self._book.next_start
496
+ if advance and cursor is not None and cursor <= start:
497
+ cursor = None
498
+ self._book.store(plan.urls, cursor)
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Final
4
+
5
+ from hushscript._errors import JobFailedError, UploadError, WaitTimeoutError
6
+ from hushscript._models import Job, ProcessingProgress
7
+ from hushscript._response import BadBodyError, parsed
8
+ from hushscript.transcript import Transcript
9
+
10
+ from .segment import usable_segment
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ import httpx
16
+
17
+ from hushscript._clock import Clock
18
+ from hushscript._models import Progress
19
+ from hushscript._redact import Credentials
20
+
21
+ FIRST_POLL_DELAY: Final = 5.0
22
+ POLL_GROWTH: Final = 1.5
23
+ MAX_POLL_DELAY: Final = 15.0
24
+ DEFAULT_WAIT_TIMEOUT: Final = 3600.0
25
+ TERMINAL: Final = frozenset({"done", "failed"})
26
+
27
+
28
+ def next_delay(delay: float) -> float:
29
+ """Spec 8.6: 5 s, times 1.5 each poll, to a 15 s ceiling (the app's cadence)."""
30
+ return min(delay * POLL_GROWTH, MAX_POLL_DELAY)
31
+
32
+
33
+ def job_failed(job: Job) -> JobFailedError:
34
+ return JobFailedError(
35
+ "The transcription failed.",
36
+ job_id=job.id,
37
+ error=job.error,
38
+ failure_kind=job.failure_kind,
39
+ )
40
+
41
+
42
+ def timed_out(wait_timeout: float, token: str | None) -> WaitTimeoutError:
43
+ return WaitTimeoutError(
44
+ f"The job was still running after {wait_timeout:g} s. It keeps running on the server.",
45
+ resume_token=token,
46
+ )
47
+
48
+
49
+ def missing_transcript(job: Job) -> UploadError:
50
+ return UploadError(f"Job {job.id} is done but names no transcript.", code="transcript_missing")
51
+
52
+
53
+ def wait_for_job( # the poll loop, with every dependency passed in
54
+ get_job: Callable[[], Job],
55
+ clock: Clock,
56
+ wait_timeout: float,
57
+ on_progress: Callable[[Progress], None] | None = None,
58
+ token: str | None = None,
59
+ first: Job | None = None,
60
+ ) -> Job:
61
+ """Polls until `done` or `failed` and returns the job, or raises WaitTimeoutError. `first` is
62
+ a job read a moment ago, which stands in for the first poll."""
63
+ started = clock.now()
64
+ delay = FIRST_POLL_DELAY
65
+ while True:
66
+ job = first or get_job()
67
+ first = None
68
+ if job.state in TERMINAL:
69
+ return job
70
+ if on_progress is not None:
71
+ on_progress(ProcessingProgress(state=job.state))
72
+ remaining = started + wait_timeout - clock.now()
73
+ if remaining <= 0:
74
+ raise timed_out(wait_timeout, token)
75
+ clock.sleep(min(delay, remaining))
76
+ delay = next_delay(delay)
77
+
78
+
79
+ async def await_job( # the same loop on the event loop
80
+ get_job: Callable[[], Awaitable[Job]],
81
+ clock: Clock,
82
+ wait_timeout: float,
83
+ on_progress: Callable[[Progress], None] | None = None,
84
+ token: str | None = None,
85
+ first: Job | None = None,
86
+ ) -> Job:
87
+ started = clock.now()
88
+ delay = FIRST_POLL_DELAY
89
+ while True:
90
+ job = first or await get_job()
91
+ first = None
92
+ if job.state in TERMINAL:
93
+ return job
94
+ if on_progress is not None:
95
+ on_progress(ProcessingProgress(state=job.state))
96
+ remaining = started + wait_timeout - clock.now()
97
+ if remaining <= 0:
98
+ raise timed_out(wait_timeout, token)
99
+ await clock.asleep(min(delay, remaining))
100
+ delay = next_delay(delay)
101
+
102
+
103
+ def _usable_job(body: object) -> Job:
104
+ job = Job.from_body(body)
105
+ if job.transcript_id is not None and not usable_segment(job.transcript_id):
106
+ raise BadBodyError("job")
107
+ return job
108
+
109
+
110
+ def job_from(response: httpx.Response, credentials: Credentials) -> Job:
111
+ return parsed(response, credentials, _usable_job)
112
+
113
+
114
+ def finish(job: Job) -> str:
115
+ """The transcript id of a finished job; a failed job raises JobFailedError."""
116
+ if job.state == "failed":
117
+ raise job_failed(job)
118
+ if job.transcript_id is None:
119
+ raise missing_transcript(job)
120
+ return job.transcript_id
121
+
122
+
123
+ def transcript_of(response: httpx.Response, credentials: Credentials) -> Transcript:
124
+ return parsed(response, credentials, Transcript.from_api)