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.
Files changed (187) hide show
  1. hushscript/__init__.py +97 -0
  2. hushscript/_client.py +687 -0
  3. hushscript/_clock.py +36 -0
  4. hushscript/_config.py +139 -0
  5. hushscript/_errors.py +563 -0
  6. hushscript/_guard.py +61 -0
  7. hushscript/_http.py +624 -0
  8. hushscript/_json.py +55 -0
  9. hushscript/_log.py +155 -0
  10. hushscript/_models.py +144 -0
  11. hushscript/_pacing.py +51 -0
  12. hushscript/_redact.py +140 -0
  13. hushscript/_response.py +67 -0
  14. hushscript/_retry.py +205 -0
  15. hushscript/_shape.py +197 -0
  16. hushscript/_shapes.py +248 -0
  17. hushscript/_version.py +1 -0
  18. hushscript/format.py +224 -0
  19. hushscript/generated/__init__.py +8 -0
  20. hushscript/generated/api/__init__.py +1 -0
  21. hushscript/generated/api/default/__init__.py +1 -0
  22. hushscript/generated/api/default/buy_minutes.py +248 -0
  23. hushscript/generated/api/default/claim_agent_account.py +238 -0
  24. hushscript/generated/api/default/complete_upload.py +238 -0
  25. hushscript/generated/api/default/create_agent_account_signup.py +261 -0
  26. hushscript/generated/api/default/create_setup_session.py +244 -0
  27. hushscript/generated/api/default/create_upload.py +225 -0
  28. hushscript/generated/api/default/get_account.py +169 -0
  29. hushscript/generated/api/default/get_job.py +198 -0
  30. hushscript/generated/api/default/get_org.py +169 -0
  31. hushscript/generated/api/default/get_transcript.py +226 -0
  32. hushscript/generated/api/default/get_transcription_context.py +173 -0
  33. hushscript/generated/api/default/get_upload_part_urls.py +246 -0
  34. hushscript/generated/api/default/heartbeat_upload.py +197 -0
  35. hushscript/generated/api/default/lift_transcript_hold.py +206 -0
  36. hushscript/generated/api/default/list_credit_ledger.py +229 -0
  37. hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
  38. hushscript/generated/api/default/list_holds.py +233 -0
  39. hushscript/generated/api/default/list_jobs.py +249 -0
  40. hushscript/generated/api/default/list_org_audit.py +221 -0
  41. hushscript/generated/api/default/list_org_invoices.py +221 -0
  42. hushscript/generated/api/default/list_org_ledger.py +221 -0
  43. hushscript/generated/api/default/list_org_matters.py +247 -0
  44. hushscript/generated/api/default/list_org_members.py +221 -0
  45. hushscript/generated/api/default/list_org_transcripts.py +233 -0
  46. hushscript/generated/api/default/list_packs.py +169 -0
  47. hushscript/generated/api/default/list_saved_cards.py +169 -0
  48. hushscript/generated/api/default/list_transactions.py +225 -0
  49. hushscript/generated/api/default/list_transcripts.py +225 -0
  50. hushscript/generated/api/default/org_audit_csv.py +163 -0
  51. hushscript/generated/api/default/org_ledger_csv.py +163 -0
  52. hushscript/generated/api/default/org_usage_report.py +247 -0
  53. hushscript/generated/api/default/org_usage_report_csv.py +229 -0
  54. hushscript/generated/api/default/patch_org_member.py +237 -0
  55. hushscript/generated/api/default/place_transcript_hold.py +206 -0
  56. hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
  57. hushscript/generated/api/default/schedule_org_custody.py +215 -0
  58. hushscript/generated/api/default/set_default_card.py +223 -0
  59. hushscript/generated/api/default/upload_part.py +249 -0
  60. hushscript/generated/catalog.py +138 -0
  61. hushscript/generated/client.py +282 -0
  62. hushscript/generated/errors.py +16 -0
  63. hushscript/generated/hints.py +28 -0
  64. hushscript/generated/models/__init__.py +193 -0
  65. hushscript/generated/models/account_response.py +114 -0
  66. hushscript/generated/models/active_hold.py +88 -0
  67. hushscript/generated/models/active_hold_kind.py +18 -0
  68. hushscript/generated/models/agent_claim_request.py +61 -0
  69. hushscript/generated/models/agent_claim_response.py +85 -0
  70. hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
  71. hushscript/generated/models/agent_signup_request.py +118 -0
  72. hushscript/generated/models/agent_signup_request_agent.py +80 -0
  73. hushscript/generated/models/agent_signup_response.py +111 -0
  74. hushscript/generated/models/buy_minutes_body.py +89 -0
  75. hushscript/generated/models/card.py +132 -0
  76. hushscript/generated/models/cards_response.py +90 -0
  77. hushscript/generated/models/complete_upload_body.py +107 -0
  78. hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
  79. hushscript/generated/models/context_dictionary.py +143 -0
  80. hushscript/generated/models/context_prompt_preset.py +118 -0
  81. hushscript/generated/models/create_setup_session_body.py +62 -0
  82. hushscript/generated/models/create_upload_body.py +254 -0
  83. hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
  84. hushscript/generated/models/credit_ledger_entry.py +113 -0
  85. hushscript/generated/models/credit_ledger_response.py +90 -0
  86. hushscript/generated/models/deleted_transcript.py +148 -0
  87. hushscript/generated/models/deleted_transcript_cause.py +18 -0
  88. hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
  89. hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
  90. hushscript/generated/models/deleted_transcripts_response.py +90 -0
  91. hushscript/generated/models/dictionary_entry.py +123 -0
  92. hushscript/generated/models/dictionary_entry_category.py +37 -0
  93. hushscript/generated/models/error.py +155 -0
  94. hushscript/generated/models/error_details.py +94 -0
  95. hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
  96. hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
  97. hushscript/generated/models/holds_response.py +90 -0
  98. hushscript/generated/models/job.py +309 -0
  99. hushscript/generated/models/job_failure_kind_type_1.py +16 -0
  100. hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
  101. hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
  102. hushscript/generated/models/job_list.py +90 -0
  103. hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
  104. hushscript/generated/models/list_jobs_active.py +17 -0
  105. hushscript/generated/models/list_org_matters_status.py +16 -0
  106. hushscript/generated/models/options_summary.py +135 -0
  107. hushscript/generated/models/org_audit_entry.py +105 -0
  108. hushscript/generated/models/org_audit_response.py +90 -0
  109. hushscript/generated/models/org_invoice.py +175 -0
  110. hushscript/generated/models/org_invoices_response.py +90 -0
  111. hushscript/generated/models/org_ledger_entry.py +155 -0
  112. hushscript/generated/models/org_ledger_response.py +90 -0
  113. hushscript/generated/models/org_matter.py +79 -0
  114. hushscript/generated/models/org_matter_status.py +16 -0
  115. hushscript/generated/models/org_matters_response.py +75 -0
  116. hushscript/generated/models/org_member.py +123 -0
  117. hushscript/generated/models/org_members_response.py +90 -0
  118. hushscript/generated/models/org_summary_response.py +482 -0
  119. hushscript/generated/models/org_transcript.py +103 -0
  120. hushscript/generated/models/org_transcripts_response.py +90 -0
  121. hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
  122. hushscript/generated/models/org_usage_report_group_by.py +17 -0
  123. hushscript/generated/models/org_usage_response.py +112 -0
  124. hushscript/generated/models/org_usage_response_group_by.py +17 -0
  125. hushscript/generated/models/org_usage_row.py +86 -0
  126. hushscript/generated/models/pack.py +107 -0
  127. hushscript/generated/models/packs_response.py +75 -0
  128. hushscript/generated/models/patch_org_member_body.py +102 -0
  129. hushscript/generated/models/patch_org_member_response_200.py +61 -0
  130. hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
  131. hushscript/generated/models/presigned_part_url.py +69 -0
  132. hushscript/generated/models/purchase_response.py +76 -0
  133. hushscript/generated/models/schedule_org_custody_body.py +66 -0
  134. hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
  135. hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
  136. hushscript/generated/models/set_default_card_body.py +61 -0
  137. hushscript/generated/models/set_default_card_response_200.py +61 -0
  138. hushscript/generated/models/setup_session_response.py +77 -0
  139. hushscript/generated/models/transaction.py +183 -0
  140. hushscript/generated/models/transactions_response.py +98 -0
  141. hushscript/generated/models/transcript_detail.py +194 -0
  142. hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
  143. hushscript/generated/models/transcript_envelope.py +89 -0
  144. hushscript/generated/models/transcript_envelope_translations.py +62 -0
  145. hushscript/generated/models/transcript_language.py +75 -0
  146. hushscript/generated/models/transcript_list.py +90 -0
  147. hushscript/generated/models/transcript_list_row.py +152 -0
  148. hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
  149. hushscript/generated/models/transcription_context_response.py +95 -0
  150. hushscript/generated/models/upload_complete_response.py +100 -0
  151. hushscript/generated/models/upload_create_response.py +148 -0
  152. hushscript/generated/models/upload_create_response_funding.py +16 -0
  153. hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
  154. hushscript/generated/models/upload_limits.py +86 -0
  155. hushscript/generated/models/upload_part_response_200.py +69 -0
  156. hushscript/generated/models/utterance.py +85 -0
  157. hushscript/generated/scopes.py +42 -0
  158. hushscript/generated/types.py +54 -0
  159. hushscript/money.py +79 -0
  160. hushscript/paginate.py +78 -0
  161. hushscript/py.typed +0 -0
  162. hushscript/resources/__init__.py +1 -0
  163. hushscript/resources/_params.py +33 -0
  164. hushscript/resources/account.py +121 -0
  165. hushscript/resources/agent.py +421 -0
  166. hushscript/resources/billing.py +567 -0
  167. hushscript/resources/jobs.py +105 -0
  168. hushscript/resources/transcripts.py +209 -0
  169. hushscript/transcribe/__init__.py +1 -0
  170. hushscript/transcribe/apipeline.py +234 -0
  171. hushscript/transcribe/aupload.py +197 -0
  172. hushscript/transcribe/heartbeat.py +78 -0
  173. hushscript/transcribe/pipeline.py +312 -0
  174. hushscript/transcribe/preflight.py +272 -0
  175. hushscript/transcribe/probe.py +185 -0
  176. hushscript/transcribe/resume.py +115 -0
  177. hushscript/transcribe/segment.py +23 -0
  178. hushscript/transcribe/session.py +34 -0
  179. hushscript/transcribe/sniff.py +30 -0
  180. hushscript/transcribe/source.py +218 -0
  181. hushscript/transcribe/upload.py +498 -0
  182. hushscript/transcribe/wait.py +124 -0
  183. hushscript/transcript.py +197 -0
  184. hushscript-1.0.0.dist-info/METADATA +502 -0
  185. hushscript-1.0.0.dist-info/RECORD +187 -0
  186. hushscript-1.0.0.dist-info/WHEEL +4 -0
  187. 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