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
hushscript/_log.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from typing import TYPE_CHECKING, Final
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._redact import redact
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Generator
|
|
16
|
+
|
|
17
|
+
LOG_LEVELS: Final = ("off", "debug")
|
|
18
|
+
_LOGGER: Final = logging.getLogger("hushscript")
|
|
19
|
+
|
|
20
|
+
# The label of the signed R2 request this thread or task is sending, or None. httpx logs every
|
|
21
|
+
# request's full URL at INFO on its own logger ("HTTP Request: PUT <url>"), httpcore traces its
|
|
22
|
+
# connection and request events at DEBUG on its own, and a signed part URL carries the signature in
|
|
23
|
+
# its query (spec 10: R2 URLs only ever appear as `r2:part/{n}`).
|
|
24
|
+
_SIGNED_REQUEST: ContextVar[str | None] = ContextVar("hushscript_signed_request", default=None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_URL_TEXT: Final = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s'\"<>]+")
|
|
28
|
+
# The query keys that make a URL signed: S3/R2 presigned (X-Amz-*), the mock's `sig`, and the
|
|
29
|
+
# generic names other signers use.
|
|
30
|
+
_SIGNED_QUERY: Final = re.compile(
|
|
31
|
+
r"[?&](?:sig|signature|token|expires|x-amz-[a-z0-9-]+|x-goog-[a-z0-9-]+)=", re.IGNORECASE
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _looks_signed(url: str) -> bool:
|
|
36
|
+
return _SIGNED_QUERY.search(url) is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _hide_text(text: str, label: str) -> str:
|
|
40
|
+
return _URL_TEXT.sub(lambda m: label if _looks_signed(m.group(0)) else m.group(0), text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _hide(value: object, label: str) -> object:
|
|
44
|
+
"""`value` with every signed-looking URL in it swapped for `label`. An object that is neither
|
|
45
|
+
text nor a number (an httpx.Request, a URL object of either library) is swapped for its hidden
|
|
46
|
+
text when its own text shows a signed URL."""
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return _hide_text(value, label)
|
|
49
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, bytes):
|
|
52
|
+
return _hide_text(value.decode("latin-1"), label).encode("latin-1")
|
|
53
|
+
try:
|
|
54
|
+
shown = f"{value}"
|
|
55
|
+
shown_repr = repr(value)
|
|
56
|
+
except Exception: # noqa: BLE001 # a log call must not fail on a hostile __str__
|
|
57
|
+
return value
|
|
58
|
+
if _looks_signed(shown) or _looks_signed(shown_repr):
|
|
59
|
+
return _hide_text(shown_repr if _looks_signed(shown_repr) else shown, label)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hide_arg(arg: object, label: str) -> object:
|
|
64
|
+
if isinstance(arg, (str, httpx.URL)) and "://" in str(arg):
|
|
65
|
+
return label
|
|
66
|
+
return _hide(arg, label)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _SignedUrlFilter(logging.Filter):
|
|
70
|
+
"""Swaps the URL in an httpx record for the request's label while a signed request is out.
|
|
71
|
+
|
|
72
|
+
It only rewrites records logged by the thread or task that is sending the signed request, so
|
|
73
|
+
other httpx users in the process keep their log lines, and it never touches a level. A URL
|
|
74
|
+
that httpx has already formatted into the message, or put in a mapping argument, goes too.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
78
|
+
label = _SIGNED_REQUEST.get()
|
|
79
|
+
if label is None:
|
|
80
|
+
return True
|
|
81
|
+
if isinstance(record.msg, str):
|
|
82
|
+
record.msg = _hide(record.msg, label)
|
|
83
|
+
if isinstance(record.args, tuple):
|
|
84
|
+
record.args = tuple(_hide_arg(arg, label) for arg in record.args)
|
|
85
|
+
elif isinstance(record.args, dict):
|
|
86
|
+
record.args = {key: _hide(item, label) for key, item in record.args.items()}
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_SIGNED_URL_FILTER: Final = _SignedUrlFilter()
|
|
91
|
+
_THIRD_PARTY_LOGGERS: Final = (
|
|
92
|
+
"httpx",
|
|
93
|
+
"httpcore",
|
|
94
|
+
"httpcore.connection",
|
|
95
|
+
"httpcore.http11",
|
|
96
|
+
"httpcore.http2",
|
|
97
|
+
"httpcore.proxy",
|
|
98
|
+
"httpcore.socks",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def hiding_signed_url(label: str) -> Generator[None]:
|
|
104
|
+
"""Within the block, httpx's own request log shows `label` where the signed URL would be."""
|
|
105
|
+
token = _SIGNED_REQUEST.set(label)
|
|
106
|
+
try:
|
|
107
|
+
yield
|
|
108
|
+
finally:
|
|
109
|
+
_SIGNED_REQUEST.reset(token)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RequestLog:
|
|
113
|
+
"""One line per attempt: method, path, status, attempt, duration and retry delay (spec 10).
|
|
114
|
+
|
|
115
|
+
Never headers, query strings, bodies, titles or transcript text; the caller passes a path
|
|
116
|
+
without a query and direct-to-R2 URLs arrive as `r2:part/{n}`.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, level: str = "off") -> None:
|
|
120
|
+
# httpx and httpcore log on their own, whatever our level; the filter is a no-op until a
|
|
121
|
+
# signed request is out. Adding it twice is harmless. A logger's filters see only the
|
|
122
|
+
# records it creates, so each httpcore logger carries it.
|
|
123
|
+
for name in _THIRD_PARTY_LOGGERS:
|
|
124
|
+
logging.getLogger(name).addFilter(_SIGNED_URL_FILTER)
|
|
125
|
+
self.enabled = level == "debug"
|
|
126
|
+
if self.enabled:
|
|
127
|
+
_LOGGER.setLevel(logging.DEBUG)
|
|
128
|
+
if not _LOGGER.handlers:
|
|
129
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
130
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
131
|
+
_LOGGER.addHandler(handler)
|
|
132
|
+
|
|
133
|
+
def note(self, message: str) -> None:
|
|
134
|
+
"""A line outside the request/attempt shape, such as a heartbeat that failed; redacted."""
|
|
135
|
+
if self.enabled:
|
|
136
|
+
_LOGGER.debug(redact(f"hushscript {message}"))
|
|
137
|
+
|
|
138
|
+
def attempt( # one call site per field of the line
|
|
139
|
+
self,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
status: int | None,
|
|
143
|
+
attempt: int,
|
|
144
|
+
duration_ms: float,
|
|
145
|
+
retry_delay: float | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
if not self.enabled:
|
|
148
|
+
return
|
|
149
|
+
outcome = "error" if status is None else str(status)
|
|
150
|
+
delay = "" if retry_delay is None else f" retry_in={retry_delay:g}s"
|
|
151
|
+
_LOGGER.debug(
|
|
152
|
+
redact(
|
|
153
|
+
f"hushscript {method} {path} {outcome} attempt={attempt} {duration_ms:.0f}ms{delay}"
|
|
154
|
+
)
|
|
155
|
+
)
|
hushscript/_models.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Literal
|
|
5
|
+
|
|
6
|
+
from ._json import ExtraFields, as_dict, as_list, extra_fields
|
|
7
|
+
from ._shape import (
|
|
8
|
+
as_flag,
|
|
9
|
+
as_text,
|
|
10
|
+
check_shape,
|
|
11
|
+
maybe_flag,
|
|
12
|
+
maybe_text,
|
|
13
|
+
maybe_whole,
|
|
14
|
+
to_datetime,
|
|
15
|
+
)
|
|
16
|
+
from ._shapes import JOB
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class UploadingProgress:
|
|
24
|
+
"""Bytes the API has acknowledged; `percent` is 0 while the call waits for a create slot."""
|
|
25
|
+
|
|
26
|
+
percent: int
|
|
27
|
+
bytes_sent: int
|
|
28
|
+
bytes_total: int
|
|
29
|
+
stage: Literal["uploading"] = "uploading"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ProcessingProgress:
|
|
34
|
+
"""The job's state while it is queued or processing."""
|
|
35
|
+
|
|
36
|
+
state: str
|
|
37
|
+
stage: Literal["processing"] = "processing"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Progress = UploadingProgress | ProcessingProgress
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class JobOptions(ExtraFields):
|
|
45
|
+
"""What the job was submitted with, from the API's content-free summary: it says whether
|
|
46
|
+
keyterms and a prompt were set, never what they were. Unknown API keys remain available
|
|
47
|
+
in `extra`."""
|
|
48
|
+
|
|
49
|
+
language_code: str | None
|
|
50
|
+
"""The forced language, or None when it was auto-detected."""
|
|
51
|
+
speaker_detection: bool
|
|
52
|
+
medical_mode: bool
|
|
53
|
+
multichannel: bool
|
|
54
|
+
translation_targets: list[str]
|
|
55
|
+
keyterms: bool
|
|
56
|
+
prompt: bool
|
|
57
|
+
auto_generate_insights: bool | None
|
|
58
|
+
"""The upload's insights override, or None when it used the account default."""
|
|
59
|
+
|
|
60
|
+
__hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Job(ExtraFields):
|
|
65
|
+
"""A processing job (spec 6.4). Fields without friendly attributes remain in `extra`."""
|
|
66
|
+
|
|
67
|
+
id: str
|
|
68
|
+
state: str
|
|
69
|
+
"""`uploading`, `fetching`, `queued`, `processing`, `done` or `failed`; a state the SDK does
|
|
70
|
+
not know yet passes through as the API's own string."""
|
|
71
|
+
transcript_id: str | None
|
|
72
|
+
error: str | None
|
|
73
|
+
"""The failure text of a failed job; opaque."""
|
|
74
|
+
failure_kind: str | None
|
|
75
|
+
"""`media_unreadable` or `media_undecodable` when the file itself could not be read."""
|
|
76
|
+
mode: str
|
|
77
|
+
declared_seconds: int | None
|
|
78
|
+
verified_seconds: int | None
|
|
79
|
+
actual_seconds: int | None
|
|
80
|
+
billable_seconds: int | None
|
|
81
|
+
retry_of: str | None
|
|
82
|
+
created_at: datetime
|
|
83
|
+
options: JobOptions
|
|
84
|
+
|
|
85
|
+
__hash__ = None # type: ignore[assignment] # holds a JobOptions, which is unhashable
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_body(cls, body: object) -> Job:
|
|
89
|
+
check_shape(body, JOB, "job response")
|
|
90
|
+
fields = as_dict(body) or {}
|
|
91
|
+
options = as_dict(fields["options"]) or {}
|
|
92
|
+
return cls(
|
|
93
|
+
_extra=extra_fields(
|
|
94
|
+
fields,
|
|
95
|
+
"id",
|
|
96
|
+
"state",
|
|
97
|
+
"transcript_id",
|
|
98
|
+
"error",
|
|
99
|
+
"failure_kind",
|
|
100
|
+
"mode",
|
|
101
|
+
"declared_seconds",
|
|
102
|
+
"verified_seconds",
|
|
103
|
+
"actual_seconds",
|
|
104
|
+
"billable_seconds",
|
|
105
|
+
"retry_of",
|
|
106
|
+
"created_at",
|
|
107
|
+
"options",
|
|
108
|
+
),
|
|
109
|
+
id=as_text(fields["id"]),
|
|
110
|
+
state=as_text(fields["state"]),
|
|
111
|
+
transcript_id=maybe_text(fields["transcript_id"]),
|
|
112
|
+
error=maybe_text(fields["error"]),
|
|
113
|
+
failure_kind=maybe_text(fields["failure_kind"]),
|
|
114
|
+
mode=as_text(fields["mode"]),
|
|
115
|
+
declared_seconds=maybe_whole(fields["declared_seconds"]),
|
|
116
|
+
verified_seconds=maybe_whole(fields["verified_seconds"]),
|
|
117
|
+
actual_seconds=maybe_whole(fields["actual_seconds"]),
|
|
118
|
+
billable_seconds=maybe_whole(fields["billable_seconds"]),
|
|
119
|
+
retry_of=maybe_text(fields["retry_of"]),
|
|
120
|
+
created_at=to_datetime(fields["created_at"]),
|
|
121
|
+
options=JobOptions(
|
|
122
|
+
_extra=extra_fields(
|
|
123
|
+
options,
|
|
124
|
+
"language_code",
|
|
125
|
+
"speaker_detection",
|
|
126
|
+
"medical_mode",
|
|
127
|
+
"multichannel",
|
|
128
|
+
"translation_targets",
|
|
129
|
+
"keyterms",
|
|
130
|
+
"prompt",
|
|
131
|
+
"auto_generate_insights",
|
|
132
|
+
),
|
|
133
|
+
language_code=maybe_text(options["language_code"]),
|
|
134
|
+
speaker_detection=as_flag(options["speaker_detection"]),
|
|
135
|
+
medical_mode=as_flag(options["medical_mode"]),
|
|
136
|
+
multichannel=as_flag(options["multichannel"]),
|
|
137
|
+
translation_targets=[
|
|
138
|
+
as_text(t) for t in as_list(options["translation_targets"]) or []
|
|
139
|
+
],
|
|
140
|
+
keyterms=as_flag(options["keyterms"]),
|
|
141
|
+
prompt=as_flag(options["prompt"]),
|
|
142
|
+
auto_generate_insights=maybe_flag(options["auto_generate_insights"]),
|
|
143
|
+
),
|
|
144
|
+
)
|
hushscript/_pacing.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ._clock import Clock
|
|
9
|
+
|
|
10
|
+
CREATES_PER_WINDOW: Final = 4
|
|
11
|
+
WINDOW_SECONDS: Final = 60.0
|
|
12
|
+
CREATE_RATE_LIMIT_BACKOFF: Final = (15.0, 30.0, 60.0)
|
|
13
|
+
ACTIVE_JOBS_WAIT: Final = 30.0
|
|
14
|
+
ACTIVE_JOBS_MAX_TRIES: Final = 10
|
|
15
|
+
ACTIVE_JOBS_RETRIES: Final = ACTIVE_JOBS_MAX_TRIES - 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenBucket:
|
|
19
|
+
"""Paces `POST /v1/uploads` to 4 per 60 s per client (spec 7.5).
|
|
20
|
+
|
|
21
|
+
`reserve()` books the next slot and says how long the caller must wait before using it, so the
|
|
22
|
+
sync and async callers sleep on their own clock method.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
clock: Clock,
|
|
28
|
+
capacity: int = CREATES_PER_WINDOW,
|
|
29
|
+
window: float = WINDOW_SECONDS,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._clock = clock
|
|
32
|
+
self._capacity = capacity
|
|
33
|
+
self._window = window
|
|
34
|
+
self._slots: deque[float] = deque()
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def reserve(self) -> float:
|
|
38
|
+
with self._lock:
|
|
39
|
+
return self._reserve()
|
|
40
|
+
|
|
41
|
+
def _reserve(self) -> float:
|
|
42
|
+
now = self._clock.now()
|
|
43
|
+
while self._slots and self._slots[0] <= now - self._window:
|
|
44
|
+
self._slots.popleft()
|
|
45
|
+
if len(self._slots) < self._capacity:
|
|
46
|
+
self._slots.append(now)
|
|
47
|
+
return 0.0
|
|
48
|
+
wait = self._slots[0] + self._window - now
|
|
49
|
+
self._slots.popleft()
|
|
50
|
+
self._slots.append(now + wait)
|
|
51
|
+
return wait
|
hushscript/_redact.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Final
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ._json import as_dict, as_list
|
|
8
|
+
|
|
9
|
+
REDACTED: Final = "hsr…[redacted]"
|
|
10
|
+
TOKEN_PATTERN: Final = re.compile(r"hsr\d+:[a-z]+:pat\.[0-9a-f]+\.[0-9a-f]+")
|
|
11
|
+
MIN_SECRET_TAIL: Final = 16
|
|
12
|
+
_WHOLE_TOKEN_FORMS: Final = 2
|
|
13
|
+
"""A shorter echoed piece of a token is an ordinary substring, not worth redacting everywhere."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _patterns(token: str) -> list[re.Pattern[str]]:
|
|
17
|
+
"""What an API could echo of `token`, most specific first: the whole token as it is and
|
|
18
|
+
percent-encoded, then what follows `pat.` and the bare secret after the last dot. Matching
|
|
19
|
+
ignores case. A later piece is only ever a substring of an earlier one, so the order keeps the
|
|
20
|
+
id from being left behind."""
|
|
21
|
+
# surrogatepass: a lone surrogate in a registered secret still has a percent-encoded form
|
|
22
|
+
pieces = [token, quote(token, safe="", errors="surrogatepass")]
|
|
23
|
+
_, found, tail = token.partition("pat.")
|
|
24
|
+
if found:
|
|
25
|
+
pieces.append(tail)
|
|
26
|
+
pieces.append(token.rpartition(".")[2])
|
|
27
|
+
return [
|
|
28
|
+
re.compile(re.escape(piece), re.IGNORECASE)
|
|
29
|
+
for index, piece in enumerate(pieces)
|
|
30
|
+
if piece and (index < _WHOLE_TOKEN_FORMS or len(piece) >= MIN_SECRET_TAIL)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact(text: str) -> str:
|
|
35
|
+
return TOKEN_PATTERN.sub(REDACTED, text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
QUERY_REDACTED: Final = "?[redacted]"
|
|
39
|
+
_URL_QUERY: Final = re.compile(
|
|
40
|
+
# a URL with any scheme, or a path at the start of the text or after a space, quote, bracket
|
|
41
|
+
# or `=`; a query stops at a space, quote, angle bracket, `#` or `)`
|
|
42
|
+
r"((?:\b[a-z][a-z0-9+.-]*://[^\s\"'<>?#]+)|(?:(?<![^\s\"'(\[<=])/[^\s\"'<>?#]*))"
|
|
43
|
+
r"\?[^\s\"'<>#)]+",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact_queries(text: str) -> str:
|
|
49
|
+
"""R96: every URL or path in `text` keeps its scheme, host and path; its query string, which
|
|
50
|
+
holds a signed URL's signature, becomes `?[redacted]`."""
|
|
51
|
+
return _URL_QUERY.sub(lambda match: match.group(1) + QUERY_REDACTED, text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Secret:
|
|
55
|
+
"""A token or a signed URL held at rest: nothing prints, pickles or copies its text.
|
|
56
|
+
|
|
57
|
+
`repr`, `str`, `format`, `pprint`, `vars` and logging show the redaction marker. A pickle holds
|
|
58
|
+
an empty secret, and a copy is the same holder (it is immutable). `reveal()` is the one way to
|
|
59
|
+
the text.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_text",)
|
|
63
|
+
|
|
64
|
+
def __init__(self, text: str = "") -> None:
|
|
65
|
+
self._text = text
|
|
66
|
+
|
|
67
|
+
def reveal(self) -> str:
|
|
68
|
+
return self._text
|
|
69
|
+
|
|
70
|
+
def __bool__(self) -> bool:
|
|
71
|
+
return bool(self._text)
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return "Secret(<redacted>)"
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str:
|
|
77
|
+
return REDACTED
|
|
78
|
+
|
|
79
|
+
def __format__(self, format_spec: str) -> str:
|
|
80
|
+
return format(REDACTED, format_spec)
|
|
81
|
+
|
|
82
|
+
def __reduce__(self) -> tuple[type[Secret], tuple[str]]:
|
|
83
|
+
return (Secret, ("",))
|
|
84
|
+
|
|
85
|
+
def __copy__(self) -> Secret:
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __deepcopy__(self, memo: dict[int, object]) -> Secret:
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Credentials:
|
|
93
|
+
"""The API token and every earlier one, kept out of every representation.
|
|
94
|
+
|
|
95
|
+
The token is held here and nowhere else in the client: not in a header on the httpx client, not
|
|
96
|
+
in a dataclass field. The transport reads it per request.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
__slots__ = ("_known", "_tokens")
|
|
100
|
+
|
|
101
|
+
def __init__(self, token: str | None = None) -> None:
|
|
102
|
+
self._tokens: list[str] = []
|
|
103
|
+
self._known: list[str] = []
|
|
104
|
+
if token:
|
|
105
|
+
self._tokens.append(token)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def token(self) -> str | None:
|
|
109
|
+
return self._tokens[-1] if self._tokens else None
|
|
110
|
+
|
|
111
|
+
def replace(self, token: str) -> None:
|
|
112
|
+
self._tokens.append(token)
|
|
113
|
+
|
|
114
|
+
def remember(self, token: str) -> None:
|
|
115
|
+
"""Scrubs `token` from every text from now on without making it the one the client sends."""
|
|
116
|
+
if token and token not in self._known:
|
|
117
|
+
self._known.append(token)
|
|
118
|
+
|
|
119
|
+
def scrub(self, text: str) -> str:
|
|
120
|
+
for token in (*self._tokens, *self._known):
|
|
121
|
+
for pattern in _patterns(token):
|
|
122
|
+
text = pattern.sub(REDACTED, text)
|
|
123
|
+
return redact(text)
|
|
124
|
+
|
|
125
|
+
def scrub_value(self, value: object) -> object:
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
return self.scrub(value)
|
|
128
|
+
mapping = as_dict(value)
|
|
129
|
+
if mapping is not None:
|
|
130
|
+
return {self.scrub(key): self.scrub_value(item) for key, item in mapping.items()}
|
|
131
|
+
items = as_list(value)
|
|
132
|
+
if items is not None:
|
|
133
|
+
return [self.scrub_value(item) for item in items]
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return "Credentials(<redacted>)"
|
|
138
|
+
|
|
139
|
+
def __reduce__(self) -> str | tuple[object, ...]:
|
|
140
|
+
raise TypeError("credentials cannot be pickled")
|
hushscript/_response.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
4
|
+
|
|
5
|
+
from ._errors import ApiError, describe_failure, forget_body, raise_for_response, redacted_cause
|
|
6
|
+
from ._json import loads
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._redact import Credentials
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BadBodyError(ValueError):
|
|
19
|
+
"""A 2xx body that parses as JSON but not into the shape the SDK needs."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bad_response(response: httpx.Response, what: str) -> ApiError:
|
|
23
|
+
"""Spec 9.2: an unusable 2xx body, with the actual status and no numeric code."""
|
|
24
|
+
return ApiError(
|
|
25
|
+
f"The API's {what} could not be used.", code="bad_response", status=response.status_code
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unreadable_response(
|
|
30
|
+
response: httpx.Response, failure: BaseException, credentials: Credentials | None
|
|
31
|
+
) -> ApiError:
|
|
32
|
+
"""`bad_response` for a body the SDK could not read, naming the original's type and redacted
|
|
33
|
+
message (R86). Its cause is a stand-in: a JSON error keeps the whole body it read, which may
|
|
34
|
+
hold a token or a claim secret."""
|
|
35
|
+
error = ApiError(
|
|
36
|
+
f"The API's response could not be used ({describe_failure(failure, credentials)}).",
|
|
37
|
+
code="bad_response",
|
|
38
|
+
status=response.status_code,
|
|
39
|
+
)
|
|
40
|
+
error.__cause__ = redacted_cause(failure, credentials)
|
|
41
|
+
forget_body(response)
|
|
42
|
+
return error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def json_body(response: httpx.Response, credentials: Credentials) -> object:
|
|
46
|
+
"""The JSON of a 2xx response; a non-2xx one raises the spec 9.2 error."""
|
|
47
|
+
raise_for_response(response, credentials=credentials)
|
|
48
|
+
try:
|
|
49
|
+
return loads(response.content)
|
|
50
|
+
except (
|
|
51
|
+
ValueError,
|
|
52
|
+
RecursionError,
|
|
53
|
+
) as failure: # a deeply nested body is not JSON the SDK reads
|
|
54
|
+
# Raised once the handler has ended, so the JSON error is not its context either.
|
|
55
|
+
failed = unreadable_response(response, failure, credentials)
|
|
56
|
+
raise failed
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parsed(response: httpx.Response, credentials: Credentials, parse: Callable[[object], T]) -> T:
|
|
60
|
+
"""`parse` of the JSON of a 2xx response; a body it cannot use is `bad_response`."""
|
|
61
|
+
body = json_body(response, credentials)
|
|
62
|
+
try:
|
|
63
|
+
return parse(body)
|
|
64
|
+
except BadBodyError as failure:
|
|
65
|
+
failed = unreadable_response(response, failure, credentials)
|
|
66
|
+
del body
|
|
67
|
+
raise failed
|