transcrypto 1.7.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- transcrypto/__init__.py +1 -1
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +370 -0
- transcrypto/cli/bidsecret.py +336 -0
- transcrypto/cli/clibase.py +183 -0
- transcrypto/cli/intmath.py +429 -0
- transcrypto/cli/publicalgos.py +878 -0
- transcrypto/core/__init__.py +3 -0
- transcrypto/{aes.py → core/aes.py} +17 -29
- transcrypto/core/bid.py +161 -0
- transcrypto/{dsa.py → core/dsa.py} +28 -27
- transcrypto/{elgamal.py → core/elgamal.py} +33 -32
- transcrypto/core/hashes.py +96 -0
- transcrypto/core/key.py +735 -0
- transcrypto/{modmath.py → core/modmath.py} +91 -17
- transcrypto/{rsa.py → core/rsa.py} +51 -50
- transcrypto/{sss.py → core/sss.py} +27 -26
- transcrypto/profiler.py +29 -13
- transcrypto/transcrypto.py +60 -1996
- transcrypto/utils/__init__.py +3 -0
- transcrypto/utils/base.py +72 -0
- transcrypto/utils/human.py +278 -0
- transcrypto/utils/logging.py +139 -0
- transcrypto/utils/saferandom.py +102 -0
- transcrypto/utils/stats.py +360 -0
- transcrypto/utils/timer.py +175 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/METADATA +111 -109
- transcrypto-2.0.0.dist-info/RECORD +33 -0
- transcrypto/base.py +0 -1918
- transcrypto-1.7.0.dist-info/RECORD +0 -17
- /transcrypto/{constants.py → core/constants.py} +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/entry_points.txt +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto base library."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import codecs
|
|
9
|
+
from collections import abc
|
|
10
|
+
|
|
11
|
+
# Data conversion utils
|
|
12
|
+
|
|
13
|
+
# JSON types
|
|
14
|
+
type JSONValue = bool | int | float | str | list[JSONValue] | dict[str, JSONValue] | None
|
|
15
|
+
type JSONDict = dict[str, JSONValue]
|
|
16
|
+
|
|
17
|
+
BytesToHex: abc.Callable[[bytes], str] = lambda b: b.hex()
|
|
18
|
+
BytesToInt: abc.Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
|
|
19
|
+
BytesToEncoded: abc.Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
|
|
20
|
+
|
|
21
|
+
HexToBytes: abc.Callable[[str], bytes] = bytes.fromhex
|
|
22
|
+
IntToFixedBytes: abc.Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
|
|
23
|
+
IntToBytes: abc.Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
|
|
24
|
+
IntToEncoded: abc.Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
|
|
25
|
+
EncodedToBytes: abc.Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
|
|
26
|
+
|
|
27
|
+
PadBytesTo: abc.Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Error(Exception):
|
|
31
|
+
"""TransCrypto exception."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InputError(Error):
|
|
35
|
+
"""Input exception (TransCrypto)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ImplementationError(Error, NotImplementedError):
|
|
39
|
+
"""Feature is not implemented yet (TransCrypto)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def BytesToRaw(b: bytes, /) -> str:
|
|
43
|
+
r"""Convert bytes to double-quoted string with \\xNN escapes where needed.
|
|
44
|
+
|
|
45
|
+
1. map bytes 0..255 to same code points (latin1)
|
|
46
|
+
2. escape non-printables/backslash/quotes via unicode_escape
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
b (bytes): input
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
str: double-quoted string with \\xNN escapes where needed
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
|
|
56
|
+
return f'"{inner.replace('"', r"\"")}"'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def RawToBytes(s: str, /) -> bytes:
|
|
60
|
+
r"""Convert double-quoted string with \\xNN escapes where needed to bytes.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
bytes: data
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
if len(s) >= 2 and s[0] == s[-1] == '"': # noqa: PLR2004
|
|
70
|
+
s = s[1:-1]
|
|
71
|
+
# decode backslash escapes to code points, then map 0..255 -> bytes
|
|
72
|
+
return codecs.decode(s, 'unicode_escape').encode('latin1')
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto human-readable formatting library."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from collections import abc
|
|
9
|
+
|
|
10
|
+
from transcrypto.utils import base, stats
|
|
11
|
+
|
|
12
|
+
# SI prefix table, powers of 1000
|
|
13
|
+
_SI_PREFIXES: dict[int, str] = {
|
|
14
|
+
-6: 'a', # atto
|
|
15
|
+
-5: 'f', # femto
|
|
16
|
+
-4: 'p', # pico
|
|
17
|
+
-3: 'n', # nano
|
|
18
|
+
-2: 'µ', # micro (unicode U+00B5) # noqa: RUF001
|
|
19
|
+
-1: 'm', # milli
|
|
20
|
+
0: '', # base
|
|
21
|
+
1: 'k', # kilo
|
|
22
|
+
2: 'M', # mega
|
|
23
|
+
3: 'G', # giga
|
|
24
|
+
4: 'T', # tera
|
|
25
|
+
5: 'P', # peta
|
|
26
|
+
6: 'E', # exa
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def HumanizedBytes(inp_sz: float, /) -> str: # noqa: PLR0911
|
|
31
|
+
"""Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
|
|
32
|
+
|
|
33
|
+
Scales the input size by powers of 1024, returning a value with the
|
|
34
|
+
appropriate IEC binary unit suffix: `B`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, `EiB`.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
inp_sz (int | float): Size in bytes. Must be non-negative.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
str: Formatted size string with up to two decimal places for units above bytes.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
base.InputError: If `inp_sz` is negative.
|
|
44
|
+
|
|
45
|
+
Notes:
|
|
46
|
+
- Units follow the IEC binary standard where:
|
|
47
|
+
1 KiB = 1024 bytes
|
|
48
|
+
1 MiB = 1024 KiB
|
|
49
|
+
1 GiB = 1024 MiB
|
|
50
|
+
1 TiB = 1024 GiB
|
|
51
|
+
1 PiB = 1024 TiB
|
|
52
|
+
1 EiB = 1024 PiB
|
|
53
|
+
- Values under 1024 bytes are returned as an integer with a space and `B`.
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
>>> HumanizedBytes(512)
|
|
57
|
+
'512 B'
|
|
58
|
+
>>> HumanizedBytes(2048)
|
|
59
|
+
'2.00 KiB'
|
|
60
|
+
>>> HumanizedBytes(5 * 1024**3)
|
|
61
|
+
'5.00 GiB'
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
if inp_sz < 0:
|
|
65
|
+
raise base.InputError(f'input should be >=0 and got {inp_sz}')
|
|
66
|
+
if inp_sz < 1024: # noqa: PLR2004
|
|
67
|
+
return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
|
|
68
|
+
if inp_sz < 1024 * 1024:
|
|
69
|
+
return f'{(inp_sz / 1024):0.3f} KiB'
|
|
70
|
+
if inp_sz < 1024 * 1024 * 1024:
|
|
71
|
+
return f'{(inp_sz / (1024 * 1024)):0.3f} MiB'
|
|
72
|
+
if inp_sz < 1024 * 1024 * 1024 * 1024:
|
|
73
|
+
return f'{(inp_sz / (1024 * 1024 * 1024)):0.3f} GiB'
|
|
74
|
+
if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024:
|
|
75
|
+
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.3f} TiB'
|
|
76
|
+
if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024 * 1024:
|
|
77
|
+
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.3f} PiB'
|
|
78
|
+
return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def HumanizedDecimal(inp_sz: float, /, *, unit: str = '') -> str:
|
|
82
|
+
"""Convert a numeric value into a human-readable string using SI metric prefixes.
|
|
83
|
+
|
|
84
|
+
Scales the input value by powers of 1000, returning a value with the
|
|
85
|
+
appropriate SI unit prefix. Supports both large multiples (kilo, mega,
|
|
86
|
+
giga, … exa) and small sub-multiples (milli, micro, nano, pico, femto, atto).
|
|
87
|
+
|
|
88
|
+
Notes:
|
|
89
|
+
• Uses decimal multiples: 1 k = 1000 units, 1 m = 1/1000 units.
|
|
90
|
+
• Supported large prefixes: k, M, G, T, P, E.
|
|
91
|
+
• Supported small prefixes: m, µ, n, p, f, a.
|
|
92
|
+
• Unit string is stripped of surrounding whitespace before use.
|
|
93
|
+
• Zero is returned as '0' plus unit (no prefix).
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
>>> HumanizedDecimal(950)
|
|
97
|
+
'950'
|
|
98
|
+
>>> HumanizedDecimal(1500)
|
|
99
|
+
'1.50 k'
|
|
100
|
+
>>> HumanizedDecimal(0.123456, unit='V')
|
|
101
|
+
'123.456 mV'
|
|
102
|
+
>>> HumanizedDecimal(3.2e-7, unit='F')
|
|
103
|
+
'320.000 nF'
|
|
104
|
+
>>> HumanizedDecimal(9.14e18, unit='Hz')
|
|
105
|
+
'9.14 EHz'
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
inp_sz (int | float): Quantity to convert. Must be finite.
|
|
109
|
+
unit (str, optional): Base unit to append to the result (e.g., 'Hz', 'm').
|
|
110
|
+
If given, it will be separated by a space for unscaled values and
|
|
111
|
+
concatenated to the prefix for scaled values.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
str: Formatted string with a few decimal places
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
base.InputError: If `inp_sz` is not finite.
|
|
118
|
+
|
|
119
|
+
""" # noqa: RUF002
|
|
120
|
+
if not math.isfinite(inp_sz):
|
|
121
|
+
raise base.InputError(f'input should finite; got {inp_sz!r}')
|
|
122
|
+
unit = unit.strip()
|
|
123
|
+
pad_unit: str = ' ' + unit if unit else ''
|
|
124
|
+
if inp_sz == 0:
|
|
125
|
+
return '0' + pad_unit
|
|
126
|
+
neg: str = '-' if inp_sz < 0 else ''
|
|
127
|
+
inp_sz = abs(inp_sz)
|
|
128
|
+
# Find exponent of 1000 that keeps value in [1, 1000)
|
|
129
|
+
exp: int = math.floor(math.log10(abs(inp_sz)) / 3)
|
|
130
|
+
exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
|
|
131
|
+
if not exp:
|
|
132
|
+
# No scaling: use int or 4-decimal float
|
|
133
|
+
if isinstance(inp_sz, int) or inp_sz.is_integer():
|
|
134
|
+
return f'{neg}{int(inp_sz)}{pad_unit}'
|
|
135
|
+
return f'{neg}{inp_sz:0.3f}{pad_unit}'
|
|
136
|
+
# scaled
|
|
137
|
+
scaled: float = inp_sz / (1000**exp)
|
|
138
|
+
prefix: str = _SI_PREFIXES[exp]
|
|
139
|
+
return f'{neg}{scaled:0.3f} {prefix}{unit}'
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def HumanizedSeconds(inp_secs: float, /) -> str: # noqa: PLR0911
|
|
143
|
+
"""Convert a duration in seconds into a human-readable time string.
|
|
144
|
+
|
|
145
|
+
Selects the appropriate time unit based on the duration's magnitude:
|
|
146
|
+
- microseconds (`µs`)
|
|
147
|
+
- milliseconds (`ms`)
|
|
148
|
+
- seconds (`s`)
|
|
149
|
+
- minutes (`min`)
|
|
150
|
+
- hours (`h`)
|
|
151
|
+
- days (`d`)
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
inp_secs (int | float): Time interval in seconds. Must be finite and non-negative.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
str: Human-readable string with the duration and unit
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
base.InputError: If `inp_secs` is negative or not finite.
|
|
161
|
+
|
|
162
|
+
Notes:
|
|
163
|
+
- Uses the micro sign (`µ`, U+00B5) for microseconds.
|
|
164
|
+
- Thresholds:
|
|
165
|
+
< 0.001 s → µs
|
|
166
|
+
< 1 s → ms
|
|
167
|
+
< 60 s → seconds
|
|
168
|
+
< 3600 s → minutes
|
|
169
|
+
< 86400 s → hours
|
|
170
|
+
≥ 86400 s → days
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
>>> HumanizedSeconds(0)
|
|
174
|
+
'0.00 s'
|
|
175
|
+
>>> HumanizedSeconds(0.000004)
|
|
176
|
+
'4.000 µs'
|
|
177
|
+
>>> HumanizedSeconds(0.25)
|
|
178
|
+
'250.000 ms'
|
|
179
|
+
>>> HumanizedSeconds(42)
|
|
180
|
+
'42.00 s'
|
|
181
|
+
>>> HumanizedSeconds(3661)
|
|
182
|
+
'1.02 h'
|
|
183
|
+
|
|
184
|
+
""" # noqa: RUF002
|
|
185
|
+
if not math.isfinite(inp_secs) or inp_secs < 0:
|
|
186
|
+
raise base.InputError(f'input should be >=0 and got {inp_secs}')
|
|
187
|
+
if inp_secs == 0:
|
|
188
|
+
return '0.000 s'
|
|
189
|
+
inp_secs = float(inp_secs)
|
|
190
|
+
if inp_secs < 0.001: # noqa: PLR2004
|
|
191
|
+
return f'{inp_secs * 1000 * 1000:0.3f} µs' # noqa: RUF001
|
|
192
|
+
if inp_secs < 1:
|
|
193
|
+
return f'{inp_secs * 1000:0.3f} ms'
|
|
194
|
+
if inp_secs < 60: # noqa: PLR2004
|
|
195
|
+
return f'{inp_secs:0.3f} s'
|
|
196
|
+
if inp_secs < 60 * 60:
|
|
197
|
+
return f'{(inp_secs / 60):0.3f} min'
|
|
198
|
+
if inp_secs < 24 * 60 * 60:
|
|
199
|
+
return f'{(inp_secs / (60 * 60)):0.3f} h'
|
|
200
|
+
return f'{(inp_secs / (24 * 60 * 60)):0.3f} d'
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _SigFigs(x: float, /, *, n: int = 6) -> str:
|
|
204
|
+
"""Format a float to n significant figures.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
x (float): The number to format.
|
|
208
|
+
n (int, optional): Number of significant figures. Defaults to 6.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
str: Formatted number string.
|
|
212
|
+
|
|
213
|
+
"""
|
|
214
|
+
if x == 0:
|
|
215
|
+
return '0'
|
|
216
|
+
if not math.isfinite(x):
|
|
217
|
+
return str(x)
|
|
218
|
+
# Calculate the magnitude to determine formatting
|
|
219
|
+
magnitude: int = math.floor(math.log10(abs(x)))
|
|
220
|
+
# Use scientific notation for very small or very large numbers
|
|
221
|
+
if magnitude < -4 or magnitude >= 9: # noqa: PLR2004
|
|
222
|
+
return f'{x:.{n - 1}e}'
|
|
223
|
+
# For numbers close to 1, use fixed point
|
|
224
|
+
decimal_places: int = max(0, n - 1 - magnitude)
|
|
225
|
+
return f'{x:.{decimal_places}f}'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def HumanizedMeasurements(
|
|
229
|
+
data: list[int | float],
|
|
230
|
+
/,
|
|
231
|
+
*,
|
|
232
|
+
unit: str = '',
|
|
233
|
+
parser: abc.Callable[[float], str] | None = None,
|
|
234
|
+
clip_negative: bool = True,
|
|
235
|
+
confidence: float = 0.95,
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Render measurement statistics as a human-readable string.
|
|
238
|
+
|
|
239
|
+
Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
|
|
240
|
+
result with units, sample count, and confidence interval. Negative values
|
|
241
|
+
can optionally be clipped to zero and marked with a leading “*”.
|
|
242
|
+
|
|
243
|
+
Notes:
|
|
244
|
+
• For a single measurement, error is displayed as “± ?”.
|
|
245
|
+
• The output includes the number of samples (@n) and the confidence
|
|
246
|
+
interval unless a different confidence was requested upstream.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
data (list[int | float]): Sequence of numeric measurements.
|
|
250
|
+
unit (str, optional): Unit of measurement to append, e.g. "ms" or "s".
|
|
251
|
+
Defaults to '' (no unit).
|
|
252
|
+
parser (Callable[[float], str] | None, optional): Custom float-to-string
|
|
253
|
+
formatter. If None, values are formatted with 3 decimal places.
|
|
254
|
+
clip_negative (bool, optional): If True (default), negative values are
|
|
255
|
+
clipped to 0.0 and prefixed with '*'.
|
|
256
|
+
confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
|
|
257
|
+
defaults to 0.95 (95% confidence interval).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
|
|
261
|
+
|
|
262
|
+
"""
|
|
263
|
+
n: int
|
|
264
|
+
mean: float
|
|
265
|
+
error: float
|
|
266
|
+
ci: tuple[float, float]
|
|
267
|
+
conf: float
|
|
268
|
+
unit = unit.strip()
|
|
269
|
+
n, mean, _, error, ci, conf = stats.MeasurementStats(data, confidence=confidence)
|
|
270
|
+
f: abc.Callable[[float], str] = lambda x: (
|
|
271
|
+
('*0' if clip_negative and x < 0.0 else _SigFigs(x))
|
|
272
|
+
if parser is None
|
|
273
|
+
else (f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x))
|
|
274
|
+
)
|
|
275
|
+
if n == 1:
|
|
276
|
+
return f'{f(mean)}{unit} ±? @1'
|
|
277
|
+
pct: int = round(conf * 100)
|
|
278
|
+
return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto logging library."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
from rich import console as rich_console
|
|
12
|
+
from rich import logging as rich_logging
|
|
13
|
+
|
|
14
|
+
from transcrypto.utils import base
|
|
15
|
+
|
|
16
|
+
# Logging
|
|
17
|
+
_LOG_FORMAT_NO_PROCESS: str = '%(funcName)s: %(message)s'
|
|
18
|
+
_LOG_FORMAT_WITH_PROCESS: str = '%(processName)s/' + _LOG_FORMAT_NO_PROCESS
|
|
19
|
+
_LOG_FORMAT_DATETIME: str = '[%Y%m%d-%H:%M:%S]' # e.g., [20240131-13:45:30]
|
|
20
|
+
_LOG_LEVELS: dict[int, int] = {
|
|
21
|
+
0: logging.ERROR,
|
|
22
|
+
1: logging.WARNING,
|
|
23
|
+
2: logging.INFO,
|
|
24
|
+
3: logging.DEBUG,
|
|
25
|
+
}
|
|
26
|
+
_LOG_COMMON_PROVIDERS: set[str] = {
|
|
27
|
+
'werkzeug',
|
|
28
|
+
'gunicorn.error',
|
|
29
|
+
'gunicorn.access',
|
|
30
|
+
'uvicorn',
|
|
31
|
+
'uvicorn.error',
|
|
32
|
+
'uvicorn.access',
|
|
33
|
+
'django.server',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
__console_lock: threading.RLock = threading.RLock()
|
|
37
|
+
__console_singleton: rich_console.Console | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def Console() -> rich_console.Console:
|
|
41
|
+
"""Get the global console instance.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
rich.console.Console: The global console instance.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
with __console_lock:
|
|
48
|
+
if __console_singleton is None:
|
|
49
|
+
return rich_console.Console() # fallback console if InitLogging hasn't been called yet
|
|
50
|
+
return __console_singleton
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ResetConsole() -> None:
|
|
54
|
+
"""Reset the global console instance."""
|
|
55
|
+
global __console_singleton # noqa: PLW0603
|
|
56
|
+
with __console_lock:
|
|
57
|
+
__console_singleton = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def InitLogging(
|
|
61
|
+
verbosity: int,
|
|
62
|
+
/,
|
|
63
|
+
*,
|
|
64
|
+
include_process: bool = False,
|
|
65
|
+
soft_wrap: bool = False,
|
|
66
|
+
color: bool | None = False,
|
|
67
|
+
) -> tuple[rich_console.Console, int, bool]:
|
|
68
|
+
"""Initialize logger (with RichHandler) and get a rich.console.Console singleton.
|
|
69
|
+
|
|
70
|
+
This method will also return the actual decided values for verbosity and color use.
|
|
71
|
+
If you have a CLI app that uses this, its pytests should call `ResetConsole()` in a fixture, like:
|
|
72
|
+
|
|
73
|
+
from transcrypto.utils import logging
|
|
74
|
+
@pytest.fixture(autouse=True)
|
|
75
|
+
def _reset_base_logging() -> Generator[None, None, None]: # type: ignore
|
|
76
|
+
logging.ResetConsole()
|
|
77
|
+
yield # stop
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
verbosity (int): Logging verbosity level: 0==ERROR, 1==WARNING, 2==INFO, 3==DEBUG
|
|
81
|
+
include_process (bool, optional): Whether to include process name in log output.
|
|
82
|
+
soft_wrap (bool, optional): Whether to enable soft wrapping in the console.
|
|
83
|
+
Default is False, and it means rich will hard-wrap long lines (by adding line breaks).
|
|
84
|
+
color (bool | None, optional): Whether to enable/disable color output in the console.
|
|
85
|
+
If None, respects NO_COLOR env var.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
tuple[rich_console.Console, int, bool]:
|
|
89
|
+
(The initialized console instance, actual log level, actual color use)
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
base.Error: if you call this more than once
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
global __console_singleton # noqa: PLW0603
|
|
96
|
+
with __console_lock:
|
|
97
|
+
if __console_singleton is not None:
|
|
98
|
+
raise base.Error(
|
|
99
|
+
'calling InitLogging() more than once is forbidden; '
|
|
100
|
+
'use Console() to get a console after first creation'
|
|
101
|
+
)
|
|
102
|
+
# set level
|
|
103
|
+
logging_level: int = _LOG_LEVELS.get(min(verbosity, 3), logging.ERROR)
|
|
104
|
+
# respect NO_COLOR unless the caller has already decided (treat env presence as "disable color")
|
|
105
|
+
no_color: bool = (
|
|
106
|
+
False
|
|
107
|
+
if (os.getenv('NO_COLOR') is None and color is None)
|
|
108
|
+
else ((os.getenv('NO_COLOR') is not None) if color is None else (not color))
|
|
109
|
+
)
|
|
110
|
+
# create console and configure logging
|
|
111
|
+
console = rich_console.Console(soft_wrap=soft_wrap, no_color=no_color)
|
|
112
|
+
logging.basicConfig(
|
|
113
|
+
level=logging_level,
|
|
114
|
+
format=_LOG_FORMAT_WITH_PROCESS if include_process else _LOG_FORMAT_NO_PROCESS,
|
|
115
|
+
datefmt=_LOG_FORMAT_DATETIME,
|
|
116
|
+
handlers=[
|
|
117
|
+
rich_logging.RichHandler( # we show name/line, but want time & level
|
|
118
|
+
console=console,
|
|
119
|
+
rich_tracebacks=True,
|
|
120
|
+
show_time=True,
|
|
121
|
+
show_level=True,
|
|
122
|
+
show_path=True,
|
|
123
|
+
),
|
|
124
|
+
],
|
|
125
|
+
force=True, # force=True to override any previous logging config
|
|
126
|
+
)
|
|
127
|
+
# configure common loggers
|
|
128
|
+
logging.captureWarnings(True)
|
|
129
|
+
for name in _LOG_COMMON_PROVIDERS:
|
|
130
|
+
log: logging.Logger = logging.getLogger(name)
|
|
131
|
+
log.handlers.clear()
|
|
132
|
+
log.propagate = True
|
|
133
|
+
log.setLevel(logging_level)
|
|
134
|
+
__console_singleton = console # need a global statement to re-bind this one
|
|
135
|
+
logging.info(
|
|
136
|
+
f'Logging initialized at level {logging.getLevelName(logging_level)} / '
|
|
137
|
+
f'{"NO " if no_color else ""}COLOR'
|
|
138
|
+
)
|
|
139
|
+
return (console, logging_level, not no_color)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto safe random number generation (RNG) library."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import secrets
|
|
8
|
+
from collections import abc
|
|
9
|
+
|
|
10
|
+
from transcrypto.utils import base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def RandBits(n_bits: int, /) -> int:
|
|
14
|
+
"""Crypto-random integer with guaranteed `n_bits` size (i.e., first bit == 1).
|
|
15
|
+
|
|
16
|
+
The fact that the first bit will be 1 means the entropy is ~ (n_bits-1) and
|
|
17
|
+
because of this we only allow for a byte or more bits generated. This drawback
|
|
18
|
+
is negligible for the large integers a crypto library will work with, in practice.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
n_bits (int): number of bits to produce, ≥ 8
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
int with n_bits size
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
base.InputError: invalid n_bits
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
# test inputs
|
|
31
|
+
if n_bits < 8: # noqa: PLR2004
|
|
32
|
+
raise base.InputError(f'n_bits must be ≥ 8: {n_bits}')
|
|
33
|
+
# call underlying method
|
|
34
|
+
n: int = 0
|
|
35
|
+
while n.bit_length() != n_bits:
|
|
36
|
+
n = secrets.randbits(n_bits) # we could just set the bit, but IMO it is better to get another
|
|
37
|
+
return n
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def RandInt(min_int: int, max_int: int, /) -> int:
|
|
41
|
+
"""Crypto-random integer uniform over [min_int, max_int].
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
min_int (int): minimum integer, inclusive, ≥ 0
|
|
45
|
+
max_int (int): maximum integer, inclusive, > min_int
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
int between [min_int, max_int] inclusive
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
base.InputError: invalid min/max
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
# test inputs
|
|
55
|
+
if min_int < 0 or min_int >= max_int:
|
|
56
|
+
raise base.InputError(f'min_int must be ≥ 0, and < max_int: {min_int} / {max_int}')
|
|
57
|
+
# uniform over [min_int, max_int]
|
|
58
|
+
span: int = max_int - min_int + 1
|
|
59
|
+
n: int = min_int + secrets.randbelow(span)
|
|
60
|
+
assert min_int <= n <= max_int, 'should never happen: generated number out of range' # noqa: S101
|
|
61
|
+
return n
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def RandShuffle[T](seq: abc.MutableSequence[T], /) -> None:
|
|
65
|
+
"""In-place Crypto-random shuffle order for `seq` mutable sequence.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
seq (MutableSequence[T]): any mutable sequence with 2 or more elements
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
base.InputError: not enough elements
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
# test inputs
|
|
75
|
+
if (n_seq := len(seq)) < 2: # noqa: PLR2004
|
|
76
|
+
raise base.InputError(f'seq must have 2 or more elements: {n_seq}')
|
|
77
|
+
# cryptographically sound Fisher-Yates using secrets.randbelow
|
|
78
|
+
for i in range(n_seq - 1, 0, -1):
|
|
79
|
+
j: int = secrets.randbelow(i + 1)
|
|
80
|
+
seq[i], seq[j] = seq[j], seq[i]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def RandBytes(n_bytes: int, /) -> bytes:
|
|
84
|
+
"""Crypto-random `n_bytes` bytes. Just plain good quality random bytes.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
n_bytes (int): number of bits to produce, > 0
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
bytes: random with len()==n_bytes
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
base.InputError: invalid n_bytes
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
# test inputs
|
|
97
|
+
if n_bytes < 1:
|
|
98
|
+
raise base.InputError(f'n_bytes must be ≥ 1: {n_bytes}')
|
|
99
|
+
# return from system call
|
|
100
|
+
b: bytes = secrets.token_bytes(n_bytes)
|
|
101
|
+
assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size' # noqa: S101
|
|
102
|
+
return b
|