transcrypto 1.5.1__py3-none-any.whl → 1.7.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 +7 -0
- transcrypto/aes.py +150 -44
- transcrypto/base.py +640 -411
- transcrypto/constants.py +20070 -1906
- transcrypto/dsa.py +132 -99
- transcrypto/elgamal.py +116 -84
- transcrypto/modmath.py +88 -78
- transcrypto/profiler.py +228 -180
- transcrypto/rsa.py +126 -90
- transcrypto/sss.py +122 -70
- transcrypto/transcrypto.py +2362 -1412
- {transcrypto-1.5.1.dist-info → transcrypto-1.7.0.dist-info}/METADATA +78 -58
- transcrypto-1.7.0.dist-info/RECORD +17 -0
- {transcrypto-1.5.1.dist-info → transcrypto-1.7.0.dist-info}/WHEEL +1 -2
- transcrypto-1.7.0.dist-info/entry_points.txt +4 -0
- transcrypto/safetrans.py +0 -1231
- transcrypto-1.5.1.dist-info/RECORD +0 -18
- transcrypto-1.5.1.dist-info/top_level.txt +0 -1
- {transcrypto-1.5.1.dist-info → transcrypto-1.7.0.dist-info}/licenses/LICENSE +0 -0
transcrypto/base.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
#
|
|
3
|
-
# Copyright 2025 Daniel Balparda (balparda@github.com) - Apache-2.0 license
|
|
4
|
-
#
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
3
|
"""Balparda's TransCrypto base library."""
|
|
6
4
|
|
|
7
5
|
from __future__ import annotations
|
|
8
6
|
|
|
9
|
-
import abc
|
|
10
|
-
import argparse
|
|
7
|
+
import abc as abstract
|
|
11
8
|
import base64
|
|
12
9
|
import codecs
|
|
13
10
|
import dataclasses
|
|
@@ -18,72 +15,104 @@ import hashlib
|
|
|
18
15
|
import json
|
|
19
16
|
import logging
|
|
20
17
|
import math
|
|
21
|
-
import os
|
|
22
|
-
import
|
|
23
|
-
#
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import pickle # noqa: S403
|
|
24
21
|
import secrets
|
|
25
22
|
import sys
|
|
23
|
+
import threading
|
|
26
24
|
import time
|
|
27
|
-
from
|
|
28
|
-
from
|
|
29
|
-
|
|
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
|
|
30
38
|
import numpy as np
|
|
31
|
-
|
|
39
|
+
import typer
|
|
32
40
|
import zstandard
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
45
|
|
|
38
46
|
# Data conversion utils
|
|
39
47
|
|
|
40
|
-
BytesToHex: Callable[[bytes], str] = lambda b: b.hex()
|
|
41
|
-
BytesToInt: Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
|
|
42
|
-
BytesToEncoded: Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
|
|
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')
|
|
43
51
|
|
|
44
|
-
HexToBytes: Callable[[str], bytes] = bytes.fromhex
|
|
45
|
-
IntToFixedBytes: Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
|
|
46
|
-
IntToBytes: Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
|
|
47
|
-
IntToEncoded: Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
|
|
48
|
-
EncodedToBytes: Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
|
|
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'))
|
|
49
57
|
|
|
50
|
-
PadBytesTo: Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
58
|
+
PadBytesTo: abc.Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
51
59
|
|
|
52
60
|
# Time utils
|
|
53
61
|
|
|
54
|
-
MIN_TM = int(
|
|
55
|
-
datetime.datetime(2000, 1, 1, 0, 0, 0).replace(tzinfo=datetime.timezone.utc).timestamp())
|
|
62
|
+
MIN_TM = int(datetime.datetime(2000, 1, 1, 0, 0, 0, tzinfo=datetime.UTC).timestamp())
|
|
56
63
|
TIME_FORMAT = '%Y/%b/%d-%H:%M:%S-UTC'
|
|
57
|
-
TimeStr: Callable[[int | float | None], str] = lambda tm: (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
|
61
89
|
|
|
62
90
|
# SI prefix table, powers of 1000
|
|
63
91
|
_SI_PREFIXES: dict[int, str] = {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
# these control the pickling of data, do NOT ever change, or you will break all databases
|
|
80
108
|
# <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
|
|
81
109
|
_PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
|
|
82
|
-
PickleGeneric: Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
|
|
83
|
-
UnpickleGeneric: Callable[[bytes], Any] = pickle.loads
|
|
84
|
-
PickleJSON: Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
|
|
85
|
-
|
|
86
|
-
|
|
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'))
|
|
87
116
|
_PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
|
|
88
117
|
# these help find compressed files, do NOT change unless zstandard changes
|
|
89
118
|
_ZSTD_MAGIC_FRAME = 0xFD2FB528
|
|
@@ -91,11 +120,17 @@ _ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
|
|
|
91
120
|
_ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
|
|
92
121
|
# JSON
|
|
93
122
|
_JSON_DATACLASS_TYPES: set[str] = {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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',
|
|
99
134
|
}
|
|
100
135
|
|
|
101
136
|
|
|
@@ -112,10 +147,116 @@ class CryptoError(Error):
|
|
|
112
147
|
|
|
113
148
|
|
|
114
149
|
class ImplementationError(Error, NotImplementedError):
|
|
115
|
-
"""
|
|
150
|
+
"""Feature is not implemented yet (TransCrypto)."""
|
|
151
|
+
|
|
116
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
|
|
117
210
|
|
|
118
|
-
|
|
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
|
|
119
260
|
"""Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
|
|
120
261
|
|
|
121
262
|
Scales the input size by powers of 1024, returning a value with the
|
|
@@ -147,10 +288,11 @@ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-r
|
|
|
147
288
|
'2.00 KiB'
|
|
148
289
|
>>> HumanizedBytes(5 * 1024**3)
|
|
149
290
|
'5.00 GiB'
|
|
291
|
+
|
|
150
292
|
"""
|
|
151
293
|
if inp_sz < 0:
|
|
152
294
|
raise InputError(f'input should be >=0 and got {inp_sz}')
|
|
153
|
-
if inp_sz < 1024:
|
|
295
|
+
if inp_sz < 1024: # noqa: PLR2004
|
|
154
296
|
return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
|
|
155
297
|
if inp_sz < 1024 * 1024:
|
|
156
298
|
return f'{(inp_sz / 1024):0.3f} KiB'
|
|
@@ -165,7 +307,7 @@ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-r
|
|
|
165
307
|
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
|
|
166
308
|
|
|
167
309
|
|
|
168
|
-
def HumanizedDecimal(inp_sz:
|
|
310
|
+
def HumanizedDecimal(inp_sz: float, /, *, unit: str = '') -> str:
|
|
169
311
|
"""Convert a numeric value into a human-readable string using SI metric prefixes.
|
|
170
312
|
|
|
171
313
|
Scales the input value by powers of 1000, returning a value with the
|
|
@@ -202,7 +344,8 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
|
202
344
|
|
|
203
345
|
Raises:
|
|
204
346
|
InputError: If `inp_sz` is not finite.
|
|
205
|
-
|
|
347
|
+
|
|
348
|
+
""" # noqa: RUF002
|
|
206
349
|
if not math.isfinite(inp_sz):
|
|
207
350
|
raise InputError(f'input should finite; got {inp_sz!r}')
|
|
208
351
|
unit = unit.strip()
|
|
@@ -212,8 +355,7 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
|
212
355
|
neg: str = '-' if inp_sz < 0 else ''
|
|
213
356
|
inp_sz = abs(inp_sz)
|
|
214
357
|
# Find exponent of 1000 that keeps value in [1, 1000)
|
|
215
|
-
exp: int
|
|
216
|
-
exp = int(math.floor(math.log10(abs(inp_sz)) / 3))
|
|
358
|
+
exp: int = math.floor(math.log10(abs(inp_sz)) / 3)
|
|
217
359
|
exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
|
|
218
360
|
if not exp:
|
|
219
361
|
# No scaling: use int or 4-decimal float
|
|
@@ -221,12 +363,12 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
|
221
363
|
return f'{neg}{int(inp_sz)}{pad_unit}'
|
|
222
364
|
return f'{neg}{inp_sz:0.3f}{pad_unit}'
|
|
223
365
|
# scaled
|
|
224
|
-
scaled: float = inp_sz / (1000
|
|
366
|
+
scaled: float = inp_sz / (1000**exp)
|
|
225
367
|
prefix: str = _SI_PREFIXES[exp]
|
|
226
368
|
return f'{neg}{scaled:0.3f} {prefix}{unit}'
|
|
227
369
|
|
|
228
370
|
|
|
229
|
-
def HumanizedSeconds(inp_secs:
|
|
371
|
+
def HumanizedSeconds(inp_secs: float, /) -> str: # noqa: PLR0911
|
|
230
372
|
"""Convert a duration in seconds into a human-readable time string.
|
|
231
373
|
|
|
232
374
|
Selects the appropriate time unit based on the duration's magnitude:
|
|
@@ -267,17 +409,18 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
|
|
|
267
409
|
'42.00 s'
|
|
268
410
|
>>> HumanizedSeconds(3661)
|
|
269
411
|
'1.02 h'
|
|
270
|
-
|
|
412
|
+
|
|
413
|
+
""" # noqa: RUF002
|
|
271
414
|
if not math.isfinite(inp_secs) or inp_secs < 0:
|
|
272
415
|
raise InputError(f'input should be >=0 and got {inp_secs}')
|
|
273
416
|
if inp_secs == 0:
|
|
274
417
|
return '0.000 s'
|
|
275
418
|
inp_secs = float(inp_secs)
|
|
276
|
-
if inp_secs < 0.001:
|
|
277
|
-
return f'{inp_secs * 1000 * 1000:0.3f} µs'
|
|
419
|
+
if inp_secs < 0.001: # noqa: PLR2004
|
|
420
|
+
return f'{inp_secs * 1000 * 1000:0.3f} µs' # noqa: RUF001
|
|
278
421
|
if inp_secs < 1:
|
|
279
422
|
return f'{inp_secs * 1000:0.3f} ms'
|
|
280
|
-
if inp_secs < 60:
|
|
423
|
+
if inp_secs < 60: # noqa: PLR2004
|
|
281
424
|
return f'{inp_secs:0.3f} s'
|
|
282
425
|
if inp_secs < 60 * 60:
|
|
283
426
|
return f'{(inp_secs / 60):0.3f} min'
|
|
@@ -287,8 +430,8 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
|
|
|
287
430
|
|
|
288
431
|
|
|
289
432
|
def MeasurementStats(
|
|
290
|
-
|
|
291
|
-
|
|
433
|
+
data: list[int | float], /, *, confidence: float = 0.95
|
|
434
|
+
) -> tuple[int, float, float, float, tuple[float, float], float]:
|
|
292
435
|
"""Compute descriptive statistics for repeated measurements.
|
|
293
436
|
|
|
294
437
|
Given N ≥ 1 measurements, this function computes the sample mean, the
|
|
@@ -317,12 +460,13 @@ def MeasurementStats(
|
|
|
317
460
|
|
|
318
461
|
Raises:
|
|
319
462
|
InputError: if the input list is empty.
|
|
463
|
+
|
|
320
464
|
"""
|
|
321
465
|
# test inputs
|
|
322
466
|
n: int = len(data)
|
|
323
467
|
if not n:
|
|
324
468
|
raise InputError('no data')
|
|
325
|
-
if not 0.5 <= confidence < 1.0:
|
|
469
|
+
if not 0.5 <= confidence < 1.0: # noqa: PLR2004
|
|
326
470
|
raise InputError(f'invalid confidence: {confidence=}')
|
|
327
471
|
# solve trivial case
|
|
328
472
|
if n == 1:
|
|
@@ -330,17 +474,22 @@ def MeasurementStats(
|
|
|
330
474
|
# call scipy for the science data
|
|
331
475
|
np_data = np.array(data)
|
|
332
476
|
mean = np.mean(np_data)
|
|
333
|
-
sem = stats.sem(np_data)
|
|
334
|
-
ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem)
|
|
335
|
-
t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1)
|
|
336
|
-
error = t_crit * sem # half-width of the CI
|
|
337
|
-
return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence)
|
|
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)
|
|
338
482
|
|
|
339
483
|
|
|
340
484
|
def HumanizedMeasurements(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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:
|
|
344
493
|
"""Render measurement statistics as a human-readable string.
|
|
345
494
|
|
|
346
495
|
Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
|
|
@@ -365,6 +514,7 @@ def HumanizedMeasurements(
|
|
|
365
514
|
|
|
366
515
|
Returns:
|
|
367
516
|
str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
|
|
517
|
+
|
|
368
518
|
"""
|
|
369
519
|
n: int
|
|
370
520
|
mean: float
|
|
@@ -373,12 +523,14 @@ def HumanizedMeasurements(
|
|
|
373
523
|
conf: float
|
|
374
524
|
unit = unit.strip()
|
|
375
525
|
n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
|
|
376
|
-
f: Callable[[float], str] = lambda x: (
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
)
|
|
379
531
|
if n == 1:
|
|
380
532
|
return f'{f(mean)}{unit} ±? @1'
|
|
381
|
-
pct =
|
|
533
|
+
pct: int = round(conf * 100)
|
|
382
534
|
return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
|
|
383
535
|
|
|
384
536
|
|
|
@@ -386,7 +538,6 @@ class Timer:
|
|
|
386
538
|
"""An execution timing class that can be used as both a context manager and a decorator.
|
|
387
539
|
|
|
388
540
|
Examples:
|
|
389
|
-
|
|
390
541
|
# As a context manager
|
|
391
542
|
with Timer('Block timing'):
|
|
392
543
|
time.sleep(1.2)
|
|
@@ -407,11 +558,12 @@ class Timer:
|
|
|
407
558
|
label (str, optional): Timer label
|
|
408
559
|
emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
|
|
409
560
|
emit_print (bool, optional): If True will print() the timer, else (default) will not
|
|
561
|
+
|
|
410
562
|
"""
|
|
411
563
|
|
|
412
564
|
def __init__(
|
|
413
|
-
|
|
414
|
-
|
|
565
|
+
self, label: str = '', /, *, emit_log: bool = True, emit_print: bool = False
|
|
566
|
+
) -> None:
|
|
415
567
|
"""Initialize the Timer.
|
|
416
568
|
|
|
417
569
|
Args:
|
|
@@ -419,8 +571,6 @@ class Timer:
|
|
|
419
571
|
emit_log (bool, optional): Emit a log message when finished; default is True
|
|
420
572
|
emit_print (bool, optional): Emit a print() message when finished; default is False
|
|
421
573
|
|
|
422
|
-
Raises:
|
|
423
|
-
InputError: empty label
|
|
424
574
|
"""
|
|
425
575
|
self.emit_log: bool = emit_log
|
|
426
576
|
self.emit_print: bool = emit_print
|
|
@@ -430,7 +580,15 @@ class Timer:
|
|
|
430
580
|
|
|
431
581
|
@property
|
|
432
582
|
def elapsed(self) -> float:
|
|
433
|
-
"""Elapsed time. Will be zero until a measurement is available with start/end.
|
|
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
|
+
"""
|
|
434
592
|
if self.start is None or self.end is None:
|
|
435
593
|
return 0.0
|
|
436
594
|
delta: float = self.end - self.start
|
|
@@ -439,27 +597,48 @@ class Timer:
|
|
|
439
597
|
return delta
|
|
440
598
|
|
|
441
599
|
def __str__(self) -> str:
|
|
442
|
-
"""
|
|
600
|
+
"""Get current timer value.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
str: human-readable representation of current time value
|
|
604
|
+
|
|
605
|
+
"""
|
|
443
606
|
if self.start is None:
|
|
444
607
|
return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
|
|
445
608
|
if self.end is None:
|
|
446
|
-
return (
|
|
447
|
-
|
|
609
|
+
return (
|
|
610
|
+
f'{self.label}: ' if self.label else ''
|
|
611
|
+
) + f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}'
|
|
448
612
|
return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
|
|
449
613
|
|
|
450
614
|
def Start(self) -> None:
|
|
451
|
-
"""Start the timer.
|
|
615
|
+
"""Start the timer.
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
Error: if you try to re-start the timer
|
|
619
|
+
|
|
620
|
+
"""
|
|
452
621
|
if self.start is not None:
|
|
453
622
|
raise Error('Re-starting timer is forbidden')
|
|
454
623
|
self.start = time.perf_counter()
|
|
455
624
|
|
|
456
|
-
def __enter__(self) ->
|
|
457
|
-
"""Start the timer when entering the context.
|
|
625
|
+
def __enter__(self) -> Self:
|
|
626
|
+
"""Start the timer when entering the context.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
Timer: context object (self)
|
|
630
|
+
|
|
631
|
+
"""
|
|
458
632
|
self.Start()
|
|
459
633
|
return self
|
|
460
634
|
|
|
461
635
|
def Stop(self) -> None:
|
|
462
|
-
"""Stop the timer and emit logging.info with timer message.
|
|
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
|
+
"""
|
|
463
642
|
if self.start is None:
|
|
464
643
|
raise Error('Stopping an unstarted timer')
|
|
465
644
|
if self.end is not None:
|
|
@@ -469,21 +648,18 @@ class Timer:
|
|
|
469
648
|
if self.emit_log:
|
|
470
649
|
logging.info(message)
|
|
471
650
|
if self.emit_print:
|
|
472
|
-
print(message)
|
|
651
|
+
Console().print(message)
|
|
473
652
|
|
|
474
653
|
def __exit__(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
exc_val (BaseException | None): Exception value, if any.
|
|
482
|
-
exc_tb (Any): Traceback object, if any.
|
|
483
|
-
"""
|
|
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."""
|
|
484
660
|
self.Stop()
|
|
485
661
|
|
|
486
|
-
_F = TypeVar('_F', bound=Callable[..., Any])
|
|
662
|
+
_F = TypeVar('_F', bound=abc.Callable[..., Any])
|
|
487
663
|
|
|
488
664
|
def __call__(self, func: Timer._F) -> Timer._F:
|
|
489
665
|
"""Allow the Timer to be used as a decorator.
|
|
@@ -493,10 +669,11 @@ class Timer:
|
|
|
493
669
|
|
|
494
670
|
Returns:
|
|
495
671
|
The wrapped function with timing behavior.
|
|
672
|
+
|
|
496
673
|
"""
|
|
497
674
|
|
|
498
675
|
@functools.wraps(func)
|
|
499
|
-
def _Wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
676
|
+
def _Wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
500
677
|
with self.__class__(self.label, emit_log=self.emit_log, emit_print=self.emit_print):
|
|
501
678
|
return func(*args, **kwargs)
|
|
502
679
|
|
|
@@ -518,9 +695,10 @@ def RandBits(n_bits: int, /) -> int:
|
|
|
518
695
|
|
|
519
696
|
Raises:
|
|
520
697
|
InputError: invalid n_bits
|
|
698
|
+
|
|
521
699
|
"""
|
|
522
700
|
# test inputs
|
|
523
|
-
if n_bits < 8:
|
|
701
|
+
if n_bits < 8: # noqa: PLR2004
|
|
524
702
|
raise InputError(f'n_bits must be ≥ 8: {n_bits}')
|
|
525
703
|
# call underlying method
|
|
526
704
|
n: int = 0
|
|
@@ -541,6 +719,7 @@ def RandInt(min_int: int, max_int: int, /) -> int:
|
|
|
541
719
|
|
|
542
720
|
Raises:
|
|
543
721
|
InputError: invalid min/max
|
|
722
|
+
|
|
544
723
|
"""
|
|
545
724
|
# test inputs
|
|
546
725
|
if min_int < 0 or min_int >= max_int:
|
|
@@ -548,11 +727,11 @@ def RandInt(min_int: int, max_int: int, /) -> int:
|
|
|
548
727
|
# uniform over [min_int, max_int]
|
|
549
728
|
span: int = max_int - min_int + 1
|
|
550
729
|
n: int = min_int + secrets.randbelow(span)
|
|
551
|
-
assert min_int <= n <= max_int, 'should never happen: generated number out of range'
|
|
730
|
+
assert min_int <= n <= max_int, 'should never happen: generated number out of range' # noqa: S101
|
|
552
731
|
return n
|
|
553
732
|
|
|
554
733
|
|
|
555
|
-
def RandShuffle[T: Any](seq: MutableSequence[T], /) -> None:
|
|
734
|
+
def RandShuffle[T: Any](seq: abc.MutableSequence[T], /) -> None:
|
|
556
735
|
"""In-place Crypto-random shuffle order for `seq` mutable sequence.
|
|
557
736
|
|
|
558
737
|
Args:
|
|
@@ -560,11 +739,12 @@ def RandShuffle[T: Any](seq: MutableSequence[T], /) -> None:
|
|
|
560
739
|
|
|
561
740
|
Raises:
|
|
562
741
|
InputError: not enough elements
|
|
742
|
+
|
|
563
743
|
"""
|
|
564
744
|
# test inputs
|
|
565
|
-
if (n_seq := len(seq)) < 2:
|
|
745
|
+
if (n_seq := len(seq)) < 2: # noqa: PLR2004
|
|
566
746
|
raise InputError(f'seq must have 2 or more elements: {n_seq}')
|
|
567
|
-
# cryptographically sound Fisher
|
|
747
|
+
# cryptographically sound Fisher-Yates using secrets.randbelow
|
|
568
748
|
for i in range(n_seq - 1, 0, -1):
|
|
569
749
|
j: int = secrets.randbelow(i + 1)
|
|
570
750
|
seq[i], seq[j] = seq[j], seq[i]
|
|
@@ -581,13 +761,14 @@ def RandBytes(n_bytes: int, /) -> bytes:
|
|
|
581
761
|
|
|
582
762
|
Raises:
|
|
583
763
|
InputError: invalid n_bytes
|
|
764
|
+
|
|
584
765
|
"""
|
|
585
766
|
# test inputs
|
|
586
767
|
if n_bytes < 1:
|
|
587
768
|
raise InputError(f'n_bytes must be ≥ 1: {n_bytes}')
|
|
588
769
|
# return from system call
|
|
589
770
|
b: bytes = secrets.token_bytes(n_bytes)
|
|
590
|
-
assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size'
|
|
771
|
+
assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size' # noqa: S101
|
|
591
772
|
return b
|
|
592
773
|
|
|
593
774
|
|
|
@@ -605,6 +786,7 @@ def GCD(a: int, b: int, /) -> int:
|
|
|
605
786
|
|
|
606
787
|
Raises:
|
|
607
788
|
InputError: invalid inputs
|
|
789
|
+
|
|
608
790
|
"""
|
|
609
791
|
# test inputs
|
|
610
792
|
if a < 0 or b < 0 or (not a and not b):
|
|
@@ -634,6 +816,7 @@ def ExtendedGCD(a: int, b: int, /) -> tuple[int, int, int]:
|
|
|
634
816
|
|
|
635
817
|
Raises:
|
|
636
818
|
InputError: invalid inputs
|
|
819
|
+
|
|
637
820
|
"""
|
|
638
821
|
# test inputs
|
|
639
822
|
if a < 0 or b < 0 or (not a and not b):
|
|
@@ -665,6 +848,7 @@ def Hash256(data: bytes, /) -> bytes:
|
|
|
665
848
|
32 bytes (256 bits) of SHA-256 hash;
|
|
666
849
|
if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
|
|
667
850
|
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
|
|
851
|
+
|
|
668
852
|
"""
|
|
669
853
|
return hashlib.sha256(data).digest()
|
|
670
854
|
|
|
@@ -679,6 +863,7 @@ def Hash512(data: bytes, /) -> bytes:
|
|
|
679
863
|
64 bytes (512 bits) of SHA-512 hash;
|
|
680
864
|
if converted to hexadecimal (with BytesToHex() or hex()) will be 128 chars of string;
|
|
681
865
|
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**512
|
|
866
|
+
|
|
682
867
|
"""
|
|
683
868
|
return hashlib.sha512(data).digest()
|
|
684
869
|
|
|
@@ -697,17 +882,18 @@ def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
|
|
|
697
882
|
|
|
698
883
|
Raises:
|
|
699
884
|
InputError: file could not be found
|
|
885
|
+
|
|
700
886
|
"""
|
|
701
887
|
# test inputs
|
|
702
888
|
digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
|
|
703
|
-
if digest not in
|
|
889
|
+
if digest not in {'sha256', 'sha512'}:
|
|
704
890
|
raise InputError(f'unrecognized digest: {digest!r}')
|
|
705
891
|
full_path = full_path.strip()
|
|
706
|
-
if not full_path or not
|
|
892
|
+
if not full_path or not pathlib.Path(full_path).exists():
|
|
707
893
|
raise InputError(f'file {full_path!r} not found for hashing')
|
|
708
894
|
# compute hash
|
|
709
895
|
logging.info(f'Hashing file {full_path!r}')
|
|
710
|
-
with open(
|
|
896
|
+
with pathlib.Path(full_path).open('rb') as file_obj:
|
|
711
897
|
return hashlib.file_digest(file_obj, digest).digest()
|
|
712
898
|
|
|
713
899
|
|
|
@@ -721,31 +907,36 @@ def ObfuscateSecret(data: str | bytes | int, /) -> str:
|
|
|
721
907
|
Args:
|
|
722
908
|
data (str | bytes | int): Data to obfuscate
|
|
723
909
|
|
|
910
|
+
Raises:
|
|
911
|
+
InputError: _description_
|
|
912
|
+
|
|
724
913
|
Returns:
|
|
725
|
-
|
|
914
|
+
str: obfuscated string, e.g. "aabbccdd…"
|
|
915
|
+
|
|
726
916
|
"""
|
|
727
917
|
if isinstance(data, str):
|
|
728
918
|
data = data.encode('utf-8')
|
|
729
919
|
elif isinstance(data, int):
|
|
730
920
|
data = IntToBytes(data)
|
|
731
|
-
if not isinstance(data, bytes):
|
|
921
|
+
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
732
922
|
raise InputError(f'invalid type for data: {type(data)}')
|
|
733
923
|
return BytesToHex(Hash512(data))[:8] + '…'
|
|
734
924
|
|
|
735
925
|
|
|
736
926
|
class CryptoInputType(enum.StrEnum):
|
|
737
927
|
"""Types of inputs that can represent arbitrary bytes."""
|
|
928
|
+
|
|
738
929
|
# prefixes; format prefixes are all 4 bytes
|
|
739
|
-
PATH = '@'
|
|
740
|
-
STDIN = '@-'
|
|
741
|
-
HEX = 'hex:'
|
|
930
|
+
PATH = '@' # @path on disk → read bytes from a file
|
|
931
|
+
STDIN = '@-' # stdin
|
|
932
|
+
HEX = 'hex:' # hex:deadbeef → decode hex
|
|
742
933
|
BASE64 = 'b64:' # b64:... → decode base64
|
|
743
|
-
STR = 'str:'
|
|
744
|
-
RAW = 'raw:'
|
|
934
|
+
STR = 'str:' # str:hello → UTF-8 encode the literal
|
|
935
|
+
RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
|
|
745
936
|
|
|
746
937
|
|
|
747
938
|
def BytesToRaw(b: bytes, /) -> str:
|
|
748
|
-
"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
939
|
+
r"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
749
940
|
|
|
750
941
|
1. map bytes 0..255 to same code points (latin1)
|
|
751
942
|
2. escape non-printables/backslash/quotes via unicode_escape
|
|
@@ -755,21 +946,23 @@ def BytesToRaw(b: bytes, /) -> str:
|
|
|
755
946
|
|
|
756
947
|
Returns:
|
|
757
948
|
str: double-quoted string with \\xNN escapes where needed
|
|
949
|
+
|
|
758
950
|
"""
|
|
759
951
|
inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
|
|
760
|
-
return f'"{inner.replace('"', r
|
|
952
|
+
return f'"{inner.replace('"', r"\"")}"'
|
|
761
953
|
|
|
762
954
|
|
|
763
955
|
def RawToBytes(s: str, /) -> bytes:
|
|
764
|
-
"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
956
|
+
r"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
765
957
|
|
|
766
958
|
Args:
|
|
767
959
|
s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
|
|
768
960
|
|
|
769
961
|
Returns:
|
|
770
962
|
bytes: data
|
|
963
|
+
|
|
771
964
|
"""
|
|
772
|
-
if len(s) >= 2 and s[0] == s[-1] == '"':
|
|
965
|
+
if len(s) >= 2 and s[0] == s[-1] == '"': # noqa: PLR2004
|
|
773
966
|
s = s[1:-1]
|
|
774
967
|
# decode backslash escapes to code points, then map 0..255 -> bytes
|
|
775
968
|
return codecs.decode(s, 'unicode_escape').encode('latin1')
|
|
@@ -784,21 +977,23 @@ def DetectInputType(data_str: str, /) -> CryptoInputType | None:
|
|
|
784
977
|
Returns:
|
|
785
978
|
CryptoInputType | None: type if has a known prefix, None otherwise
|
|
786
979
|
|
|
787
|
-
Raises:
|
|
788
|
-
InputError: unexpected type or conversion error
|
|
789
980
|
"""
|
|
790
981
|
data_str = data_str.strip()
|
|
791
982
|
if data_str == CryptoInputType.STDIN:
|
|
792
983
|
return CryptoInputType.STDIN
|
|
793
984
|
for t in (
|
|
794
|
-
|
|
795
|
-
|
|
985
|
+
CryptoInputType.PATH,
|
|
986
|
+
CryptoInputType.STR,
|
|
987
|
+
CryptoInputType.HEX,
|
|
988
|
+
CryptoInputType.BASE64,
|
|
989
|
+
CryptoInputType.RAW,
|
|
990
|
+
):
|
|
796
991
|
if data_str.startswith(t):
|
|
797
992
|
return t
|
|
798
993
|
return None
|
|
799
994
|
|
|
800
995
|
|
|
801
|
-
def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: #
|
|
996
|
+
def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # noqa: C901, PLR0911, PLR0912
|
|
802
997
|
"""Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
|
|
803
998
|
|
|
804
999
|
Can load from disk ('@'). Can load from stdin ('@-').
|
|
@@ -815,6 +1010,7 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
815
1010
|
|
|
816
1011
|
Raises:
|
|
817
1012
|
InputError: unexpected type or conversion error
|
|
1013
|
+
|
|
818
1014
|
"""
|
|
819
1015
|
data_str = data_str.strip()
|
|
820
1016
|
# auto-detect
|
|
@@ -824,8 +1020,8 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
824
1020
|
raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
|
|
825
1021
|
# now we know they don't conflict, so unify them; remove prefix if we have it
|
|
826
1022
|
expect = detected_type if expect is None else expect
|
|
827
|
-
assert expect is not None, 'should never happen: type should be known here'
|
|
828
|
-
data_str = data_str
|
|
1023
|
+
assert expect is not None, 'should never happen: type should be known here' # noqa: S101
|
|
1024
|
+
data_str = data_str.removeprefix(expect)
|
|
829
1025
|
# for every type something different will happen now
|
|
830
1026
|
try:
|
|
831
1027
|
match expect:
|
|
@@ -835,18 +1031,17 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
835
1031
|
stream = getattr(sys.stdin, 'buffer', None)
|
|
836
1032
|
if stream is None:
|
|
837
1033
|
text: str = sys.stdin.read()
|
|
838
|
-
if not isinstance(text, str): #
|
|
839
|
-
raise InputError('sys.stdin.read() produced non-text data')
|
|
1034
|
+
if not isinstance(text, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1035
|
+
raise InputError('sys.stdin.read() produced non-text data') # noqa: TRY301
|
|
840
1036
|
return text.encode('utf-8')
|
|
841
1037
|
data: bytes = stream.read()
|
|
842
|
-
if not isinstance(data, bytes): #
|
|
843
|
-
raise InputError('sys.stdin.buffer.read() produced non-binary data')
|
|
1038
|
+
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1039
|
+
raise InputError('sys.stdin.buffer.read() produced non-binary data') # noqa: TRY301
|
|
844
1040
|
return data
|
|
845
1041
|
case CryptoInputType.PATH:
|
|
846
|
-
if not
|
|
847
|
-
raise InputError(f'cannot find file {data_str!r}')
|
|
848
|
-
|
|
849
|
-
return file_obj.read()
|
|
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()
|
|
850
1045
|
case CryptoInputType.STR:
|
|
851
1046
|
return data_str.encode('utf-8')
|
|
852
1047
|
case CryptoInputType.HEX:
|
|
@@ -856,24 +1051,27 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
856
1051
|
case CryptoInputType.RAW:
|
|
857
1052
|
return RawToBytes(data_str)
|
|
858
1053
|
case _:
|
|
859
|
-
raise InputError(f'invalid type {expect!r}')
|
|
1054
|
+
raise InputError(f'invalid type {expect!r}') # noqa: TRY301
|
|
860
1055
|
except Exception as err:
|
|
861
1056
|
raise InputError(f'invalid input: {err}') from err
|
|
862
1057
|
|
|
863
1058
|
|
|
864
1059
|
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
865
|
-
class CryptoKey(
|
|
1060
|
+
class CryptoKey(abstract.ABC):
|
|
866
1061
|
"""A cryptographic key."""
|
|
867
1062
|
|
|
1063
|
+
@abstract.abstractmethod
|
|
868
1064
|
def __post_init__(self) -> None:
|
|
869
1065
|
"""Check data."""
|
|
1066
|
+
# every sub-class of CryptoKey has to implement its own version of __post_init__()
|
|
870
1067
|
|
|
871
|
-
@
|
|
1068
|
+
@abstract.abstractmethod
|
|
872
1069
|
def __str__(self) -> str:
|
|
873
1070
|
"""Safe (no secrets) string representation of the key.
|
|
874
1071
|
|
|
875
1072
|
Returns:
|
|
876
1073
|
string representation of the key without leaking secrets
|
|
1074
|
+
|
|
877
1075
|
"""
|
|
878
1076
|
# every sub-class of CryptoKey has to implement its own version of __str__()
|
|
879
1077
|
|
|
@@ -883,6 +1081,7 @@ class CryptoKey(abc.ABC):
|
|
|
883
1081
|
|
|
884
1082
|
Returns:
|
|
885
1083
|
string representation of the key without leaking secrets
|
|
1084
|
+
|
|
886
1085
|
"""
|
|
887
1086
|
# concrete __repr__() delegates to the (abstract) __str__():
|
|
888
1087
|
# this avoids marking __repr__() abstract while still unifying behavior
|
|
@@ -898,12 +1097,13 @@ class CryptoKey(abc.ABC):
|
|
|
898
1097
|
|
|
899
1098
|
Returns:
|
|
900
1099
|
string with all the object's fields explicit values
|
|
1100
|
+
|
|
901
1101
|
"""
|
|
902
1102
|
cls: str = type(self).__name__
|
|
903
1103
|
parts: list[str] = []
|
|
904
1104
|
for field in dataclasses.fields(self):
|
|
905
1105
|
val: Any = getattr(self, field.name) # getattr is fine with frozen/slots
|
|
906
|
-
parts.append(f'{field.name}={
|
|
1106
|
+
parts.append(f'{field.name}={val!r}')
|
|
907
1107
|
return f'{cls}({", ".join(parts)})'
|
|
908
1108
|
|
|
909
1109
|
@final
|
|
@@ -916,13 +1116,15 @@ class CryptoKey(abc.ABC):
|
|
|
916
1116
|
|
|
917
1117
|
Raises:
|
|
918
1118
|
ImplementationError: object has types that are not supported in JSON
|
|
1119
|
+
|
|
919
1120
|
"""
|
|
920
1121
|
self_dict: dict[str, Any] = dataclasses.asdict(self)
|
|
921
1122
|
for field in dataclasses.fields(self):
|
|
922
1123
|
# check the type is OK
|
|
923
1124
|
if field.type not in _JSON_DATACLASS_TYPES:
|
|
924
1125
|
raise ImplementationError(
|
|
925
|
-
|
|
1126
|
+
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
|
|
1127
|
+
)
|
|
926
1128
|
# convert types that we accept but JSON does not
|
|
927
1129
|
if field.type == 'bytes':
|
|
928
1130
|
self_dict[field.name] = BytesToEncoded(self_dict[field.name])
|
|
@@ -936,8 +1138,6 @@ class CryptoKey(abc.ABC):
|
|
|
936
1138
|
Returns:
|
|
937
1139
|
str: JSON representation of the object, tightly packed
|
|
938
1140
|
|
|
939
|
-
Raises:
|
|
940
|
-
ImplementationError: object has types that are not supported in JSON
|
|
941
1141
|
"""
|
|
942
1142
|
return json.dumps(self._json_dict, separators=(',', ':'))
|
|
943
1143
|
|
|
@@ -949,8 +1149,6 @@ class CryptoKey(abc.ABC):
|
|
|
949
1149
|
Returns:
|
|
950
1150
|
str: JSON representation of the object formatted for humans
|
|
951
1151
|
|
|
952
|
-
Raises:
|
|
953
|
-
ImplementationError: object has types that are not supported in JSON
|
|
954
1152
|
"""
|
|
955
1153
|
return json.dumps(self._json_dict, indent=4, sort_keys=True)
|
|
956
1154
|
|
|
@@ -967,9 +1165,11 @@ class CryptoKey(abc.ABC):
|
|
|
967
1165
|
|
|
968
1166
|
Raises:
|
|
969
1167
|
InputError: unexpected type/fields
|
|
1168
|
+
ImplementationError: unsupported JSON field
|
|
1169
|
+
|
|
970
1170
|
"""
|
|
971
1171
|
# check we got exactly the fields we needed
|
|
972
|
-
cls_fields: set[str] =
|
|
1172
|
+
cls_fields: set[str] = {f.name for f in dataclasses.fields(cls)}
|
|
973
1173
|
json_fields: set[str] = set(json_dict)
|
|
974
1174
|
if cls_fields != json_fields:
|
|
975
1175
|
raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
|
|
@@ -977,7 +1177,8 @@ class CryptoKey(abc.ABC):
|
|
|
977
1177
|
for field in dataclasses.fields(cls):
|
|
978
1178
|
if field.type not in _JSON_DATACLASS_TYPES:
|
|
979
1179
|
raise ImplementationError(
|
|
980
|
-
|
|
1180
|
+
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
|
|
1181
|
+
)
|
|
981
1182
|
if field.type == 'bytes':
|
|
982
1183
|
json_dict[field.name] = EncodedToBytes(json_dict[field.name])
|
|
983
1184
|
# build the object
|
|
@@ -996,10 +1197,11 @@ class CryptoKey(abc.ABC):
|
|
|
996
1197
|
|
|
997
1198
|
Raises:
|
|
998
1199
|
InputError: unexpected type/fields
|
|
1200
|
+
|
|
999
1201
|
"""
|
|
1000
1202
|
# get the dict back
|
|
1001
1203
|
json_dict: dict[str, Any] = json.loads(json_data)
|
|
1002
|
-
if not isinstance(json_dict, dict): #
|
|
1204
|
+
if not isinstance(json_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1003
1205
|
raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
|
|
1004
1206
|
return cls._FromJSONDict(json_dict)
|
|
1005
1207
|
|
|
@@ -1010,12 +1212,13 @@ class CryptoKey(abc.ABC):
|
|
|
1010
1212
|
|
|
1011
1213
|
Returns:
|
|
1012
1214
|
bytes, pickled, representation of the object
|
|
1215
|
+
|
|
1013
1216
|
"""
|
|
1014
1217
|
return self.Blob()
|
|
1015
1218
|
|
|
1016
1219
|
@final
|
|
1017
1220
|
def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
|
|
1018
|
-
"""
|
|
1221
|
+
"""Get serial (bytes) representation of the object with more options, including encryption.
|
|
1019
1222
|
|
|
1020
1223
|
Args:
|
|
1021
1224
|
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
@@ -1023,6 +1226,7 @@ class CryptoKey(abc.ABC):
|
|
|
1023
1226
|
|
|
1024
1227
|
Returns:
|
|
1025
1228
|
bytes, pickled, representation of the object
|
|
1229
|
+
|
|
1026
1230
|
"""
|
|
1027
1231
|
return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
|
|
1028
1232
|
|
|
@@ -1033,6 +1237,7 @@ class CryptoKey(abc.ABC):
|
|
|
1033
1237
|
|
|
1034
1238
|
Returns:
|
|
1035
1239
|
str, pickled, base64, representation of the object
|
|
1240
|
+
|
|
1036
1241
|
"""
|
|
1037
1242
|
return self.Encoded()
|
|
1038
1243
|
|
|
@@ -1046,6 +1251,7 @@ class CryptoKey(abc.ABC):
|
|
|
1046
1251
|
|
|
1047
1252
|
Returns:
|
|
1048
1253
|
str, pickled, base64, representation of the object
|
|
1254
|
+
|
|
1049
1255
|
"""
|
|
1050
1256
|
return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
|
|
1051
1257
|
|
|
@@ -1056,6 +1262,7 @@ class CryptoKey(abc.ABC):
|
|
|
1056
1262
|
|
|
1057
1263
|
Returns:
|
|
1058
1264
|
str, pickled, hexadecimal, representation of the object
|
|
1265
|
+
|
|
1059
1266
|
"""
|
|
1060
1267
|
return self.Hex()
|
|
1061
1268
|
|
|
@@ -1069,6 +1276,7 @@ class CryptoKey(abc.ABC):
|
|
|
1069
1276
|
|
|
1070
1277
|
Returns:
|
|
1071
1278
|
str, pickled, hexadecimal, representation of the object
|
|
1279
|
+
|
|
1072
1280
|
"""
|
|
1073
1281
|
return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
|
|
1074
1282
|
|
|
@@ -1079,6 +1287,7 @@ class CryptoKey(abc.ABC):
|
|
|
1079
1287
|
|
|
1080
1288
|
Returns:
|
|
1081
1289
|
str, pickled, raw escaped binary, representation of the object
|
|
1290
|
+
|
|
1082
1291
|
"""
|
|
1083
1292
|
return self.Raw()
|
|
1084
1293
|
|
|
@@ -1092,13 +1301,13 @@ class CryptoKey(abc.ABC):
|
|
|
1092
1301
|
|
|
1093
1302
|
Returns:
|
|
1094
1303
|
str, pickled, raw escaped binary, representation of the object
|
|
1304
|
+
|
|
1095
1305
|
"""
|
|
1096
1306
|
return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
|
|
1097
1307
|
|
|
1098
1308
|
@final
|
|
1099
1309
|
@classmethod
|
|
1100
|
-
def Load(
|
|
1101
|
-
cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
|
|
1310
|
+
def Load(cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
|
|
1102
1311
|
"""Load (create) object from serialized bytes or string.
|
|
1103
1312
|
|
|
1104
1313
|
Args:
|
|
@@ -1109,6 +1318,10 @@ class CryptoKey(abc.ABC):
|
|
|
1109
1318
|
|
|
1110
1319
|
Returns:
|
|
1111
1320
|
a CryptoKey object ready for use
|
|
1321
|
+
|
|
1322
|
+
Raises:
|
|
1323
|
+
InputError: decode error
|
|
1324
|
+
|
|
1112
1325
|
"""
|
|
1113
1326
|
# if this is a string, then we suppose it is base64
|
|
1114
1327
|
if isinstance(data, str):
|
|
@@ -1116,15 +1329,16 @@ class CryptoKey(abc.ABC):
|
|
|
1116
1329
|
# we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
|
|
1117
1330
|
try:
|
|
1118
1331
|
json_dict: dict[str, Any] = DeSerialize(
|
|
1119
|
-
|
|
1332
|
+
data=data, key=key, silent=silent, unpickler=UnpickleJSON
|
|
1333
|
+
)
|
|
1120
1334
|
return cls._FromJSONDict(json_dict)
|
|
1121
1335
|
except Exception as err:
|
|
1122
1336
|
raise InputError(f'input decode error: {err}') from err
|
|
1123
1337
|
|
|
1124
1338
|
|
|
1125
1339
|
@runtime_checkable
|
|
1126
|
-
class Encryptor(Protocol):
|
|
1127
|
-
"""Abstract interface for a class that has encryption
|
|
1340
|
+
class Encryptor(Protocol):
|
|
1341
|
+
"""Abstract interface for a class that has encryption.
|
|
1128
1342
|
|
|
1129
1343
|
Contract:
|
|
1130
1344
|
- If algorithm accepts a `nonce` or `tag` these have to be handled internally by the
|
|
@@ -1137,9 +1351,10 @@ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1137
1351
|
Metadata like nonce/tag may be:
|
|
1138
1352
|
- returned alongside `ciphertext`/`signature`, or
|
|
1139
1353
|
- bundled/serialized into `ciphertext`/`signature` by the implementation.
|
|
1354
|
+
|
|
1140
1355
|
"""
|
|
1141
1356
|
|
|
1142
|
-
@
|
|
1357
|
+
@abstract.abstractmethod
|
|
1143
1358
|
def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1144
1359
|
"""Encrypt `plaintext` and return `ciphertext`.
|
|
1145
1360
|
|
|
@@ -1155,14 +1370,15 @@ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1155
1370
|
Raises:
|
|
1156
1371
|
InputError: invalid inputs
|
|
1157
1372
|
CryptoError: internal crypto failures
|
|
1373
|
+
|
|
1158
1374
|
"""
|
|
1159
1375
|
|
|
1160
1376
|
|
|
1161
1377
|
@runtime_checkable
|
|
1162
|
-
class Decryptor(Protocol):
|
|
1378
|
+
class Decryptor(Protocol):
|
|
1163
1379
|
"""Abstract interface for a class that has decryption (see contract/notes in Encryptor)."""
|
|
1164
1380
|
|
|
1165
|
-
@
|
|
1381
|
+
@abstract.abstractmethod
|
|
1166
1382
|
def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1167
1383
|
"""Decrypt `ciphertext` and return the original `plaintext`.
|
|
1168
1384
|
|
|
@@ -1176,16 +1392,18 @@ class Decryptor(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1176
1392
|
Raises:
|
|
1177
1393
|
InputError: invalid inputs
|
|
1178
1394
|
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1395
|
+
|
|
1179
1396
|
"""
|
|
1180
1397
|
|
|
1181
1398
|
|
|
1182
1399
|
@runtime_checkable
|
|
1183
|
-
class Verifier(Protocol):
|
|
1400
|
+
class Verifier(Protocol):
|
|
1184
1401
|
"""Abstract interface for asymmetric signature verify. (see contract/notes in Encryptor)."""
|
|
1185
1402
|
|
|
1186
|
-
@
|
|
1403
|
+
@abstract.abstractmethod
|
|
1187
1404
|
def Verify(
|
|
1188
|
-
|
|
1405
|
+
self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None
|
|
1406
|
+
) -> bool:
|
|
1189
1407
|
"""Verify a `signature` for `message`. True if OK; False if failed verification.
|
|
1190
1408
|
|
|
1191
1409
|
Args:
|
|
@@ -1199,14 +1417,15 @@ class Verifier(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1199
1417
|
Raises:
|
|
1200
1418
|
InputError: invalid inputs
|
|
1201
1419
|
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1420
|
+
|
|
1202
1421
|
"""
|
|
1203
1422
|
|
|
1204
1423
|
|
|
1205
1424
|
@runtime_checkable
|
|
1206
|
-
class Signer(Protocol):
|
|
1425
|
+
class Signer(Protocol):
|
|
1207
1426
|
"""Abstract interface for asymmetric signing. (see contract/notes in Encryptor)."""
|
|
1208
1427
|
|
|
1209
|
-
@
|
|
1428
|
+
@abstract.abstractmethod
|
|
1210
1429
|
def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1211
1430
|
"""Sign `message` and return the `signature`.
|
|
1212
1431
|
|
|
@@ -1222,13 +1441,20 @@ class Signer(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1222
1441
|
Raises:
|
|
1223
1442
|
InputError: invalid inputs
|
|
1224
1443
|
CryptoError: internal crypto failures
|
|
1444
|
+
|
|
1225
1445
|
"""
|
|
1226
1446
|
|
|
1227
1447
|
|
|
1228
|
-
def Serialize(
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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:
|
|
1232
1458
|
"""Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
|
|
1233
1459
|
|
|
1234
1460
|
Data path is:
|
|
@@ -1240,14 +1466,14 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1240
1466
|
|
|
1241
1467
|
Compression levels / speed can be controlled by `compress`. Use this as reference:
|
|
1242
1468
|
|
|
1243
|
-
| Level | Speed | Compression ratio
|
|
1244
|
-
| -------- | ------------|
|
|
1245
|
-
| -5 to -1 | Fastest | Poor (better than
|
|
1246
|
-
| 0…3 | Very fast | Good ratio
|
|
1247
|
-
| 4…6 | Moderate | Better ratio
|
|
1248
|
-
| 7…10 | Slower | Marginally better ratio
|
|
1249
|
-
| 11…15 | Much slower | Slight gains
|
|
1250
|
-
| 16…22 | Very slow | Tiny gains
|
|
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 |
|
|
1251
1477
|
|
|
1252
1478
|
Args:
|
|
1253
1479
|
python_obj (Any): serializable Python object
|
|
@@ -1262,6 +1488,7 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1262
1488
|
|
|
1263
1489
|
Returns:
|
|
1264
1490
|
bytes: serialized binary data corresponding to obj + (compression) + (encryption)
|
|
1491
|
+
|
|
1265
1492
|
"""
|
|
1266
1493
|
messages: list[str] = []
|
|
1267
1494
|
with Timer('Serialization complete', emit_log=False) as tm_all:
|
|
@@ -1272,8 +1499,8 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1272
1499
|
messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
|
|
1273
1500
|
# compress, if needed
|
|
1274
1501
|
if compress is not None:
|
|
1275
|
-
compress =
|
|
1276
|
-
compress =
|
|
1502
|
+
compress = max(compress, -22)
|
|
1503
|
+
compress = min(compress, 22)
|
|
1277
1504
|
with Timer(f'COMPRESS@{compress}', emit_log=False) as tm_compress:
|
|
1278
1505
|
obj = zstandard.ZstdCompressor(level=compress).compress(obj)
|
|
1279
1506
|
if not silent:
|
|
@@ -1287,21 +1514,24 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1287
1514
|
# optionally save to disk
|
|
1288
1515
|
if file_path is not None:
|
|
1289
1516
|
with Timer('SAVE', emit_log=False) as tm_save:
|
|
1290
|
-
|
|
1291
|
-
file_obj.write(obj)
|
|
1517
|
+
pathlib.Path(file_path).write_bytes(obj)
|
|
1292
1518
|
if not silent:
|
|
1293
1519
|
messages.append(f' {tm_save}, to {file_path!r}')
|
|
1294
1520
|
# log and return
|
|
1295
1521
|
if not silent:
|
|
1296
|
-
logging.info(f'{tm_all}; parts:\n
|
|
1522
|
+
logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
|
|
1297
1523
|
return obj
|
|
1298
1524
|
|
|
1299
1525
|
|
|
1300
|
-
def DeSerialize(
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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.
|
|
1305
1535
|
|
|
1306
1536
|
Data path is:
|
|
1307
1537
|
|
|
@@ -1312,15 +1542,17 @@ def DeSerialize(
|
|
|
1312
1542
|
Compression versus no compression will be automatically detected.
|
|
1313
1543
|
|
|
1314
1544
|
Args:
|
|
1315
|
-
data (bytes, optional): if given, use this as binary data string (input);
|
|
1316
|
-
|
|
1317
|
-
file_path (str, optional): if given, use this as file path to load binary data
|
|
1318
|
-
|
|
1319
|
-
key (Decryptor, optional): if given will key.Decrypt() data before decompressing/loading
|
|
1320
|
-
|
|
1321
|
-
|
|
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;
|
|
1322
1553
|
if given will be a method to convert a `bytes` representation back to a Python object;
|
|
1323
|
-
UnpickleGeneric is the default, but another useful value is UnpickleJSON
|
|
1554
|
+
UnpickleGeneric is the default, but another useful value is UnpickleJSON.
|
|
1555
|
+
Defaults to UnpickleGeneric.
|
|
1324
1556
|
|
|
1325
1557
|
Returns:
|
|
1326
1558
|
De-Serialized Python object corresponding to data
|
|
@@ -1328,24 +1560,24 @@ def DeSerialize(
|
|
|
1328
1560
|
Raises:
|
|
1329
1561
|
InputError: invalid inputs
|
|
1330
1562
|
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1331
|
-
|
|
1563
|
+
|
|
1564
|
+
""" # noqa: DOC502
|
|
1332
1565
|
# test inputs
|
|
1333
1566
|
if (data is None and file_path is None) or (data is not None and file_path is not None):
|
|
1334
1567
|
raise InputError('you must provide only one of either `data` or `file_path`')
|
|
1335
|
-
if file_path and not
|
|
1568
|
+
if file_path and not pathlib.Path(file_path).exists():
|
|
1336
1569
|
raise InputError(f'invalid file_path: {file_path!r}')
|
|
1337
|
-
if data and len(data) < 4:
|
|
1570
|
+
if data and len(data) < 4: # noqa: PLR2004
|
|
1338
1571
|
raise InputError('invalid data: too small')
|
|
1339
1572
|
# start the pipeline
|
|
1340
|
-
obj: bytes = data
|
|
1573
|
+
obj: bytes = data or b''
|
|
1341
1574
|
messages: list[str] = [f'DATA: {HumanizedBytes(len(obj))}'] if data and not silent else []
|
|
1342
1575
|
with Timer('De-Serialization complete', emit_log=False) as tm_all:
|
|
1343
1576
|
# optionally load from disk
|
|
1344
1577
|
if file_path:
|
|
1345
|
-
assert not obj, 'should never happen: if we have a file obj should be empty'
|
|
1578
|
+
assert not obj, 'should never happen: if we have a file obj should be empty' # noqa: S101
|
|
1346
1579
|
with Timer('LOAD', emit_log=False) as tm_load:
|
|
1347
|
-
|
|
1348
|
-
obj = file_obj.read()
|
|
1580
|
+
obj = pathlib.Path(file_path).read_bytes()
|
|
1349
1581
|
if not silent:
|
|
1350
1582
|
messages.append(f' {tm_load}, {HumanizedBytes(len(obj))}, from {file_path!r}')
|
|
1351
1583
|
# decrypt, if needed
|
|
@@ -1355,16 +1587,19 @@ def DeSerialize(
|
|
|
1355
1587
|
if not silent:
|
|
1356
1588
|
messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
|
|
1357
1589
|
# decompress: we try to detect compression to determine if we must call zstandard
|
|
1358
|
-
if (
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
+
):
|
|
1361
1597
|
with Timer('DECOMPRESS', emit_log=False) as tm_decompress:
|
|
1362
1598
|
obj = zstandard.ZstdDecompressor().decompress(obj)
|
|
1363
1599
|
if not silent:
|
|
1364
1600
|
messages.append(f' {tm_decompress}, {HumanizedBytes(len(obj))}')
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
messages.append(' (no compression detected)')
|
|
1601
|
+
elif not silent:
|
|
1602
|
+
messages.append(' (no compression detected)')
|
|
1368
1603
|
# create the actual object = unpickle
|
|
1369
1604
|
with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
|
|
1370
1605
|
python_obj: Any = unpickler(obj)
|
|
@@ -1372,7 +1607,7 @@ def DeSerialize(
|
|
|
1372
1607
|
messages.append(f' {tm_unpickle}')
|
|
1373
1608
|
# log and return
|
|
1374
1609
|
if not silent:
|
|
1375
|
-
logging.info(f'{tm_all}; parts:\n
|
|
1610
|
+
logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
|
|
1376
1611
|
return python_obj
|
|
1377
1612
|
|
|
1378
1613
|
|
|
@@ -1390,6 +1625,7 @@ class PublicBid512(CryptoKey):
|
|
|
1390
1625
|
Attributes:
|
|
1391
1626
|
public_key (bytes): 512-bits random value
|
|
1392
1627
|
public_hash (bytes): SHA-512 hash of (public_key || private_key || secret_bid)
|
|
1628
|
+
|
|
1393
1629
|
"""
|
|
1394
1630
|
|
|
1395
1631
|
public_key: bytes
|
|
@@ -1400,9 +1636,9 @@ class PublicBid512(CryptoKey):
|
|
|
1400
1636
|
|
|
1401
1637
|
Raises:
|
|
1402
1638
|
InputError: invalid inputs
|
|
1639
|
+
|
|
1403
1640
|
"""
|
|
1404
|
-
|
|
1405
|
-
if len(self.public_key) != 64 or len(self.public_hash) != 64:
|
|
1641
|
+
if len(self.public_key) != 64 or len(self.public_hash) != 64: # noqa: PLR2004
|
|
1406
1642
|
raise InputError(f'invalid public_key or public_hash: {self}')
|
|
1407
1643
|
|
|
1408
1644
|
def __str__(self) -> str:
|
|
@@ -1410,10 +1646,13 @@ class PublicBid512(CryptoKey):
|
|
|
1410
1646
|
|
|
1411
1647
|
Returns:
|
|
1412
1648
|
string representation of PublicBid
|
|
1649
|
+
|
|
1413
1650
|
"""
|
|
1414
|
-
return (
|
|
1415
|
-
|
|
1416
|
-
|
|
1651
|
+
return (
|
|
1652
|
+
'PublicBid512('
|
|
1653
|
+
f'public_key={BytesToEncoded(self.public_key)}, '
|
|
1654
|
+
f'public_hash={BytesToHex(self.public_hash)})'
|
|
1655
|
+
)
|
|
1417
1656
|
|
|
1418
1657
|
def VerifyBid(self, private_key: bytes, secret: bytes, /) -> bool:
|
|
1419
1658
|
"""Verify a bid. True if OK; False if failed verification.
|
|
@@ -1425,21 +1664,30 @@ class PublicBid512(CryptoKey):
|
|
|
1425
1664
|
Returns:
|
|
1426
1665
|
True if bid is valid, False otherwise
|
|
1427
1666
|
|
|
1428
|
-
Raises:
|
|
1429
|
-
InputError: invalid inputs
|
|
1430
1667
|
"""
|
|
1431
1668
|
try:
|
|
1432
1669
|
# creating the PrivateBid object will validate everything; InputError we allow to propagate
|
|
1433
1670
|
PrivateBid512(
|
|
1434
|
-
|
|
1435
|
-
|
|
1671
|
+
public_key=self.public_key,
|
|
1672
|
+
public_hash=self.public_hash,
|
|
1673
|
+
private_key=private_key,
|
|
1674
|
+
secret_bid=secret,
|
|
1675
|
+
)
|
|
1436
1676
|
return True # if we got here, all is good
|
|
1437
1677
|
except CryptoError:
|
|
1438
1678
|
return False # bid does not match the public commitment
|
|
1439
1679
|
|
|
1440
1680
|
@classmethod
|
|
1441
1681
|
def Copy(cls, other: PublicBid512, /) -> Self:
|
|
1442
|
-
"""Initialize a public bid by taking the public parts of a public/private bid.
|
|
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
|
+
"""
|
|
1443
1691
|
return cls(public_key=other.public_key, public_hash=other.public_hash)
|
|
1444
1692
|
|
|
1445
1693
|
|
|
@@ -1450,6 +1698,7 @@ class PrivateBid512(PublicBid512):
|
|
|
1450
1698
|
Attributes:
|
|
1451
1699
|
private_key (bytes): 512-bits random value
|
|
1452
1700
|
secret_bid (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
|
|
1701
|
+
|
|
1453
1702
|
"""
|
|
1454
1703
|
|
|
1455
1704
|
private_key: bytes
|
|
@@ -1461,9 +1710,10 @@ class PrivateBid512(PublicBid512):
|
|
|
1461
1710
|
Raises:
|
|
1462
1711
|
InputError: invalid inputs
|
|
1463
1712
|
CryptoError: bid does not match the public commitment
|
|
1713
|
+
|
|
1464
1714
|
"""
|
|
1465
|
-
super(PrivateBid512, self).__post_init__()
|
|
1466
|
-
if len(self.private_key) != 64 or len(self.secret_bid) < 1:
|
|
1715
|
+
super(PrivateBid512, self).__post_init__()
|
|
1716
|
+
if len(self.private_key) != 64 or len(self.secret_bid) < 1: # noqa: PLR2004
|
|
1467
1717
|
raise InputError(f'invalid private_key or secret_bid: {self}')
|
|
1468
1718
|
if self.public_hash != Hash512(self.public_key + self.private_key + self.secret_bid):
|
|
1469
1719
|
raise CryptoError(f'inconsistent bid: {self}')
|
|
@@ -1473,11 +1723,14 @@ class PrivateBid512(PublicBid512):
|
|
|
1473
1723
|
|
|
1474
1724
|
Returns:
|
|
1475
1725
|
string representation of PrivateBid without leaking secrets
|
|
1726
|
+
|
|
1476
1727
|
"""
|
|
1477
|
-
return (
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
+
)
|
|
1481
1734
|
|
|
1482
1735
|
@classmethod
|
|
1483
1736
|
def New(cls, secret: bytes, /) -> Self:
|
|
@@ -1491,199 +1744,175 @@ class PrivateBid512(PublicBid512):
|
|
|
1491
1744
|
|
|
1492
1745
|
Raises:
|
|
1493
1746
|
InputError: invalid inputs
|
|
1747
|
+
|
|
1494
1748
|
"""
|
|
1495
1749
|
# test inputs
|
|
1496
1750
|
if len(secret) < 1:
|
|
1497
1751
|
raise InputError(f'invalid secret length: {len(secret)}')
|
|
1498
1752
|
# generate random values
|
|
1499
|
-
public_key: bytes = RandBytes(64)
|
|
1753
|
+
public_key: bytes = RandBytes(64) # 512 bits
|
|
1500
1754
|
private_key: bytes = RandBytes(64) # 512 bits
|
|
1501
1755
|
# build object
|
|
1502
1756
|
return cls(
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
def
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
return
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
def
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
if
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
Will treat epilog strings as examples, splitting on '$$' to get multiple examples.
|
|
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.
|
|
1613
1865
|
|
|
1614
1866
|
Args:
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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").
|
|
1618
1871
|
|
|
1619
1872
|
Returns:
|
|
1620
|
-
|
|
1873
|
+
Markdown string.
|
|
1621
1874
|
|
|
1622
|
-
Raises:
|
|
1623
|
-
InputError: invalid app name
|
|
1624
1875
|
"""
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
help_text = _HelpText(sub_parser, parent_sub_action)
|
|
1669
|
-
if help_text:
|
|
1670
|
-
lines.append(f'\n{help_text}')
|
|
1671
|
-
usage: str = sub_parser.format_usage().replace('usage: ', '').strip()
|
|
1672
|
-
lines.append('\n```bash')
|
|
1673
|
-
lines.append(str(usage))
|
|
1674
|
-
lines.append('```\n')
|
|
1675
|
-
# Options/args table
|
|
1676
|
-
rows: list[tuple[str, str]] = _RowsForActions(sub_parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1677
|
-
if rows:
|
|
1678
|
-
lines.append(_MarkdownTable(rows))
|
|
1679
|
-
lines.append('')
|
|
1680
|
-
# Examples (if any) - stored in epilog argument
|
|
1681
|
-
epilog: str = sub_parser.epilog.strip() if sub_parser.epilog else ''
|
|
1682
|
-
if epilog:
|
|
1683
|
-
lines.append('**Example:**\n')
|
|
1684
|
-
lines.append('```bash')
|
|
1685
|
-
for epilog_line in epilog.split('$$'):
|
|
1686
|
-
lines.append(f'$ {parser.prog} {epilog_line.strip()}')
|
|
1687
|
-
lines.append('```\n')
|
|
1688
|
-
# join all lines as the markdown string
|
|
1689
|
-
return ('\n'.join(lines)).strip()
|
|
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()
|