transcrypto 1.7.0__py3-none-any.whl → 2.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.
- transcrypto/__init__.py +1 -1
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +370 -0
- transcrypto/cli/bidsecret.py +336 -0
- transcrypto/cli/clibase.py +183 -0
- transcrypto/cli/intmath.py +429 -0
- transcrypto/cli/publicalgos.py +878 -0
- transcrypto/core/__init__.py +3 -0
- transcrypto/{aes.py → core/aes.py} +17 -29
- transcrypto/core/bid.py +161 -0
- transcrypto/{dsa.py → core/dsa.py} +28 -27
- transcrypto/{elgamal.py → core/elgamal.py} +33 -32
- transcrypto/core/hashes.py +96 -0
- transcrypto/core/key.py +735 -0
- transcrypto/{modmath.py → core/modmath.py} +91 -17
- transcrypto/{rsa.py → core/rsa.py} +51 -50
- transcrypto/{sss.py → core/sss.py} +27 -26
- transcrypto/profiler.py +29 -13
- transcrypto/transcrypto.py +60 -1996
- transcrypto/utils/__init__.py +3 -0
- transcrypto/utils/base.py +72 -0
- transcrypto/utils/human.py +278 -0
- transcrypto/utils/logging.py +139 -0
- transcrypto/utils/saferandom.py +102 -0
- transcrypto/utils/stats.py +360 -0
- transcrypto/utils/timer.py +175 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/METADATA +111 -109
- transcrypto-2.0.0.dist-info/RECORD +33 -0
- transcrypto/base.py +0 -1918
- transcrypto-1.7.0.dist-info/RECORD +0 -17
- /transcrypto/{constants.py → core/constants.py} +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/entry_points.txt +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/licenses/LICENSE +0 -0
transcrypto/base.py
DELETED
|
@@ -1,1918 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
-
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
"""Balparda's TransCrypto base library."""
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
import abc as abstract
|
|
8
|
-
import base64
|
|
9
|
-
import codecs
|
|
10
|
-
import dataclasses
|
|
11
|
-
import datetime
|
|
12
|
-
import enum
|
|
13
|
-
import functools
|
|
14
|
-
import hashlib
|
|
15
|
-
import json
|
|
16
|
-
import logging
|
|
17
|
-
import math
|
|
18
|
-
import os
|
|
19
|
-
import pathlib
|
|
20
|
-
import pickle # noqa: S403
|
|
21
|
-
import secrets
|
|
22
|
-
import sys
|
|
23
|
-
import threading
|
|
24
|
-
import time
|
|
25
|
-
from collections import abc
|
|
26
|
-
from types import TracebackType
|
|
27
|
-
from typing import (
|
|
28
|
-
Any,
|
|
29
|
-
Protocol,
|
|
30
|
-
Self,
|
|
31
|
-
TypeVar,
|
|
32
|
-
cast,
|
|
33
|
-
final,
|
|
34
|
-
runtime_checkable,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
import click
|
|
38
|
-
import numpy as np
|
|
39
|
-
import typer
|
|
40
|
-
import zstandard
|
|
41
|
-
from click import testing as click_testing
|
|
42
|
-
from rich import console as rich_console
|
|
43
|
-
from rich import logging as rich_logging
|
|
44
|
-
from scipy import stats
|
|
45
|
-
|
|
46
|
-
# Data conversion utils
|
|
47
|
-
|
|
48
|
-
BytesToHex: abc.Callable[[bytes], str] = lambda b: b.hex()
|
|
49
|
-
BytesToInt: abc.Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
|
|
50
|
-
BytesToEncoded: abc.Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
|
|
51
|
-
|
|
52
|
-
HexToBytes: abc.Callable[[str], bytes] = bytes.fromhex
|
|
53
|
-
IntToFixedBytes: abc.Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
|
|
54
|
-
IntToBytes: abc.Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
|
|
55
|
-
IntToEncoded: abc.Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
|
|
56
|
-
EncodedToBytes: abc.Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
|
|
57
|
-
|
|
58
|
-
PadBytesTo: abc.Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
59
|
-
|
|
60
|
-
# Time utils
|
|
61
|
-
|
|
62
|
-
MIN_TM = int(datetime.datetime(2000, 1, 1, 0, 0, 0, tzinfo=datetime.UTC).timestamp())
|
|
63
|
-
TIME_FORMAT = '%Y/%b/%d-%H:%M:%S-UTC'
|
|
64
|
-
TimeStr: abc.Callable[[int | float | None], str] = lambda tm: (
|
|
65
|
-
time.strftime(TIME_FORMAT, time.gmtime(tm)) if tm else '-'
|
|
66
|
-
)
|
|
67
|
-
Now: abc.Callable[[], int] = lambda: int(time.time())
|
|
68
|
-
StrNow: abc.Callable[[], str] = lambda: TimeStr(Now())
|
|
69
|
-
|
|
70
|
-
# Logging
|
|
71
|
-
_LOG_FORMAT_NO_PROCESS: str = '%(funcName)s: %(message)s'
|
|
72
|
-
_LOG_FORMAT_WITH_PROCESS: str = '%(processName)s/' + _LOG_FORMAT_NO_PROCESS
|
|
73
|
-
_LOG_FORMAT_DATETIME: str = '[%Y%m%d-%H:%M:%S]' # e.g., [20240131-13:45:30]
|
|
74
|
-
_LOG_LEVELS: dict[int, int] = {
|
|
75
|
-
0: logging.ERROR,
|
|
76
|
-
1: logging.WARNING,
|
|
77
|
-
2: logging.INFO,
|
|
78
|
-
3: logging.DEBUG,
|
|
79
|
-
}
|
|
80
|
-
_LOG_COMMON_PROVIDERS: set[str] = {
|
|
81
|
-
'werkzeug',
|
|
82
|
-
'gunicorn.error',
|
|
83
|
-
'gunicorn.access',
|
|
84
|
-
'uvicorn',
|
|
85
|
-
'uvicorn.error',
|
|
86
|
-
'uvicorn.access',
|
|
87
|
-
'django.server',
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
# SI prefix table, powers of 1000
|
|
91
|
-
_SI_PREFIXES: dict[int, str] = {
|
|
92
|
-
-6: 'a', # atto
|
|
93
|
-
-5: 'f', # femto
|
|
94
|
-
-4: 'p', # pico
|
|
95
|
-
-3: 'n', # nano
|
|
96
|
-
-2: 'µ', # micro (unicode U+00B5) # noqa: RUF001
|
|
97
|
-
-1: 'm', # milli
|
|
98
|
-
0: '', # base
|
|
99
|
-
1: 'k', # kilo
|
|
100
|
-
2: 'M', # mega
|
|
101
|
-
3: 'G', # giga
|
|
102
|
-
4: 'T', # tera
|
|
103
|
-
5: 'P', # peta
|
|
104
|
-
6: 'E', # exa
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
# these control the pickling of data, do NOT ever change, or you will break all databases
|
|
108
|
-
# <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
|
|
109
|
-
_PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
|
|
110
|
-
PickleGeneric: abc.Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
|
|
111
|
-
UnpickleGeneric: abc.Callable[[bytes], Any] = pickle.loads # noqa: S301
|
|
112
|
-
PickleJSON: abc.Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
|
|
113
|
-
d, separators=(',', ':')
|
|
114
|
-
).encode('utf-8')
|
|
115
|
-
UnpickleJSON: abc.Callable[[bytes], dict[str, Any]] = lambda b: json.loads(b.decode('utf-8'))
|
|
116
|
-
_PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
|
|
117
|
-
# these help find compressed files, do NOT change unless zstandard changes
|
|
118
|
-
_ZSTD_MAGIC_FRAME = 0xFD2FB528
|
|
119
|
-
_ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
|
|
120
|
-
_ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
|
|
121
|
-
# JSON
|
|
122
|
-
_JSON_DATACLASS_TYPES: set[str] = {
|
|
123
|
-
# native support
|
|
124
|
-
'int',
|
|
125
|
-
'float',
|
|
126
|
-
'str',
|
|
127
|
-
'bool',
|
|
128
|
-
'list[int]',
|
|
129
|
-
'list[float]',
|
|
130
|
-
'list[str]',
|
|
131
|
-
'list[bool]',
|
|
132
|
-
# need conversion/encoding
|
|
133
|
-
'bytes',
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
class Error(Exception):
|
|
138
|
-
"""TransCrypto exception."""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
class InputError(Error):
|
|
142
|
-
"""Input exception (TransCrypto)."""
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
class CryptoError(Error):
|
|
146
|
-
"""Cryptographic exception (TransCrypto)."""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class ImplementationError(Error, NotImplementedError):
|
|
150
|
-
"""Feature is not implemented yet (TransCrypto)."""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
__console_lock: threading.RLock = threading.RLock()
|
|
154
|
-
__console_singleton: rich_console.Console | None = None
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def Console() -> rich_console.Console:
|
|
158
|
-
"""Get the global console instance.
|
|
159
|
-
|
|
160
|
-
Returns:
|
|
161
|
-
rich.console.Console: The global console instance.
|
|
162
|
-
|
|
163
|
-
"""
|
|
164
|
-
with __console_lock:
|
|
165
|
-
if __console_singleton is None:
|
|
166
|
-
return rich_console.Console() # fallback console if InitLogging hasn't been called yet
|
|
167
|
-
return __console_singleton
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def ResetConsole() -> None:
|
|
171
|
-
"""Reset the global console instance."""
|
|
172
|
-
global __console_singleton # noqa: PLW0603
|
|
173
|
-
with __console_lock:
|
|
174
|
-
__console_singleton = None
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def InitLogging(
|
|
178
|
-
verbosity: int,
|
|
179
|
-
/,
|
|
180
|
-
*,
|
|
181
|
-
include_process: bool = False,
|
|
182
|
-
soft_wrap: bool = False,
|
|
183
|
-
color: bool | None = False,
|
|
184
|
-
) -> tuple[rich_console.Console, int, bool]:
|
|
185
|
-
"""Initialize logger (with RichHandler) and get a rich.console.Console singleton.
|
|
186
|
-
|
|
187
|
-
This method will also return the actual decided values for verbosity and color use.
|
|
188
|
-
If you have a CLI app that uses this, its pytests should call `ResetConsole()` in a fixture, like:
|
|
189
|
-
|
|
190
|
-
from transcrypto import logging
|
|
191
|
-
@pytest.fixture(autouse=True)
|
|
192
|
-
def _reset_base_logging() -> Generator[None, None, None]: # type: ignore
|
|
193
|
-
logging.ResetConsole()
|
|
194
|
-
yield # stop
|
|
195
|
-
|
|
196
|
-
Args:
|
|
197
|
-
verbosity (int): Logging verbosity level: 0==ERROR, 1==WARNING, 2==INFO, 3==DEBUG
|
|
198
|
-
include_process (bool, optional): Whether to include process name in log output.
|
|
199
|
-
soft_wrap (bool, optional): Whether to enable soft wrapping in the console.
|
|
200
|
-
Default is False, and it means rich will hard-wrap long lines (by adding line breaks).
|
|
201
|
-
color (bool | None, optional): Whether to enable/disable color output in the console.
|
|
202
|
-
If None, respects NO_COLOR env var.
|
|
203
|
-
|
|
204
|
-
Returns:
|
|
205
|
-
tuple[rich_console.Console, int, bool]:
|
|
206
|
-
(The initialized console instance, actual log level, actual color use)
|
|
207
|
-
|
|
208
|
-
Raises:
|
|
209
|
-
RuntimeError: if you call this more than once
|
|
210
|
-
|
|
211
|
-
"""
|
|
212
|
-
global __console_singleton # noqa: PLW0603
|
|
213
|
-
with __console_lock:
|
|
214
|
-
if __console_singleton is not None:
|
|
215
|
-
raise RuntimeError(
|
|
216
|
-
'calling InitLogging() more than once is forbidden; '
|
|
217
|
-
'use Console() to get a console after first creation'
|
|
218
|
-
)
|
|
219
|
-
# set level
|
|
220
|
-
logging_level: int = _LOG_LEVELS.get(min(verbosity, 3), logging.ERROR)
|
|
221
|
-
# respect NO_COLOR unless the caller has already decided (treat env presence as "disable color")
|
|
222
|
-
no_color: bool = (
|
|
223
|
-
False
|
|
224
|
-
if (os.getenv('NO_COLOR') is None and color is None)
|
|
225
|
-
else ((os.getenv('NO_COLOR') is not None) if color is None else (not color))
|
|
226
|
-
)
|
|
227
|
-
# create console and configure logging
|
|
228
|
-
console = rich_console.Console(soft_wrap=soft_wrap, no_color=no_color)
|
|
229
|
-
logging.basicConfig(
|
|
230
|
-
level=logging_level,
|
|
231
|
-
format=_LOG_FORMAT_WITH_PROCESS if include_process else _LOG_FORMAT_NO_PROCESS,
|
|
232
|
-
datefmt=_LOG_FORMAT_DATETIME,
|
|
233
|
-
handlers=[
|
|
234
|
-
rich_logging.RichHandler( # we show name/line, but want time & level
|
|
235
|
-
console=console,
|
|
236
|
-
rich_tracebacks=True,
|
|
237
|
-
show_time=True,
|
|
238
|
-
show_level=True,
|
|
239
|
-
show_path=True,
|
|
240
|
-
),
|
|
241
|
-
],
|
|
242
|
-
force=True, # force=True to override any previous logging config
|
|
243
|
-
)
|
|
244
|
-
# configure common loggers
|
|
245
|
-
logging.captureWarnings(True)
|
|
246
|
-
for name in _LOG_COMMON_PROVIDERS:
|
|
247
|
-
log: logging.Logger = logging.getLogger(name)
|
|
248
|
-
log.handlers.clear()
|
|
249
|
-
log.propagate = True
|
|
250
|
-
log.setLevel(logging_level)
|
|
251
|
-
__console_singleton = console # need a global statement to re-bind this one
|
|
252
|
-
logging.info(
|
|
253
|
-
f'Logging initialized at level {logging.getLevelName(logging_level)} / '
|
|
254
|
-
f'{"NO " if no_color else ""}COLOR'
|
|
255
|
-
)
|
|
256
|
-
return (console, logging_level, not no_color)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def HumanizedBytes(inp_sz: float, /) -> str: # noqa: PLR0911
|
|
260
|
-
"""Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
|
|
261
|
-
|
|
262
|
-
Scales the input size by powers of 1024, returning a value with the
|
|
263
|
-
appropriate IEC binary unit suffix: `B`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, `EiB`.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
inp_sz (int | float): Size in bytes. Must be non-negative.
|
|
267
|
-
|
|
268
|
-
Returns:
|
|
269
|
-
str: Formatted size string with up to two decimal places for units above bytes.
|
|
270
|
-
|
|
271
|
-
Raises:
|
|
272
|
-
InputError: If `inp_sz` is negative.
|
|
273
|
-
|
|
274
|
-
Notes:
|
|
275
|
-
- Units follow the IEC binary standard where:
|
|
276
|
-
1 KiB = 1024 bytes
|
|
277
|
-
1 MiB = 1024 KiB
|
|
278
|
-
1 GiB = 1024 MiB
|
|
279
|
-
1 TiB = 1024 GiB
|
|
280
|
-
1 PiB = 1024 TiB
|
|
281
|
-
1 EiB = 1024 PiB
|
|
282
|
-
- Values under 1024 bytes are returned as an integer with a space and `B`.
|
|
283
|
-
|
|
284
|
-
Examples:
|
|
285
|
-
>>> HumanizedBytes(512)
|
|
286
|
-
'512 B'
|
|
287
|
-
>>> HumanizedBytes(2048)
|
|
288
|
-
'2.00 KiB'
|
|
289
|
-
>>> HumanizedBytes(5 * 1024**3)
|
|
290
|
-
'5.00 GiB'
|
|
291
|
-
|
|
292
|
-
"""
|
|
293
|
-
if inp_sz < 0:
|
|
294
|
-
raise InputError(f'input should be >=0 and got {inp_sz}')
|
|
295
|
-
if inp_sz < 1024: # noqa: PLR2004
|
|
296
|
-
return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
|
|
297
|
-
if inp_sz < 1024 * 1024:
|
|
298
|
-
return f'{(inp_sz / 1024):0.3f} KiB'
|
|
299
|
-
if inp_sz < 1024 * 1024 * 1024:
|
|
300
|
-
return f'{(inp_sz / (1024 * 1024)):0.3f} MiB'
|
|
301
|
-
if inp_sz < 1024 * 1024 * 1024 * 1024:
|
|
302
|
-
return f'{(inp_sz / (1024 * 1024 * 1024)):0.3f} GiB'
|
|
303
|
-
if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024:
|
|
304
|
-
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.3f} TiB'
|
|
305
|
-
if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024 * 1024:
|
|
306
|
-
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.3f} PiB'
|
|
307
|
-
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def HumanizedDecimal(inp_sz: float, /, *, unit: str = '') -> str:
|
|
311
|
-
"""Convert a numeric value into a human-readable string using SI metric prefixes.
|
|
312
|
-
|
|
313
|
-
Scales the input value by powers of 1000, returning a value with the
|
|
314
|
-
appropriate SI unit prefix. Supports both large multiples (kilo, mega,
|
|
315
|
-
giga, … exa) and small sub-multiples (milli, micro, nano, pico, femto, atto).
|
|
316
|
-
|
|
317
|
-
Notes:
|
|
318
|
-
• Uses decimal multiples: 1 k = 1000 units, 1 m = 1/1000 units.
|
|
319
|
-
• Supported large prefixes: k, M, G, T, P, E.
|
|
320
|
-
• Supported small prefixes: m, µ, n, p, f, a.
|
|
321
|
-
• Unit string is stripped of surrounding whitespace before use.
|
|
322
|
-
• Zero is returned as '0' plus unit (no prefix).
|
|
323
|
-
|
|
324
|
-
Examples:
|
|
325
|
-
>>> HumanizedDecimal(950)
|
|
326
|
-
'950'
|
|
327
|
-
>>> HumanizedDecimal(1500)
|
|
328
|
-
'1.50 k'
|
|
329
|
-
>>> HumanizedDecimal(0.123456, unit='V')
|
|
330
|
-
'123.456 mV'
|
|
331
|
-
>>> HumanizedDecimal(3.2e-7, unit='F')
|
|
332
|
-
'320.000 nF'
|
|
333
|
-
>>> HumanizedDecimal(9.14e18, unit='Hz')
|
|
334
|
-
'9.14 EHz'
|
|
335
|
-
|
|
336
|
-
Args:
|
|
337
|
-
inp_sz (int | float): Quantity to convert. Must be finite.
|
|
338
|
-
unit (str, optional): Base unit to append to the result (e.g., 'Hz', 'm').
|
|
339
|
-
If given, it will be separated by a space for unscaled values and
|
|
340
|
-
concatenated to the prefix for scaled values.
|
|
341
|
-
|
|
342
|
-
Returns:
|
|
343
|
-
str: Formatted string with a few decimal places
|
|
344
|
-
|
|
345
|
-
Raises:
|
|
346
|
-
InputError: If `inp_sz` is not finite.
|
|
347
|
-
|
|
348
|
-
""" # noqa: RUF002
|
|
349
|
-
if not math.isfinite(inp_sz):
|
|
350
|
-
raise InputError(f'input should finite; got {inp_sz!r}')
|
|
351
|
-
unit = unit.strip()
|
|
352
|
-
pad_unit: str = ' ' + unit if unit else ''
|
|
353
|
-
if inp_sz == 0:
|
|
354
|
-
return '0' + pad_unit
|
|
355
|
-
neg: str = '-' if inp_sz < 0 else ''
|
|
356
|
-
inp_sz = abs(inp_sz)
|
|
357
|
-
# Find exponent of 1000 that keeps value in [1, 1000)
|
|
358
|
-
exp: int = math.floor(math.log10(abs(inp_sz)) / 3)
|
|
359
|
-
exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
|
|
360
|
-
if not exp:
|
|
361
|
-
# No scaling: use int or 4-decimal float
|
|
362
|
-
if isinstance(inp_sz, int) or inp_sz.is_integer():
|
|
363
|
-
return f'{neg}{int(inp_sz)}{pad_unit}'
|
|
364
|
-
return f'{neg}{inp_sz:0.3f}{pad_unit}'
|
|
365
|
-
# scaled
|
|
366
|
-
scaled: float = inp_sz / (1000**exp)
|
|
367
|
-
prefix: str = _SI_PREFIXES[exp]
|
|
368
|
-
return f'{neg}{scaled:0.3f} {prefix}{unit}'
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def HumanizedSeconds(inp_secs: float, /) -> str: # noqa: PLR0911
|
|
372
|
-
"""Convert a duration in seconds into a human-readable time string.
|
|
373
|
-
|
|
374
|
-
Selects the appropriate time unit based on the duration's magnitude:
|
|
375
|
-
- microseconds (`µs`)
|
|
376
|
-
- milliseconds (`ms`)
|
|
377
|
-
- seconds (`s`)
|
|
378
|
-
- minutes (`min`)
|
|
379
|
-
- hours (`h`)
|
|
380
|
-
- days (`d`)
|
|
381
|
-
|
|
382
|
-
Args:
|
|
383
|
-
inp_secs (int | float): Time interval in seconds. Must be finite and non-negative.
|
|
384
|
-
|
|
385
|
-
Returns:
|
|
386
|
-
str: Human-readable string with the duration and unit
|
|
387
|
-
|
|
388
|
-
Raises:
|
|
389
|
-
InputError: If `inp_secs` is negative or not finite.
|
|
390
|
-
|
|
391
|
-
Notes:
|
|
392
|
-
- Uses the micro sign (`µ`, U+00B5) for microseconds.
|
|
393
|
-
- Thresholds:
|
|
394
|
-
< 0.001 s → µs
|
|
395
|
-
< 1 s → ms
|
|
396
|
-
< 60 s → seconds
|
|
397
|
-
< 3600 s → minutes
|
|
398
|
-
< 86400 s → hours
|
|
399
|
-
≥ 86400 s → days
|
|
400
|
-
|
|
401
|
-
Examples:
|
|
402
|
-
>>> HumanizedSeconds(0)
|
|
403
|
-
'0.00 s'
|
|
404
|
-
>>> HumanizedSeconds(0.000004)
|
|
405
|
-
'4.000 µs'
|
|
406
|
-
>>> HumanizedSeconds(0.25)
|
|
407
|
-
'250.000 ms'
|
|
408
|
-
>>> HumanizedSeconds(42)
|
|
409
|
-
'42.00 s'
|
|
410
|
-
>>> HumanizedSeconds(3661)
|
|
411
|
-
'1.02 h'
|
|
412
|
-
|
|
413
|
-
""" # noqa: RUF002
|
|
414
|
-
if not math.isfinite(inp_secs) or inp_secs < 0:
|
|
415
|
-
raise InputError(f'input should be >=0 and got {inp_secs}')
|
|
416
|
-
if inp_secs == 0:
|
|
417
|
-
return '0.000 s'
|
|
418
|
-
inp_secs = float(inp_secs)
|
|
419
|
-
if inp_secs < 0.001: # noqa: PLR2004
|
|
420
|
-
return f'{inp_secs * 1000 * 1000:0.3f} µs' # noqa: RUF001
|
|
421
|
-
if inp_secs < 1:
|
|
422
|
-
return f'{inp_secs * 1000:0.3f} ms'
|
|
423
|
-
if inp_secs < 60: # noqa: PLR2004
|
|
424
|
-
return f'{inp_secs:0.3f} s'
|
|
425
|
-
if inp_secs < 60 * 60:
|
|
426
|
-
return f'{(inp_secs / 60):0.3f} min'
|
|
427
|
-
if inp_secs < 24 * 60 * 60:
|
|
428
|
-
return f'{(inp_secs / (60 * 60)):0.3f} h'
|
|
429
|
-
return f'{(inp_secs / (24 * 60 * 60)):0.3f} d'
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
def MeasurementStats(
|
|
433
|
-
data: list[int | float], /, *, confidence: float = 0.95
|
|
434
|
-
) -> tuple[int, float, float, float, tuple[float, float], float]:
|
|
435
|
-
"""Compute descriptive statistics for repeated measurements.
|
|
436
|
-
|
|
437
|
-
Given N ≥ 1 measurements, this function computes the sample mean, the
|
|
438
|
-
standard error of the mean (SEM), and the symmetric error estimate for
|
|
439
|
-
the chosen confidence interval using Student's t distribution.
|
|
440
|
-
|
|
441
|
-
Notes:
|
|
442
|
-
• If only one measurement is given, SEM and error are reported as +∞ and
|
|
443
|
-
the confidence interval is (-∞, +∞).
|
|
444
|
-
• This function assumes the underlying distribution is approximately
|
|
445
|
-
normal, or n is large enough for the Central Limit Theorem to apply.
|
|
446
|
-
|
|
447
|
-
Args:
|
|
448
|
-
data (list[int | float]): Sequence of numeric measurements.
|
|
449
|
-
confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
|
|
450
|
-
defaults to 0.95 (95% confidence interval).
|
|
451
|
-
|
|
452
|
-
Returns:
|
|
453
|
-
tuple:
|
|
454
|
-
- n (int): number of measurements.
|
|
455
|
-
- mean (float): arithmetic mean of the data
|
|
456
|
-
- sem (float): standard error of the mean, sigma / √n
|
|
457
|
-
- error (float): half-width of the confidence interval (mean ± error)
|
|
458
|
-
- ci (tuple[float, float]): lower and upper confidence interval bounds
|
|
459
|
-
- confidence (float): the confidence level used
|
|
460
|
-
|
|
461
|
-
Raises:
|
|
462
|
-
InputError: if the input list is empty.
|
|
463
|
-
|
|
464
|
-
"""
|
|
465
|
-
# test inputs
|
|
466
|
-
n: int = len(data)
|
|
467
|
-
if not n:
|
|
468
|
-
raise InputError('no data')
|
|
469
|
-
if not 0.5 <= confidence < 1.0: # noqa: PLR2004
|
|
470
|
-
raise InputError(f'invalid confidence: {confidence=}')
|
|
471
|
-
# solve trivial case
|
|
472
|
-
if n == 1:
|
|
473
|
-
return (n, float(data[0]), math.inf, math.inf, (-math.inf, math.inf), confidence)
|
|
474
|
-
# call scipy for the science data
|
|
475
|
-
np_data = np.array(data)
|
|
476
|
-
mean = np.mean(np_data)
|
|
477
|
-
sem = stats.sem(np_data)
|
|
478
|
-
ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem)
|
|
479
|
-
t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1)
|
|
480
|
-
error = t_crit * sem # half-width of the CI
|
|
481
|
-
return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def HumanizedMeasurements(
|
|
485
|
-
data: list[int | float],
|
|
486
|
-
/,
|
|
487
|
-
*,
|
|
488
|
-
unit: str = '',
|
|
489
|
-
parser: abc.Callable[[float], str] | None = None,
|
|
490
|
-
clip_negative: bool = True,
|
|
491
|
-
confidence: float = 0.95,
|
|
492
|
-
) -> str:
|
|
493
|
-
"""Render measurement statistics as a human-readable string.
|
|
494
|
-
|
|
495
|
-
Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
|
|
496
|
-
result with units, sample count, and confidence interval. Negative values
|
|
497
|
-
can optionally be clipped to zero and marked with a leading “*”.
|
|
498
|
-
|
|
499
|
-
Notes:
|
|
500
|
-
• For a single measurement, error is displayed as “± ?”.
|
|
501
|
-
• The output includes the number of samples (@n) and the confidence
|
|
502
|
-
interval unless a different confidence was requested upstream.
|
|
503
|
-
|
|
504
|
-
Args:
|
|
505
|
-
data (list[int | float]): Sequence of numeric measurements.
|
|
506
|
-
unit (str, optional): Unit of measurement to append, e.g. "ms" or "s".
|
|
507
|
-
Defaults to '' (no unit).
|
|
508
|
-
parser (Callable[[float], str] | None, optional): Custom float-to-string
|
|
509
|
-
formatter. If None, values are formatted with 3 decimal places.
|
|
510
|
-
clip_negative (bool, optional): If True (default), negative values are
|
|
511
|
-
clipped to 0.0 and prefixed with '*'.
|
|
512
|
-
confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
|
|
513
|
-
defaults to 0.95 (95% confidence interval).
|
|
514
|
-
|
|
515
|
-
Returns:
|
|
516
|
-
str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
|
|
517
|
-
|
|
518
|
-
"""
|
|
519
|
-
n: int
|
|
520
|
-
mean: float
|
|
521
|
-
error: float
|
|
522
|
-
ci: tuple[float, float]
|
|
523
|
-
conf: float
|
|
524
|
-
unit = unit.strip()
|
|
525
|
-
n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
|
|
526
|
-
f: abc.Callable[[float], str] = lambda x: (
|
|
527
|
-
('*0' if clip_negative and x < 0.0 else str(x))
|
|
528
|
-
if parser is None
|
|
529
|
-
else (f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x))
|
|
530
|
-
)
|
|
531
|
-
if n == 1:
|
|
532
|
-
return f'{f(mean)}{unit} ±? @1'
|
|
533
|
-
pct: int = round(conf * 100)
|
|
534
|
-
return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
class Timer:
|
|
538
|
-
"""An execution timing class that can be used as both a context manager and a decorator.
|
|
539
|
-
|
|
540
|
-
Examples:
|
|
541
|
-
# As a context manager
|
|
542
|
-
with Timer('Block timing'):
|
|
543
|
-
time.sleep(1.2)
|
|
544
|
-
|
|
545
|
-
# As a decorator
|
|
546
|
-
@Timer('Function timing')
|
|
547
|
-
def slow_function():
|
|
548
|
-
time.sleep(0.8)
|
|
549
|
-
|
|
550
|
-
# As a regular object
|
|
551
|
-
tm = Timer('Inline timing')
|
|
552
|
-
tm.Start()
|
|
553
|
-
time.sleep(0.1)
|
|
554
|
-
tm.Stop()
|
|
555
|
-
print(tm)
|
|
556
|
-
|
|
557
|
-
Attributes:
|
|
558
|
-
label (str, optional): Timer label
|
|
559
|
-
emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
|
|
560
|
-
emit_print (bool, optional): If True will print() the timer, else (default) will not
|
|
561
|
-
|
|
562
|
-
"""
|
|
563
|
-
|
|
564
|
-
def __init__(
|
|
565
|
-
self, label: str = '', /, *, emit_log: bool = True, emit_print: bool = False
|
|
566
|
-
) -> None:
|
|
567
|
-
"""Initialize the Timer.
|
|
568
|
-
|
|
569
|
-
Args:
|
|
570
|
-
label (str, optional): A description or name for the timed block or function
|
|
571
|
-
emit_log (bool, optional): Emit a log message when finished; default is True
|
|
572
|
-
emit_print (bool, optional): Emit a print() message when finished; default is False
|
|
573
|
-
|
|
574
|
-
"""
|
|
575
|
-
self.emit_log: bool = emit_log
|
|
576
|
-
self.emit_print: bool = emit_print
|
|
577
|
-
self.label: str = label.strip()
|
|
578
|
-
self.start: float | None = None
|
|
579
|
-
self.end: float | None = None
|
|
580
|
-
|
|
581
|
-
@property
|
|
582
|
-
def elapsed(self) -> float:
|
|
583
|
-
"""Elapsed time. Will be zero until a measurement is available with start/end.
|
|
584
|
-
|
|
585
|
-
Raises:
|
|
586
|
-
Error: negative elapsed time
|
|
587
|
-
|
|
588
|
-
Returns:
|
|
589
|
-
float: elapsed time, in seconds
|
|
590
|
-
|
|
591
|
-
"""
|
|
592
|
-
if self.start is None or self.end is None:
|
|
593
|
-
return 0.0
|
|
594
|
-
delta: float = self.end - self.start
|
|
595
|
-
if delta <= 0.0:
|
|
596
|
-
raise Error(f'negative/zero delta: {delta}')
|
|
597
|
-
return delta
|
|
598
|
-
|
|
599
|
-
def __str__(self) -> str:
|
|
600
|
-
"""Get current timer value.
|
|
601
|
-
|
|
602
|
-
Returns:
|
|
603
|
-
str: human-readable representation of current time value
|
|
604
|
-
|
|
605
|
-
"""
|
|
606
|
-
if self.start is None:
|
|
607
|
-
return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
|
|
608
|
-
if self.end is None:
|
|
609
|
-
return (
|
|
610
|
-
f'{self.label}: ' if self.label else ''
|
|
611
|
-
) + f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}'
|
|
612
|
-
return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
|
|
613
|
-
|
|
614
|
-
def Start(self) -> None:
|
|
615
|
-
"""Start the timer.
|
|
616
|
-
|
|
617
|
-
Raises:
|
|
618
|
-
Error: if you try to re-start the timer
|
|
619
|
-
|
|
620
|
-
"""
|
|
621
|
-
if self.start is not None:
|
|
622
|
-
raise Error('Re-starting timer is forbidden')
|
|
623
|
-
self.start = time.perf_counter()
|
|
624
|
-
|
|
625
|
-
def __enter__(self) -> Self:
|
|
626
|
-
"""Start the timer when entering the context.
|
|
627
|
-
|
|
628
|
-
Returns:
|
|
629
|
-
Timer: context object (self)
|
|
630
|
-
|
|
631
|
-
"""
|
|
632
|
-
self.Start()
|
|
633
|
-
return self
|
|
634
|
-
|
|
635
|
-
def Stop(self) -> None:
|
|
636
|
-
"""Stop the timer and emit logging.info with timer message.
|
|
637
|
-
|
|
638
|
-
Raises:
|
|
639
|
-
Error: trying to re-start timer or stop unstarted timer
|
|
640
|
-
|
|
641
|
-
"""
|
|
642
|
-
if self.start is None:
|
|
643
|
-
raise Error('Stopping an unstarted timer')
|
|
644
|
-
if self.end is not None:
|
|
645
|
-
raise Error('Re-stopping timer is forbidden')
|
|
646
|
-
self.end = time.perf_counter()
|
|
647
|
-
message: str = str(self)
|
|
648
|
-
if self.emit_log:
|
|
649
|
-
logging.info(message)
|
|
650
|
-
if self.emit_print:
|
|
651
|
-
Console().print(message)
|
|
652
|
-
|
|
653
|
-
def __exit__(
|
|
654
|
-
self,
|
|
655
|
-
unused_exc_type: type[BaseException] | None,
|
|
656
|
-
unused_exc_val: BaseException | None,
|
|
657
|
-
exc_tb: TracebackType | None,
|
|
658
|
-
) -> None:
|
|
659
|
-
"""Stop the timer when exiting the context."""
|
|
660
|
-
self.Stop()
|
|
661
|
-
|
|
662
|
-
_F = TypeVar('_F', bound=abc.Callable[..., Any])
|
|
663
|
-
|
|
664
|
-
def __call__(self, func: Timer._F) -> Timer._F:
|
|
665
|
-
"""Allow the Timer to be used as a decorator.
|
|
666
|
-
|
|
667
|
-
Args:
|
|
668
|
-
func: The function to time.
|
|
669
|
-
|
|
670
|
-
Returns:
|
|
671
|
-
The wrapped function with timing behavior.
|
|
672
|
-
|
|
673
|
-
"""
|
|
674
|
-
|
|
675
|
-
@functools.wraps(func)
|
|
676
|
-
def _Wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
677
|
-
with self.__class__(self.label, emit_log=self.emit_log, emit_print=self.emit_print):
|
|
678
|
-
return func(*args, **kwargs)
|
|
679
|
-
|
|
680
|
-
return _Wrapper # type:ignore
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def RandBits(n_bits: int, /) -> int:
|
|
684
|
-
"""Crypto-random integer with guaranteed `n_bits` size (i.e., first bit == 1).
|
|
685
|
-
|
|
686
|
-
The fact that the first bit will be 1 means the entropy is ~ (n_bits-1) and
|
|
687
|
-
because of this we only allow for a byte or more bits generated. This drawback
|
|
688
|
-
is negligible for the large integers a crypto library will work with, in practice.
|
|
689
|
-
|
|
690
|
-
Args:
|
|
691
|
-
n_bits (int): number of bits to produce, ≥ 8
|
|
692
|
-
|
|
693
|
-
Returns:
|
|
694
|
-
int with n_bits size
|
|
695
|
-
|
|
696
|
-
Raises:
|
|
697
|
-
InputError: invalid n_bits
|
|
698
|
-
|
|
699
|
-
"""
|
|
700
|
-
# test inputs
|
|
701
|
-
if n_bits < 8: # noqa: PLR2004
|
|
702
|
-
raise InputError(f'n_bits must be ≥ 8: {n_bits}')
|
|
703
|
-
# call underlying method
|
|
704
|
-
n: int = 0
|
|
705
|
-
while n.bit_length() != n_bits:
|
|
706
|
-
n = secrets.randbits(n_bits) # we could just set the bit, but IMO it is better to get another
|
|
707
|
-
return n
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
def RandInt(min_int: int, max_int: int, /) -> int:
|
|
711
|
-
"""Crypto-random integer uniform over [min_int, max_int].
|
|
712
|
-
|
|
713
|
-
Args:
|
|
714
|
-
min_int (int): minimum integer, inclusive, ≥ 0
|
|
715
|
-
max_int (int): maximum integer, inclusive, > min_int
|
|
716
|
-
|
|
717
|
-
Returns:
|
|
718
|
-
int between [min_int, max_int] inclusive
|
|
719
|
-
|
|
720
|
-
Raises:
|
|
721
|
-
InputError: invalid min/max
|
|
722
|
-
|
|
723
|
-
"""
|
|
724
|
-
# test inputs
|
|
725
|
-
if min_int < 0 or min_int >= max_int:
|
|
726
|
-
raise InputError(f'min_int must be ≥ 0, and < max_int: {min_int} / {max_int}')
|
|
727
|
-
# uniform over [min_int, max_int]
|
|
728
|
-
span: int = max_int - min_int + 1
|
|
729
|
-
n: int = min_int + secrets.randbelow(span)
|
|
730
|
-
assert min_int <= n <= max_int, 'should never happen: generated number out of range' # noqa: S101
|
|
731
|
-
return n
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
def RandShuffle[T: Any](seq: abc.MutableSequence[T], /) -> None:
|
|
735
|
-
"""In-place Crypto-random shuffle order for `seq` mutable sequence.
|
|
736
|
-
|
|
737
|
-
Args:
|
|
738
|
-
seq (MutableSequence[T]): any mutable sequence with 2 or more elements
|
|
739
|
-
|
|
740
|
-
Raises:
|
|
741
|
-
InputError: not enough elements
|
|
742
|
-
|
|
743
|
-
"""
|
|
744
|
-
# test inputs
|
|
745
|
-
if (n_seq := len(seq)) < 2: # noqa: PLR2004
|
|
746
|
-
raise InputError(f'seq must have 2 or more elements: {n_seq}')
|
|
747
|
-
# cryptographically sound Fisher-Yates using secrets.randbelow
|
|
748
|
-
for i in range(n_seq - 1, 0, -1):
|
|
749
|
-
j: int = secrets.randbelow(i + 1)
|
|
750
|
-
seq[i], seq[j] = seq[j], seq[i]
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
def RandBytes(n_bytes: int, /) -> bytes:
|
|
754
|
-
"""Crypto-random `n_bytes` bytes. Just plain good quality random bytes.
|
|
755
|
-
|
|
756
|
-
Args:
|
|
757
|
-
n_bytes (int): number of bits to produce, > 0
|
|
758
|
-
|
|
759
|
-
Returns:
|
|
760
|
-
bytes: random with len()==n_bytes
|
|
761
|
-
|
|
762
|
-
Raises:
|
|
763
|
-
InputError: invalid n_bytes
|
|
764
|
-
|
|
765
|
-
"""
|
|
766
|
-
# test inputs
|
|
767
|
-
if n_bytes < 1:
|
|
768
|
-
raise InputError(f'n_bytes must be ≥ 1: {n_bytes}')
|
|
769
|
-
# return from system call
|
|
770
|
-
b: bytes = secrets.token_bytes(n_bytes)
|
|
771
|
-
assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size' # noqa: S101
|
|
772
|
-
return b
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
def GCD(a: int, b: int, /) -> int:
|
|
776
|
-
"""Greatest Common Divisor for `a` and `b`, integers ≥0. Uses the Euclid method.
|
|
777
|
-
|
|
778
|
-
O(log(min(a, b)))
|
|
779
|
-
|
|
780
|
-
Args:
|
|
781
|
-
a (int): integer a ≥ 0
|
|
782
|
-
b (int): integer b ≥ 0 (can't be both zero)
|
|
783
|
-
|
|
784
|
-
Returns:
|
|
785
|
-
gcd(a, b)
|
|
786
|
-
|
|
787
|
-
Raises:
|
|
788
|
-
InputError: invalid inputs
|
|
789
|
-
|
|
790
|
-
"""
|
|
791
|
-
# test inputs
|
|
792
|
-
if a < 0 or b < 0 or (not a and not b):
|
|
793
|
-
raise InputError(f'negative input or undefined gcd(0, 0): {a=} , {b=}')
|
|
794
|
-
# algo needs to start with a >= b
|
|
795
|
-
if a < b:
|
|
796
|
-
a, b = b, a
|
|
797
|
-
# euclid
|
|
798
|
-
while b:
|
|
799
|
-
r: int = a % b
|
|
800
|
-
a, b = b, r
|
|
801
|
-
return a
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def ExtendedGCD(a: int, b: int, /) -> tuple[int, int, int]:
|
|
805
|
-
"""Greatest Common Divisor Extended for `a` and `b`, integers ≥0. Uses the Euclid method.
|
|
806
|
-
|
|
807
|
-
O(log(min(a, b)))
|
|
808
|
-
|
|
809
|
-
Args:
|
|
810
|
-
a (int): integer a ≥ 0
|
|
811
|
-
b (int): integer b ≥ 0 (can't be both zero)
|
|
812
|
-
|
|
813
|
-
Returns:
|
|
814
|
-
(gcd, x, y) so that a * x + b * y = gcd
|
|
815
|
-
x and y may be negative integers or zero but won't be both zero.
|
|
816
|
-
|
|
817
|
-
Raises:
|
|
818
|
-
InputError: invalid inputs
|
|
819
|
-
|
|
820
|
-
"""
|
|
821
|
-
# test inputs
|
|
822
|
-
if a < 0 or b < 0 or (not a and not b):
|
|
823
|
-
raise InputError(f'negative input or undefined gcd(0, 0): {a=} , {b=}')
|
|
824
|
-
# algo needs to start with a >= b (but we remember if we did swap)
|
|
825
|
-
swapped = False
|
|
826
|
-
if a < b:
|
|
827
|
-
a, b = b, a
|
|
828
|
-
swapped = True
|
|
829
|
-
# trivial case
|
|
830
|
-
if not b:
|
|
831
|
-
return (a, 0 if swapped else 1, 1 if swapped else 0)
|
|
832
|
-
# euclid
|
|
833
|
-
x1, x2, y1, y2 = 0, 1, 1, 0
|
|
834
|
-
while b:
|
|
835
|
-
q, r = divmod(a, b)
|
|
836
|
-
x, y = x2 - q * x1, y2 - q * y1
|
|
837
|
-
a, b, x1, x2, y1, y2 = b, r, x, x1, y, y1
|
|
838
|
-
return (a, y2 if swapped else x2, x2 if swapped else y2)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
def Hash256(data: bytes, /) -> bytes:
|
|
842
|
-
"""SHA-256 hash of bytes data. Always a length of 32 bytes.
|
|
843
|
-
|
|
844
|
-
Args:
|
|
845
|
-
data (bytes): Data to compute hash for
|
|
846
|
-
|
|
847
|
-
Returns:
|
|
848
|
-
32 bytes (256 bits) of SHA-256 hash;
|
|
849
|
-
if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
|
|
850
|
-
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
|
|
851
|
-
|
|
852
|
-
"""
|
|
853
|
-
return hashlib.sha256(data).digest()
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
def Hash512(data: bytes, /) -> bytes:
|
|
857
|
-
"""SHA-512 hash of bytes data. Always a length of 64 bytes.
|
|
858
|
-
|
|
859
|
-
Args:
|
|
860
|
-
data (bytes): Data to compute hash for
|
|
861
|
-
|
|
862
|
-
Returns:
|
|
863
|
-
64 bytes (512 bits) of SHA-512 hash;
|
|
864
|
-
if converted to hexadecimal (with BytesToHex() or hex()) will be 128 chars of string;
|
|
865
|
-
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**512
|
|
866
|
-
|
|
867
|
-
"""
|
|
868
|
-
return hashlib.sha512(data).digest()
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
|
|
872
|
-
"""SHA-256 hex hash of file on disk. Always a length of 32 bytes (if default digest=='sha256').
|
|
873
|
-
|
|
874
|
-
Args:
|
|
875
|
-
full_path (str): Path to existing file on disk
|
|
876
|
-
digest (str, optional): Hash method to use, accepts 'sha256' (default) or 'sha512'
|
|
877
|
-
|
|
878
|
-
Returns:
|
|
879
|
-
32 bytes (256 bits) of SHA-256 hash (if default digest=='sha256');
|
|
880
|
-
if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
|
|
881
|
-
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
|
|
882
|
-
|
|
883
|
-
Raises:
|
|
884
|
-
InputError: file could not be found
|
|
885
|
-
|
|
886
|
-
"""
|
|
887
|
-
# test inputs
|
|
888
|
-
digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
|
|
889
|
-
if digest not in {'sha256', 'sha512'}:
|
|
890
|
-
raise InputError(f'unrecognized digest: {digest!r}')
|
|
891
|
-
full_path = full_path.strip()
|
|
892
|
-
if not full_path or not pathlib.Path(full_path).exists():
|
|
893
|
-
raise InputError(f'file {full_path!r} not found for hashing')
|
|
894
|
-
# compute hash
|
|
895
|
-
logging.info(f'Hashing file {full_path!r}')
|
|
896
|
-
with pathlib.Path(full_path).open('rb') as file_obj:
|
|
897
|
-
return hashlib.file_digest(file_obj, digest).digest()
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
def ObfuscateSecret(data: str | bytes | int, /) -> str:
|
|
901
|
-
"""Obfuscate a secret string/key/bytes/int by hashing SHA-512 and only showing the first 4 bytes.
|
|
902
|
-
|
|
903
|
-
Always a length of 9 chars, e.g. "aabbccdd…" (always adds '…' at the end).
|
|
904
|
-
Known vulnerability: If the secret is small, can be brute-forced!
|
|
905
|
-
Use only on large (~>64bits) secrets.
|
|
906
|
-
|
|
907
|
-
Args:
|
|
908
|
-
data (str | bytes | int): Data to obfuscate
|
|
909
|
-
|
|
910
|
-
Raises:
|
|
911
|
-
InputError: _description_
|
|
912
|
-
|
|
913
|
-
Returns:
|
|
914
|
-
str: obfuscated string, e.g. "aabbccdd…"
|
|
915
|
-
|
|
916
|
-
"""
|
|
917
|
-
if isinstance(data, str):
|
|
918
|
-
data = data.encode('utf-8')
|
|
919
|
-
elif isinstance(data, int):
|
|
920
|
-
data = IntToBytes(data)
|
|
921
|
-
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
922
|
-
raise InputError(f'invalid type for data: {type(data)}')
|
|
923
|
-
return BytesToHex(Hash512(data))[:8] + '…'
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
class CryptoInputType(enum.StrEnum):
|
|
927
|
-
"""Types of inputs that can represent arbitrary bytes."""
|
|
928
|
-
|
|
929
|
-
# prefixes; format prefixes are all 4 bytes
|
|
930
|
-
PATH = '@' # @path on disk → read bytes from a file
|
|
931
|
-
STDIN = '@-' # stdin
|
|
932
|
-
HEX = 'hex:' # hex:deadbeef → decode hex
|
|
933
|
-
BASE64 = 'b64:' # b64:... → decode base64
|
|
934
|
-
STR = 'str:' # str:hello → UTF-8 encode the literal
|
|
935
|
-
RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def BytesToRaw(b: bytes, /) -> str:
|
|
939
|
-
r"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
940
|
-
|
|
941
|
-
1. map bytes 0..255 to same code points (latin1)
|
|
942
|
-
2. escape non-printables/backslash/quotes via unicode_escape
|
|
943
|
-
|
|
944
|
-
Args:
|
|
945
|
-
b (bytes): input
|
|
946
|
-
|
|
947
|
-
Returns:
|
|
948
|
-
str: double-quoted string with \\xNN escapes where needed
|
|
949
|
-
|
|
950
|
-
"""
|
|
951
|
-
inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
|
|
952
|
-
return f'"{inner.replace('"', r"\"")}"'
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
def RawToBytes(s: str, /) -> bytes:
|
|
956
|
-
r"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
957
|
-
|
|
958
|
-
Args:
|
|
959
|
-
s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
|
|
960
|
-
|
|
961
|
-
Returns:
|
|
962
|
-
bytes: data
|
|
963
|
-
|
|
964
|
-
"""
|
|
965
|
-
if len(s) >= 2 and s[0] == s[-1] == '"': # noqa: PLR2004
|
|
966
|
-
s = s[1:-1]
|
|
967
|
-
# decode backslash escapes to code points, then map 0..255 -> bytes
|
|
968
|
-
return codecs.decode(s, 'unicode_escape').encode('latin1')
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
def DetectInputType(data_str: str, /) -> CryptoInputType | None:
|
|
972
|
-
"""Auto-detect `data_str` type, if possible.
|
|
973
|
-
|
|
974
|
-
Args:
|
|
975
|
-
data_str (str): data to process, putatively a bytes blob
|
|
976
|
-
|
|
977
|
-
Returns:
|
|
978
|
-
CryptoInputType | None: type if has a known prefix, None otherwise
|
|
979
|
-
|
|
980
|
-
"""
|
|
981
|
-
data_str = data_str.strip()
|
|
982
|
-
if data_str == CryptoInputType.STDIN:
|
|
983
|
-
return CryptoInputType.STDIN
|
|
984
|
-
for t in (
|
|
985
|
-
CryptoInputType.PATH,
|
|
986
|
-
CryptoInputType.STR,
|
|
987
|
-
CryptoInputType.HEX,
|
|
988
|
-
CryptoInputType.BASE64,
|
|
989
|
-
CryptoInputType.RAW,
|
|
990
|
-
):
|
|
991
|
-
if data_str.startswith(t):
|
|
992
|
-
return t
|
|
993
|
-
return None
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # noqa: C901, PLR0911, PLR0912
|
|
997
|
-
"""Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
|
|
998
|
-
|
|
999
|
-
Can load from disk ('@'). Can load from stdin ('@-').
|
|
1000
|
-
|
|
1001
|
-
Args:
|
|
1002
|
-
data_str (str): data to process, putatively a bytes blob
|
|
1003
|
-
expect (CryptoInputType | None, optional): If not given (None) will try to auto-detect the
|
|
1004
|
-
input type by looking at the prefix on `data_str` and if none is found will suppose
|
|
1005
|
-
a 'str:' was given; if one of the supported CryptoInputType is given then will enforce
|
|
1006
|
-
that specific type prefix or no prefix
|
|
1007
|
-
|
|
1008
|
-
Returns:
|
|
1009
|
-
bytes: data
|
|
1010
|
-
|
|
1011
|
-
Raises:
|
|
1012
|
-
InputError: unexpected type or conversion error
|
|
1013
|
-
|
|
1014
|
-
"""
|
|
1015
|
-
data_str = data_str.strip()
|
|
1016
|
-
# auto-detect
|
|
1017
|
-
detected_type: CryptoInputType | None = DetectInputType(data_str)
|
|
1018
|
-
expect = CryptoInputType.STR if expect is None and detected_type is None else expect
|
|
1019
|
-
if detected_type is not None and expect is not None and detected_type != expect:
|
|
1020
|
-
raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
|
|
1021
|
-
# now we know they don't conflict, so unify them; remove prefix if we have it
|
|
1022
|
-
expect = detected_type if expect is None else expect
|
|
1023
|
-
assert expect is not None, 'should never happen: type should be known here' # noqa: S101
|
|
1024
|
-
data_str = data_str.removeprefix(expect)
|
|
1025
|
-
# for every type something different will happen now
|
|
1026
|
-
try:
|
|
1027
|
-
match expect:
|
|
1028
|
-
case CryptoInputType.STDIN:
|
|
1029
|
-
# read raw bytes from stdin: prefer the binary buffer; if unavailable,
|
|
1030
|
-
# fall back to text stream encoded as UTF-8 (consistent with str: policy).
|
|
1031
|
-
stream = getattr(sys.stdin, 'buffer', None)
|
|
1032
|
-
if stream is None:
|
|
1033
|
-
text: str = sys.stdin.read()
|
|
1034
|
-
if not isinstance(text, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1035
|
-
raise InputError('sys.stdin.read() produced non-text data') # noqa: TRY301
|
|
1036
|
-
return text.encode('utf-8')
|
|
1037
|
-
data: bytes = stream.read()
|
|
1038
|
-
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1039
|
-
raise InputError('sys.stdin.buffer.read() produced non-binary data') # noqa: TRY301
|
|
1040
|
-
return data
|
|
1041
|
-
case CryptoInputType.PATH:
|
|
1042
|
-
if not pathlib.Path(data_str).exists():
|
|
1043
|
-
raise InputError(f'cannot find file {data_str!r}') # noqa: TRY301
|
|
1044
|
-
return pathlib.Path(data_str).read_bytes()
|
|
1045
|
-
case CryptoInputType.STR:
|
|
1046
|
-
return data_str.encode('utf-8')
|
|
1047
|
-
case CryptoInputType.HEX:
|
|
1048
|
-
return HexToBytes(data_str)
|
|
1049
|
-
case CryptoInputType.BASE64:
|
|
1050
|
-
return EncodedToBytes(data_str)
|
|
1051
|
-
case CryptoInputType.RAW:
|
|
1052
|
-
return RawToBytes(data_str)
|
|
1053
|
-
case _:
|
|
1054
|
-
raise InputError(f'invalid type {expect!r}') # noqa: TRY301
|
|
1055
|
-
except Exception as err:
|
|
1056
|
-
raise InputError(f'invalid input: {err}') from err
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
1060
|
-
class CryptoKey(abstract.ABC):
|
|
1061
|
-
"""A cryptographic key."""
|
|
1062
|
-
|
|
1063
|
-
@abstract.abstractmethod
|
|
1064
|
-
def __post_init__(self) -> None:
|
|
1065
|
-
"""Check data."""
|
|
1066
|
-
# every sub-class of CryptoKey has to implement its own version of __post_init__()
|
|
1067
|
-
|
|
1068
|
-
@abstract.abstractmethod
|
|
1069
|
-
def __str__(self) -> str:
|
|
1070
|
-
"""Safe (no secrets) string representation of the key.
|
|
1071
|
-
|
|
1072
|
-
Returns:
|
|
1073
|
-
string representation of the key without leaking secrets
|
|
1074
|
-
|
|
1075
|
-
"""
|
|
1076
|
-
# every sub-class of CryptoKey has to implement its own version of __str__()
|
|
1077
|
-
|
|
1078
|
-
@final
|
|
1079
|
-
def __repr__(self) -> str:
|
|
1080
|
-
"""Safe (no secrets) string representation of the key. Same as __str__().
|
|
1081
|
-
|
|
1082
|
-
Returns:
|
|
1083
|
-
string representation of the key without leaking secrets
|
|
1084
|
-
|
|
1085
|
-
"""
|
|
1086
|
-
# concrete __repr__() delegates to the (abstract) __str__():
|
|
1087
|
-
# this avoids marking __repr__() abstract while still unifying behavior
|
|
1088
|
-
return self.__str__()
|
|
1089
|
-
|
|
1090
|
-
@final
|
|
1091
|
-
def _DebugDump(self) -> str:
|
|
1092
|
-
"""Debug dump of the key object. NOT for logging, NOT for regular use, EXPOSES secrets.
|
|
1093
|
-
|
|
1094
|
-
We disable default __repr__() for the CryptoKey classes for security reasons, so we won't
|
|
1095
|
-
leak private key values into logs, but this method allows for explicit access to the
|
|
1096
|
-
class fields for debugging purposes by mimicking the usual dataclass __repr__().
|
|
1097
|
-
|
|
1098
|
-
Returns:
|
|
1099
|
-
string with all the object's fields explicit values
|
|
1100
|
-
|
|
1101
|
-
"""
|
|
1102
|
-
cls: str = type(self).__name__
|
|
1103
|
-
parts: list[str] = []
|
|
1104
|
-
for field in dataclasses.fields(self):
|
|
1105
|
-
val: Any = getattr(self, field.name) # getattr is fine with frozen/slots
|
|
1106
|
-
parts.append(f'{field.name}={val!r}')
|
|
1107
|
-
return f'{cls}({", ".join(parts)})'
|
|
1108
|
-
|
|
1109
|
-
@final
|
|
1110
|
-
@property
|
|
1111
|
-
def _json_dict(self) -> dict[str, Any]:
|
|
1112
|
-
"""Dictionary representation of the object suitable for JSON conversion.
|
|
1113
|
-
|
|
1114
|
-
Returns:
|
|
1115
|
-
dict[str, Any]: representation of the object suitable for JSON conversion
|
|
1116
|
-
|
|
1117
|
-
Raises:
|
|
1118
|
-
ImplementationError: object has types that are not supported in JSON
|
|
1119
|
-
|
|
1120
|
-
"""
|
|
1121
|
-
self_dict: dict[str, Any] = dataclasses.asdict(self)
|
|
1122
|
-
for field in dataclasses.fields(self):
|
|
1123
|
-
# check the type is OK
|
|
1124
|
-
if field.type not in _JSON_DATACLASS_TYPES:
|
|
1125
|
-
raise ImplementationError(
|
|
1126
|
-
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
|
|
1127
|
-
)
|
|
1128
|
-
# convert types that we accept but JSON does not
|
|
1129
|
-
if field.type == 'bytes':
|
|
1130
|
-
self_dict[field.name] = BytesToEncoded(self_dict[field.name])
|
|
1131
|
-
return self_dict
|
|
1132
|
-
|
|
1133
|
-
@final
|
|
1134
|
-
@property
|
|
1135
|
-
def json(self) -> str:
|
|
1136
|
-
"""JSON representation of the object, tightly packed, not for humans.
|
|
1137
|
-
|
|
1138
|
-
Returns:
|
|
1139
|
-
str: JSON representation of the object, tightly packed
|
|
1140
|
-
|
|
1141
|
-
"""
|
|
1142
|
-
return json.dumps(self._json_dict, separators=(',', ':'))
|
|
1143
|
-
|
|
1144
|
-
@final
|
|
1145
|
-
@property
|
|
1146
|
-
def formatted_json(self) -> str:
|
|
1147
|
-
"""JSON representation of the object formatted for humans.
|
|
1148
|
-
|
|
1149
|
-
Returns:
|
|
1150
|
-
str: JSON representation of the object formatted for humans
|
|
1151
|
-
|
|
1152
|
-
"""
|
|
1153
|
-
return json.dumps(self._json_dict, indent=4, sort_keys=True)
|
|
1154
|
-
|
|
1155
|
-
@final
|
|
1156
|
-
@classmethod
|
|
1157
|
-
def _FromJSONDict(cls, json_dict: dict[str, Any], /) -> Self:
|
|
1158
|
-
"""Create object from JSON representation.
|
|
1159
|
-
|
|
1160
|
-
Args:
|
|
1161
|
-
json_dict (dict[str, Any]): JSON dict
|
|
1162
|
-
|
|
1163
|
-
Returns:
|
|
1164
|
-
a CryptoKey object ready for use
|
|
1165
|
-
|
|
1166
|
-
Raises:
|
|
1167
|
-
InputError: unexpected type/fields
|
|
1168
|
-
ImplementationError: unsupported JSON field
|
|
1169
|
-
|
|
1170
|
-
"""
|
|
1171
|
-
# check we got exactly the fields we needed
|
|
1172
|
-
cls_fields: set[str] = {f.name for f in dataclasses.fields(cls)}
|
|
1173
|
-
json_fields: set[str] = set(json_dict)
|
|
1174
|
-
if cls_fields != json_fields:
|
|
1175
|
-
raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
|
|
1176
|
-
# reconstruct the types we meddled with inside self._json_dict
|
|
1177
|
-
for field in dataclasses.fields(cls):
|
|
1178
|
-
if field.type not in _JSON_DATACLASS_TYPES:
|
|
1179
|
-
raise ImplementationError(
|
|
1180
|
-
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
|
|
1181
|
-
)
|
|
1182
|
-
if field.type == 'bytes':
|
|
1183
|
-
json_dict[field.name] = EncodedToBytes(json_dict[field.name])
|
|
1184
|
-
# build the object
|
|
1185
|
-
return cls(**json_dict)
|
|
1186
|
-
|
|
1187
|
-
@final
|
|
1188
|
-
@classmethod
|
|
1189
|
-
def FromJSON(cls, json_data: str, /) -> Self:
|
|
1190
|
-
"""Create object from JSON representation.
|
|
1191
|
-
|
|
1192
|
-
Args:
|
|
1193
|
-
json_data (str): JSON string
|
|
1194
|
-
|
|
1195
|
-
Returns:
|
|
1196
|
-
a CryptoKey object ready for use
|
|
1197
|
-
|
|
1198
|
-
Raises:
|
|
1199
|
-
InputError: unexpected type/fields
|
|
1200
|
-
|
|
1201
|
-
"""
|
|
1202
|
-
# get the dict back
|
|
1203
|
-
json_dict: dict[str, Any] = json.loads(json_data)
|
|
1204
|
-
if not isinstance(json_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1205
|
-
raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
|
|
1206
|
-
return cls._FromJSONDict(json_dict)
|
|
1207
|
-
|
|
1208
|
-
@final
|
|
1209
|
-
@property
|
|
1210
|
-
def blob(self) -> bytes:
|
|
1211
|
-
"""Serial (bytes) representation of the object.
|
|
1212
|
-
|
|
1213
|
-
Returns:
|
|
1214
|
-
bytes, pickled, representation of the object
|
|
1215
|
-
|
|
1216
|
-
"""
|
|
1217
|
-
return self.Blob()
|
|
1218
|
-
|
|
1219
|
-
@final
|
|
1220
|
-
def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
|
|
1221
|
-
"""Get serial (bytes) representation of the object with more options, including encryption.
|
|
1222
|
-
|
|
1223
|
-
Args:
|
|
1224
|
-
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1225
|
-
silent (bool, optional): if True (default) will not log
|
|
1226
|
-
|
|
1227
|
-
Returns:
|
|
1228
|
-
bytes, pickled, representation of the object
|
|
1229
|
-
|
|
1230
|
-
"""
|
|
1231
|
-
return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
|
|
1232
|
-
|
|
1233
|
-
@final
|
|
1234
|
-
@property
|
|
1235
|
-
def encoded(self) -> str:
|
|
1236
|
-
"""Base-64 representation of the object.
|
|
1237
|
-
|
|
1238
|
-
Returns:
|
|
1239
|
-
str, pickled, base64, representation of the object
|
|
1240
|
-
|
|
1241
|
-
"""
|
|
1242
|
-
return self.Encoded()
|
|
1243
|
-
|
|
1244
|
-
@final
|
|
1245
|
-
def Encoded(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
|
|
1246
|
-
"""Base-64 representation of the object with more options, including encryption.
|
|
1247
|
-
|
|
1248
|
-
Args:
|
|
1249
|
-
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1250
|
-
silent (bool, optional): if True (default) will not log
|
|
1251
|
-
|
|
1252
|
-
Returns:
|
|
1253
|
-
str, pickled, base64, representation of the object
|
|
1254
|
-
|
|
1255
|
-
"""
|
|
1256
|
-
return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
|
|
1257
|
-
|
|
1258
|
-
@final
|
|
1259
|
-
@property
|
|
1260
|
-
def hex(self) -> str:
|
|
1261
|
-
"""Hexadecimal representation of the object.
|
|
1262
|
-
|
|
1263
|
-
Returns:
|
|
1264
|
-
str, pickled, hexadecimal, representation of the object
|
|
1265
|
-
|
|
1266
|
-
"""
|
|
1267
|
-
return self.Hex()
|
|
1268
|
-
|
|
1269
|
-
@final
|
|
1270
|
-
def Hex(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
|
|
1271
|
-
"""Hexadecimal representation of the object with more options, including encryption.
|
|
1272
|
-
|
|
1273
|
-
Args:
|
|
1274
|
-
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1275
|
-
silent (bool, optional): if True (default) will not log
|
|
1276
|
-
|
|
1277
|
-
Returns:
|
|
1278
|
-
str, pickled, hexadecimal, representation of the object
|
|
1279
|
-
|
|
1280
|
-
"""
|
|
1281
|
-
return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
|
|
1282
|
-
|
|
1283
|
-
@final
|
|
1284
|
-
@property
|
|
1285
|
-
def raw(self) -> str:
|
|
1286
|
-
"""Raw escaped binary representation of the object.
|
|
1287
|
-
|
|
1288
|
-
Returns:
|
|
1289
|
-
str, pickled, raw escaped binary, representation of the object
|
|
1290
|
-
|
|
1291
|
-
"""
|
|
1292
|
-
return self.Raw()
|
|
1293
|
-
|
|
1294
|
-
@final
|
|
1295
|
-
def Raw(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
|
|
1296
|
-
"""Raw escaped binary representation of the object with more options, including encryption.
|
|
1297
|
-
|
|
1298
|
-
Args:
|
|
1299
|
-
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1300
|
-
silent (bool, optional): if True (default) will not log
|
|
1301
|
-
|
|
1302
|
-
Returns:
|
|
1303
|
-
str, pickled, raw escaped binary, representation of the object
|
|
1304
|
-
|
|
1305
|
-
"""
|
|
1306
|
-
return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
|
|
1307
|
-
|
|
1308
|
-
@final
|
|
1309
|
-
@classmethod
|
|
1310
|
-
def Load(cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
|
|
1311
|
-
"""Load (create) object from serialized bytes or string.
|
|
1312
|
-
|
|
1313
|
-
Args:
|
|
1314
|
-
data (str | bytes): if bytes is assumed from CryptoKey.blob/Blob(), and
|
|
1315
|
-
if string is assumed from CryptoKey.encoded/Encoded()
|
|
1316
|
-
key (Decryptor, optional): if given will key.Encrypt() data before saving
|
|
1317
|
-
silent (bool, optional): if True (default) will not log
|
|
1318
|
-
|
|
1319
|
-
Returns:
|
|
1320
|
-
a CryptoKey object ready for use
|
|
1321
|
-
|
|
1322
|
-
Raises:
|
|
1323
|
-
InputError: decode error
|
|
1324
|
-
|
|
1325
|
-
"""
|
|
1326
|
-
# if this is a string, then we suppose it is base64
|
|
1327
|
-
if isinstance(data, str):
|
|
1328
|
-
data = BytesFromInput(data)
|
|
1329
|
-
# we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
|
|
1330
|
-
try:
|
|
1331
|
-
json_dict: dict[str, Any] = DeSerialize(
|
|
1332
|
-
data=data, key=key, silent=silent, unpickler=UnpickleJSON
|
|
1333
|
-
)
|
|
1334
|
-
return cls._FromJSONDict(json_dict)
|
|
1335
|
-
except Exception as err:
|
|
1336
|
-
raise InputError(f'input decode error: {err}') from err
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
@runtime_checkable
|
|
1340
|
-
class Encryptor(Protocol):
|
|
1341
|
-
"""Abstract interface for a class that has encryption.
|
|
1342
|
-
|
|
1343
|
-
Contract:
|
|
1344
|
-
- If algorithm accepts a `nonce` or `tag` these have to be handled internally by the
|
|
1345
|
-
implementation and appended to the `ciphertext`/`signature`.
|
|
1346
|
-
- If AEAD is supported, `associated_data` (AAD) must be authenticated. If not supported
|
|
1347
|
-
then `associated_data` different from None must raise InputError.
|
|
1348
|
-
|
|
1349
|
-
Notes:
|
|
1350
|
-
The interface is deliberately minimal: byte-in / byte-out.
|
|
1351
|
-
Metadata like nonce/tag may be:
|
|
1352
|
-
- returned alongside `ciphertext`/`signature`, or
|
|
1353
|
-
- bundled/serialized into `ciphertext`/`signature` by the implementation.
|
|
1354
|
-
|
|
1355
|
-
"""
|
|
1356
|
-
|
|
1357
|
-
@abstract.abstractmethod
|
|
1358
|
-
def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1359
|
-
"""Encrypt `plaintext` and return `ciphertext`.
|
|
1360
|
-
|
|
1361
|
-
Args:
|
|
1362
|
-
plaintext (bytes): Data to encrypt.
|
|
1363
|
-
associated_data (bytes, optional): Optional AAD for AEAD modes; must be
|
|
1364
|
-
provided again on decrypt
|
|
1365
|
-
|
|
1366
|
-
Returns:
|
|
1367
|
-
bytes: Ciphertext; if a nonce/tag is needed for decryption, the implementation
|
|
1368
|
-
must encode it within the returned bytes (or document how to retrieve it)
|
|
1369
|
-
|
|
1370
|
-
Raises:
|
|
1371
|
-
InputError: invalid inputs
|
|
1372
|
-
CryptoError: internal crypto failures
|
|
1373
|
-
|
|
1374
|
-
"""
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
@runtime_checkable
|
|
1378
|
-
class Decryptor(Protocol):
|
|
1379
|
-
"""Abstract interface for a class that has decryption (see contract/notes in Encryptor)."""
|
|
1380
|
-
|
|
1381
|
-
@abstract.abstractmethod
|
|
1382
|
-
def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1383
|
-
"""Decrypt `ciphertext` and return the original `plaintext`.
|
|
1384
|
-
|
|
1385
|
-
Args:
|
|
1386
|
-
ciphertext (bytes): Data to decrypt (including any embedded nonce/tag if applicable)
|
|
1387
|
-
associated_data (bytes, optional): Optional AAD (must match what was used during encrypt)
|
|
1388
|
-
|
|
1389
|
-
Returns:
|
|
1390
|
-
bytes: Decrypted plaintext bytes
|
|
1391
|
-
|
|
1392
|
-
Raises:
|
|
1393
|
-
InputError: invalid inputs
|
|
1394
|
-
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1395
|
-
|
|
1396
|
-
"""
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
@runtime_checkable
|
|
1400
|
-
class Verifier(Protocol):
|
|
1401
|
-
"""Abstract interface for asymmetric signature verify. (see contract/notes in Encryptor)."""
|
|
1402
|
-
|
|
1403
|
-
@abstract.abstractmethod
|
|
1404
|
-
def Verify(
|
|
1405
|
-
self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None
|
|
1406
|
-
) -> bool:
|
|
1407
|
-
"""Verify a `signature` for `message`. True if OK; False if failed verification.
|
|
1408
|
-
|
|
1409
|
-
Args:
|
|
1410
|
-
message (bytes): Data that was signed (including any embedded nonce/tag if applicable)
|
|
1411
|
-
signature (bytes): Signature data to verify (including any embedded nonce/tag if applicable)
|
|
1412
|
-
associated_data (bytes, optional): Optional AAD (must match what was used during signing)
|
|
1413
|
-
|
|
1414
|
-
Returns:
|
|
1415
|
-
True if signature is valid, False otherwise
|
|
1416
|
-
|
|
1417
|
-
Raises:
|
|
1418
|
-
InputError: invalid inputs
|
|
1419
|
-
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1420
|
-
|
|
1421
|
-
"""
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
@runtime_checkable
|
|
1425
|
-
class Signer(Protocol):
|
|
1426
|
-
"""Abstract interface for asymmetric signing. (see contract/notes in Encryptor)."""
|
|
1427
|
-
|
|
1428
|
-
@abstract.abstractmethod
|
|
1429
|
-
def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1430
|
-
"""Sign `message` and return the `signature`.
|
|
1431
|
-
|
|
1432
|
-
Args:
|
|
1433
|
-
message (bytes): Data to sign.
|
|
1434
|
-
associated_data (bytes, optional): Optional AAD for AEAD modes; must be
|
|
1435
|
-
provided again on decrypt
|
|
1436
|
-
|
|
1437
|
-
Returns:
|
|
1438
|
-
bytes: Signature; if a nonce/tag is needed for decryption, the implementation
|
|
1439
|
-
must encode it within the returned bytes (or document how to retrieve it)
|
|
1440
|
-
|
|
1441
|
-
Raises:
|
|
1442
|
-
InputError: invalid inputs
|
|
1443
|
-
CryptoError: internal crypto failures
|
|
1444
|
-
|
|
1445
|
-
"""
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
def Serialize(
|
|
1449
|
-
python_obj: Any, # noqa: ANN401
|
|
1450
|
-
/,
|
|
1451
|
-
*,
|
|
1452
|
-
file_path: str | None = None,
|
|
1453
|
-
compress: int | None = 3,
|
|
1454
|
-
key: Encryptor | None = None,
|
|
1455
|
-
silent: bool = False,
|
|
1456
|
-
pickler: abc.Callable[[Any], bytes] = PickleGeneric,
|
|
1457
|
-
) -> bytes:
|
|
1458
|
-
"""Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
|
|
1459
|
-
|
|
1460
|
-
Data path is:
|
|
1461
|
-
|
|
1462
|
-
`obj` => [pickler] => (compress) => (encrypt) => (save to `file_path`) => return
|
|
1463
|
-
|
|
1464
|
-
At every step of the data path the data will be measured, in bytes.
|
|
1465
|
-
Every data conversion will be timed. The measurements/times will be logged (once).
|
|
1466
|
-
|
|
1467
|
-
Compression levels / speed can be controlled by `compress`. Use this as reference:
|
|
1468
|
-
|
|
1469
|
-
| Level | Speed | Compression ratio | Typical use case |
|
|
1470
|
-
| -------- | ------------| ------------------------| --------------------------------------- |
|
|
1471
|
-
| -5 to -1 | Fastest | Poor (better than none) | Real-time / very latency-sensitive |
|
|
1472
|
-
| 0…3 | Very fast | Good ratio | Default CLI choice, safe baseline |
|
|
1473
|
-
| 4…6 | Moderate | Better ratio | Good compromise for general persistence |
|
|
1474
|
-
| 7…10 | Slower | Marginally better ratio | Only if storage space is precious |
|
|
1475
|
-
| 11…15 | Much slower | Slight gains | Large archives, not for runtime use |
|
|
1476
|
-
| 16…22 | Very slow | Tiny gains | Archival-only, multi-GB datasets |
|
|
1477
|
-
|
|
1478
|
-
Args:
|
|
1479
|
-
python_obj (Any): serializable Python object
|
|
1480
|
-
file_path (str, optional): full path to optionally save the data to
|
|
1481
|
-
compress (int | None, optional): Compress level before encrypting/saving; -22 ≤ compress ≤ 22;
|
|
1482
|
-
None is no compression; default is 3, which is fast, see table above for other values
|
|
1483
|
-
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1484
|
-
silent (bool, optional): if True will not log; default is False (will log)
|
|
1485
|
-
pickler (Callable[[Any], bytes], optional): if not given, will just be the `pickle` module;
|
|
1486
|
-
if given will be a method to convert any Python object to its `bytes` representation;
|
|
1487
|
-
PickleGeneric is the default, but another useful value is PickleJSON
|
|
1488
|
-
|
|
1489
|
-
Returns:
|
|
1490
|
-
bytes: serialized binary data corresponding to obj + (compression) + (encryption)
|
|
1491
|
-
|
|
1492
|
-
"""
|
|
1493
|
-
messages: list[str] = []
|
|
1494
|
-
with Timer('Serialization complete', emit_log=False) as tm_all:
|
|
1495
|
-
# pickle
|
|
1496
|
-
with Timer('PICKLE', emit_log=False) as tm_pickle:
|
|
1497
|
-
obj: bytes = pickler(python_obj)
|
|
1498
|
-
if not silent:
|
|
1499
|
-
messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
|
|
1500
|
-
# compress, if needed
|
|
1501
|
-
if compress is not None:
|
|
1502
|
-
compress = max(compress, -22)
|
|
1503
|
-
compress = min(compress, 22)
|
|
1504
|
-
with Timer(f'COMPRESS@{compress}', emit_log=False) as tm_compress:
|
|
1505
|
-
obj = zstandard.ZstdCompressor(level=compress).compress(obj)
|
|
1506
|
-
if not silent:
|
|
1507
|
-
messages.append(f' {tm_compress}, {HumanizedBytes(len(obj))}')
|
|
1508
|
-
# encrypt, if needed
|
|
1509
|
-
if key is not None:
|
|
1510
|
-
with Timer('ENCRYPT', emit_log=False) as tm_crypto:
|
|
1511
|
-
obj = key.Encrypt(obj, associated_data=_PICKLE_AAD)
|
|
1512
|
-
if not silent:
|
|
1513
|
-
messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
|
|
1514
|
-
# optionally save to disk
|
|
1515
|
-
if file_path is not None:
|
|
1516
|
-
with Timer('SAVE', emit_log=False) as tm_save:
|
|
1517
|
-
pathlib.Path(file_path).write_bytes(obj)
|
|
1518
|
-
if not silent:
|
|
1519
|
-
messages.append(f' {tm_save}, to {file_path!r}')
|
|
1520
|
-
# log and return
|
|
1521
|
-
if not silent:
|
|
1522
|
-
logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
|
|
1523
|
-
return obj
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
def DeSerialize( # noqa: C901
|
|
1527
|
-
*,
|
|
1528
|
-
data: bytes | None = None,
|
|
1529
|
-
file_path: str | None = None,
|
|
1530
|
-
key: Decryptor | None = None,
|
|
1531
|
-
silent: bool = False,
|
|
1532
|
-
unpickler: abc.Callable[[bytes], Any] = UnpickleGeneric,
|
|
1533
|
-
) -> Any: # noqa: ANN401
|
|
1534
|
-
"""Load (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
|
|
1535
|
-
|
|
1536
|
-
Data path is:
|
|
1537
|
-
|
|
1538
|
-
`data` or `file_path` => (decrypt) => (decompress) => [unpickler] => return object
|
|
1539
|
-
|
|
1540
|
-
At every step of the data path the data will be measured, in bytes.
|
|
1541
|
-
Every data conversion will be timed. The measurements/times will be logged (once).
|
|
1542
|
-
Compression versus no compression will be automatically detected.
|
|
1543
|
-
|
|
1544
|
-
Args:
|
|
1545
|
-
data (bytes | None, optional): if given, use this as binary data string (input);
|
|
1546
|
-
if you use this option, `file_path` will be ignored
|
|
1547
|
-
file_path (str | None, optional): if given, use this as file path to load binary data
|
|
1548
|
-
string (input); if you use this option, `data` will be ignored. Defaults to None.
|
|
1549
|
-
key (Decryptor | None, optional): if given will key.Decrypt() data before decompressing/loading.
|
|
1550
|
-
Defaults to None.
|
|
1551
|
-
silent (bool, optional): if True will not log; default is False (will log). Defaults to False.
|
|
1552
|
-
unpickler (Callable[[bytes], Any], optional): if not given, will just be the `pickle` module;
|
|
1553
|
-
if given will be a method to convert a `bytes` representation back to a Python object;
|
|
1554
|
-
UnpickleGeneric is the default, but another useful value is UnpickleJSON.
|
|
1555
|
-
Defaults to UnpickleGeneric.
|
|
1556
|
-
|
|
1557
|
-
Returns:
|
|
1558
|
-
De-Serialized Python object corresponding to data
|
|
1559
|
-
|
|
1560
|
-
Raises:
|
|
1561
|
-
InputError: invalid inputs
|
|
1562
|
-
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1563
|
-
|
|
1564
|
-
""" # noqa: DOC502
|
|
1565
|
-
# test inputs
|
|
1566
|
-
if (data is None and file_path is None) or (data is not None and file_path is not None):
|
|
1567
|
-
raise InputError('you must provide only one of either `data` or `file_path`')
|
|
1568
|
-
if file_path and not pathlib.Path(file_path).exists():
|
|
1569
|
-
raise InputError(f'invalid file_path: {file_path!r}')
|
|
1570
|
-
if data and len(data) < 4: # noqa: PLR2004
|
|
1571
|
-
raise InputError('invalid data: too small')
|
|
1572
|
-
# start the pipeline
|
|
1573
|
-
obj: bytes = data or b''
|
|
1574
|
-
messages: list[str] = [f'DATA: {HumanizedBytes(len(obj))}'] if data and not silent else []
|
|
1575
|
-
with Timer('De-Serialization complete', emit_log=False) as tm_all:
|
|
1576
|
-
# optionally load from disk
|
|
1577
|
-
if file_path:
|
|
1578
|
-
assert not obj, 'should never happen: if we have a file obj should be empty' # noqa: S101
|
|
1579
|
-
with Timer('LOAD', emit_log=False) as tm_load:
|
|
1580
|
-
obj = pathlib.Path(file_path).read_bytes()
|
|
1581
|
-
if not silent:
|
|
1582
|
-
messages.append(f' {tm_load}, {HumanizedBytes(len(obj))}, from {file_path!r}')
|
|
1583
|
-
# decrypt, if needed
|
|
1584
|
-
if key is not None:
|
|
1585
|
-
with Timer('DECRYPT', emit_log=False) as tm_crypto:
|
|
1586
|
-
obj = key.Decrypt(obj, associated_data=_PICKLE_AAD)
|
|
1587
|
-
if not silent:
|
|
1588
|
-
messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
|
|
1589
|
-
# decompress: we try to detect compression to determine if we must call zstandard
|
|
1590
|
-
if (
|
|
1591
|
-
len(obj) >= 4 # noqa: PLR2004
|
|
1592
|
-
and (
|
|
1593
|
-
((magic := int.from_bytes(obj[:4], 'little')) == _ZSTD_MAGIC_FRAME)
|
|
1594
|
-
or (_ZSTD_MAGIC_SKIPPABLE_MIN <= magic <= _ZSTD_MAGIC_SKIPPABLE_MAX)
|
|
1595
|
-
)
|
|
1596
|
-
):
|
|
1597
|
-
with Timer('DECOMPRESS', emit_log=False) as tm_decompress:
|
|
1598
|
-
obj = zstandard.ZstdDecompressor().decompress(obj)
|
|
1599
|
-
if not silent:
|
|
1600
|
-
messages.append(f' {tm_decompress}, {HumanizedBytes(len(obj))}')
|
|
1601
|
-
elif not silent:
|
|
1602
|
-
messages.append(' (no compression detected)')
|
|
1603
|
-
# create the actual object = unpickle
|
|
1604
|
-
with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
|
|
1605
|
-
python_obj: Any = unpickler(obj)
|
|
1606
|
-
if not silent:
|
|
1607
|
-
messages.append(f' {tm_unpickle}')
|
|
1608
|
-
# log and return
|
|
1609
|
-
if not silent:
|
|
1610
|
-
logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
|
|
1611
|
-
return python_obj
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
1615
|
-
class PublicBid512(CryptoKey):
|
|
1616
|
-
"""Public commitment to a (cryptographically secure) bid that can be revealed/validated later.
|
|
1617
|
-
|
|
1618
|
-
Bid is computed as: public_hash = Hash512(public_key || private_key || secret_bid)
|
|
1619
|
-
|
|
1620
|
-
Everything is bytes. The public part is (public_key, public_hash) and the private
|
|
1621
|
-
part is (private_key, secret_bid). The whole computation can be checked later.
|
|
1622
|
-
|
|
1623
|
-
No measures are taken here to prevent timing attacks (probably not a concern).
|
|
1624
|
-
|
|
1625
|
-
Attributes:
|
|
1626
|
-
public_key (bytes): 512-bits random value
|
|
1627
|
-
public_hash (bytes): SHA-512 hash of (public_key || private_key || secret_bid)
|
|
1628
|
-
|
|
1629
|
-
"""
|
|
1630
|
-
|
|
1631
|
-
public_key: bytes
|
|
1632
|
-
public_hash: bytes
|
|
1633
|
-
|
|
1634
|
-
def __post_init__(self) -> None:
|
|
1635
|
-
"""Check data.
|
|
1636
|
-
|
|
1637
|
-
Raises:
|
|
1638
|
-
InputError: invalid inputs
|
|
1639
|
-
|
|
1640
|
-
"""
|
|
1641
|
-
if len(self.public_key) != 64 or len(self.public_hash) != 64: # noqa: PLR2004
|
|
1642
|
-
raise InputError(f'invalid public_key or public_hash: {self}')
|
|
1643
|
-
|
|
1644
|
-
def __str__(self) -> str:
|
|
1645
|
-
"""Safe string representation of the PublicBid.
|
|
1646
|
-
|
|
1647
|
-
Returns:
|
|
1648
|
-
string representation of PublicBid
|
|
1649
|
-
|
|
1650
|
-
"""
|
|
1651
|
-
return (
|
|
1652
|
-
'PublicBid512('
|
|
1653
|
-
f'public_key={BytesToEncoded(self.public_key)}, '
|
|
1654
|
-
f'public_hash={BytesToHex(self.public_hash)})'
|
|
1655
|
-
)
|
|
1656
|
-
|
|
1657
|
-
def VerifyBid(self, private_key: bytes, secret: bytes, /) -> bool:
|
|
1658
|
-
"""Verify a bid. True if OK; False if failed verification.
|
|
1659
|
-
|
|
1660
|
-
Args:
|
|
1661
|
-
private_key (bytes): 512-bits private key
|
|
1662
|
-
secret (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
|
|
1663
|
-
|
|
1664
|
-
Returns:
|
|
1665
|
-
True if bid is valid, False otherwise
|
|
1666
|
-
|
|
1667
|
-
"""
|
|
1668
|
-
try:
|
|
1669
|
-
# creating the PrivateBid object will validate everything; InputError we allow to propagate
|
|
1670
|
-
PrivateBid512(
|
|
1671
|
-
public_key=self.public_key,
|
|
1672
|
-
public_hash=self.public_hash,
|
|
1673
|
-
private_key=private_key,
|
|
1674
|
-
secret_bid=secret,
|
|
1675
|
-
)
|
|
1676
|
-
return True # if we got here, all is good
|
|
1677
|
-
except CryptoError:
|
|
1678
|
-
return False # bid does not match the public commitment
|
|
1679
|
-
|
|
1680
|
-
@classmethod
|
|
1681
|
-
def Copy(cls, other: PublicBid512, /) -> Self:
|
|
1682
|
-
"""Initialize a public bid by taking the public parts of a public/private bid.
|
|
1683
|
-
|
|
1684
|
-
Args:
|
|
1685
|
-
other (PublicBid512): the bid to copy from
|
|
1686
|
-
|
|
1687
|
-
Returns:
|
|
1688
|
-
Self: an initialized PublicBid512
|
|
1689
|
-
|
|
1690
|
-
"""
|
|
1691
|
-
return cls(public_key=other.public_key, public_hash=other.public_hash)
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
1695
|
-
class PrivateBid512(PublicBid512):
|
|
1696
|
-
"""Private bid that can be revealed and validated against a public commitment (see PublicBid).
|
|
1697
|
-
|
|
1698
|
-
Attributes:
|
|
1699
|
-
private_key (bytes): 512-bits random value
|
|
1700
|
-
secret_bid (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
|
|
1701
|
-
|
|
1702
|
-
"""
|
|
1703
|
-
|
|
1704
|
-
private_key: bytes
|
|
1705
|
-
secret_bid: bytes
|
|
1706
|
-
|
|
1707
|
-
def __post_init__(self) -> None:
|
|
1708
|
-
"""Check data.
|
|
1709
|
-
|
|
1710
|
-
Raises:
|
|
1711
|
-
InputError: invalid inputs
|
|
1712
|
-
CryptoError: bid does not match the public commitment
|
|
1713
|
-
|
|
1714
|
-
"""
|
|
1715
|
-
super(PrivateBid512, self).__post_init__()
|
|
1716
|
-
if len(self.private_key) != 64 or len(self.secret_bid) < 1: # noqa: PLR2004
|
|
1717
|
-
raise InputError(f'invalid private_key or secret_bid: {self}')
|
|
1718
|
-
if self.public_hash != Hash512(self.public_key + self.private_key + self.secret_bid):
|
|
1719
|
-
raise CryptoError(f'inconsistent bid: {self}')
|
|
1720
|
-
|
|
1721
|
-
def __str__(self) -> str:
|
|
1722
|
-
"""Safe (no secrets) string representation of the PrivateBid.
|
|
1723
|
-
|
|
1724
|
-
Returns:
|
|
1725
|
-
string representation of PrivateBid without leaking secrets
|
|
1726
|
-
|
|
1727
|
-
"""
|
|
1728
|
-
return (
|
|
1729
|
-
'PrivateBid512('
|
|
1730
|
-
f'{super(PrivateBid512, self).__str__()}, '
|
|
1731
|
-
f'private_key={ObfuscateSecret(self.private_key)}, '
|
|
1732
|
-
f'secret_bid={ObfuscateSecret(self.secret_bid)})'
|
|
1733
|
-
)
|
|
1734
|
-
|
|
1735
|
-
@classmethod
|
|
1736
|
-
def New(cls, secret: bytes, /) -> Self:
|
|
1737
|
-
"""Make the `secret` into a new bid.
|
|
1738
|
-
|
|
1739
|
-
Args:
|
|
1740
|
-
secret (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
|
|
1741
|
-
|
|
1742
|
-
Returns:
|
|
1743
|
-
PrivateBid object ready for use (use PublicBid.Copy() to get the public part)
|
|
1744
|
-
|
|
1745
|
-
Raises:
|
|
1746
|
-
InputError: invalid inputs
|
|
1747
|
-
|
|
1748
|
-
"""
|
|
1749
|
-
# test inputs
|
|
1750
|
-
if len(secret) < 1:
|
|
1751
|
-
raise InputError(f'invalid secret length: {len(secret)}')
|
|
1752
|
-
# generate random values
|
|
1753
|
-
public_key: bytes = RandBytes(64) # 512 bits
|
|
1754
|
-
private_key: bytes = RandBytes(64) # 512 bits
|
|
1755
|
-
# build object
|
|
1756
|
-
return cls(
|
|
1757
|
-
public_key=public_key,
|
|
1758
|
-
public_hash=Hash512(public_key + private_key + secret),
|
|
1759
|
-
private_key=private_key,
|
|
1760
|
-
secret_bid=secret,
|
|
1761
|
-
)
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
1765
|
-
class CLIConfig:
|
|
1766
|
-
"""CLI global context, storing the configuration."""
|
|
1767
|
-
|
|
1768
|
-
console: rich_console.Console
|
|
1769
|
-
verbose: int
|
|
1770
|
-
color: bool | None
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
def CLIErrorGuard[**P](fn: abc.Callable[P, None], /) -> abc.Callable[P, None]:
|
|
1774
|
-
"""Guard CLI command functions.
|
|
1775
|
-
|
|
1776
|
-
Returns:
|
|
1777
|
-
A wrapped function that catches expected user-facing errors and prints them consistently.
|
|
1778
|
-
|
|
1779
|
-
"""
|
|
1780
|
-
|
|
1781
|
-
@functools.wraps(fn)
|
|
1782
|
-
def _Wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
1783
|
-
try:
|
|
1784
|
-
# call the actual function
|
|
1785
|
-
fn(*args, **kwargs)
|
|
1786
|
-
except (Error, ValueError) as err:
|
|
1787
|
-
# get context
|
|
1788
|
-
ctx: object | None = dict(kwargs).get('ctx')
|
|
1789
|
-
if not isinstance(ctx, typer.Context):
|
|
1790
|
-
ctx = next((a for a in args if isinstance(a, typer.Context)), None)
|
|
1791
|
-
# print error nicely
|
|
1792
|
-
if isinstance(ctx, typer.Context):
|
|
1793
|
-
# we have context
|
|
1794
|
-
obj: CLIConfig = cast('CLIConfig', ctx.obj)
|
|
1795
|
-
if obj.verbose >= 2: # verbose >= 2 means INFO level or more verbose # noqa: PLR2004
|
|
1796
|
-
obj.console.print_exception() # print full traceback
|
|
1797
|
-
else:
|
|
1798
|
-
obj.console.print(str(err)) # print only error message
|
|
1799
|
-
# no context
|
|
1800
|
-
elif logging.getLogger().getEffectiveLevel() < logging.INFO:
|
|
1801
|
-
Console().print(str(err)) # print only error message (DEBUG level is verbose already)
|
|
1802
|
-
else:
|
|
1803
|
-
Console().print_exception() # print full traceback (less verbose mode needs it)
|
|
1804
|
-
|
|
1805
|
-
return _Wrapper
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
def _ClickWalk(
|
|
1809
|
-
command: click.Command,
|
|
1810
|
-
ctx: typer.Context,
|
|
1811
|
-
path: list[str],
|
|
1812
|
-
/,
|
|
1813
|
-
) -> abc.Iterator[tuple[list[str], click.Command, typer.Context]]:
|
|
1814
|
-
"""Recursively walk Click commands/groups.
|
|
1815
|
-
|
|
1816
|
-
Yields:
|
|
1817
|
-
tuple[list[str], click.Command, typer.Context]: path, command, ctx
|
|
1818
|
-
|
|
1819
|
-
"""
|
|
1820
|
-
yield (path, command, ctx) # yield self
|
|
1821
|
-
# now walk subcommands, if any
|
|
1822
|
-
sub_cmd: click.Command | None
|
|
1823
|
-
sub_ctx: typer.Context
|
|
1824
|
-
# prefer the explicit `.commands` mapping when present; otherwise fall back to
|
|
1825
|
-
# click's `list_commands()`/`get_command()` for dynamic groups
|
|
1826
|
-
if not isinstance(command, click.Group):
|
|
1827
|
-
return
|
|
1828
|
-
# explicit commands mapping
|
|
1829
|
-
if command.commands:
|
|
1830
|
-
for name, sub_cmd in sorted(command.commands.items()):
|
|
1831
|
-
sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
|
|
1832
|
-
yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
|
|
1833
|
-
return
|
|
1834
|
-
# dynamic commands
|
|
1835
|
-
for name in sorted(command.list_commands(ctx)):
|
|
1836
|
-
sub_cmd = command.get_command(ctx, name)
|
|
1837
|
-
if sub_cmd is None:
|
|
1838
|
-
continue # skip invalid subcommands
|
|
1839
|
-
sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
|
|
1840
|
-
yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
def GenerateTyperHelpMarkdown(
|
|
1844
|
-
typer_app: typer.Typer,
|
|
1845
|
-
/,
|
|
1846
|
-
*,
|
|
1847
|
-
prog_name: str,
|
|
1848
|
-
heading_level: int = 1,
|
|
1849
|
-
code_fence_language: str = 'text',
|
|
1850
|
-
) -> str:
|
|
1851
|
-
"""Capture `--help` for a Typer CLI and all subcommands as Markdown.
|
|
1852
|
-
|
|
1853
|
-
This function converts a Typer app to its underlying Click command tree and then:
|
|
1854
|
-
- invokes `--help` for the root ("Main") command
|
|
1855
|
-
- walks commands/subcommands recursively
|
|
1856
|
-
- invokes `--help` for each command path
|
|
1857
|
-
|
|
1858
|
-
It emits a Markdown document with a heading per command and a fenced block
|
|
1859
|
-
containing the exact `--help` output.
|
|
1860
|
-
|
|
1861
|
-
Notes:
|
|
1862
|
-
- This uses Click's `CliRunner().invoke(...)` for faithful output.
|
|
1863
|
-
- The walk is generic over Click `MultiCommand`/`Group` structures.
|
|
1864
|
-
- If a command cannot be loaded, it is skipped.
|
|
1865
|
-
|
|
1866
|
-
Args:
|
|
1867
|
-
typer_app: The Typer app (e.g. `app`).
|
|
1868
|
-
prog_name: Program name used in usage strings (e.g. "profiler").
|
|
1869
|
-
heading_level: Markdown heading level for each command section.
|
|
1870
|
-
code_fence_language: Language tag for fenced blocks (default: "text").
|
|
1871
|
-
|
|
1872
|
-
Returns:
|
|
1873
|
-
Markdown string.
|
|
1874
|
-
|
|
1875
|
-
"""
|
|
1876
|
-
# prepare Click root command and context
|
|
1877
|
-
click_root: click.Command = typer.main.get_command(typer_app)
|
|
1878
|
-
root_ctx: typer.Context = typer.Context(click_root, info_name=prog_name)
|
|
1879
|
-
runner = click_testing.CliRunner()
|
|
1880
|
-
parts: list[str] = []
|
|
1881
|
-
for path, _, _ in _ClickWalk(click_root, root_ctx, []):
|
|
1882
|
-
# build command path
|
|
1883
|
-
command_path: str = ' '.join([prog_name, *path]).strip()
|
|
1884
|
-
heading_prefix: str = '#' * max(1, heading_level + len(path))
|
|
1885
|
-
ResetConsole() # ensure clean state for each command (also it raises on duplicate loggers)
|
|
1886
|
-
# invoke --help for this command path
|
|
1887
|
-
result: click_testing.Result = runner.invoke(
|
|
1888
|
-
click_root,
|
|
1889
|
-
[*path, '--help'],
|
|
1890
|
-
prog_name=prog_name,
|
|
1891
|
-
color=False,
|
|
1892
|
-
)
|
|
1893
|
-
if result.exit_code != 0 and not result.output:
|
|
1894
|
-
continue # skip invalid commands
|
|
1895
|
-
# build markdown section
|
|
1896
|
-
global_prefix: str = ( # only for the top-level command
|
|
1897
|
-
(
|
|
1898
|
-
'<!-- cspell:disable -->\n'
|
|
1899
|
-
'<!-- auto-generated; DO NOT EDIT! see base.GenerateTyperHelpMarkdown() -->\n\n'
|
|
1900
|
-
)
|
|
1901
|
-
if not path
|
|
1902
|
-
else ''
|
|
1903
|
-
)
|
|
1904
|
-
extras: str = ( # type of command, by level
|
|
1905
|
-
('Command-Line Interface' if not path else 'Command') if len(path) <= 1 else 'Sub-Command'
|
|
1906
|
-
)
|
|
1907
|
-
parts.extend(
|
|
1908
|
-
(
|
|
1909
|
-
f'{global_prefix}{heading_prefix} `{command_path}` {extras}',
|
|
1910
|
-
'',
|
|
1911
|
-
f'```{code_fence_language}',
|
|
1912
|
-
result.output.strip(),
|
|
1913
|
-
'```',
|
|
1914
|
-
'',
|
|
1915
|
-
)
|
|
1916
|
-
)
|
|
1917
|
-
# join all parts and return
|
|
1918
|
-
return '\n'.join(parts).rstrip()
|