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/_retry.py ADDED
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ import httpx
9
+
10
+ from ._pacing import ACTIVE_JOBS_RETRIES, ACTIVE_JOBS_WAIT, CREATE_RATE_LIMIT_BACKOFF
11
+
12
+ if TYPE_CHECKING:
13
+ import random
14
+
15
+ MAX_RETRY_AFTER: Final = 60.0
16
+ BACKOFF_CAP: Final = 8.0
17
+ BACKOFF_BASE: Final = 0.5
18
+ TOO_MANY_ACTIVE_JOBS: Final = "too_many_active_jobs"
19
+ TOO_MANY_REQUESTS: Final = 429
20
+
21
+ # Spec 7.4: only DNS, connect, TLS-handshake and pool-wait failures happen before the request body
22
+ # is written; httpx maps the first three to ConnectError/ConnectTimeout. Everything else is "sent".
23
+ _NOT_SENT: Final = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
24
+
25
+
26
+ class NetworkRetry(enum.Enum):
27
+ ANY = "any"
28
+ BEFORE_SENT = "before-sent"
29
+ NONE = "none"
30
+
31
+
32
+ class RetryClass(enum.Enum):
33
+ SAFE = "safe"
34
+ PART = "part"
35
+ COMPLETE = "complete"
36
+ CREATE = "create"
37
+ PURCHASE = "purchase"
38
+ NEVER = "never"
39
+ MUTATE = "mutate"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class RetryPolicy:
44
+ statuses: frozenset[int]
45
+ network: NetworkRetry
46
+ cap: int | None = None
47
+
48
+
49
+ _SERVER_ERRORS: Final = frozenset(range(500, 600))
50
+ _POLICIES: Final = {
51
+ RetryClass.SAFE: RetryPolicy(frozenset({429, 500, 502, 503, 504}), NetworkRetry.ANY),
52
+ RetryClass.PART: RetryPolicy(frozenset({429}) | _SERVER_ERRORS, NetworkRetry.ANY),
53
+ RetryClass.COMPLETE: RetryPolicy(frozenset({429}) | _SERVER_ERRORS, NetworkRetry.ANY),
54
+ RetryClass.CREATE: RetryPolicy(frozenset({429}) | _SERVER_ERRORS, NetworkRetry.ANY),
55
+ RetryClass.PURCHASE: RetryPolicy(frozenset({502, 503}), NetworkRetry.ANY, cap=1),
56
+ RetryClass.NEVER: RetryPolicy(frozenset(), NetworkRetry.NONE),
57
+ RetryClass.MUTATE: RetryPolicy(frozenset(), NetworkRetry.BEFORE_SENT),
58
+ }
59
+
60
+ CLAIM: Final = "claim"
61
+ ROTATE: Final = "rotate"
62
+ SIGNUP: Final = "signup"
63
+
64
+ _JOB: Final = r"/v1/uploads/[^/]+"
65
+ # (method, path, class, name of a route whose lost response needs its own hint)
66
+ _ROUTES: Final = (
67
+ ("POST", re.compile(rf"^{_JOB}/heartbeat$"), RetryClass.SAFE, None),
68
+ ("PUT", re.compile(rf"^{_JOB}/parts/[^/]+$"), RetryClass.PART, None),
69
+ ("POST", re.compile(rf"^{_JOB}/complete$"), RetryClass.COMPLETE, None),
70
+ ("POST", re.compile(r"^/v1/uploads$"), RetryClass.CREATE, None),
71
+ ("POST", re.compile(r"^/v1/billing/(purchase|setup-session)$"), RetryClass.PURCHASE, None),
72
+ ("POST", re.compile(r"^/v1/agent/accounts/[^/]+/claim$"), RetryClass.NEVER, CLAIM),
73
+ ("POST", re.compile(r"^/v1/agent/credentials/rotate$"), RetryClass.NEVER, ROTATE),
74
+ ("POST", re.compile(r"^/v1/agent/accounts$"), RetryClass.NEVER, SIGNUP),
75
+ )
76
+
77
+
78
+ def _route(method: str, path: str) -> tuple[RetryClass, str | None] | None:
79
+ verb = method.upper()
80
+ for route_method, pattern, retry_class, name in _ROUTES:
81
+ if verb == route_method and pattern.match(path.rstrip("/") or "/"):
82
+ return retry_class, name
83
+ return None
84
+
85
+
86
+ def classify(method: str, path: str) -> RetryClass:
87
+ """The spec 7.2 operation class of a request; `path` is relative to the base URL."""
88
+ route = _route(method, path)
89
+ if route is not None:
90
+ return route[0]
91
+ return RetryClass.SAFE if method.upper() in {"GET", "HEAD", "OPTIONS"} else RetryClass.MUTATE
92
+
93
+
94
+ def unrecoverable_route(method: str, path: str) -> str | None:
95
+ """`claim`, `rotate` or `signup` when the request is one of the spec 7.4 routes, else None."""
96
+ route = _route(method, path)
97
+ return None if route is None else route[1]
98
+
99
+
100
+ def is_not_sent(failure: httpx.RequestError) -> bool:
101
+ """True when the failure happened before the request body was written (spec 7.4)."""
102
+ return isinstance(failure, _NOT_SENT)
103
+
104
+
105
+ def policy_for(retry_class: RetryClass) -> RetryPolicy:
106
+ return _POLICIES[retry_class]
107
+
108
+
109
+ def backoff_delay(attempt: int, rng: random.Random) -> float:
110
+ """Full jitter: random(0, min(8 s, 0.5 s * 2^attempt)); `attempt` counts retries from 0."""
111
+ return rng.uniform(0.0, min(BACKOFF_CAP, BACKOFF_BASE * 2.0**attempt))
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class Outcome:
116
+ """What one attempt produced: a response (status, Retry-After, slug) or a transport failure."""
117
+
118
+ status: int | None = None
119
+ retry_after: float | None = None
120
+ slug: str | None = None
121
+ failure: httpx.RequestError | None = None
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class Decision:
126
+ retry: bool
127
+ delay: float = 0.0
128
+
129
+
130
+ STOP: Final = Decision(retry=False)
131
+
132
+
133
+ def _after_failure(
134
+ policy: RetryPolicy, attempt: int, failure: httpx.RequestError, rng: random.Random
135
+ ) -> Decision:
136
+ if policy.network is NetworkRetry.NONE:
137
+ return STOP
138
+ if policy.network is NetworkRetry.BEFORE_SENT and not is_not_sent(failure):
139
+ return STOP
140
+ return Decision(retry=True, delay=backoff_delay(attempt, rng))
141
+
142
+
143
+ RATE_LIMITED: Final = "rate_limited"
144
+ ACTIVE_JOBS: Final = "active_jobs"
145
+
146
+
147
+ def create_limit_kind(retry_class: RetryClass, outcome: Outcome) -> str | None:
148
+ """The 429 schedule (spec 7.5) a response belongs to: `active_jobs`, `rate_limited` or None."""
149
+ if retry_class is not RetryClass.CREATE or outcome.status != TOO_MANY_REQUESTS:
150
+ return None
151
+ return ACTIVE_JOBS if outcome.slug == TOO_MANY_ACTIVE_JOBS else RATE_LIMITED
152
+
153
+
154
+ def _after_create_rate_limit(own_429: int, outcome: Outcome) -> Decision:
155
+ if outcome.slug == TOO_MANY_ACTIVE_JOBS:
156
+ return Decision(retry=True, delay=ACTIVE_JOBS_WAIT)
157
+ if own_429 >= len(CREATE_RATE_LIMIT_BACKOFF):
158
+ return STOP
159
+ return Decision(retry=True, delay=CREATE_RATE_LIMIT_BACKOFF[own_429])
160
+
161
+
162
+ def _after_response(
163
+ retry_class: RetryClass, attempt: int, own_429: int, outcome: Outcome, rng: random.Random
164
+ ) -> Decision:
165
+ if outcome.status is None or outcome.status not in policy_for(retry_class).statuses:
166
+ return STOP
167
+ if outcome.retry_after is not None:
168
+ if outcome.retry_after > MAX_RETRY_AFTER:
169
+ return STOP
170
+ return Decision(retry=True, delay=outcome.retry_after)
171
+ if create_limit_kind(retry_class, outcome) is not None:
172
+ return _after_create_rate_limit(own_429, outcome)
173
+ return Decision(retry=True, delay=backoff_delay(attempt, rng))
174
+
175
+
176
+ def _exhausted(
177
+ retry_class: RetryClass, attempt: int, own_429: int, max_retries: int, outcome: Outcome
178
+ ) -> bool:
179
+ if create_limit_kind(retry_class, outcome) == ACTIVE_JOBS:
180
+ # Spec 7.5: ten tries whatever max_retries says, counted over active-jobs 429s only; only
181
+ # max_retries=0 turns this off.
182
+ return max_retries == 0 or own_429 >= ACTIVE_JOBS_RETRIES
183
+ cap = policy_for(retry_class).cap
184
+ return attempt >= (max_retries if cap is None else min(max_retries, cap))
185
+
186
+
187
+ def decide( # the attempt counters are separate on purpose (spec 7.2)
188
+ retry_class: RetryClass,
189
+ attempt: int,
190
+ max_retries: int,
191
+ outcome: Outcome,
192
+ rng: random.Random,
193
+ own_429: int | None = None,
194
+ ) -> Decision:
195
+ """Whether to try again after attempt number `attempt` (0 is the first) and how long to wait.
196
+
197
+ `own_429` is how many earlier responses were 429s of the same schedule as this one; the 429
198
+ schedules count only those, not retries of other kinds. It defaults to `attempt`.
199
+ """
200
+ own = attempt if own_429 is None else own_429
201
+ if _exhausted(retry_class, attempt, own, max_retries, outcome):
202
+ return STOP
203
+ if outcome.failure is not None:
204
+ return _after_failure(policy_for(retry_class), attempt, outcome.failure, rng)
205
+ return _after_response(retry_class, attempt, own, outcome, rng)
hushscript/_shape.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Callable, Mapping # the Shape alias is built at runtime
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Final, TypeAlias
8
+
9
+ from ._json import as_dict, as_list
10
+ from ._response import BadBodyError
11
+
12
+ INT_LIMIT: Final = 2**63
13
+ """Whole numbers in a body stay inside a signed 64-bit range, as the API's own counters do."""
14
+ MAX_UNIX_SECONDS: Final = 253_402_300_799
15
+ """9999-12-31T23:59:59Z: the last instant a `datetime` holds."""
16
+ _EPOCH: Final = datetime(1970, 1, 1, tzinfo=timezone.utc)
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Problem:
21
+ """Where a body departs from its shape: the path to the field and what was expected there."""
22
+
23
+ path: tuple[str | int, ...]
24
+ expected: str
25
+
26
+ def under(self, step: str | int) -> Problem:
27
+ return Problem((step, *self.path), self.expected)
28
+
29
+ def describe(self) -> str:
30
+ where = ""
31
+ for part in self.path:
32
+ if isinstance(part, int):
33
+ where += f"[{part}]"
34
+ else:
35
+ where += f".{part}" if where else part
36
+ return f"{where}: expected {self.expected}" if where else f"expected {self.expected}"
37
+
38
+
39
+ Shape: TypeAlias = Callable[[object], Problem | None]
40
+
41
+
42
+ def _whole(value: object) -> int | None:
43
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
44
+
45
+
46
+ def string(value: object) -> Problem | None:
47
+ return None if isinstance(value, str) else Problem((), "a string")
48
+
49
+
50
+ def boolean(value: object) -> Problem | None:
51
+ return None if isinstance(value, bool) else Problem((), "a boolean")
52
+
53
+
54
+ def integer(value: object) -> Problem | None:
55
+ whole = _whole(value)
56
+ return None if whole is not None and abs(whole) < INT_LIMIT else Problem((), "a whole number")
57
+
58
+
59
+ def count(value: object) -> Problem | None:
60
+ """A quantity that cannot be negative: seconds, bytes, jobs."""
61
+ whole = _whole(value)
62
+ ok = whole is not None and 0 <= whole < INT_LIMIT
63
+ return None if ok else Problem((), "a whole number of 0 or more")
64
+
65
+
66
+ def positive(value: object) -> Problem | None:
67
+ whole = _whole(value)
68
+ return None if whole is not None and 0 < whole < INT_LIMIT else Problem((), "a number above 0")
69
+
70
+
71
+ def finite_number(value: object) -> bool:
72
+ """A number inside the 64-bit range; total, where `math.isfinite` raises on a huge int."""
73
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
74
+ return False
75
+ if isinstance(value, float) and not math.isfinite(value):
76
+ return False
77
+ return abs(value) < INT_LIMIT
78
+
79
+
80
+ def number(value: object) -> Problem | None:
81
+ """A finite number; `NaN` and `Infinity` parse in Python but are not JSON."""
82
+ return None if finite_number(value) else Problem((), "a number")
83
+
84
+
85
+ def timestamp(value: object) -> Problem | None:
86
+ whole = _whole(value)
87
+ ok = whole is not None and 0 <= whole <= MAX_UNIX_SECONDS
88
+ return None if ok else Problem((), "a unix timestamp in seconds")
89
+
90
+
91
+ def anything(_value: object) -> Problem | None:
92
+ return None
93
+
94
+
95
+ def nullable(shape: Shape) -> Shape:
96
+ return lambda value: None if value is None else shape(value)
97
+
98
+
99
+ def array_of(shape: Shape) -> Shape:
100
+ def check(value: object) -> Problem | None:
101
+ items = as_list(value)
102
+ if items is None:
103
+ return Problem((), "an array")
104
+ for index, item in enumerate(items):
105
+ found = shape(item)
106
+ if found is not None:
107
+ return found.under(index)
108
+ return None
109
+
110
+ return check
111
+
112
+
113
+ def record_of(shape: Shape) -> Shape:
114
+ """An object with arbitrary keys whose values all fit `shape`."""
115
+
116
+ def check(value: object) -> Problem | None:
117
+ fields = as_dict(value)
118
+ if fields is None:
119
+ return Problem((), "an object")
120
+ for key, item in fields.items():
121
+ found = shape(item)
122
+ if found is not None:
123
+ return found.under(key)
124
+ return None
125
+
126
+ return check
127
+
128
+
129
+ def object_of(required: Mapping[str, Shape], optional: Mapping[str, Shape] | None = None) -> Shape:
130
+ """An object with each required key present and fitting, and each optional key fitting when it
131
+ is there. Keys the shape does not list pass, so a newer API still parses."""
132
+ extras = optional or {}
133
+
134
+ def check(value: object) -> Problem | None:
135
+ fields = as_dict(value)
136
+ if fields is None:
137
+ return Problem((), "an object")
138
+ for key, shape in required.items():
139
+ found = shape(fields[key]) if key in fields else Problem((), "a value")
140
+ if found is not None:
141
+ return found.under(key)
142
+ for key, shape in extras.items():
143
+ found = shape(fields[key]) if key in fields else None
144
+ if found is not None:
145
+ return found.under(key)
146
+ return None
147
+
148
+ return check
149
+
150
+
151
+ def check_shape(value: object, shape: Shape, what: str) -> None:
152
+ """Raises BadBodyError naming the path and the expectation, never the value, when `value` does
153
+ not fit `shape`."""
154
+ found = shape(value)
155
+ if found is not None:
156
+ raise BadBodyError(f"{what} ({found.describe()})")
157
+
158
+
159
+ def to_datetime(seconds: object) -> datetime:
160
+ """The aware UTC `datetime` of a unix timestamp that the `timestamp` shape accepted."""
161
+ whole = _whole(seconds)
162
+ if whole is None:
163
+ raise BadBodyError("a timestamp (not a whole number)")
164
+ return _EPOCH + timedelta(seconds=whole)
165
+
166
+
167
+ def as_whole(value: object) -> int:
168
+ """`value` as an int once the shape has vouched for it; a stray type is BadBodyError, never
169
+ a TypeError at a later line."""
170
+ whole = _whole(value)
171
+ if whole is None:
172
+ raise BadBodyError("a whole number")
173
+ return whole
174
+
175
+
176
+ def as_text(value: object) -> str:
177
+ if not isinstance(value, str):
178
+ raise BadBodyError("a string")
179
+ return value
180
+
181
+
182
+ def as_flag(value: object) -> bool:
183
+ if not isinstance(value, bool):
184
+ raise BadBodyError("a boolean")
185
+ return value
186
+
187
+
188
+ def maybe_whole(value: object) -> int | None:
189
+ return None if value is None else as_whole(value)
190
+
191
+
192
+ def maybe_text(value: object) -> str | None:
193
+ return None if value is None else as_text(value)
194
+
195
+
196
+ def maybe_flag(value: object) -> bool | None:
197
+ return None if value is None else as_flag(value)
hushscript/_shapes.py ADDED
@@ -0,0 +1,248 @@
1
+ """What the friendly layer reads from each 2xx body, checked before any field is touched.
2
+
3
+ The tables list the fields the mappers use (`openapi.json` is the reference); keys they do not
4
+ list pass through, so a newer API still parses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Final
10
+
11
+ from ._shape import (
12
+ Shape,
13
+ anything,
14
+ array_of,
15
+ boolean,
16
+ count,
17
+ integer,
18
+ nullable,
19
+ number,
20
+ object_of,
21
+ record_of,
22
+ string,
23
+ timestamp,
24
+ )
25
+
26
+ UPLOAD_LIMITS: Final[Shape] = object_of(
27
+ {
28
+ "max_daily_seconds": count,
29
+ "max_active_jobs": count,
30
+ "active_jobs": count,
31
+ "used_today_seconds": count,
32
+ }
33
+ )
34
+
35
+ # `purchases_remaining_in_window` is in the body for agent accounts only, and not yet in the schema.
36
+ BALANCE: Final[Shape] = object_of(
37
+ {"balance_seconds": integer, "held_seconds": count},
38
+ {
39
+ "credits_expire_at": nullable(timestamp),
40
+ "purchases_remaining_in_window": nullable(count),
41
+ },
42
+ )
43
+
44
+ ACCOUNT: Final[Shape] = object_of(
45
+ {
46
+ "balance_seconds": integer,
47
+ "held_seconds": count,
48
+ "credits_expire_at": nullable(timestamp),
49
+ "card_on_file": boolean,
50
+ "data_region": string,
51
+ "limits": UPLOAD_LIMITS,
52
+ },
53
+ {"purchases_remaining_in_window": nullable(count)},
54
+ )
55
+
56
+ OPTIONS_SUMMARY: Final[Shape] = object_of(
57
+ {
58
+ "language_code": nullable(string),
59
+ "speaker_detection": boolean,
60
+ "medical_mode": boolean,
61
+ "multichannel": boolean,
62
+ "translation_targets": array_of(string),
63
+ "keyterms": boolean,
64
+ "prompt": boolean,
65
+ "auto_generate_insights": nullable(boolean),
66
+ }
67
+ )
68
+
69
+ JOB: Final[Shape] = object_of(
70
+ {
71
+ "id": string,
72
+ "state": string,
73
+ "mode": string,
74
+ "declared_seconds": nullable(count),
75
+ "verified_seconds": nullable(count),
76
+ "actual_seconds": nullable(count),
77
+ "billable_seconds": nullable(count),
78
+ "error": nullable(string),
79
+ "failure_kind": nullable(string),
80
+ "retry_of": nullable(string),
81
+ "created_at": timestamp,
82
+ "transcript_id": nullable(string),
83
+ "options": OPTIONS_SUMMARY,
84
+ }
85
+ )
86
+
87
+ JOB_LIST: Final[Shape] = object_of({"jobs": array_of(JOB), "next_cursor": nullable(string)})
88
+
89
+ _UTTERANCE: Final[Shape] = object_of(
90
+ {"speaker": string, "start_ms": number, "end_ms": number, "text": string}
91
+ )
92
+ _TRANSCRIPT_LANGUAGE: Final[Shape] = object_of({"utterances": array_of(_UTTERANCE)})
93
+
94
+ TRANSCRIPT_DETAIL: Final[Shape] = object_of(
95
+ {
96
+ "id": string,
97
+ "title": string,
98
+ "language_detected": nullable(string),
99
+ "duration_seconds": count,
100
+ "speaker_names": record_of(string),
101
+ "tags": array_of(string),
102
+ "created_at": timestamp,
103
+ "updated_at": timestamp,
104
+ "auto_delete_at": nullable(timestamp),
105
+ "edited": boolean,
106
+ "body": object_of(
107
+ {"original": _TRANSCRIPT_LANGUAGE, "translations": record_of(_TRANSCRIPT_LANGUAGE)}
108
+ ),
109
+ }
110
+ )
111
+
112
+ TRANSCRIPT_LIST: Final[Shape] = object_of(
113
+ {
114
+ "transcripts": array_of(
115
+ object_of(
116
+ {
117
+ "id": string,
118
+ "title": string,
119
+ "language_detected": nullable(string),
120
+ "duration_seconds": count,
121
+ "created_at": timestamp,
122
+ "auto_delete_at": nullable(timestamp),
123
+ "tags": array_of(string),
124
+ }
125
+ )
126
+ ),
127
+ "next_cursor": nullable(string),
128
+ }
129
+ )
130
+
131
+ DELETED_LIST: Final[Shape] = object_of(
132
+ {
133
+ "deleted": array_of(
134
+ object_of(
135
+ {
136
+ "id": string,
137
+ "title": string,
138
+ "duration_seconds": count,
139
+ "created_at": timestamp,
140
+ "deleted_at": timestamp,
141
+ "cause": string,
142
+ "receipt": nullable(record_of(anything)),
143
+ }
144
+ )
145
+ ),
146
+ "next_cursor": nullable(string),
147
+ }
148
+ )
149
+
150
+ PACKS: Final[Shape] = object_of(
151
+ {
152
+ "packs": array_of(
153
+ object_of(
154
+ {
155
+ "id": string,
156
+ "amount": integer,
157
+ "currency": string,
158
+ "seconds": count,
159
+ "referral_bonus_seconds": count,
160
+ },
161
+ {"available": nullable(boolean)},
162
+ )
163
+ )
164
+ }
165
+ )
166
+
167
+ CARDS: Final[Shape] = object_of(
168
+ {
169
+ "default_id": nullable(string),
170
+ "methods": array_of(
171
+ object_of(
172
+ {
173
+ "id": string,
174
+ "type": string,
175
+ "brand": string,
176
+ "last4": string,
177
+ "exp_month": count,
178
+ "exp_year": count,
179
+ "is_default": boolean,
180
+ "fast_checkout_eligible": boolean,
181
+ },
182
+ {"email": nullable(string)},
183
+ )
184
+ ),
185
+ }
186
+ )
187
+
188
+ SETUP_SESSION: Final[Shape] = object_of({"checkout_url": string})
189
+
190
+ PURCHASE: Final[Shape] = object_of({"status": string, "balance_seconds": integer})
191
+
192
+ TRANSACTIONS: Final[Shape] = object_of(
193
+ {
194
+ "transactions": array_of(
195
+ object_of(
196
+ {
197
+ "id": integer,
198
+ "seconds": integer,
199
+ "amount_cents": integer,
200
+ "amount_refunded_cents": integer,
201
+ "currency": string,
202
+ "status": string,
203
+ "created_at": timestamp,
204
+ },
205
+ {
206
+ "payment_method_type": nullable(string),
207
+ "payment_method_brand": nullable(string),
208
+ "payment_method_last4": nullable(string),
209
+ "receipt_number": nullable(string),
210
+ "initiated_via": nullable(string),
211
+ },
212
+ )
213
+ ),
214
+ "next_cursor": nullable(string),
215
+ }
216
+ )
217
+
218
+ LEDGER: Final[Shape] = object_of(
219
+ {
220
+ "entries": array_of(
221
+ object_of(
222
+ {"id": integer, "delta_seconds": integer, "kind": string, "created_at": timestamp},
223
+ {"job_id": nullable(string), "note": nullable(string)},
224
+ )
225
+ ),
226
+ "next_cursor": nullable(string),
227
+ }
228
+ )
229
+
230
+ HOLDS: Final[Shape] = object_of(
231
+ {
232
+ "holds": array_of(
233
+ object_of({"id": string, "kind": string, "seconds": count, "created_at": timestamp})
234
+ ),
235
+ "next_cursor": nullable(string),
236
+ }
237
+ )
238
+
239
+ AGENT_SIGNUP: Final[Shape] = object_of(
240
+ {
241
+ "signup_id": string,
242
+ "claim_secret": string,
243
+ "checkout_url": string,
244
+ "amount": integer,
245
+ "currency": string,
246
+ "expires_at": timestamp,
247
+ }
248
+ )
hushscript/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0" # x-release-please-version