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,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)
|