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/_http.py ADDED
@@ -0,0 +1,624 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Final, cast
7
+
8
+ import async_timeout
9
+ import httpx
10
+
11
+ from ._config import PART_TIMEOUT
12
+ from ._errors import (
13
+ HushscriptError,
14
+ NetworkError,
15
+ OutcomeUnknownError,
16
+ add_note,
17
+ describe_failure,
18
+ mark_unsent,
19
+ parse_retry_after,
20
+ redacted_cause,
21
+ unexpected_error,
22
+ )
23
+ from ._guard import generated_parser, unusable_answer
24
+ from ._json import as_dict, loads
25
+ from ._retry import (
26
+ CLAIM,
27
+ ROTATE,
28
+ Decision,
29
+ Outcome,
30
+ RetryClass,
31
+ classify,
32
+ create_limit_kind,
33
+ decide,
34
+ is_not_sent,
35
+ unrecoverable_route,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator
40
+
41
+ from ._clock import Clock
42
+ from ._log import RequestLog
43
+ from ._pacing import TokenBucket
44
+ from ._redact import Credentials
45
+
46
+ LABEL_EXTENSION: Final = "hushscript_label"
47
+ CLASS_EXTENSION: Final = "hushscript_class"
48
+ ANONYMOUS_EXTENSION: Final = "hushscript_anonymous"
49
+ # Set on a part PUT: the transport still guards the attempt but leaves the response body unread,
50
+ # so the caller decides whether to read it (a large download must not be buffered whole).
51
+ STREAM_EXTENSION: Final = "hushscript_stream"
52
+ IDEMPOTENCY_FIELD: Final = "idempotency_key"
53
+ PURCHASE_PATH: Final = "/v1/billing/purchase"
54
+
55
+ _PHASES: Final = ("connect", "read", "write", "pool")
56
+ # Classes whose decision needs no response body, so the body may be left for the caller.
57
+ _STREAMABLE: Final = frozenset({RetryClass.SAFE, RetryClass.PART})
58
+
59
+ # Spec 7.4. A lost signup has no hint in v0.1.
60
+ UNKNOWN_HINTS: Final = {
61
+ CLAIM: (
62
+ "The claim may have succeeded. Retrying will fail with agent_claim_invalid. "
63
+ "If you have no token, contact support with the signup id."
64
+ ),
65
+ ROTATE: (
66
+ "The old token may already be revoked. Try a request with it; if it fails, "
67
+ "the new token was lost and the account needs support."
68
+ ),
69
+ }
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class TransportSettings:
74
+ clock: Clock
75
+ log: RequestLog
76
+ credentials: Credentials | None
77
+ max_retries: int
78
+ base_url: httpx.URL
79
+ timeout: float
80
+ rng: random.Random = field(default_factory=random.SystemRandom)
81
+ bucket: TokenBucket | None = None
82
+ """Paces `POST /v1/uploads` for the client (spec 7.5), keyed or not."""
83
+
84
+ @property
85
+ def ceiling(self) -> float:
86
+ """The longest deadline any request can ask for: a per-request timeout above the
87
+ configured one (an upload part's, `hs.api.with_timeout`) is honored up to this bound."""
88
+ return max(self.timeout, PART_TIMEOUT)
89
+
90
+
91
+ def _same_origin(url: httpx.URL, other: httpx.URL) -> bool:
92
+ return (url.scheme, url.host, url.port) == (other.scheme, other.host, other.port)
93
+
94
+
95
+ def _label(request: httpx.Request) -> str:
96
+ label = request.extensions.get(LABEL_EXTENSION)
97
+ return label if isinstance(label, str) else request.url.path
98
+
99
+
100
+ def _idempotency_key(request: httpx.Request) -> str | None:
101
+ try:
102
+ parsed: object = loads(request.content)
103
+ except (ValueError, RecursionError, httpx.RequestNotRead):
104
+ return None
105
+ key = (as_dict(parsed) or {}).get(IDEMPOTENCY_FIELD)
106
+ return key if isinstance(key, str) and key else None
107
+
108
+
109
+ def _slug_of(body: bytes) -> str | None:
110
+ try:
111
+ parsed: object = loads(body)
112
+ except (ValueError, RecursionError):
113
+ return None
114
+ slug = (as_dict(parsed) or {}).get("error")
115
+ return slug if isinstance(slug, str) else None
116
+
117
+
118
+ def release(request: httpx.Request) -> None:
119
+ """Lets go of the part's bytes. httpx ties a response to its request in a reference cycle that
120
+ only the garbage collector clears, which would keep every finished part alive for a while."""
121
+ request.stream = httpx.ByteStream(b"")
122
+ request._content = b"" # noqa: SLF001 # pyright: ignore[reportPrivateUsage] # httpx cached the body
123
+
124
+
125
+ def sanitize(request: httpx.Request) -> None:
126
+ """Strips what is secret from a request that is done with (R91): the bearer header, the
127
+ query (a signed URL's signature) and the body (a claim secret). httpx keeps the request on
128
+ the response and on its errors, so it is changed in place; method, host and path stay."""
129
+ request.headers.pop("Authorization", None)
130
+ request.url = request.url.copy_with(query=None)
131
+ release(request)
132
+
133
+
134
+ class _WrittenBody(httpx.SyncByteStream, httpx.AsyncByteStream):
135
+ """Passes a request body through and notes when the last chunk has been handed to the socket."""
136
+
137
+ def __init__(self, inner: httpx.SyncByteStream | httpx.AsyncByteStream) -> None:
138
+ self._inner = inner
139
+ self.done = False
140
+
141
+ def __iter__(self) -> Iterator[bytes]:
142
+ yield from cast("Iterable[bytes]", self._inner)
143
+ self.done = True
144
+
145
+ async def __aiter__(self) -> AsyncIterator[bytes]:
146
+ async for chunk in cast("AsyncIterable[bytes]", self._inner):
147
+ yield chunk
148
+ self.done = True
149
+
150
+ def close(self) -> None:
151
+ cast("httpx.SyncByteStream", self._inner).close()
152
+
153
+ async def aclose(self) -> None:
154
+ await cast("httpx.AsyncByteStream", self._inner).aclose()
155
+
156
+
157
+ class _DeadlineBody(httpx.SyncByteStream, httpx.AsyncByteStream):
158
+ """Reads a response body chunk by chunk, holding every chunk to the attempt's deadline."""
159
+
160
+ def __init__(
161
+ self, inner: httpx.SyncByteStream | httpx.AsyncByteStream, attempt: _Attempt
162
+ ) -> None:
163
+ self._inner = inner
164
+ self._attempt = attempt
165
+
166
+ def __iter__(self) -> Iterator[bytes]:
167
+ try:
168
+ chunks = iter(cast("Iterable[bytes]", self._inner))
169
+ while True:
170
+ self._attempt.tighten()
171
+ try:
172
+ chunk = next(chunks)
173
+ except StopIteration:
174
+ return
175
+ yield chunk
176
+ finally:
177
+ self._attempt.body_ended()
178
+
179
+ async def __aiter__(self) -> AsyncIterator[bytes]:
180
+ try:
181
+ chunks = aiter(cast("AsyncIterable[bytes]", self._inner))
182
+ while True:
183
+ self._attempt.tighten()
184
+ try:
185
+ chunk = await anext(chunks)
186
+ except StopAsyncIteration:
187
+ return
188
+ yield chunk
189
+ finally:
190
+ self._attempt.body_ended()
191
+
192
+ def close(self) -> None:
193
+ try:
194
+ cast("httpx.SyncByteStream", self._inner).close()
195
+ finally:
196
+ self._let_go()
197
+
198
+ async def aclose(self) -> None:
199
+ try:
200
+ await cast("httpx.AsyncByteStream", self._inner).aclose()
201
+ finally:
202
+ self._let_go()
203
+
204
+ def _let_go(self) -> None:
205
+ """A closed body is done with: the transport's stream underneath keeps its own copy of
206
+ the request (bearer header, signed URL), so it is dropped."""
207
+ self._inner = httpx.ByteStream(b"")
208
+ self._attempt.body_ended()
209
+
210
+
211
+ class _Attempt:
212
+ """The bookkeeping shared by the sync and async transports."""
213
+
214
+ def __init__(self, request: httpx.Request, settings: TransportSettings) -> None:
215
+ self._request: httpx.Request | None = request
216
+ self.settings = settings
217
+ on_base = _same_origin(request.url, settings.base_url)
218
+ # Spec 7.4: the class comes from the method and the path relative to the base URL. A
219
+ # request to any other host (a signed R2 URL) matches no API route; only it may carry a
220
+ # class of its own.
221
+ path = request.url.path.removeprefix(settings.base_url.path.rstrip("/")) if on_base else ""
222
+ self.on_base = on_base
223
+ self.route = unrecoverable_route(request.method, path)
224
+ override = None if on_base else request.extensions.get(CLASS_EXTENSION)
225
+ self.retry_class = (
226
+ override if isinstance(override, RetryClass) else classify(request.method, path)
227
+ )
228
+ # R61: every error after a purchase went out carries its key, whoever made the request.
229
+ # Only that request's body is read for it: a part's body is megabytes of media.
230
+ self.purchase_key = _idempotency_key(request) if on_base and path == PURCHASE_PATH else None
231
+ if self.retry_class is RetryClass.CREATE and _idempotency_key(request) is None:
232
+ # Only a keyed create is a safe replay; without a key it is any other mutation.
233
+ self.retry_class = RetryClass.MUTATE
234
+ self.stream = (
235
+ bool(request.extensions.get(STREAM_EXTENSION)) and self.retry_class in _STREAMABLE
236
+ )
237
+ self.label = _label(request)
238
+ self.number = 0
239
+ self.limited: dict[str, int] = {}
240
+ self.started = settings.clock.now()
241
+ self.budget = settings.timeout
242
+ self.deadline = self.started + self.budget
243
+ self.body: _WrittenBody | None = None
244
+ self.answered = False
245
+ self.maybe_sent = False
246
+ """Some attempt may have reached the server: an answer came, a failure was not one of
247
+ those raised before anything is written, or the SDK cannot tell (R103)."""
248
+ self.handed_out = False
249
+ """The response went to the caller with its body unread; reading or closing that body
250
+ ends the call."""
251
+ self.phases: dict[str, float | None] = {}
252
+ self._track_body()
253
+
254
+ @property
255
+ def request(self) -> httpx.Request:
256
+ """The request carries the bearer token, a claim secret in its body and, for a part, the
257
+ signed URL as plain text, so it is stripped and let go once the call is over (R91); a
258
+ streamed body still being read keeps the attempt open."""
259
+ if self._request is None:
260
+ raise RuntimeError("the attempt is over")
261
+ return self._request
262
+
263
+ def finish(self) -> None:
264
+ """Ends the call; a second call does nothing."""
265
+ request, self._request = self._request, None
266
+ self.body = None
267
+ if request is not None:
268
+ sanitize(request)
269
+
270
+ def body_ended(self) -> None:
271
+ """A response body was read to its end or closed. That ends the call only for the
272
+ response handed out; one closed before a retry leaves the attempt going."""
273
+ if self.handed_out:
274
+ self.finish()
275
+
276
+ def _authorize(self) -> None:
277
+ credentials = self.settings.credentials
278
+ token = None if credentials is None else credentials.token
279
+ if not self.on_base or self.request.extensions.get(ANONYMOUS_EXTENSION):
280
+ return
281
+ if token is not None:
282
+ self.request.headers["Authorization"] = f"Bearer {token}"
283
+ else:
284
+ # A token that was unset since an earlier attempt must not ride along on this one.
285
+ self.request.headers.pop("Authorization", None)
286
+
287
+ def _track_body(self) -> None:
288
+ if self.retry_class is RetryClass.NEVER:
289
+ self.body = _WrittenBody(self.request.stream)
290
+ self.request.stream = self.body
291
+ configured = self.request.extensions.get("timeout")
292
+ if isinstance(configured, dict):
293
+ known = cast("dict[str, float | None]", configured)
294
+ self.phases = {phase: known.get(phase) for phase in _PHASES}
295
+ asked = [limit for limit in self.phases.values() if limit is not None]
296
+ if asked:
297
+ self.budget = min(self.settings.ceiling, max(self.settings.timeout, *asked))
298
+
299
+ def begin(self) -> None:
300
+ # The token is read per attempt, after any pacing or backoff sleep, so a rotation applies
301
+ # to a request that was queued behind it (spec 7.4, R44).
302
+ self._authorize()
303
+ self.started = self.settings.clock.now()
304
+ self.deadline = self.started + self.budget
305
+ self.answered = False
306
+ if self.body is not None:
307
+ self.body.done = False
308
+ self.tighten()
309
+
310
+ def pace(self) -> float:
311
+ """Seconds to wait before an upload create may go out (spec 7.5); every attempt takes a
312
+ token, whatever its retry class, a retry included."""
313
+ bucket = self.settings.bucket
314
+ if bucket is None or not self.on_base or self.request.method != "POST":
315
+ return 0.0
316
+ if (
317
+ self.request.url.path.removeprefix(self.settings.base_url.path.rstrip("/"))
318
+ != "/v1/uploads"
319
+ ):
320
+ return 0.0
321
+ return bucket.reserve()
322
+
323
+ def remaining(self) -> float:
324
+ return self.deadline - self.settings.clock.now()
325
+
326
+ def tighten(self) -> None:
327
+ """Holds the attempt to one overall deadline of `budget` seconds (spec 3 and 7.1).
328
+
329
+ httpx's own timeout is per phase. Before the request and before every body chunk the
330
+ remaining time is computed and the phase timeouts are cut to it; with none left the attempt
331
+ fails like any other timeout.
332
+ """
333
+ remaining = self.remaining()
334
+ if remaining <= 0:
335
+ raise httpx.ReadTimeout(
336
+ f"the request took longer than {self.budget:g} s", request=self.request
337
+ )
338
+ if self.phases:
339
+ self.request.extensions["timeout"] = {
340
+ phase: remaining if limit is None else min(limit, remaining)
341
+ for phase, limit in self.phases.items()
342
+ }
343
+
344
+ def read(self, response: httpx.Response) -> None:
345
+ response.stream = _DeadlineBody(response.stream, self)
346
+
347
+ def stamp(self, error: HushscriptError) -> None:
348
+ """R61 and R103: the purchase key rides on every error unless no attempt can have been
349
+ sent, when nothing can have been charged."""
350
+ if self.purchase_key is not None and self.maybe_sent and error.idempotency_key is None:
351
+ error.idempotency_key = self.purchase_key
352
+
353
+ def unusable(self, response: httpx.Response) -> HushscriptError | None:
354
+ """For a call made through a generated function (`hs.api`): the error for an answer its
355
+ parser cannot read, which would otherwise leave as a bare `JSONDecodeError`, `KeyError` or
356
+ `TypeError` (R61)."""
357
+ parse = None if self.stream else generated_parser()
358
+ if parse is None:
359
+ return None
360
+ return unusable_answer(parse, response, self.settings.credentials)
361
+
362
+ def sent(self) -> bool:
363
+ """The body was fully written, or an answer came, which a transport that never reads the
364
+ body proves just as well."""
365
+ return self.answered or (self.body is not None and self.body.done)
366
+
367
+ def judge(self, outcome: Outcome) -> Decision:
368
+ kind = create_limit_kind(self.retry_class, outcome)
369
+ decision = decide(
370
+ self.retry_class,
371
+ self.number,
372
+ self.settings.max_retries,
373
+ outcome,
374
+ self.settings.rng,
375
+ own_429=None if kind is None else self.limited.get(kind, 0),
376
+ )
377
+ if kind is not None and decision.retry:
378
+ self.limited[kind] = self.limited.get(kind, 0) + 1
379
+ elapsed = (self.settings.clock.now() - self.started) * 1000
380
+ self.settings.log.attempt(
381
+ self.request.method,
382
+ self.label,
383
+ outcome.status,
384
+ self.number + 1,
385
+ elapsed,
386
+ decision.delay if decision.retry else None,
387
+ )
388
+ self.number += 1
389
+ return decision
390
+
391
+ def outcome_of(self, response: httpx.Response) -> Outcome:
392
+ slug = None
393
+ if create_limit_kind(self.retry_class, Outcome(status=response.status_code)) is not None:
394
+ slug = _slug_of(response.content)
395
+ return Outcome(
396
+ status=response.status_code,
397
+ retry_after=parse_retry_after(response.headers.get("retry-after")),
398
+ slug=slug,
399
+ )
400
+
401
+ def failure_error(self, failure: httpx.RequestError) -> NetworkError | OutcomeUnknownError:
402
+ """The error for a transport failure. Its cause is a stand-in: the httpx error keeps the
403
+ request it sent, bearer header and signed URL included (R86, R91)."""
404
+ reason = describe_failure(failure, self.settings.credentials)
405
+ where = f"{self.request.method} {self.label}"
406
+ error: NetworkError | OutcomeUnknownError
407
+ if self.retry_class is RetryClass.NEVER and not is_not_sent(failure):
408
+ error = OutcomeUnknownError(
409
+ f"No response arrived for {where} ({reason}).",
410
+ code="outcome_unknown",
411
+ hint=UNKNOWN_HINTS.get(self.route or ""),
412
+ )
413
+ elif self.purchase_key is not None and not self.maybe_sent:
414
+ error = NetworkError(
415
+ "Could not reach the Hushscript API before the request was sent: "
416
+ f"{where} ({reason})."
417
+ )
418
+ mark_unsent(error)
419
+ else:
420
+ error = NetworkError(f"Could not reach the Hushscript API: {where} ({reason}).")
421
+ error.__cause__ = redacted_cause(failure, self.settings.credentials)
422
+ return error
423
+
424
+ def unexpected(self, failure: Exception) -> HushscriptError:
425
+ """Spec 9.2 and R61: a failure of the SDK's own. A claim, rotation or signup answer lost
426
+ this way after the request went out is as lost as one that never came (spec 7.4); one
427
+ before it was sent lost nothing. Whether it was sent is known once an answer came, or,
428
+ for a request whose body is tracked, from that body (R100)."""
429
+ sent: bool | None = True if self.sent() else (None if self.body is None else False)
430
+ self.maybe_sent = self.maybe_sent or sent is not False
431
+ error = unexpected_error(failure, self.settings.credentials, sent=sent)
432
+ if self.retry_class is not RetryClass.NEVER or not self.sent():
433
+ return error
434
+ unknown = OutcomeUnknownError(
435
+ f"The answer to {self.request.method} {self.label} could not be used "
436
+ f"({describe_failure(failure, self.settings.credentials)}).",
437
+ code="outcome_unknown",
438
+ hint=UNKNOWN_HINTS.get(self.route or ""),
439
+ )
440
+ unknown.__cause__ = error.__cause__
441
+ return unknown
442
+
443
+ def note_cancellation(self, cancelled: asyncio.CancelledError) -> None:
444
+ """Spec 7.4: a cancel after a claim, rotate or signup body was written leaves the outcome
445
+ unknown. The cancellation itself must reach asyncio untouched (timeouts and task groups
446
+ depend on it), so the outcome rides along as a note."""
447
+ if not self.sent():
448
+ return
449
+ hint = UNKNOWN_HINTS.get(self.route or "")
450
+ text = (
451
+ f"Cancelled after {self.request.method} {self.label} was sent: its outcome is unknown."
452
+ )
453
+ add_note(cancelled, f"{text} {hint}" if hint else text)
454
+
455
+
456
+ class RetryingTransport(httpx.BaseTransport):
457
+ def __init__(self, inner: httpx.BaseTransport, settings: TransportSettings) -> None:
458
+ self._inner = inner
459
+ self._settings = settings
460
+
461
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
462
+ attempt = _Attempt(request, self._settings)
463
+ del request
464
+ try:
465
+ response = self._run(attempt)
466
+ except BaseException as error:
467
+ if isinstance(error, HushscriptError):
468
+ attempt.stamp(error)
469
+ if attempt.retry_class is RetryClass.PART:
470
+ release(attempt.request)
471
+ attempt.finish()
472
+ raise
473
+ if attempt.stream and not response.is_closed:
474
+ attempt.handed_out = True # its body's end or close finishes the attempt
475
+ else:
476
+ attempt.finish()
477
+ return response
478
+
479
+ def _run(self, attempt: _Attempt) -> httpx.Response:
480
+ while True:
481
+ if (paced := attempt.pace()) > 0:
482
+ self._settings.clock.sleep(paced)
483
+ attempt.begin()
484
+ decision, response = self._once(attempt)
485
+ if response is not None:
486
+ if not decision.retry:
487
+ if (unusable := attempt.unusable(response)) is not None:
488
+ response.close()
489
+ raise unusable
490
+ return response
491
+ response.close()
492
+ self._settings.clock.sleep(decision.delay)
493
+
494
+ def _once(self, attempt: _Attempt) -> tuple[Decision, httpx.Response | None]:
495
+ """One attempt: what to do next and the response, if one came. Nothing but an SDK error
496
+ leaves it (R61)."""
497
+ failed: HushscriptError
498
+ try:
499
+ try:
500
+ response = self._send(attempt)
501
+ except httpx.RequestError as failure:
502
+ attempt.maybe_sent = attempt.maybe_sent or not is_not_sent(failure)
503
+ decision = attempt.judge(Outcome(failure=failure))
504
+ if decision.retry:
505
+ return decision, None
506
+ # Raised once the handlers have ended, so the httpx error is not its context.
507
+ failed = attempt.failure_error(failure)
508
+ else:
509
+ try:
510
+ return attempt.judge(attempt.outcome_of(response)), response
511
+ except BaseException:
512
+ response.close()
513
+ raise
514
+ except HushscriptError:
515
+ raise
516
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
517
+ failed = attempt.unexpected(failure)
518
+ raise failed
519
+
520
+ def _send(self, attempt: _Attempt) -> httpx.Response:
521
+ response = self._inner.handle_request(attempt.request)
522
+ attempt.answered = attempt.maybe_sent = True
523
+ try:
524
+ attempt.read(response)
525
+ if not attempt.stream:
526
+ response.read()
527
+ except BaseException:
528
+ response.close()
529
+ raise
530
+ return response
531
+
532
+ def close(self) -> None:
533
+ self._inner.close()
534
+
535
+
536
+ class AsyncRetryingTransport(httpx.AsyncBaseTransport):
537
+ def __init__(self, inner: httpx.AsyncBaseTransport, settings: TransportSettings) -> None:
538
+ self._inner = inner
539
+ self._settings = settings
540
+
541
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
542
+ attempt = _Attempt(request, self._settings)
543
+ del request
544
+ try:
545
+ response = await self._run(attempt)
546
+ except BaseException as error:
547
+ if isinstance(error, HushscriptError):
548
+ attempt.stamp(error)
549
+ if attempt.retry_class is RetryClass.PART:
550
+ release(attempt.request)
551
+ attempt.finish()
552
+ raise
553
+ if attempt.stream and not response.is_closed:
554
+ attempt.handed_out = True # its body's end or close finishes the attempt
555
+ else:
556
+ attempt.finish()
557
+ return response
558
+
559
+ async def _run(self, attempt: _Attempt) -> httpx.Response:
560
+ while True:
561
+ if (paced := attempt.pace()) > 0:
562
+ await self._settings.clock.asleep(paced)
563
+ attempt.begin()
564
+ decision, response = await self._once(attempt)
565
+ if response is not None:
566
+ if not decision.retry:
567
+ if (unusable := attempt.unusable(response)) is not None:
568
+ await response.aclose()
569
+ raise unusable
570
+ return response
571
+ await response.aclose()
572
+ await self._settings.clock.asleep(decision.delay)
573
+
574
+ async def _once(self, attempt: _Attempt) -> tuple[Decision, httpx.Response | None]:
575
+ """One attempt: what to do next and the response, if one came. Nothing but an SDK error
576
+ leaves it (R61); a cancellation passes through, with the note a lost secret needs."""
577
+ failed: HushscriptError
578
+ try:
579
+ try:
580
+ response = await self._send(attempt)
581
+ except httpx.RequestError as failure:
582
+ attempt.maybe_sent = attempt.maybe_sent or not is_not_sent(failure)
583
+ decision = attempt.judge(Outcome(failure=failure))
584
+ if decision.retry:
585
+ return decision, None
586
+ # Raised once the handlers have ended, so the httpx error is not its context.
587
+ failed = attempt.failure_error(failure)
588
+ except asyncio.CancelledError as cancelled:
589
+ attempt.note_cancellation(cancelled)
590
+ raise
591
+ else:
592
+ try:
593
+ return attempt.judge(attempt.outcome_of(response)), response
594
+ except BaseException:
595
+ await response.aclose()
596
+ raise
597
+ except HushscriptError:
598
+ raise
599
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
600
+ failed = attempt.unexpected(failure)
601
+ raise failed
602
+
603
+ async def _send(self, attempt: _Attempt) -> httpx.Response:
604
+ """One attempt, held to its deadline even while blocked on the network. Only the expiry of
605
+ this timeout becomes a failure; a cancellation from the caller passes through."""
606
+ try:
607
+ async with async_timeout.timeout(max(attempt.remaining(), 0.0)):
608
+ response = await self._inner.handle_async_request(attempt.request)
609
+ attempt.answered = attempt.maybe_sent = True
610
+ try:
611
+ attempt.read(response)
612
+ if not attempt.stream:
613
+ await response.aread()
614
+ except BaseException:
615
+ await response.aclose()
616
+ raise
617
+ except asyncio.TimeoutError as expired:
618
+ raise httpx.ReadTimeout(
619
+ f"the request took longer than {attempt.budget:g} s", request=attempt.request
620
+ ) from expired
621
+ return response
622
+
623
+ async def aclose(self) -> None:
624
+ await self._inner.aclose()
hushscript/_json.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from types import MappingProxyType
8
+ from typing import TYPE_CHECKING, Final, cast
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ _BEYOND_RANGE: Final = 2**64
14
+ _MAX_JSON_INT_DIGITS: Final = 4000
15
+
16
+
17
+ def as_dict(value: object) -> dict[str, object] | None:
18
+ """`value` as a JSON object with typed keys, or None (isinstance alone leaves them unknown)."""
19
+ return cast("dict[str, object]", value) if isinstance(value, dict) else None
20
+
21
+
22
+ def as_list(value: object) -> list[object] | None:
23
+ """`value` as a JSON array or tuple, or None."""
24
+ if isinstance(value, (list, tuple)):
25
+ return list(cast("list[object]", value))
26
+ return None
27
+
28
+
29
+ def _bounded_int(text: str) -> int:
30
+ """An integer literal of any length: past what `int()` reads, under this interpreter's own
31
+ limit too, it is beyond every range the SDK accepts, and keeps its sign."""
32
+ limit = sys.get_int_max_str_digits()
33
+ cap = min(_MAX_JSON_INT_DIGITS, limit) if limit else _MAX_JSON_INT_DIGITS
34
+ if len(text) <= cap:
35
+ return int(text)
36
+ return -_BEYOND_RANGE if text.startswith("-") else _BEYOND_RANGE
37
+
38
+
39
+ def loads(data: str | bytes) -> object:
40
+ """`json.loads` for every JSON the SDK reads: an integer literal of any length parses."""
41
+ return json.loads(data, parse_int=_bounded_int)
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ExtraFields:
46
+ _extra: dict[str, object] = field(default_factory=dict, kw_only=True, repr=False, compare=False)
47
+
48
+ @property
49
+ def extra(self) -> Mapping[str, object]:
50
+ """Unknown API fields, with a read-only mapping and independent nested values."""
51
+ return MappingProxyType(copy.deepcopy(self._extra))
52
+
53
+
54
+ def extra_fields(body: Mapping[str, object], *known: str) -> dict[str, object]:
55
+ return copy.deepcopy({key: value for key, value in body.items() if key not in known})