transcrypto 1.6.0__py3-none-any.whl → 1.8.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 +384 -520
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +368 -0
- transcrypto/cli/bidsecret.py +334 -0
- transcrypto/cli/clibase.py +303 -0
- transcrypto/cli/intmath.py +427 -0
- transcrypto/cli/publicalgos.py +877 -0
- transcrypto/constants.py +20070 -1906
- transcrypto/dsa.py +132 -99
- transcrypto/elgamal.py +116 -84
- transcrypto/modmath.py +88 -78
- transcrypto/profiler.py +228 -175
- transcrypto/rsa.py +126 -90
- transcrypto/sss.py +122 -70
- transcrypto/transcrypto.py +419 -1423
- {transcrypto-1.6.0.dist-info → transcrypto-1.8.0.dist-info}/METADATA +88 -66
- transcrypto-1.8.0.dist-info/RECORD +23 -0
- {transcrypto-1.6.0.dist-info → transcrypto-1.8.0.dist-info}/WHEEL +1 -2
- transcrypto-1.8.0.dist-info/entry_points.txt +4 -0
- transcrypto/safetrans.py +0 -1228
- transcrypto-1.6.0.dist-info/RECORD +0 -18
- transcrypto-1.6.0.dist-info/top_level.txt +0 -1
- {transcrypto-1.6.0.dist-info → transcrypto-1.8.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,100 +15,103 @@ import hashlib
|
|
|
18
15
|
import json
|
|
19
16
|
import logging
|
|
20
17
|
import math
|
|
21
|
-
import
|
|
22
|
-
import pickle
|
|
23
|
-
# import pdb
|
|
18
|
+
import pathlib
|
|
19
|
+
import pickle # noqa: S403
|
|
24
20
|
import secrets
|
|
25
21
|
import sys
|
|
26
22
|
import time
|
|
27
|
-
import
|
|
28
|
-
from
|
|
29
|
-
from typing import
|
|
23
|
+
from collections import abc
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import (
|
|
26
|
+
Any,
|
|
27
|
+
Protocol,
|
|
28
|
+
Self,
|
|
29
|
+
cast,
|
|
30
|
+
final,
|
|
31
|
+
runtime_checkable,
|
|
32
|
+
)
|
|
30
33
|
|
|
31
34
|
import numpy as np
|
|
32
|
-
from rich import console as rich_console
|
|
33
|
-
from rich import logging as rich_logging
|
|
34
|
-
from scipy import stats # type:ignore
|
|
35
35
|
import zstandard
|
|
36
|
-
|
|
37
|
-
__author__ = 'balparda@github.com'
|
|
38
|
-
__version__ = '1.6.0' # 2026-01-15, Thu
|
|
39
|
-
__version_tuple__: tuple[int, ...] = tuple(int(v) for v in __version__.split('.'))
|
|
36
|
+
from scipy import stats
|
|
40
37
|
|
|
41
38
|
# Data conversion utils
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
# JSON types
|
|
41
|
+
type JSONValue = bool | int | float | str | list[JSONValue] | dict[str, JSONValue] | None
|
|
42
|
+
type JSONDict = dict[str, JSONValue]
|
|
43
|
+
|
|
44
|
+
# Crypto types: add bytes for cryptographic data; has to be encoded for JSON serialization
|
|
45
|
+
type CryptValue = bool | int | float | str | bytes | list[CryptValue] | dict[str, CryptValue] | None
|
|
46
|
+
type CryptDict = dict[str, CryptValue]
|
|
47
|
+
_JSON_DATACLASS_TYPES: set[str] = {
|
|
48
|
+
# native support
|
|
49
|
+
'int',
|
|
50
|
+
'float',
|
|
51
|
+
'str',
|
|
52
|
+
'bool',
|
|
53
|
+
# support for lists for now, but no nested lists or dicts yet
|
|
54
|
+
'list[int]',
|
|
55
|
+
'list[float]',
|
|
56
|
+
'list[str]',
|
|
57
|
+
'list[bool]',
|
|
58
|
+
# need conversion/encoding: see CryptValue/CryptDict
|
|
59
|
+
'bytes',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
BytesToHex: abc.Callable[[bytes], str] = lambda b: b.hex()
|
|
63
|
+
BytesToInt: abc.Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
|
|
64
|
+
BytesToEncoded: abc.Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
|
|
46
65
|
|
|
47
|
-
HexToBytes: Callable[[str], bytes] = bytes.fromhex
|
|
48
|
-
IntToFixedBytes: Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
|
|
49
|
-
IntToBytes: Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
|
|
50
|
-
IntToEncoded: Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
|
|
51
|
-
EncodedToBytes: Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
|
|
66
|
+
HexToBytes: abc.Callable[[str], bytes] = bytes.fromhex
|
|
67
|
+
IntToFixedBytes: abc.Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
|
|
68
|
+
IntToBytes: abc.Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
|
|
69
|
+
IntToEncoded: abc.Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
|
|
70
|
+
EncodedToBytes: abc.Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
|
|
52
71
|
|
|
53
|
-
PadBytesTo: Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
72
|
+
PadBytesTo: abc.Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
54
73
|
|
|
55
74
|
# Time utils
|
|
56
75
|
|
|
57
|
-
MIN_TM = int(
|
|
58
|
-
datetime.datetime(2000, 1, 1, 0, 0, 0).replace(tzinfo=datetime.timezone.utc).timestamp())
|
|
76
|
+
MIN_TM = int(datetime.datetime(2000, 1, 1, 0, 0, 0, tzinfo=datetime.UTC).timestamp())
|
|
59
77
|
TIME_FORMAT = '%Y/%b/%d-%H:%M:%S-UTC'
|
|
60
|
-
TimeStr: Callable[[int | float | None], str] = lambda tm: (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# Logging
|
|
66
|
-
_LOG_FORMAT_NO_PROCESS: str = '%(funcName)s: %(message)s'
|
|
67
|
-
_LOG_FORMAT_WITH_PROCESS: str = '%(processName)s/' + _LOG_FORMAT_NO_PROCESS
|
|
68
|
-
_LOG_FORMAT_DATETIME: str = '[%Y%m%d-%H:%M:%S]' # e.g., [20240131-13:45:30]
|
|
69
|
-
_LOG_LEVELS: list[int] = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
|
|
70
|
-
_LOG_COMMON_PROVIDERS: set[str] = {
|
|
71
|
-
'werkzeug',
|
|
72
|
-
'gunicorn.error', 'gunicorn.access',
|
|
73
|
-
'uvicorn', 'uvicorn.error', 'uvicorn.access',
|
|
74
|
-
'django.server',
|
|
75
|
-
}
|
|
78
|
+
TimeStr: abc.Callable[[int | float | None], str] = lambda tm: (
|
|
79
|
+
time.strftime(TIME_FORMAT, time.gmtime(tm)) if tm else '-'
|
|
80
|
+
)
|
|
81
|
+
Now: abc.Callable[[], int] = lambda: int(time.time())
|
|
82
|
+
StrNow: abc.Callable[[], str] = lambda: TimeStr(Now())
|
|
76
83
|
|
|
77
84
|
# SI prefix table, powers of 1000
|
|
78
85
|
_SI_PREFIXES: dict[int, str] = {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
-6: 'a', # atto
|
|
87
|
+
-5: 'f', # femto
|
|
88
|
+
-4: 'p', # pico
|
|
89
|
+
-3: 'n', # nano
|
|
90
|
+
-2: 'µ', # micro (unicode U+00B5) # noqa: RUF001
|
|
91
|
+
-1: 'm', # milli
|
|
92
|
+
0: '', # base
|
|
93
|
+
1: 'k', # kilo
|
|
94
|
+
2: 'M', # mega
|
|
95
|
+
3: 'G', # giga
|
|
96
|
+
4: 'T', # tera
|
|
97
|
+
5: 'P', # peta
|
|
98
|
+
6: 'E', # exa
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
# these control the pickling of data, do NOT ever change, or you will break all databases
|
|
95
102
|
# <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
|
|
96
103
|
_PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
|
|
97
|
-
PickleGeneric: Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
|
|
98
|
-
UnpickleGeneric: Callable[[bytes], Any] = pickle.loads
|
|
99
|
-
PickleJSON: Callable[[
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
PickleGeneric: abc.Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
|
|
105
|
+
UnpickleGeneric: abc.Callable[[bytes], Any] = pickle.loads # noqa: S301
|
|
106
|
+
PickleJSON: abc.Callable[[JSONDict], bytes] = lambda d: json.dumps(d, separators=(',', ':')).encode(
|
|
107
|
+
'utf-8'
|
|
108
|
+
)
|
|
109
|
+
UnpickleJSON: abc.Callable[[bytes], JSONDict] = lambda b: json.loads(b.decode('utf-8'))
|
|
102
110
|
_PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
|
|
103
111
|
# these help find compressed files, do NOT change unless zstandard changes
|
|
104
112
|
_ZSTD_MAGIC_FRAME = 0xFD2FB528
|
|
105
113
|
_ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
|
|
106
114
|
_ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
|
|
107
|
-
# JSON
|
|
108
|
-
_JSON_DATACLASS_TYPES: set[str] = {
|
|
109
|
-
# native support
|
|
110
|
-
'int', 'float', 'str', 'bool',
|
|
111
|
-
'list[int]', 'list[float]', 'list[str]', 'list[bool]',
|
|
112
|
-
# need conversion/encoding
|
|
113
|
-
'bytes',
|
|
114
|
-
}
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
class Error(Exception):
|
|
@@ -127,79 +127,10 @@ class CryptoError(Error):
|
|
|
127
127
|
|
|
128
128
|
|
|
129
129
|
class ImplementationError(Error, NotImplementedError):
|
|
130
|
-
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
__console_lock = threading.RLock()
|
|
134
|
-
__console_singleton: rich_console.Console | None = None
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def Console() -> rich_console.Console:
|
|
138
|
-
"""Get the global console instance.
|
|
139
|
-
|
|
140
|
-
Returns:
|
|
141
|
-
rich.console.Console: The global console instance.
|
|
142
|
-
"""
|
|
143
|
-
with __console_lock:
|
|
144
|
-
if __console_singleton is None:
|
|
145
|
-
return rich_console.Console() # fallback console if InitLogging hasn't been called yet
|
|
146
|
-
return __console_singleton
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def ResetConsole() -> None:
|
|
150
|
-
"""Reset the global console instance."""
|
|
151
|
-
global __console_singleton # pylint: disable=global-statement
|
|
152
|
-
with __console_lock:
|
|
153
|
-
__console_singleton = None
|
|
130
|
+
"""Feature is not implemented yet (TransCrypto)."""
|
|
154
131
|
|
|
155
132
|
|
|
156
|
-
def
|
|
157
|
-
verbosity: int, /, *,
|
|
158
|
-
include_process: bool = False, soft_wrap: bool = False) -> rich_console.Console:
|
|
159
|
-
"""Initialize logger (with RichHandler).
|
|
160
|
-
|
|
161
|
-
If you have a CLI app that uses this, its pytests should call `ResetConsole()` in a fixture, like:
|
|
162
|
-
|
|
163
|
-
from transcrypto import base
|
|
164
|
-
@pytest.fixture(autouse=True)
|
|
165
|
-
def _reset_base_logging():
|
|
166
|
-
base.ResetConsole()
|
|
167
|
-
yield
|
|
168
|
-
|
|
169
|
-
Args:
|
|
170
|
-
verbosity (int): Logging verbosity level.
|
|
171
|
-
include_process (bool, optional): Whether to include process name in log output.
|
|
172
|
-
soft_wrap (bool, optional): Whether to enable soft wrapping in the console.
|
|
173
|
-
Default is False, and it means rich will hard-wrap long lines (by adding '\n' chars).
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
rich.console.Console: The initialized console instance.
|
|
177
|
-
"""
|
|
178
|
-
global __console_singleton # pylint: disable=global-statement
|
|
179
|
-
with __console_lock:
|
|
180
|
-
if __console_singleton is not None:
|
|
181
|
-
return __console_singleton
|
|
182
|
-
logging_level: int = _LOG_LEVELS[max(0, min(verbosity, len(_LOG_LEVELS) - 1))]
|
|
183
|
-
console = rich_console.Console(soft_wrap=soft_wrap)
|
|
184
|
-
logging.basicConfig(
|
|
185
|
-
level=logging_level,
|
|
186
|
-
format=_LOG_FORMAT_WITH_PROCESS if include_process else _LOG_FORMAT_NO_PROCESS,
|
|
187
|
-
datefmt=_LOG_FORMAT_DATETIME,
|
|
188
|
-
handlers=[rich_logging.RichHandler( # we show name/line, but want time & level
|
|
189
|
-
console=console, rich_tracebacks=True, show_time=True, show_level=True, show_path=True)],
|
|
190
|
-
force=True) # force=True to override any previous logging config
|
|
191
|
-
logging.captureWarnings(True)
|
|
192
|
-
for name in _LOG_COMMON_PROVIDERS:
|
|
193
|
-
log: logging.Logger = logging.getLogger(name)
|
|
194
|
-
log.handlers.clear()
|
|
195
|
-
log.propagate = True
|
|
196
|
-
log.setLevel(logging_level)
|
|
197
|
-
__console_singleton = console # need a global statement to re-bind this one
|
|
198
|
-
logging.info(f'Logging initialized at level {logging.getLevelName(logging_level)}')
|
|
199
|
-
return console
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-return-statements
|
|
133
|
+
def HumanizedBytes(inp_sz: float, /) -> str: # noqa: PLR0911
|
|
203
134
|
"""Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
|
|
204
135
|
|
|
205
136
|
Scales the input size by powers of 1024, returning a value with the
|
|
@@ -231,10 +162,11 @@ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-r
|
|
|
231
162
|
'2.00 KiB'
|
|
232
163
|
>>> HumanizedBytes(5 * 1024**3)
|
|
233
164
|
'5.00 GiB'
|
|
165
|
+
|
|
234
166
|
"""
|
|
235
167
|
if inp_sz < 0:
|
|
236
168
|
raise InputError(f'input should be >=0 and got {inp_sz}')
|
|
237
|
-
if inp_sz < 1024:
|
|
169
|
+
if inp_sz < 1024: # noqa: PLR2004
|
|
238
170
|
return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
|
|
239
171
|
if inp_sz < 1024 * 1024:
|
|
240
172
|
return f'{(inp_sz / 1024):0.3f} KiB'
|
|
@@ -249,7 +181,7 @@ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-r
|
|
|
249
181
|
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
|
|
250
182
|
|
|
251
183
|
|
|
252
|
-
def HumanizedDecimal(inp_sz:
|
|
184
|
+
def HumanizedDecimal(inp_sz: float, /, *, unit: str = '') -> str:
|
|
253
185
|
"""Convert a numeric value into a human-readable string using SI metric prefixes.
|
|
254
186
|
|
|
255
187
|
Scales the input value by powers of 1000, returning a value with the
|
|
@@ -286,7 +218,8 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
|
286
218
|
|
|
287
219
|
Raises:
|
|
288
220
|
InputError: If `inp_sz` is not finite.
|
|
289
|
-
|
|
221
|
+
|
|
222
|
+
""" # noqa: RUF002
|
|
290
223
|
if not math.isfinite(inp_sz):
|
|
291
224
|
raise InputError(f'input should finite; got {inp_sz!r}')
|
|
292
225
|
unit = unit.strip()
|
|
@@ -296,8 +229,7 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
|
296
229
|
neg: str = '-' if inp_sz < 0 else ''
|
|
297
230
|
inp_sz = abs(inp_sz)
|
|
298
231
|
# Find exponent of 1000 that keeps value in [1, 1000)
|
|
299
|
-
exp: int
|
|
300
|
-
exp = int(math.floor(math.log10(abs(inp_sz)) / 3))
|
|
232
|
+
exp: int = math.floor(math.log10(abs(inp_sz)) / 3)
|
|
301
233
|
exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
|
|
302
234
|
if not exp:
|
|
303
235
|
# No scaling: use int or 4-decimal float
|
|
@@ -305,12 +237,12 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
|
305
237
|
return f'{neg}{int(inp_sz)}{pad_unit}'
|
|
306
238
|
return f'{neg}{inp_sz:0.3f}{pad_unit}'
|
|
307
239
|
# scaled
|
|
308
|
-
scaled: float = inp_sz / (1000
|
|
240
|
+
scaled: float = inp_sz / (1000**exp)
|
|
309
241
|
prefix: str = _SI_PREFIXES[exp]
|
|
310
242
|
return f'{neg}{scaled:0.3f} {prefix}{unit}'
|
|
311
243
|
|
|
312
244
|
|
|
313
|
-
def HumanizedSeconds(inp_secs:
|
|
245
|
+
def HumanizedSeconds(inp_secs: float, /) -> str: # noqa: PLR0911
|
|
314
246
|
"""Convert a duration in seconds into a human-readable time string.
|
|
315
247
|
|
|
316
248
|
Selects the appropriate time unit based on the duration's magnitude:
|
|
@@ -351,17 +283,18 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
|
|
|
351
283
|
'42.00 s'
|
|
352
284
|
>>> HumanizedSeconds(3661)
|
|
353
285
|
'1.02 h'
|
|
354
|
-
|
|
286
|
+
|
|
287
|
+
""" # noqa: RUF002
|
|
355
288
|
if not math.isfinite(inp_secs) or inp_secs < 0:
|
|
356
289
|
raise InputError(f'input should be >=0 and got {inp_secs}')
|
|
357
290
|
if inp_secs == 0:
|
|
358
291
|
return '0.000 s'
|
|
359
292
|
inp_secs = float(inp_secs)
|
|
360
|
-
if inp_secs < 0.001:
|
|
361
|
-
return f'{inp_secs * 1000 * 1000:0.3f} µs'
|
|
293
|
+
if inp_secs < 0.001: # noqa: PLR2004
|
|
294
|
+
return f'{inp_secs * 1000 * 1000:0.3f} µs' # noqa: RUF001
|
|
362
295
|
if inp_secs < 1:
|
|
363
296
|
return f'{inp_secs * 1000:0.3f} ms'
|
|
364
|
-
if inp_secs < 60:
|
|
297
|
+
if inp_secs < 60: # noqa: PLR2004
|
|
365
298
|
return f'{inp_secs:0.3f} s'
|
|
366
299
|
if inp_secs < 60 * 60:
|
|
367
300
|
return f'{(inp_secs / 60):0.3f} min'
|
|
@@ -371,8 +304,8 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
|
|
|
371
304
|
|
|
372
305
|
|
|
373
306
|
def MeasurementStats(
|
|
374
|
-
|
|
375
|
-
|
|
307
|
+
data: list[int | float], /, *, confidence: float = 0.95
|
|
308
|
+
) -> tuple[int, float, float, float, tuple[float, float], float]:
|
|
376
309
|
"""Compute descriptive statistics for repeated measurements.
|
|
377
310
|
|
|
378
311
|
Given N ≥ 1 measurements, this function computes the sample mean, the
|
|
@@ -401,12 +334,13 @@ def MeasurementStats(
|
|
|
401
334
|
|
|
402
335
|
Raises:
|
|
403
336
|
InputError: if the input list is empty.
|
|
337
|
+
|
|
404
338
|
"""
|
|
405
339
|
# test inputs
|
|
406
340
|
n: int = len(data)
|
|
407
341
|
if not n:
|
|
408
342
|
raise InputError('no data')
|
|
409
|
-
if not 0.5 <= confidence < 1.0:
|
|
343
|
+
if not 0.5 <= confidence < 1.0: # noqa: PLR2004
|
|
410
344
|
raise InputError(f'invalid confidence: {confidence=}')
|
|
411
345
|
# solve trivial case
|
|
412
346
|
if n == 1:
|
|
@@ -414,17 +348,22 @@ def MeasurementStats(
|
|
|
414
348
|
# call scipy for the science data
|
|
415
349
|
np_data = np.array(data)
|
|
416
350
|
mean = np.mean(np_data)
|
|
417
|
-
sem = stats.sem(np_data)
|
|
418
|
-
ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem)
|
|
419
|
-
t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1)
|
|
420
|
-
error = t_crit * sem # half-width of the CI
|
|
421
|
-
return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence)
|
|
351
|
+
sem = stats.sem(np_data)
|
|
352
|
+
ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem)
|
|
353
|
+
t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1)
|
|
354
|
+
error = t_crit * sem # half-width of the CI
|
|
355
|
+
return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence)
|
|
422
356
|
|
|
423
357
|
|
|
424
358
|
def HumanizedMeasurements(
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
359
|
+
data: list[int | float],
|
|
360
|
+
/,
|
|
361
|
+
*,
|
|
362
|
+
unit: str = '',
|
|
363
|
+
parser: abc.Callable[[float], str] | None = None,
|
|
364
|
+
clip_negative: bool = True,
|
|
365
|
+
confidence: float = 0.95,
|
|
366
|
+
) -> str:
|
|
428
367
|
"""Render measurement statistics as a human-readable string.
|
|
429
368
|
|
|
430
369
|
Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
|
|
@@ -449,6 +388,7 @@ def HumanizedMeasurements(
|
|
|
449
388
|
|
|
450
389
|
Returns:
|
|
451
390
|
str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
|
|
391
|
+
|
|
452
392
|
"""
|
|
453
393
|
n: int
|
|
454
394
|
mean: float
|
|
@@ -457,12 +397,14 @@ def HumanizedMeasurements(
|
|
|
457
397
|
conf: float
|
|
458
398
|
unit = unit.strip()
|
|
459
399
|
n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
|
|
460
|
-
f: Callable[[float], str] = lambda x: (
|
|
461
|
-
|
|
462
|
-
|
|
400
|
+
f: abc.Callable[[float], str] = lambda x: (
|
|
401
|
+
('*0' if clip_negative and x < 0.0 else str(x))
|
|
402
|
+
if parser is None
|
|
403
|
+
else (f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x))
|
|
404
|
+
)
|
|
463
405
|
if n == 1:
|
|
464
406
|
return f'{f(mean)}{unit} ±? @1'
|
|
465
|
-
pct =
|
|
407
|
+
pct: int = round(conf * 100)
|
|
466
408
|
return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
|
|
467
409
|
|
|
468
410
|
|
|
@@ -470,7 +412,6 @@ class Timer:
|
|
|
470
412
|
"""An execution timing class that can be used as both a context manager and a decorator.
|
|
471
413
|
|
|
472
414
|
Examples:
|
|
473
|
-
|
|
474
415
|
# As a context manager
|
|
475
416
|
with Timer('Block timing'):
|
|
476
417
|
time.sleep(1.2)
|
|
@@ -491,30 +432,43 @@ class Timer:
|
|
|
491
432
|
label (str, optional): Timer label
|
|
492
433
|
emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
|
|
493
434
|
emit_print (bool, optional): If True will print() the timer, else (default) will not
|
|
435
|
+
|
|
494
436
|
"""
|
|
495
437
|
|
|
496
438
|
def __init__(
|
|
497
|
-
|
|
498
|
-
|
|
439
|
+
self,
|
|
440
|
+
label: str = '',
|
|
441
|
+
/,
|
|
442
|
+
*,
|
|
443
|
+
emit_log: bool = True,
|
|
444
|
+
emit_print: abc.Callable[[str], None] | None = None,
|
|
445
|
+
) -> None:
|
|
499
446
|
"""Initialize the Timer.
|
|
500
447
|
|
|
501
448
|
Args:
|
|
502
449
|
label (str, optional): A description or name for the timed block or function
|
|
503
450
|
emit_log (bool, optional): Emit a log message when finished; default is True
|
|
504
|
-
emit_print (
|
|
451
|
+
emit_print (Callable[[str], None] | None, optional): Emit a print() message when
|
|
452
|
+
finished using the provided callable; default is None
|
|
505
453
|
|
|
506
|
-
Raises:
|
|
507
|
-
InputError: empty label
|
|
508
454
|
"""
|
|
509
455
|
self.emit_log: bool = emit_log
|
|
510
|
-
self.emit_print:
|
|
456
|
+
self.emit_print: abc.Callable[[str], None] | None = emit_print
|
|
511
457
|
self.label: str = label.strip()
|
|
512
458
|
self.start: float | None = None
|
|
513
459
|
self.end: float | None = None
|
|
514
460
|
|
|
515
461
|
@property
|
|
516
462
|
def elapsed(self) -> float:
|
|
517
|
-
"""Elapsed time. Will be zero until a measurement is available with start/end.
|
|
463
|
+
"""Elapsed time. Will be zero until a measurement is available with start/end.
|
|
464
|
+
|
|
465
|
+
Raises:
|
|
466
|
+
Error: negative elapsed time
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
float: elapsed time, in seconds
|
|
470
|
+
|
|
471
|
+
"""
|
|
518
472
|
if self.start is None or self.end is None:
|
|
519
473
|
return 0.0
|
|
520
474
|
delta: float = self.end - self.start
|
|
@@ -523,27 +477,48 @@ class Timer:
|
|
|
523
477
|
return delta
|
|
524
478
|
|
|
525
479
|
def __str__(self) -> str:
|
|
526
|
-
"""
|
|
480
|
+
"""Get current timer value.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
str: human-readable representation of current time value
|
|
484
|
+
|
|
485
|
+
"""
|
|
527
486
|
if self.start is None:
|
|
528
487
|
return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
|
|
529
488
|
if self.end is None:
|
|
530
|
-
return (
|
|
531
|
-
|
|
489
|
+
return (
|
|
490
|
+
f'{self.label}: ' if self.label else ''
|
|
491
|
+
) + f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}'
|
|
532
492
|
return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
|
|
533
493
|
|
|
534
494
|
def Start(self) -> None:
|
|
535
|
-
"""Start the timer.
|
|
495
|
+
"""Start the timer.
|
|
496
|
+
|
|
497
|
+
Raises:
|
|
498
|
+
Error: if you try to re-start the timer
|
|
499
|
+
|
|
500
|
+
"""
|
|
536
501
|
if self.start is not None:
|
|
537
502
|
raise Error('Re-starting timer is forbidden')
|
|
538
503
|
self.start = time.perf_counter()
|
|
539
504
|
|
|
540
|
-
def __enter__(self) ->
|
|
541
|
-
"""Start the timer when entering the context.
|
|
505
|
+
def __enter__(self) -> Self:
|
|
506
|
+
"""Start the timer when entering the context.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Timer: context object (self)
|
|
510
|
+
|
|
511
|
+
"""
|
|
542
512
|
self.Start()
|
|
543
513
|
return self
|
|
544
514
|
|
|
545
515
|
def Stop(self) -> None:
|
|
546
|
-
"""Stop the timer and emit logging.info with timer message.
|
|
516
|
+
"""Stop the timer and emit logging.info with timer message.
|
|
517
|
+
|
|
518
|
+
Raises:
|
|
519
|
+
Error: trying to re-start timer or stop unstarted timer
|
|
520
|
+
|
|
521
|
+
"""
|
|
547
522
|
if self.start is None:
|
|
548
523
|
raise Error('Stopping an unstarted timer')
|
|
549
524
|
if self.end is not None:
|
|
@@ -552,24 +527,19 @@ class Timer:
|
|
|
552
527
|
message: str = str(self)
|
|
553
528
|
if self.emit_log:
|
|
554
529
|
logging.info(message)
|
|
555
|
-
if self.emit_print:
|
|
556
|
-
|
|
530
|
+
if self.emit_print is not None:
|
|
531
|
+
self.emit_print(message)
|
|
557
532
|
|
|
558
533
|
def __exit__(
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
exc_val (BaseException | None): Exception value, if any.
|
|
566
|
-
exc_tb (Any): Traceback object, if any.
|
|
567
|
-
"""
|
|
534
|
+
self,
|
|
535
|
+
unused_exc_type: type[BaseException] | None,
|
|
536
|
+
unused_exc_val: BaseException | None,
|
|
537
|
+
exc_tb: TracebackType | None,
|
|
538
|
+
) -> None:
|
|
539
|
+
"""Stop the timer when exiting the context."""
|
|
568
540
|
self.Stop()
|
|
569
541
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def __call__(self, func: Timer._F) -> Timer._F:
|
|
542
|
+
def __call__[**F, R](self, func: abc.Callable[F, R]) -> abc.Callable[F, R]:
|
|
573
543
|
"""Allow the Timer to be used as a decorator.
|
|
574
544
|
|
|
575
545
|
Args:
|
|
@@ -577,14 +547,15 @@ class Timer:
|
|
|
577
547
|
|
|
578
548
|
Returns:
|
|
579
549
|
The wrapped function with timing behavior.
|
|
550
|
+
|
|
580
551
|
"""
|
|
581
552
|
|
|
582
553
|
@functools.wraps(func)
|
|
583
|
-
def _Wrapper(*args:
|
|
554
|
+
def _Wrapper(*args: F.args, **kwargs: F.kwargs) -> R:
|
|
584
555
|
with self.__class__(self.label, emit_log=self.emit_log, emit_print=self.emit_print):
|
|
585
556
|
return func(*args, **kwargs)
|
|
586
557
|
|
|
587
|
-
return _Wrapper
|
|
558
|
+
return _Wrapper
|
|
588
559
|
|
|
589
560
|
|
|
590
561
|
def RandBits(n_bits: int, /) -> int:
|
|
@@ -602,9 +573,10 @@ def RandBits(n_bits: int, /) -> int:
|
|
|
602
573
|
|
|
603
574
|
Raises:
|
|
604
575
|
InputError: invalid n_bits
|
|
576
|
+
|
|
605
577
|
"""
|
|
606
578
|
# test inputs
|
|
607
|
-
if n_bits < 8:
|
|
579
|
+
if n_bits < 8: # noqa: PLR2004
|
|
608
580
|
raise InputError(f'n_bits must be ≥ 8: {n_bits}')
|
|
609
581
|
# call underlying method
|
|
610
582
|
n: int = 0
|
|
@@ -625,6 +597,7 @@ def RandInt(min_int: int, max_int: int, /) -> int:
|
|
|
625
597
|
|
|
626
598
|
Raises:
|
|
627
599
|
InputError: invalid min/max
|
|
600
|
+
|
|
628
601
|
"""
|
|
629
602
|
# test inputs
|
|
630
603
|
if min_int < 0 or min_int >= max_int:
|
|
@@ -632,11 +605,11 @@ def RandInt(min_int: int, max_int: int, /) -> int:
|
|
|
632
605
|
# uniform over [min_int, max_int]
|
|
633
606
|
span: int = max_int - min_int + 1
|
|
634
607
|
n: int = min_int + secrets.randbelow(span)
|
|
635
|
-
assert min_int <= n <= max_int, 'should never happen: generated number out of range'
|
|
608
|
+
assert min_int <= n <= max_int, 'should never happen: generated number out of range' # noqa: S101
|
|
636
609
|
return n
|
|
637
610
|
|
|
638
611
|
|
|
639
|
-
def RandShuffle[T
|
|
612
|
+
def RandShuffle[T](seq: abc.MutableSequence[T], /) -> None:
|
|
640
613
|
"""In-place Crypto-random shuffle order for `seq` mutable sequence.
|
|
641
614
|
|
|
642
615
|
Args:
|
|
@@ -644,11 +617,12 @@ def RandShuffle[T: Any](seq: MutableSequence[T], /) -> None:
|
|
|
644
617
|
|
|
645
618
|
Raises:
|
|
646
619
|
InputError: not enough elements
|
|
620
|
+
|
|
647
621
|
"""
|
|
648
622
|
# test inputs
|
|
649
|
-
if (n_seq := len(seq)) < 2:
|
|
623
|
+
if (n_seq := len(seq)) < 2: # noqa: PLR2004
|
|
650
624
|
raise InputError(f'seq must have 2 or more elements: {n_seq}')
|
|
651
|
-
# cryptographically sound Fisher
|
|
625
|
+
# cryptographically sound Fisher-Yates using secrets.randbelow
|
|
652
626
|
for i in range(n_seq - 1, 0, -1):
|
|
653
627
|
j: int = secrets.randbelow(i + 1)
|
|
654
628
|
seq[i], seq[j] = seq[j], seq[i]
|
|
@@ -665,13 +639,14 @@ def RandBytes(n_bytes: int, /) -> bytes:
|
|
|
665
639
|
|
|
666
640
|
Raises:
|
|
667
641
|
InputError: invalid n_bytes
|
|
642
|
+
|
|
668
643
|
"""
|
|
669
644
|
# test inputs
|
|
670
645
|
if n_bytes < 1:
|
|
671
646
|
raise InputError(f'n_bytes must be ≥ 1: {n_bytes}')
|
|
672
647
|
# return from system call
|
|
673
648
|
b: bytes = secrets.token_bytes(n_bytes)
|
|
674
|
-
assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size'
|
|
649
|
+
assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size' # noqa: S101
|
|
675
650
|
return b
|
|
676
651
|
|
|
677
652
|
|
|
@@ -689,6 +664,7 @@ def GCD(a: int, b: int, /) -> int:
|
|
|
689
664
|
|
|
690
665
|
Raises:
|
|
691
666
|
InputError: invalid inputs
|
|
667
|
+
|
|
692
668
|
"""
|
|
693
669
|
# test inputs
|
|
694
670
|
if a < 0 or b < 0 or (not a and not b):
|
|
@@ -718,6 +694,7 @@ def ExtendedGCD(a: int, b: int, /) -> tuple[int, int, int]:
|
|
|
718
694
|
|
|
719
695
|
Raises:
|
|
720
696
|
InputError: invalid inputs
|
|
697
|
+
|
|
721
698
|
"""
|
|
722
699
|
# test inputs
|
|
723
700
|
if a < 0 or b < 0 or (not a and not b):
|
|
@@ -749,6 +726,7 @@ def Hash256(data: bytes, /) -> bytes:
|
|
|
749
726
|
32 bytes (256 bits) of SHA-256 hash;
|
|
750
727
|
if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
|
|
751
728
|
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
|
|
729
|
+
|
|
752
730
|
"""
|
|
753
731
|
return hashlib.sha256(data).digest()
|
|
754
732
|
|
|
@@ -763,6 +741,7 @@ def Hash512(data: bytes, /) -> bytes:
|
|
|
763
741
|
64 bytes (512 bits) of SHA-512 hash;
|
|
764
742
|
if converted to hexadecimal (with BytesToHex() or hex()) will be 128 chars of string;
|
|
765
743
|
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**512
|
|
744
|
+
|
|
766
745
|
"""
|
|
767
746
|
return hashlib.sha512(data).digest()
|
|
768
747
|
|
|
@@ -781,17 +760,18 @@ def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
|
|
|
781
760
|
|
|
782
761
|
Raises:
|
|
783
762
|
InputError: file could not be found
|
|
763
|
+
|
|
784
764
|
"""
|
|
785
765
|
# test inputs
|
|
786
766
|
digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
|
|
787
|
-
if digest not in
|
|
767
|
+
if digest not in {'sha256', 'sha512'}:
|
|
788
768
|
raise InputError(f'unrecognized digest: {digest!r}')
|
|
789
769
|
full_path = full_path.strip()
|
|
790
|
-
if not full_path or not
|
|
770
|
+
if not full_path or not pathlib.Path(full_path).exists():
|
|
791
771
|
raise InputError(f'file {full_path!r} not found for hashing')
|
|
792
772
|
# compute hash
|
|
793
773
|
logging.info(f'Hashing file {full_path!r}')
|
|
794
|
-
with open(
|
|
774
|
+
with pathlib.Path(full_path).open('rb') as file_obj:
|
|
795
775
|
return hashlib.file_digest(file_obj, digest).digest()
|
|
796
776
|
|
|
797
777
|
|
|
@@ -805,31 +785,36 @@ def ObfuscateSecret(data: str | bytes | int, /) -> str:
|
|
|
805
785
|
Args:
|
|
806
786
|
data (str | bytes | int): Data to obfuscate
|
|
807
787
|
|
|
788
|
+
Raises:
|
|
789
|
+
InputError: _description_
|
|
790
|
+
|
|
808
791
|
Returns:
|
|
809
|
-
|
|
792
|
+
str: obfuscated string, e.g. "aabbccdd…"
|
|
793
|
+
|
|
810
794
|
"""
|
|
811
795
|
if isinstance(data, str):
|
|
812
796
|
data = data.encode('utf-8')
|
|
813
797
|
elif isinstance(data, int):
|
|
814
798
|
data = IntToBytes(data)
|
|
815
|
-
if not isinstance(data, bytes):
|
|
799
|
+
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
816
800
|
raise InputError(f'invalid type for data: {type(data)}')
|
|
817
801
|
return BytesToHex(Hash512(data))[:8] + '…'
|
|
818
802
|
|
|
819
803
|
|
|
820
804
|
class CryptoInputType(enum.StrEnum):
|
|
821
805
|
"""Types of inputs that can represent arbitrary bytes."""
|
|
806
|
+
|
|
822
807
|
# prefixes; format prefixes are all 4 bytes
|
|
823
|
-
PATH = '@'
|
|
824
|
-
STDIN = '@-'
|
|
825
|
-
HEX = 'hex:'
|
|
808
|
+
PATH = '@' # @path on disk → read bytes from a file
|
|
809
|
+
STDIN = '@-' # stdin
|
|
810
|
+
HEX = 'hex:' # hex:deadbeef → decode hex
|
|
826
811
|
BASE64 = 'b64:' # b64:... → decode base64
|
|
827
|
-
STR = 'str:'
|
|
828
|
-
RAW = 'raw:'
|
|
812
|
+
STR = 'str:' # str:hello → UTF-8 encode the literal
|
|
813
|
+
RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
|
|
829
814
|
|
|
830
815
|
|
|
831
816
|
def BytesToRaw(b: bytes, /) -> str:
|
|
832
|
-
"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
817
|
+
r"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
833
818
|
|
|
834
819
|
1. map bytes 0..255 to same code points (latin1)
|
|
835
820
|
2. escape non-printables/backslash/quotes via unicode_escape
|
|
@@ -839,21 +824,23 @@ def BytesToRaw(b: bytes, /) -> str:
|
|
|
839
824
|
|
|
840
825
|
Returns:
|
|
841
826
|
str: double-quoted string with \\xNN escapes where needed
|
|
827
|
+
|
|
842
828
|
"""
|
|
843
829
|
inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
|
|
844
|
-
return f'"{inner.replace('"', r
|
|
830
|
+
return f'"{inner.replace('"', r"\"")}"'
|
|
845
831
|
|
|
846
832
|
|
|
847
833
|
def RawToBytes(s: str, /) -> bytes:
|
|
848
|
-
"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
834
|
+
r"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
849
835
|
|
|
850
836
|
Args:
|
|
851
837
|
s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
|
|
852
838
|
|
|
853
839
|
Returns:
|
|
854
840
|
bytes: data
|
|
841
|
+
|
|
855
842
|
"""
|
|
856
|
-
if len(s) >= 2 and s[0] == s[-1] == '"':
|
|
843
|
+
if len(s) >= 2 and s[0] == s[-1] == '"': # noqa: PLR2004
|
|
857
844
|
s = s[1:-1]
|
|
858
845
|
# decode backslash escapes to code points, then map 0..255 -> bytes
|
|
859
846
|
return codecs.decode(s, 'unicode_escape').encode('latin1')
|
|
@@ -868,21 +855,23 @@ def DetectInputType(data_str: str, /) -> CryptoInputType | None:
|
|
|
868
855
|
Returns:
|
|
869
856
|
CryptoInputType | None: type if has a known prefix, None otherwise
|
|
870
857
|
|
|
871
|
-
Raises:
|
|
872
|
-
InputError: unexpected type or conversion error
|
|
873
858
|
"""
|
|
874
859
|
data_str = data_str.strip()
|
|
875
860
|
if data_str == CryptoInputType.STDIN:
|
|
876
861
|
return CryptoInputType.STDIN
|
|
877
862
|
for t in (
|
|
878
|
-
|
|
879
|
-
|
|
863
|
+
CryptoInputType.PATH,
|
|
864
|
+
CryptoInputType.STR,
|
|
865
|
+
CryptoInputType.HEX,
|
|
866
|
+
CryptoInputType.BASE64,
|
|
867
|
+
CryptoInputType.RAW,
|
|
868
|
+
):
|
|
880
869
|
if data_str.startswith(t):
|
|
881
870
|
return t
|
|
882
871
|
return None
|
|
883
872
|
|
|
884
873
|
|
|
885
|
-
def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: #
|
|
874
|
+
def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # noqa: C901, PLR0911, PLR0912
|
|
886
875
|
"""Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
|
|
887
876
|
|
|
888
877
|
Can load from disk ('@'). Can load from stdin ('@-').
|
|
@@ -899,6 +888,7 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
899
888
|
|
|
900
889
|
Raises:
|
|
901
890
|
InputError: unexpected type or conversion error
|
|
891
|
+
|
|
902
892
|
"""
|
|
903
893
|
data_str = data_str.strip()
|
|
904
894
|
# auto-detect
|
|
@@ -908,8 +898,8 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
908
898
|
raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
|
|
909
899
|
# now we know they don't conflict, so unify them; remove prefix if we have it
|
|
910
900
|
expect = detected_type if expect is None else expect
|
|
911
|
-
assert expect is not None, 'should never happen: type should be known here'
|
|
912
|
-
data_str = data_str
|
|
901
|
+
assert expect is not None, 'should never happen: type should be known here' # noqa: S101
|
|
902
|
+
data_str = data_str.removeprefix(expect)
|
|
913
903
|
# for every type something different will happen now
|
|
914
904
|
try:
|
|
915
905
|
match expect:
|
|
@@ -919,18 +909,17 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
919
909
|
stream = getattr(sys.stdin, 'buffer', None)
|
|
920
910
|
if stream is None:
|
|
921
911
|
text: str = sys.stdin.read()
|
|
922
|
-
if not isinstance(text, str): #
|
|
923
|
-
raise InputError('sys.stdin.read() produced non-text data')
|
|
912
|
+
if not isinstance(text, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
913
|
+
raise InputError('sys.stdin.read() produced non-text data') # noqa: TRY301
|
|
924
914
|
return text.encode('utf-8')
|
|
925
915
|
data: bytes = stream.read()
|
|
926
|
-
if not isinstance(data, bytes): #
|
|
927
|
-
raise InputError('sys.stdin.buffer.read() produced non-binary data')
|
|
916
|
+
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
917
|
+
raise InputError('sys.stdin.buffer.read() produced non-binary data') # noqa: TRY301
|
|
928
918
|
return data
|
|
929
919
|
case CryptoInputType.PATH:
|
|
930
|
-
if not
|
|
931
|
-
raise InputError(f'cannot find file {data_str!r}')
|
|
932
|
-
|
|
933
|
-
return file_obj.read()
|
|
920
|
+
if not pathlib.Path(data_str).exists():
|
|
921
|
+
raise InputError(f'cannot find file {data_str!r}') # noqa: TRY301
|
|
922
|
+
return pathlib.Path(data_str).read_bytes()
|
|
934
923
|
case CryptoInputType.STR:
|
|
935
924
|
return data_str.encode('utf-8')
|
|
936
925
|
case CryptoInputType.HEX:
|
|
@@ -940,24 +929,27 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
|
|
|
940
929
|
case CryptoInputType.RAW:
|
|
941
930
|
return RawToBytes(data_str)
|
|
942
931
|
case _:
|
|
943
|
-
raise InputError(f'invalid type {expect!r}')
|
|
932
|
+
raise InputError(f'invalid type {expect!r}') # noqa: TRY301
|
|
944
933
|
except Exception as err:
|
|
945
934
|
raise InputError(f'invalid input: {err}') from err
|
|
946
935
|
|
|
947
936
|
|
|
948
937
|
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
949
|
-
class CryptoKey(
|
|
938
|
+
class CryptoKey(abstract.ABC):
|
|
950
939
|
"""A cryptographic key."""
|
|
951
940
|
|
|
941
|
+
@abstract.abstractmethod
|
|
952
942
|
def __post_init__(self) -> None:
|
|
953
943
|
"""Check data."""
|
|
944
|
+
# every sub-class of CryptoKey has to implement its own version of __post_init__()
|
|
954
945
|
|
|
955
|
-
@
|
|
946
|
+
@abstract.abstractmethod
|
|
956
947
|
def __str__(self) -> str:
|
|
957
948
|
"""Safe (no secrets) string representation of the key.
|
|
958
949
|
|
|
959
950
|
Returns:
|
|
960
951
|
string representation of the key without leaking secrets
|
|
952
|
+
|
|
961
953
|
"""
|
|
962
954
|
# every sub-class of CryptoKey has to implement its own version of __str__()
|
|
963
955
|
|
|
@@ -967,6 +959,7 @@ class CryptoKey(abc.ABC):
|
|
|
967
959
|
|
|
968
960
|
Returns:
|
|
969
961
|
string representation of the key without leaking secrets
|
|
962
|
+
|
|
970
963
|
"""
|
|
971
964
|
# concrete __repr__() delegates to the (abstract) __str__():
|
|
972
965
|
# this avoids marking __repr__() abstract while still unifying behavior
|
|
@@ -982,35 +975,38 @@ class CryptoKey(abc.ABC):
|
|
|
982
975
|
|
|
983
976
|
Returns:
|
|
984
977
|
string with all the object's fields explicit values
|
|
978
|
+
|
|
985
979
|
"""
|
|
986
980
|
cls: str = type(self).__name__
|
|
987
981
|
parts: list[str] = []
|
|
988
982
|
for field in dataclasses.fields(self):
|
|
989
983
|
val: Any = getattr(self, field.name) # getattr is fine with frozen/slots
|
|
990
|
-
parts.append(f'{field.name}={
|
|
984
|
+
parts.append(f'{field.name}={val!r}')
|
|
991
985
|
return f'{cls}({", ".join(parts)})'
|
|
992
986
|
|
|
993
987
|
@final
|
|
994
988
|
@property
|
|
995
|
-
def _json_dict(self) ->
|
|
989
|
+
def _json_dict(self) -> JSONDict:
|
|
996
990
|
"""Dictionary representation of the object suitable for JSON conversion.
|
|
997
991
|
|
|
998
992
|
Returns:
|
|
999
|
-
|
|
993
|
+
JSONDict: representation of the object suitable for JSON conversion
|
|
1000
994
|
|
|
1001
995
|
Raises:
|
|
1002
996
|
ImplementationError: object has types that are not supported in JSON
|
|
997
|
+
|
|
1003
998
|
"""
|
|
1004
|
-
self_dict:
|
|
999
|
+
self_dict: CryptDict = dataclasses.asdict(self)
|
|
1005
1000
|
for field in dataclasses.fields(self):
|
|
1006
1001
|
# check the type is OK
|
|
1007
1002
|
if field.type not in _JSON_DATACLASS_TYPES:
|
|
1008
1003
|
raise ImplementationError(
|
|
1009
|
-
|
|
1004
|
+
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
|
|
1005
|
+
)
|
|
1010
1006
|
# convert types that we accept but JSON does not
|
|
1011
1007
|
if field.type == 'bytes':
|
|
1012
|
-
self_dict[field.name] = BytesToEncoded(self_dict[field.name])
|
|
1013
|
-
return self_dict
|
|
1008
|
+
self_dict[field.name] = BytesToEncoded(cast('bytes', self_dict[field.name]))
|
|
1009
|
+
return cast('JSONDict', self_dict)
|
|
1014
1010
|
|
|
1015
1011
|
@final
|
|
1016
1012
|
@property
|
|
@@ -1020,8 +1016,6 @@ class CryptoKey(abc.ABC):
|
|
|
1020
1016
|
Returns:
|
|
1021
1017
|
str: JSON representation of the object, tightly packed
|
|
1022
1018
|
|
|
1023
|
-
Raises:
|
|
1024
|
-
ImplementationError: object has types that are not supported in JSON
|
|
1025
1019
|
"""
|
|
1026
1020
|
return json.dumps(self._json_dict, separators=(',', ':'))
|
|
1027
1021
|
|
|
@@ -1033,27 +1027,27 @@ class CryptoKey(abc.ABC):
|
|
|
1033
1027
|
Returns:
|
|
1034
1028
|
str: JSON representation of the object formatted for humans
|
|
1035
1029
|
|
|
1036
|
-
Raises:
|
|
1037
|
-
ImplementationError: object has types that are not supported in JSON
|
|
1038
1030
|
"""
|
|
1039
1031
|
return json.dumps(self._json_dict, indent=4, sort_keys=True)
|
|
1040
1032
|
|
|
1041
1033
|
@final
|
|
1042
1034
|
@classmethod
|
|
1043
|
-
def _FromJSONDict(cls, json_dict:
|
|
1035
|
+
def _FromJSONDict(cls, json_dict: JSONDict, /) -> Self:
|
|
1044
1036
|
"""Create object from JSON representation.
|
|
1045
1037
|
|
|
1046
1038
|
Args:
|
|
1047
|
-
json_dict (
|
|
1039
|
+
json_dict (JSONDict): JSON dict
|
|
1048
1040
|
|
|
1049
1041
|
Returns:
|
|
1050
1042
|
a CryptoKey object ready for use
|
|
1051
1043
|
|
|
1052
1044
|
Raises:
|
|
1053
1045
|
InputError: unexpected type/fields
|
|
1046
|
+
ImplementationError: unsupported JSON field
|
|
1047
|
+
|
|
1054
1048
|
"""
|
|
1055
1049
|
# check we got exactly the fields we needed
|
|
1056
|
-
cls_fields: set[str] =
|
|
1050
|
+
cls_fields: set[str] = {f.name for f in dataclasses.fields(cls)}
|
|
1057
1051
|
json_fields: set[str] = set(json_dict)
|
|
1058
1052
|
if cls_fields != json_fields:
|
|
1059
1053
|
raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
|
|
@@ -1061,9 +1055,10 @@ class CryptoKey(abc.ABC):
|
|
|
1061
1055
|
for field in dataclasses.fields(cls):
|
|
1062
1056
|
if field.type not in _JSON_DATACLASS_TYPES:
|
|
1063
1057
|
raise ImplementationError(
|
|
1064
|
-
|
|
1058
|
+
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
|
|
1059
|
+
)
|
|
1065
1060
|
if field.type == 'bytes':
|
|
1066
|
-
json_dict[field.name] = EncodedToBytes(json_dict[field.name])
|
|
1061
|
+
json_dict[field.name] = EncodedToBytes(json_dict[field.name]) # type: ignore[assignment, arg-type]
|
|
1067
1062
|
# build the object
|
|
1068
1063
|
return cls(**json_dict)
|
|
1069
1064
|
|
|
@@ -1080,10 +1075,11 @@ class CryptoKey(abc.ABC):
|
|
|
1080
1075
|
|
|
1081
1076
|
Raises:
|
|
1082
1077
|
InputError: unexpected type/fields
|
|
1078
|
+
|
|
1083
1079
|
"""
|
|
1084
1080
|
# get the dict back
|
|
1085
|
-
json_dict:
|
|
1086
|
-
if not isinstance(json_dict, dict): #
|
|
1081
|
+
json_dict: JSONDict = json.loads(json_data)
|
|
1082
|
+
if not isinstance(json_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1087
1083
|
raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
|
|
1088
1084
|
return cls._FromJSONDict(json_dict)
|
|
1089
1085
|
|
|
@@ -1094,12 +1090,13 @@ class CryptoKey(abc.ABC):
|
|
|
1094
1090
|
|
|
1095
1091
|
Returns:
|
|
1096
1092
|
bytes, pickled, representation of the object
|
|
1093
|
+
|
|
1097
1094
|
"""
|
|
1098
1095
|
return self.Blob()
|
|
1099
1096
|
|
|
1100
1097
|
@final
|
|
1101
1098
|
def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
|
|
1102
|
-
"""
|
|
1099
|
+
"""Get serial (bytes) representation of the object with more options, including encryption.
|
|
1103
1100
|
|
|
1104
1101
|
Args:
|
|
1105
1102
|
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
@@ -1107,6 +1104,7 @@ class CryptoKey(abc.ABC):
|
|
|
1107
1104
|
|
|
1108
1105
|
Returns:
|
|
1109
1106
|
bytes, pickled, representation of the object
|
|
1107
|
+
|
|
1110
1108
|
"""
|
|
1111
1109
|
return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
|
|
1112
1110
|
|
|
@@ -1117,6 +1115,7 @@ class CryptoKey(abc.ABC):
|
|
|
1117
1115
|
|
|
1118
1116
|
Returns:
|
|
1119
1117
|
str, pickled, base64, representation of the object
|
|
1118
|
+
|
|
1120
1119
|
"""
|
|
1121
1120
|
return self.Encoded()
|
|
1122
1121
|
|
|
@@ -1130,6 +1129,7 @@ class CryptoKey(abc.ABC):
|
|
|
1130
1129
|
|
|
1131
1130
|
Returns:
|
|
1132
1131
|
str, pickled, base64, representation of the object
|
|
1132
|
+
|
|
1133
1133
|
"""
|
|
1134
1134
|
return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
|
|
1135
1135
|
|
|
@@ -1140,6 +1140,7 @@ class CryptoKey(abc.ABC):
|
|
|
1140
1140
|
|
|
1141
1141
|
Returns:
|
|
1142
1142
|
str, pickled, hexadecimal, representation of the object
|
|
1143
|
+
|
|
1143
1144
|
"""
|
|
1144
1145
|
return self.Hex()
|
|
1145
1146
|
|
|
@@ -1153,6 +1154,7 @@ class CryptoKey(abc.ABC):
|
|
|
1153
1154
|
|
|
1154
1155
|
Returns:
|
|
1155
1156
|
str, pickled, hexadecimal, representation of the object
|
|
1157
|
+
|
|
1156
1158
|
"""
|
|
1157
1159
|
return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
|
|
1158
1160
|
|
|
@@ -1163,6 +1165,7 @@ class CryptoKey(abc.ABC):
|
|
|
1163
1165
|
|
|
1164
1166
|
Returns:
|
|
1165
1167
|
str, pickled, raw escaped binary, representation of the object
|
|
1168
|
+
|
|
1166
1169
|
"""
|
|
1167
1170
|
return self.Raw()
|
|
1168
1171
|
|
|
@@ -1176,13 +1179,13 @@ class CryptoKey(abc.ABC):
|
|
|
1176
1179
|
|
|
1177
1180
|
Returns:
|
|
1178
1181
|
str, pickled, raw escaped binary, representation of the object
|
|
1182
|
+
|
|
1179
1183
|
"""
|
|
1180
1184
|
return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
|
|
1181
1185
|
|
|
1182
1186
|
@final
|
|
1183
1187
|
@classmethod
|
|
1184
|
-
def Load(
|
|
1185
|
-
cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
|
|
1188
|
+
def Load(cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
|
|
1186
1189
|
"""Load (create) object from serialized bytes or string.
|
|
1187
1190
|
|
|
1188
1191
|
Args:
|
|
@@ -1193,22 +1196,25 @@ class CryptoKey(abc.ABC):
|
|
|
1193
1196
|
|
|
1194
1197
|
Returns:
|
|
1195
1198
|
a CryptoKey object ready for use
|
|
1199
|
+
|
|
1200
|
+
Raises:
|
|
1201
|
+
InputError: decode error
|
|
1202
|
+
|
|
1196
1203
|
"""
|
|
1197
1204
|
# if this is a string, then we suppose it is base64
|
|
1198
1205
|
if isinstance(data, str):
|
|
1199
1206
|
data = BytesFromInput(data)
|
|
1200
1207
|
# we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
|
|
1201
1208
|
try:
|
|
1202
|
-
json_dict:
|
|
1203
|
-
data=data, key=key, silent=silent, unpickler=UnpickleJSON)
|
|
1209
|
+
json_dict: JSONDict = DeSerialize(data=data, key=key, silent=silent, unpickler=UnpickleJSON)
|
|
1204
1210
|
return cls._FromJSONDict(json_dict)
|
|
1205
1211
|
except Exception as err:
|
|
1206
1212
|
raise InputError(f'input decode error: {err}') from err
|
|
1207
1213
|
|
|
1208
1214
|
|
|
1209
1215
|
@runtime_checkable
|
|
1210
|
-
class Encryptor(Protocol):
|
|
1211
|
-
"""Abstract interface for a class that has encryption
|
|
1216
|
+
class Encryptor(Protocol):
|
|
1217
|
+
"""Abstract interface for a class that has encryption.
|
|
1212
1218
|
|
|
1213
1219
|
Contract:
|
|
1214
1220
|
- If algorithm accepts a `nonce` or `tag` these have to be handled internally by the
|
|
@@ -1221,9 +1227,10 @@ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1221
1227
|
Metadata like nonce/tag may be:
|
|
1222
1228
|
- returned alongside `ciphertext`/`signature`, or
|
|
1223
1229
|
- bundled/serialized into `ciphertext`/`signature` by the implementation.
|
|
1230
|
+
|
|
1224
1231
|
"""
|
|
1225
1232
|
|
|
1226
|
-
@
|
|
1233
|
+
@abstract.abstractmethod
|
|
1227
1234
|
def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1228
1235
|
"""Encrypt `plaintext` and return `ciphertext`.
|
|
1229
1236
|
|
|
@@ -1239,14 +1246,15 @@ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1239
1246
|
Raises:
|
|
1240
1247
|
InputError: invalid inputs
|
|
1241
1248
|
CryptoError: internal crypto failures
|
|
1249
|
+
|
|
1242
1250
|
"""
|
|
1243
1251
|
|
|
1244
1252
|
|
|
1245
1253
|
@runtime_checkable
|
|
1246
|
-
class Decryptor(Protocol):
|
|
1254
|
+
class Decryptor(Protocol):
|
|
1247
1255
|
"""Abstract interface for a class that has decryption (see contract/notes in Encryptor)."""
|
|
1248
1256
|
|
|
1249
|
-
@
|
|
1257
|
+
@abstract.abstractmethod
|
|
1250
1258
|
def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1251
1259
|
"""Decrypt `ciphertext` and return the original `plaintext`.
|
|
1252
1260
|
|
|
@@ -1260,16 +1268,18 @@ class Decryptor(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1260
1268
|
Raises:
|
|
1261
1269
|
InputError: invalid inputs
|
|
1262
1270
|
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1271
|
+
|
|
1263
1272
|
"""
|
|
1264
1273
|
|
|
1265
1274
|
|
|
1266
1275
|
@runtime_checkable
|
|
1267
|
-
class Verifier(Protocol):
|
|
1276
|
+
class Verifier(Protocol):
|
|
1268
1277
|
"""Abstract interface for asymmetric signature verify. (see contract/notes in Encryptor)."""
|
|
1269
1278
|
|
|
1270
|
-
@
|
|
1279
|
+
@abstract.abstractmethod
|
|
1271
1280
|
def Verify(
|
|
1272
|
-
|
|
1281
|
+
self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None
|
|
1282
|
+
) -> bool:
|
|
1273
1283
|
"""Verify a `signature` for `message`. True if OK; False if failed verification.
|
|
1274
1284
|
|
|
1275
1285
|
Args:
|
|
@@ -1283,14 +1293,15 @@ class Verifier(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1283
1293
|
Raises:
|
|
1284
1294
|
InputError: invalid inputs
|
|
1285
1295
|
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1296
|
+
|
|
1286
1297
|
"""
|
|
1287
1298
|
|
|
1288
1299
|
|
|
1289
1300
|
@runtime_checkable
|
|
1290
|
-
class Signer(Protocol):
|
|
1301
|
+
class Signer(Protocol):
|
|
1291
1302
|
"""Abstract interface for asymmetric signing. (see contract/notes in Encryptor)."""
|
|
1292
1303
|
|
|
1293
|
-
@
|
|
1304
|
+
@abstract.abstractmethod
|
|
1294
1305
|
def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
|
|
1295
1306
|
"""Sign `message` and return the `signature`.
|
|
1296
1307
|
|
|
@@ -1306,13 +1317,20 @@ class Signer(Protocol): # pylint: disable=too-few-public-methods
|
|
|
1306
1317
|
Raises:
|
|
1307
1318
|
InputError: invalid inputs
|
|
1308
1319
|
CryptoError: internal crypto failures
|
|
1320
|
+
|
|
1309
1321
|
"""
|
|
1310
1322
|
|
|
1311
1323
|
|
|
1312
|
-
def Serialize(
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1324
|
+
def Serialize[T](
|
|
1325
|
+
python_obj: T,
|
|
1326
|
+
/,
|
|
1327
|
+
*,
|
|
1328
|
+
file_path: str | None = None,
|
|
1329
|
+
compress: int | None = 3,
|
|
1330
|
+
key: Encryptor | None = None,
|
|
1331
|
+
silent: bool = False,
|
|
1332
|
+
pickler: abc.Callable[[T], bytes] = PickleGeneric,
|
|
1333
|
+
) -> bytes:
|
|
1316
1334
|
"""Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
|
|
1317
1335
|
|
|
1318
1336
|
Data path is:
|
|
@@ -1324,14 +1342,14 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1324
1342
|
|
|
1325
1343
|
Compression levels / speed can be controlled by `compress`. Use this as reference:
|
|
1326
1344
|
|
|
1327
|
-
| Level | Speed | Compression ratio
|
|
1328
|
-
| -------- | ------------|
|
|
1329
|
-
| -5 to -1 | Fastest | Poor (better than
|
|
1330
|
-
| 0…3 | Very fast | Good ratio
|
|
1331
|
-
| 4…6 | Moderate | Better ratio
|
|
1332
|
-
| 7…10 | Slower | Marginally better ratio
|
|
1333
|
-
| 11…15 | Much slower | Slight gains
|
|
1334
|
-
| 16…22 | Very slow | Tiny gains
|
|
1345
|
+
| Level | Speed | Compression ratio | Typical use case |
|
|
1346
|
+
| -------- | ------------| ------------------------| --------------------------------------- |
|
|
1347
|
+
| -5 to -1 | Fastest | Poor (better than none) | Real-time / very latency-sensitive |
|
|
1348
|
+
| 0…3 | Very fast | Good ratio | Default CLI choice, safe baseline |
|
|
1349
|
+
| 4…6 | Moderate | Better ratio | Good compromise for general persistence |
|
|
1350
|
+
| 7…10 | Slower | Marginally better ratio | Only if storage space is precious |
|
|
1351
|
+
| 11…15 | Much slower | Slight gains | Large archives, not for runtime use |
|
|
1352
|
+
| 16…22 | Very slow | Tiny gains | Archival-only, multi-GB datasets |
|
|
1335
1353
|
|
|
1336
1354
|
Args:
|
|
1337
1355
|
python_obj (Any): serializable Python object
|
|
@@ -1346,6 +1364,7 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1346
1364
|
|
|
1347
1365
|
Returns:
|
|
1348
1366
|
bytes: serialized binary data corresponding to obj + (compression) + (encryption)
|
|
1367
|
+
|
|
1349
1368
|
"""
|
|
1350
1369
|
messages: list[str] = []
|
|
1351
1370
|
with Timer('Serialization complete', emit_log=False) as tm_all:
|
|
@@ -1356,8 +1375,8 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1356
1375
|
messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
|
|
1357
1376
|
# compress, if needed
|
|
1358
1377
|
if compress is not None:
|
|
1359
|
-
compress =
|
|
1360
|
-
compress =
|
|
1378
|
+
compress = max(compress, -22)
|
|
1379
|
+
compress = min(compress, 22)
|
|
1361
1380
|
with Timer(f'COMPRESS@{compress}', emit_log=False) as tm_compress:
|
|
1362
1381
|
obj = zstandard.ZstdCompressor(level=compress).compress(obj)
|
|
1363
1382
|
if not silent:
|
|
@@ -1371,21 +1390,24 @@ def Serialize( # pylint:disable=too-many-arguments
|
|
|
1371
1390
|
# optionally save to disk
|
|
1372
1391
|
if file_path is not None:
|
|
1373
1392
|
with Timer('SAVE', emit_log=False) as tm_save:
|
|
1374
|
-
|
|
1375
|
-
file_obj.write(obj)
|
|
1393
|
+
pathlib.Path(file_path).write_bytes(obj)
|
|
1376
1394
|
if not silent:
|
|
1377
1395
|
messages.append(f' {tm_save}, to {file_path!r}')
|
|
1378
1396
|
# log and return
|
|
1379
1397
|
if not silent:
|
|
1380
|
-
logging.info(f'{tm_all}; parts:\n
|
|
1398
|
+
logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
|
|
1381
1399
|
return obj
|
|
1382
1400
|
|
|
1383
1401
|
|
|
1384
|
-
def DeSerialize(
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1402
|
+
def DeSerialize[T]( # noqa: C901
|
|
1403
|
+
*,
|
|
1404
|
+
data: bytes | None = None,
|
|
1405
|
+
file_path: str | None = None,
|
|
1406
|
+
key: Decryptor | None = None,
|
|
1407
|
+
silent: bool = False,
|
|
1408
|
+
unpickler: abc.Callable[[bytes], T] = UnpickleGeneric,
|
|
1409
|
+
) -> T:
|
|
1410
|
+
"""Load (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
|
|
1389
1411
|
|
|
1390
1412
|
Data path is:
|
|
1391
1413
|
|
|
@@ -1396,15 +1418,17 @@ def DeSerialize(
|
|
|
1396
1418
|
Compression versus no compression will be automatically detected.
|
|
1397
1419
|
|
|
1398
1420
|
Args:
|
|
1399
|
-
data (bytes, optional): if given, use this as binary data string (input);
|
|
1400
|
-
|
|
1401
|
-
file_path (str, optional): if given, use this as file path to load binary data
|
|
1402
|
-
|
|
1403
|
-
key (Decryptor, optional): if given will key.Decrypt() data before decompressing/loading
|
|
1404
|
-
|
|
1405
|
-
|
|
1421
|
+
data (bytes | None, optional): if given, use this as binary data string (input);
|
|
1422
|
+
if you use this option, `file_path` will be ignored
|
|
1423
|
+
file_path (str | None, optional): if given, use this as file path to load binary data
|
|
1424
|
+
string (input); if you use this option, `data` will be ignored. Defaults to None.
|
|
1425
|
+
key (Decryptor | None, optional): if given will key.Decrypt() data before decompressing/loading.
|
|
1426
|
+
Defaults to None.
|
|
1427
|
+
silent (bool, optional): if True will not log; default is False (will log). Defaults to False.
|
|
1428
|
+
unpickler (Callable[[bytes], Any], optional): if not given, will just be the `pickle` module;
|
|
1406
1429
|
if given will be a method to convert a `bytes` representation back to a Python object;
|
|
1407
|
-
UnpickleGeneric is the default, but another useful value is UnpickleJSON
|
|
1430
|
+
UnpickleGeneric is the default, but another useful value is UnpickleJSON.
|
|
1431
|
+
Defaults to UnpickleGeneric.
|
|
1408
1432
|
|
|
1409
1433
|
Returns:
|
|
1410
1434
|
De-Serialized Python object corresponding to data
|
|
@@ -1412,24 +1436,24 @@ def DeSerialize(
|
|
|
1412
1436
|
Raises:
|
|
1413
1437
|
InputError: invalid inputs
|
|
1414
1438
|
CryptoError: internal crypto failures, authentication failure, key mismatch, etc
|
|
1415
|
-
|
|
1439
|
+
|
|
1440
|
+
""" # noqa: DOC502
|
|
1416
1441
|
# test inputs
|
|
1417
1442
|
if (data is None and file_path is None) or (data is not None and file_path is not None):
|
|
1418
1443
|
raise InputError('you must provide only one of either `data` or `file_path`')
|
|
1419
|
-
if file_path and not
|
|
1444
|
+
if file_path and not pathlib.Path(file_path).exists():
|
|
1420
1445
|
raise InputError(f'invalid file_path: {file_path!r}')
|
|
1421
|
-
if data and len(data) < 4:
|
|
1446
|
+
if data and len(data) < 4: # noqa: PLR2004
|
|
1422
1447
|
raise InputError('invalid data: too small')
|
|
1423
1448
|
# start the pipeline
|
|
1424
|
-
obj: bytes = data
|
|
1449
|
+
obj: bytes = data or b''
|
|
1425
1450
|
messages: list[str] = [f'DATA: {HumanizedBytes(len(obj))}'] if data and not silent else []
|
|
1426
1451
|
with Timer('De-Serialization complete', emit_log=False) as tm_all:
|
|
1427
1452
|
# optionally load from disk
|
|
1428
1453
|
if file_path:
|
|
1429
|
-
assert not obj, 'should never happen: if we have a file obj should be empty'
|
|
1454
|
+
assert not obj, 'should never happen: if we have a file obj should be empty' # noqa: S101
|
|
1430
1455
|
with Timer('LOAD', emit_log=False) as tm_load:
|
|
1431
|
-
|
|
1432
|
-
obj = file_obj.read()
|
|
1456
|
+
obj = pathlib.Path(file_path).read_bytes()
|
|
1433
1457
|
if not silent:
|
|
1434
1458
|
messages.append(f' {tm_load}, {HumanizedBytes(len(obj))}, from {file_path!r}')
|
|
1435
1459
|
# decrypt, if needed
|
|
@@ -1439,24 +1463,27 @@ def DeSerialize(
|
|
|
1439
1463
|
if not silent:
|
|
1440
1464
|
messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
|
|
1441
1465
|
# decompress: we try to detect compression to determine if we must call zstandard
|
|
1442
|
-
if (
|
|
1443
|
-
|
|
1444
|
-
|
|
1466
|
+
if (
|
|
1467
|
+
len(obj) >= 4 # noqa: PLR2004
|
|
1468
|
+
and (
|
|
1469
|
+
((magic := int.from_bytes(obj[:4], 'little')) == _ZSTD_MAGIC_FRAME)
|
|
1470
|
+
or (_ZSTD_MAGIC_SKIPPABLE_MIN <= magic <= _ZSTD_MAGIC_SKIPPABLE_MAX)
|
|
1471
|
+
)
|
|
1472
|
+
):
|
|
1445
1473
|
with Timer('DECOMPRESS', emit_log=False) as tm_decompress:
|
|
1446
1474
|
obj = zstandard.ZstdDecompressor().decompress(obj)
|
|
1447
1475
|
if not silent:
|
|
1448
1476
|
messages.append(f' {tm_decompress}, {HumanizedBytes(len(obj))}')
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
messages.append(' (no compression detected)')
|
|
1477
|
+
elif not silent:
|
|
1478
|
+
messages.append(' (no compression detected)')
|
|
1452
1479
|
# create the actual object = unpickle
|
|
1453
1480
|
with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
|
|
1454
|
-
python_obj:
|
|
1481
|
+
python_obj: T = unpickler(obj)
|
|
1455
1482
|
if not silent:
|
|
1456
1483
|
messages.append(f' {tm_unpickle}')
|
|
1457
1484
|
# log and return
|
|
1458
1485
|
if not silent:
|
|
1459
|
-
logging.info(f'{tm_all}; parts:\n
|
|
1486
|
+
logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
|
|
1460
1487
|
return python_obj
|
|
1461
1488
|
|
|
1462
1489
|
|
|
@@ -1474,6 +1501,7 @@ class PublicBid512(CryptoKey):
|
|
|
1474
1501
|
Attributes:
|
|
1475
1502
|
public_key (bytes): 512-bits random value
|
|
1476
1503
|
public_hash (bytes): SHA-512 hash of (public_key || private_key || secret_bid)
|
|
1504
|
+
|
|
1477
1505
|
"""
|
|
1478
1506
|
|
|
1479
1507
|
public_key: bytes
|
|
@@ -1484,9 +1512,9 @@ class PublicBid512(CryptoKey):
|
|
|
1484
1512
|
|
|
1485
1513
|
Raises:
|
|
1486
1514
|
InputError: invalid inputs
|
|
1515
|
+
|
|
1487
1516
|
"""
|
|
1488
|
-
|
|
1489
|
-
if len(self.public_key) != 64 or len(self.public_hash) != 64:
|
|
1517
|
+
if len(self.public_key) != 64 or len(self.public_hash) != 64: # noqa: PLR2004
|
|
1490
1518
|
raise InputError(f'invalid public_key or public_hash: {self}')
|
|
1491
1519
|
|
|
1492
1520
|
def __str__(self) -> str:
|
|
@@ -1494,10 +1522,13 @@ class PublicBid512(CryptoKey):
|
|
|
1494
1522
|
|
|
1495
1523
|
Returns:
|
|
1496
1524
|
string representation of PublicBid
|
|
1525
|
+
|
|
1497
1526
|
"""
|
|
1498
|
-
return (
|
|
1499
|
-
|
|
1500
|
-
|
|
1527
|
+
return (
|
|
1528
|
+
'PublicBid512('
|
|
1529
|
+
f'public_key={BytesToEncoded(self.public_key)}, '
|
|
1530
|
+
f'public_hash={BytesToHex(self.public_hash)})'
|
|
1531
|
+
)
|
|
1501
1532
|
|
|
1502
1533
|
def VerifyBid(self, private_key: bytes, secret: bytes, /) -> bool:
|
|
1503
1534
|
"""Verify a bid. True if OK; False if failed verification.
|
|
@@ -1509,21 +1540,30 @@ class PublicBid512(CryptoKey):
|
|
|
1509
1540
|
Returns:
|
|
1510
1541
|
True if bid is valid, False otherwise
|
|
1511
1542
|
|
|
1512
|
-
Raises:
|
|
1513
|
-
InputError: invalid inputs
|
|
1514
1543
|
"""
|
|
1515
1544
|
try:
|
|
1516
1545
|
# creating the PrivateBid object will validate everything; InputError we allow to propagate
|
|
1517
1546
|
PrivateBid512(
|
|
1518
|
-
|
|
1519
|
-
|
|
1547
|
+
public_key=self.public_key,
|
|
1548
|
+
public_hash=self.public_hash,
|
|
1549
|
+
private_key=private_key,
|
|
1550
|
+
secret_bid=secret,
|
|
1551
|
+
)
|
|
1520
1552
|
return True # if we got here, all is good
|
|
1521
1553
|
except CryptoError:
|
|
1522
1554
|
return False # bid does not match the public commitment
|
|
1523
1555
|
|
|
1524
1556
|
@classmethod
|
|
1525
1557
|
def Copy(cls, other: PublicBid512, /) -> Self:
|
|
1526
|
-
"""Initialize a public bid by taking the public parts of a public/private bid.
|
|
1558
|
+
"""Initialize a public bid by taking the public parts of a public/private bid.
|
|
1559
|
+
|
|
1560
|
+
Args:
|
|
1561
|
+
other (PublicBid512): the bid to copy from
|
|
1562
|
+
|
|
1563
|
+
Returns:
|
|
1564
|
+
Self: an initialized PublicBid512
|
|
1565
|
+
|
|
1566
|
+
"""
|
|
1527
1567
|
return cls(public_key=other.public_key, public_hash=other.public_hash)
|
|
1528
1568
|
|
|
1529
1569
|
|
|
@@ -1534,6 +1574,7 @@ class PrivateBid512(PublicBid512):
|
|
|
1534
1574
|
Attributes:
|
|
1535
1575
|
private_key (bytes): 512-bits random value
|
|
1536
1576
|
secret_bid (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
|
|
1577
|
+
|
|
1537
1578
|
"""
|
|
1538
1579
|
|
|
1539
1580
|
private_key: bytes
|
|
@@ -1545,9 +1586,10 @@ class PrivateBid512(PublicBid512):
|
|
|
1545
1586
|
Raises:
|
|
1546
1587
|
InputError: invalid inputs
|
|
1547
1588
|
CryptoError: bid does not match the public commitment
|
|
1589
|
+
|
|
1548
1590
|
"""
|
|
1549
|
-
super(PrivateBid512, self).__post_init__()
|
|
1550
|
-
if len(self.private_key) != 64 or len(self.secret_bid) < 1:
|
|
1591
|
+
super(PrivateBid512, self).__post_init__()
|
|
1592
|
+
if len(self.private_key) != 64 or len(self.secret_bid) < 1: # noqa: PLR2004
|
|
1551
1593
|
raise InputError(f'invalid private_key or secret_bid: {self}')
|
|
1552
1594
|
if self.public_hash != Hash512(self.public_key + self.private_key + self.secret_bid):
|
|
1553
1595
|
raise CryptoError(f'inconsistent bid: {self}')
|
|
@@ -1557,11 +1599,14 @@ class PrivateBid512(PublicBid512):
|
|
|
1557
1599
|
|
|
1558
1600
|
Returns:
|
|
1559
1601
|
string representation of PrivateBid without leaking secrets
|
|
1602
|
+
|
|
1560
1603
|
"""
|
|
1561
|
-
return (
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1604
|
+
return (
|
|
1605
|
+
'PrivateBid512('
|
|
1606
|
+
f'{super(PrivateBid512, self).__str__()}, '
|
|
1607
|
+
f'private_key={ObfuscateSecret(self.private_key)}, '
|
|
1608
|
+
f'secret_bid={ObfuscateSecret(self.secret_bid)})'
|
|
1609
|
+
)
|
|
1565
1610
|
|
|
1566
1611
|
@classmethod
|
|
1567
1612
|
def New(cls, secret: bytes, /) -> Self:
|
|
@@ -1575,199 +1620,18 @@ class PrivateBid512(PublicBid512):
|
|
|
1575
1620
|
|
|
1576
1621
|
Raises:
|
|
1577
1622
|
InputError: invalid inputs
|
|
1623
|
+
|
|
1578
1624
|
"""
|
|
1579
1625
|
# test inputs
|
|
1580
1626
|
if len(secret) < 1:
|
|
1581
1627
|
raise InputError(f'invalid secret length: {len(secret)}')
|
|
1582
1628
|
# generate random values
|
|
1583
|
-
public_key: bytes = RandBytes(64)
|
|
1629
|
+
public_key: bytes = RandBytes(64) # 512 bits
|
|
1584
1630
|
private_key: bytes = RandBytes(64) # 512 bits
|
|
1585
1631
|
# build object
|
|
1586
1632
|
return cls(
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
def _FlagNames(a: argparse.Action, /) -> list[str]:
|
|
1594
|
-
# Positional args have empty 'option_strings'; otherwise use them (e.g., ['-v','--verbose'])
|
|
1595
|
-
if a.option_strings:
|
|
1596
|
-
return list(a.option_strings)
|
|
1597
|
-
if a.nargs:
|
|
1598
|
-
if isinstance(a.metavar, str) and a.metavar:
|
|
1599
|
-
# e.g., nargs=2, metavar='FILE'
|
|
1600
|
-
return [a.metavar]
|
|
1601
|
-
if isinstance(a.metavar, tuple):
|
|
1602
|
-
# e.g., nargs=2, metavar=('FILE1', 'FILE2')
|
|
1603
|
-
return list(a.metavar)
|
|
1604
|
-
# Otherwise, it’s a positional arg with no flags, so return the destination name
|
|
1605
|
-
return [a.dest]
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
def _ActionIsSubparser(a: argparse.Action, /) -> bool:
|
|
1609
|
-
return isinstance(a, argparse._SubParsersAction) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
def _FormatDefault(a: argparse.Action, /) -> str:
|
|
1613
|
-
if a.default is argparse.SUPPRESS:
|
|
1614
|
-
return ''
|
|
1615
|
-
if isinstance(a.default, bool):
|
|
1616
|
-
return ' (default: on)' if a.default else ''
|
|
1617
|
-
if a.default in (None, '', 0, False):
|
|
1618
|
-
return ''
|
|
1619
|
-
return f' (default: {a.default})'
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
def _FormatChoices(a: argparse.Action, /) -> str:
|
|
1623
|
-
return f' choices: {list(a.choices)}' if getattr(a, 'choices', None) else '' # type:ignore
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
def _FormatType(a: argparse.Action, /) -> str:
|
|
1627
|
-
t: Any | None = getattr(a, 'type', None)
|
|
1628
|
-
if t is None:
|
|
1629
|
-
return ''
|
|
1630
|
-
# Show clean type names (int, str, float); for callables, just say 'custom'
|
|
1631
|
-
return f' type: {t.__name__ if hasattr(t, "__name__") else "custom"}'
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
def _FormatNArgs(a: argparse.Action, /) -> str:
|
|
1635
|
-
return f' nargs: {a.nargs}' if getattr(a, 'nargs', None) not in (None, 0) else ''
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
def _RowsForActions(actions: Sequence[argparse.Action], /) -> list[tuple[str, str]]:
|
|
1639
|
-
rows: list[tuple[str, str]] = []
|
|
1640
|
-
for a in actions:
|
|
1641
|
-
if _ActionIsSubparser(a):
|
|
1642
|
-
continue
|
|
1643
|
-
# skip the built-in help action; it’s implied
|
|
1644
|
-
if getattr(a, 'help', '') == argparse.SUPPRESS or isinstance(a, argparse._HelpAction): # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1645
|
-
continue
|
|
1646
|
-
flags: str = ', '.join(_FlagNames(a))
|
|
1647
|
-
meta: str = ''.join(
|
|
1648
|
-
(_FormatType(a), _FormatNArgs(a), _FormatChoices(a), _FormatDefault(a))).strip()
|
|
1649
|
-
desc: str = (a.help or '').strip()
|
|
1650
|
-
if meta:
|
|
1651
|
-
desc = f'{desc} [{meta}]' if desc else f'[{meta}]'
|
|
1652
|
-
rows.append((flags, desc))
|
|
1653
|
-
return rows
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
def _MarkdownTable(
|
|
1657
|
-
rows: Sequence[tuple[str, str]],
|
|
1658
|
-
headers: tuple[str, str] = ('Option/Arg', 'Description'), /) -> str:
|
|
1659
|
-
if not rows:
|
|
1660
|
-
return ''
|
|
1661
|
-
out: list[str] = ['| ' + headers[0] + ' | ' + headers[1] + ' |', '|---|---|']
|
|
1662
|
-
for left, right in rows:
|
|
1663
|
-
out.append(f'| `{left}` | {right} |')
|
|
1664
|
-
return '\n'.join(out)
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
def _WalkSubcommands(
|
|
1668
|
-
parser: argparse.ArgumentParser, path: list[str] | None = None, /) -> list[
|
|
1669
|
-
tuple[list[str], argparse.ArgumentParser, Any]]:
|
|
1670
|
-
path = path or []
|
|
1671
|
-
items: list[tuple[list[str], argparse.ArgumentParser, Any]] = []
|
|
1672
|
-
# sub_action = None
|
|
1673
|
-
name: str
|
|
1674
|
-
sp: argparse.ArgumentParser
|
|
1675
|
-
for action in parser._actions: # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1676
|
-
if _ActionIsSubparser(action):
|
|
1677
|
-
# sub_action = a # type: ignore[assignment]
|
|
1678
|
-
for name, sp in action.choices.items(): # type:ignore
|
|
1679
|
-
items.append((path + [name], sp, action)) # type:ignore
|
|
1680
|
-
items.extend(_WalkSubcommands(sp, path + [name])) # type:ignore
|
|
1681
|
-
return items
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
def _HelpText(sub_parser: argparse.ArgumentParser, parent_sub_action: Any, /) -> str:
|
|
1685
|
-
if parent_sub_action is not None:
|
|
1686
|
-
for choice_action in parent_sub_action._choices_actions: # type: ignore # pylint: disable=protected-access
|
|
1687
|
-
if choice_action.dest == sub_parser.prog.split()[-1]:
|
|
1688
|
-
return choice_action.help or ''
|
|
1689
|
-
return ''
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
def GenerateCLIMarkdown( # pylint:disable=too-many-locals,too-many-statements
|
|
1693
|
-
prog: str, parser: argparse.ArgumentParser, /, *, description: str = '') -> str: # pylint: disable=too-many-locals
|
|
1694
|
-
"""Return a Markdown doc section that reflects the current _BuildParser() tree.
|
|
1695
|
-
|
|
1696
|
-
Will treat epilog strings as examples, splitting on '$$' to get multiple examples.
|
|
1697
|
-
|
|
1698
|
-
Args:
|
|
1699
|
-
prog (str): name of app, eg. 'transcrypto' or 'transcrypto.py'
|
|
1700
|
-
parser (argparse.ArgumentParser): parser to use for data
|
|
1701
|
-
description (str, optional): app description to use as intro
|
|
1702
|
-
|
|
1703
|
-
Returns:
|
|
1704
|
-
str: markdown
|
|
1705
|
-
|
|
1706
|
-
Raises:
|
|
1707
|
-
InputError: invalid app name
|
|
1708
|
-
"""
|
|
1709
|
-
prog, description = prog.strip(), description.strip()
|
|
1710
|
-
if not prog or prog not in parser.prog:
|
|
1711
|
-
raise InputError(f'invalid prog/parser.prog: {prog=}, {parser.prog=}')
|
|
1712
|
-
lines: list[str] = ['']
|
|
1713
|
-
lines.append('<!-- cspell:disable -->')
|
|
1714
|
-
lines.append('<!-- auto-generated; do not edit -->\n')
|
|
1715
|
-
# Header + global flags
|
|
1716
|
-
lines.append(f'# `{prog}` Command-Line Interface\n')
|
|
1717
|
-
lines.append(description + '\n')
|
|
1718
|
-
lines.append('Invoke with:\n')
|
|
1719
|
-
lines.append('```bash')
|
|
1720
|
-
lines.append(f'{parser.prog} <command> [sub-command] [options...]')
|
|
1721
|
-
lines.append('```\n')
|
|
1722
|
-
# Global options table
|
|
1723
|
-
global_rows: list[tuple[str, str]] = _RowsForActions(parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1724
|
-
if global_rows:
|
|
1725
|
-
lines.append('## Global Options\n')
|
|
1726
|
-
lines.append(_MarkdownTable(global_rows))
|
|
1727
|
-
lines.append('')
|
|
1728
|
-
# Top-level commands summary
|
|
1729
|
-
lines.append('## Top-Level Commands\n')
|
|
1730
|
-
# Find top-level subparsers to list available commands
|
|
1731
|
-
top_subs: list[argparse.Action] = [a for a in parser._actions if _ActionIsSubparser(a)] # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1732
|
-
for action in top_subs:
|
|
1733
|
-
for name, sp in action.choices.items(): # type: ignore[union-attr]
|
|
1734
|
-
help_text: str = ( # type:ignore
|
|
1735
|
-
sp.description or ' '.join(i.strip() for i in sp.format_usage().splitlines())).strip() # type:ignore
|
|
1736
|
-
short: str = (sp.help if hasattr(sp, 'help') else '') or '' # type:ignore
|
|
1737
|
-
help_text = short or help_text # type:ignore
|
|
1738
|
-
help_text = help_text.replace('usage: ', '').strip() # type:ignore
|
|
1739
|
-
lines.append(f'- **`{name}`** — `{help_text}`')
|
|
1740
|
-
lines.append('')
|
|
1741
|
-
if parser.epilog:
|
|
1742
|
-
lines.append('```bash')
|
|
1743
|
-
lines.append(parser.epilog)
|
|
1744
|
-
lines.append('```\n')
|
|
1745
|
-
# Detailed sections per (sub)command
|
|
1746
|
-
for path, sub_parser, parent_sub_action in _WalkSubcommands(parser):
|
|
1747
|
-
if len(path) == 1:
|
|
1748
|
-
lines.append('---\n') # horizontal rule between top-level commands
|
|
1749
|
-
header: str = ' '.join(path)
|
|
1750
|
-
lines.append(f'##{"" if len(path) == 1 else "#"} `{header}`') # (header level 3 or 4)
|
|
1751
|
-
# Usage block
|
|
1752
|
-
help_text = _HelpText(sub_parser, parent_sub_action)
|
|
1753
|
-
if help_text:
|
|
1754
|
-
lines.append(f'\n{help_text}')
|
|
1755
|
-
usage: str = sub_parser.format_usage().replace('usage: ', '').strip()
|
|
1756
|
-
lines.append('\n```bash')
|
|
1757
|
-
lines.append(str(usage))
|
|
1758
|
-
lines.append('```\n')
|
|
1759
|
-
# Options/args table
|
|
1760
|
-
rows: list[tuple[str, str]] = _RowsForActions(sub_parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1761
|
-
if rows:
|
|
1762
|
-
lines.append(_MarkdownTable(rows))
|
|
1763
|
-
lines.append('')
|
|
1764
|
-
# Examples (if any) - stored in epilog argument
|
|
1765
|
-
epilog: str = sub_parser.epilog.strip() if sub_parser.epilog else ''
|
|
1766
|
-
if epilog:
|
|
1767
|
-
lines.append('**Example:**\n')
|
|
1768
|
-
lines.append('```bash')
|
|
1769
|
-
for epilog_line in epilog.split('$$'):
|
|
1770
|
-
lines.append(f'$ {parser.prog} {epilog_line.strip()}')
|
|
1771
|
-
lines.append('```\n')
|
|
1772
|
-
# join all lines as the markdown string
|
|
1773
|
-
return ('\n'.join(lines)).strip()
|
|
1633
|
+
public_key=public_key,
|
|
1634
|
+
public_hash=Hash512(public_key + private_key + secret),
|
|
1635
|
+
private_key=private_key,
|
|
1636
|
+
secret_bid=secret,
|
|
1637
|
+
)
|