transcrypto 1.2.0__py3-none-any.whl → 1.4.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/aes.py +10 -2
- transcrypto/base.py +708 -100
- transcrypto/constants.py +1921 -0
- transcrypto/dsa.py +106 -49
- transcrypto/elgamal.py +14 -13
- transcrypto/modmath.py +108 -43
- transcrypto/profiler.py +191 -0
- transcrypto/rsa.py +17 -17
- transcrypto/safetrans.py +1231 -0
- transcrypto/sss.py +1 -3
- transcrypto/transcrypto.py +55 -206
- transcrypto-1.4.0.dist-info/METADATA +1071 -0
- transcrypto-1.4.0.dist-info/RECORD +18 -0
- transcrypto-1.2.0.dist-info/METADATA +0 -2515
- transcrypto-1.2.0.dist-info/RECORD +0 -15
- {transcrypto-1.2.0.dist-info → transcrypto-1.4.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.2.0.dist-info → transcrypto-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {transcrypto-1.2.0.dist-info → transcrypto-1.4.0.dist-info}/top_level.txt +0 -0
transcrypto/base.py
CHANGED
|
@@ -7,23 +7,32 @@
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import abc
|
|
10
|
+
import argparse
|
|
10
11
|
import base64
|
|
12
|
+
import codecs
|
|
11
13
|
import dataclasses
|
|
12
14
|
# import datetime
|
|
15
|
+
import enum
|
|
13
16
|
import functools
|
|
14
17
|
import hashlib
|
|
18
|
+
import json
|
|
15
19
|
import logging
|
|
16
20
|
import math
|
|
17
21
|
import os.path
|
|
18
22
|
import pickle
|
|
19
23
|
# import pdb
|
|
20
24
|
import secrets
|
|
25
|
+
import sys
|
|
21
26
|
import time
|
|
22
|
-
from typing import Any, Callable, final, MutableSequence, Protocol, runtime_checkable
|
|
27
|
+
from typing import Any, Callable, final, MutableSequence, Protocol, runtime_checkable
|
|
28
|
+
from typing import Sequence, Self, TypeVar
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from scipy import stats # type:ignore
|
|
23
32
|
import zstandard
|
|
24
33
|
|
|
25
34
|
__author__ = 'balparda@github.com'
|
|
26
|
-
__version__ = '1.
|
|
35
|
+
__version__ = '1.4.0' # 2026-01-13, Tue
|
|
27
36
|
__version_tuple__: tuple[int, ...] = tuple(int(v) for v in __version__.split('.'))
|
|
28
37
|
|
|
29
38
|
# MIN_TM = int( # minimum allowed timestamp
|
|
@@ -41,15 +50,44 @@ EncodedToBytes: Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.en
|
|
|
41
50
|
|
|
42
51
|
PadBytesTo: Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
43
52
|
|
|
53
|
+
# SI prefix table, powers of 1000
|
|
54
|
+
_SI_PREFIXES: dict[int, str] = {
|
|
55
|
+
-6: 'a', # atto
|
|
56
|
+
-5: 'f', # femto
|
|
57
|
+
-4: 'p', # pico
|
|
58
|
+
-3: 'n', # nano
|
|
59
|
+
-2: 'µ', # micro (unicode U+00B5)
|
|
60
|
+
-1: 'm', # milli
|
|
61
|
+
0: '', # base
|
|
62
|
+
1: 'k', # kilo
|
|
63
|
+
2: 'M', # mega
|
|
64
|
+
3: 'G', # giga
|
|
65
|
+
4: 'T', # tera
|
|
66
|
+
5: 'P', # peta
|
|
67
|
+
6: 'E', # exa
|
|
68
|
+
}
|
|
44
69
|
|
|
45
70
|
# these control the pickling of data, do NOT ever change, or you will break all databases
|
|
46
71
|
# <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
|
|
47
72
|
_PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
|
|
73
|
+
PickleGeneric: Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
|
|
74
|
+
UnpickleGeneric: Callable[[bytes], Any] = pickle.loads
|
|
75
|
+
PickleJSON: Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
|
|
76
|
+
d, separators=(',', ':')).encode('utf-8')
|
|
77
|
+
UnpickleJSON: Callable[[bytes], dict[str, Any]] = lambda b: json.loads(b.decode('utf-8'))
|
|
48
78
|
_PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
|
|
49
79
|
# these help find compressed files, do NOT change unless zstandard changes
|
|
50
80
|
_ZSTD_MAGIC_FRAME = 0xFD2FB528
|
|
51
81
|
_ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
|
|
52
82
|
_ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
|
|
83
|
+
# JSON
|
|
84
|
+
_JSON_DATACLASS_TYPES: set[str] = {
|
|
85
|
+
# native support
|
|
86
|
+
'int', 'float', 'str', 'bool',
|
|
87
|
+
'list[int]', 'list[float]', 'list[str]', 'list[bool]',
|
|
88
|
+
# need conversion/encoding
|
|
89
|
+
'bytes',
|
|
90
|
+
}
|
|
53
91
|
|
|
54
92
|
|
|
55
93
|
class Error(Exception):
|
|
@@ -64,14 +102,18 @@ class CryptoError(Error):
|
|
|
64
102
|
"""Cryptographic exception (TransCrypto)."""
|
|
65
103
|
|
|
66
104
|
|
|
67
|
-
|
|
105
|
+
class ImplementationError(Error, NotImplementedError):
|
|
106
|
+
"""This feature is not implemented yet (TransCrypto)."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-return-statements
|
|
68
110
|
"""Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
|
|
69
111
|
|
|
70
112
|
Scales the input size by powers of 1024, returning a value with the
|
|
71
113
|
appropriate IEC binary unit suffix: `B`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, `EiB`.
|
|
72
114
|
|
|
73
115
|
Args:
|
|
74
|
-
inp_sz (int): Size in bytes. Must be
|
|
116
|
+
inp_sz (int | float): Size in bytes. Must be non-negative.
|
|
75
117
|
|
|
76
118
|
Returns:
|
|
77
119
|
str: Formatted size string with up to two decimal places for units above bytes.
|
|
@@ -100,72 +142,79 @@ def HumanizedBytes(inp_sz: int, /) -> str: # pylint: disable=too-many-return-st
|
|
|
100
142
|
if inp_sz < 0:
|
|
101
143
|
raise InputError(f'input should be >=0 and got {inp_sz}')
|
|
102
144
|
if inp_sz < 1024:
|
|
103
|
-
return f'{inp_sz} B'
|
|
145
|
+
return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
|
|
104
146
|
if inp_sz < 1024 * 1024:
|
|
105
|
-
return f'{(inp_sz / 1024):0.
|
|
147
|
+
return f'{(inp_sz / 1024):0.3f} KiB'
|
|
106
148
|
if inp_sz < 1024 * 1024 * 1024:
|
|
107
|
-
return f'{(inp_sz / (1024 * 1024)):0.
|
|
149
|
+
return f'{(inp_sz / (1024 * 1024)):0.3f} MiB'
|
|
108
150
|
if inp_sz < 1024 * 1024 * 1024 * 1024:
|
|
109
|
-
return f'{(inp_sz / (1024 * 1024 * 1024)):0.
|
|
151
|
+
return f'{(inp_sz / (1024 * 1024 * 1024)):0.3f} GiB'
|
|
110
152
|
if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024:
|
|
111
|
-
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.
|
|
153
|
+
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.3f} TiB'
|
|
112
154
|
if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024 * 1024:
|
|
113
|
-
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.
|
|
114
|
-
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.
|
|
155
|
+
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.3f} PiB'
|
|
156
|
+
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
|
|
115
157
|
|
|
116
158
|
|
|
117
|
-
def HumanizedDecimal(inp_sz: int | float, unit: str = ''
|
|
118
|
-
"""Convert a numeric value into a human-readable string using metric prefixes
|
|
159
|
+
def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
|
|
160
|
+
"""Convert a numeric value into a human-readable string using SI metric prefixes.
|
|
119
161
|
|
|
120
162
|
Scales the input value by powers of 1000, returning a value with the
|
|
121
|
-
appropriate SI
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
inp_sz (int | float): Quantity to convert. Must be finite and non-negative.
|
|
126
|
-
unit (str, optional): Base unit to append to the result (e.g., `'Hz'`).
|
|
127
|
-
If given, it will be separated by a space for values <1000 and appended
|
|
128
|
-
without a space for scaled values.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
str: Formatted string with up to two decimal places for scaled values
|
|
132
|
-
and up to four decimal places for small floats.
|
|
133
|
-
|
|
134
|
-
Raises:
|
|
135
|
-
InputError: If `inp_sz` is negative or not finite.
|
|
163
|
+
appropriate SI unit prefix. Supports both large multiples (kilo, mega,
|
|
164
|
+
giga, … exa) and small sub-multiples (milli, micro, nano, pico, femto, atto).
|
|
136
165
|
|
|
137
166
|
Notes:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
167
|
+
• Uses decimal multiples: 1 k = 1000 units, 1 m = 1/1000 units.
|
|
168
|
+
• Supported large prefixes: k, M, G, T, P, E.
|
|
169
|
+
• Supported small prefixes: m, µ, n, p, f, a.
|
|
170
|
+
• Unit string is stripped of surrounding whitespace before use.
|
|
171
|
+
• Zero is returned as '0' plus unit (no prefix).
|
|
141
172
|
|
|
142
173
|
Examples:
|
|
143
174
|
>>> HumanizedDecimal(950)
|
|
144
175
|
'950'
|
|
145
176
|
>>> HumanizedDecimal(1500)
|
|
146
177
|
'1.50 k'
|
|
147
|
-
>>> HumanizedDecimal(
|
|
148
|
-
'
|
|
149
|
-
>>> HumanizedDecimal(
|
|
150
|
-
'
|
|
178
|
+
>>> HumanizedDecimal(0.123456, unit='V')
|
|
179
|
+
'123.456 mV'
|
|
180
|
+
>>> HumanizedDecimal(3.2e-7, unit='F')
|
|
181
|
+
'320.000 nF'
|
|
182
|
+
>>> HumanizedDecimal(9.14e18, unit='Hz')
|
|
183
|
+
'9.14 EHz'
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
inp_sz (int | float): Quantity to convert. Must be finite.
|
|
187
|
+
unit (str, optional): Base unit to append to the result (e.g., 'Hz', 'm').
|
|
188
|
+
If given, it will be separated by a space for unscaled values and
|
|
189
|
+
concatenated to the prefix for scaled values.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
str: Formatted string with a few decimal places
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
InputError: If `inp_sz` is not finite.
|
|
151
196
|
"""
|
|
152
|
-
if not math.isfinite(inp_sz)
|
|
153
|
-
raise InputError(f'input should
|
|
197
|
+
if not math.isfinite(inp_sz):
|
|
198
|
+
raise InputError(f'input should finite; got {inp_sz!r}')
|
|
154
199
|
unit = unit.strip()
|
|
155
|
-
if
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if inp_sz <
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
200
|
+
pad_unit: str = ' ' + unit if unit else ''
|
|
201
|
+
if inp_sz == 0:
|
|
202
|
+
return '0' + pad_unit
|
|
203
|
+
neg: str = '-' if inp_sz < 0 else ''
|
|
204
|
+
inp_sz = abs(inp_sz)
|
|
205
|
+
# Find exponent of 1000 that keeps value in [1, 1000)
|
|
206
|
+
exp: int
|
|
207
|
+
exp = int(math.floor(math.log10(abs(inp_sz)) / 3))
|
|
208
|
+
exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
|
|
209
|
+
if not exp:
|
|
210
|
+
# No scaling: use int or 4-decimal float
|
|
211
|
+
if isinstance(inp_sz, int) or inp_sz.is_integer():
|
|
212
|
+
return f'{neg}{int(inp_sz)}{pad_unit}'
|
|
213
|
+
return f'{neg}{inp_sz:0.3f}{pad_unit}'
|
|
214
|
+
# scaled
|
|
215
|
+
scaled: float = inp_sz / (1000 ** exp)
|
|
216
|
+
prefix: str = _SI_PREFIXES[exp]
|
|
217
|
+
return f'{neg}{scaled:0.3f} {prefix}{unit}'
|
|
169
218
|
|
|
170
219
|
|
|
171
220
|
def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-many-return-statements
|
|
@@ -183,11 +232,7 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
|
|
|
183
232
|
inp_secs (int | float): Time interval in seconds. Must be finite and non-negative.
|
|
184
233
|
|
|
185
234
|
Returns:
|
|
186
|
-
str: Human-readable string with the duration and unit
|
|
187
|
-
on the chosen unit:
|
|
188
|
-
- µs / ms: 3 decimal places
|
|
189
|
-
- seconds ≥1: 2 decimal places
|
|
190
|
-
- minutes, hours, days: 2 decimal places
|
|
235
|
+
str: Human-readable string with the duration and unit
|
|
191
236
|
|
|
192
237
|
Raises:
|
|
193
238
|
InputError: If `inp_secs` is negative or not finite.
|
|
@@ -217,19 +262,115 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
|
|
|
217
262
|
if not math.isfinite(inp_secs) or inp_secs < 0:
|
|
218
263
|
raise InputError(f'input should be >=0 and got {inp_secs}')
|
|
219
264
|
if inp_secs == 0:
|
|
220
|
-
return '0.
|
|
265
|
+
return '0.000 s'
|
|
221
266
|
inp_secs = float(inp_secs)
|
|
222
267
|
if inp_secs < 0.001:
|
|
223
268
|
return f'{inp_secs * 1000 * 1000:0.3f} µs'
|
|
224
269
|
if inp_secs < 1:
|
|
225
270
|
return f'{inp_secs * 1000:0.3f} ms'
|
|
226
271
|
if inp_secs < 60:
|
|
227
|
-
return f'{inp_secs:0.
|
|
272
|
+
return f'{inp_secs:0.3f} s'
|
|
228
273
|
if inp_secs < 60 * 60:
|
|
229
|
-
return f'{(inp_secs / 60):0.
|
|
274
|
+
return f'{(inp_secs / 60):0.3f} min'
|
|
230
275
|
if inp_secs < 24 * 60 * 60:
|
|
231
|
-
return f'{(inp_secs / (60 * 60)):0.
|
|
232
|
-
return f'{(inp_secs / (24 * 60 * 60)):0.
|
|
276
|
+
return f'{(inp_secs / (60 * 60)):0.3f} h'
|
|
277
|
+
return f'{(inp_secs / (24 * 60 * 60)):0.3f} d'
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def MeasurementStats(
|
|
281
|
+
data: list[int | float], /, *,
|
|
282
|
+
confidence: float = 0.95) -> tuple[int, float, float, float, tuple[float, float], float]:
|
|
283
|
+
"""Compute descriptive statistics for repeated measurements.
|
|
284
|
+
|
|
285
|
+
Given N ≥ 1 measurements, this function computes the sample mean, the
|
|
286
|
+
standard error of the mean (SEM), and the symmetric error estimate for
|
|
287
|
+
the chosen confidence interval using Student's t distribution.
|
|
288
|
+
|
|
289
|
+
Notes:
|
|
290
|
+
• If only one measurement is given, SEM and error are reported as +∞ and
|
|
291
|
+
the confidence interval is (-∞, +∞).
|
|
292
|
+
• This function assumes the underlying distribution is approximately
|
|
293
|
+
normal, or n is large enough for the Central Limit Theorem to apply.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
data (list[int | float]): Sequence of numeric measurements.
|
|
297
|
+
confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
|
|
298
|
+
defaults to 0.95 (95% confidence interval).
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
tuple:
|
|
302
|
+
- n (int): number of measurements.
|
|
303
|
+
- mean (float): arithmetic mean of the data
|
|
304
|
+
- sem (float): standard error of the mean, sigma / √n
|
|
305
|
+
- error (float): half-width of the confidence interval (mean ± error)
|
|
306
|
+
- ci (tuple[float, float]): lower and upper confidence interval bounds
|
|
307
|
+
- confidence (float): the confidence level used
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
InputError: if the input list is empty.
|
|
311
|
+
"""
|
|
312
|
+
# test inputs
|
|
313
|
+
n: int = len(data)
|
|
314
|
+
if not n:
|
|
315
|
+
raise InputError('no data')
|
|
316
|
+
if not 0.5 <= confidence < 1.0:
|
|
317
|
+
raise InputError(f'invalid confidence: {confidence=}')
|
|
318
|
+
# solve trivial case
|
|
319
|
+
if n == 1:
|
|
320
|
+
return (n, float(data[0]), math.inf, math.inf, (-math.inf, math.inf), confidence)
|
|
321
|
+
# call scipy for the science data
|
|
322
|
+
np_data = np.array(data)
|
|
323
|
+
mean = np.mean(np_data)
|
|
324
|
+
sem = stats.sem(np_data) # type:ignore
|
|
325
|
+
ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem) # type:ignore
|
|
326
|
+
t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1) # type:ignore
|
|
327
|
+
error = t_crit * sem # half-width of the CI # type:ignore
|
|
328
|
+
return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence) # type:ignore
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def HumanizedMeasurements(
|
|
332
|
+
data: list[int | float], /, *,
|
|
333
|
+
unit: str = '', parser: Callable[[float], str] | None = None,
|
|
334
|
+
clip_negative: bool = True, confidence: float = 0.95) -> str:
|
|
335
|
+
"""Render measurement statistics as a human-readable string.
|
|
336
|
+
|
|
337
|
+
Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
|
|
338
|
+
result with units, sample count, and confidence interval. Negative values
|
|
339
|
+
can optionally be clipped to zero and marked with a leading “*”.
|
|
340
|
+
|
|
341
|
+
Notes:
|
|
342
|
+
• For a single measurement, error is displayed as “± ?”.
|
|
343
|
+
• The output includes the number of samples (@n) and the confidence
|
|
344
|
+
interval unless a different confidence was requested upstream.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
data (list[int | float]): Sequence of numeric measurements.
|
|
348
|
+
unit (str, optional): Unit of measurement to append, e.g. "ms" or "s".
|
|
349
|
+
Defaults to '' (no unit).
|
|
350
|
+
parser (Callable[[float], str] | None, optional): Custom float-to-string
|
|
351
|
+
formatter. If None, values are formatted with 3 decimal places.
|
|
352
|
+
clip_negative (bool, optional): If True (default), negative values are
|
|
353
|
+
clipped to 0.0 and prefixed with '*'.
|
|
354
|
+
confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
|
|
355
|
+
defaults to 0.95 (95% confidence interval).
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
|
|
359
|
+
"""
|
|
360
|
+
n: int
|
|
361
|
+
mean: float
|
|
362
|
+
error: float
|
|
363
|
+
ci: tuple[float, float]
|
|
364
|
+
conf: float
|
|
365
|
+
unit = unit.strip()
|
|
366
|
+
n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
|
|
367
|
+
f: Callable[[float], str] = lambda x: (
|
|
368
|
+
('*0' if clip_negative and x < 0.0 else str(x)) if parser is None else
|
|
369
|
+
(f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x)))
|
|
370
|
+
if n == 1:
|
|
371
|
+
return f'{f(mean)}{unit} ±? @1'
|
|
372
|
+
pct = int(round(conf * 100))
|
|
373
|
+
return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
|
|
233
374
|
|
|
234
375
|
|
|
235
376
|
class Timer:
|
|
@@ -254,15 +395,13 @@ class Timer:
|
|
|
254
395
|
print(tm)
|
|
255
396
|
|
|
256
397
|
Attributes:
|
|
257
|
-
label (str): Timer label
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
end (float | None): End time
|
|
261
|
-
elapsed (float | None): Time delta
|
|
398
|
+
label (str, optional): Timer label
|
|
399
|
+
emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
|
|
400
|
+
emit_print (bool, optional): If True will print() the timer, else (default) will not
|
|
262
401
|
"""
|
|
263
402
|
|
|
264
403
|
def __init__(
|
|
265
|
-
self, label: str = '
|
|
404
|
+
self, label: str = '', /, *,
|
|
266
405
|
emit_log: bool = True, emit_print: bool = False) -> None:
|
|
267
406
|
"""Initialize the Timer.
|
|
268
407
|
|
|
@@ -277,19 +416,27 @@ class Timer:
|
|
|
277
416
|
self.emit_log: bool = emit_log
|
|
278
417
|
self.emit_print: bool = emit_print
|
|
279
418
|
self.label: str = label.strip()
|
|
280
|
-
if not self.label:
|
|
281
|
-
raise InputError('Empty label')
|
|
282
419
|
self.start: float | None = None
|
|
283
420
|
self.end: float | None = None
|
|
284
|
-
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def elapsed(self) -> float:
|
|
424
|
+
"""Elapsed time. Will be zero until a measurement is available with start/end."""
|
|
425
|
+
if self.start is None or self.end is None:
|
|
426
|
+
return 0.0
|
|
427
|
+
delta: float = self.end - self.start
|
|
428
|
+
if delta <= 0.0:
|
|
429
|
+
raise Error(f'negative/zero delta: {delta}')
|
|
430
|
+
return delta
|
|
285
431
|
|
|
286
432
|
def __str__(self) -> str:
|
|
287
433
|
"""Current timer value."""
|
|
288
434
|
if self.start is None:
|
|
289
|
-
return f'{self.label}: <UNSTARTED>'
|
|
290
|
-
if self.end is None
|
|
291
|
-
return f'{self.label}:
|
|
292
|
-
|
|
435
|
+
return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
|
|
436
|
+
if self.end is None:
|
|
437
|
+
return ((f'{self.label}: ' if self.label else '') +
|
|
438
|
+
f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}')
|
|
439
|
+
return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
|
|
293
440
|
|
|
294
441
|
def Start(self) -> None:
|
|
295
442
|
"""Start the timer."""
|
|
@@ -306,10 +453,9 @@ class Timer:
|
|
|
306
453
|
"""Stop the timer and emit logging.info with timer message."""
|
|
307
454
|
if self.start is None:
|
|
308
455
|
raise Error('Stopping an unstarted timer')
|
|
309
|
-
if self.end is not None
|
|
456
|
+
if self.end is not None:
|
|
310
457
|
raise Error('Re-stopping timer is forbidden')
|
|
311
458
|
self.end = time.perf_counter()
|
|
312
|
-
self.elapsed = self.end - self.start
|
|
313
459
|
message: str = str(self)
|
|
314
460
|
if self.emit_log:
|
|
315
461
|
logging.info(message)
|
|
@@ -578,6 +724,134 @@ def ObfuscateSecret(data: str | bytes | int, /) -> str:
|
|
|
578
724
|
return BytesToHex(Hash512(data))[:8] + '…'
|
|
579
725
|
|
|
580
726
|
|
|
727
|
+
class CryptoInputType(enum.StrEnum):
|
|
728
|
+
"""Types of inputs that can represent arbitrary bytes."""
|
|
729
|
+
# prefixes; format prefixes are all 4 bytes
|
|
730
|
+
PATH = '@' # @path on disk → read bytes from a file
|
|
731
|
+
STDIN = '@-' # stdin
|
|
732
|
+
HEX = 'hex:' # hex:deadbeef → decode hex
|
|
733
|
+
BASE64 = 'b64:' # b64:... → decode base64
|
|
734
|
+
STR = 'str:' # str:hello → UTF-8 encode the literal
|
|
735
|
+
RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def BytesToRaw(b: bytes, /) -> str:
|
|
739
|
+
"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
740
|
+
|
|
741
|
+
1. map bytes 0..255 to same code points (latin1)
|
|
742
|
+
2. escape non-printables/backslash/quotes via unicode_escape
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
b (bytes): input
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
str: double-quoted string with \\xNN escapes where needed
|
|
749
|
+
"""
|
|
750
|
+
inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
|
|
751
|
+
return f'"{inner.replace('"', r'\"')}"'
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def RawToBytes(s: str, /) -> bytes:
|
|
755
|
+
"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
bytes: data
|
|
762
|
+
"""
|
|
763
|
+
if len(s) >= 2 and s[0] == s[-1] == '"':
|
|
764
|
+
s = s[1:-1]
|
|
765
|
+
# decode backslash escapes to code points, then map 0..255 -> bytes
|
|
766
|
+
return codecs.decode(s, 'unicode_escape').encode('latin1')
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def DetectInputType(data_str: str, /) -> CryptoInputType | None:
|
|
770
|
+
"""Auto-detect `data_str` type, if possible.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
data_str (str): data to process, putatively a bytes blob
|
|
774
|
+
|
|
775
|
+
Returns:
|
|
776
|
+
CryptoInputType | None: type if has a known prefix, None otherwise
|
|
777
|
+
|
|
778
|
+
Raises:
|
|
779
|
+
InputError: unexpected type or conversion error
|
|
780
|
+
"""
|
|
781
|
+
data_str = data_str.strip()
|
|
782
|
+
if data_str == CryptoInputType.STDIN:
|
|
783
|
+
return CryptoInputType.STDIN
|
|
784
|
+
for t in (
|
|
785
|
+
CryptoInputType.PATH, CryptoInputType.STR, CryptoInputType.HEX,
|
|
786
|
+
CryptoInputType.BASE64, CryptoInputType.RAW):
|
|
787
|
+
if data_str.startswith(t):
|
|
788
|
+
return t
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # pylint:disable=too-many-return-statements
|
|
793
|
+
"""Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
|
|
794
|
+
|
|
795
|
+
Can load from disk ('@'). Can load from stdin ('@-').
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
data_str (str): data to process, putatively a bytes blob
|
|
799
|
+
expect (CryptoInputType | None, optional): If not given (None) will try to auto-detect the
|
|
800
|
+
input type by looking at the prefix on `data_str` and if none is found will suppose
|
|
801
|
+
a 'str:' was given; if one of the supported CryptoInputType is given then will enforce
|
|
802
|
+
that specific type prefix or no prefix
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
bytes: data
|
|
806
|
+
|
|
807
|
+
Raises:
|
|
808
|
+
InputError: unexpected type or conversion error
|
|
809
|
+
"""
|
|
810
|
+
data_str = data_str.strip()
|
|
811
|
+
# auto-detect
|
|
812
|
+
detected_type: CryptoInputType | None = DetectInputType(data_str)
|
|
813
|
+
expect = CryptoInputType.STR if expect is None and detected_type is None else expect
|
|
814
|
+
if detected_type is not None and expect is not None and detected_type != expect:
|
|
815
|
+
raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
|
|
816
|
+
# now we know they don't conflict, so unify them; remove prefix if we have it
|
|
817
|
+
expect = detected_type if expect is None else expect
|
|
818
|
+
assert expect is not None, 'should never happen: type should be known here'
|
|
819
|
+
data_str = data_str[len(expect):] if data_str.startswith(expect) else data_str
|
|
820
|
+
# for every type something different will happen now
|
|
821
|
+
try:
|
|
822
|
+
match expect:
|
|
823
|
+
case CryptoInputType.STDIN:
|
|
824
|
+
# read raw bytes from stdin: prefer the binary buffer; if unavailable,
|
|
825
|
+
# fall back to text stream encoded as UTF-8 (consistent with str: policy).
|
|
826
|
+
stream = getattr(sys.stdin, 'buffer', None)
|
|
827
|
+
if stream is None:
|
|
828
|
+
text: str = sys.stdin.read()
|
|
829
|
+
if not isinstance(text, str): # type:ignore
|
|
830
|
+
raise InputError('sys.stdin.read() produced non-text data')
|
|
831
|
+
return text.encode('utf-8')
|
|
832
|
+
data: bytes = stream.read()
|
|
833
|
+
if not isinstance(data, bytes): # type:ignore
|
|
834
|
+
raise InputError('sys.stdin.buffer.read() produced non-binary data')
|
|
835
|
+
return data
|
|
836
|
+
case CryptoInputType.PATH:
|
|
837
|
+
if not os.path.exists(data_str):
|
|
838
|
+
raise InputError(f'cannot find file {data_str!r}')
|
|
839
|
+
with open(data_str, 'rb') as file_obj:
|
|
840
|
+
return file_obj.read()
|
|
841
|
+
case CryptoInputType.STR:
|
|
842
|
+
return data_str.encode('utf-8')
|
|
843
|
+
case CryptoInputType.HEX:
|
|
844
|
+
return HexToBytes(data_str)
|
|
845
|
+
case CryptoInputType.BASE64:
|
|
846
|
+
return EncodedToBytes(data_str)
|
|
847
|
+
case CryptoInputType.RAW:
|
|
848
|
+
return RawToBytes(data_str)
|
|
849
|
+
case _:
|
|
850
|
+
raise InputError(f'invalid type {expect!r}')
|
|
851
|
+
except Exception as err:
|
|
852
|
+
raise InputError(f'invalid input: {err}') from err
|
|
853
|
+
|
|
854
|
+
|
|
581
855
|
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
582
856
|
class CryptoKey(abc.ABC):
|
|
583
857
|
"""A cryptographic key."""
|
|
@@ -593,7 +867,6 @@ class CryptoKey(abc.ABC):
|
|
|
593
867
|
string representation of the key without leaking secrets
|
|
594
868
|
"""
|
|
595
869
|
# every sub-class of CryptoKey has to implement its own version of __str__()
|
|
596
|
-
# TODO: make printing a part of the CLI
|
|
597
870
|
|
|
598
871
|
@final
|
|
599
872
|
def __repr__(self) -> str:
|
|
@@ -626,23 +899,110 @@ class CryptoKey(abc.ABC):
|
|
|
626
899
|
|
|
627
900
|
@final
|
|
628
901
|
@property
|
|
629
|
-
def
|
|
630
|
-
"""
|
|
902
|
+
def _json_dict(self) -> dict[str, Any]:
|
|
903
|
+
"""Dictionary representation of the object suitable for JSON conversion.
|
|
631
904
|
|
|
632
905
|
Returns:
|
|
633
|
-
|
|
906
|
+
dict[str, Any]: representation of the object suitable for JSON conversion
|
|
907
|
+
|
|
908
|
+
Raises:
|
|
909
|
+
ImplementationError: object has types that are not supported in JSON
|
|
634
910
|
"""
|
|
635
|
-
|
|
911
|
+
self_dict: dict[str, Any] = dataclasses.asdict(self)
|
|
912
|
+
for field in dataclasses.fields(self):
|
|
913
|
+
# check the type is OK
|
|
914
|
+
if field.type not in _JSON_DATACLASS_TYPES:
|
|
915
|
+
raise ImplementationError(
|
|
916
|
+
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}')
|
|
917
|
+
# convert types that we accept but JSON does not
|
|
918
|
+
if field.type == 'bytes':
|
|
919
|
+
self_dict[field.name] = BytesToEncoded(self_dict[field.name])
|
|
920
|
+
return self_dict
|
|
636
921
|
|
|
637
922
|
@final
|
|
638
923
|
@property
|
|
639
|
-
def
|
|
640
|
-
"""
|
|
924
|
+
def json(self) -> str:
|
|
925
|
+
"""JSON representation of the object, tightly packed, not for humans.
|
|
641
926
|
|
|
642
927
|
Returns:
|
|
643
|
-
str
|
|
928
|
+
str: JSON representation of the object, tightly packed
|
|
929
|
+
|
|
930
|
+
Raises:
|
|
931
|
+
ImplementationError: object has types that are not supported in JSON
|
|
644
932
|
"""
|
|
645
|
-
return
|
|
933
|
+
return json.dumps(self._json_dict, separators=(',', ':'))
|
|
934
|
+
|
|
935
|
+
@final
|
|
936
|
+
@property
|
|
937
|
+
def formatted_json(self) -> str:
|
|
938
|
+
"""JSON representation of the object formatted for humans.
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
str: JSON representation of the object formatted for humans
|
|
942
|
+
|
|
943
|
+
Raises:
|
|
944
|
+
ImplementationError: object has types that are not supported in JSON
|
|
945
|
+
"""
|
|
946
|
+
return json.dumps(self._json_dict, indent=4, sort_keys=True)
|
|
947
|
+
|
|
948
|
+
@final
|
|
949
|
+
@classmethod
|
|
950
|
+
def _FromJSONDict(cls, json_dict: dict[str, Any], /) -> Self:
|
|
951
|
+
"""Create object from JSON representation.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
json_dict (dict[str, Any]): JSON dict
|
|
955
|
+
|
|
956
|
+
Returns:
|
|
957
|
+
a CryptoKey object ready for use
|
|
958
|
+
|
|
959
|
+
Raises:
|
|
960
|
+
InputError: unexpected type/fields
|
|
961
|
+
"""
|
|
962
|
+
# check we got exactly the fields we needed
|
|
963
|
+
cls_fields: set[str] = set(f.name for f in dataclasses.fields(cls))
|
|
964
|
+
json_fields: set[str] = set(json_dict)
|
|
965
|
+
if cls_fields != json_fields:
|
|
966
|
+
raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
|
|
967
|
+
# reconstruct the types we meddled with inside self._json_dict
|
|
968
|
+
for field in dataclasses.fields(cls):
|
|
969
|
+
if field.type not in _JSON_DATACLASS_TYPES:
|
|
970
|
+
raise ImplementationError(
|
|
971
|
+
f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}')
|
|
972
|
+
if field.type == 'bytes':
|
|
973
|
+
json_dict[field.name] = EncodedToBytes(json_dict[field.name])
|
|
974
|
+
# build the object
|
|
975
|
+
return cls(**json_dict)
|
|
976
|
+
|
|
977
|
+
@final
|
|
978
|
+
@classmethod
|
|
979
|
+
def FromJSON(cls, json_data: str, /) -> Self:
|
|
980
|
+
"""Create object from JSON representation.
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
json_data (str): JSON string
|
|
984
|
+
|
|
985
|
+
Returns:
|
|
986
|
+
a CryptoKey object ready for use
|
|
987
|
+
|
|
988
|
+
Raises:
|
|
989
|
+
InputError: unexpected type/fields
|
|
990
|
+
"""
|
|
991
|
+
# get the dict back
|
|
992
|
+
json_dict: dict[str, Any] = json.loads(json_data)
|
|
993
|
+
if not isinstance(json_dict, dict): # type:ignore
|
|
994
|
+
raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
|
|
995
|
+
return cls._FromJSONDict(json_dict)
|
|
996
|
+
|
|
997
|
+
@final
|
|
998
|
+
@property
|
|
999
|
+
def blob(self) -> bytes:
|
|
1000
|
+
"""Serial (bytes) representation of the object.
|
|
1001
|
+
|
|
1002
|
+
Returns:
|
|
1003
|
+
bytes, pickled, representation of the object
|
|
1004
|
+
"""
|
|
1005
|
+
return self.Blob()
|
|
646
1006
|
|
|
647
1007
|
@final
|
|
648
1008
|
def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
|
|
@@ -655,7 +1015,17 @@ class CryptoKey(abc.ABC):
|
|
|
655
1015
|
Returns:
|
|
656
1016
|
bytes, pickled, representation of the object
|
|
657
1017
|
"""
|
|
658
|
-
return Serialize(self, compress=-2, key=key, silent=silent)
|
|
1018
|
+
return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
|
|
1019
|
+
|
|
1020
|
+
@final
|
|
1021
|
+
@property
|
|
1022
|
+
def encoded(self) -> str:
|
|
1023
|
+
"""Base-64 representation of the object.
|
|
1024
|
+
|
|
1025
|
+
Returns:
|
|
1026
|
+
str, pickled, base64, representation of the object
|
|
1027
|
+
"""
|
|
1028
|
+
return self.Encoded()
|
|
659
1029
|
|
|
660
1030
|
@final
|
|
661
1031
|
def Encoded(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
|
|
@@ -668,7 +1038,53 @@ class CryptoKey(abc.ABC):
|
|
|
668
1038
|
Returns:
|
|
669
1039
|
str, pickled, base64, representation of the object
|
|
670
1040
|
"""
|
|
671
|
-
return BytesToEncoded(self.Blob(key=key, silent=silent))
|
|
1041
|
+
return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
|
|
1042
|
+
|
|
1043
|
+
@final
|
|
1044
|
+
@property
|
|
1045
|
+
def hex(self) -> str:
|
|
1046
|
+
"""Hexadecimal representation of the object.
|
|
1047
|
+
|
|
1048
|
+
Returns:
|
|
1049
|
+
str, pickled, hexadecimal, representation of the object
|
|
1050
|
+
"""
|
|
1051
|
+
return self.Hex()
|
|
1052
|
+
|
|
1053
|
+
@final
|
|
1054
|
+
def Hex(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
|
|
1055
|
+
"""Hexadecimal representation of the object with more options, including encryption.
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1059
|
+
silent (bool, optional): if True (default) will not log
|
|
1060
|
+
|
|
1061
|
+
Returns:
|
|
1062
|
+
str, pickled, hexadecimal, representation of the object
|
|
1063
|
+
"""
|
|
1064
|
+
return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
|
|
1065
|
+
|
|
1066
|
+
@final
|
|
1067
|
+
@property
|
|
1068
|
+
def raw(self) -> str:
|
|
1069
|
+
"""Raw escaped binary representation of the object.
|
|
1070
|
+
|
|
1071
|
+
Returns:
|
|
1072
|
+
str, pickled, raw escaped binary, representation of the object
|
|
1073
|
+
"""
|
|
1074
|
+
return self.Raw()
|
|
1075
|
+
|
|
1076
|
+
@final
|
|
1077
|
+
def Raw(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
|
|
1078
|
+
"""Raw escaped binary representation of the object with more options, including encryption.
|
|
1079
|
+
|
|
1080
|
+
Args:
|
|
1081
|
+
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
1082
|
+
silent (bool, optional): if True (default) will not log
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
str, pickled, raw escaped binary, representation of the object
|
|
1086
|
+
"""
|
|
1087
|
+
return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
|
|
672
1088
|
|
|
673
1089
|
@final
|
|
674
1090
|
@classmethod
|
|
@@ -687,13 +1103,14 @@ class CryptoKey(abc.ABC):
|
|
|
687
1103
|
"""
|
|
688
1104
|
# if this is a string, then we suppose it is base64
|
|
689
1105
|
if isinstance(data, str):
|
|
690
|
-
data =
|
|
1106
|
+
data = BytesFromInput(data)
|
|
691
1107
|
# we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1108
|
+
try:
|
|
1109
|
+
json_dict: dict[str, Any] = DeSerialize(
|
|
1110
|
+
data=data, key=key, silent=silent, unpickler=UnpickleJSON)
|
|
1111
|
+
return cls._FromJSONDict(json_dict)
|
|
1112
|
+
except Exception as err:
|
|
1113
|
+
raise InputError(f'input decode error: {err}') from err
|
|
697
1114
|
|
|
698
1115
|
|
|
699
1116
|
@runtime_checkable
|
|
@@ -799,14 +1216,15 @@ class Signer(Protocol): # pylint: disable=too-few-public-methods
|
|
|
799
1216
|
"""
|
|
800
1217
|
|
|
801
1218
|
|
|
802
|
-
def Serialize(
|
|
1219
|
+
def Serialize( # pylint:disable=too-many-arguments
|
|
803
1220
|
python_obj: Any, /, *, file_path: str | None = None,
|
|
804
|
-
compress: int | None = 3, key: Encryptor | None = None, silent: bool = False
|
|
1221
|
+
compress: int | None = 3, key: Encryptor | None = None, silent: bool = False,
|
|
1222
|
+
pickler: Callable[[Any], bytes] = PickleGeneric) -> bytes:
|
|
805
1223
|
"""Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
|
|
806
1224
|
|
|
807
1225
|
Data path is:
|
|
808
1226
|
|
|
809
|
-
`obj` =>
|
|
1227
|
+
`obj` => [pickler] => (compress) => (encrypt) => (save to `file_path`) => return
|
|
810
1228
|
|
|
811
1229
|
At every step of the data path the data will be measured, in bytes.
|
|
812
1230
|
Every data conversion will be timed. The measurements/times will be logged (once).
|
|
@@ -829,6 +1247,9 @@ def Serialize(
|
|
|
829
1247
|
None is no compression; default is 3, which is fast, see table above for other values
|
|
830
1248
|
key (Encryptor, optional): if given will key.Encrypt() data before saving
|
|
831
1249
|
silent (bool, optional): if True will not log; default is False (will log)
|
|
1250
|
+
pickler (Callable[[Any], bytes], optional): if not given, will just be the `pickle` module;
|
|
1251
|
+
if given will be a method to convert any Python object to its `bytes` representation;
|
|
1252
|
+
PickleGeneric is the default, but another useful value is PickleJSON
|
|
832
1253
|
|
|
833
1254
|
Returns:
|
|
834
1255
|
bytes: serialized binary data corresponding to obj + (compression) + (encryption)
|
|
@@ -837,7 +1258,7 @@ def Serialize(
|
|
|
837
1258
|
with Timer('Serialization complete', emit_log=False) as tm_all:
|
|
838
1259
|
# pickle
|
|
839
1260
|
with Timer('PICKLE', emit_log=False) as tm_pickle:
|
|
840
|
-
obj: bytes =
|
|
1261
|
+
obj: bytes = pickler(python_obj)
|
|
841
1262
|
if not silent:
|
|
842
1263
|
messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
|
|
843
1264
|
# compress, if needed
|
|
@@ -869,12 +1290,13 @@ def Serialize(
|
|
|
869
1290
|
|
|
870
1291
|
def DeSerialize(
|
|
871
1292
|
*, data: bytes | None = None, file_path: str | None = None,
|
|
872
|
-
key: Decryptor | None = None, silent: bool = False
|
|
1293
|
+
key: Decryptor | None = None, silent: bool = False,
|
|
1294
|
+
unpickler: Callable[[bytes], Any] = UnpickleGeneric) -> Any:
|
|
873
1295
|
"""Loads (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
|
|
874
1296
|
|
|
875
1297
|
Data path is:
|
|
876
1298
|
|
|
877
|
-
`data` or `file_path` => (decrypt) => (decompress) =>
|
|
1299
|
+
`data` or `file_path` => (decrypt) => (decompress) => [unpickler] => return object
|
|
878
1300
|
|
|
879
1301
|
At every step of the data path the data will be measured, in bytes.
|
|
880
1302
|
Every data conversion will be timed. The measurements/times will be logged (once).
|
|
@@ -887,6 +1309,9 @@ def DeSerialize(
|
|
|
887
1309
|
if you use this option, `data` will be ignored
|
|
888
1310
|
key (Decryptor, optional): if given will key.Decrypt() data before decompressing/loading
|
|
889
1311
|
silent (bool, optional): if True will not log; default is False (will log)
|
|
1312
|
+
pickler (Callable[[bytes], Any], optional): if not given, will just be the `pickle` module;
|
|
1313
|
+
if given will be a method to convert a `bytes` representation back to a Python object;
|
|
1314
|
+
UnpickleGeneric is the default, but another useful value is UnpickleJSON
|
|
890
1315
|
|
|
891
1316
|
Returns:
|
|
892
1317
|
De-Serialized Python object corresponding to data
|
|
@@ -933,7 +1358,7 @@ def DeSerialize(
|
|
|
933
1358
|
messages.append(' (no compression detected)')
|
|
934
1359
|
# create the actual object = unpickle
|
|
935
1360
|
with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
|
|
936
|
-
python_obj: Any =
|
|
1361
|
+
python_obj: Any = unpickler(obj)
|
|
937
1362
|
if not silent:
|
|
938
1363
|
messages.append(f' {tm_unpickle}')
|
|
939
1364
|
# log and return
|
|
@@ -1070,3 +1495,186 @@ class PrivateBid512(PublicBid512):
|
|
|
1070
1495
|
public_hash=Hash512(public_key + private_key + secret),
|
|
1071
1496
|
private_key=private_key,
|
|
1072
1497
|
secret_bid=secret)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _FlagNames(a: argparse.Action, /) -> list[str]:
|
|
1501
|
+
# Positional args have empty 'option_strings'; otherwise use them (e.g., ['-v','--verbose'])
|
|
1502
|
+
if a.option_strings:
|
|
1503
|
+
return list(a.option_strings)
|
|
1504
|
+
if a.nargs:
|
|
1505
|
+
if isinstance(a.metavar, str) and a.metavar:
|
|
1506
|
+
# e.g., nargs=2, metavar='FILE'
|
|
1507
|
+
return [a.metavar]
|
|
1508
|
+
if isinstance(a.metavar, tuple):
|
|
1509
|
+
# e.g., nargs=2, metavar=('FILE1', 'FILE2')
|
|
1510
|
+
return list(a.metavar)
|
|
1511
|
+
# Otherwise, it’s a positional arg with no flags, so return the destination name
|
|
1512
|
+
return [a.dest]
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _ActionIsSubparser(a: argparse.Action, /) -> bool:
|
|
1516
|
+
return isinstance(a, argparse._SubParsersAction) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _FormatDefault(a: argparse.Action, /) -> str:
|
|
1520
|
+
if a.default is argparse.SUPPRESS:
|
|
1521
|
+
return ''
|
|
1522
|
+
if isinstance(a.default, bool):
|
|
1523
|
+
return ' (default: on)' if a.default else ''
|
|
1524
|
+
if a.default in (None, '', 0, False):
|
|
1525
|
+
return ''
|
|
1526
|
+
return f' (default: {a.default})'
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _FormatChoices(a: argparse.Action, /) -> str:
|
|
1530
|
+
return f' choices: {list(a.choices)}' if getattr(a, 'choices', None) else '' # type:ignore
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _FormatType(a: argparse.Action, /) -> str:
|
|
1534
|
+
t: Any | None = getattr(a, 'type', None)
|
|
1535
|
+
if t is None:
|
|
1536
|
+
return ''
|
|
1537
|
+
# Show clean type names (int, str, float); for callables, just say 'custom'
|
|
1538
|
+
return f' type: {t.__name__ if hasattr(t, "__name__") else "custom"}'
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _FormatNArgs(a: argparse.Action, /) -> str:
|
|
1542
|
+
return f' nargs: {a.nargs}' if getattr(a, 'nargs', None) not in (None, 0) else ''
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def _RowsForActions(actions: Sequence[argparse.Action], /) -> list[tuple[str, str]]:
|
|
1546
|
+
rows: list[tuple[str, str]] = []
|
|
1547
|
+
for a in actions:
|
|
1548
|
+
if _ActionIsSubparser(a):
|
|
1549
|
+
continue
|
|
1550
|
+
# skip the built-in help action; it’s implied
|
|
1551
|
+
if getattr(a, 'help', '') == argparse.SUPPRESS or isinstance(a, argparse._HelpAction): # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1552
|
+
continue
|
|
1553
|
+
flags: str = ', '.join(_FlagNames(a))
|
|
1554
|
+
meta: str = ''.join(
|
|
1555
|
+
(_FormatType(a), _FormatNArgs(a), _FormatChoices(a), _FormatDefault(a))).strip()
|
|
1556
|
+
desc: str = (a.help or '').strip()
|
|
1557
|
+
if meta:
|
|
1558
|
+
desc = f'{desc} [{meta}]' if desc else f'[{meta}]'
|
|
1559
|
+
rows.append((flags, desc))
|
|
1560
|
+
return rows
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _MarkdownTable(
|
|
1564
|
+
rows: Sequence[tuple[str, str]],
|
|
1565
|
+
headers: tuple[str, str] = ('Option/Arg', 'Description'), /) -> str:
|
|
1566
|
+
if not rows:
|
|
1567
|
+
return ''
|
|
1568
|
+
out: list[str] = ['| ' + headers[0] + ' | ' + headers[1] + ' |', '|---|---|']
|
|
1569
|
+
for left, right in rows:
|
|
1570
|
+
out.append(f'| `{left}` | {right} |')
|
|
1571
|
+
return '\n'.join(out)
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def _WalkSubcommands(
|
|
1575
|
+
parser: argparse.ArgumentParser, path: list[str] | None = None, /) -> list[
|
|
1576
|
+
tuple[list[str], argparse.ArgumentParser, Any]]:
|
|
1577
|
+
path = path or []
|
|
1578
|
+
items: list[tuple[list[str], argparse.ArgumentParser, Any]] = []
|
|
1579
|
+
# sub_action = None
|
|
1580
|
+
name: str
|
|
1581
|
+
sp: argparse.ArgumentParser
|
|
1582
|
+
for action in parser._actions: # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1583
|
+
if _ActionIsSubparser(action):
|
|
1584
|
+
# sub_action = a # type: ignore[assignment]
|
|
1585
|
+
for name, sp in action.choices.items(): # type:ignore
|
|
1586
|
+
items.append((path + [name], sp, action)) # type:ignore
|
|
1587
|
+
items.extend(_WalkSubcommands(sp, path + [name])) # type:ignore
|
|
1588
|
+
return items
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def _HelpText(sub_parser: argparse.ArgumentParser, parent_sub_action: Any, /) -> str:
|
|
1592
|
+
if parent_sub_action is not None:
|
|
1593
|
+
for choice_action in parent_sub_action._choices_actions: # type: ignore # pylint: disable=protected-access
|
|
1594
|
+
if choice_action.dest == sub_parser.prog.split()[-1]:
|
|
1595
|
+
return choice_action.help or ''
|
|
1596
|
+
return ''
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def GenerateCLIMarkdown( # pylint:disable=too-many-locals,too-many-statements
|
|
1600
|
+
prog: str, parser: argparse.ArgumentParser, /, *, description: str = '') -> str: # pylint: disable=too-many-locals
|
|
1601
|
+
"""Return a Markdown doc section that reflects the current _BuildParser() tree.
|
|
1602
|
+
|
|
1603
|
+
Will treat epilog strings as examples, splitting on '$$' to get multiple examples.
|
|
1604
|
+
|
|
1605
|
+
Args:
|
|
1606
|
+
prog (str): name of app, eg. 'transcrypto' or 'transcrypto.py'
|
|
1607
|
+
parser (argparse.ArgumentParser): parser to use for data
|
|
1608
|
+
description (str, optional): app description to use as intro
|
|
1609
|
+
|
|
1610
|
+
Returns:
|
|
1611
|
+
str: markdown
|
|
1612
|
+
|
|
1613
|
+
Raises:
|
|
1614
|
+
InputError: invalid app name
|
|
1615
|
+
"""
|
|
1616
|
+
prog, description = prog.strip(), description.strip()
|
|
1617
|
+
if not prog or prog not in parser.prog:
|
|
1618
|
+
raise InputError(f'invalid prog/parser.prog: {prog=}, {parser.prog=}')
|
|
1619
|
+
lines: list[str] = ['']
|
|
1620
|
+
lines.append('<!-- cspell:disable -->')
|
|
1621
|
+
lines.append('<!-- auto-generated; do not edit -->\n')
|
|
1622
|
+
# Header + global flags
|
|
1623
|
+
lines.append(f'# `{prog}` Command-Line Interface\n')
|
|
1624
|
+
lines.append(description + '\n')
|
|
1625
|
+
lines.append('Invoke with:\n')
|
|
1626
|
+
lines.append('```bash')
|
|
1627
|
+
lines.append(f'{parser.prog} <command> [sub-command] [options...]')
|
|
1628
|
+
lines.append('```\n')
|
|
1629
|
+
# Global options table
|
|
1630
|
+
global_rows: list[tuple[str, str]] = _RowsForActions(parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1631
|
+
if global_rows:
|
|
1632
|
+
lines.append('## Global Options\n')
|
|
1633
|
+
lines.append(_MarkdownTable(global_rows))
|
|
1634
|
+
lines.append('')
|
|
1635
|
+
# Top-level commands summary
|
|
1636
|
+
lines.append('## Top-Level Commands\n')
|
|
1637
|
+
# Find top-level subparsers to list available commands
|
|
1638
|
+
top_subs: list[argparse.Action] = [a for a in parser._actions if _ActionIsSubparser(a)] # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1639
|
+
for action in top_subs:
|
|
1640
|
+
for name, sp in action.choices.items(): # type: ignore[union-attr]
|
|
1641
|
+
help_text: str = ( # type:ignore
|
|
1642
|
+
sp.description or ' '.join(i.strip() for i in sp.format_usage().splitlines())).strip() # type:ignore
|
|
1643
|
+
short: str = (sp.help if hasattr(sp, 'help') else '') or '' # type:ignore
|
|
1644
|
+
help_text = short or help_text # type:ignore
|
|
1645
|
+
help_text = help_text.replace('usage: ', '').strip() # type:ignore
|
|
1646
|
+
lines.append(f'- **`{name}`** — `{help_text}`')
|
|
1647
|
+
lines.append('')
|
|
1648
|
+
if parser.epilog:
|
|
1649
|
+
lines.append('```bash')
|
|
1650
|
+
lines.append(parser.epilog)
|
|
1651
|
+
lines.append('```\n')
|
|
1652
|
+
# Detailed sections per (sub)command
|
|
1653
|
+
for path, sub_parser, parent_sub_action in _WalkSubcommands(parser):
|
|
1654
|
+
if len(path) == 1:
|
|
1655
|
+
lines.append('---\n') # horizontal rule between top-level commands
|
|
1656
|
+
header: str = ' '.join(path)
|
|
1657
|
+
lines.append(f'##{"" if len(path) == 1 else "#"} `{header}`') # (header level 3 or 4)
|
|
1658
|
+
# Usage block
|
|
1659
|
+
help_text = _HelpText(sub_parser, parent_sub_action)
|
|
1660
|
+
if help_text:
|
|
1661
|
+
lines.append(f'\n{help_text}')
|
|
1662
|
+
usage: str = sub_parser.format_usage().replace('usage: ', '').strip()
|
|
1663
|
+
lines.append('\n```bash')
|
|
1664
|
+
lines.append(str(usage))
|
|
1665
|
+
lines.append('```\n')
|
|
1666
|
+
# Options/args table
|
|
1667
|
+
rows: list[tuple[str, str]] = _RowsForActions(sub_parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
1668
|
+
if rows:
|
|
1669
|
+
lines.append(_MarkdownTable(rows))
|
|
1670
|
+
lines.append('')
|
|
1671
|
+
# Examples (if any) - stored in epilog argument
|
|
1672
|
+
epilog: str = sub_parser.epilog.strip() if sub_parser.epilog else ''
|
|
1673
|
+
if epilog:
|
|
1674
|
+
lines.append('**Example:**\n')
|
|
1675
|
+
lines.append('```bash')
|
|
1676
|
+
for epilog_line in epilog.split('$$'):
|
|
1677
|
+
lines.append(f'$ {parser.prog} {epilog_line.strip()}')
|
|
1678
|
+
lines.append('```\n')
|
|
1679
|
+
# join all lines as the markdown string
|
|
1680
|
+
return ('\n'.join(lines)).strip()
|