hushscript 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hushscript/__init__.py +97 -0
- hushscript/_client.py +687 -0
- hushscript/_clock.py +36 -0
- hushscript/_config.py +139 -0
- hushscript/_errors.py +563 -0
- hushscript/_guard.py +61 -0
- hushscript/_http.py +624 -0
- hushscript/_json.py +55 -0
- hushscript/_log.py +155 -0
- hushscript/_models.py +144 -0
- hushscript/_pacing.py +51 -0
- hushscript/_redact.py +140 -0
- hushscript/_response.py +67 -0
- hushscript/_retry.py +205 -0
- hushscript/_shape.py +197 -0
- hushscript/_shapes.py +248 -0
- hushscript/_version.py +1 -0
- hushscript/format.py +224 -0
- hushscript/generated/__init__.py +8 -0
- hushscript/generated/api/__init__.py +1 -0
- hushscript/generated/api/default/__init__.py +1 -0
- hushscript/generated/api/default/buy_minutes.py +248 -0
- hushscript/generated/api/default/claim_agent_account.py +238 -0
- hushscript/generated/api/default/complete_upload.py +238 -0
- hushscript/generated/api/default/create_agent_account_signup.py +261 -0
- hushscript/generated/api/default/create_setup_session.py +244 -0
- hushscript/generated/api/default/create_upload.py +225 -0
- hushscript/generated/api/default/get_account.py +169 -0
- hushscript/generated/api/default/get_job.py +198 -0
- hushscript/generated/api/default/get_org.py +169 -0
- hushscript/generated/api/default/get_transcript.py +226 -0
- hushscript/generated/api/default/get_transcription_context.py +173 -0
- hushscript/generated/api/default/get_upload_part_urls.py +246 -0
- hushscript/generated/api/default/heartbeat_upload.py +197 -0
- hushscript/generated/api/default/lift_transcript_hold.py +206 -0
- hushscript/generated/api/default/list_credit_ledger.py +229 -0
- hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
- hushscript/generated/api/default/list_holds.py +233 -0
- hushscript/generated/api/default/list_jobs.py +249 -0
- hushscript/generated/api/default/list_org_audit.py +221 -0
- hushscript/generated/api/default/list_org_invoices.py +221 -0
- hushscript/generated/api/default/list_org_ledger.py +221 -0
- hushscript/generated/api/default/list_org_matters.py +247 -0
- hushscript/generated/api/default/list_org_members.py +221 -0
- hushscript/generated/api/default/list_org_transcripts.py +233 -0
- hushscript/generated/api/default/list_packs.py +169 -0
- hushscript/generated/api/default/list_saved_cards.py +169 -0
- hushscript/generated/api/default/list_transactions.py +225 -0
- hushscript/generated/api/default/list_transcripts.py +225 -0
- hushscript/generated/api/default/org_audit_csv.py +163 -0
- hushscript/generated/api/default/org_ledger_csv.py +163 -0
- hushscript/generated/api/default/org_usage_report.py +247 -0
- hushscript/generated/api/default/org_usage_report_csv.py +229 -0
- hushscript/generated/api/default/patch_org_member.py +237 -0
- hushscript/generated/api/default/place_transcript_hold.py +206 -0
- hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
- hushscript/generated/api/default/schedule_org_custody.py +215 -0
- hushscript/generated/api/default/set_default_card.py +223 -0
- hushscript/generated/api/default/upload_part.py +249 -0
- hushscript/generated/catalog.py +138 -0
- hushscript/generated/client.py +282 -0
- hushscript/generated/errors.py +16 -0
- hushscript/generated/hints.py +28 -0
- hushscript/generated/models/__init__.py +193 -0
- hushscript/generated/models/account_response.py +114 -0
- hushscript/generated/models/active_hold.py +88 -0
- hushscript/generated/models/active_hold_kind.py +18 -0
- hushscript/generated/models/agent_claim_request.py +61 -0
- hushscript/generated/models/agent_claim_response.py +85 -0
- hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
- hushscript/generated/models/agent_signup_request.py +118 -0
- hushscript/generated/models/agent_signup_request_agent.py +80 -0
- hushscript/generated/models/agent_signup_response.py +111 -0
- hushscript/generated/models/buy_minutes_body.py +89 -0
- hushscript/generated/models/card.py +132 -0
- hushscript/generated/models/cards_response.py +90 -0
- hushscript/generated/models/complete_upload_body.py +107 -0
- hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
- hushscript/generated/models/context_dictionary.py +143 -0
- hushscript/generated/models/context_prompt_preset.py +118 -0
- hushscript/generated/models/create_setup_session_body.py +62 -0
- hushscript/generated/models/create_upload_body.py +254 -0
- hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
- hushscript/generated/models/credit_ledger_entry.py +113 -0
- hushscript/generated/models/credit_ledger_response.py +90 -0
- hushscript/generated/models/deleted_transcript.py +148 -0
- hushscript/generated/models/deleted_transcript_cause.py +18 -0
- hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
- hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
- hushscript/generated/models/deleted_transcripts_response.py +90 -0
- hushscript/generated/models/dictionary_entry.py +123 -0
- hushscript/generated/models/dictionary_entry_category.py +37 -0
- hushscript/generated/models/error.py +155 -0
- hushscript/generated/models/error_details.py +94 -0
- hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
- hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
- hushscript/generated/models/holds_response.py +90 -0
- hushscript/generated/models/job.py +309 -0
- hushscript/generated/models/job_failure_kind_type_1.py +16 -0
- hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
- hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
- hushscript/generated/models/job_list.py +90 -0
- hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
- hushscript/generated/models/list_jobs_active.py +17 -0
- hushscript/generated/models/list_org_matters_status.py +16 -0
- hushscript/generated/models/options_summary.py +135 -0
- hushscript/generated/models/org_audit_entry.py +105 -0
- hushscript/generated/models/org_audit_response.py +90 -0
- hushscript/generated/models/org_invoice.py +175 -0
- hushscript/generated/models/org_invoices_response.py +90 -0
- hushscript/generated/models/org_ledger_entry.py +155 -0
- hushscript/generated/models/org_ledger_response.py +90 -0
- hushscript/generated/models/org_matter.py +79 -0
- hushscript/generated/models/org_matter_status.py +16 -0
- hushscript/generated/models/org_matters_response.py +75 -0
- hushscript/generated/models/org_member.py +123 -0
- hushscript/generated/models/org_members_response.py +90 -0
- hushscript/generated/models/org_summary_response.py +482 -0
- hushscript/generated/models/org_transcript.py +103 -0
- hushscript/generated/models/org_transcripts_response.py +90 -0
- hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
- hushscript/generated/models/org_usage_report_group_by.py +17 -0
- hushscript/generated/models/org_usage_response.py +112 -0
- hushscript/generated/models/org_usage_response_group_by.py +17 -0
- hushscript/generated/models/org_usage_row.py +86 -0
- hushscript/generated/models/pack.py +107 -0
- hushscript/generated/models/packs_response.py +75 -0
- hushscript/generated/models/patch_org_member_body.py +102 -0
- hushscript/generated/models/patch_org_member_response_200.py +61 -0
- hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
- hushscript/generated/models/presigned_part_url.py +69 -0
- hushscript/generated/models/purchase_response.py +76 -0
- hushscript/generated/models/schedule_org_custody_body.py +66 -0
- hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
- hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
- hushscript/generated/models/set_default_card_body.py +61 -0
- hushscript/generated/models/set_default_card_response_200.py +61 -0
- hushscript/generated/models/setup_session_response.py +77 -0
- hushscript/generated/models/transaction.py +183 -0
- hushscript/generated/models/transactions_response.py +98 -0
- hushscript/generated/models/transcript_detail.py +194 -0
- hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
- hushscript/generated/models/transcript_envelope.py +89 -0
- hushscript/generated/models/transcript_envelope_translations.py +62 -0
- hushscript/generated/models/transcript_language.py +75 -0
- hushscript/generated/models/transcript_list.py +90 -0
- hushscript/generated/models/transcript_list_row.py +152 -0
- hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
- hushscript/generated/models/transcription_context_response.py +95 -0
- hushscript/generated/models/upload_complete_response.py +100 -0
- hushscript/generated/models/upload_create_response.py +148 -0
- hushscript/generated/models/upload_create_response_funding.py +16 -0
- hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
- hushscript/generated/models/upload_limits.py +86 -0
- hushscript/generated/models/upload_part_response_200.py +69 -0
- hushscript/generated/models/utterance.py +85 -0
- hushscript/generated/scopes.py +42 -0
- hushscript/generated/types.py +54 -0
- hushscript/money.py +79 -0
- hushscript/paginate.py +78 -0
- hushscript/py.typed +0 -0
- hushscript/resources/__init__.py +1 -0
- hushscript/resources/_params.py +33 -0
- hushscript/resources/account.py +121 -0
- hushscript/resources/agent.py +421 -0
- hushscript/resources/billing.py +567 -0
- hushscript/resources/jobs.py +105 -0
- hushscript/resources/transcripts.py +209 -0
- hushscript/transcribe/__init__.py +1 -0
- hushscript/transcribe/apipeline.py +234 -0
- hushscript/transcribe/aupload.py +197 -0
- hushscript/transcribe/heartbeat.py +78 -0
- hushscript/transcribe/pipeline.py +312 -0
- hushscript/transcribe/preflight.py +272 -0
- hushscript/transcribe/probe.py +185 -0
- hushscript/transcribe/resume.py +115 -0
- hushscript/transcribe/segment.py +23 -0
- hushscript/transcribe/session.py +34 -0
- hushscript/transcribe/sniff.py +30 -0
- hushscript/transcribe/source.py +218 -0
- hushscript/transcribe/upload.py +498 -0
- hushscript/transcribe/wait.py +124 -0
- hushscript/transcript.py +197 -0
- hushscript-1.0.0.dist-info/METADATA +502 -0
- hushscript-1.0.0.dist-info/RECORD +187 -0
- hushscript-1.0.0.dist-info/WHEEL +4 -0
- hushscript-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|