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,209 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from hushscript._json import ExtraFields, as_dict, as_list, extra_fields
7
+ from hushscript._response import parsed
8
+ from hushscript._shape import (
9
+ as_text,
10
+ as_whole,
11
+ check_shape,
12
+ maybe_text,
13
+ to_datetime,
14
+ )
15
+ from hushscript._shapes import DELETED_LIST, TRANSCRIPT_LIST
16
+ from hushscript.paginate import Page, apaginate, check_cursor_advanced, paginate
17
+ from hushscript.resources._params import check_page_size, list_params, path_id
18
+ from hushscript.transcript import Transcript
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import AsyncIterator, Iterator
22
+ from datetime import datetime
23
+
24
+ from hushscript.transcribe.session import AsyncSession, SyncSession
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class TranscriptSummary(ExtraFields):
29
+ """One row of the transcript list."""
30
+
31
+ id: str
32
+ title: str
33
+ language: str | None
34
+ duration_seconds: int
35
+ created_at: datetime
36
+ auto_delete_at: datetime | None
37
+ tags: list[str]
38
+
39
+ __hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class DeletedTranscript(ExtraFields):
44
+ """The record kept after a transcript was removed; its content is gone."""
45
+
46
+ id: str
47
+ title: str
48
+ duration_seconds: int
49
+ created_at: datetime
50
+ """When the transcript was uploaded."""
51
+ deleted_at: datetime
52
+ cause: str
53
+ """`manual`, `scheduled`, `dormant` or `expired`; a cause the SDK does not know yet passes
54
+ through as the API's own string."""
55
+ receipt: dict[str, object] | None
56
+ """The stored deletion receipt, opaque and as the API sends it (snake_case keys, string
57
+ timestamps); None when it could not be read back."""
58
+
59
+ __hash__ = None # type: ignore[assignment] # holds a dict, so it is unhashable by design
60
+
61
+
62
+ def summary_page(body: object) -> Page[TranscriptSummary]:
63
+ check_shape(body, TRANSCRIPT_LIST, "transcript list response")
64
+ fields = as_dict(body) or {}
65
+ items: list[TranscriptSummary] = []
66
+ for row in as_list(fields["transcripts"]) or []:
67
+ item = as_dict(row) or {}
68
+ auto_delete = item["auto_delete_at"]
69
+ items.append(
70
+ TranscriptSummary(
71
+ _extra=extra_fields(
72
+ item,
73
+ "id",
74
+ "title",
75
+ "language_detected",
76
+ "duration_seconds",
77
+ "created_at",
78
+ "auto_delete_at",
79
+ "tags",
80
+ ),
81
+ id=as_text(item["id"]),
82
+ title=as_text(item["title"]),
83
+ language=maybe_text(item["language_detected"]),
84
+ duration_seconds=as_whole(item["duration_seconds"]),
85
+ created_at=to_datetime(item["created_at"]),
86
+ auto_delete_at=None if auto_delete is None else to_datetime(auto_delete),
87
+ tags=[as_text(tag) for tag in as_list(item["tags"]) or []],
88
+ )
89
+ )
90
+ return Page(
91
+ _extra=extra_fields(fields, "transcripts", "next_cursor"),
92
+ items=items,
93
+ next_cursor=maybe_text(fields["next_cursor"]),
94
+ )
95
+
96
+
97
+ def deleted_page(body: object) -> Page[DeletedTranscript]:
98
+ check_shape(body, DELETED_LIST, "deleted transcript list response")
99
+ fields = as_dict(body) or {}
100
+ items: list[DeletedTranscript] = []
101
+ for row in as_list(fields["deleted"]) or []:
102
+ item = as_dict(row) or {}
103
+ items.append(
104
+ DeletedTranscript(
105
+ _extra=extra_fields(
106
+ item,
107
+ "id",
108
+ "title",
109
+ "duration_seconds",
110
+ "created_at",
111
+ "deleted_at",
112
+ "cause",
113
+ "receipt",
114
+ ),
115
+ id=as_text(item["id"]),
116
+ title=as_text(item["title"]),
117
+ duration_seconds=as_whole(item["duration_seconds"]),
118
+ created_at=to_datetime(item["created_at"]),
119
+ deleted_at=to_datetime(item["deleted_at"]),
120
+ cause=as_text(item["cause"]),
121
+ receipt=as_dict(item["receipt"]),
122
+ )
123
+ )
124
+ return Page(
125
+ _extra=extra_fields(fields, "deleted", "next_cursor"),
126
+ items=items,
127
+ next_cursor=maybe_text(fields["next_cursor"]),
128
+ )
129
+
130
+
131
+ class Transcripts:
132
+ """`hs.transcripts`: the account's finished transcripts. Ones still processing are jobs: see
133
+ `hs.jobs.list(active=True)`."""
134
+
135
+ def __init__(self, session: SyncSession) -> None:
136
+ self._session = session
137
+
138
+ def list(self, *, page_size: int | None = None) -> Iterator[TranscriptSummary]:
139
+ """Every finished transcript, newest first, fetching pages as it goes. `page_size` is 1 to
140
+ 100 (the API clamps it there) and defaults to 50."""
141
+ check_page_size(page_size)
142
+ return paginate(lambda cursor: self._page(cursor, page_size))
143
+
144
+ def page(
145
+ self, *, cursor: str | None = None, page_size: int | None = None
146
+ ) -> Page[TranscriptSummary]:
147
+ """One page of `list()`; `cursor` is a previous page's `next_cursor`. A page that returns
148
+ the cursor it was sent raises `pagination_loop`."""
149
+ check_page_size(page_size)
150
+ page = self._page(cursor, page_size)
151
+ check_cursor_advanced(cursor, page)
152
+ return page
153
+
154
+ def get(self, transcript_id: str) -> Transcript:
155
+ path = f"/v1/transcripts/{path_id(transcript_id)}"
156
+ return parsed(self._session.http.get(path), self._session.credentials, Transcript.from_api)
157
+
158
+ def deleted(self) -> Iterator[DeletedTranscript]:
159
+ """The deletion records, by upload time, newest first."""
160
+ return paginate(self._deleted_page)
161
+
162
+ def _page(self, cursor: str | None, page_size: int | None) -> Page[TranscriptSummary]:
163
+ response = self._session.http.get("/v1/transcripts", params=list_params(cursor, page_size))
164
+ return parsed(response, self._session.credentials, summary_page)
165
+
166
+ def _deleted_page(self, cursor: str | None) -> Page[DeletedTranscript]:
167
+ response = self._session.http.get(
168
+ "/v1/transcripts/deleted", params=list_params(cursor, None)
169
+ )
170
+ return parsed(response, self._session.credentials, deleted_page)
171
+
172
+
173
+ class AsyncTranscripts:
174
+ """`hs.transcripts` on the asyncio client; the lists are async iterators."""
175
+
176
+ def __init__(self, session: AsyncSession) -> None:
177
+ self._session = session
178
+
179
+ def list(self, *, page_size: int | None = None) -> AsyncIterator[TranscriptSummary]:
180
+ check_page_size(page_size)
181
+ return apaginate(lambda cursor: self._page(cursor, page_size))
182
+
183
+ async def page(
184
+ self, *, cursor: str | None = None, page_size: int | None = None
185
+ ) -> Page[TranscriptSummary]:
186
+ check_page_size(page_size)
187
+ page = await self._page(cursor, page_size)
188
+ check_cursor_advanced(cursor, page)
189
+ return page
190
+
191
+ async def get(self, transcript_id: str) -> Transcript:
192
+ path = f"/v1/transcripts/{path_id(transcript_id)}"
193
+ response = await self._session.http.get(path)
194
+ return parsed(response, self._session.credentials, Transcript.from_api)
195
+
196
+ def deleted(self) -> AsyncIterator[DeletedTranscript]:
197
+ return apaginate(self._deleted_page)
198
+
199
+ async def _page(self, cursor: str | None, page_size: int | None) -> Page[TranscriptSummary]:
200
+ response = await self._session.http.get(
201
+ "/v1/transcripts", params=list_params(cursor, page_size)
202
+ )
203
+ return parsed(response, self._session.credentials, summary_page)
204
+
205
+ async def _deleted_page(self, cursor: str | None) -> Page[DeletedTranscript]:
206
+ response = await self._session.http.get(
207
+ "/v1/transcripts/deleted", params=list_params(cursor, None)
208
+ )
209
+ return parsed(response, self._session.credentials, deleted_page)
@@ -0,0 +1 @@
1
+ """The transcribe pipeline (spec 8): preflight, probe, create, parts, heartbeat, complete, wait."""
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Any, TypeVar
5
+
6
+ from hushscript._errors import NotFoundError, raise_for_response
7
+ from hushscript._models import Job, UploadingProgress
8
+ from hushscript._response import json_body, parsed
9
+
10
+ from .aupload import AsyncUploader
11
+ from .heartbeat import Heartbeat
12
+ from .pipeline import (
13
+ UPLOADING,
14
+ Callbacks,
15
+ Tracker,
16
+ annotate,
17
+ note_interrupt,
18
+ percent,
19
+ replay_body,
20
+ )
21
+ from .preflight import Options, check_account, check_media, create_body
22
+ from .resume import (
23
+ ResumeState,
24
+ decode_token,
25
+ early_source,
26
+ encode_token,
27
+ file_required,
28
+ job_missing,
29
+ late_source,
30
+ )
31
+ from .segment import segment
32
+ from .source import FileInput, Source, make_source
33
+ from .upload import parse_created, stream_size_mismatch
34
+ from .wait import await_job, finish, job_from, transcript_of
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Awaitable, Callable, Mapping
38
+
39
+ from hushscript.transcript import Transcript
40
+
41
+ from .session import AsyncSession
42
+ from .upload import Created
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ async def create(session: AsyncSession, body: Mapping[str, object]) -> Created:
48
+ response = await session.http.post("/v1/uploads", json=body)
49
+ return parsed(response, session.credentials, parse_created)
50
+
51
+
52
+ async def get_job(session: AsyncSession, job_segment: str) -> Job:
53
+ return job_from(await session.http.get(f"/v1/jobs/{job_segment}"), session.credentials)
54
+
55
+
56
+ async def wait_job(
57
+ session: AsyncSession,
58
+ job_segment: str,
59
+ wait_timeout: float,
60
+ callbacks: Callbacks,
61
+ token: str | None,
62
+ first: Job | None = None,
63
+ ) -> Job:
64
+ return await await_job(
65
+ lambda: get_job(session, job_segment),
66
+ session.clock,
67
+ wait_timeout,
68
+ callbacks.progress,
69
+ token,
70
+ first,
71
+ )
72
+
73
+
74
+ async def fetch_transcript(session: AsyncSession, job: Job) -> Transcript:
75
+ transcript_id = finish(job)
76
+ return transcript_of(
77
+ await session.http.get(f"/v1/transcripts/{segment(transcript_id)}"), session.credentials
78
+ )
79
+
80
+
81
+ async def _supervised(
82
+ heartbeat: Heartbeat, ping: Callable[[], Awaitable[None]], work: Awaitable[T]
83
+ ) -> T:
84
+ """Runs `work` beside the heartbeat. A heartbeat that ends in `unauthorized` cancels the work
85
+ and raises its error; one that ends in `wrong_state` just stops."""
86
+ work_task = asyncio.ensure_future(work)
87
+ beat_task = asyncio.ensure_future(heartbeat.arun(ping))
88
+ try:
89
+ waiting: set[asyncio.Future[Any]] = {work_task, beat_task}
90
+ while True:
91
+ done, _ = await asyncio.wait(waiting, return_when=asyncio.FIRST_COMPLETED)
92
+ if work_task in done:
93
+ return work_task.result()
94
+ heartbeat.raise_if_failed()
95
+ waiting = {work_task}
96
+ finally:
97
+ for task in (work_task, beat_task):
98
+ task.cancel()
99
+ await asyncio.gather(work_task, beat_task, return_exceptions=True)
100
+
101
+
102
+ async def _upload(
103
+ session: AsyncSession,
104
+ created: Created,
105
+ source: Source,
106
+ concurrency: int,
107
+ callbacks: Callbacks,
108
+ ) -> None:
109
+ job_path = f"/v1/uploads/{segment(created.job_id)}"
110
+ uploader = AsyncUploader(session, created.job_id, created.plan, source, concurrency)
111
+ total = source.size
112
+
113
+ async def ping() -> None:
114
+ raise_for_response(
115
+ await session.http.post(f"{job_path}/heartbeat"), credentials=session.credentials
116
+ )
117
+
118
+ async def parts_and_complete() -> None:
119
+ await uploader.upload(
120
+ lambda sent: callbacks.progress(UploadingProgress(percent(sent, total), sent, total))
121
+ )
122
+ if not await asyncio.to_thread(source.at_end):
123
+ raise stream_size_mismatch()
124
+ body = uploader.state.parts_body()
125
+ response = (
126
+ await session.http.post(f"{job_path}/complete")
127
+ if body is None
128
+ else await session.http.post(f"{job_path}/complete", json=body)
129
+ )
130
+ raise_for_response(response, credentials=session.credentials)
131
+
132
+ await _supervised(Heartbeat(session.clock, session.log), ping, parts_and_complete())
133
+
134
+
135
+ async def _after_create( # the upload, wait and fetch that follow a create
136
+ session: AsyncSession,
137
+ created: Created,
138
+ tracker: Tracker,
139
+ state: ResumeState,
140
+ source: Source | None,
141
+ options: Options,
142
+ callbacks: Callbacks,
143
+ ) -> Transcript:
144
+ token = encode_token(
145
+ ResumeState(created.job_id, state.idempotency_key, state.size, state.duration_seconds)
146
+ )
147
+ tracker.token = token
148
+ callbacks.resume_token(token)
149
+ if created.state == UPLOADING:
150
+ if source is None:
151
+ raise file_required()
152
+ await _upload(session, created, source, options.concurrency, callbacks)
153
+ job = await wait_job(session, segment(created.job_id), options.wait_timeout, callbacks, token)
154
+ return await fetch_transcript(session, job)
155
+
156
+
157
+ async def annotated(tracker: Tracker, run: Callable[[], Awaitable[T]]) -> T:
158
+ """`pipeline.annotated` for the asyncio client."""
159
+ try:
160
+ return await run()
161
+ except (asyncio.CancelledError, KeyboardInterrupt) as interrupted:
162
+ note_interrupt(tracker, interrupted)
163
+ raise
164
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
165
+ failed = annotate(tracker, failure)
166
+ raise failed
167
+
168
+
169
+ async def run_transcribe(
170
+ session: AsyncSession,
171
+ file: FileInput,
172
+ options: Options,
173
+ size: int | None,
174
+ callbacks: Callbacks,
175
+ ) -> Transcript:
176
+ options.validate()
177
+ source = make_source(file, size)
178
+ media = await asyncio.to_thread(check_media, source, options)
179
+ account = json_body(await session.http.get("/v1/account"), session.credentials)
180
+ check_account(account, media)
181
+ key = options.new_key()
182
+ state = ResumeState("", key, media.size, media.duration_seconds)
183
+ body = create_body(media, options, key, title=options.title)
184
+ tracker = Tracker(key, callers=callbacks.raised, credentials=session.credentials)
185
+
186
+ async def run() -> Transcript:
187
+ callbacks.progress(UploadingProgress(0, 0, state.size))
188
+ tracker.sent = True
189
+ created = await create(session, body)
190
+ return await _after_create(session, created, tracker, state, source, options, callbacks)
191
+
192
+ return await annotated(tracker, run)
193
+
194
+
195
+ async def read_job_to_resume(session: AsyncSession, job_id: str) -> Job:
196
+ try:
197
+ return await get_job(session, segment(job_id))
198
+ except NotFoundError as missing:
199
+ raise job_missing() from missing
200
+
201
+
202
+ async def run_resume(
203
+ session: AsyncSession,
204
+ token: str,
205
+ file: FileInput | None,
206
+ options: Options,
207
+ callbacks: Callbacks,
208
+ ) -> Transcript:
209
+ options.validate()
210
+ state = decode_token(token)
211
+ source = early_source(state, file)
212
+ tracker = Tracker(
213
+ state.idempotency_key,
214
+ token,
215
+ sent=True,
216
+ callers=callbacks.raised,
217
+ credentials=session.credentials,
218
+ )
219
+
220
+ async def run() -> Transcript:
221
+ job = await read_job_to_resume(session, state.job_id)
222
+ if job.state != UPLOADING:
223
+ waited = await wait_job(
224
+ session, segment(state.job_id), options.wait_timeout, callbacks, token, job
225
+ )
226
+ return await fetch_transcript(session, waited)
227
+ late = late_source(state, file, source)
228
+ callbacks.progress(UploadingProgress(0, 0, state.size))
229
+ created = await create(session, replay_body(state))
230
+ if created.job_id != state.job_id:
231
+ raise job_missing()
232
+ return await _after_create(session, created, tracker, state, late, options, callbacks)
233
+
234
+ return await annotated(tracker, run)
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from typing import TYPE_CHECKING
6
+
7
+ import anyio
8
+
9
+ if sys.version_info < (3, 11):
10
+ from exceptiongroup import ExceptionGroup
11
+
12
+ from hushscript._errors import NetworkError, raise_for_response
13
+ from hushscript._http import STREAM_EXTENSION, release
14
+ from hushscript._log import hiding_signed_url
15
+
16
+ from .segment import segment
17
+ from .upload import (
18
+ OCTET_STREAM,
19
+ PART_TIMEOUTS,
20
+ Action,
21
+ PartAttempts,
22
+ Plan,
23
+ UploadState,
24
+ UrlBook,
25
+ etag_missing,
26
+ etag_of,
27
+ exhausted,
28
+ first_mode,
29
+ parse_page,
30
+ part_url_missing,
31
+ plan_for,
32
+ r2_extensions,
33
+ read_part,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable
38
+
39
+ from hushscript._redact import Secret
40
+
41
+ from .session import AsyncSession
42
+ from .source import Source
43
+
44
+
45
+ class AsyncUploader:
46
+ """The asyncio twin of `SyncUploader`: `concurrency` worker tasks in a task group, so one
47
+ failed part cancels the others."""
48
+
49
+ def __init__(
50
+ self,
51
+ session: AsyncSession,
52
+ job_id: str,
53
+ plan: Plan | None,
54
+ source: Source,
55
+ concurrency: int,
56
+ ) -> None:
57
+ self._session = session
58
+ self._job_id = job_id
59
+ self._source = source
60
+ self.plan = plan_for(plan, source.size)
61
+ self.state = UploadState(first_mode(plan))
62
+ self._book = UrlBook(session.clock)
63
+ self._book.store(self.plan.urls, self.plan.next_part)
64
+ self._pages = asyncio.Lock()
65
+ self._width = 1 if source.sequential else concurrency
66
+ self._sent = 0
67
+
68
+ async def upload(self, on_bytes: Callable[[int], None]) -> None:
69
+ numbers = iter(range(1, self.plan.total_parts + 1))
70
+
71
+ async def worker() -> None:
72
+ for n in numbers:
73
+ size = await self._upload_part(n)
74
+ self._sent += size
75
+ on_bytes(self._sent)
76
+
77
+ try:
78
+ async with anyio.create_task_group() as group:
79
+ for _ in range(self._width):
80
+ group.start_soon(worker)
81
+ except ExceptionGroup as failed:
82
+ raise failed.exceptions[0] from None
83
+
84
+ async def _read(self, n: int) -> bytes:
85
+ return await asyncio.to_thread(read_part, self._source, self.plan, n)
86
+
87
+ async def _upload_part(self, n: int) -> int:
88
+ data = await self._read(n)
89
+ attempts = PartAttempts()
90
+ while True:
91
+ if self.state.proxied:
92
+ await self._put_proxied(n, data)
93
+ return len(data)
94
+ url = await self._url_for(n)
95
+ if url is None:
96
+ continue
97
+ status, etag = await self._put_direct(n, url, data)
98
+ if etag is not None:
99
+ if not self.state.record(n, etag):
100
+ await self._put_proxied(n, data)
101
+ return len(data)
102
+ action = self.state.decide(attempts, status)
103
+ if action is Action.EXHAUSTED:
104
+ raise exhausted(n)
105
+ if action is Action.REFRESH:
106
+ await self._refresh(n, url)
107
+
108
+ async def _put_direct(self, n: int, url: Secret, data: bytes) -> tuple[int | None, str | None]:
109
+ client = self._session.r2
110
+ try:
111
+ with hiding_signed_url(f"r2:part/{n}"):
112
+ response = await client.send(
113
+ client.build_request(
114
+ "PUT",
115
+ url.reveal(),
116
+ content=data,
117
+ timeout=PART_TIMEOUTS,
118
+ extensions=r2_extensions(n),
119
+ ),
120
+ stream=True,
121
+ )
122
+ try:
123
+ etag = etag_of(response)
124
+ if response.is_success and etag is None:
125
+ raise etag_missing(n)
126
+ return response.status_code, etag
127
+ finally:
128
+ try:
129
+ await response.aclose()
130
+ finally:
131
+ release(response.request)
132
+ except NetworkError:
133
+ return None, None
134
+
135
+ async def _put_proxied(self, n: int, data: bytes) -> None:
136
+ client = self._session.http
137
+ response = await client.send(
138
+ client.build_request(
139
+ "PUT",
140
+ f"/v1/uploads/{segment(self._job_id)}/parts/{n}",
141
+ content=data,
142
+ headers=OCTET_STREAM,
143
+ timeout=PART_TIMEOUTS,
144
+ extensions={STREAM_EXTENSION: True},
145
+ ),
146
+ stream=True,
147
+ )
148
+ try:
149
+ if not response.is_success:
150
+ await response.aread()
151
+ raise_for_response(response, credentials=self._session.credentials)
152
+ finally:
153
+ try:
154
+ await response.aclose()
155
+ finally:
156
+ release(response.request)
157
+
158
+ async def _url_for(self, n: int) -> Secret | None:
159
+ async with self._pages:
160
+ url = self._book.get(n)
161
+ if url is None:
162
+ await self._fetch_for(n)
163
+ if self.state.proxied:
164
+ return None
165
+ url = self._book.get(n)
166
+ if url is None:
167
+ raise part_url_missing(n)
168
+ return url
169
+
170
+ async def _fetch_for(self, n: int) -> None:
171
+ start = self._book.next_start
172
+ if start is None or n < start:
173
+ await self._fetch(n, advance=False)
174
+ return
175
+ while (
176
+ self._book.get(n) is None
177
+ and self._book.next_start is not None
178
+ and not self.state.proxied
179
+ ):
180
+ await self._fetch(self._book.next_start, advance=True)
181
+
182
+ async def _refresh(self, n: int, failed_url: Secret) -> None:
183
+ async with self._pages:
184
+ if self._book.is_current(n, failed_url):
185
+ await self._fetch(n, advance=False)
186
+
187
+ async def _fetch(self, start: int, *, advance: bool) -> None:
188
+ response = await self._session.http.get(
189
+ f"/v1/uploads/{segment(self._job_id)}/part-urls", params={"start": start}
190
+ )
191
+ plan = parse_page(response, self._session.credentials)
192
+ if not plan.direct and self.state.fall_back():
193
+ return
194
+ cursor = plan.next_part if advance else self._book.next_start
195
+ if advance and cursor is not None and cursor <= start:
196
+ cursor = None
197
+ self._book.store(plan.urls, cursor)