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
@@ -0,0 +1,567 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Final, TypeVar, cast
8
+
9
+ from hushscript._errors import (
10
+ HushscriptError,
11
+ PaymentMethodRequiredError,
12
+ add_note,
13
+ is_unsent,
14
+ raise_for_response,
15
+ unexpected_error,
16
+ )
17
+ from hushscript._json import ExtraFields, as_dict, as_list, extra_fields
18
+ from hushscript._response import BadBodyError, parsed
19
+ from hushscript._shape import (
20
+ as_flag,
21
+ as_text,
22
+ as_whole,
23
+ check_shape,
24
+ maybe_text,
25
+ to_datetime,
26
+ )
27
+ from hushscript._shapes import CARDS, HOLDS, LEDGER, PACKS, PURCHASE, SETUP_SESSION, TRANSACTIONS
28
+ from hushscript.money import Money, to_money
29
+ from hushscript.paginate import Page, apaginate, paginate
30
+ from hushscript.resources._params import list_params, path_id
31
+ from hushscript.resources.account import minutes_of
32
+ from hushscript.transcribe.preflight import invalid_option
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
36
+ from datetime import datetime
37
+
38
+ from hushscript._redact import Credentials
39
+ from hushscript.transcribe.session import AsyncSession, SyncSession
40
+
41
+ PURCHASE_KEY: Final = re.compile(r"[A-Za-z0-9_-]{1,128}")
42
+ OK: Final = "ok"
43
+ T = TypeVar("T")
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Pack(ExtraFields):
48
+ """A minute pack as the account can see it."""
49
+
50
+ id: str
51
+ """`45min`, `300min`, `900min`, `1800min` or `6000min`."""
52
+ seconds: int
53
+ minutes: int
54
+ """`seconds / 60`, rounded toward zero."""
55
+ price: Money
56
+ """In the account's own currency, as the API states it: never hard-code one."""
57
+ bonus_minutes: int
58
+ """What an available referral bonus would add to this purchase; 0 when none."""
59
+ available: bool
60
+ """False only for an agent account's tier-locked pack."""
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Card(ExtraFields):
65
+ """A saved payment method."""
66
+
67
+ id: str
68
+ type: str
69
+ brand: str
70
+ last4: str
71
+ exp_month: int
72
+ exp_year: int
73
+ """`exp_month` and `exp_year` are 0 when the method has no expiry."""
74
+ email: str | None
75
+ is_default: bool
76
+ fast_checkout_eligible: bool
77
+ """Whether `buy()` may charge it without the buyer present."""
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class Cards(ExtraFields):
82
+ """The saved payment methods and which one is the default."""
83
+
84
+ default_id: str | None
85
+ cards: list[Card]
86
+
87
+ __hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class Purchase(ExtraFields):
92
+ """What a purchase left the account with, read from the purchase answer alone."""
93
+
94
+ seconds: int
95
+ """The balance after the purchase settled: spendable now, holds already deducted."""
96
+ minutes: int
97
+ """`seconds / 60`, rounded toward zero."""
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class Transaction(ExtraFields):
102
+ """One purchase, as the API reports it."""
103
+
104
+ id: int
105
+ seconds: int
106
+ amount_cents: int
107
+ amount_refunded_cents: int
108
+ currency: str
109
+ status: str
110
+ created_at: datetime
111
+ payment_method_type: str | None
112
+ payment_method_brand: str | None
113
+ payment_method_last4: str | None
114
+ receipt_number: str | None
115
+ """The public receipt reference, when there is one."""
116
+ initiated_via: str | None
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class LedgerEntry(ExtraFields):
121
+ """One movement of the credit balance."""
122
+
123
+ id: int
124
+ delta_seconds: int
125
+ kind: str
126
+ job_id: str | None
127
+ note: str | None
128
+ created_at: datetime
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class Hold(ExtraFields):
133
+ """Seconds reserved by work still running; they are already deducted from the balance."""
134
+
135
+ id: str
136
+ kind: str
137
+ """`transcription`, `translation`, `insights` or `qa`; a kind the SDK does not know yet passes
138
+ through."""
139
+ seconds: int
140
+ created_at: datetime
141
+
142
+
143
+ def pack_list(body: object) -> list[Pack]:
144
+ check_shape(body, PACKS, "pack list response")
145
+ packs: list[Pack] = []
146
+ for row in as_list((as_dict(body) or {})["packs"]) or []:
147
+ item = as_dict(row) or {}
148
+ seconds = as_whole(item["seconds"])
149
+ packs.append(
150
+ Pack(
151
+ _extra=extra_fields(
152
+ item,
153
+ "id",
154
+ "seconds",
155
+ "amount",
156
+ "currency",
157
+ "referral_bonus_seconds",
158
+ "available",
159
+ ),
160
+ id=as_text(item["id"]),
161
+ seconds=seconds,
162
+ minutes=minutes_of(seconds),
163
+ price=to_money(as_whole(item["amount"]), as_text(item["currency"])),
164
+ bonus_minutes=minutes_of(as_whole(item["referral_bonus_seconds"])),
165
+ available=item.get("available") is not False,
166
+ )
167
+ )
168
+ return packs
169
+
170
+
171
+ def card_list(body: object) -> Cards:
172
+ check_shape(body, CARDS, "card list response")
173
+ fields = as_dict(body) or {}
174
+ cards = [
175
+ Card(
176
+ _extra=extra_fields(
177
+ item,
178
+ "id",
179
+ "type",
180
+ "brand",
181
+ "last4",
182
+ "exp_month",
183
+ "exp_year",
184
+ "email",
185
+ "is_default",
186
+ "fast_checkout_eligible",
187
+ ),
188
+ id=as_text(item["id"]),
189
+ type=as_text(item["type"]),
190
+ brand=as_text(item["brand"]),
191
+ last4=as_text(item["last4"]),
192
+ exp_month=as_whole(item["exp_month"]),
193
+ exp_year=as_whole(item["exp_year"]),
194
+ email=maybe_text(item.get("email")),
195
+ is_default=as_flag(item["is_default"]),
196
+ fast_checkout_eligible=as_flag(item["fast_checkout_eligible"]),
197
+ )
198
+ for item in (as_dict(row) or {} for row in as_list(fields["methods"]) or [])
199
+ ]
200
+ return Cards(
201
+ _extra=extra_fields(fields, "default_id", "methods"),
202
+ default_id=maybe_text(fields["default_id"]),
203
+ cards=cards,
204
+ )
205
+
206
+
207
+ def purchase_of(body: object) -> Purchase:
208
+ check_shape(body, PURCHASE, "purchase response")
209
+ fields = as_dict(body) or {}
210
+ if fields["status"] != OK:
211
+ raise BadBodyError("purchase response (status: expected ok)")
212
+ seconds = as_whole(fields["balance_seconds"])
213
+ return Purchase(
214
+ _extra=extra_fields(fields, "status", "balance_seconds"),
215
+ seconds=seconds,
216
+ minutes=minutes_of(seconds),
217
+ )
218
+
219
+
220
+ def setup_url(body: object) -> str:
221
+ check_shape(body, SETUP_SESSION, "setup session response")
222
+ url = as_text((as_dict(body) or {})["checkout_url"])
223
+ if not url:
224
+ raise BadBodyError("setup session response (checkout_url: expected a URL)")
225
+ return url
226
+
227
+
228
+ def transaction_page(body: object) -> Page[Transaction]:
229
+ check_shape(body, TRANSACTIONS, "transaction list response")
230
+ fields = as_dict(body) or {}
231
+ items = [
232
+ Transaction(
233
+ _extra=extra_fields(
234
+ item,
235
+ "id",
236
+ "seconds",
237
+ "amount_cents",
238
+ "amount_refunded_cents",
239
+ "currency",
240
+ "status",
241
+ "created_at",
242
+ "payment_method_type",
243
+ "payment_method_brand",
244
+ "payment_method_last4",
245
+ "receipt_number",
246
+ "initiated_via",
247
+ ),
248
+ id=as_whole(item["id"]),
249
+ seconds=as_whole(item["seconds"]),
250
+ amount_cents=as_whole(item["amount_cents"]),
251
+ amount_refunded_cents=as_whole(item["amount_refunded_cents"]),
252
+ currency=as_text(item["currency"]),
253
+ status=as_text(item["status"]),
254
+ created_at=to_datetime(item["created_at"]),
255
+ payment_method_type=maybe_text(item.get("payment_method_type")),
256
+ payment_method_brand=maybe_text(item.get("payment_method_brand")),
257
+ payment_method_last4=maybe_text(item.get("payment_method_last4")),
258
+ receipt_number=maybe_text(item.get("receipt_number")),
259
+ initiated_via=maybe_text(item.get("initiated_via")),
260
+ )
261
+ for item in (as_dict(row) or {} for row in as_list(fields["transactions"]) or [])
262
+ ]
263
+ return Page(
264
+ _extra=extra_fields(fields, "transactions", "next_cursor"),
265
+ items=items,
266
+ next_cursor=maybe_text(fields["next_cursor"]),
267
+ )
268
+
269
+
270
+ def ledger_page(body: object) -> Page[LedgerEntry]:
271
+ check_shape(body, LEDGER, "ledger response")
272
+ fields = as_dict(body) or {}
273
+ items = [
274
+ LedgerEntry(
275
+ _extra=extra_fields(
276
+ item, "id", "delta_seconds", "kind", "job_id", "note", "created_at"
277
+ ),
278
+ id=as_whole(item["id"]),
279
+ delta_seconds=as_whole(item["delta_seconds"]),
280
+ kind=as_text(item["kind"]),
281
+ job_id=maybe_text(item.get("job_id")),
282
+ note=maybe_text(item.get("note")),
283
+ created_at=to_datetime(item["created_at"]),
284
+ )
285
+ for item in (as_dict(row) or {} for row in as_list(fields["entries"]) or [])
286
+ ]
287
+ return Page(
288
+ _extra=extra_fields(fields, "entries", "next_cursor"),
289
+ items=items,
290
+ next_cursor=maybe_text(fields["next_cursor"]),
291
+ )
292
+
293
+
294
+ def hold_page(body: object) -> Page[Hold]:
295
+ check_shape(body, HOLDS, "hold list response")
296
+ fields = as_dict(body) or {}
297
+ items = [
298
+ Hold(
299
+ _extra=extra_fields(item, "id", "kind", "seconds", "created_at"),
300
+ id=as_text(item["id"]),
301
+ kind=as_text(item["kind"]),
302
+ seconds=as_whole(item["seconds"]),
303
+ created_at=to_datetime(item["created_at"]),
304
+ )
305
+ for item in (as_dict(row) or {} for row in as_list(fields["holds"]) or [])
306
+ ]
307
+ return Page(
308
+ _extra=extra_fields(fields, "holds", "next_cursor"),
309
+ items=items,
310
+ next_cursor=maybe_text(fields["next_cursor"]),
311
+ )
312
+
313
+
314
+ def purchase_key(idempotency_key: object) -> str:
315
+ """The key a purchase goes out with: the caller's (1 to 128 of `A-Za-z0-9_-`, spec 7.3) or a
316
+ fresh one."""
317
+ if idempotency_key is None:
318
+ return str(uuid.uuid4())
319
+ if not isinstance(idempotency_key, str) or not PURCHASE_KEY.fullmatch(idempotency_key):
320
+ raise invalid_option(
321
+ "idempotency_key must be 1 to 128 characters from A-Z, a-z, 0-9, _ and -."
322
+ )
323
+ return idempotency_key
324
+
325
+
326
+ def check_pack(pack: object) -> str:
327
+ if not isinstance(pack, str) or not pack:
328
+ raise invalid_option("pack is a pack id such as 300min.")
329
+ return pack
330
+
331
+
332
+ def choose_card(cards: Cards) -> str:
333
+ """The card `buy()` charges: the default, else the only one that can be charged. With none
334
+ usable it raises before anything is sent."""
335
+ if cards.default_id:
336
+ return cards.default_id
337
+ eligible = [card.id for card in cards.cards if card.fast_checkout_eligible]
338
+ if len(eligible) == 1:
339
+ return eligible[0]
340
+ if not eligible:
341
+ raise PaymentMethodRequiredError(
342
+ "The account has no saved card that can be charged.", code="no_default_card"
343
+ )
344
+ raise PaymentMethodRequiredError(
345
+ "The account has no default card and several that can be charged.",
346
+ code="default_card_required",
347
+ )
348
+
349
+
350
+ def purchase_body(
351
+ pack: str, key: str, card_id: str, *, acknowledge_no_refund: bool
352
+ ) -> dict[str, object]:
353
+ return {
354
+ "pack": pack,
355
+ "idempotency_key": key,
356
+ "quick_payment_method_id": card_id,
357
+ "policy_ack": acknowledge_no_refund is True,
358
+ }
359
+
360
+
361
+ def _interrupted(interrupted: BaseException, key: str) -> None:
362
+ add_note(
363
+ interrupted,
364
+ f"The purchase request was sent, so the card may have been charged. Idempotency key: {key}",
365
+ )
366
+
367
+
368
+ def sent_purchase(key: str, credentials: Credentials, send: Callable[[], T]) -> T:
369
+ """From the moment the purchase request goes out the card may have been charged, so every error
370
+ carries the key (spec 6.5), whatever its kind; an interrupt is never converted, and carries it
371
+ as a note. A failure of the SDK's own is raised once the handler has ended, so the unredacted
372
+ original is on neither the cause nor the `__context__` of what the caller gets."""
373
+ try:
374
+ return send()
375
+ except (asyncio.CancelledError, KeyboardInterrupt) as interrupted:
376
+ _interrupted(interrupted, key)
377
+ raise
378
+ except HushscriptError as error:
379
+ if not is_unsent(error): # R103: provably never sent, so nothing was charged
380
+ error.idempotency_key = key
381
+ raise
382
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
383
+ failed = unexpected_error(failure, credentials)
384
+ failed.idempotency_key = key
385
+ raise failed
386
+
387
+
388
+ async def sent_purchase_async(
389
+ key: str, credentials: Credentials, send: Callable[[], Awaitable[T]]
390
+ ) -> T:
391
+ """`sent_purchase` for the asyncio client."""
392
+ try:
393
+ return await send()
394
+ except (asyncio.CancelledError, KeyboardInterrupt) as interrupted:
395
+ _interrupted(interrupted, key)
396
+ raise
397
+ except HushscriptError as error:
398
+ if not is_unsent(error): # R103: provably never sent, so nothing was charged
399
+ error.idempotency_key = key
400
+ raise
401
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
402
+ failed = unexpected_error(failure, credentials)
403
+ failed.idempotency_key = key
404
+ raise failed
405
+
406
+
407
+ def check_card_id(card_id: object) -> str:
408
+ path_id(card_id)
409
+ return cast("str", card_id)
410
+
411
+
412
+ class Billing:
413
+ """`hs.billing`: packs, cards, purchases and the billing history."""
414
+
415
+ def __init__(self, session: SyncSession) -> None:
416
+ self._session = session
417
+
418
+ def packs(self) -> list[Pack]:
419
+ """The minute packs the account can buy, priced in its own currency. Needs the billing
420
+ scope."""
421
+ return parsed(
422
+ self._session.http.get("/v1/billing/packs"), self._session.credentials, pack_list
423
+ )
424
+
425
+ def cards(self) -> Cards:
426
+ """The saved payment methods and the default one."""
427
+ return parsed(
428
+ self._session.http.get("/v1/billing/cards"), self._session.credentials, card_list
429
+ )
430
+
431
+ def set_default_card(self, card_id: str) -> None:
432
+ """Makes a saved card the default one."""
433
+ body = {"payment_method_id": check_card_id(card_id)}
434
+ raise_for_response(
435
+ self._session.http.post("/v1/billing/cards/default", json=body),
436
+ credentials=self._session.credentials,
437
+ )
438
+
439
+ def add_card_url(self) -> str:
440
+ """The hosted page where a card is saved: open it in a browser. The request carries a key,
441
+ so its one retry after a 502 lands on the same page."""
442
+ response = self._session.http.post(
443
+ "/v1/billing/setup-session", json={"idempotency_key": str(uuid.uuid4())}
444
+ )
445
+ return parsed(response, self._session.credentials, setup_url)
446
+
447
+ def buy(
448
+ self,
449
+ pack: str,
450
+ *,
451
+ acknowledge_no_refund: bool = False,
452
+ idempotency_key: str | None = None,
453
+ ) -> Purchase:
454
+ """Buys a pack with the default card, or the only card that can be charged when there is no
455
+ default, and returns the balance the purchase answer reports, without a follow-up request.
456
+ A lost or unusable response can still raise an error after payment. Every error raised after
457
+ the purchase request was sent carries `idempotency_key`; pass it back with no chance of a
458
+ second charge. `acknowledge_no_refund` records that the no-refund notice was shown to the
459
+ buyer; it is evidence for disputes and never gates the charge."""
460
+ pack = check_pack(pack)
461
+ key = purchase_key(idempotency_key)
462
+ card_id = choose_card(self.cards())
463
+ body = purchase_body(pack, key, card_id, acknowledge_no_refund=acknowledge_no_refund)
464
+
465
+ def send() -> Purchase:
466
+ response = self._session.http.post("/v1/billing/purchase", json=body)
467
+ return parsed(response, self._session.credentials, purchase_of)
468
+
469
+ return sent_purchase(key, self._session.credentials, send)
470
+
471
+ def transactions(self) -> Iterator[Transaction]:
472
+ """Every purchase, newest first, fetching pages as it goes."""
473
+ return paginate(self._transactions)
474
+
475
+ def ledger(self) -> Iterator[LedgerEntry]:
476
+ """Every movement of the credit balance, newest first: holds, settles, releases, purchases
477
+ and refunds."""
478
+ return paginate(self._ledger)
479
+
480
+ def holds(self) -> Iterator[Hold]:
481
+ """The seconds reserved by work in flight, newest first."""
482
+ return paginate(self._holds)
483
+
484
+ def _transactions(self, cursor: str | None) -> Page[Transaction]:
485
+ response = self._session.http.get(
486
+ "/v1/billing/transactions", params=list_params(cursor, None)
487
+ )
488
+ return parsed(response, self._session.credentials, transaction_page)
489
+
490
+ def _ledger(self, cursor: str | None) -> Page[LedgerEntry]:
491
+ response = self._session.http.get("/v1/billing/ledger", params=list_params(cursor, None))
492
+ return parsed(response, self._session.credentials, ledger_page)
493
+
494
+ def _holds(self, cursor: str | None) -> Page[Hold]:
495
+ response = self._session.http.get("/v1/billing/holds", params=list_params(cursor, None))
496
+ return parsed(response, self._session.credentials, hold_page)
497
+
498
+
499
+ class AsyncBilling:
500
+ """`hs.billing` on the asyncio client; the lists are async iterators."""
501
+
502
+ def __init__(self, session: AsyncSession) -> None:
503
+ self._session = session
504
+
505
+ async def packs(self) -> list[Pack]:
506
+ response = await self._session.http.get("/v1/billing/packs")
507
+ return parsed(response, self._session.credentials, pack_list)
508
+
509
+ async def cards(self) -> Cards:
510
+ response = await self._session.http.get("/v1/billing/cards")
511
+ return parsed(response, self._session.credentials, card_list)
512
+
513
+ async def set_default_card(self, card_id: str) -> None:
514
+ body = {"payment_method_id": check_card_id(card_id)}
515
+ response = await self._session.http.post("/v1/billing/cards/default", json=body)
516
+ raise_for_response(response, credentials=self._session.credentials)
517
+
518
+ async def add_card_url(self) -> str:
519
+ response = await self._session.http.post(
520
+ "/v1/billing/setup-session", json={"idempotency_key": str(uuid.uuid4())}
521
+ )
522
+ return parsed(response, self._session.credentials, setup_url)
523
+
524
+ async def buy(
525
+ self,
526
+ pack: str,
527
+ *,
528
+ acknowledge_no_refund: bool = False,
529
+ idempotency_key: str | None = None,
530
+ ) -> Purchase:
531
+ pack = check_pack(pack)
532
+ key = purchase_key(idempotency_key)
533
+ card_id = choose_card(await self.cards())
534
+ body = purchase_body(pack, key, card_id, acknowledge_no_refund=acknowledge_no_refund)
535
+
536
+ async def send() -> Purchase:
537
+ response = await self._session.http.post("/v1/billing/purchase", json=body)
538
+ return parsed(response, self._session.credentials, purchase_of)
539
+
540
+ return await sent_purchase_async(key, self._session.credentials, send)
541
+
542
+ def transactions(self) -> AsyncIterator[Transaction]:
543
+ return apaginate(self._transactions)
544
+
545
+ def ledger(self) -> AsyncIterator[LedgerEntry]:
546
+ return apaginate(self._ledger)
547
+
548
+ def holds(self) -> AsyncIterator[Hold]:
549
+ return apaginate(self._holds)
550
+
551
+ async def _transactions(self, cursor: str | None) -> Page[Transaction]:
552
+ response = await self._session.http.get(
553
+ "/v1/billing/transactions", params=list_params(cursor, None)
554
+ )
555
+ return parsed(response, self._session.credentials, transaction_page)
556
+
557
+ async def _ledger(self, cursor: str | None) -> Page[LedgerEntry]:
558
+ response = await self._session.http.get(
559
+ "/v1/billing/ledger", params=list_params(cursor, None)
560
+ )
561
+ return parsed(response, self._session.credentials, ledger_page)
562
+
563
+ async def _holds(self, cursor: str | None) -> Page[Hold]:
564
+ response = await self._session.http.get(
565
+ "/v1/billing/holds", params=list_params(cursor, None)
566
+ )
567
+ return parsed(response, self._session.credentials, hold_page)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from hushscript._json import as_dict, as_list, extra_fields
6
+ from hushscript._models import Job
7
+ from hushscript._response import parsed
8
+ from hushscript._shape import check_shape, maybe_text
9
+ from hushscript._shapes import JOB_LIST
10
+ from hushscript.paginate import Page, apaginate, paginate
11
+ from hushscript.resources._params import path_id
12
+ from hushscript.transcribe import apipeline, pipeline
13
+ from hushscript.transcribe.pipeline import Callbacks
14
+ from hushscript.transcribe.preflight import check_wait_timeout
15
+ from hushscript.transcribe.wait import DEFAULT_WAIT_TIMEOUT
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import AsyncIterator, Callable, Iterator
19
+
20
+ from hushscript._models import Progress
21
+ from hushscript.transcribe.session import AsyncSession, SyncSession
22
+
23
+
24
+ def job_page(body: object) -> Page[Job]:
25
+ check_shape(body, JOB_LIST, "job list response")
26
+ fields = as_dict(body) or {}
27
+ items = [Job.from_body(row) for row in as_list(fields["jobs"]) or []]
28
+ return Page(
29
+ _extra=extra_fields(fields, "jobs", "next_cursor"),
30
+ items=items,
31
+ next_cursor=maybe_text(fields["next_cursor"]),
32
+ )
33
+
34
+
35
+ def job_params(cursor: str | None, *, active: bool) -> dict[str, str]:
36
+ params: dict[str, str] = {}
37
+ if active:
38
+ params["active"] = "true"
39
+ if cursor is not None:
40
+ params["cursor"] = cursor
41
+ return params
42
+
43
+
44
+ class Jobs:
45
+ """`hs.jobs`: the account's transcription jobs."""
46
+
47
+ def __init__(self, session: SyncSession) -> None:
48
+ self._session = session
49
+
50
+ def list(self, *, active: bool = False) -> Iterator[Job]:
51
+ """Every job, newest first, fetching pages as it goes; `active=True` keeps only the jobs
52
+ that have not finished."""
53
+ return paginate(lambda cursor: self._page(cursor, active=active))
54
+
55
+ def get(self, job_id: str) -> Job:
56
+ return pipeline.get_job(self._session, path_id(job_id))
57
+
58
+ def wait(
59
+ self,
60
+ job_id: str,
61
+ *,
62
+ wait_timeout: float = DEFAULT_WAIT_TIMEOUT,
63
+ on_progress: Callable[[Progress], None] | None = None,
64
+ ) -> Job:
65
+ """Polls until the job is `done` or `failed` (spec 8.6); WaitTimeoutError after
66
+ `wait_timeout` seconds. A failed job is returned, not raised."""
67
+ check_wait_timeout(wait_timeout)
68
+ return pipeline.wait_job(
69
+ self._session, path_id(job_id), wait_timeout, Callbacks(on_progress=on_progress), None
70
+ )
71
+
72
+ def _page(self, cursor: str | None, *, active: bool) -> Page[Job]:
73
+ response = self._session.http.get("/v1/jobs", params=job_params(cursor, active=active))
74
+ return parsed(response, self._session.credentials, job_page)
75
+
76
+
77
+ class AsyncJobs:
78
+ """`hs.jobs` on the asyncio client; `list` is an async iterator."""
79
+
80
+ def __init__(self, session: AsyncSession) -> None:
81
+ self._session = session
82
+
83
+ def list(self, *, active: bool = False) -> AsyncIterator[Job]:
84
+ return apaginate(lambda cursor: self._page(cursor, active=active))
85
+
86
+ async def get(self, job_id: str) -> Job:
87
+ return await apipeline.get_job(self._session, path_id(job_id))
88
+
89
+ async def wait(
90
+ self,
91
+ job_id: str,
92
+ *,
93
+ wait_timeout: float = DEFAULT_WAIT_TIMEOUT,
94
+ on_progress: Callable[[Progress], None] | None = None,
95
+ ) -> Job:
96
+ check_wait_timeout(wait_timeout)
97
+ return await apipeline.wait_job(
98
+ self._session, path_id(job_id), wait_timeout, Callbacks(on_progress=on_progress), None
99
+ )
100
+
101
+ async def _page(self, cursor: str | None, *, active: bool) -> Page[Job]:
102
+ response = await self._session.http.get(
103
+ "/v1/jobs", params=job_params(cursor, active=active)
104
+ )
105
+ return parsed(response, self._session.credentials, job_page)