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