hushscript 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. hushscript/__init__.py +97 -0
  2. hushscript/_client.py +687 -0
  3. hushscript/_clock.py +36 -0
  4. hushscript/_config.py +139 -0
  5. hushscript/_errors.py +563 -0
  6. hushscript/_guard.py +61 -0
  7. hushscript/_http.py +624 -0
  8. hushscript/_json.py +55 -0
  9. hushscript/_log.py +155 -0
  10. hushscript/_models.py +144 -0
  11. hushscript/_pacing.py +51 -0
  12. hushscript/_redact.py +140 -0
  13. hushscript/_response.py +67 -0
  14. hushscript/_retry.py +205 -0
  15. hushscript/_shape.py +197 -0
  16. hushscript/_shapes.py +248 -0
  17. hushscript/_version.py +1 -0
  18. hushscript/format.py +224 -0
  19. hushscript/generated/__init__.py +8 -0
  20. hushscript/generated/api/__init__.py +1 -0
  21. hushscript/generated/api/default/__init__.py +1 -0
  22. hushscript/generated/api/default/buy_minutes.py +248 -0
  23. hushscript/generated/api/default/claim_agent_account.py +238 -0
  24. hushscript/generated/api/default/complete_upload.py +238 -0
  25. hushscript/generated/api/default/create_agent_account_signup.py +261 -0
  26. hushscript/generated/api/default/create_setup_session.py +244 -0
  27. hushscript/generated/api/default/create_upload.py +225 -0
  28. hushscript/generated/api/default/get_account.py +169 -0
  29. hushscript/generated/api/default/get_job.py +198 -0
  30. hushscript/generated/api/default/get_org.py +169 -0
  31. hushscript/generated/api/default/get_transcript.py +226 -0
  32. hushscript/generated/api/default/get_transcription_context.py +173 -0
  33. hushscript/generated/api/default/get_upload_part_urls.py +246 -0
  34. hushscript/generated/api/default/heartbeat_upload.py +197 -0
  35. hushscript/generated/api/default/lift_transcript_hold.py +206 -0
  36. hushscript/generated/api/default/list_credit_ledger.py +229 -0
  37. hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
  38. hushscript/generated/api/default/list_holds.py +233 -0
  39. hushscript/generated/api/default/list_jobs.py +249 -0
  40. hushscript/generated/api/default/list_org_audit.py +221 -0
  41. hushscript/generated/api/default/list_org_invoices.py +221 -0
  42. hushscript/generated/api/default/list_org_ledger.py +221 -0
  43. hushscript/generated/api/default/list_org_matters.py +247 -0
  44. hushscript/generated/api/default/list_org_members.py +221 -0
  45. hushscript/generated/api/default/list_org_transcripts.py +233 -0
  46. hushscript/generated/api/default/list_packs.py +169 -0
  47. hushscript/generated/api/default/list_saved_cards.py +169 -0
  48. hushscript/generated/api/default/list_transactions.py +225 -0
  49. hushscript/generated/api/default/list_transcripts.py +225 -0
  50. hushscript/generated/api/default/org_audit_csv.py +163 -0
  51. hushscript/generated/api/default/org_ledger_csv.py +163 -0
  52. hushscript/generated/api/default/org_usage_report.py +247 -0
  53. hushscript/generated/api/default/org_usage_report_csv.py +229 -0
  54. hushscript/generated/api/default/patch_org_member.py +237 -0
  55. hushscript/generated/api/default/place_transcript_hold.py +206 -0
  56. hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
  57. hushscript/generated/api/default/schedule_org_custody.py +215 -0
  58. hushscript/generated/api/default/set_default_card.py +223 -0
  59. hushscript/generated/api/default/upload_part.py +249 -0
  60. hushscript/generated/catalog.py +138 -0
  61. hushscript/generated/client.py +282 -0
  62. hushscript/generated/errors.py +16 -0
  63. hushscript/generated/hints.py +28 -0
  64. hushscript/generated/models/__init__.py +193 -0
  65. hushscript/generated/models/account_response.py +114 -0
  66. hushscript/generated/models/active_hold.py +88 -0
  67. hushscript/generated/models/active_hold_kind.py +18 -0
  68. hushscript/generated/models/agent_claim_request.py +61 -0
  69. hushscript/generated/models/agent_claim_response.py +85 -0
  70. hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
  71. hushscript/generated/models/agent_signup_request.py +118 -0
  72. hushscript/generated/models/agent_signup_request_agent.py +80 -0
  73. hushscript/generated/models/agent_signup_response.py +111 -0
  74. hushscript/generated/models/buy_minutes_body.py +89 -0
  75. hushscript/generated/models/card.py +132 -0
  76. hushscript/generated/models/cards_response.py +90 -0
  77. hushscript/generated/models/complete_upload_body.py +107 -0
  78. hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
  79. hushscript/generated/models/context_dictionary.py +143 -0
  80. hushscript/generated/models/context_prompt_preset.py +118 -0
  81. hushscript/generated/models/create_setup_session_body.py +62 -0
  82. hushscript/generated/models/create_upload_body.py +254 -0
  83. hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
  84. hushscript/generated/models/credit_ledger_entry.py +113 -0
  85. hushscript/generated/models/credit_ledger_response.py +90 -0
  86. hushscript/generated/models/deleted_transcript.py +148 -0
  87. hushscript/generated/models/deleted_transcript_cause.py +18 -0
  88. hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
  89. hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
  90. hushscript/generated/models/deleted_transcripts_response.py +90 -0
  91. hushscript/generated/models/dictionary_entry.py +123 -0
  92. hushscript/generated/models/dictionary_entry_category.py +37 -0
  93. hushscript/generated/models/error.py +155 -0
  94. hushscript/generated/models/error_details.py +94 -0
  95. hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
  96. hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
  97. hushscript/generated/models/holds_response.py +90 -0
  98. hushscript/generated/models/job.py +309 -0
  99. hushscript/generated/models/job_failure_kind_type_1.py +16 -0
  100. hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
  101. hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
  102. hushscript/generated/models/job_list.py +90 -0
  103. hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
  104. hushscript/generated/models/list_jobs_active.py +17 -0
  105. hushscript/generated/models/list_org_matters_status.py +16 -0
  106. hushscript/generated/models/options_summary.py +135 -0
  107. hushscript/generated/models/org_audit_entry.py +105 -0
  108. hushscript/generated/models/org_audit_response.py +90 -0
  109. hushscript/generated/models/org_invoice.py +175 -0
  110. hushscript/generated/models/org_invoices_response.py +90 -0
  111. hushscript/generated/models/org_ledger_entry.py +155 -0
  112. hushscript/generated/models/org_ledger_response.py +90 -0
  113. hushscript/generated/models/org_matter.py +79 -0
  114. hushscript/generated/models/org_matter_status.py +16 -0
  115. hushscript/generated/models/org_matters_response.py +75 -0
  116. hushscript/generated/models/org_member.py +123 -0
  117. hushscript/generated/models/org_members_response.py +90 -0
  118. hushscript/generated/models/org_summary_response.py +482 -0
  119. hushscript/generated/models/org_transcript.py +103 -0
  120. hushscript/generated/models/org_transcripts_response.py +90 -0
  121. hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
  122. hushscript/generated/models/org_usage_report_group_by.py +17 -0
  123. hushscript/generated/models/org_usage_response.py +112 -0
  124. hushscript/generated/models/org_usage_response_group_by.py +17 -0
  125. hushscript/generated/models/org_usage_row.py +86 -0
  126. hushscript/generated/models/pack.py +107 -0
  127. hushscript/generated/models/packs_response.py +75 -0
  128. hushscript/generated/models/patch_org_member_body.py +102 -0
  129. hushscript/generated/models/patch_org_member_response_200.py +61 -0
  130. hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
  131. hushscript/generated/models/presigned_part_url.py +69 -0
  132. hushscript/generated/models/purchase_response.py +76 -0
  133. hushscript/generated/models/schedule_org_custody_body.py +66 -0
  134. hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
  135. hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
  136. hushscript/generated/models/set_default_card_body.py +61 -0
  137. hushscript/generated/models/set_default_card_response_200.py +61 -0
  138. hushscript/generated/models/setup_session_response.py +77 -0
  139. hushscript/generated/models/transaction.py +183 -0
  140. hushscript/generated/models/transactions_response.py +98 -0
  141. hushscript/generated/models/transcript_detail.py +194 -0
  142. hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
  143. hushscript/generated/models/transcript_envelope.py +89 -0
  144. hushscript/generated/models/transcript_envelope_translations.py +62 -0
  145. hushscript/generated/models/transcript_language.py +75 -0
  146. hushscript/generated/models/transcript_list.py +90 -0
  147. hushscript/generated/models/transcript_list_row.py +152 -0
  148. hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
  149. hushscript/generated/models/transcription_context_response.py +95 -0
  150. hushscript/generated/models/upload_complete_response.py +100 -0
  151. hushscript/generated/models/upload_create_response.py +148 -0
  152. hushscript/generated/models/upload_create_response_funding.py +16 -0
  153. hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
  154. hushscript/generated/models/upload_limits.py +86 -0
  155. hushscript/generated/models/upload_part_response_200.py +69 -0
  156. hushscript/generated/models/utterance.py +85 -0
  157. hushscript/generated/scopes.py +42 -0
  158. hushscript/generated/types.py +54 -0
  159. hushscript/money.py +79 -0
  160. hushscript/paginate.py +78 -0
  161. hushscript/py.typed +0 -0
  162. hushscript/resources/__init__.py +1 -0
  163. hushscript/resources/_params.py +33 -0
  164. hushscript/resources/account.py +121 -0
  165. hushscript/resources/agent.py +421 -0
  166. hushscript/resources/billing.py +567 -0
  167. hushscript/resources/jobs.py +105 -0
  168. hushscript/resources/transcripts.py +209 -0
  169. hushscript/transcribe/__init__.py +1 -0
  170. hushscript/transcribe/apipeline.py +234 -0
  171. hushscript/transcribe/aupload.py +197 -0
  172. hushscript/transcribe/heartbeat.py +78 -0
  173. hushscript/transcribe/pipeline.py +312 -0
  174. hushscript/transcribe/preflight.py +272 -0
  175. hushscript/transcribe/probe.py +185 -0
  176. hushscript/transcribe/resume.py +115 -0
  177. hushscript/transcribe/segment.py +23 -0
  178. hushscript/transcribe/session.py +34 -0
  179. hushscript/transcribe/sniff.py +30 -0
  180. hushscript/transcribe/source.py +218 -0
  181. hushscript/transcribe/upload.py +498 -0
  182. hushscript/transcribe/wait.py +124 -0
  183. hushscript/transcript.py +197 -0
  184. hushscript-1.0.0.dist-info/METADATA +502 -0
  185. hushscript-1.0.0.dist-info/RECORD +187 -0
  186. hushscript-1.0.0.dist-info/WHEEL +4 -0
  187. hushscript-1.0.0.dist-info/licenses/LICENSE +21 -0
