hushscript 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hushscript/__init__.py +97 -0
- hushscript/_client.py +687 -0
- hushscript/_clock.py +36 -0
- hushscript/_config.py +139 -0
- hushscript/_errors.py +563 -0
- hushscript/_guard.py +61 -0
- hushscript/_http.py +624 -0
- hushscript/_json.py +55 -0
- hushscript/_log.py +155 -0
- hushscript/_models.py +144 -0
- hushscript/_pacing.py +51 -0
- hushscript/_redact.py +140 -0
- hushscript/_response.py +67 -0
- hushscript/_retry.py +205 -0
- hushscript/_shape.py +197 -0
- hushscript/_shapes.py +248 -0
- hushscript/_version.py +1 -0
- hushscript/format.py +224 -0
- hushscript/generated/__init__.py +8 -0
- hushscript/generated/api/__init__.py +1 -0
- hushscript/generated/api/default/__init__.py +1 -0
- hushscript/generated/api/default/buy_minutes.py +248 -0
- hushscript/generated/api/default/claim_agent_account.py +238 -0
- hushscript/generated/api/default/complete_upload.py +238 -0
- hushscript/generated/api/default/create_agent_account_signup.py +261 -0
- hushscript/generated/api/default/create_setup_session.py +244 -0
- hushscript/generated/api/default/create_upload.py +225 -0
- hushscript/generated/api/default/get_account.py +169 -0
- hushscript/generated/api/default/get_job.py +198 -0
- hushscript/generated/api/default/get_org.py +169 -0
- hushscript/generated/api/default/get_transcript.py +226 -0
- hushscript/generated/api/default/get_transcription_context.py +173 -0
- hushscript/generated/api/default/get_upload_part_urls.py +246 -0
- hushscript/generated/api/default/heartbeat_upload.py +197 -0
- hushscript/generated/api/default/lift_transcript_hold.py +206 -0
- hushscript/generated/api/default/list_credit_ledger.py +229 -0
- hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
- hushscript/generated/api/default/list_holds.py +233 -0
- hushscript/generated/api/default/list_jobs.py +249 -0
- hushscript/generated/api/default/list_org_audit.py +221 -0
- hushscript/generated/api/default/list_org_invoices.py +221 -0
- hushscript/generated/api/default/list_org_ledger.py +221 -0
- hushscript/generated/api/default/list_org_matters.py +247 -0
- hushscript/generated/api/default/list_org_members.py +221 -0
- hushscript/generated/api/default/list_org_transcripts.py +233 -0
- hushscript/generated/api/default/list_packs.py +169 -0
- hushscript/generated/api/default/list_saved_cards.py +169 -0
- hushscript/generated/api/default/list_transactions.py +225 -0
- hushscript/generated/api/default/list_transcripts.py +225 -0
- hushscript/generated/api/default/org_audit_csv.py +163 -0
- hushscript/generated/api/default/org_ledger_csv.py +163 -0
- hushscript/generated/api/default/org_usage_report.py +247 -0
- hushscript/generated/api/default/org_usage_report_csv.py +229 -0
- hushscript/generated/api/default/patch_org_member.py +237 -0
- hushscript/generated/api/default/place_transcript_hold.py +206 -0
- hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
- hushscript/generated/api/default/schedule_org_custody.py +215 -0
- hushscript/generated/api/default/set_default_card.py +223 -0
- hushscript/generated/api/default/upload_part.py +249 -0
- hushscript/generated/catalog.py +138 -0
- hushscript/generated/client.py +282 -0
- hushscript/generated/errors.py +16 -0
- hushscript/generated/hints.py +28 -0
- hushscript/generated/models/__init__.py +193 -0
- hushscript/generated/models/account_response.py +114 -0
- hushscript/generated/models/active_hold.py +88 -0
- hushscript/generated/models/active_hold_kind.py +18 -0
- hushscript/generated/models/agent_claim_request.py +61 -0
- hushscript/generated/models/agent_claim_response.py +85 -0
- hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
- hushscript/generated/models/agent_signup_request.py +118 -0
- hushscript/generated/models/agent_signup_request_agent.py +80 -0
- hushscript/generated/models/agent_signup_response.py +111 -0
- hushscript/generated/models/buy_minutes_body.py +89 -0
- hushscript/generated/models/card.py +132 -0
- hushscript/generated/models/cards_response.py +90 -0
- hushscript/generated/models/complete_upload_body.py +107 -0
- hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
- hushscript/generated/models/context_dictionary.py +143 -0
- hushscript/generated/models/context_prompt_preset.py +118 -0
- hushscript/generated/models/create_setup_session_body.py +62 -0
- hushscript/generated/models/create_upload_body.py +254 -0
- hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
- hushscript/generated/models/credit_ledger_entry.py +113 -0
- hushscript/generated/models/credit_ledger_response.py +90 -0
- hushscript/generated/models/deleted_transcript.py +148 -0
- hushscript/generated/models/deleted_transcript_cause.py +18 -0
- hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
- hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
- hushscript/generated/models/deleted_transcripts_response.py +90 -0
- hushscript/generated/models/dictionary_entry.py +123 -0
- hushscript/generated/models/dictionary_entry_category.py +37 -0
- hushscript/generated/models/error.py +155 -0
- hushscript/generated/models/error_details.py +94 -0
- hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
- hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
- hushscript/generated/models/holds_response.py +90 -0
- hushscript/generated/models/job.py +309 -0
- hushscript/generated/models/job_failure_kind_type_1.py +16 -0
- hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
- hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
- hushscript/generated/models/job_list.py +90 -0
- hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
- hushscript/generated/models/list_jobs_active.py +17 -0
- hushscript/generated/models/list_org_matters_status.py +16 -0
- hushscript/generated/models/options_summary.py +135 -0
- hushscript/generated/models/org_audit_entry.py +105 -0
- hushscript/generated/models/org_audit_response.py +90 -0
- hushscript/generated/models/org_invoice.py +175 -0
- hushscript/generated/models/org_invoices_response.py +90 -0
- hushscript/generated/models/org_ledger_entry.py +155 -0
- hushscript/generated/models/org_ledger_response.py +90 -0
- hushscript/generated/models/org_matter.py +79 -0
- hushscript/generated/models/org_matter_status.py +16 -0
- hushscript/generated/models/org_matters_response.py +75 -0
- hushscript/generated/models/org_member.py +123 -0
- hushscript/generated/models/org_members_response.py +90 -0
- hushscript/generated/models/org_summary_response.py +482 -0
- hushscript/generated/models/org_transcript.py +103 -0
- hushscript/generated/models/org_transcripts_response.py +90 -0
- hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
- hushscript/generated/models/org_usage_report_group_by.py +17 -0
- hushscript/generated/models/org_usage_response.py +112 -0
- hushscript/generated/models/org_usage_response_group_by.py +17 -0
- hushscript/generated/models/org_usage_row.py +86 -0
- hushscript/generated/models/pack.py +107 -0
- hushscript/generated/models/packs_response.py +75 -0
- hushscript/generated/models/patch_org_member_body.py +102 -0
- hushscript/generated/models/patch_org_member_response_200.py +61 -0
- hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
- hushscript/generated/models/presigned_part_url.py +69 -0
- hushscript/generated/models/purchase_response.py +76 -0
- hushscript/generated/models/schedule_org_custody_body.py +66 -0
- hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
- hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
- hushscript/generated/models/set_default_card_body.py +61 -0
- hushscript/generated/models/set_default_card_response_200.py +61 -0
- hushscript/generated/models/setup_session_response.py +77 -0
- hushscript/generated/models/transaction.py +183 -0
- hushscript/generated/models/transactions_response.py +98 -0
- hushscript/generated/models/transcript_detail.py +194 -0
- hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
- hushscript/generated/models/transcript_envelope.py +89 -0
- hushscript/generated/models/transcript_envelope_translations.py +62 -0
- hushscript/generated/models/transcript_language.py +75 -0
- hushscript/generated/models/transcript_list.py +90 -0
- hushscript/generated/models/transcript_list_row.py +152 -0
- hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
- hushscript/generated/models/transcription_context_response.py +95 -0
- hushscript/generated/models/upload_complete_response.py +100 -0
- hushscript/generated/models/upload_create_response.py +148 -0
- hushscript/generated/models/upload_create_response_funding.py +16 -0
- hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
- hushscript/generated/models/upload_limits.py +86 -0
- hushscript/generated/models/upload_part_response_200.py +69 -0
- hushscript/generated/models/utterance.py +85 -0
- hushscript/generated/scopes.py +42 -0
- hushscript/generated/types.py +54 -0
- hushscript/money.py +79 -0
- hushscript/paginate.py +78 -0
- hushscript/py.typed +0 -0
- hushscript/resources/__init__.py +1 -0
- hushscript/resources/_params.py +33 -0
- hushscript/resources/account.py +121 -0
- hushscript/resources/agent.py +421 -0
- hushscript/resources/billing.py +567 -0
- hushscript/resources/jobs.py +105 -0
- hushscript/resources/transcripts.py +209 -0
- hushscript/transcribe/__init__.py +1 -0
- hushscript/transcribe/apipeline.py +234 -0
- hushscript/transcribe/aupload.py +197 -0
- hushscript/transcribe/heartbeat.py +78 -0
- hushscript/transcribe/pipeline.py +312 -0
- hushscript/transcribe/preflight.py +272 -0
- hushscript/transcribe/probe.py +185 -0
- hushscript/transcribe/resume.py +115 -0
- hushscript/transcribe/segment.py +23 -0
- hushscript/transcribe/session.py +34 -0
- hushscript/transcribe/sniff.py +30 -0
- hushscript/transcribe/source.py +218 -0
- hushscript/transcribe/upload.py +498 -0
- hushscript/transcribe/wait.py +124 -0
- hushscript/transcript.py +197 -0
- hushscript-1.0.0.dist-info/METADATA +502 -0
- hushscript-1.0.0.dist-info/RECORD +187 -0
- hushscript-1.0.0.dist-info/WHEEL +4 -0
- hushscript-1.0.0.dist-info/licenses/LICENSE +21 -0
hushscript/_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
|