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/_errors.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Final
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._json import as_dict, as_list, loads
|
|
13
|
+
from ._redact import Credentials, Secret, redact, redact_queries
|
|
14
|
+
from .generated.catalog import ERROR_CATALOG
|
|
15
|
+
from .generated.hints import HINTS
|
|
16
|
+
from .generated.scopes import OPERATION_SCOPES
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_note(error: BaseException, note: str) -> None:
|
|
23
|
+
if sys.version_info >= (3, 11):
|
|
24
|
+
error.add_note(note)
|
|
25
|
+
else:
|
|
26
|
+
notes = getattr(error, "__notes__", [])
|
|
27
|
+
notes.append(note)
|
|
28
|
+
vars(error)["__notes__"] = notes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_PLACEHOLDER: Final = re.compile(r"\{(\w+)\}")
|
|
32
|
+
_SENTENCE_BREAK: Final = re.compile(r"(?<=\.) ")
|
|
33
|
+
_TEMPLATE_PARAMETER: Final = re.compile(r"\\\{[^}/]+\\\}")
|
|
34
|
+
_JOB_OR_UPLOAD: Final = re.compile(r"^[A-Z]+ /v1/(?:jobs|uploads)(?:/|$)")
|
|
35
|
+
_INT_LIMIT: Final = 2**63
|
|
36
|
+
"""Numbers the SDK reads stay inside a signed 64-bit range; beyond it, or not finite, they are
|
|
37
|
+
absent."""
|
|
38
|
+
_MAX_RETRY_AFTER_DIGITS: Final = 19
|
|
39
|
+
RETRY_AFTER_CAP: Final = float(2**53 - 1)
|
|
40
|
+
"""R71: a Retry-After that is a non-negative integer, however large, is "over 60" and reported at
|
|
41
|
+
most this."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _camel(key: str) -> str:
|
|
45
|
+
head, *rest = key.split("_")
|
|
46
|
+
return head + "".join(part[:1].upper() + part[1:] for part in rest)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _snake(key: str) -> str:
|
|
50
|
+
return re.sub(r"(?<=[a-z0-9])([A-Z])", lambda match: "_" + match.group(1).lower(), key)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _snake_keys(value: object) -> object:
|
|
54
|
+
"""Spec 9.3: details keys follow the language convention (snake_case here), recursively."""
|
|
55
|
+
mapping = as_dict(value)
|
|
56
|
+
if mapping is not None:
|
|
57
|
+
return {_snake(key): _snake_keys(item) for key, item in mapping.items()}
|
|
58
|
+
items = as_list(value)
|
|
59
|
+
if items is not None:
|
|
60
|
+
return [_snake_keys(item) for item in items]
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _number(value: object) -> float | None:
|
|
65
|
+
"""`value` when it is a finite number inside the 64-bit range, else None (absent)."""
|
|
66
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
69
|
+
return None
|
|
70
|
+
return value if abs(value) < _INT_LIMIT else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _format_number(value: float) -> str:
|
|
74
|
+
return str(int(value)) if float(value).is_integer() else str(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_context_value(value: object) -> str | None:
|
|
78
|
+
if isinstance(value, bool):
|
|
79
|
+
return str(value).lower()
|
|
80
|
+
if isinstance(value, (int, float)):
|
|
81
|
+
usable = _number(value)
|
|
82
|
+
return None if usable is None else _format_number(usable)
|
|
83
|
+
items = as_list(value)
|
|
84
|
+
if items is not None:
|
|
85
|
+
return ", ".join(str(item) for item in items)
|
|
86
|
+
return str(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _minutes(details: Mapping[str, object], keys: tuple[str, ...], *, up: bool) -> str | None:
|
|
90
|
+
for key in keys:
|
|
91
|
+
seconds = _number(details.get(key))
|
|
92
|
+
if seconds is not None:
|
|
93
|
+
return str(math.ceil(seconds / 60) if up else math.floor(seconds / 60))
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _retry_after_detail(value: object) -> float | None:
|
|
98
|
+
"""R71: `retry_after_seconds` as a number of seconds, capped; negative, non-finite and
|
|
99
|
+
non-numeric values are absent."""
|
|
100
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
103
|
+
return None
|
|
104
|
+
return None if value < 0 else float(min(value, RETRY_AFTER_CAP))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _whole_seconds(retry_after: float | None, from_details: object) -> str | None:
|
|
108
|
+
seconds = retry_after if retry_after is not None else _retry_after_detail(from_details)
|
|
109
|
+
return None if seconds is None else str(math.ceil(seconds))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _hint_context(
|
|
113
|
+
details: Mapping[str, object], retry_after: float | None, scope: str | None
|
|
114
|
+
) -> dict[str, str]:
|
|
115
|
+
formatted = {_camel(key): _format_context_value(value) for key, value in details.items()}
|
|
116
|
+
context = {name: text for name, text in formatted.items() if text is not None}
|
|
117
|
+
derived = {
|
|
118
|
+
"needed": _minutes(details, ("needed_seconds",), up=True),
|
|
119
|
+
"balance": _minutes(details, ("balance_seconds",), up=False),
|
|
120
|
+
"used": _minutes(details, ("used_seconds", "used_today_seconds"), up=False),
|
|
121
|
+
"max": _minutes(details, ("max_seconds", "max_daily_seconds"), up=False),
|
|
122
|
+
"retryAfter": _whole_seconds(retry_after, details.get("retry_after_seconds")),
|
|
123
|
+
"scope": scope,
|
|
124
|
+
}
|
|
125
|
+
context.update({name: value for name, value in derived.items() if value is not None})
|
|
126
|
+
return context
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_hint(
|
|
130
|
+
code: str,
|
|
131
|
+
details: Mapping[str, object] | None = None,
|
|
132
|
+
retry_after: float | None = None,
|
|
133
|
+
scope: str | None = None,
|
|
134
|
+
) -> str | None:
|
|
135
|
+
"""Spec 9.3 rendering: split after each ". ", drop the sentences with an unfilled placeholder,
|
|
136
|
+
join the rest with a space; nothing left is None."""
|
|
137
|
+
template = HINTS.get(code)
|
|
138
|
+
if template is None:
|
|
139
|
+
return None
|
|
140
|
+
context = _hint_context(details or {}, retry_after, scope)
|
|
141
|
+
kept = [
|
|
142
|
+
_PLACEHOLDER.sub(lambda match: context[match.group(1)], sentence)
|
|
143
|
+
for sentence in _SENTENCE_BREAK.split(template)
|
|
144
|
+
if all(name in context for name in _PLACEHOLDER.findall(sentence))
|
|
145
|
+
]
|
|
146
|
+
return " ".join(kept) or None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class HushscriptError(Exception):
|
|
150
|
+
default_code: ClassVar[str] = "hushscript_error"
|
|
151
|
+
auto_hint: ClassVar[bool] = True
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
message: str,
|
|
156
|
+
*,
|
|
157
|
+
code: str | None = None,
|
|
158
|
+
numeric_code: int | None = None,
|
|
159
|
+
status: int | None = None,
|
|
160
|
+
hint: str | None = None,
|
|
161
|
+
details: Mapping[str, object] | None = None,
|
|
162
|
+
retry_after: float | None = None,
|
|
163
|
+
scope: str | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
self.code: str = code or self.default_code
|
|
166
|
+
self.numeric_code: int | None = numeric_code
|
|
167
|
+
self.status: int | None = status
|
|
168
|
+
self.details: dict[str, object] = dict(details or {})
|
|
169
|
+
self.retry_after: float | None = retry_after
|
|
170
|
+
self.idempotency_key: str | None = None
|
|
171
|
+
"""Set on every error `transcribe()` raises once its create was sent (spec 7.3): passing it
|
|
172
|
+
back to `transcribe()` replays the same job."""
|
|
173
|
+
self.resume_token: str | None = None
|
|
174
|
+
self._unsent = False
|
|
175
|
+
if hint is None and self.auto_hint:
|
|
176
|
+
hint = render_hint(self.code, self.details, retry_after, scope)
|
|
177
|
+
self.hint: str | None = redact_queries(redact(hint)) if hint is not None else None
|
|
178
|
+
self.message: str = redact_queries(redact(message))
|
|
179
|
+
super().__init__(self.message)
|
|
180
|
+
|
|
181
|
+
def __str__(self) -> str:
|
|
182
|
+
return f"{self.message} {self.hint}" if self.hint else self.message
|
|
183
|
+
|
|
184
|
+
def __repr__(self) -> str:
|
|
185
|
+
return (
|
|
186
|
+
f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, "
|
|
187
|
+
f"message={self.message!r})"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def mark_unsent(error: HushscriptError) -> None:
|
|
192
|
+
"""R103: the request provably never went out, so a purchase key must not ride along."""
|
|
193
|
+
error._unsent = True # noqa: SLF001 # pyright: ignore[reportPrivateUsage] # the one writer
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_unsent(error: HushscriptError) -> bool:
|
|
197
|
+
return error._unsent # noqa: SLF001 # pyright: ignore[reportPrivateUsage] # the one reader
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _RedactedCause(Exception): # noqa: N818 # a stand-in for an exception, never raised by name
|
|
201
|
+
"""What an unexpected failure looked like, with tokens and signed-URL queries removed."""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
UNEXPECTED: Final = "unexpected_error"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def describe_failure(failure: BaseException, credentials: Credentials | None = None) -> str:
|
|
208
|
+
"""R86: the original's type and its message, with tokens and signed-URL queries removed. A
|
|
209
|
+
message that is blank, or whose own text breaks, leaves the type alone."""
|
|
210
|
+
name = type(failure).__name__
|
|
211
|
+
try:
|
|
212
|
+
text = str(failure)
|
|
213
|
+
except Exception: # noqa: BLE001 # an exception whose own text breaks still has a name
|
|
214
|
+
text = ""
|
|
215
|
+
if not text.strip():
|
|
216
|
+
return name
|
|
217
|
+
scrubbed = credentials.scrub(text) if credentials is not None else redact(text)
|
|
218
|
+
return f"{name}: {redact_queries(scrubbed)}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def redacted_cause(failure: BaseException, credentials: Credentials | None = None) -> BaseException:
|
|
222
|
+
"""R86: what stands in for a foreign exception as the cause of an SDK error. The original
|
|
223
|
+
cannot be redacted in place: an HTTP error keeps the request it sent (bearer header, signed
|
|
224
|
+
URL, body) and a JSON error keeps the whole text it read. The stand-in has its type and
|
|
225
|
+
redacted message, and its traceback with the locals of every finished frame cleared, so the
|
|
226
|
+
HTTP library's frames no longer hold their copy of the request either."""
|
|
227
|
+
traceback.clear_frames(failure.__traceback__)
|
|
228
|
+
return _RedactedCause(describe_failure(failure, credentials)).with_traceback(
|
|
229
|
+
failure.__traceback__
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_SENT: Final[dict[bool | None, str]] = {
|
|
234
|
+
True: " after the request was sent",
|
|
235
|
+
False: " before the request was sent",
|
|
236
|
+
None: "",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def unexpected_error(
|
|
241
|
+
failure: BaseException, credentials: Credentials | None = None, *, sent: bool | None = None
|
|
242
|
+
) -> HushscriptError:
|
|
243
|
+
"""Spec 9.2: a failure inside the SDK that fits no other class. The message names the
|
|
244
|
+
original's type and redacted message (R86) and says whether the request was sent only when
|
|
245
|
+
`sent` says the SDK knows (R100); the original rides along as a redacted cause."""
|
|
246
|
+
description = describe_failure(failure, credentials)
|
|
247
|
+
error = HushscriptError(
|
|
248
|
+
f"An unexpected {type(failure).__name__} interrupted the SDK{_SENT[sent]} ({description}).",
|
|
249
|
+
code=UNEXPECTED,
|
|
250
|
+
)
|
|
251
|
+
error.__cause__ = redacted_cause(failure, credentials)
|
|
252
|
+
return error
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class ConfigurationError(HushscriptError):
|
|
256
|
+
default_code = "config_invalid"
|
|
257
|
+
auto_hint = False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class PreflightError(HushscriptError):
|
|
261
|
+
"""The request was refused before any credit hold was placed."""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class UnsupportedMediaError(PreflightError):
|
|
265
|
+
default_code = "unsupported_media"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class FileLimitError(PreflightError):
|
|
269
|
+
default_code = "size_out_of_range"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class DailyLimitError(PreflightError):
|
|
273
|
+
default_code = "daily_limit"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class ResumeMismatchError(PreflightError):
|
|
277
|
+
default_code = "resume_file_mismatch"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ApiError(HushscriptError):
|
|
281
|
+
default_code = "api_error"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class AuthenticationError(ApiError):
|
|
285
|
+
default_code = "unauthorized"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class PermissionDeniedError(ApiError):
|
|
289
|
+
default_code = "pat_forbidden"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class InsufficientBalanceError(ApiError):
|
|
293
|
+
default_code = "insufficient_balance"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class NotFoundError(ApiError):
|
|
297
|
+
default_code = "not_found"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ConflictError(ApiError):
|
|
301
|
+
default_code = "wrong_state"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ValidationError(ApiError):
|
|
305
|
+
default_code = "bad_request"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class RateLimitError(ApiError):
|
|
309
|
+
default_code = "rate_limited"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class PaymentMethodRequiredError(HushscriptError):
|
|
313
|
+
default_code = "no_default_card"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class UploadError(HushscriptError):
|
|
317
|
+
default_code = "upload_incomplete"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class JobFailedError(HushscriptError):
|
|
321
|
+
default_code = "job_failed"
|
|
322
|
+
|
|
323
|
+
def __init__(
|
|
324
|
+
self,
|
|
325
|
+
message: str,
|
|
326
|
+
*,
|
|
327
|
+
job_id: str | None = None,
|
|
328
|
+
error: str | None = None,
|
|
329
|
+
failure_kind: str | None = None,
|
|
330
|
+
**kwargs: Any, # noqa: ANN401 # forwarded to HushscriptError, which types them
|
|
331
|
+
) -> None:
|
|
332
|
+
super().__init__(message, **kwargs)
|
|
333
|
+
self.job_id = job_id
|
|
334
|
+
self.error = redact(error) if error is not None else None
|
|
335
|
+
self.failure_kind = failure_kind
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class WaitTimeoutError(HushscriptError):
|
|
339
|
+
default_code = "wait_timeout"
|
|
340
|
+
|
|
341
|
+
def __init__(
|
|
342
|
+
self,
|
|
343
|
+
message: str,
|
|
344
|
+
*,
|
|
345
|
+
resume_token: str | None = None,
|
|
346
|
+
**kwargs: Any, # noqa: ANN401 # forwarded to HushscriptError, which types them
|
|
347
|
+
) -> None:
|
|
348
|
+
super().__init__(message, **kwargs)
|
|
349
|
+
self.resume_token = resume_token
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class AbortError(HushscriptError):
|
|
353
|
+
default_code = "aborted"
|
|
354
|
+
|
|
355
|
+
def __init__(
|
|
356
|
+
self,
|
|
357
|
+
message: str,
|
|
358
|
+
*,
|
|
359
|
+
resume_token: str | None = None,
|
|
360
|
+
**kwargs: Any, # noqa: ANN401 # forwarded to HushscriptError, which types them
|
|
361
|
+
) -> None:
|
|
362
|
+
super().__init__(message, **kwargs)
|
|
363
|
+
self.resume_token = resume_token
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class NetworkError(HushscriptError):
|
|
367
|
+
default_code = "network_error"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class OutcomeUnknownError(HushscriptError):
|
|
371
|
+
default_code = "outcome_unknown"
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
message: str,
|
|
376
|
+
*,
|
|
377
|
+
received_token: str | Secret | None = None,
|
|
378
|
+
**kwargs: Any, # noqa: ANN401 # forwarded to HushscriptError, which types them
|
|
379
|
+
) -> None:
|
|
380
|
+
super().__init__(message, **kwargs)
|
|
381
|
+
text = received_token.reveal() if isinstance(received_token, Secret) else received_token
|
|
382
|
+
self._received = Secret(text if text and text.strip() else "")
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def received_token(self) -> str | None:
|
|
386
|
+
"""The token a claim or rotation answer carried when the rest of it could not be used, so
|
|
387
|
+
the credential the server shows once is not lost; None when there is none. It is never part
|
|
388
|
+
of the message, the repr, a pickle or a log line; treat it like any token."""
|
|
389
|
+
return self._received.reveal() or None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
_CLASS_BY_SLUG: Final[dict[str, type[HushscriptError]]] = {
|
|
393
|
+
"upload_incomplete": UploadError,
|
|
394
|
+
"size_exceeded": UploadError,
|
|
395
|
+
"agent_claim_invalid": AuthenticationError,
|
|
396
|
+
"unauthorized": AuthenticationError,
|
|
397
|
+
"pat_scope_missing": PermissionDeniedError,
|
|
398
|
+
"pat_forbidden": PermissionDeniedError,
|
|
399
|
+
"account_suspended": PermissionDeniedError,
|
|
400
|
+
"agent_pack_locked": PermissionDeniedError,
|
|
401
|
+
"pat_purchase_requires_app": PermissionDeniedError,
|
|
402
|
+
"pat_purchase_requires_authentication": PermissionDeniedError,
|
|
403
|
+
"country_restricted": PermissionDeniedError,
|
|
404
|
+
"signup_not_allowed": PermissionDeniedError,
|
|
405
|
+
"insufficient_balance": InsufficientBalanceError,
|
|
406
|
+
"org_insufficient_balance": InsufficientBalanceError,
|
|
407
|
+
"org_pool_expired": InsufficientBalanceError,
|
|
408
|
+
"org_plan_frozen": InsufficientBalanceError,
|
|
409
|
+
"wrong_state": ConflictError,
|
|
410
|
+
"agent_signup_pending": ConflictError,
|
|
411
|
+
"pat_limit": ConflictError,
|
|
412
|
+
"rate_limited": RateLimitError,
|
|
413
|
+
"too_many_active_jobs": RateLimitError,
|
|
414
|
+
"agent_purchase_capped": RateLimitError,
|
|
415
|
+
"url_target_busy": RateLimitError,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
_CLASS_BY_STATUS: Final[dict[int, type[HushscriptError]]] = {
|
|
419
|
+
400: ValidationError,
|
|
420
|
+
401: AuthenticationError,
|
|
421
|
+
402: InsufficientBalanceError,
|
|
422
|
+
403: PermissionDeniedError,
|
|
423
|
+
404: NotFoundError,
|
|
424
|
+
409: ConflictError,
|
|
425
|
+
422: ValidationError,
|
|
426
|
+
429: RateLimitError,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_OPERATION_PATTERNS: Final = [
|
|
430
|
+
(re.compile("^" + _TEMPLATE_PARAMETER.sub("[^/]+", re.escape(key.split(" ", 1)[1])) + "$"), key)
|
|
431
|
+
for key in OPERATION_SCOPES
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def operation_key(method: str, path: str) -> str | None:
|
|
436
|
+
"""Maps a concrete request (`GET`, `/v1/jobs/abc`) to its OPERATION_SCOPES key."""
|
|
437
|
+
prefix = f"{method.upper()} "
|
|
438
|
+
for pattern, key in _OPERATION_PATTERNS:
|
|
439
|
+
if key.startswith(prefix) and pattern.match(path):
|
|
440
|
+
return key
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
445
|
+
if value is None:
|
|
446
|
+
return None
|
|
447
|
+
text = value.strip()
|
|
448
|
+
if not text.isascii() or not text.isdigit():
|
|
449
|
+
return None
|
|
450
|
+
digits = text.lstrip("0") or "0"
|
|
451
|
+
if len(digits) > _MAX_RETRY_AFTER_DIGITS:
|
|
452
|
+
return RETRY_AFTER_CAP
|
|
453
|
+
return float(min(int(digits), RETRY_AFTER_CAP))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _extra_details(body: Mapping[str, object]) -> dict[str, object]:
|
|
457
|
+
details: dict[str, object] = {
|
|
458
|
+
key: value for key, value in body.items() if key not in {"error", "code", "details"}
|
|
459
|
+
}
|
|
460
|
+
nested = body.get("details")
|
|
461
|
+
nested_details = as_dict(nested)
|
|
462
|
+
if nested_details is not None:
|
|
463
|
+
for key, value in nested_details.items():
|
|
464
|
+
details.setdefault(key, value)
|
|
465
|
+
elif nested is not None:
|
|
466
|
+
details["details"] = nested
|
|
467
|
+
return details
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _operation_of(response: httpx.Response) -> str | None:
|
|
471
|
+
try:
|
|
472
|
+
request = response.request
|
|
473
|
+
except RuntimeError: # the response was built without a request
|
|
474
|
+
return None
|
|
475
|
+
return operation_key(request.method, request.url.path)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def forget_body(response: httpx.Response) -> None:
|
|
479
|
+
response.stream = httpx.ByteStream(b"")
|
|
480
|
+
response._content = b"" # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _scrub_queries(value: object) -> object:
|
|
484
|
+
if isinstance(value, str):
|
|
485
|
+
return redact_queries(value)
|
|
486
|
+
fields = as_dict(value)
|
|
487
|
+
if fields is not None:
|
|
488
|
+
return {redact_queries(key): _scrub_queries(item) for key, item in fields.items()}
|
|
489
|
+
items = as_list(value)
|
|
490
|
+
if items is not None:
|
|
491
|
+
return [_scrub_queries(item) for item in items]
|
|
492
|
+
return value
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _parse_body(response: httpx.Response, credentials: Credentials) -> dict[str, object]:
|
|
496
|
+
try:
|
|
497
|
+
parsed: object = _scrub_queries(credentials.scrub_value(loads(response.content)))
|
|
498
|
+
except (ValueError, RecursionError): # not JSON, or nested past what the parser reads
|
|
499
|
+
return {}
|
|
500
|
+
finally:
|
|
501
|
+
forget_body(response)
|
|
502
|
+
body = as_dict(parsed) or {}
|
|
503
|
+
# Only sanitized bytes remain reachable through the response.
|
|
504
|
+
response._content = json.dumps(body).encode() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
505
|
+
response.stream = httpx.ByteStream(response.content)
|
|
506
|
+
return body
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _details_of(body: Mapping[str, object], credentials: Credentials) -> dict[str, object]:
|
|
510
|
+
try:
|
|
511
|
+
return as_dict(_snake_keys(credentials.scrub_value(_extra_details(body)))) or {}
|
|
512
|
+
except RecursionError: # a body nested past what the walkers read carries no details
|
|
513
|
+
return {}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def error_from_response(
|
|
517
|
+
response: httpx.Response,
|
|
518
|
+
*,
|
|
519
|
+
credentials: Credentials | None = None,
|
|
520
|
+
operation: str | None = None,
|
|
521
|
+
) -> HushscriptError:
|
|
522
|
+
"""Builds the spec 9.2 error for a non-2xx API response."""
|
|
523
|
+
credentials = credentials or Credentials()
|
|
524
|
+
status = response.status_code
|
|
525
|
+
body = _parse_body(response, credentials)
|
|
526
|
+
raw_slug = body.get("error")
|
|
527
|
+
slug = credentials.scrub(raw_slug) if isinstance(raw_slug, str) else raw_slug
|
|
528
|
+
code_value = body.get("code")
|
|
529
|
+
numeric_code = (
|
|
530
|
+
code_value if isinstance(code_value, int) and not isinstance(code_value, bool) else None
|
|
531
|
+
)
|
|
532
|
+
code = slug if isinstance(slug, str) and slug else f"http_{status}"
|
|
533
|
+
entry = ERROR_CATALOG.get(code)
|
|
534
|
+
message = entry.meaning if entry is not None else f"The API answered {status} ({code})."
|
|
535
|
+
if operation is None:
|
|
536
|
+
operation = _operation_of(response)
|
|
537
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
538
|
+
details = _details_of(body, credentials)
|
|
539
|
+
error_class = _CLASS_BY_SLUG.get(code) or _CLASS_BY_STATUS.get(status) or ApiError
|
|
540
|
+
error = error_class(
|
|
541
|
+
credentials.scrub(message),
|
|
542
|
+
code=code,
|
|
543
|
+
numeric_code=numeric_code,
|
|
544
|
+
status=status,
|
|
545
|
+
details=details,
|
|
546
|
+
retry_after=retry_after,
|
|
547
|
+
scope=OPERATION_SCOPES.get(operation) if operation else None,
|
|
548
|
+
)
|
|
549
|
+
if code == "wrong_state" and not _JOB_OR_UPLOAD.match(operation or ""):
|
|
550
|
+
# The hint names a job call, so only job and upload operations carry it (spec 9.3).
|
|
551
|
+
error.hint = None
|
|
552
|
+
return error
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def raise_for_response(
|
|
556
|
+
response: httpx.Response,
|
|
557
|
+
*,
|
|
558
|
+
credentials: Credentials | None = None,
|
|
559
|
+
operation: str | None = None,
|
|
560
|
+
) -> None:
|
|
561
|
+
if response.is_success:
|
|
562
|
+
return
|
|
563
|
+
raise error_from_response(response, credentials=credentials, operation=operation)
|
hushscript/_guard.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`hs.api` keeps one promise: nothing but an SDK error leaves it (R61).
|
|
2
|
+
|
|
3
|
+
The generated functions parse the answer after the transport has returned it, reading its JSON and
|
|
4
|
+
building a model from it, and a body that is not what they expect raises a bare `JSONDecodeError`,
|
|
5
|
+
`KeyError` or `TypeError`. The transport cannot see a call is generated, but the generated function
|
|
6
|
+
is on the stack that called it, so it runs that function's own parser on the answer before handing
|
|
7
|
+
it back and turns a failure into an SDK error. Generated code is never edited (spec section 5).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from typing import TYPE_CHECKING, Final
|
|
15
|
+
|
|
16
|
+
from ._errors import HushscriptError, error_from_response, redacted_cause
|
|
17
|
+
from ._response import unreadable_response
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ._redact import Credentials
|
|
25
|
+
|
|
26
|
+
GENERATED_MODULES: Final = "hushscript.generated.api."
|
|
27
|
+
_LENIENT_CLIENT: Final = SimpleNamespace(raise_on_unexpected_status=False)
|
|
28
|
+
"""Stands in for the client: an undocumented status is the generated function's own business."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generated_parser() -> Callable[..., object] | None:
|
|
32
|
+
"""The `_parse_response` of the generated operation that is making the current request, or
|
|
33
|
+
None when the request is not one."""
|
|
34
|
+
frame = inspect.currentframe()
|
|
35
|
+
try:
|
|
36
|
+
while frame is not None:
|
|
37
|
+
name = frame.f_globals.get("__name__")
|
|
38
|
+
if isinstance(name, str) and name.startswith(GENERATED_MODULES):
|
|
39
|
+
parse = frame.f_globals.get("_parse_response")
|
|
40
|
+
return parse if callable(parse) else None
|
|
41
|
+
frame = frame.f_back
|
|
42
|
+
return None
|
|
43
|
+
finally:
|
|
44
|
+
del frame
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def unusable_answer(
|
|
48
|
+
parse: Callable[..., object], response: httpx.Response, credentials: Credentials | None
|
|
49
|
+
) -> HushscriptError | None:
|
|
50
|
+
"""The SDK error for an answer the generated parser cannot read, or None when it can. A 2xx is
|
|
51
|
+
`bad_response`; any other status is the error the friendly layer would raise for it. The error
|
|
52
|
+
is returned, not raised, so the original is on neither its cause nor its `__context__`."""
|
|
53
|
+
try:
|
|
54
|
+
parse(client=_LENIENT_CLIENT, response=response)
|
|
55
|
+
except Exception as failure: # noqa: BLE001 # whatever the generated parser trips on
|
|
56
|
+
if response.is_success:
|
|
57
|
+
return unreadable_response(response, failure, credentials)
|
|
58
|
+
error = error_from_response(response, credentials=credentials)
|
|
59
|
+
error.__cause__ = redacted_cause(failure, credentials)
|
|
60
|
+
return error
|
|
61
|
+
return None
|