transcrypto 1.8.0__py3-none-any.whl → 2.0.3__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.
@@ -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