hushscript/_log.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import sys
6
+ from contextlib import contextmanager
7
+ from contextvars import ContextVar
8
+ from typing import TYPE_CHECKING, Final
9
+
10
+ import httpx
11
+
12
+ from ._redact import redact
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Generator
16
+
17
+ LOG_LEVELS: Final = ("off", "debug")
18
+ _LOGGER: Final = logging.getLogger("hushscript")
19
+
20
+ # The label of the signed R2 request this thread or task is sending, or None. httpx logs every
21
+ # request's full URL at INFO on its own logger ("HTTP Request: PUT <url>"), httpcore traces its
22
+ # connection and request events at DEBUG on its own, and a signed part URL carries the signature in
23
+ # its query (spec 10: R2 URLs only ever appear as `r2:part/{n}`).
24
+ _SIGNED_REQUEST: ContextVar[str | None] = ContextVar("hushscript_signed_request", default=None)
25
+
26
+
27
+ _URL_TEXT: Final = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s'\"<>]+")
28
+ # The query keys that make a URL signed: S3/R2 presigned (X-Amz-*), the mock's `sig`, and the
29
+ # generic names other signers use.
30
+ _SIGNED_QUERY: Final = re.compile(
31
+ r"[?&](?:sig|signature|token|expires|x-amz-[a-z0-9-]+|x-goog-[a-z0-9-]+)=", re.IGNORECASE
32
+ )
33
+
34
+
35
+ def _looks_signed(url: str) -> bool:
36
+ return _SIGNED_QUERY.search(url) is not None
37
+
38
+
39
+ def _hide_text(text: str, label: str) -> str:
40
+ return _URL_TEXT.sub(lambda m: label if _looks_signed(m.group(0)) else m.group(0), text)
41
+
42
+
43
+ def _hide(value: object, label: str) -> object:
44
+ """`value` with every signed-looking URL in it swapped for `label`. An object that is neither
45
+ text nor a number (an httpx.Request, a URL object of either library) is swapped for its hidden
46
+ text when its own text shows a signed URL."""
47
+ if isinstance(value, str):
48
+ return _hide_text(value, label)
49
+ if value is None or isinstance(value, (bool, int, float)):
50
+ return value
51
+ if isinstance(value, bytes):
52
+ return _hide_text(value.decode("latin-1"), label).encode("latin-1")
53
+ try:
54
+ shown = f"{value}"
55
+ shown_repr = repr(value)
56
+ except Exception: # noqa: BLE001 # a log call must not fail on a hostile __str__
57
+ return value
58
+ if _looks_signed(shown) or _looks_signed(shown_repr):
59
+ return _hide_text(shown_repr if _looks_signed(shown_repr) else shown, label)
60
+ return value
61
+
62
+
63
+ def _hide_arg(arg: object, label: str) -> object:
64
+ if isinstance(arg, (str, httpx.URL)) and "://" in str(arg):
65
+ return label
66
+ return _hide(arg, label)
67
+
68
+
69
+ class _SignedUrlFilter(logging.Filter):
70
+ """Swaps the URL in an httpx record for the request's label while a signed request is out.
71
+
72
+ It only rewrites records logged by the thread or task that is sending the signed request, so
73
+ other httpx users in the process keep their log lines, and it never touches a level. A URL
74
+ that httpx has already formatted into the message, or put in a mapping argument, goes too.
75
+ """
76
+
77
+ def filter(self, record: logging.LogRecord) -> bool:
78
+ label = _SIGNED_REQUEST.get()
79
+ if label is None:
80
+ return True
81
+ if isinstance(record.msg, str):
82
+ record.msg = _hide(record.msg, label)
83
+ if isinstance(record.args, tuple):
84
+ record.args = tuple(_hide_arg(arg, label) for arg in record.args)
85
+ elif isinstance(record.args, dict):
86
+ record.args = {key: _hide(item, label) for key, item in record.args.items()}
87
+ return True
88
+
89
+
90
+ _SIGNED_URL_FILTER: Final = _SignedUrlFilter()
91
+ _THIRD_PARTY_LOGGERS: Final = (
92
+ "httpx",
93
+ "httpcore",
94
+ "httpcore.connection",
95
+ "httpcore.http11",
96
+ "httpcore.http2",
97
+ "httpcore.proxy",
98
+ "httpcore.socks",
99
+ )
100
+
101
+
102
+ @contextmanager
103
+ def hiding_signed_url(label: str) -> Generator[None]:
104
+ """Within the block, httpx's own request log shows `label` where the signed URL would be."""
105
+ token = _SIGNED_REQUEST.set(label)
106
+ try:
107
+ yield
108
+ finally:
109
+ _SIGNED_REQUEST.reset(token)
110
+
111
+
112
+ class RequestLog:
113
+ """One line per attempt: method, path, status, attempt, duration and retry delay (spec 10).
114
+
115
+ Never headers, query strings, bodies, titles or transcript text; the caller passes a path
116
+ without a query and direct-to-R2 URLs arrive as `r2:part/{n}`.
117
+ """
118
+
119
+ def __init__(self, level: str = "off") -> None:
120
+ # httpx and httpcore log on their own, whatever our level; the filter is a no-op until a
121
+ # signed request is out. Adding it twice is harmless. A logger's filters see only the
122
+ # records it creates, so each httpcore logger carries it.
123
+ for name in _THIRD_PARTY_LOGGERS:
124
+ logging.getLogger(name).addFilter(_SIGNED_URL_FILTER)
125
+ self.enabled = level == "debug"
126
+ if self.enabled:
127
+ _LOGGER.setLevel(logging.DEBUG)
128
+ if not _LOGGER.handlers:
129
+ handler = logging.StreamHandler(sys.stderr)
130
+ handler.setFormatter(logging.Formatter("%(message)s"))
131
+ _LOGGER.addHandler(handler)
132
+
133
+ def note(self, message: str) -> None:
134
+ """A line outside the request/attempt shape, such as a heartbeat that failed; redacted."""
135
+ if self.enabled:
136
+ _LOGGER.debug(redact(f"hushscript {message}"))
137
+
138
+ def attempt( # one call site per field of the line
139
+ self,
140
+ method: str,
141
+ path: str,
142
+ status: int | None,
143
+ attempt: int,
144
+ duration_ms: float,
145
+ retry_delay: float | None,
146
+ ) -> None:
147
+ if not self.enabled:
148
+ return
149
+ outcome = "error" if status is None else str(status)
150
+ delay = "" if retry_delay is None else f" retry_in={retry_delay:g}s"
151
+ _LOGGER.debug(
152
+ redact(
153
+ f"hushscript {method} {path} {outcome} attempt={attempt} {duration_ms:.0f}ms{delay}"
154
+ )
155
+ )
hushscript/_models.py ADDED
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ from ._json import ExtraFields, as_dict, as_list, extra_fields
7
+ from ._shape import (
8
+ as_flag,
9
+ as_text,
10
+ check_shape,
11
+ maybe_flag,
12
+ maybe_text,
13
+ maybe_whole,
14
+ to_datetime,
15
+ )
16
+ from ._shapes import JOB
17
+
18
+ if TYPE_CHECKING:
19
+ from datetime import datetime
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class UploadingProgress:
24
+ """Bytes the API has acknowledged; `percent` is 0 while the call waits for a create slot."""
25
+
26
+ percent: int
27
+ bytes_sent: int
28
+ bytes_total: int
29
+ stage: Literal["uploading"] = "uploading"
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ProcessingProgress:
34
+ """The job's state while it is queued or processing."""
35
+
36
+ state: str
37
+ stage: Literal["processing"] = "processing"
38
+
39
+
40
+ Progress = UploadingProgress | ProcessingProgress
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class JobOptions(ExtraFields):
45
+ """What the job was submitted with, from the API's content-free summary: it says whether
46
+ keyterms and a prompt were set, never what they were. Unknown API keys remain available
47
+ in `extra`."""
48
+
49
+ language_code: str | None
50
+ """The forced language, or None when it was auto-detected."""
51
+ speaker_detection: bool
52
+ medical_mode: bool
53
+ multichannel: bool
54
+ translation_targets: list[str]
55
+ keyterms: bool
56
+ prompt: bool
57
+ auto_generate_insights: bool | None
58
+ """The upload's insights override, or None when it used the account default."""
59
+
60
+ __hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Job(ExtraFields):
65
+ """A processing job (spec 6.4). Fields without friendly attributes remain in `extra`."""
66
+
67
+ id: str
68
+ state: str
69
+ """`uploading`, `fetching`, `queued`, `processing`, `done` or `failed`; a state the SDK does
70
+ not know yet passes through as the API's own string."""
71
+ transcript_id: str | None
72
+ error: str | None
73
+ """The failure text of a failed job; opaque."""
74
+ failure_kind: str | None
75
+ """`media_unreadable` or `media_undecodable` when the file itself could not be read."""
76
+ mode: str
77
+ declared_seconds: int | None
78
+ verified_seconds: int | None
79
+ actual_seconds: int | None
80
+ billable_seconds: int | None
81
+ retry_of: str | None
82
+ created_at: datetime
83
+ options: JobOptions
84
+
85
+ __hash__ = None # type: ignore[assignment] # holds a JobOptions, which is unhashable
86
+
87
+ @classmethod
88
+ def from_body(cls, body: object) -> Job:
89
+ check_shape(body, JOB, "job response")
90
+ fields = as_dict(body) or {}
91
+ options = as_dict(fields["options"]) or {}
92
+ return cls(
93
+ _extra=extra_fields(
94
+ fields,
95
+ "id",
96
+ "state",
97
+ "transcript_id",
98
+ "error",
99
+ "failure_kind",
100
+ "mode",
101
+ "declared_seconds",
102
+ "verified_seconds",
103
+ "actual_seconds",
104
+ "billable_seconds",
105
+ "retry_of",
106
+ "created_at",
107
+ "options",
108
+ ),
109
+ id=as_text(fields["id"]),
110
+ state=as_text(fields["state"]),
111
+ transcript_id=maybe_text(fields["transcript_id"]),
112
+ error=maybe_text(fields["error"]),
113
+ failure_kind=maybe_text(fields["failure_kind"]),
114
+ mode=as_text(fields["mode"]),
115
+ declared_seconds=maybe_whole(fields["declared_seconds"]),
116
+ verified_seconds=maybe_whole(fields["verified_seconds"]),
117
+ actual_seconds=maybe_whole(fields["actual_seconds"]),
118
+ billable_seconds=maybe_whole(fields["billable_seconds"]),
119
+ retry_of=maybe_text(fields["retry_of"]),
120
+ created_at=to_datetime(fields["created_at"]),
121
+ options=JobOptions(
122
+ _extra=extra_fields(
123
+ options,
124
+ "language_code",
125
+ "speaker_detection",
126
+ "medical_mode",
127
+ "multichannel",
128
+ "translation_targets",
129
+ "keyterms",
130
+ "prompt",
131
+ "auto_generate_insights",
132
+ ),
133
+ language_code=maybe_text(options["language_code"]),
134
+ speaker_detection=as_flag(options["speaker_detection"]),
135
+ medical_mode=as_flag(options["medical_mode"]),
136
+ multichannel=as_flag(options["multichannel"]),
137
+ translation_targets=[
138
+ as_text(t) for t in as_list(options["translation_targets"]) or []
139
+ ],
140
+ keyterms=as_flag(options["keyterms"]),
141
+ prompt=as_flag(options["prompt"]),
142
+ auto_generate_insights=maybe_flag(options["auto_generate_insights"]),
143
+ ),
144
+ )
hushscript/_pacing.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections import deque
5
+ from typing import TYPE_CHECKING, Final
6
+
7
+ if TYPE_CHECKING:
8
+ from ._clock import Clock
9
+
10
+ CREATES_PER_WINDOW: Final = 4
11
+ WINDOW_SECONDS: Final = 60.0
12
+ CREATE_RATE_LIMIT_BACKOFF: Final = (15.0, 30.0, 60.0)
13
+ ACTIVE_JOBS_WAIT: Final = 30.0
14
+ ACTIVE_JOBS_MAX_TRIES: Final = 10
15
+ ACTIVE_JOBS_RETRIES: Final = ACTIVE_JOBS_MAX_TRIES - 1
16
+
17
+
18
+ class TokenBucket:
19
+ """Paces `POST /v1/uploads` to 4 per 60 s per client (spec 7.5).
20
+
21
+ `reserve()` books the next slot and says how long the caller must wait before using it, so the
22
+ sync and async callers sleep on their own clock method.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ clock: Clock,
28
+ capacity: int = CREATES_PER_WINDOW,
29
+ window: float = WINDOW_SECONDS,
30
+ ) -> None:
31
+ self._clock = clock
32
+ self._capacity = capacity
33
+ self._window = window
34
+ self._slots: deque[float] = deque()
35
+ self._lock = threading.Lock()
36
+
37
+ def reserve(self) -> float:
38
+ with self._lock:
39
+ return self._reserve()
40
+
41
+ def _reserve(self) -> float:
42
+ now = self._clock.now()
43
+ while self._slots and self._slots[0] <= now - self._window:
44
+ self._slots.popleft()
45
+ if len(self._slots) < self._capacity:
46
+ self._slots.append(now)
47
+ return 0.0
48
+ wait = self._slots[0] + self._window - now
49
+ self._slots.popleft()
50
+ self._slots.append(now + wait)
51
+ return wait
hushscript/_redact.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Final
5
+ from urllib.parse import quote
6
+
7
+ from ._json import as_dict, as_list
8
+
9
+ REDACTED: Final = "hsr…[redacted]"
10
+ TOKEN_PATTERN: Final = re.compile(r"hsr\d+:[a-z]+:pat\.[0-9a-f]+\.[0-9a-f]+")
11
+ MIN_SECRET_TAIL: Final = 16
12
+ _WHOLE_TOKEN_FORMS: Final = 2
13
+ """A shorter echoed piece of a token is an ordinary substring, not worth redacting everywhere."""
14
+
15
+
16
+ def _patterns(token: str) -> list[re.Pattern[str]]:
17
+ """What an API could echo of `token`, most specific first: the whole token as it is and
18
+ percent-encoded, then what follows `pat.` and the bare secret after the last dot. Matching
19
+ ignores case. A later piece is only ever a substring of an earlier one, so the order keeps the
20
+ id from being left behind."""
21
+ # surrogatepass: a lone surrogate in a registered secret still has a percent-encoded form
22
+ pieces = [token, quote(token, safe="", errors="surrogatepass")]
23
+ _, found, tail = token.partition("pat.")
24
+ if found:
25
+ pieces.append(tail)
26
+ pieces.append(token.rpartition(".")[2])
27
+ return [
28
+ re.compile(re.escape(piece), re.IGNORECASE)
29
+ for index, piece in enumerate(pieces)
30
+ if piece and (index < _WHOLE_TOKEN_FORMS or len(piece) >= MIN_SECRET_TAIL)
31
+ ]
32
+
33
+
34
+ def redact(text: str) -> str:
35
+ return TOKEN_PATTERN.sub(REDACTED, text)
36
+
37
+
38
+ QUERY_REDACTED: Final = "?[redacted]"
39
+ _URL_QUERY: Final = re.compile(
40
+ # a URL with any scheme, or a path at the start of the text or after a space, quote, bracket
41
+ # or `=`; a query stops at a space, quote, angle bracket, `#` or `)`
42
+ r"((?:\b[a-z][a-z0-9+.-]*://[^\s\"'<>?#]+)|(?:(?<![^\s\"'(\[<=])/[^\s\"'<>?#]*))"
43
+ r"\?[^\s\"'<>#)]+",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def redact_queries(text: str) -> str:
49
+ """R96: every URL or path in `text` keeps its scheme, host and path; its query string, which
50
+ holds a signed URL's signature, becomes `?[redacted]`."""
51
+ return _URL_QUERY.sub(lambda match: match.group(1) + QUERY_REDACTED, text)
52
+
53
+
54
+ class Secret:
55
+ """A token or a signed URL held at rest: nothing prints, pickles or copies its text.
56
+
57
+ `repr`, `str`, `format`, `pprint`, `vars` and logging show the redaction marker. A pickle holds
58
+ an empty secret, and a copy is the same holder (it is immutable). `reveal()` is the one way to
59
+ the text.
60
+ """
61
+
62
+ __slots__ = ("_text",)
63
+
64
+ def __init__(self, text: str = "") -> None:
65
+ self._text = text
66
+
67
+ def reveal(self) -> str:
68
+ return self._text
69
+
70
+ def __bool__(self) -> bool:
71
+ return bool(self._text)
72
+
73
+ def __repr__(self) -> str:
74
+ return "Secret(<redacted>)"
75
+
76
+ def __str__(self) -> str:
77
+ return REDACTED
78
+
79
+ def __format__(self, format_spec: str) -> str:
80
+ return format(REDACTED, format_spec)
81
+
82
+ def __reduce__(self) -> tuple[type[Secret], tuple[str]]:
83
+ return (Secret, ("",))
84
+
85
+ def __copy__(self) -> Secret:
86
+ return self
87
+
88
+ def __deepcopy__(self, memo: dict[int, object]) -> Secret:
89
+ return self
90
+
91
+
92
+ class Credentials:
93
+ """The API token and every earlier one, kept out of every representation.
94
+
95
+ The token is held here and nowhere else in the client: not in a header on the httpx client, not
96
+ in a dataclass field. The transport reads it per request.
97
+ """
98
+
99
+ __slots__ = ("_known", "_tokens")
100
+
101
+ def __init__(self, token: str | None = None) -> None:
102
+ self._tokens: list[str] = []
103
+ self._known: list[str] = []
104
+ if token:
105
+ self._tokens.append(token)
106
+
107
+ @property
108
+ def token(self) -> str | None:
109
+ return self._tokens[-1] if self._tokens else None
110
+
111
+ def replace(self, token: str) -> None:
112
+ self._tokens.append(token)
113
+
114
+ def remember(self, token: str) -> None:
115
+ """Scrubs `token` from every text from now on without making it the one the client sends."""
116
+ if token and token not in self._known:
117
+ self._known.append(token)
118
+
119
+ def scrub(self, text: str) -> str:
120
+ for token in (*self._tokens, *self._known):
121
+ for pattern in _patterns(token):
122
+ text = pattern.sub(REDACTED, text)
123
+ return redact(text)
124
+
125
+ def scrub_value(self, value: object) -> object:
126
+ if isinstance(value, str):
127
+ return self.scrub(value)
128
+ mapping = as_dict(value)
129
+ if mapping is not None:
130
+ return {self.scrub(key): self.scrub_value(item) for key, item in mapping.items()}
131
+ items = as_list(value)
132
+ if items is not None:
133
+ return [self.scrub_value(item) for item in items]
134
+ return value
135
+
136
+ def __repr__(self) -> str:
137
+ return "Credentials(<redacted>)"
138
+
139
+ def __reduce__(self) -> str | tuple[object, ...]:
140
+ raise TypeError("credentials cannot be pickled")
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, TypeVar
4
+
5
+ from ._errors import ApiError, describe_failure, forget_body, raise_for_response, redacted_cause
6
+ from ._json import loads
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Callable
10
+
11
+ import httpx
12
+
13
+ from ._redact import Credentials
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class BadBodyError(ValueError):
19
+ """A 2xx body that parses as JSON but not into the shape the SDK needs."""
20
+
21
+
22
+ def bad_response(response: httpx.Response, what: str) -> ApiError:
23
+ """Spec 9.2: an unusable 2xx body, with the actual status and no numeric code."""
24
+ return ApiError(
25
+ f"The API's {what} could not be used.", code="bad_response", status=response.status_code
26
+ )
27
+
28
+
29
+ def unreadable_response(
30
+ response: httpx.Response, failure: BaseException, credentials: Credentials | None
31
+ ) -> ApiError:
32
+ """`bad_response` for a body the SDK could not read, naming the original's type and redacted
33
+ message (R86). Its cause is a stand-in: a JSON error keeps the whole body it read, which may
34
+ hold a token or a claim secret."""
35
+ error = ApiError(
36
+ f"The API's response could not be used ({describe_failure(failure, credentials)}).",
37
+ code="bad_response",
38
+ status=response.status_code,
39
+ )
40
+ error.__cause__ = redacted_cause(failure, credentials)
41
+ forget_body(response)
42
+ return error
43
+
44
+
45
+ def json_body(response: httpx.Response, credentials: Credentials) -> object:
46
+ """The JSON of a 2xx response; a non-2xx one raises the spec 9.2 error."""
47
+ raise_for_response(response, credentials=credentials)
48
+ try:
49
+ return loads(response.content)
50
+ except (
51
+ ValueError,
52
+ RecursionError,
53
+ ) as failure: # a deeply nested body is not JSON the SDK reads
54
+ # Raised once the handler has ended, so the JSON error is not its context either.
55
+ failed = unreadable_response(response, failure, credentials)
56
+ raise failed
57
+
58
+
59
+ def parsed(response: httpx.Response, credentials: Credentials, parse: Callable[[object], T]) -> T:
60
+ """`parse` of the JSON of a 2xx response; a body it cannot use is `bad_response`."""
61
+ body = json_body(response, credentials)
62
+ try:
63
+ return parse(body)
64
+ except BadBodyError as failure:
65
+ failed = unreadable_response(response, failure, credentials)
66
+ del body
67
+ raise failed