voltkit-rtc-server 0.1.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.
- voltkit_rtc/server/__init__.py +114 -0
- voltkit_rtc/server/_redaction.py +333 -0
- voltkit_rtc/server/_version.py +3 -0
- voltkit_rtc/server/client.py +846 -0
- voltkit_rtc/server/errors.py +278 -0
- voltkit_rtc/server/py.typed +0 -0
- voltkit_rtc/server/transport.py +208 -0
- voltkit_rtc/server/types.py +448 -0
- voltkit_rtc/server/webhooks.py +184 -0
- voltkit_rtc_server-0.1.0.dist-info/METADATA +877 -0
- voltkit_rtc_server-0.1.0.dist-info/RECORD +13 -0
- voltkit_rtc_server-0.1.0.dist-info/WHEEL +4 -0
- voltkit_rtc_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server SDK for VoltKit RTC: rooms, tokens, chat, broadcast streams, usage,
|
|
3
|
+
billing and verified webhooks. Standard library only.
|
|
4
|
+
|
|
5
|
+
from voltkit_rtc.server import StreamClient
|
|
6
|
+
|
|
7
|
+
rtc = StreamClient(os.environ["VOLTKIT_RTC_URL"], os.environ["VOLTKIT_RTC_SECRET"])
|
|
8
|
+
room = rtc.ensure_room("show-42", host_identity="user-7")
|
|
9
|
+
token = rtc.create_token("show-42", identity="user-99", role="viewer")
|
|
10
|
+
|
|
11
|
+
The API secret is tenant-wide, so it stays on your server, and the SDK
|
|
12
|
+
attaches it only to requests for ``base_url``, never across a redirect.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .client import DEFAULT_RETRIES, DEFAULT_TIMEOUT, MAX_TIMEOUT, RATES_PATH, USER_AGENT, StreamClient
|
|
17
|
+
from .errors import ERROR_CODES, REST_ERROR_CODES, ErrorInfo, StreamError, error_info, is_terminal
|
|
18
|
+
from .transport import HttpClientTransport, Transport, TransportError, TransportRequest, TransportResponse
|
|
19
|
+
from .types import (
|
|
20
|
+
Allowance,
|
|
21
|
+
BroadcastStream,
|
|
22
|
+
BroadcastStreamWithPublish,
|
|
23
|
+
BurnView,
|
|
24
|
+
ChatInfo,
|
|
25
|
+
ChatMessage,
|
|
26
|
+
ChatToken,
|
|
27
|
+
DailyUsage,
|
|
28
|
+
LegacyFunds,
|
|
29
|
+
Participant,
|
|
30
|
+
PlaybackPolicy,
|
|
31
|
+
PlaybackUrls,
|
|
32
|
+
ProjectBalance,
|
|
33
|
+
PublishGrant,
|
|
34
|
+
Role,
|
|
35
|
+
Room,
|
|
36
|
+
RoomList,
|
|
37
|
+
RoomToken,
|
|
38
|
+
RoomView,
|
|
39
|
+
RoomWithHostToken,
|
|
40
|
+
RtcConversion,
|
|
41
|
+
RtcMode,
|
|
42
|
+
RtcRates,
|
|
43
|
+
RtcSurcharge,
|
|
44
|
+
RtcTierRate,
|
|
45
|
+
RtcUnit,
|
|
46
|
+
StreamAction,
|
|
47
|
+
StreamEvent,
|
|
48
|
+
StreamToken,
|
|
49
|
+
Usage,
|
|
50
|
+
UsageSummary,
|
|
51
|
+
WalletFunds,
|
|
52
|
+
WalletLowState,
|
|
53
|
+
WalletScopeAmount,
|
|
54
|
+
)
|
|
55
|
+
from .webhooks import DEFAULT_TOLERANCE_SECONDS, MAX_HEADER_LENGTH, parse_webhook, verify_webhook
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"__version__",
|
|
59
|
+
"StreamClient",
|
|
60
|
+
"StreamError",
|
|
61
|
+
"ErrorInfo",
|
|
62
|
+
"ERROR_CODES",
|
|
63
|
+
"REST_ERROR_CODES",
|
|
64
|
+
"error_info",
|
|
65
|
+
"is_terminal",
|
|
66
|
+
"verify_webhook",
|
|
67
|
+
"parse_webhook",
|
|
68
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
69
|
+
"MAX_HEADER_LENGTH",
|
|
70
|
+
"RATES_PATH",
|
|
71
|
+
"USER_AGENT",
|
|
72
|
+
"DEFAULT_TIMEOUT",
|
|
73
|
+
"DEFAULT_RETRIES",
|
|
74
|
+
"MAX_TIMEOUT",
|
|
75
|
+
"Transport",
|
|
76
|
+
"TransportRequest",
|
|
77
|
+
"TransportResponse",
|
|
78
|
+
"TransportError",
|
|
79
|
+
"HttpClientTransport",
|
|
80
|
+
"Allowance",
|
|
81
|
+
"BroadcastStream",
|
|
82
|
+
"BroadcastStreamWithPublish",
|
|
83
|
+
"BurnView",
|
|
84
|
+
"ChatInfo",
|
|
85
|
+
"ChatMessage",
|
|
86
|
+
"ChatToken",
|
|
87
|
+
"DailyUsage",
|
|
88
|
+
"LegacyFunds",
|
|
89
|
+
"Participant",
|
|
90
|
+
"PlaybackPolicy",
|
|
91
|
+
"PlaybackUrls",
|
|
92
|
+
"ProjectBalance",
|
|
93
|
+
"PublishGrant",
|
|
94
|
+
"Role",
|
|
95
|
+
"Room",
|
|
96
|
+
"RoomList",
|
|
97
|
+
"RoomToken",
|
|
98
|
+
"RoomView",
|
|
99
|
+
"RoomWithHostToken",
|
|
100
|
+
"RtcConversion",
|
|
101
|
+
"RtcMode",
|
|
102
|
+
"RtcRates",
|
|
103
|
+
"RtcSurcharge",
|
|
104
|
+
"RtcTierRate",
|
|
105
|
+
"RtcUnit",
|
|
106
|
+
"StreamAction",
|
|
107
|
+
"StreamEvent",
|
|
108
|
+
"StreamToken",
|
|
109
|
+
"Usage",
|
|
110
|
+
"UsageSummary",
|
|
111
|
+
"WalletFunds",
|
|
112
|
+
"WalletLowState",
|
|
113
|
+
"WalletScopeAmount",
|
|
114
|
+
]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What keeps the API key out of anything a caller might log: the checks a key
|
|
3
|
+
passes when the client is made, and the redaction of the key, however it is
|
|
4
|
+
encoded, and of URL credentials from someone else's words (the server's reply,
|
|
5
|
+
a redirect's target, the transport's own error). The SDK's own words never
|
|
6
|
+
hold the key, so only quoted text comes through here.
|
|
7
|
+
|
|
8
|
+
The forms looked for and the limits are the Node SDK's (server-sdk/src/index.ts,
|
|
9
|
+
keyRedactorOf and keyPatternOf); docs/server-sdk-vectors.json pins them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from typing import Callable, Optional, Union
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"KEY_NOT_CHECKED",
|
|
22
|
+
"LONGEST_KEY_LOOKED_FOR",
|
|
23
|
+
"MARKER",
|
|
24
|
+
"KeyRedactor",
|
|
25
|
+
"check_api_key",
|
|
26
|
+
"hide_url_credentials",
|
|
27
|
+
"key_pattern_of",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
#: What replaces the key, and the credentials of a URL.
|
|
31
|
+
MARKER = "[redacted]"
|
|
32
|
+
#: What replaces words that could not be checked for the key.
|
|
33
|
+
KEY_NOT_CHECKED = "[not shown: it could not be checked for the API key]"
|
|
34
|
+
#: The longest key redaction looks for: the pattern grows with the key, and a
|
|
35
|
+
#: key this long is already not one the controller issued.
|
|
36
|
+
LONGEST_KEY_LOOKED_FOR = 256
|
|
37
|
+
|
|
38
|
+
#: Spaces, tabs and line breaks at either end of a header value: fetch drops
|
|
39
|
+
#: them before sending; this SDK trims them itself and sends the net key.
|
|
40
|
+
HEADER_WHITESPACE = " \t\r\n"
|
|
41
|
+
|
|
42
|
+
# JavaScript's \s, so that words and URL credentials are read the way the Node
|
|
43
|
+
# SDK reads them (Python's \s differs at the edges).
|
|
44
|
+
_JS_SPACE = "\t\n\x0b\x0c\r -
"
|
|
45
|
+
_WORD = re.compile("[^%s]+" % _JS_SPACE)
|
|
46
|
+
_ANY_SPACE = re.compile("[%s]" % _JS_SPACE)
|
|
47
|
+
# A character no HTTP header can carry: a line break, a NUL, another control
|
|
48
|
+
# character, DEL, or anything above U+00FF.
|
|
49
|
+
_HEADER_UNSAFE = re.compile("[^\t\x20-\x7e\x80-\xff]")
|
|
50
|
+
# A backslash (JSON doubles it, and a run of them made the pattern backtrack
|
|
51
|
+
# exponentially) or a Latin-1 letter (sent as one byte that servers echo as
|
|
52
|
+
# U+FFFD, a ?, or mojibake, which no pattern can list). No VoltKit key holds
|
|
53
|
+
# either: the controller issues sk_ and hex digits.
|
|
54
|
+
_NOT_A_VOLTKIT_KEY = re.compile("[\\\\\x80-\xff]")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_api_key(api_key: object) -> str:
|
|
58
|
+
"""The key as it will be sent, or a TypeError that never quotes it."""
|
|
59
|
+
if api_key is None or api_key == "":
|
|
60
|
+
raise TypeError("api_key is required")
|
|
61
|
+
if not isinstance(api_key, str):
|
|
62
|
+
raise TypeError("api_key must be a str, got %s" % type(api_key).__name__)
|
|
63
|
+
sent = api_key.strip(HEADER_WHITESPACE)
|
|
64
|
+
# Only whitespace (a bare line break from a file or an environment
|
|
65
|
+
# variable): every call would send an empty X-API-Key and fail with 401,
|
|
66
|
+
# far from the mistake.
|
|
67
|
+
if sent == "":
|
|
68
|
+
raise TypeError("api_key is required: the one given is only whitespace")
|
|
69
|
+
if _HEADER_UNSAFE.search(sent):
|
|
70
|
+
raise TypeError(
|
|
71
|
+
"api_key holds a character no HTTP header can carry: a line break, a NUL, another control "
|
|
72
|
+
"character, or one above U+00FF. Check how the key is loaded (it is not shown here)"
|
|
73
|
+
)
|
|
74
|
+
if _NOT_A_VOLTKIT_KEY.search(sent):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
"api_key holds a character no VoltKit key has: a backslash, or one outside ASCII such as an "
|
|
77
|
+
"accented letter. Check how the key is loaded (it is not shown here)"
|
|
78
|
+
)
|
|
79
|
+
return sent
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- URL credentials -----------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
# A URL's credentials: a run of scheme characters, `://`, then the user info up
|
|
85
|
+
# to the last @ before whitespace, `/`, `?` or `#` (a password holding an @ is
|
|
86
|
+
# taken out whole). The lookbehind starts a match only at the first character
|
|
87
|
+
# of such a run: tried from every character of a long run of letters, as the
|
|
88
|
+
# Node SDK once did, a large page took seconds (N044-2). A run with no letter
|
|
89
|
+
# in it is not a scheme and is left alone, so the matches are the Node SDK's.
|
|
90
|
+
_URL_CREDENTIALS = re.compile("(?<![A-Za-z0-9+.\\-])([A-Za-z0-9+.\\-]+://)[^%s/?#]*@" % _JS_SPACE)
|
|
91
|
+
_A_LETTER = re.compile("[A-Za-z]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hide(match: re.Match[str]) -> str:
|
|
95
|
+
run = match.group(1)
|
|
96
|
+
if _A_LETTER.search(run) is None:
|
|
97
|
+
return match.group(0)
|
|
98
|
+
return run + MARKER + "@"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def hide_url_credentials(text: str) -> str:
|
|
102
|
+
"""`text` with every `scheme://user:pass@` shown as `scheme://[redacted]@`."""
|
|
103
|
+
return _URL_CREDENTIALS.sub(_hide, text)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- the key, in every encoding a reply can hold -----------------------------
|
|
107
|
+
|
|
108
|
+
#: One character of an encoded form: the character, and its pattern (a hex
|
|
109
|
+
#: digit, or the x of &#x, in either case).
|
|
110
|
+
Piece = tuple[str, str]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _pieces_of(text: str) -> list[Piece]:
|
|
114
|
+
return [(ch, re.escape(ch)) for ch in text]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _hex_pieces_of(code: int, width: int) -> list[Piece]:
|
|
118
|
+
return [
|
|
119
|
+
(digit, "[%s%s]" % (digit, digit.lower()) if digit in "ABCDEF" else digit)
|
|
120
|
+
for digit in format(code, "X").rjust(width, "0")
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _percent_of(code: int) -> list[Piece]:
|
|
125
|
+
return _pieces_of("%") + _hex_pieces_of(code, 2)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _hex_reference_of(code: int) -> list[Piece]:
|
|
129
|
+
return _pieces_of("&#") + [("x", "[Xx]")] + _hex_pieces_of(code, 1) + _pieces_of(";")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _decimal_reference_of(code: int, width: int = 1) -> list[Piece]:
|
|
133
|
+
return _pieces_of("&#%s;" % str(code).rjust(width, "0"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
#: The HTML5 named references of the characters a key may hold, and of the
|
|
137
|
+
#: backslash, which escapes hold.
|
|
138
|
+
_HTML_NAMES: Mapping[str, Sequence[str]] = {
|
|
139
|
+
"\t": ("Tab",),
|
|
140
|
+
"!": ("excl",),
|
|
141
|
+
'"': ("quot", "QUOT"),
|
|
142
|
+
"#": ("num",),
|
|
143
|
+
"$": ("dollar",),
|
|
144
|
+
"%": ("percnt",),
|
|
145
|
+
"&": ("amp", "AMP"),
|
|
146
|
+
"'": ("apos",),
|
|
147
|
+
"(": ("lpar",),
|
|
148
|
+
")": ("rpar",),
|
|
149
|
+
"*": ("ast", "midast"),
|
|
150
|
+
"+": ("plus",),
|
|
151
|
+
",": ("comma",),
|
|
152
|
+
".": ("period",),
|
|
153
|
+
"/": ("sol",),
|
|
154
|
+
":": ("colon",),
|
|
155
|
+
";": ("semi",),
|
|
156
|
+
"<": ("lt", "LT"),
|
|
157
|
+
"=": ("equals",),
|
|
158
|
+
">": ("gt", "GT"),
|
|
159
|
+
"?": ("quest",),
|
|
160
|
+
"@": ("commat",),
|
|
161
|
+
"[": ("lsqb", "lbrack"),
|
|
162
|
+
"\\": ("bsol",),
|
|
163
|
+
"]": ("rsqb", "rbrack"),
|
|
164
|
+
"^": ("Hat",),
|
|
165
|
+
"_": ("lowbar", "UnderBar"),
|
|
166
|
+
"`": ("grave", "DiacriticalGrave"),
|
|
167
|
+
"{": ("lcub", "lbrace"),
|
|
168
|
+
"|": ("verbar", "vert", "VerticalLine"),
|
|
169
|
+
"}": ("rcub", "rbrace"),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#: The characters one-layer escapes are made of, other than letters and digits:
|
|
173
|
+
#: all a second escaper changes.
|
|
174
|
+
_ESCAPE_CHARACTERS = '%\\"&#;/+'
|
|
175
|
+
|
|
176
|
+
Escaper = Mapping[str, list[Piece]]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _escaping(chars: str, encode: Callable[[int], list[Piece]]) -> dict[str, list[Piece]]:
|
|
180
|
+
return {ch: encode(ord(ch)) for ch in chars}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# URL: encodeURIComponent, URLSearchParams and Python's quote(safe='') encode
|
|
184
|
+
# each of these; Python's quote() and encodeURI keep /, which matters only for
|
|
185
|
+
# PHP's \/ (%5C/).
|
|
186
|
+
_URL_ENCODED = _escaping(_ESCAPE_CHARACTERS, _percent_of)
|
|
187
|
+
_URL_ENCODED_BUT_SLASH = _escaping('%\\"&#;+', _percent_of)
|
|
188
|
+
# HTML: PHP's htmlentities with ENT_HTML5 names each character, OWASP's
|
|
189
|
+
# encoders write &#x..; for each, others &#..;.
|
|
190
|
+
_HTML_NAMED = _escaping(_ESCAPE_CHARACTERS, lambda code: _pieces_of("&%s;" % _HTML_NAMES[chr(code)][0]))
|
|
191
|
+
_HTML_HEX = _escaping(_ESCAPE_CHARACTERS, _hex_reference_of)
|
|
192
|
+
_HTML_DECIMAL = _escaping(_ESCAPE_CHARACTERS, _decimal_reference_of)
|
|
193
|
+
|
|
194
|
+
_NO_ESCAPER: dict[str, list[Piece]] = {}
|
|
195
|
+
|
|
196
|
+
#: The standard escapers, each as what it makes of the escape characters: a
|
|
197
|
+
#: second layer is one run over a first.
|
|
198
|
+
_ESCAPERS: Sequence[Escaper] = (
|
|
199
|
+
_URL_ENCODED,
|
|
200
|
+
_URL_ENCODED_BUT_SLASH,
|
|
201
|
+
# JSON: JSON.stringify and json.dumps write \ as \\ and " as \"; Go's
|
|
202
|
+
# json.Marshal writes & as & too, PHP's json_encode / as \/.
|
|
203
|
+
{"\\": _pieces_of("\\\\"), '"': _pieces_of('\\"'), "&": _pieces_of("\\u0026")},
|
|
204
|
+
{"\\": _pieces_of("\\\\"), '"': _pieces_of('\\"'), "/": _pieces_of("\\/")},
|
|
205
|
+
# HTML: html.escape, htmlspecialchars and lodash write ", Go ".
|
|
206
|
+
{"&": _pieces_of("&"), '"': _pieces_of(""")},
|
|
207
|
+
{"&": _pieces_of("&"), '"': _pieces_of(""")},
|
|
208
|
+
_HTML_NAMED,
|
|
209
|
+
_HTML_HEX,
|
|
210
|
+
_HTML_DECIMAL,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _one_layer_forms_of(ch: str) -> list[list[Piece]]:
|
|
215
|
+
"""
|
|
216
|
+
Every form one escaper gives `ch`: itself; its byte as %XX in either hex
|
|
217
|
+
case; + for a space; its JSON escapes (\\", \\t, PHP's \\/, \\uXXXX); and its
|
|
218
|
+
HTML references, named, decimal (PHP's ' included) and hex.
|
|
219
|
+
"""
|
|
220
|
+
code = ord(ch)
|
|
221
|
+
forms = [
|
|
222
|
+
_pieces_of(ch),
|
|
223
|
+
_percent_of(code),
|
|
224
|
+
_pieces_of("\\u") + _hex_pieces_of(code, 4),
|
|
225
|
+
_decimal_reference_of(code),
|
|
226
|
+
_decimal_reference_of(code, 3),
|
|
227
|
+
_hex_reference_of(code),
|
|
228
|
+
]
|
|
229
|
+
forms.extend(_pieces_of("&%s;" % name) for name in _HTML_NAMES.get(ch, ()))
|
|
230
|
+
if ch == " ":
|
|
231
|
+
forms.append(_pieces_of("+"))
|
|
232
|
+
escaped = json.dumps(ch)[1:-1]
|
|
233
|
+
if escaped != ch:
|
|
234
|
+
forms.append(_pieces_of(escaped))
|
|
235
|
+
if ch == "/":
|
|
236
|
+
forms.append(_pieces_of("\\/"))
|
|
237
|
+
return forms
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Each character's alternatives, built once per process (there are at most 96)
|
|
241
|
+
# and shared by every client.
|
|
242
|
+
_ALTERNATIVES: dict[str, str] = {}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _alternatives_of(ch: str) -> str:
|
|
246
|
+
cached = _ALTERNATIVES.get(ch)
|
|
247
|
+
if cached is not None:
|
|
248
|
+
return cached
|
|
249
|
+
# An insertion-ordered set: the order of the alternatives is the Node
|
|
250
|
+
# SDK's, so both engines, which take the first alternative that leads to a
|
|
251
|
+
# match, replace the same text.
|
|
252
|
+
patterns: dict[str, None] = {}
|
|
253
|
+
for form in _one_layer_forms_of(ch):
|
|
254
|
+
for escaper in (_NO_ESCAPER, *_ESCAPERS):
|
|
255
|
+
patterns["".join(piece[1] for original in form for piece in escaper.get(original[0], [original]))] = None
|
|
256
|
+
alternatives = "(?:%s)" % "|".join(patterns)
|
|
257
|
+
_ALTERNATIVES[ch] = alternatives
|
|
258
|
+
return alternatives
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def key_pattern_of(key: str) -> re.Pattern[str]:
|
|
262
|
+
"""
|
|
263
|
+
The key in any encoding someone else's words can hold, as one pattern: each
|
|
264
|
+
character in each of its one-layer forms, and each of those run through
|
|
265
|
+
every standard escaper once more (%252F, \\\\\\/, \\"), since a reply is
|
|
266
|
+
often escaped twice. Each alternative is a fixed sequence for one character
|
|
267
|
+
and there is no quantifier, so a match covers the whole key in order and
|
|
268
|
+
unrelated text is untouched. That holds because check_api_key has run: no
|
|
269
|
+
character of the key is a backslash or above U+007F.
|
|
270
|
+
"""
|
|
271
|
+
return re.compile("".join(_alternatives_of(ch) for ch in key))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
_Pattern = Union[re.Pattern[str], None, bool]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class KeyRedactor:
|
|
278
|
+
"""
|
|
279
|
+
Replaces `key`, in any form key_pattern_of knows, by [redacted].
|
|
280
|
+
|
|
281
|
+
The pattern is built for the first text, not with the client: most clients
|
|
282
|
+
never see an error, and building it is what redaction costs. It fails
|
|
283
|
+
closed: for a key too long to look for, or a pattern the engine cannot
|
|
284
|
+
compile, each word (a run of non-whitespace) at least as long as the key
|
|
285
|
+
is replaced by KEY_NOT_CHECKED and shorter words are kept, since every
|
|
286
|
+
form of the key is at least as long as the key and, when the key holds no
|
|
287
|
+
whitespace, lies within one word. A key holding whitespace (a tab) could be
|
|
288
|
+
split across words: then the whole text goes, unless it is shorter than
|
|
289
|
+
the key. A pattern that failed is not tried again.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
__slots__ = ("_key", "_lock", "_pattern")
|
|
293
|
+
|
|
294
|
+
def __init__(self, key: str) -> None:
|
|
295
|
+
self._key = key
|
|
296
|
+
self._lock = threading.Lock()
|
|
297
|
+
# None: not built yet; False: not looked for (too long, or the build failed).
|
|
298
|
+
self._pattern: _Pattern = False if len(key) > LONGEST_KEY_LOOKED_FOR else None
|
|
299
|
+
|
|
300
|
+
def __repr__(self) -> str:
|
|
301
|
+
return "<secrets>"
|
|
302
|
+
|
|
303
|
+
def __call__(self, text: str) -> str:
|
|
304
|
+
pattern = self._pattern
|
|
305
|
+
if pattern is None:
|
|
306
|
+
# Built once, whichever thread reports the first error.
|
|
307
|
+
with self._lock:
|
|
308
|
+
if self._pattern is None:
|
|
309
|
+
try:
|
|
310
|
+
self._pattern = key_pattern_of(self._key)
|
|
311
|
+
except Exception:
|
|
312
|
+
self._pattern = False
|
|
313
|
+
pattern = self._pattern
|
|
314
|
+
if pattern is not False and pattern is not None and pattern is not True:
|
|
315
|
+
try:
|
|
316
|
+
return pattern.sub(MARKER, text)
|
|
317
|
+
except Exception:
|
|
318
|
+
self._pattern = False
|
|
319
|
+
if _ANY_SPACE.search(self._key):
|
|
320
|
+
return text if len(text) < len(self._key) else KEY_NOT_CHECKED
|
|
321
|
+
return _WORD.sub(self._word, text)
|
|
322
|
+
|
|
323
|
+
def _word(self, match: re.Match[str]) -> str:
|
|
324
|
+
word = match.group(0)
|
|
325
|
+
return word if len(word) < len(self._key) else KEY_NOT_CHECKED
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def built(self) -> Optional[bool]:
|
|
329
|
+
"""None before the first text; True once the pattern exists; False when it is not looked for."""
|
|
330
|
+
pattern = self._pattern
|
|
331
|
+
if pattern is None:
|
|
332
|
+
return None
|
|
333
|
+
return pattern is not False
|