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.
Files changed (34) hide show
  1. transcrypto/__init__.py +1 -1
  2. transcrypto/cli/__init__.py +3 -0
  3. transcrypto/cli/aeshash.py +370 -0
  4. transcrypto/cli/bidsecret.py +336 -0
  5. transcrypto/cli/clibase.py +183 -0
  6. transcrypto/cli/intmath.py +429 -0
  7. transcrypto/cli/publicalgos.py +878 -0
  8. transcrypto/core/__init__.py +3 -0
  9. transcrypto/{aes.py → core/aes.py} +17 -29
  10. transcrypto/core/bid.py +161 -0
  11. transcrypto/{dsa.py → core/dsa.py} +28 -27
  12. transcrypto/{elgamal.py → core/elgamal.py} +33 -32
  13. transcrypto/core/hashes.py +96 -0
  14. transcrypto/core/key.py +735 -0
  15. transcrypto/{modmath.py → core/modmath.py} +91 -17
  16. transcrypto/{rsa.py → core/rsa.py} +51 -50
  17. transcrypto/{sss.py → core/sss.py} +27 -26
  18. transcrypto/profiler.py +29 -13
  19. transcrypto/transcrypto.py +60 -1996
  20. transcrypto/utils/__init__.py +3 -0
  21. transcrypto/utils/base.py +72 -0
  22. transcrypto/utils/human.py +278 -0
  23. transcrypto/utils/logging.py +139 -0
  24. transcrypto/utils/saferandom.py +102 -0
  25. transcrypto/utils/stats.py +360 -0
  26. transcrypto/utils/timer.py +175 -0
  27. {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/METADATA +111 -109
  28. transcrypto-2.0.0.dist-info/RECORD +33 -0
  29. transcrypto/base.py +0 -1918
  30. transcrypto-1.7.0.dist-info/RECORD +0 -17
  31. /transcrypto/{constants.py → core/constants.py} +0 -0
  32. {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/WHEEL +0 -0
  33. {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/entry_points.txt +0 -0
  34. {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/licenses/LICENSE +0 -0
transcrypto/base.py DELETED
@@ -1,1918 +0,0 @@
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 abc as abstract
8
- import base64
9
- import codecs
10
- import dataclasses
11
- import datetime
12
- import enum
13
- import functools
14
- import hashlib
15
- import json
16
- import logging
17
- import math
18
- import os
19
- import pathlib
20
- import pickle # noqa: S403
21
- import secrets
22
- import sys
23
- import threading
24
- import time
25
- from collections import abc
26
- from types import TracebackType
27
- from typing import (
28
- Any,
29
- Protocol,
30
- Self,
31
- TypeVar,
32
- cast,
33
- final,
34
- runtime_checkable,
35
- )
36
-
37
- import click
38
- import numpy as np
39
- import typer
40
- import zstandard
41
- from click import testing as click_testing
42
- from rich import console as rich_console
43
- from rich import logging as rich_logging
44
- from scipy import stats
45
-
46
- # Data conversion utils
47
-
48
- BytesToHex: abc.Callable[[bytes], str] = lambda b: b.hex()
49
- BytesToInt: abc.Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
50
- BytesToEncoded: abc.Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
51
-
52
- HexToBytes: abc.Callable[[str], bytes] = bytes.fromhex
53
- IntToFixedBytes: abc.Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
54
- IntToBytes: abc.Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
55
- IntToEncoded: abc.Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
56
- EncodedToBytes: abc.Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
57
-
58
- PadBytesTo: abc.Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
59
-
60
- # Time utils
61
-
62
- MIN_TM = int(datetime.datetime(2000, 1, 1, 0, 0, 0, tzinfo=datetime.UTC).timestamp())
63
- TIME_FORMAT = '%Y/%b/%d-%H:%M:%S-UTC'
64
- TimeStr: abc.Callable[[int | float | None], str] = lambda tm: (
65
- time.strftime(TIME_FORMAT, time.gmtime(tm)) if tm else '-'
66
- )
67
- Now: abc.Callable[[], int] = lambda: int(time.time())
68
- StrNow: abc.Callable[[], str] = lambda: TimeStr(Now())
69
-
70
- # Logging
71
- _LOG_FORMAT_NO_PROCESS: str = '%(funcName)s: %(message)s'
72
- _LOG_FORMAT_WITH_PROCESS: str = '%(processName)s/' + _LOG_FORMAT_NO_PROCESS
73
- _LOG_FORMAT_DATETIME: str = '[%Y%m%d-%H:%M:%S]' # e.g., [20240131-13:45:30]
74
- _LOG_LEVELS: dict[int, int] = {
75
- 0: logging.ERROR,
76
- 1: logging.WARNING,
77
- 2: logging.INFO,
78
- 3: logging.DEBUG,
79
- }
80
- _LOG_COMMON_PROVIDERS: set[str] = {
81
- 'werkzeug',
82
- 'gunicorn.error',
83
- 'gunicorn.access',
84
- 'uvicorn',
85
- 'uvicorn.error',
86
- 'uvicorn.access',
87
- 'django.server',
88
- }
89
-
90
- # SI prefix table, powers of 1000
91
- _SI_PREFIXES: dict[int, str] = {
92
- -6: 'a', # atto
93
- -5: 'f', # femto
94
- -4: 'p', # pico
95
- -3: 'n', # nano
96
- -2: 'µ', # micro (unicode U+00B5) # noqa: RUF001
97
- -1: 'm', # milli
98
- 0: '', # base
99
- 1: 'k', # kilo
100
- 2: 'M', # mega
101
- 3: 'G', # giga
102
- 4: 'T', # tera
103
- 5: 'P', # peta
104
- 6: 'E', # exa
105
- }
106
-
107
- # these control the pickling of data, do NOT ever change, or you will break all databases
108
- # <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
109
- _PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
110
- PickleGeneric: abc.Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
111
- UnpickleGeneric: abc.Callable[[bytes], Any] = pickle.loads # noqa: S301
112
- PickleJSON: abc.Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
113
- d, separators=(',', ':')
114
- ).encode('utf-8')
115
- UnpickleJSON: abc.Callable[[bytes], dict[str, Any]] = lambda b: json.loads(b.decode('utf-8'))
116
- _PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
117
- # these help find compressed files, do NOT change unless zstandard changes
118
- _ZSTD_MAGIC_FRAME = 0xFD2FB528
119
- _ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
120
- _ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
121
- # JSON
122
- _JSON_DATACLASS_TYPES: set[str] = {
123
- # native support
124
- 'int',
125
- 'float',
126
- 'str',
127
- 'bool',
128
- 'list[int]',
129
- 'list[float]',
130
- 'list[str]',
131
- 'list[bool]',
132
- # need conversion/encoding
133
- 'bytes',
134
- }
135
-
136
-
137
- class Error(Exception):
138
- """TransCrypto exception."""
139
-
140
-
141
- class InputError(Error):
142
- """Input exception (TransCrypto)."""
143
-
144
-
145
- class CryptoError(Error):
146
- """Cryptographic exception (TransCrypto)."""
147
-
148
-
149
- class ImplementationError(Error, NotImplementedError):
150
- """Feature is not implemented yet (TransCrypto)."""
151
-
152
-
153
- __console_lock: threading.RLock = threading.RLock()
154
- __console_singleton: rich_console.Console | None = None
155
-
156
-
157
- def Console() -> rich_console.Console:
158
- """Get the global console instance.
159
-
160
- Returns:
161
- rich.console.Console: The global console instance.
162
-
163
- """
164
- with __console_lock:
165
- if __console_singleton is None:
166
- return rich_console.Console() # fallback console if InitLogging hasn't been called yet
167
- return __console_singleton
168
-
169
-
170
- def ResetConsole() -> None:
171
- """Reset the global console instance."""
172
- global __console_singleton # noqa: PLW0603
173
- with __console_lock:
174
- __console_singleton = None
175
-
176
-
177
- def InitLogging(
178
- verbosity: int,
179
- /,
180
- *,
181
- include_process: bool = False,
182
- soft_wrap: bool = False,
183
- color: bool | None = False,
184
- ) -> tuple[rich_console.Console, int, bool]:
185
- """Initialize logger (with RichHandler) and get a rich.console.Console singleton.
186
-
187
- This method will also return the actual decided values for verbosity and color use.
188
- If you have a CLI app that uses this, its pytests should call `ResetConsole()` in a fixture, like:
189
-
190
- from transcrypto import logging
191
- @pytest.fixture(autouse=True)
192
- def _reset_base_logging() -> Generator[None, None, None]: # type: ignore
193
- logging.ResetConsole()
194
- yield # stop
195
-
196
- Args:
197
- verbosity (int): Logging verbosity level: 0==ERROR, 1==WARNING, 2==INFO, 3==DEBUG
198
- include_process (bool, optional): Whether to include process name in log output.
199
- soft_wrap (bool, optional): Whether to enable soft wrapping in the console.
200
- Default is False, and it means rich will hard-wrap long lines (by adding line breaks).
201
- color (bool | None, optional): Whether to enable/disable color output in the console.
202
- If None, respects NO_COLOR env var.
203
-
204
- Returns:
205
- tuple[rich_console.Console, int, bool]:
206
- (The initialized console instance, actual log level, actual color use)
207
-
208
- Raises:
209
- RuntimeError: if you call this more than once
210
-
211
- """
212
- global __console_singleton # noqa: PLW0603
213
- with __console_lock:
214
- if __console_singleton is not None:
215
- raise RuntimeError(
216
- 'calling InitLogging() more than once is forbidden; '
217
- 'use Console() to get a console after first creation'
218
- )
219
- # set level
220
- logging_level: int = _LOG_LEVELS.get(min(verbosity, 3), logging.ERROR)
221
- # respect NO_COLOR unless the caller has already decided (treat env presence as "disable color")
222
- no_color: bool = (
223
- False
224
- if (os.getenv('NO_COLOR') is None and color is None)
225
- else ((os.getenv('NO_COLOR') is not None) if color is None else (not color))
226
- )
227
- # create console and configure logging
228
- console = rich_console.Console(soft_wrap=soft_wrap, no_color=no_color)
229
- logging.basicConfig(
230
- level=logging_level,
231
- format=_LOG_FORMAT_WITH_PROCESS if include_process else _LOG_FORMAT_NO_PROCESS,
232
- datefmt=_LOG_FORMAT_DATETIME,
233
- handlers=[
234
- rich_logging.RichHandler( # we show name/line, but want time & level
235
- console=console,
236
- rich_tracebacks=True,
237
- show_time=True,
238
- show_level=True,
239
- show_path=True,
240
- ),
241
- ],
242
- force=True, # force=True to override any previous logging config
243
- )
244
- # configure common loggers
245
- logging.captureWarnings(True)
246
- for name in _LOG_COMMON_PROVIDERS:
247
- log: logging.Logger = logging.getLogger(name)
248
- log.handlers.clear()
249
- log.propagate = True
250
- log.setLevel(logging_level)
251
- __console_singleton = console # need a global statement to re-bind this one
252
- logging.info(
253
- f'Logging initialized at level {logging.getLevelName(logging_level)} / '
254
- f'{"NO " if no_color else ""}COLOR'
255
- )
256
- return (console, logging_level, not no_color)
257
-
258
-
259
- def HumanizedBytes(inp_sz: float, /) -> str: # noqa: PLR0911
260
- """Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
261
-
262
- Scales the input size by powers of 1024, returning a value with the
263
- appropriate IEC binary unit suffix: `B`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, `EiB`.
264
-
265
- Args:
266
- inp_sz (int | float): Size in bytes. Must be non-negative.
267
-
268
- Returns:
269
- str: Formatted size string with up to two decimal places for units above bytes.
270
-
271
- Raises:
272
- InputError: If `inp_sz` is negative.
273
-
274
- Notes:
275
- - Units follow the IEC binary standard where:
276
- 1 KiB = 1024 bytes
277
- 1 MiB = 1024 KiB
278
- 1 GiB = 1024 MiB
279
- 1 TiB = 1024 GiB
280
- 1 PiB = 1024 TiB
281
- 1 EiB = 1024 PiB
282
- - Values under 1024 bytes are returned as an integer with a space and `B`.
283
-
284
- Examples:
285
- >>> HumanizedBytes(512)
286
- '512 B'
287
- >>> HumanizedBytes(2048)
288
- '2.00 KiB'
289
- >>> HumanizedBytes(5 * 1024**3)
290
- '5.00 GiB'
291
-
292
- """
293
- if inp_sz < 0:
294
- raise InputError(f'input should be >=0 and got {inp_sz}')
295
- if inp_sz < 1024: # noqa: PLR2004
296
- return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
297
- if inp_sz < 1024 * 1024:
298
- return f'{(inp_sz / 1024):0.3f} KiB'
299
- if inp_sz < 1024 * 1024 * 1024:
300
- return f'{(inp_sz / (1024 * 1024)):0.3f} MiB'
301
- if inp_sz < 1024 * 1024 * 1024 * 1024:
302
- return f'{(inp_sz / (1024 * 1024 * 1024)):0.3f} GiB'
303
- if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024:
304
- return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.3f} TiB'
305
- if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024 * 1024:
306
- return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.3f} PiB'
307
- return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
308
-
309
-
310
- def HumanizedDecimal(inp_sz: float, /, *, unit: str = '') -> str:
311
- """Convert a numeric value into a human-readable string using SI metric prefixes.
312
-
313
- Scales the input value by powers of 1000, returning a value with the
314
- appropriate SI unit prefix. Supports both large multiples (kilo, mega,
315
- giga, … exa) and small sub-multiples (milli, micro, nano, pico, femto, atto).
316
-
317
- Notes:
318
- • Uses decimal multiples: 1 k = 1000 units, 1 m = 1/1000 units.
319
- • Supported large prefixes: k, M, G, T, P, E.
320
- • Supported small prefixes: m, µ, n, p, f, a.
321
- • Unit string is stripped of surrounding whitespace before use.
322
- • Zero is returned as '0' plus unit (no prefix).
323
-
324
- Examples:
325
- >>> HumanizedDecimal(950)
326
- '950'
327
- >>> HumanizedDecimal(1500)
328
- '1.50 k'
329
- >>> HumanizedDecimal(0.123456, unit='V')
330
- '123.456 mV'
331
- >>> HumanizedDecimal(3.2e-7, unit='F')
332
- '320.000 nF'
333
- >>> HumanizedDecimal(9.14e18, unit='Hz')
334
- '9.14 EHz'
335
-
336
- Args:
337
- inp_sz (int | float): Quantity to convert. Must be finite.
338
- unit (str, optional): Base unit to append to the result (e.g., 'Hz', 'm').
339
- If given, it will be separated by a space for unscaled values and
340
- concatenated to the prefix for scaled values.
341
-
342
- Returns:
343
- str: Formatted string with a few decimal places
344
-
345
- Raises:
346
- InputError: If `inp_sz` is not finite.
347
-
348
- """ # noqa: RUF002
349
- if not math.isfinite(inp_sz):
350
- raise InputError(f'input should finite; got {inp_sz!r}')
351
- unit = unit.strip()
352
- pad_unit: str = ' ' + unit if unit else ''
353
- if inp_sz == 0:
354
- return '0' + pad_unit
355
- neg: str = '-' if inp_sz < 0 else ''
356
- inp_sz = abs(inp_sz)
357
- # Find exponent of 1000 that keeps value in [1, 1000)
358
- exp: int = math.floor(math.log10(abs(inp_sz)) / 3)
359
- exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
360
- if not exp:
361
- # No scaling: use int or 4-decimal float
362
- if isinstance(inp_sz, int) or inp_sz.is_integer():
363
- return f'{neg}{int(inp_sz)}{pad_unit}'
364
- return f'{neg}{inp_sz:0.3f}{pad_unit}'
365
- # scaled
366
- scaled: float = inp_sz / (1000**exp)
367
- prefix: str = _SI_PREFIXES[exp]
368
- return f'{neg}{scaled:0.3f} {prefix}{unit}'
369
-
370
-
371
- def HumanizedSeconds(inp_secs: float, /) -> str: # noqa: PLR0911
372
- """Convert a duration in seconds into a human-readable time string.
373
-
374
- Selects the appropriate time unit based on the duration's magnitude:
375
- - microseconds (`µs`)
376
- - milliseconds (`ms`)
377
- - seconds (`s`)
378
- - minutes (`min`)
379
- - hours (`h`)
380
- - days (`d`)
381
-
382
- Args:
383
- inp_secs (int | float): Time interval in seconds. Must be finite and non-negative.
384
-
385
- Returns:
386
- str: Human-readable string with the duration and unit
387
-
388
- Raises:
389
- InputError: If `inp_secs` is negative or not finite.
390
-
391
- Notes:
392
- - Uses the micro sign (`µ`, U+00B5) for microseconds.
393
- - Thresholds:
394
- < 0.001 s → µs
395
- < 1 s → ms
396
- < 60 s → seconds
397
- < 3600 s → minutes
398
- < 86400 s → hours
399
- ≥ 86400 s → days
400
-
401
- Examples:
402
- >>> HumanizedSeconds(0)
403
- '0.00 s'
404
- >>> HumanizedSeconds(0.000004)
405
- '4.000 µs'
406
- >>> HumanizedSeconds(0.25)
407
- '250.000 ms'
408
- >>> HumanizedSeconds(42)
409
- '42.00 s'
410
- >>> HumanizedSeconds(3661)
411
- '1.02 h'
412
-
413
- """ # noqa: RUF002
414
- if not math.isfinite(inp_secs) or inp_secs < 0:
415
- raise InputError(f'input should be >=0 and got {inp_secs}')
416
- if inp_secs == 0:
417
- return '0.000 s'
418
- inp_secs = float(inp_secs)
419
- if inp_secs < 0.001: # noqa: PLR2004
420
- return f'{inp_secs * 1000 * 1000:0.3f} µs' # noqa: RUF001
421
- if inp_secs < 1:
422
- return f'{inp_secs * 1000:0.3f} ms'
423
- if inp_secs < 60: # noqa: PLR2004
424
- return f'{inp_secs:0.3f} s'
425
- if inp_secs < 60 * 60:
426
- return f'{(inp_secs / 60):0.3f} min'
427
- if inp_secs < 24 * 60 * 60:
428
- return f'{(inp_secs / (60 * 60)):0.3f} h'
429
- return f'{(inp_secs / (24 * 60 * 60)):0.3f} d'
430
-
431
-
432
- def MeasurementStats(
433
- data: list[int | float], /, *, confidence: float = 0.95
434
- ) -> tuple[int, float, float, float, tuple[float, float], float]:
435
- """Compute descriptive statistics for repeated measurements.
436
-
437
- Given N ≥ 1 measurements, this function computes the sample mean, the
438
- standard error of the mean (SEM), and the symmetric error estimate for
439
- the chosen confidence interval using Student's t distribution.
440
-
441
- Notes:
442
- • If only one measurement is given, SEM and error are reported as +∞ and
443
- the confidence interval is (-∞, +∞).
444
- • This function assumes the underlying distribution is approximately
445
- normal, or n is large enough for the Central Limit Theorem to apply.
446
-
447
- Args:
448
- data (list[int | float]): Sequence of numeric measurements.
449
- confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
450
- defaults to 0.95 (95% confidence interval).
451
-
452
- Returns:
453
- tuple:
454
- - n (int): number of measurements.
455
- - mean (float): arithmetic mean of the data
456
- - sem (float): standard error of the mean, sigma / √n
457
- - error (float): half-width of the confidence interval (mean ± error)
458
- - ci (tuple[float, float]): lower and upper confidence interval bounds
459
- - confidence (float): the confidence level used
460
-
461
- Raises:
462
- InputError: if the input list is empty.
463
-
464
- """
465
- # test inputs
466
- n: int = len(data)
467
- if not n:
468
- raise InputError('no data')
469
- if not 0.5 <= confidence < 1.0: # noqa: PLR2004
470
- raise InputError(f'invalid confidence: {confidence=}')
471
- # solve trivial case
472
- if n == 1:
473
- return (n, float(data[0]), math.inf, math.inf, (-math.inf, math.inf), confidence)
474
- # call scipy for the science data
475
- np_data = np.array(data)
476
- mean = np.mean(np_data)
477
- sem = stats.sem(np_data)
478
- ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem)
479
- t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1)
480
- error = t_crit * sem # half-width of the CI
481
- return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence)
482
-
483
-
484
- def HumanizedMeasurements(
485
- data: list[int | float],
486
- /,
487
- *,
488
- unit: str = '',
489
- parser: abc.Callable[[float], str] | None = None,
490
- clip_negative: bool = True,
491
- confidence: float = 0.95,
492
- ) -> str:
493
- """Render measurement statistics as a human-readable string.
494
-
495
- Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
496
- result with units, sample count, and confidence interval. Negative values
497
- can optionally be clipped to zero and marked with a leading “*”.
498
-
499
- Notes:
500
- • For a single measurement, error is displayed as “± ?”.
501
- • The output includes the number of samples (@n) and the confidence
502
- interval unless a different confidence was requested upstream.
503
-
504
- Args:
505
- data (list[int | float]): Sequence of numeric measurements.
506
- unit (str, optional): Unit of measurement to append, e.g. "ms" or "s".
507
- Defaults to '' (no unit).
508
- parser (Callable[[float], str] | None, optional): Custom float-to-string
509
- formatter. If None, values are formatted with 3 decimal places.
510
- clip_negative (bool, optional): If True (default), negative values are
511
- clipped to 0.0 and prefixed with '*'.
512
- confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
513
- defaults to 0.95 (95% confidence interval).
514
-
515
- Returns:
516
- str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
517
-
518
- """
519
- n: int
520
- mean: float
521
- error: float
522
- ci: tuple[float, float]
523
- conf: float
524
- unit = unit.strip()
525
- n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
526
- f: abc.Callable[[float], str] = lambda x: (
527
- ('*0' if clip_negative and x < 0.0 else str(x))
528
- if parser is None
529
- else (f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x))
530
- )
531
- if n == 1:
532
- return f'{f(mean)}{unit} ±? @1'
533
- pct: int = round(conf * 100)
534
- return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
535
-
536
-
537
- class Timer:
538
- """An execution timing class that can be used as both a context manager and a decorator.
539
-
540
- Examples:
541
- # As a context manager
542
- with Timer('Block timing'):
543
- time.sleep(1.2)
544
-
545
- # As a decorator
546
- @Timer('Function timing')
547
- def slow_function():
548
- time.sleep(0.8)
549
-
550
- # As a regular object
551
- tm = Timer('Inline timing')
552
- tm.Start()
553
- time.sleep(0.1)
554
- tm.Stop()
555
- print(tm)
556
-
557
- Attributes:
558
- label (str, optional): Timer label
559
- emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
560
- emit_print (bool, optional): If True will print() the timer, else (default) will not
561
-
562
- """
563
-
564
- def __init__(
565
- self, label: str = '', /, *, emit_log: bool = True, emit_print: bool = False
566
- ) -> None:
567
- """Initialize the Timer.
568
-
569
- Args:
570
- label (str, optional): A description or name for the timed block or function
571
- emit_log (bool, optional): Emit a log message when finished; default is True
572
- emit_print (bool, optional): Emit a print() message when finished; default is False
573
-
574
- """
575
- self.emit_log: bool = emit_log
576
- self.emit_print: bool = emit_print
577
- self.label: str = label.strip()
578
- self.start: float | None = None
579
- self.end: float | None = None
580
-
581
- @property
582
- def elapsed(self) -> float:
583
- """Elapsed time. Will be zero until a measurement is available with start/end.
584
-
585
- Raises:
586
- Error: negative elapsed time
587
-
588
- Returns:
589
- float: elapsed time, in seconds
590
-
591
- """
592
- if self.start is None or self.end is None:
593
- return 0.0
594
- delta: float = self.end - self.start
595
- if delta <= 0.0:
596
- raise Error(f'negative/zero delta: {delta}')
597
- return delta
598
-
599
- def __str__(self) -> str:
600
- """Get current timer value.
601
-
602
- Returns:
603
- str: human-readable representation of current time value
604
-
605
- """
606
- if self.start is None:
607
- return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
608
- if self.end is None:
609
- return (
610
- f'{self.label}: ' if self.label else ''
611
- ) + f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}'
612
- return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
613
-
614
- def Start(self) -> None:
615
- """Start the timer.
616
-
617
- Raises:
618
- Error: if you try to re-start the timer
619
-
620
- """
621
- if self.start is not None:
622
- raise Error('Re-starting timer is forbidden')
623
- self.start = time.perf_counter()
624
-
625
- def __enter__(self) -> Self:
626
- """Start the timer when entering the context.
627
-
628
- Returns:
629
- Timer: context object (self)
630
-
631
- """
632
- self.Start()
633
- return self
634
-
635
- def Stop(self) -> None:
636
- """Stop the timer and emit logging.info with timer message.
637
-
638
- Raises:
639
- Error: trying to re-start timer or stop unstarted timer
640
-
641
- """
642
- if self.start is None:
643
- raise Error('Stopping an unstarted timer')
644
- if self.end is not None:
645
- raise Error('Re-stopping timer is forbidden')
646
- self.end = time.perf_counter()
647
- message: str = str(self)
648
- if self.emit_log:
649
- logging.info(message)
650
- if self.emit_print:
651
- Console().print(message)
652
-
653
- def __exit__(
654
- self,
655
- unused_exc_type: type[BaseException] | None,
656
- unused_exc_val: BaseException | None,
657
- exc_tb: TracebackType | None,
658
- ) -> None:
659
- """Stop the timer when exiting the context."""
660
- self.Stop()
661
-
662
- _F = TypeVar('_F', bound=abc.Callable[..., Any])
663
-
664
- def __call__(self, func: Timer._F) -> Timer._F:
665
- """Allow the Timer to be used as a decorator.
666
-
667
- Args:
668
- func: The function to time.
669
-
670
- Returns:
671
- The wrapped function with timing behavior.
672
-
673
- """
674
-
675
- @functools.wraps(func)
676
- def _Wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
677
- with self.__class__(self.label, emit_log=self.emit_log, emit_print=self.emit_print):
678
- return func(*args, **kwargs)
679
-
680
- return _Wrapper # type:ignore
681
-
682
-
683
- def RandBits(n_bits: int, /) -> int:
684
- """Crypto-random integer with guaranteed `n_bits` size (i.e., first bit == 1).
685
-
686
- The fact that the first bit will be 1 means the entropy is ~ (n_bits-1) and
687
- because of this we only allow for a byte or more bits generated. This drawback
688
- is negligible for the large integers a crypto library will work with, in practice.
689
-
690
- Args:
691
- n_bits (int): number of bits to produce, ≥ 8
692
-
693
- Returns:
694
- int with n_bits size
695
-
696
- Raises:
697
- InputError: invalid n_bits
698
-
699
- """
700
- # test inputs
701
- if n_bits < 8: # noqa: PLR2004
702
- raise InputError(f'n_bits must be ≥ 8: {n_bits}')
703
- # call underlying method
704
- n: int = 0
705
- while n.bit_length() != n_bits:
706
- n = secrets.randbits(n_bits) # we could just set the bit, but IMO it is better to get another
707
- return n
708
-
709
-
710
- def RandInt(min_int: int, max_int: int, /) -> int:
711
- """Crypto-random integer uniform over [min_int, max_int].
712
-
713
- Args:
714
- min_int (int): minimum integer, inclusive, ≥ 0
715
- max_int (int): maximum integer, inclusive, > min_int
716
-
717
- Returns:
718
- int between [min_int, max_int] inclusive
719
-
720
- Raises:
721
- InputError: invalid min/max
722
-
723
- """
724
- # test inputs
725
- if min_int < 0 or min_int >= max_int:
726
- raise InputError(f'min_int must be ≥ 0, and < max_int: {min_int} / {max_int}')
727
- # uniform over [min_int, max_int]
728
- span: int = max_int - min_int + 1
729
- n: int = min_int + secrets.randbelow(span)
730
- assert min_int <= n <= max_int, 'should never happen: generated number out of range' # noqa: S101
731
- return n
732
-
733
-
734
- def RandShuffle[T: Any](seq: abc.MutableSequence[T], /) -> None:
735
- """In-place Crypto-random shuffle order for `seq` mutable sequence.
736
-
737
- Args:
738
- seq (MutableSequence[T]): any mutable sequence with 2 or more elements
739
-
740
- Raises:
741
- InputError: not enough elements
742
-
743
- """
744
- # test inputs
745
- if (n_seq := len(seq)) < 2: # noqa: PLR2004
746
- raise InputError(f'seq must have 2 or more elements: {n_seq}')
747
- # cryptographically sound Fisher-Yates using secrets.randbelow
748
- for i in range(n_seq - 1, 0, -1):
749
- j: int = secrets.randbelow(i + 1)
750
- seq[i], seq[j] = seq[j], seq[i]
751
-
752
-
753
- def RandBytes(n_bytes: int, /) -> bytes:
754
- """Crypto-random `n_bytes` bytes. Just plain good quality random bytes.
755
-
756
- Args:
757
- n_bytes (int): number of bits to produce, > 0
758
-
759
- Returns:
760
- bytes: random with len()==n_bytes
761
-
762
- Raises:
763
- InputError: invalid n_bytes
764
-
765
- """
766
- # test inputs
767
- if n_bytes < 1:
768
- raise InputError(f'n_bytes must be ≥ 1: {n_bytes}')
769
- # return from system call
770
- b: bytes = secrets.token_bytes(n_bytes)
771
- assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size' # noqa: S101
772
- return b
773
-
774
-
775
- def GCD(a: int, b: int, /) -> int:
776
- """Greatest Common Divisor for `a` and `b`, integers ≥0. Uses the Euclid method.
777
-
778
- O(log(min(a, b)))
779
-
780
- Args:
781
- a (int): integer a ≥ 0
782
- b (int): integer b ≥ 0 (can't be both zero)
783
-
784
- Returns:
785
- gcd(a, b)
786
-
787
- Raises:
788
- InputError: invalid inputs
789
-
790
- """
791
- # test inputs
792
- if a < 0 or b < 0 or (not a and not b):
793
- raise InputError(f'negative input or undefined gcd(0, 0): {a=} , {b=}')
794
- # algo needs to start with a >= b
795
- if a < b:
796
- a, b = b, a
797
- # euclid
798
- while b:
799
- r: int = a % b
800
- a, b = b, r
801
- return a
802
-
803
-
804
- def ExtendedGCD(a: int, b: int, /) -> tuple[int, int, int]:
805
- """Greatest Common Divisor Extended for `a` and `b`, integers ≥0. Uses the Euclid method.
806
-
807
- O(log(min(a, b)))
808
-
809
- Args:
810
- a (int): integer a ≥ 0
811
- b (int): integer b ≥ 0 (can't be both zero)
812
-
813
- Returns:
814
- (gcd, x, y) so that a * x + b * y = gcd
815
- x and y may be negative integers or zero but won't be both zero.
816
-
817
- Raises:
818
- InputError: invalid inputs
819
-
820
- """
821
- # test inputs
822
- if a < 0 or b < 0 or (not a and not b):
823
- raise InputError(f'negative input or undefined gcd(0, 0): {a=} , {b=}')
824
- # algo needs to start with a >= b (but we remember if we did swap)
825
- swapped = False
826
- if a < b:
827
- a, b = b, a
828
- swapped = True
829
- # trivial case
830
- if not b:
831
- return (a, 0 if swapped else 1, 1 if swapped else 0)
832
- # euclid
833
- x1, x2, y1, y2 = 0, 1, 1, 0
834
- while b:
835
- q, r = divmod(a, b)
836
- x, y = x2 - q * x1, y2 - q * y1
837
- a, b, x1, x2, y1, y2 = b, r, x, x1, y, y1
838
- return (a, y2 if swapped else x2, x2 if swapped else y2)
839
-
840
-
841
- def Hash256(data: bytes, /) -> bytes:
842
- """SHA-256 hash of bytes data. Always a length of 32 bytes.
843
-
844
- Args:
845
- data (bytes): Data to compute hash for
846
-
847
- Returns:
848
- 32 bytes (256 bits) of SHA-256 hash;
849
- if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
850
- if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
851
-
852
- """
853
- return hashlib.sha256(data).digest()
854
-
855
-
856
- def Hash512(data: bytes, /) -> bytes:
857
- """SHA-512 hash of bytes data. Always a length of 64 bytes.
858
-
859
- Args:
860
- data (bytes): Data to compute hash for
861
-
862
- Returns:
863
- 64 bytes (512 bits) of SHA-512 hash;
864
- if converted to hexadecimal (with BytesToHex() or hex()) will be 128 chars of string;
865
- if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**512
866
-
867
- """
868
- return hashlib.sha512(data).digest()
869
-
870
-
871
- def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
872
- """SHA-256 hex hash of file on disk. Always a length of 32 bytes (if default digest=='sha256').
873
-
874
- Args:
875
- full_path (str): Path to existing file on disk
876
- digest (str, optional): Hash method to use, accepts 'sha256' (default) or 'sha512'
877
-
878
- Returns:
879
- 32 bytes (256 bits) of SHA-256 hash (if default digest=='sha256');
880
- if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
881
- if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
882
-
883
- Raises:
884
- InputError: file could not be found
885
-
886
- """
887
- # test inputs
888
- digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
889
- if digest not in {'sha256', 'sha512'}:
890
- raise InputError(f'unrecognized digest: {digest!r}')
891
- full_path = full_path.strip()
892
- if not full_path or not pathlib.Path(full_path).exists():
893
- raise InputError(f'file {full_path!r} not found for hashing')
894
- # compute hash
895
- logging.info(f'Hashing file {full_path!r}')
896
- with pathlib.Path(full_path).open('rb') as file_obj:
897
- return hashlib.file_digest(file_obj, digest).digest()
898
-
899
-
900
- def ObfuscateSecret(data: str | bytes | int, /) -> str:
901
- """Obfuscate a secret string/key/bytes/int by hashing SHA-512 and only showing the first 4 bytes.
902
-
903
- Always a length of 9 chars, e.g. "aabbccdd…" (always adds '…' at the end).
904
- Known vulnerability: If the secret is small, can be brute-forced!
905
- Use only on large (~>64bits) secrets.
906
-
907
- Args:
908
- data (str | bytes | int): Data to obfuscate
909
-
910
- Raises:
911
- InputError: _description_
912
-
913
- Returns:
914
- str: obfuscated string, e.g. "aabbccdd…"
915
-
916
- """
917
- if isinstance(data, str):
918
- data = data.encode('utf-8')
919
- elif isinstance(data, int):
920
- data = IntToBytes(data)
921
- if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
922
- raise InputError(f'invalid type for data: {type(data)}')
923
- return BytesToHex(Hash512(data))[:8] + '…'
924
-
925
-
926
- class CryptoInputType(enum.StrEnum):
927
- """Types of inputs that can represent arbitrary bytes."""
928
-
929
- # prefixes; format prefixes are all 4 bytes
930
- PATH = '@' # @path on disk → read bytes from a file
931
- STDIN = '@-' # stdin
932
- HEX = 'hex:' # hex:deadbeef → decode hex
933
- BASE64 = 'b64:' # b64:... → decode base64
934
- STR = 'str:' # str:hello → UTF-8 encode the literal
935
- RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
936
-
937
-
938
- def BytesToRaw(b: bytes, /) -> str:
939
- r"""Convert bytes to double-quoted string with \\xNN escapes where needed.
940
-
941
- 1. map bytes 0..255 to same code points (latin1)
942
- 2. escape non-printables/backslash/quotes via unicode_escape
943
-
944
- Args:
945
- b (bytes): input
946
-
947
- Returns:
948
- str: double-quoted string with \\xNN escapes where needed
949
-
950
- """
951
- inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
952
- return f'"{inner.replace('"', r"\"")}"'
953
-
954
-
955
- def RawToBytes(s: str, /) -> bytes:
956
- r"""Convert double-quoted string with \\xNN escapes where needed to bytes.
957
-
958
- Args:
959
- s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
960
-
961
- Returns:
962
- bytes: data
963
-
964
- """
965
- if len(s) >= 2 and s[0] == s[-1] == '"': # noqa: PLR2004
966
- s = s[1:-1]
967
- # decode backslash escapes to code points, then map 0..255 -> bytes
968
- return codecs.decode(s, 'unicode_escape').encode('latin1')
969
-
970
-
971
- def DetectInputType(data_str: str, /) -> CryptoInputType | None:
972
- """Auto-detect `data_str` type, if possible.
973
-
974
- Args:
975
- data_str (str): data to process, putatively a bytes blob
976
-
977
- Returns:
978
- CryptoInputType | None: type if has a known prefix, None otherwise
979
-
980
- """
981
- data_str = data_str.strip()
982
- if data_str == CryptoInputType.STDIN:
983
- return CryptoInputType.STDIN
984
- for t in (
985
- CryptoInputType.PATH,
986
- CryptoInputType.STR,
987
- CryptoInputType.HEX,
988
- CryptoInputType.BASE64,
989
- CryptoInputType.RAW,
990
- ):
991
- if data_str.startswith(t):
992
- return t
993
- return None
994
-
995
-
996
- def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # noqa: C901, PLR0911, PLR0912
997
- """Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
998
-
999
- Can load from disk ('@'). Can load from stdin ('@-').
1000
-
1001
- Args:
1002
- data_str (str): data to process, putatively a bytes blob
1003
- expect (CryptoInputType | None, optional): If not given (None) will try to auto-detect the
1004
- input type by looking at the prefix on `data_str` and if none is found will suppose
1005
- a 'str:' was given; if one of the supported CryptoInputType is given then will enforce
1006
- that specific type prefix or no prefix
1007
-
1008
- Returns:
1009
- bytes: data
1010
-
1011
- Raises:
1012
- InputError: unexpected type or conversion error
1013
-
1014
- """
1015
- data_str = data_str.strip()
1016
- # auto-detect
1017
- detected_type: CryptoInputType | None = DetectInputType(data_str)
1018
- expect = CryptoInputType.STR if expect is None and detected_type is None else expect
1019
- if detected_type is not None and expect is not None and detected_type != expect:
1020
- raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
1021
- # now we know they don't conflict, so unify them; remove prefix if we have it
1022
- expect = detected_type if expect is None else expect
1023
- assert expect is not None, 'should never happen: type should be known here' # noqa: S101
1024
- data_str = data_str.removeprefix(expect)
1025
- # for every type something different will happen now
1026
- try:
1027
- match expect:
1028
- case CryptoInputType.STDIN:
1029
- # read raw bytes from stdin: prefer the binary buffer; if unavailable,
1030
- # fall back to text stream encoded as UTF-8 (consistent with str: policy).
1031
- stream = getattr(sys.stdin, 'buffer', None)
1032
- if stream is None:
1033
- text: str = sys.stdin.read()
1034
- if not isinstance(text, str): # pyright: ignore[reportUnnecessaryIsInstance]
1035
- raise InputError('sys.stdin.read() produced non-text data') # noqa: TRY301
1036
- return text.encode('utf-8')
1037
- data: bytes = stream.read()
1038
- if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
1039
- raise InputError('sys.stdin.buffer.read() produced non-binary data') # noqa: TRY301
1040
- return data
1041
- case CryptoInputType.PATH:
1042
- if not pathlib.Path(data_str).exists():
1043
- raise InputError(f'cannot find file {data_str!r}') # noqa: TRY301
1044
- return pathlib.Path(data_str).read_bytes()
1045
- case CryptoInputType.STR:
1046
- return data_str.encode('utf-8')
1047
- case CryptoInputType.HEX:
1048
- return HexToBytes(data_str)
1049
- case CryptoInputType.BASE64:
1050
- return EncodedToBytes(data_str)
1051
- case CryptoInputType.RAW:
1052
- return RawToBytes(data_str)
1053
- case _:
1054
- raise InputError(f'invalid type {expect!r}') # noqa: TRY301
1055
- except Exception as err:
1056
- raise InputError(f'invalid input: {err}') from err
1057
-
1058
-
1059
- @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
1060
- class CryptoKey(abstract.ABC):
1061
- """A cryptographic key."""
1062
-
1063
- @abstract.abstractmethod
1064
- def __post_init__(self) -> None:
1065
- """Check data."""
1066
- # every sub-class of CryptoKey has to implement its own version of __post_init__()
1067
-
1068
- @abstract.abstractmethod
1069
- def __str__(self) -> str:
1070
- """Safe (no secrets) string representation of the key.
1071
-
1072
- Returns:
1073
- string representation of the key without leaking secrets
1074
-
1075
- """
1076
- # every sub-class of CryptoKey has to implement its own version of __str__()
1077
-
1078
- @final
1079
- def __repr__(self) -> str:
1080
- """Safe (no secrets) string representation of the key. Same as __str__().
1081
-
1082
- Returns:
1083
- string representation of the key without leaking secrets
1084
-
1085
- """
1086
- # concrete __repr__() delegates to the (abstract) __str__():
1087
- # this avoids marking __repr__() abstract while still unifying behavior
1088
- return self.__str__()
1089
-
1090
- @final
1091
- def _DebugDump(self) -> str:
1092
- """Debug dump of the key object. NOT for logging, NOT for regular use, EXPOSES secrets.
1093
-
1094
- We disable default __repr__() for the CryptoKey classes for security reasons, so we won't
1095
- leak private key values into logs, but this method allows for explicit access to the
1096
- class fields for debugging purposes by mimicking the usual dataclass __repr__().
1097
-
1098
- Returns:
1099
- string with all the object's fields explicit values
1100
-
1101
- """
1102
- cls: str = type(self).__name__
1103
- parts: list[str] = []
1104
- for field in dataclasses.fields(self):
1105
- val: Any = getattr(self, field.name) # getattr is fine with frozen/slots
1106
- parts.append(f'{field.name}={val!r}')
1107
- return f'{cls}({", ".join(parts)})'
1108
-
1109
- @final
1110
- @property
1111
- def _json_dict(self) -> dict[str, Any]:
1112
- """Dictionary representation of the object suitable for JSON conversion.
1113
-
1114
- Returns:
1115
- dict[str, Any]: representation of the object suitable for JSON conversion
1116
-
1117
- Raises:
1118
- ImplementationError: object has types that are not supported in JSON
1119
-
1120
- """
1121
- self_dict: dict[str, Any] = dataclasses.asdict(self)
1122
- for field in dataclasses.fields(self):
1123
- # check the type is OK
1124
- if field.type not in _JSON_DATACLASS_TYPES:
1125
- raise ImplementationError(
1126
- f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
1127
- )
1128
- # convert types that we accept but JSON does not
1129
- if field.type == 'bytes':
1130
- self_dict[field.name] = BytesToEncoded(self_dict[field.name])
1131
- return self_dict
1132
-
1133
- @final
1134
- @property
1135
- def json(self) -> str:
1136
- """JSON representation of the object, tightly packed, not for humans.
1137
-
1138
- Returns:
1139
- str: JSON representation of the object, tightly packed
1140
-
1141
- """
1142
- return json.dumps(self._json_dict, separators=(',', ':'))
1143
-
1144
- @final
1145
- @property
1146
- def formatted_json(self) -> str:
1147
- """JSON representation of the object formatted for humans.
1148
-
1149
- Returns:
1150
- str: JSON representation of the object formatted for humans
1151
-
1152
- """
1153
- return json.dumps(self._json_dict, indent=4, sort_keys=True)
1154
-
1155
- @final
1156
- @classmethod
1157
- def _FromJSONDict(cls, json_dict: dict[str, Any], /) -> Self:
1158
- """Create object from JSON representation.
1159
-
1160
- Args:
1161
- json_dict (dict[str, Any]): JSON dict
1162
-
1163
- Returns:
1164
- a CryptoKey object ready for use
1165
-
1166
- Raises:
1167
- InputError: unexpected type/fields
1168
- ImplementationError: unsupported JSON field
1169
-
1170
- """
1171
- # check we got exactly the fields we needed
1172
- cls_fields: set[str] = {f.name for f in dataclasses.fields(cls)}
1173
- json_fields: set[str] = set(json_dict)
1174
- if cls_fields != json_fields:
1175
- raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
1176
- # reconstruct the types we meddled with inside self._json_dict
1177
- for field in dataclasses.fields(cls):
1178
- if field.type not in _JSON_DATACLASS_TYPES:
1179
- raise ImplementationError(
1180
- f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
1181
- )
1182
- if field.type == 'bytes':
1183
- json_dict[field.name] = EncodedToBytes(json_dict[field.name])
1184
- # build the object
1185
- return cls(**json_dict)
1186
-
1187
- @final
1188
- @classmethod
1189
- def FromJSON(cls, json_data: str, /) -> Self:
1190
- """Create object from JSON representation.
1191
-
1192
- Args:
1193
- json_data (str): JSON string
1194
-
1195
- Returns:
1196
- a CryptoKey object ready for use
1197
-
1198
- Raises:
1199
- InputError: unexpected type/fields
1200
-
1201
- """
1202
- # get the dict back
1203
- json_dict: dict[str, Any] = json.loads(json_data)
1204
- if not isinstance(json_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
1205
- raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
1206
- return cls._FromJSONDict(json_dict)
1207
-
1208
- @final
1209
- @property
1210
- def blob(self) -> bytes:
1211
- """Serial (bytes) representation of the object.
1212
-
1213
- Returns:
1214
- bytes, pickled, representation of the object
1215
-
1216
- """
1217
- return self.Blob()
1218
-
1219
- @final
1220
- def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
1221
- """Get serial (bytes) representation of the object with more options, including encryption.
1222
-
1223
- Args:
1224
- key (Encryptor, optional): if given will key.Encrypt() data before saving
1225
- silent (bool, optional): if True (default) will not log
1226
-
1227
- Returns:
1228
- bytes, pickled, representation of the object
1229
-
1230
- """
1231
- return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
1232
-
1233
- @final
1234
- @property
1235
- def encoded(self) -> str:
1236
- """Base-64 representation of the object.
1237
-
1238
- Returns:
1239
- str, pickled, base64, representation of the object
1240
-
1241
- """
1242
- return self.Encoded()
1243
-
1244
- @final
1245
- def Encoded(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
1246
- """Base-64 representation of the object with more options, including encryption.
1247
-
1248
- Args:
1249
- key (Encryptor, optional): if given will key.Encrypt() data before saving
1250
- silent (bool, optional): if True (default) will not log
1251
-
1252
- Returns:
1253
- str, pickled, base64, representation of the object
1254
-
1255
- """
1256
- return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
1257
-
1258
- @final
1259
- @property
1260
- def hex(self) -> str:
1261
- """Hexadecimal representation of the object.
1262
-
1263
- Returns:
1264
- str, pickled, hexadecimal, representation of the object
1265
-
1266
- """
1267
- return self.Hex()
1268
-
1269
- @final
1270
- def Hex(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
1271
- """Hexadecimal representation of the object with more options, including encryption.
1272
-
1273
- Args:
1274
- key (Encryptor, optional): if given will key.Encrypt() data before saving
1275
- silent (bool, optional): if True (default) will not log
1276
-
1277
- Returns:
1278
- str, pickled, hexadecimal, representation of the object
1279
-
1280
- """
1281
- return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
1282
-
1283
- @final
1284
- @property
1285
- def raw(self) -> str:
1286
- """Raw escaped binary representation of the object.
1287
-
1288
- Returns:
1289
- str, pickled, raw escaped binary, representation of the object
1290
-
1291
- """
1292
- return self.Raw()
1293
-
1294
- @final
1295
- def Raw(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
1296
- """Raw escaped binary representation of the object with more options, including encryption.
1297
-
1298
- Args:
1299
- key (Encryptor, optional): if given will key.Encrypt() data before saving
1300
- silent (bool, optional): if True (default) will not log
1301
-
1302
- Returns:
1303
- str, pickled, raw escaped binary, representation of the object
1304
-
1305
- """
1306
- return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
1307
-
1308
- @final
1309
- @classmethod
1310
- def Load(cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
1311
- """Load (create) object from serialized bytes or string.
1312
-
1313
- Args:
1314
- data (str | bytes): if bytes is assumed from CryptoKey.blob/Blob(), and
1315
- if string is assumed from CryptoKey.encoded/Encoded()
1316
- key (Decryptor, optional): if given will key.Encrypt() data before saving
1317
- silent (bool, optional): if True (default) will not log
1318
-
1319
- Returns:
1320
- a CryptoKey object ready for use
1321
-
1322
- Raises:
1323
- InputError: decode error
1324
-
1325
- """
1326
- # if this is a string, then we suppose it is base64
1327
- if isinstance(data, str):
1328
- data = BytesFromInput(data)
1329
- # we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
1330
- try:
1331
- json_dict: dict[str, Any] = DeSerialize(
1332
- data=data, key=key, silent=silent, unpickler=UnpickleJSON
1333
- )
1334
- return cls._FromJSONDict(json_dict)
1335
- except Exception as err:
1336
- raise InputError(f'input decode error: {err}') from err
1337
-
1338
-
1339
- @runtime_checkable
1340
- class Encryptor(Protocol):
1341
- """Abstract interface for a class that has encryption.
1342
-
1343
- Contract:
1344
- - If algorithm accepts a `nonce` or `tag` these have to be handled internally by the
1345
- implementation and appended to the `ciphertext`/`signature`.
1346
- - If AEAD is supported, `associated_data` (AAD) must be authenticated. If not supported
1347
- then `associated_data` different from None must raise InputError.
1348
-
1349
- Notes:
1350
- The interface is deliberately minimal: byte-in / byte-out.
1351
- Metadata like nonce/tag may be:
1352
- - returned alongside `ciphertext`/`signature`, or
1353
- - bundled/serialized into `ciphertext`/`signature` by the implementation.
1354
-
1355
- """
1356
-
1357
- @abstract.abstractmethod
1358
- def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
1359
- """Encrypt `plaintext` and return `ciphertext`.
1360
-
1361
- Args:
1362
- plaintext (bytes): Data to encrypt.
1363
- associated_data (bytes, optional): Optional AAD for AEAD modes; must be
1364
- provided again on decrypt
1365
-
1366
- Returns:
1367
- bytes: Ciphertext; if a nonce/tag is needed for decryption, the implementation
1368
- must encode it within the returned bytes (or document how to retrieve it)
1369
-
1370
- Raises:
1371
- InputError: invalid inputs
1372
- CryptoError: internal crypto failures
1373
-
1374
- """
1375
-
1376
-
1377
- @runtime_checkable
1378
- class Decryptor(Protocol):
1379
- """Abstract interface for a class that has decryption (see contract/notes in Encryptor)."""
1380
-
1381
- @abstract.abstractmethod
1382
- def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
1383
- """Decrypt `ciphertext` and return the original `plaintext`.
1384
-
1385
- Args:
1386
- ciphertext (bytes): Data to decrypt (including any embedded nonce/tag if applicable)
1387
- associated_data (bytes, optional): Optional AAD (must match what was used during encrypt)
1388
-
1389
- Returns:
1390
- bytes: Decrypted plaintext bytes
1391
-
1392
- Raises:
1393
- InputError: invalid inputs
1394
- CryptoError: internal crypto failures, authentication failure, key mismatch, etc
1395
-
1396
- """
1397
-
1398
-
1399
- @runtime_checkable
1400
- class Verifier(Protocol):
1401
- """Abstract interface for asymmetric signature verify. (see contract/notes in Encryptor)."""
1402
-
1403
- @abstract.abstractmethod
1404
- def Verify(
1405
- self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None
1406
- ) -> bool:
1407
- """Verify a `signature` for `message`. True if OK; False if failed verification.
1408
-
1409
- Args:
1410
- message (bytes): Data that was signed (including any embedded nonce/tag if applicable)
1411
- signature (bytes): Signature data to verify (including any embedded nonce/tag if applicable)
1412
- associated_data (bytes, optional): Optional AAD (must match what was used during signing)
1413
-
1414
- Returns:
1415
- True if signature is valid, False otherwise
1416
-
1417
- Raises:
1418
- InputError: invalid inputs
1419
- CryptoError: internal crypto failures, authentication failure, key mismatch, etc
1420
-
1421
- """
1422
-
1423
-
1424
- @runtime_checkable
1425
- class Signer(Protocol):
1426
- """Abstract interface for asymmetric signing. (see contract/notes in Encryptor)."""
1427
-
1428
- @abstract.abstractmethod
1429
- def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
1430
- """Sign `message` and return the `signature`.
1431
-
1432
- Args:
1433
- message (bytes): Data to sign.
1434
- associated_data (bytes, optional): Optional AAD for AEAD modes; must be
1435
- provided again on decrypt
1436
-
1437
- Returns:
1438
- bytes: Signature; if a nonce/tag is needed for decryption, the implementation
1439
- must encode it within the returned bytes (or document how to retrieve it)
1440
-
1441
- Raises:
1442
- InputError: invalid inputs
1443
- CryptoError: internal crypto failures
1444
-
1445
- """
1446
-
1447
-
1448
- def Serialize(
1449
- python_obj: Any, # noqa: ANN401
1450
- /,
1451
- *,
1452
- file_path: str | None = None,
1453
- compress: int | None = 3,
1454
- key: Encryptor | None = None,
1455
- silent: bool = False,
1456
- pickler: abc.Callable[[Any], bytes] = PickleGeneric,
1457
- ) -> bytes:
1458
- """Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
1459
-
1460
- Data path is:
1461
-
1462
- `obj` => [pickler] => (compress) => (encrypt) => (save to `file_path`) => return
1463
-
1464
- At every step of the data path the data will be measured, in bytes.
1465
- Every data conversion will be timed. The measurements/times will be logged (once).
1466
-
1467
- Compression levels / speed can be controlled by `compress`. Use this as reference:
1468
-
1469
- | Level | Speed | Compression ratio | Typical use case |
1470
- | -------- | ------------| ------------------------| --------------------------------------- |
1471
- | -5 to -1 | Fastest | Poor (better than none) | Real-time / very latency-sensitive |
1472
- | 0…3 | Very fast | Good ratio | Default CLI choice, safe baseline |
1473
- | 4…6 | Moderate | Better ratio | Good compromise for general persistence |
1474
- | 7…10 | Slower | Marginally better ratio | Only if storage space is precious |
1475
- | 11…15 | Much slower | Slight gains | Large archives, not for runtime use |
1476
- | 16…22 | Very slow | Tiny gains | Archival-only, multi-GB datasets |
1477
-
1478
- Args:
1479
- python_obj (Any): serializable Python object
1480
- file_path (str, optional): full path to optionally save the data to
1481
- compress (int | None, optional): Compress level before encrypting/saving; -22 ≤ compress ≤ 22;
1482
- None is no compression; default is 3, which is fast, see table above for other values
1483
- key (Encryptor, optional): if given will key.Encrypt() data before saving
1484
- silent (bool, optional): if True will not log; default is False (will log)
1485
- pickler (Callable[[Any], bytes], optional): if not given, will just be the `pickle` module;
1486
- if given will be a method to convert any Python object to its `bytes` representation;
1487
- PickleGeneric is the default, but another useful value is PickleJSON
1488
-
1489
- Returns:
1490
- bytes: serialized binary data corresponding to obj + (compression) + (encryption)
1491
-
1492
- """
1493
- messages: list[str] = []
1494
- with Timer('Serialization complete', emit_log=False) as tm_all:
1495
- # pickle
1496
- with Timer('PICKLE', emit_log=False) as tm_pickle:
1497
- obj: bytes = pickler(python_obj)
1498
- if not silent:
1499
- messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
1500
- # compress, if needed
1501
- if compress is not None:
1502
- compress = max(compress, -22)
1503
- compress = min(compress, 22)
1504
- with Timer(f'COMPRESS@{compress}', emit_log=False) as tm_compress:
1505
- obj = zstandard.ZstdCompressor(level=compress).compress(obj)
1506
- if not silent:
1507
- messages.append(f' {tm_compress}, {HumanizedBytes(len(obj))}')
1508
- # encrypt, if needed
1509
- if key is not None:
1510
- with Timer('ENCRYPT', emit_log=False) as tm_crypto:
1511
- obj = key.Encrypt(obj, associated_data=_PICKLE_AAD)
1512
- if not silent:
1513
- messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
1514
- # optionally save to disk
1515
- if file_path is not None:
1516
- with Timer('SAVE', emit_log=False) as tm_save:
1517
- pathlib.Path(file_path).write_bytes(obj)
1518
- if not silent:
1519
- messages.append(f' {tm_save}, to {file_path!r}')
1520
- # log and return
1521
- if not silent:
1522
- logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
1523
- return obj
1524
-
1525
-
1526
- def DeSerialize( # noqa: C901
1527
- *,
1528
- data: bytes | None = None,
1529
- file_path: str | None = None,
1530
- key: Decryptor | None = None,
1531
- silent: bool = False,
1532
- unpickler: abc.Callable[[bytes], Any] = UnpickleGeneric,
1533
- ) -> Any: # noqa: ANN401
1534
- """Load (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
1535
-
1536
- Data path is:
1537
-
1538
- `data` or `file_path` => (decrypt) => (decompress) => [unpickler] => return object
1539
-
1540
- At every step of the data path the data will be measured, in bytes.
1541
- Every data conversion will be timed. The measurements/times will be logged (once).
1542
- Compression versus no compression will be automatically detected.
1543
-
1544
- Args:
1545
- data (bytes | None, optional): if given, use this as binary data string (input);
1546
- if you use this option, `file_path` will be ignored
1547
- file_path (str | None, optional): if given, use this as file path to load binary data
1548
- string (input); if you use this option, `data` will be ignored. Defaults to None.
1549
- key (Decryptor | None, optional): if given will key.Decrypt() data before decompressing/loading.
1550
- Defaults to None.
1551
- silent (bool, optional): if True will not log; default is False (will log). Defaults to False.
1552
- unpickler (Callable[[bytes], Any], optional): if not given, will just be the `pickle` module;
1553
- if given will be a method to convert a `bytes` representation back to a Python object;
1554
- UnpickleGeneric is the default, but another useful value is UnpickleJSON.
1555
- Defaults to UnpickleGeneric.
1556
-
1557
- Returns:
1558
- De-Serialized Python object corresponding to data
1559
-
1560
- Raises:
1561
- InputError: invalid inputs
1562
- CryptoError: internal crypto failures, authentication failure, key mismatch, etc
1563
-
1564
- """ # noqa: DOC502
1565
- # test inputs
1566
- if (data is None and file_path is None) or (data is not None and file_path is not None):
1567
- raise InputError('you must provide only one of either `data` or `file_path`')
1568
- if file_path and not pathlib.Path(file_path).exists():
1569
- raise InputError(f'invalid file_path: {file_path!r}')
1570
- if data and len(data) < 4: # noqa: PLR2004
1571
- raise InputError('invalid data: too small')
1572
- # start the pipeline
1573
- obj: bytes = data or b''
1574
- messages: list[str] = [f'DATA: {HumanizedBytes(len(obj))}'] if data and not silent else []
1575
- with Timer('De-Serialization complete', emit_log=False) as tm_all:
1576
- # optionally load from disk
1577
- if file_path:
1578
- assert not obj, 'should never happen: if we have a file obj should be empty' # noqa: S101
1579
- with Timer('LOAD', emit_log=False) as tm_load:
1580
- obj = pathlib.Path(file_path).read_bytes()
1581
- if not silent:
1582
- messages.append(f' {tm_load}, {HumanizedBytes(len(obj))}, from {file_path!r}')
1583
- # decrypt, if needed
1584
- if key is not None:
1585
- with Timer('DECRYPT', emit_log=False) as tm_crypto:
1586
- obj = key.Decrypt(obj, associated_data=_PICKLE_AAD)
1587
- if not silent:
1588
- messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
1589
- # decompress: we try to detect compression to determine if we must call zstandard
1590
- if (
1591
- len(obj) >= 4 # noqa: PLR2004
1592
- and (
1593
- ((magic := int.from_bytes(obj[:4], 'little')) == _ZSTD_MAGIC_FRAME)
1594
- or (_ZSTD_MAGIC_SKIPPABLE_MIN <= magic <= _ZSTD_MAGIC_SKIPPABLE_MAX)
1595
- )
1596
- ):
1597
- with Timer('DECOMPRESS', emit_log=False) as tm_decompress:
1598
- obj = zstandard.ZstdDecompressor().decompress(obj)
1599
- if not silent:
1600
- messages.append(f' {tm_decompress}, {HumanizedBytes(len(obj))}')
1601
- elif not silent:
1602
- messages.append(' (no compression detected)')
1603
- # create the actual object = unpickle
1604
- with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
1605
- python_obj: Any = unpickler(obj)
1606
- if not silent:
1607
- messages.append(f' {tm_unpickle}')
1608
- # log and return
1609
- if not silent:
1610
- logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
1611
- return python_obj
1612
-
1613
-
1614
- @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
1615
- class PublicBid512(CryptoKey):
1616
- """Public commitment to a (cryptographically secure) bid that can be revealed/validated later.
1617
-
1618
- Bid is computed as: public_hash = Hash512(public_key || private_key || secret_bid)
1619
-
1620
- Everything is bytes. The public part is (public_key, public_hash) and the private
1621
- part is (private_key, secret_bid). The whole computation can be checked later.
1622
-
1623
- No measures are taken here to prevent timing attacks (probably not a concern).
1624
-
1625
- Attributes:
1626
- public_key (bytes): 512-bits random value
1627
- public_hash (bytes): SHA-512 hash of (public_key || private_key || secret_bid)
1628
-
1629
- """
1630
-
1631
- public_key: bytes
1632
- public_hash: bytes
1633
-
1634
- def __post_init__(self) -> None:
1635
- """Check data.
1636
-
1637
- Raises:
1638
- InputError: invalid inputs
1639
-
1640
- """
1641
- if len(self.public_key) != 64 or len(self.public_hash) != 64: # noqa: PLR2004
1642
- raise InputError(f'invalid public_key or public_hash: {self}')
1643
-
1644
- def __str__(self) -> str:
1645
- """Safe string representation of the PublicBid.
1646
-
1647
- Returns:
1648
- string representation of PublicBid
1649
-
1650
- """
1651
- return (
1652
- 'PublicBid512('
1653
- f'public_key={BytesToEncoded(self.public_key)}, '
1654
- f'public_hash={BytesToHex(self.public_hash)})'
1655
- )
1656
-
1657
- def VerifyBid(self, private_key: bytes, secret: bytes, /) -> bool:
1658
- """Verify a bid. True if OK; False if failed verification.
1659
-
1660
- Args:
1661
- private_key (bytes): 512-bits private key
1662
- secret (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
1663
-
1664
- Returns:
1665
- True if bid is valid, False otherwise
1666
-
1667
- """
1668
- try:
1669
- # creating the PrivateBid object will validate everything; InputError we allow to propagate
1670
- PrivateBid512(
1671
- public_key=self.public_key,
1672
- public_hash=self.public_hash,
1673
- private_key=private_key,
1674
- secret_bid=secret,
1675
- )
1676
- return True # if we got here, all is good
1677
- except CryptoError:
1678
- return False # bid does not match the public commitment
1679
-
1680
- @classmethod
1681
- def Copy(cls, other: PublicBid512, /) -> Self:
1682
- """Initialize a public bid by taking the public parts of a public/private bid.
1683
-
1684
- Args:
1685
- other (PublicBid512): the bid to copy from
1686
-
1687
- Returns:
1688
- Self: an initialized PublicBid512
1689
-
1690
- """
1691
- return cls(public_key=other.public_key, public_hash=other.public_hash)
1692
-
1693
-
1694
- @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
1695
- class PrivateBid512(PublicBid512):
1696
- """Private bid that can be revealed and validated against a public commitment (see PublicBid).
1697
-
1698
- Attributes:
1699
- private_key (bytes): 512-bits random value
1700
- secret_bid (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
1701
-
1702
- """
1703
-
1704
- private_key: bytes
1705
- secret_bid: bytes
1706
-
1707
- def __post_init__(self) -> None:
1708
- """Check data.
1709
-
1710
- Raises:
1711
- InputError: invalid inputs
1712
- CryptoError: bid does not match the public commitment
1713
-
1714
- """
1715
- super(PrivateBid512, self).__post_init__()
1716
- if len(self.private_key) != 64 or len(self.secret_bid) < 1: # noqa: PLR2004
1717
- raise InputError(f'invalid private_key or secret_bid: {self}')
1718
- if self.public_hash != Hash512(self.public_key + self.private_key + self.secret_bid):
1719
- raise CryptoError(f'inconsistent bid: {self}')
1720
-
1721
- def __str__(self) -> str:
1722
- """Safe (no secrets) string representation of the PrivateBid.
1723
-
1724
- Returns:
1725
- string representation of PrivateBid without leaking secrets
1726
-
1727
- """
1728
- return (
1729
- 'PrivateBid512('
1730
- f'{super(PrivateBid512, self).__str__()}, '
1731
- f'private_key={ObfuscateSecret(self.private_key)}, '
1732
- f'secret_bid={ObfuscateSecret(self.secret_bid)})'
1733
- )
1734
-
1735
- @classmethod
1736
- def New(cls, secret: bytes, /) -> Self:
1737
- """Make the `secret` into a new bid.
1738
-
1739
- Args:
1740
- secret (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
1741
-
1742
- Returns:
1743
- PrivateBid object ready for use (use PublicBid.Copy() to get the public part)
1744
-
1745
- Raises:
1746
- InputError: invalid inputs
1747
-
1748
- """
1749
- # test inputs
1750
- if len(secret) < 1:
1751
- raise InputError(f'invalid secret length: {len(secret)}')
1752
- # generate random values
1753
- public_key: bytes = RandBytes(64) # 512 bits
1754
- private_key: bytes = RandBytes(64) # 512 bits
1755
- # build object
1756
- return cls(
1757
- public_key=public_key,
1758
- public_hash=Hash512(public_key + private_key + secret),
1759
- private_key=private_key,
1760
- secret_bid=secret,
1761
- )
1762
-
1763
-
1764
- @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
1765
- class CLIConfig:
1766
- """CLI global context, storing the configuration."""
1767
-
1768
- console: rich_console.Console
1769
- verbose: int
1770
- color: bool | None
1771
-
1772
-
1773
- def CLIErrorGuard[**P](fn: abc.Callable[P, None], /) -> abc.Callable[P, None]:
1774
- """Guard CLI command functions.
1775
-
1776
- Returns:
1777
- A wrapped function that catches expected user-facing errors and prints them consistently.
1778
-
1779
- """
1780
-
1781
- @functools.wraps(fn)
1782
- def _Wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
1783
- try:
1784
- # call the actual function
1785
- fn(*args, **kwargs)
1786
- except (Error, ValueError) as err:
1787
- # get context
1788
- ctx: object | None = dict(kwargs).get('ctx')
1789
- if not isinstance(ctx, typer.Context):
1790
- ctx = next((a for a in args if isinstance(a, typer.Context)), None)
1791
- # print error nicely
1792
- if isinstance(ctx, typer.Context):
1793
- # we have context
1794
- obj: CLIConfig = cast('CLIConfig', ctx.obj)
1795
- if obj.verbose >= 2: # verbose >= 2 means INFO level or more verbose # noqa: PLR2004
1796
- obj.console.print_exception() # print full traceback
1797
- else:
1798
- obj.console.print(str(err)) # print only error message
1799
- # no context
1800
- elif logging.getLogger().getEffectiveLevel() < logging.INFO:
1801
- Console().print(str(err)) # print only error message (DEBUG level is verbose already)
1802
- else:
1803
- Console().print_exception() # print full traceback (less verbose mode needs it)
1804
-
1805
- return _Wrapper
1806
-
1807
-
1808
- def _ClickWalk(
1809
- command: click.Command,
1810
- ctx: typer.Context,
1811
- path: list[str],
1812
- /,
1813
- ) -> abc.Iterator[tuple[list[str], click.Command, typer.Context]]:
1814
- """Recursively walk Click commands/groups.
1815
-
1816
- Yields:
1817
- tuple[list[str], click.Command, typer.Context]: path, command, ctx
1818
-
1819
- """
1820
- yield (path, command, ctx) # yield self
1821
- # now walk subcommands, if any
1822
- sub_cmd: click.Command | None
1823
- sub_ctx: typer.Context
1824
- # prefer the explicit `.commands` mapping when present; otherwise fall back to
1825
- # click's `list_commands()`/`get_command()` for dynamic groups
1826
- if not isinstance(command, click.Group):
1827
- return
1828
- # explicit commands mapping
1829
- if command.commands:
1830
- for name, sub_cmd in sorted(command.commands.items()):
1831
- sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
1832
- yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
1833
- return
1834
- # dynamic commands
1835
- for name in sorted(command.list_commands(ctx)):
1836
- sub_cmd = command.get_command(ctx, name)
1837
- if sub_cmd is None:
1838
- continue # skip invalid subcommands
1839
- sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
1840
- yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
1841
-
1842
-
1843
- def GenerateTyperHelpMarkdown(
1844
- typer_app: typer.Typer,
1845
- /,
1846
- *,
1847
- prog_name: str,
1848
- heading_level: int = 1,
1849
- code_fence_language: str = 'text',
1850
- ) -> str:
1851
- """Capture `--help` for a Typer CLI and all subcommands as Markdown.
1852
-
1853
- This function converts a Typer app to its underlying Click command tree and then:
1854
- - invokes `--help` for the root ("Main") command
1855
- - walks commands/subcommands recursively
1856
- - invokes `--help` for each command path
1857
-
1858
- It emits a Markdown document with a heading per command and a fenced block
1859
- containing the exact `--help` output.
1860
-
1861
- Notes:
1862
- - This uses Click's `CliRunner().invoke(...)` for faithful output.
1863
- - The walk is generic over Click `MultiCommand`/`Group` structures.
1864
- - If a command cannot be loaded, it is skipped.
1865
-
1866
- Args:
1867
- typer_app: The Typer app (e.g. `app`).
1868
- prog_name: Program name used in usage strings (e.g. "profiler").
1869
- heading_level: Markdown heading level for each command section.
1870
- code_fence_language: Language tag for fenced blocks (default: "text").
1871
-
1872
- Returns:
1873
- Markdown string.
1874
-
1875
- """
1876
- # prepare Click root command and context
1877
- click_root: click.Command = typer.main.get_command(typer_app)
1878
- root_ctx: typer.Context = typer.Context(click_root, info_name=prog_name)
1879
- runner = click_testing.CliRunner()
1880
- parts: list[str] = []
1881
- for path, _, _ in _ClickWalk(click_root, root_ctx, []):
1882
- # build command path
1883
- command_path: str = ' '.join([prog_name, *path]).strip()
1884
- heading_prefix: str = '#' * max(1, heading_level + len(path))
1885
- ResetConsole() # ensure clean state for each command (also it raises on duplicate loggers)
1886
- # invoke --help for this command path
1887
- result: click_testing.Result = runner.invoke(
1888
- click_root,
1889
- [*path, '--help'],
1890
- prog_name=prog_name,
1891
- color=False,
1892
- )
1893
- if result.exit_code != 0 and not result.output:
1894
- continue # skip invalid commands
1895
- # build markdown section
1896
- global_prefix: str = ( # only for the top-level command
1897
- (
1898
- '<!-- cspell:disable -->\n'
1899
- '<!-- auto-generated; DO NOT EDIT! see base.GenerateTyperHelpMarkdown() -->\n\n'
1900
- )
1901
- if not path
1902
- else ''
1903
- )
1904
- extras: str = ( # type of command, by level
1905
- ('Command-Line Interface' if not path else 'Command') if len(path) <= 1 else 'Sub-Command'
1906
- )
1907
- parts.extend(
1908
- (
1909
- f'{global_prefix}{heading_prefix} `{command_path}` {extras}',
1910
- '',
1911
- f'```{code_fence_language}',
1912
- result.output.strip(),
1913
- '```',
1914
- '',
1915
- )
1916
- )
1917
- # join all parts and return
1918
- return '\n'.join(parts).rstrip()