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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import fields as fields_of
|
|
6
|
+
from typing import TYPE_CHECKING, Final, TypeVar
|
|
7
|
+
|
|
8
|
+
from hushscript._config import acceptable_token
|
|
9
|
+
from hushscript._errors import (
|
|
10
|
+
HushscriptError,
|
|
11
|
+
OutcomeUnknownError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
describe_failure,
|
|
14
|
+
forget_body,
|
|
15
|
+
raise_for_response,
|
|
16
|
+
redacted_cause,
|
|
17
|
+
)
|
|
18
|
+
from hushscript._http import UNKNOWN_HINTS
|
|
19
|
+
from hushscript._json import ExtraFields, as_dict, as_list, extra_fields, loads
|
|
20
|
+
from hushscript._redact import MIN_SECRET_TAIL, REDACTED, Secret
|
|
21
|
+
from hushscript._response import bad_response, parsed
|
|
22
|
+
from hushscript._retry import CLAIM, ROTATE
|
|
23
|
+
from hushscript._shape import INT_LIMIT, as_text, as_whole, check_shape, to_datetime
|
|
24
|
+
from hushscript._shapes import AGENT_SIGNUP
|
|
25
|
+
from hushscript.money import Money, to_money
|
|
26
|
+
from hushscript.resources._params import path_id
|
|
27
|
+
from hushscript.resources.account import Balance, minutes_of
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
from _typeshed import DataclassInstance
|
|
35
|
+
|
|
36
|
+
from hushscript._redact import Credentials
|
|
37
|
+
from hushscript.transcribe.session import AsyncSession, SyncSession
|
|
38
|
+
|
|
39
|
+
CLAIM_ROUTE: Final = "POST /v1/agent/accounts/{signup_id}/claim"
|
|
40
|
+
ROTATE_ROUTE: Final = "POST /v1/agent/credentials/rotate"
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _repr_without(value: DataclassInstance, secret: str) -> str:
|
|
46
|
+
"""A dataclass repr that names `secret` and shows the marker in place of its value (R79). The
|
|
47
|
+
field stays a plain readable attribute; `asdict`, `json` and `pickle` carry it."""
|
|
48
|
+
shown = [
|
|
49
|
+
f"{field.name}={REDACTED}"
|
|
50
|
+
if field.name == secret
|
|
51
|
+
else f"{field.name}={getattr(value, field.name)!r}"
|
|
52
|
+
for field in fields_of(value)
|
|
53
|
+
if field.repr
|
|
54
|
+
]
|
|
55
|
+
return f"{type(value).__name__}({', '.join(shown)})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class AgentInfo(ExtraFields):
|
|
60
|
+
"""The agent an account is opened for."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
platform: str
|
|
64
|
+
contact_url: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
68
|
+
class AgentSignup(ExtraFields):
|
|
69
|
+
"""What `agent_signup` returns. `repr` and `str` show `claim_secret` as a marker."""
|
|
70
|
+
|
|
71
|
+
signup_id: str
|
|
72
|
+
"""Passed back to `agent_claim` verbatim."""
|
|
73
|
+
claim_secret: str
|
|
74
|
+
"""Shown once; `agent_claim` needs it."""
|
|
75
|
+
checkout_url: str
|
|
76
|
+
"""The hosted checkout page where the account is paid for; it expires at `expires_at`."""
|
|
77
|
+
price: Money
|
|
78
|
+
expires_at: datetime
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return _repr_without(self, "claim_secret")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
85
|
+
class AgentAccount(ExtraFields):
|
|
86
|
+
"""What `agent_claim` returns. `repr` and `str` show `token` as a marker."""
|
|
87
|
+
|
|
88
|
+
token: str
|
|
89
|
+
"""The account's token, shown once: write it down before anything else can fail."""
|
|
90
|
+
user_id: str
|
|
91
|
+
scopes: list[str]
|
|
92
|
+
balance: Balance
|
|
93
|
+
"""The pack just granted. A new account holds nothing and has no expiry in the answer."""
|
|
94
|
+
|
|
95
|
+
__hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return _repr_without(self, "token")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
102
|
+
class RotatedToken(ExtraFields):
|
|
103
|
+
"""What `rotate_token` returns. `repr` and `str` show `token` as a marker."""
|
|
104
|
+
|
|
105
|
+
token: str
|
|
106
|
+
"""The successor token, shown once; the client already sends it."""
|
|
107
|
+
token_id: str
|
|
108
|
+
scopes: list[str]
|
|
109
|
+
expires_at: datetime | None
|
|
110
|
+
"""None when the token never expires."""
|
|
111
|
+
|
|
112
|
+
__hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return _repr_without(self, "token")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def accept_terms_required() -> ValidationError:
|
|
119
|
+
return ValidationError(
|
|
120
|
+
"accept_terms is required: pass the policy version you accept. "
|
|
121
|
+
"It is never filled in for you.",
|
|
122
|
+
code="accept_terms_required",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_accept_terms(accept_terms: object) -> str:
|
|
127
|
+
if not isinstance(accept_terms, str) or not accept_terms.strip():
|
|
128
|
+
raise accept_terms_required()
|
|
129
|
+
return accept_terms
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def signup_body( # mirrors the keyword surface of `agent_signup`
|
|
133
|
+
*,
|
|
134
|
+
email: str,
|
|
135
|
+
pack: str,
|
|
136
|
+
accept_terms: str,
|
|
137
|
+
agent: AgentInfo,
|
|
138
|
+
data_region: str | None,
|
|
139
|
+
dry_run: bool,
|
|
140
|
+
) -> dict[str, object]:
|
|
141
|
+
who: dict[str, object] = {"name": agent.name, "platform": agent.platform}
|
|
142
|
+
if agent.contact_url is not None:
|
|
143
|
+
who["contact_url"] = agent.contact_url
|
|
144
|
+
body: dict[str, object] = {
|
|
145
|
+
"contact_email": email,
|
|
146
|
+
"pack_id": pack,
|
|
147
|
+
"accept_terms_version": accept_terms,
|
|
148
|
+
"agent": who,
|
|
149
|
+
}
|
|
150
|
+
if data_region is not None:
|
|
151
|
+
body["data_region"] = data_region
|
|
152
|
+
if dry_run:
|
|
153
|
+
body["dry_run"] = True
|
|
154
|
+
return body
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def signup_of(body: object) -> AgentSignup:
|
|
158
|
+
fields = as_dict(body) or {}
|
|
159
|
+
# R81: the secret is held in a Secret until the result is built, and the body other frames
|
|
160
|
+
# share carries the marker, which is still a string to the shape check
|
|
161
|
+
text = fields.get("claim_secret")
|
|
162
|
+
claim_secret = Secret(text if isinstance(text, str) else "")
|
|
163
|
+
if isinstance(text, str):
|
|
164
|
+
fields["claim_secret"] = REDACTED
|
|
165
|
+
del text
|
|
166
|
+
check_shape(body, AGENT_SIGNUP, "signup response")
|
|
167
|
+
return AgentSignup(
|
|
168
|
+
_extra=extra_fields(
|
|
169
|
+
fields, "signup_id", "claim_secret", "checkout_url", "amount", "currency", "expires_at"
|
|
170
|
+
),
|
|
171
|
+
signup_id=as_text(fields["signup_id"]),
|
|
172
|
+
claim_secret=claim_secret.reveal(),
|
|
173
|
+
checkout_url=as_text(fields["checkout_url"]),
|
|
174
|
+
price=to_money(as_whole(fields["amount"]), as_text(fields["currency"])),
|
|
175
|
+
expires_at=to_datetime(fields["expires_at"]),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _strings(value: object) -> list[str] | None:
|
|
180
|
+
items = as_list(value)
|
|
181
|
+
if items is None or not all(isinstance(item, str) for item in items):
|
|
182
|
+
return None
|
|
183
|
+
return [as_text(item) for item in items]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _whole(value: object) -> bool:
|
|
187
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _count(value: object) -> bool:
|
|
191
|
+
return _whole(value) and isinstance(value, int) and 0 <= value < INT_LIMIT
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _claim_usable(fields: dict[str, object]) -> bool:
|
|
195
|
+
return (
|
|
196
|
+
isinstance(fields.get("user_id"), str)
|
|
197
|
+
and _strings(fields.get("scopes")) is not None
|
|
198
|
+
and _count(fields.get("balance_seconds"))
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _expiry_usable(fields: dict[str, object]) -> bool:
|
|
203
|
+
expires = fields.get("expires_at")
|
|
204
|
+
return expires is None or (
|
|
205
|
+
_whole(expires) and isinstance(expires, int) and 0 <= expires <= _MAX_SECONDS
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_MAX_SECONDS: Final = 253_402_300_799
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _rotate_usable(fields: dict[str, object]) -> bool:
|
|
213
|
+
return (
|
|
214
|
+
isinstance(fields.get("pat_id"), str)
|
|
215
|
+
and _strings(fields.get("scopes")) is not None
|
|
216
|
+
and _expiry_usable(fields)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _decode(response: httpx.Response) -> dict[str, object]:
|
|
221
|
+
try:
|
|
222
|
+
return as_dict(loads(response.content)) or {}
|
|
223
|
+
except (ValueError, RecursionError):
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _unknown(
|
|
228
|
+
route: str,
|
|
229
|
+
hint_key: str,
|
|
230
|
+
seen: Secret | None,
|
|
231
|
+
credentials: Credentials,
|
|
232
|
+
reason: str | None = None,
|
|
233
|
+
) -> OutcomeUnknownError:
|
|
234
|
+
because = "" if reason is None else f" ({reason})"
|
|
235
|
+
return OutcomeUnknownError(
|
|
236
|
+
credentials.scrub(
|
|
237
|
+
f"The answer to {route} could not be used{because}, so the token may be lost."
|
|
238
|
+
),
|
|
239
|
+
hint=UNKNOWN_HINTS[hint_key],
|
|
240
|
+
received_token=seen,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def lost_credential( # one place that builds the spec 7.4 error
|
|
245
|
+
response: httpx.Response,
|
|
246
|
+
route: str,
|
|
247
|
+
hint_key: str,
|
|
248
|
+
seen: Secret | None,
|
|
249
|
+
credentials: Credentials,
|
|
250
|
+
) -> OutcomeUnknownError:
|
|
251
|
+
"""A claim or rotation answered 2xx without a usable credential: it was minted and may be
|
|
252
|
+
lost, exactly as if the answer never arrived (spec 7.4). `seen` is whatever the answer carried
|
|
253
|
+
in its `pat` field, kept on the error so it is not lost."""
|
|
254
|
+
failure = _unknown(route, hint_key, seen, credentials)
|
|
255
|
+
failure.__cause__ = bad_response(response, "credential response")
|
|
256
|
+
return failure
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _stray_credential(
|
|
260
|
+
failure: Exception, route: str, hint_key: str, seen: Secret | None, credentials: Credentials
|
|
261
|
+
) -> OutcomeUnknownError:
|
|
262
|
+
"""The same loss when the SDK itself failed on the answer (R61): the original rides along as
|
|
263
|
+
a redacted cause, and `seen` is kept on the error."""
|
|
264
|
+
lost = _unknown(route, hint_key, seen, credentials, describe_failure(failure, credentials))
|
|
265
|
+
lost.__cause__ = redacted_cause(failure, credentials)
|
|
266
|
+
return lost
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _take_pat(fields: dict[str, object]) -> Secret | None:
|
|
270
|
+
"""The `pat` the answer carried, moved out of the decoded body into a Secret so no frame that
|
|
271
|
+
shares the body holds it as text (R81)."""
|
|
272
|
+
pat = fields.pop("pat", None)
|
|
273
|
+
return Secret(pat) if isinstance(pat, str) else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _register(seen: Secret | None, credentials: Credentials) -> None:
|
|
277
|
+
"""Scrubs a token the answer carried from every text from now on, trimmed, once it passes the
|
|
278
|
+
token check; anything else the server put in `pat` is never registered."""
|
|
279
|
+
if seen is not None and acceptable_token(seen.reveal().strip()):
|
|
280
|
+
credentials.remember(seen.reveal().strip())
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def settle(
|
|
284
|
+
response: httpx.Response,
|
|
285
|
+
credentials: Credentials,
|
|
286
|
+
route: str,
|
|
287
|
+
hint_key: str,
|
|
288
|
+
use: Callable[[dict[str, object], Secret | None], T],
|
|
289
|
+
) -> T:
|
|
290
|
+
"""`use` of a claim or rotation answer. A failure of the SDK's own while reading it, after the
|
|
291
|
+
answer came, is as lost as an answer that never arrived (spec 7.4 and R61): an
|
|
292
|
+
`OutcomeUnknownError` that keeps the `pat` the answer carried. It is raised once the handler
|
|
293
|
+
has ended, so the unredacted original is not left on its `__context__`."""
|
|
294
|
+
raise_for_response(response, credentials=credentials)
|
|
295
|
+
seen: Secret | None = None
|
|
296
|
+
lost: HushscriptError
|
|
297
|
+
fields: dict[str, object] = {}
|
|
298
|
+
try:
|
|
299
|
+
fields = _decode(response)
|
|
300
|
+
seen = _take_pat(fields)
|
|
301
|
+
_register(seen, credentials)
|
|
302
|
+
return use(fields, seen)
|
|
303
|
+
except HushscriptError as failure:
|
|
304
|
+
traceback.clear_frames(failure.__traceback__)
|
|
305
|
+
lost = failure
|
|
306
|
+
except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
|
|
307
|
+
lost = _stray_credential(failure, route, hint_key, seen, credentials)
|
|
308
|
+
finally:
|
|
309
|
+
del fields
|
|
310
|
+
forget_body(response) # the body held the token as text
|
|
311
|
+
raise lost
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def account_of(token: Secret, fields: dict[str, object]) -> AgentAccount:
|
|
315
|
+
seconds = as_whole(fields["balance_seconds"])
|
|
316
|
+
return AgentAccount(
|
|
317
|
+
_extra=extra_fields(fields, "pat", "user_id", "scopes", "balance_seconds"),
|
|
318
|
+
token=token.reveal(),
|
|
319
|
+
user_id=as_text(fields["user_id"]),
|
|
320
|
+
scopes=_strings(fields["scopes"]) or [],
|
|
321
|
+
balance=Balance(
|
|
322
|
+
seconds=seconds,
|
|
323
|
+
minutes=minutes_of(seconds),
|
|
324
|
+
held_seconds=0,
|
|
325
|
+
expires_at=None,
|
|
326
|
+
purchases_remaining=None,
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def rotated_of(token: Secret, fields: dict[str, object]) -> RotatedToken:
|
|
332
|
+
expires = fields.get("expires_at")
|
|
333
|
+
return RotatedToken(
|
|
334
|
+
_extra=extra_fields(fields, "pat", "pat_id", "scopes", "expires_at"),
|
|
335
|
+
token=token.reveal(),
|
|
336
|
+
token_id=as_text(fields["pat_id"]),
|
|
337
|
+
scopes=_strings(fields["scopes"]) or [],
|
|
338
|
+
expires_at=None if expires is None else to_datetime(expires),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def claim_path(signup_id: object) -> str:
|
|
343
|
+
return f"/v1/agent/accounts/{path_id(signup_id)}/claim"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def remember_claim_secret(credentials: Credentials, claim_secret: Secret) -> None:
|
|
347
|
+
"""R96: the claim secret is scrubbed from every error text while the claim runs, like the
|
|
348
|
+
token. A text shorter than a token's secret part is not one the server issues, and scrubbing
|
|
349
|
+
it would cut every word that contains it, so it is left out."""
|
|
350
|
+
if len(claim_secret.reveal()) >= MIN_SECRET_TAIL:
|
|
351
|
+
credentials.remember(claim_secret.reveal())
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def claim_body(claim_secret: Secret) -> dict[str, object]:
|
|
355
|
+
return {"claim_secret": claim_secret.reveal()}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def signed_up(response: httpx.Response, credentials: Credentials) -> AgentSignup:
|
|
359
|
+
try:
|
|
360
|
+
return parsed(response, credentials, signup_of)
|
|
361
|
+
finally:
|
|
362
|
+
forget_body(response) # the body held the claim secret as text
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def claimed(response: httpx.Response, credentials: Credentials) -> AgentAccount:
|
|
366
|
+
def use(fields: dict[str, object], seen: Secret | None) -> AgentAccount:
|
|
367
|
+
if seen is None or not acceptable_token(seen.reveal()) or not _claim_usable(fields):
|
|
368
|
+
raise lost_credential(response, CLAIM_ROUTE, CLAIM, seen, credentials)
|
|
369
|
+
return account_of(seen, fields)
|
|
370
|
+
|
|
371
|
+
return settle(response, credentials, CLAIM_ROUTE, CLAIM, use)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def rotated(response: httpx.Response, credentials: Credentials) -> RotatedToken:
|
|
375
|
+
"""The rotation answer. The server revoked the old token when it answered 2xx, so a token that
|
|
376
|
+
passes the token check is the one the client sends from then on (spec 7.4), whatever else is
|
|
377
|
+
wrong with the answer, and whatever the SDK then fails on: the error carries it as
|
|
378
|
+
`received_token`, so the caller always holds the token the client sends. The one exception is
|
|
379
|
+
an expiry that cannot be read, as in the TypeScript SDK: the answer is then not trusted at all,
|
|
380
|
+
the old token stays in use and the new one is only kept on the error."""
|
|
381
|
+
|
|
382
|
+
def use(fields: dict[str, object], seen: Secret | None) -> RotatedToken:
|
|
383
|
+
token_usable = seen is not None and acceptable_token(seen.reveal())
|
|
384
|
+
if seen is not None and token_usable and _expiry_usable(fields):
|
|
385
|
+
credentials.replace(seen.reveal())
|
|
386
|
+
if seen is None or not token_usable or not _rotate_usable(fields):
|
|
387
|
+
raise lost_credential(response, ROTATE_ROUTE, ROTATE, seen, credentials)
|
|
388
|
+
return rotated_of(seen, fields)
|
|
389
|
+
|
|
390
|
+
return settle(response, credentials, ROTATE_ROUTE, ROTATE, use)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class Agent:
|
|
394
|
+
"""`hs.agent`: the calls an agent account makes with its own token."""
|
|
395
|
+
|
|
396
|
+
def __init__(self, session: SyncSession) -> None:
|
|
397
|
+
self._session = session
|
|
398
|
+
|
|
399
|
+
def rotate_token(self) -> RotatedToken:
|
|
400
|
+
"""Mints a successor token and revokes the one in use. The client sends the new token from
|
|
401
|
+
then on, to retries already queued as well; store the returned one right away. It is never
|
|
402
|
+
retried. Apart from an API error answer, a failure after the request was sent is an
|
|
403
|
+
`OutcomeUnknownError` whose hint says what to check: a lost answer, an answer without a
|
|
404
|
+
usable token, or a failure of the SDK's own while reading it. The error's `received_token`
|
|
405
|
+
holds the token the answer carried; the client sends it from then on when it passes the
|
|
406
|
+
token check and the expiry can be read, and keeps its old token otherwise."""
|
|
407
|
+
response = self._session.http.post("/v1/agent/credentials/rotate")
|
|
408
|
+
return rotated(response, self._session.credentials)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class AsyncAgent:
|
|
412
|
+
"""`hs.agent` on the asyncio client."""
|
|
413
|
+
|
|
414
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
415
|
+
self._session = session
|
|
416
|
+
|
|
417
|
+
async def rotate_token(self) -> RotatedToken:
|
|
418
|
+
"""`hs.agent.rotate_token()` on the asyncio client; a failure after the request was sent, an
|
|
419
|
+
API error answer apart, is the same `OutcomeUnknownError` with `received_token`."""
|
|
420
|
+
response = await self._session.http.post("/v1/agent/credentials/rotate")
|
|
421
|
+
return rotated(response, self._session.credentials)
|