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