transcrypto 1.5.1__py3-none-any.whl → 1.7.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/base.py CHANGED
@@ -1,13 +1,10 @@
1
- #!/usr/bin/env python3
2
- #
3
- # Copyright 2025 Daniel Balparda (balparda@github.com) - Apache-2.0 license
4
- #
1
+ # SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
2
+ # SPDX-License-Identifier: Apache-2.0
5
3
  """Balparda's TransCrypto base library."""
6
4
 
7
5
  from __future__ import annotations
8
6
 
9
- import abc
10
- import argparse
7
+ import abc as abstract
11
8
  import base64
12
9
  import codecs
13
10
  import dataclasses
@@ -18,72 +15,104 @@ import hashlib
18
15
  import json
19
16
  import logging
20
17
  import math
21
- import os.path
22
- import pickle
23
- # import pdb
18
+ import os
19
+ import pathlib
20
+ import pickle # noqa: S403
24
21
  import secrets
25
22
  import sys
23
+ import threading
26
24
  import time
27
- from typing import Any, Callable, final, MutableSequence, Protocol, runtime_checkable
28
- from typing import Sequence, Self, TypeVar
29
-
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
30
38
  import numpy as np
31
- from scipy import stats # type:ignore
39
+ import typer
32
40
  import zstandard
33
-
34
- __author__ = 'balparda@github.com'
35
- __version__ = '1.5.1' # 2026-01-13, Tue
36
- __version_tuple__: tuple[int, ...] = tuple(int(v) for v in __version__.split('.'))
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
37
45
 
38
46
  # Data conversion utils
39
47
 
40
- BytesToHex: Callable[[bytes], str] = lambda b: b.hex()
41
- BytesToInt: Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
42
- BytesToEncoded: Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
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')
43
51
 
44
- HexToBytes: Callable[[str], bytes] = bytes.fromhex
45
- IntToFixedBytes: Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
46
- IntToBytes: Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
47
- IntToEncoded: Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
48
- EncodedToBytes: Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
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'))
49
57
 
50
- PadBytesTo: Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
58
+ PadBytesTo: abc.Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
51
59
 
52
60
  # Time utils
53
61
 
54
- MIN_TM = int(
55
- datetime.datetime(2000, 1, 1, 0, 0, 0).replace(tzinfo=datetime.timezone.utc).timestamp())
62
+ MIN_TM = int(datetime.datetime(2000, 1, 1, 0, 0, 0, tzinfo=datetime.UTC).timestamp())
56
63
  TIME_FORMAT = '%Y/%b/%d-%H:%M:%S-UTC'
57
- TimeStr: Callable[[int | float | None], str] = lambda tm: (
58
- time.strftime(TIME_FORMAT, time.gmtime(tm)) if tm else '-')
59
- Now: Callable[[], int] = lambda: int(time.time())
60
- StrNow: Callable[[], str] = lambda: TimeStr(Now())
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
+ }
61
89
 
62
90
  # SI prefix table, powers of 1000
63
91
  _SI_PREFIXES: dict[int, str] = {
64
- -6: 'a', # atto
65
- -5: 'f', # femto
66
- -4: 'p', # pico
67
- -3: 'n', # nano
68
- -2: 'µ', # micro (unicode U+00B5)
69
- -1: 'm', # milli
70
- 0: '', # base
71
- 1: 'k', # kilo
72
- 2: 'M', # mega
73
- 3: 'G', # giga
74
- 4: 'T', # tera
75
- 5: 'P', # peta
76
- 6: 'E', # exa
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
77
105
  }
78
106
 
79
107
  # these control the pickling of data, do NOT ever change, or you will break all databases
80
108
  # <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
81
109
  _PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
82
- PickleGeneric: Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
83
- UnpickleGeneric: Callable[[bytes], Any] = pickle.loads
84
- PickleJSON: Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
85
- d, separators=(',', ':')).encode('utf-8')
86
- UnpickleJSON: Callable[[bytes], dict[str, Any]] = lambda b: json.loads(b.decode('utf-8'))
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'))
87
116
  _PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
88
117
  # these help find compressed files, do NOT change unless zstandard changes
89
118
  _ZSTD_MAGIC_FRAME = 0xFD2FB528
@@ -91,11 +120,17 @@ _ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
91
120
  _ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
92
121
  # JSON
93
122
  _JSON_DATACLASS_TYPES: set[str] = {
94
- # native support
95
- 'int', 'float', 'str', 'bool',
96
- 'list[int]', 'list[float]', 'list[str]', 'list[bool]',
97
- # need conversion/encoding
98
- 'bytes',
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',
99
134
  }
100
135
 
101
136
 
@@ -112,10 +147,116 @@ class CryptoError(Error):
112
147
 
113
148
 
114
149
  class ImplementationError(Error, NotImplementedError):
115
- """This feature is not implemented yet (TransCrypto)."""
150
+ """Feature is not implemented yet (TransCrypto)."""
151
+
116
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
117
210
 
118
- def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-return-statements
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
119
260
  """Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
120
261
 
121
262
  Scales the input size by powers of 1024, returning a value with the
@@ -147,10 +288,11 @@ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-r
147
288
  '2.00 KiB'
148
289
  >>> HumanizedBytes(5 * 1024**3)
149
290
  '5.00 GiB'
291
+
150
292
  """
151
293
  if inp_sz < 0:
152
294
  raise InputError(f'input should be >=0 and got {inp_sz}')
153
- if inp_sz < 1024:
295
+ if inp_sz < 1024: # noqa: PLR2004
154
296
  return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
155
297
  if inp_sz < 1024 * 1024:
156
298
  return f'{(inp_sz / 1024):0.3f} KiB'
@@ -165,7 +307,7 @@ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-r
165
307
  return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
166
308
 
167
309
 
168
- def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
310
+ def HumanizedDecimal(inp_sz: float, /, *, unit: str = '') -> str:
169
311
  """Convert a numeric value into a human-readable string using SI metric prefixes.
170
312
 
171
313
  Scales the input value by powers of 1000, returning a value with the
@@ -202,7 +344,8 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
202
344
 
203
345
  Raises:
204
346
  InputError: If `inp_sz` is not finite.
205
- """
347
+
348
+ """ # noqa: RUF002
206
349
  if not math.isfinite(inp_sz):
207
350
  raise InputError(f'input should finite; got {inp_sz!r}')
208
351
  unit = unit.strip()
@@ -212,8 +355,7 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
212
355
  neg: str = '-' if inp_sz < 0 else ''
213
356
  inp_sz = abs(inp_sz)
214
357
  # Find exponent of 1000 that keeps value in [1, 1000)
215
- exp: int
216
- exp = int(math.floor(math.log10(abs(inp_sz)) / 3))
358
+ exp: int = math.floor(math.log10(abs(inp_sz)) / 3)
217
359
  exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
218
360
  if not exp:
219
361
  # No scaling: use int or 4-decimal float
@@ -221,12 +363,12 @@ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
221
363
  return f'{neg}{int(inp_sz)}{pad_unit}'
222
364
  return f'{neg}{inp_sz:0.3f}{pad_unit}'
223
365
  # scaled
224
- scaled: float = inp_sz / (1000 ** exp)
366
+ scaled: float = inp_sz / (1000**exp)
225
367
  prefix: str = _SI_PREFIXES[exp]
226
368
  return f'{neg}{scaled:0.3f} {prefix}{unit}'
227
369
 
228
370
 
229
- def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-many-return-statements
371
+ def HumanizedSeconds(inp_secs: float, /) -> str: # noqa: PLR0911
230
372
  """Convert a duration in seconds into a human-readable time string.
231
373
 
232
374
  Selects the appropriate time unit based on the duration's magnitude:
@@ -267,17 +409,18 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
267
409
  '42.00 s'
268
410
  >>> HumanizedSeconds(3661)
269
411
  '1.02 h'
270
- """
412
+
413
+ """ # noqa: RUF002
271
414
  if not math.isfinite(inp_secs) or inp_secs < 0:
272
415
  raise InputError(f'input should be >=0 and got {inp_secs}')
273
416
  if inp_secs == 0:
274
417
  return '0.000 s'
275
418
  inp_secs = float(inp_secs)
276
- if inp_secs < 0.001:
277
- return f'{inp_secs * 1000 * 1000:0.3f} µs'
419
+ if inp_secs < 0.001: # noqa: PLR2004
420
+ return f'{inp_secs * 1000 * 1000:0.3f} µs' # noqa: RUF001
278
421
  if inp_secs < 1:
279
422
  return f'{inp_secs * 1000:0.3f} ms'
280
- if inp_secs < 60:
423
+ if inp_secs < 60: # noqa: PLR2004
281
424
  return f'{inp_secs:0.3f} s'
282
425
  if inp_secs < 60 * 60:
283
426
  return f'{(inp_secs / 60):0.3f} min'
@@ -287,8 +430,8 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
287
430
 
288
431
 
289
432
  def MeasurementStats(
290
- data: list[int | float], /, *,
291
- confidence: float = 0.95) -> tuple[int, float, float, float, tuple[float, float], float]:
433
+ data: list[int | float], /, *, confidence: float = 0.95
434
+ ) -> tuple[int, float, float, float, tuple[float, float], float]:
292
435
  """Compute descriptive statistics for repeated measurements.
293
436
 
294
437
  Given N ≥ 1 measurements, this function computes the sample mean, the
@@ -317,12 +460,13 @@ def MeasurementStats(
317
460
 
318
461
  Raises:
319
462
  InputError: if the input list is empty.
463
+
320
464
  """
321
465
  # test inputs
322
466
  n: int = len(data)
323
467
  if not n:
324
468
  raise InputError('no data')
325
- if not 0.5 <= confidence < 1.0:
469
+ if not 0.5 <= confidence < 1.0: # noqa: PLR2004
326
470
  raise InputError(f'invalid confidence: {confidence=}')
327
471
  # solve trivial case
328
472
  if n == 1:
@@ -330,17 +474,22 @@ def MeasurementStats(
330
474
  # call scipy for the science data
331
475
  np_data = np.array(data)
332
476
  mean = np.mean(np_data)
333
- sem = stats.sem(np_data) # type:ignore
334
- ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem) # type:ignore
335
- t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1) # type:ignore
336
- error = t_crit * sem # half-width of the CI # type:ignore
337
- return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence) # type:ignore
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)
338
482
 
339
483
 
340
484
  def HumanizedMeasurements(
341
- data: list[int | float], /, *,
342
- unit: str = '', parser: Callable[[float], str] | None = None,
343
- clip_negative: bool = True, confidence: float = 0.95) -> str:
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:
344
493
  """Render measurement statistics as a human-readable string.
345
494
 
346
495
  Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
@@ -365,6 +514,7 @@ def HumanizedMeasurements(
365
514
 
366
515
  Returns:
367
516
  str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
517
+
368
518
  """
369
519
  n: int
370
520
  mean: float
@@ -373,12 +523,14 @@ def HumanizedMeasurements(
373
523
  conf: float
374
524
  unit = unit.strip()
375
525
  n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
376
- f: Callable[[float], str] = lambda x: (
377
- ('*0' if clip_negative and x < 0.0 else str(x)) if parser is None else
378
- (f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x)))
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
+ )
379
531
  if n == 1:
380
532
  return f'{f(mean)}{unit} ±? @1'
381
- pct = int(round(conf * 100))
533
+ pct: int = round(conf * 100)
382
534
  return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
383
535
 
384
536
 
@@ -386,7 +538,6 @@ class Timer:
386
538
  """An execution timing class that can be used as both a context manager and a decorator.
387
539
 
388
540
  Examples:
389
-
390
541
  # As a context manager
391
542
  with Timer('Block timing'):
392
543
  time.sleep(1.2)
@@ -407,11 +558,12 @@ class Timer:
407
558
  label (str, optional): Timer label
408
559
  emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
409
560
  emit_print (bool, optional): If True will print() the timer, else (default) will not
561
+
410
562
  """
411
563
 
412
564
  def __init__(
413
- self, label: str = '', /, *,
414
- emit_log: bool = True, emit_print: bool = False) -> None:
565
+ self, label: str = '', /, *, emit_log: bool = True, emit_print: bool = False
566
+ ) -> None:
415
567
  """Initialize the Timer.
416
568
 
417
569
  Args:
@@ -419,8 +571,6 @@ class Timer:
419
571
  emit_log (bool, optional): Emit a log message when finished; default is True
420
572
  emit_print (bool, optional): Emit a print() message when finished; default is False
421
573
 
422
- Raises:
423
- InputError: empty label
424
574
  """
425
575
  self.emit_log: bool = emit_log
426
576
  self.emit_print: bool = emit_print
@@ -430,7 +580,15 @@ class Timer:
430
580
 
431
581
  @property
432
582
  def elapsed(self) -> float:
433
- """Elapsed time. Will be zero until a measurement is available with start/end."""
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
+ """
434
592
  if self.start is None or self.end is None:
435
593
  return 0.0
436
594
  delta: float = self.end - self.start
@@ -439,27 +597,48 @@ class Timer:
439
597
  return delta
440
598
 
441
599
  def __str__(self) -> str:
442
- """Current timer value."""
600
+ """Get current timer value.
601
+
602
+ Returns:
603
+ str: human-readable representation of current time value
604
+
605
+ """
443
606
  if self.start is None:
444
607
  return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
445
608
  if self.end is None:
446
- return ((f'{self.label}: ' if self.label else '') +
447
- f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}')
609
+ return (
610
+ f'{self.label}: ' if self.label else ''
611
+ ) + f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}'
448
612
  return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
449
613
 
450
614
  def Start(self) -> None:
451
- """Start the timer."""
615
+ """Start the timer.
616
+
617
+ Raises:
618
+ Error: if you try to re-start the timer
619
+
620
+ """
452
621
  if self.start is not None:
453
622
  raise Error('Re-starting timer is forbidden')
454
623
  self.start = time.perf_counter()
455
624
 
456
- def __enter__(self) -> Timer:
457
- """Start the timer when entering the context."""
625
+ def __enter__(self) -> Self:
626
+ """Start the timer when entering the context.
627
+
628
+ Returns:
629
+ Timer: context object (self)
630
+
631
+ """
458
632
  self.Start()
459
633
  return self
460
634
 
461
635
  def Stop(self) -> None:
462
- """Stop the timer and emit logging.info with timer message."""
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
+ """
463
642
  if self.start is None:
464
643
  raise Error('Stopping an unstarted timer')
465
644
  if self.end is not None:
@@ -469,21 +648,18 @@ class Timer:
469
648
  if self.emit_log:
470
649
  logging.info(message)
471
650
  if self.emit_print:
472
- print(message)
651
+ Console().print(message)
473
652
 
474
653
  def __exit__(
475
- self, unused_exc_type: type[BaseException] | None,
476
- unused_exc_val: BaseException | None, exc_tb: Any) -> None:
477
- """Stop the timer when exiting the context, emit logging.info and optionally print elapsed time.
478
-
479
- Args:
480
- exc_type (type | None): Exception type, if any.
481
- exc_val (BaseException | None): Exception value, if any.
482
- exc_tb (Any): Traceback object, if any.
483
- """
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."""
484
660
  self.Stop()
485
661
 
486
- _F = TypeVar('_F', bound=Callable[..., Any])
662
+ _F = TypeVar('_F', bound=abc.Callable[..., Any])
487
663
 
488
664
  def __call__(self, func: Timer._F) -> Timer._F:
489
665
  """Allow the Timer to be used as a decorator.
@@ -493,10 +669,11 @@ class Timer:
493
669
 
494
670
  Returns:
495
671
  The wrapped function with timing behavior.
672
+
496
673
  """
497
674
 
498
675
  @functools.wraps(func)
499
- def _Wrapper(*args: Any, **kwargs: Any) -> Any:
676
+ def _Wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
500
677
  with self.__class__(self.label, emit_log=self.emit_log, emit_print=self.emit_print):
501
678
  return func(*args, **kwargs)
502
679
 
@@ -518,9 +695,10 @@ def RandBits(n_bits: int, /) -> int:
518
695
 
519
696
  Raises:
520
697
  InputError: invalid n_bits
698
+
521
699
  """
522
700
  # test inputs
523
- if n_bits < 8:
701
+ if n_bits < 8: # noqa: PLR2004
524
702
  raise InputError(f'n_bits must be ≥ 8: {n_bits}')
525
703
  # call underlying method
526
704
  n: int = 0
@@ -541,6 +719,7 @@ def RandInt(min_int: int, max_int: int, /) -> int:
541
719
 
542
720
  Raises:
543
721
  InputError: invalid min/max
722
+
544
723
  """
545
724
  # test inputs
546
725
  if min_int < 0 or min_int >= max_int:
@@ -548,11 +727,11 @@ def RandInt(min_int: int, max_int: int, /) -> int:
548
727
  # uniform over [min_int, max_int]
549
728
  span: int = max_int - min_int + 1
550
729
  n: int = min_int + secrets.randbelow(span)
551
- assert min_int <= n <= max_int, 'should never happen: generated number out of range'
730
+ assert min_int <= n <= max_int, 'should never happen: generated number out of range' # noqa: S101
552
731
  return n
553
732
 
554
733
 
555
- def RandShuffle[T: Any](seq: MutableSequence[T], /) -> None:
734
+ def RandShuffle[T: Any](seq: abc.MutableSequence[T], /) -> None:
556
735
  """In-place Crypto-random shuffle order for `seq` mutable sequence.
557
736
 
558
737
  Args:
@@ -560,11 +739,12 @@ def RandShuffle[T: Any](seq: MutableSequence[T], /) -> None:
560
739
 
561
740
  Raises:
562
741
  InputError: not enough elements
742
+
563
743
  """
564
744
  # test inputs
565
- if (n_seq := len(seq)) < 2:
745
+ if (n_seq := len(seq)) < 2: # noqa: PLR2004
566
746
  raise InputError(f'seq must have 2 or more elements: {n_seq}')
567
- # cryptographically sound FisherYates using secrets.randbelow
747
+ # cryptographically sound Fisher-Yates using secrets.randbelow
568
748
  for i in range(n_seq - 1, 0, -1):
569
749
  j: int = secrets.randbelow(i + 1)
570
750
  seq[i], seq[j] = seq[j], seq[i]
@@ -581,13 +761,14 @@ def RandBytes(n_bytes: int, /) -> bytes:
581
761
 
582
762
  Raises:
583
763
  InputError: invalid n_bytes
764
+
584
765
  """
585
766
  # test inputs
586
767
  if n_bytes < 1:
587
768
  raise InputError(f'n_bytes must be ≥ 1: {n_bytes}')
588
769
  # return from system call
589
770
  b: bytes = secrets.token_bytes(n_bytes)
590
- assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size'
771
+ assert len(b) == n_bytes, 'should never happen: generated bytes incorrect size' # noqa: S101
591
772
  return b
592
773
 
593
774
 
@@ -605,6 +786,7 @@ def GCD(a: int, b: int, /) -> int:
605
786
 
606
787
  Raises:
607
788
  InputError: invalid inputs
789
+
608
790
  """
609
791
  # test inputs
610
792
  if a < 0 or b < 0 or (not a and not b):
@@ -634,6 +816,7 @@ def ExtendedGCD(a: int, b: int, /) -> tuple[int, int, int]:
634
816
 
635
817
  Raises:
636
818
  InputError: invalid inputs
819
+
637
820
  """
638
821
  # test inputs
639
822
  if a < 0 or b < 0 or (not a and not b):
@@ -665,6 +848,7 @@ def Hash256(data: bytes, /) -> bytes:
665
848
  32 bytes (256 bits) of SHA-256 hash;
666
849
  if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
667
850
  if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
851
+
668
852
  """
669
853
  return hashlib.sha256(data).digest()
670
854
 
@@ -679,6 +863,7 @@ def Hash512(data: bytes, /) -> bytes:
679
863
  64 bytes (512 bits) of SHA-512 hash;
680
864
  if converted to hexadecimal (with BytesToHex() or hex()) will be 128 chars of string;
681
865
  if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**512
866
+
682
867
  """
683
868
  return hashlib.sha512(data).digest()
684
869
 
@@ -697,17 +882,18 @@ def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
697
882
 
698
883
  Raises:
699
884
  InputError: file could not be found
885
+
700
886
  """
701
887
  # test inputs
702
888
  digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
703
- if digest not in ('sha256', 'sha512'):
889
+ if digest not in {'sha256', 'sha512'}:
704
890
  raise InputError(f'unrecognized digest: {digest!r}')
705
891
  full_path = full_path.strip()
706
- if not full_path or not os.path.exists(full_path):
892
+ if not full_path or not pathlib.Path(full_path).exists():
707
893
  raise InputError(f'file {full_path!r} not found for hashing')
708
894
  # compute hash
709
895
  logging.info(f'Hashing file {full_path!r}')
710
- with open(full_path, 'rb') as file_obj:
896
+ with pathlib.Path(full_path).open('rb') as file_obj:
711
897
  return hashlib.file_digest(file_obj, digest).digest()
712
898
 
713
899
 
@@ -721,31 +907,36 @@ def ObfuscateSecret(data: str | bytes | int, /) -> str:
721
907
  Args:
722
908
  data (str | bytes | int): Data to obfuscate
723
909
 
910
+ Raises:
911
+ InputError: _description_
912
+
724
913
  Returns:
725
- obfuscated string, e.g. "aabbccdd…"
914
+ str: obfuscated string, e.g. "aabbccdd…"
915
+
726
916
  """
727
917
  if isinstance(data, str):
728
918
  data = data.encode('utf-8')
729
919
  elif isinstance(data, int):
730
920
  data = IntToBytes(data)
731
- if not isinstance(data, bytes):
921
+ if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
732
922
  raise InputError(f'invalid type for data: {type(data)}')
733
923
  return BytesToHex(Hash512(data))[:8] + '…'
734
924
 
735
925
 
736
926
  class CryptoInputType(enum.StrEnum):
737
927
  """Types of inputs that can represent arbitrary bytes."""
928
+
738
929
  # prefixes; format prefixes are all 4 bytes
739
- PATH = '@' # @path on disk → read bytes from a file
740
- STDIN = '@-' # stdin
741
- HEX = 'hex:' # hex:deadbeef → decode hex
930
+ PATH = '@' # @path on disk → read bytes from a file
931
+ STDIN = '@-' # stdin
932
+ HEX = 'hex:' # hex:deadbeef → decode hex
742
933
  BASE64 = 'b64:' # b64:... → decode base64
743
- STR = 'str:' # str:hello → UTF-8 encode the literal
744
- RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
934
+ STR = 'str:' # str:hello → UTF-8 encode the literal
935
+ RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
745
936
 
746
937
 
747
938
  def BytesToRaw(b: bytes, /) -> str:
748
- """Convert bytes to double-quoted string with \\xNN escapes where needed.
939
+ r"""Convert bytes to double-quoted string with \\xNN escapes where needed.
749
940
 
750
941
  1. map bytes 0..255 to same code points (latin1)
751
942
  2. escape non-printables/backslash/quotes via unicode_escape
@@ -755,21 +946,23 @@ def BytesToRaw(b: bytes, /) -> str:
755
946
 
756
947
  Returns:
757
948
  str: double-quoted string with \\xNN escapes where needed
949
+
758
950
  """
759
951
  inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
760
- return f'"{inner.replace('"', r'\"')}"'
952
+ return f'"{inner.replace('"', r"\"")}"'
761
953
 
762
954
 
763
955
  def RawToBytes(s: str, /) -> bytes:
764
- """Convert double-quoted string with \\xNN escapes where needed to bytes.
956
+ r"""Convert double-quoted string with \\xNN escapes where needed to bytes.
765
957
 
766
958
  Args:
767
959
  s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
768
960
 
769
961
  Returns:
770
962
  bytes: data
963
+
771
964
  """
772
- if len(s) >= 2 and s[0] == s[-1] == '"':
965
+ if len(s) >= 2 and s[0] == s[-1] == '"': # noqa: PLR2004
773
966
  s = s[1:-1]
774
967
  # decode backslash escapes to code points, then map 0..255 -> bytes
775
968
  return codecs.decode(s, 'unicode_escape').encode('latin1')
@@ -784,21 +977,23 @@ def DetectInputType(data_str: str, /) -> CryptoInputType | None:
784
977
  Returns:
785
978
  CryptoInputType | None: type if has a known prefix, None otherwise
786
979
 
787
- Raises:
788
- InputError: unexpected type or conversion error
789
980
  """
790
981
  data_str = data_str.strip()
791
982
  if data_str == CryptoInputType.STDIN:
792
983
  return CryptoInputType.STDIN
793
984
  for t in (
794
- CryptoInputType.PATH, CryptoInputType.STR, CryptoInputType.HEX,
795
- CryptoInputType.BASE64, CryptoInputType.RAW):
985
+ CryptoInputType.PATH,
986
+ CryptoInputType.STR,
987
+ CryptoInputType.HEX,
988
+ CryptoInputType.BASE64,
989
+ CryptoInputType.RAW,
990
+ ):
796
991
  if data_str.startswith(t):
797
992
  return t
798
993
  return None
799
994
 
800
995
 
801
- def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # pylint:disable=too-many-return-statements
996
+ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # noqa: C901, PLR0911, PLR0912
802
997
  """Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
803
998
 
804
999
  Can load from disk ('@'). Can load from stdin ('@-').
@@ -815,6 +1010,7 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
815
1010
 
816
1011
  Raises:
817
1012
  InputError: unexpected type or conversion error
1013
+
818
1014
  """
819
1015
  data_str = data_str.strip()
820
1016
  # auto-detect
@@ -824,8 +1020,8 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
824
1020
  raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
825
1021
  # now we know they don't conflict, so unify them; remove prefix if we have it
826
1022
  expect = detected_type if expect is None else expect
827
- assert expect is not None, 'should never happen: type should be known here'
828
- data_str = data_str[len(expect):] if data_str.startswith(expect) else data_str
1023
+ assert expect is not None, 'should never happen: type should be known here' # noqa: S101
1024
+ data_str = data_str.removeprefix(expect)
829
1025
  # for every type something different will happen now
830
1026
  try:
831
1027
  match expect:
@@ -835,18 +1031,17 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
835
1031
  stream = getattr(sys.stdin, 'buffer', None)
836
1032
  if stream is None:
837
1033
  text: str = sys.stdin.read()
838
- if not isinstance(text, str): # type:ignore
839
- raise InputError('sys.stdin.read() produced non-text data')
1034
+ if not isinstance(text, str): # pyright: ignore[reportUnnecessaryIsInstance]
1035
+ raise InputError('sys.stdin.read() produced non-text data') # noqa: TRY301
840
1036
  return text.encode('utf-8')
841
1037
  data: bytes = stream.read()
842
- if not isinstance(data, bytes): # type:ignore
843
- raise InputError('sys.stdin.buffer.read() produced non-binary data')
1038
+ if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
1039
+ raise InputError('sys.stdin.buffer.read() produced non-binary data') # noqa: TRY301
844
1040
  return data
845
1041
  case CryptoInputType.PATH:
846
- if not os.path.exists(data_str):
847
- raise InputError(f'cannot find file {data_str!r}')
848
- with open(data_str, 'rb') as file_obj:
849
- return file_obj.read()
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()
850
1045
  case CryptoInputType.STR:
851
1046
  return data_str.encode('utf-8')
852
1047
  case CryptoInputType.HEX:
@@ -856,24 +1051,27 @@ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -
856
1051
  case CryptoInputType.RAW:
857
1052
  return RawToBytes(data_str)
858
1053
  case _:
859
- raise InputError(f'invalid type {expect!r}')
1054
+ raise InputError(f'invalid type {expect!r}') # noqa: TRY301
860
1055
  except Exception as err:
861
1056
  raise InputError(f'invalid input: {err}') from err
862
1057
 
863
1058
 
864
1059
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
865
- class CryptoKey(abc.ABC):
1060
+ class CryptoKey(abstract.ABC):
866
1061
  """A cryptographic key."""
867
1062
 
1063
+ @abstract.abstractmethod
868
1064
  def __post_init__(self) -> None:
869
1065
  """Check data."""
1066
+ # every sub-class of CryptoKey has to implement its own version of __post_init__()
870
1067
 
871
- @abc.abstractmethod
1068
+ @abstract.abstractmethod
872
1069
  def __str__(self) -> str:
873
1070
  """Safe (no secrets) string representation of the key.
874
1071
 
875
1072
  Returns:
876
1073
  string representation of the key without leaking secrets
1074
+
877
1075
  """
878
1076
  # every sub-class of CryptoKey has to implement its own version of __str__()
879
1077
 
@@ -883,6 +1081,7 @@ class CryptoKey(abc.ABC):
883
1081
 
884
1082
  Returns:
885
1083
  string representation of the key without leaking secrets
1084
+
886
1085
  """
887
1086
  # concrete __repr__() delegates to the (abstract) __str__():
888
1087
  # this avoids marking __repr__() abstract while still unifying behavior
@@ -898,12 +1097,13 @@ class CryptoKey(abc.ABC):
898
1097
 
899
1098
  Returns:
900
1099
  string with all the object's fields explicit values
1100
+
901
1101
  """
902
1102
  cls: str = type(self).__name__
903
1103
  parts: list[str] = []
904
1104
  for field in dataclasses.fields(self):
905
1105
  val: Any = getattr(self, field.name) # getattr is fine with frozen/slots
906
- parts.append(f'{field.name}={repr(val)}')
1106
+ parts.append(f'{field.name}={val!r}')
907
1107
  return f'{cls}({", ".join(parts)})'
908
1108
 
909
1109
  @final
@@ -916,13 +1116,15 @@ class CryptoKey(abc.ABC):
916
1116
 
917
1117
  Raises:
918
1118
  ImplementationError: object has types that are not supported in JSON
1119
+
919
1120
  """
920
1121
  self_dict: dict[str, Any] = dataclasses.asdict(self)
921
1122
  for field in dataclasses.fields(self):
922
1123
  # check the type is OK
923
1124
  if field.type not in _JSON_DATACLASS_TYPES:
924
1125
  raise ImplementationError(
925
- f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}')
1126
+ f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
1127
+ )
926
1128
  # convert types that we accept but JSON does not
927
1129
  if field.type == 'bytes':
928
1130
  self_dict[field.name] = BytesToEncoded(self_dict[field.name])
@@ -936,8 +1138,6 @@ class CryptoKey(abc.ABC):
936
1138
  Returns:
937
1139
  str: JSON representation of the object, tightly packed
938
1140
 
939
- Raises:
940
- ImplementationError: object has types that are not supported in JSON
941
1141
  """
942
1142
  return json.dumps(self._json_dict, separators=(',', ':'))
943
1143
 
@@ -949,8 +1149,6 @@ class CryptoKey(abc.ABC):
949
1149
  Returns:
950
1150
  str: JSON representation of the object formatted for humans
951
1151
 
952
- Raises:
953
- ImplementationError: object has types that are not supported in JSON
954
1152
  """
955
1153
  return json.dumps(self._json_dict, indent=4, sort_keys=True)
956
1154
 
@@ -967,9 +1165,11 @@ class CryptoKey(abc.ABC):
967
1165
 
968
1166
  Raises:
969
1167
  InputError: unexpected type/fields
1168
+ ImplementationError: unsupported JSON field
1169
+
970
1170
  """
971
1171
  # check we got exactly the fields we needed
972
- cls_fields: set[str] = set(f.name for f in dataclasses.fields(cls))
1172
+ cls_fields: set[str] = {f.name for f in dataclasses.fields(cls)}
973
1173
  json_fields: set[str] = set(json_dict)
974
1174
  if cls_fields != json_fields:
975
1175
  raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
@@ -977,7 +1177,8 @@ class CryptoKey(abc.ABC):
977
1177
  for field in dataclasses.fields(cls):
978
1178
  if field.type not in _JSON_DATACLASS_TYPES:
979
1179
  raise ImplementationError(
980
- f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}')
1180
+ f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
1181
+ )
981
1182
  if field.type == 'bytes':
982
1183
  json_dict[field.name] = EncodedToBytes(json_dict[field.name])
983
1184
  # build the object
@@ -996,10 +1197,11 @@ class CryptoKey(abc.ABC):
996
1197
 
997
1198
  Raises:
998
1199
  InputError: unexpected type/fields
1200
+
999
1201
  """
1000
1202
  # get the dict back
1001
1203
  json_dict: dict[str, Any] = json.loads(json_data)
1002
- if not isinstance(json_dict, dict): # type:ignore
1204
+ if not isinstance(json_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
1003
1205
  raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
1004
1206
  return cls._FromJSONDict(json_dict)
1005
1207
 
@@ -1010,12 +1212,13 @@ class CryptoKey(abc.ABC):
1010
1212
 
1011
1213
  Returns:
1012
1214
  bytes, pickled, representation of the object
1215
+
1013
1216
  """
1014
1217
  return self.Blob()
1015
1218
 
1016
1219
  @final
1017
1220
  def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
1018
- """Serial (bytes) representation of the object with more options, including encryption.
1221
+ """Get serial (bytes) representation of the object with more options, including encryption.
1019
1222
 
1020
1223
  Args:
1021
1224
  key (Encryptor, optional): if given will key.Encrypt() data before saving
@@ -1023,6 +1226,7 @@ class CryptoKey(abc.ABC):
1023
1226
 
1024
1227
  Returns:
1025
1228
  bytes, pickled, representation of the object
1229
+
1026
1230
  """
1027
1231
  return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
1028
1232
 
@@ -1033,6 +1237,7 @@ class CryptoKey(abc.ABC):
1033
1237
 
1034
1238
  Returns:
1035
1239
  str, pickled, base64, representation of the object
1240
+
1036
1241
  """
1037
1242
  return self.Encoded()
1038
1243
 
@@ -1046,6 +1251,7 @@ class CryptoKey(abc.ABC):
1046
1251
 
1047
1252
  Returns:
1048
1253
  str, pickled, base64, representation of the object
1254
+
1049
1255
  """
1050
1256
  return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
1051
1257
 
@@ -1056,6 +1262,7 @@ class CryptoKey(abc.ABC):
1056
1262
 
1057
1263
  Returns:
1058
1264
  str, pickled, hexadecimal, representation of the object
1265
+
1059
1266
  """
1060
1267
  return self.Hex()
1061
1268
 
@@ -1069,6 +1276,7 @@ class CryptoKey(abc.ABC):
1069
1276
 
1070
1277
  Returns:
1071
1278
  str, pickled, hexadecimal, representation of the object
1279
+
1072
1280
  """
1073
1281
  return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
1074
1282
 
@@ -1079,6 +1287,7 @@ class CryptoKey(abc.ABC):
1079
1287
 
1080
1288
  Returns:
1081
1289
  str, pickled, raw escaped binary, representation of the object
1290
+
1082
1291
  """
1083
1292
  return self.Raw()
1084
1293
 
@@ -1092,13 +1301,13 @@ class CryptoKey(abc.ABC):
1092
1301
 
1093
1302
  Returns:
1094
1303
  str, pickled, raw escaped binary, representation of the object
1304
+
1095
1305
  """
1096
1306
  return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
1097
1307
 
1098
1308
  @final
1099
1309
  @classmethod
1100
- def Load(
1101
- cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
1310
+ def Load(cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
1102
1311
  """Load (create) object from serialized bytes or string.
1103
1312
 
1104
1313
  Args:
@@ -1109,6 +1318,10 @@ class CryptoKey(abc.ABC):
1109
1318
 
1110
1319
  Returns:
1111
1320
  a CryptoKey object ready for use
1321
+
1322
+ Raises:
1323
+ InputError: decode error
1324
+
1112
1325
  """
1113
1326
  # if this is a string, then we suppose it is base64
1114
1327
  if isinstance(data, str):
@@ -1116,15 +1329,16 @@ class CryptoKey(abc.ABC):
1116
1329
  # we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
1117
1330
  try:
1118
1331
  json_dict: dict[str, Any] = DeSerialize(
1119
- data=data, key=key, silent=silent, unpickler=UnpickleJSON)
1332
+ data=data, key=key, silent=silent, unpickler=UnpickleJSON
1333
+ )
1120
1334
  return cls._FromJSONDict(json_dict)
1121
1335
  except Exception as err:
1122
1336
  raise InputError(f'input decode error: {err}') from err
1123
1337
 
1124
1338
 
1125
1339
  @runtime_checkable
1126
- class Encryptor(Protocol): # pylint: disable=too-few-public-methods
1127
- """Abstract interface for a class that has encryption
1340
+ class Encryptor(Protocol):
1341
+ """Abstract interface for a class that has encryption.
1128
1342
 
1129
1343
  Contract:
1130
1344
  - If algorithm accepts a `nonce` or `tag` these have to be handled internally by the
@@ -1137,9 +1351,10 @@ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
1137
1351
  Metadata like nonce/tag may be:
1138
1352
  - returned alongside `ciphertext`/`signature`, or
1139
1353
  - bundled/serialized into `ciphertext`/`signature` by the implementation.
1354
+
1140
1355
  """
1141
1356
 
1142
- @abc.abstractmethod
1357
+ @abstract.abstractmethod
1143
1358
  def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
1144
1359
  """Encrypt `plaintext` and return `ciphertext`.
1145
1360
 
@@ -1155,14 +1370,15 @@ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
1155
1370
  Raises:
1156
1371
  InputError: invalid inputs
1157
1372
  CryptoError: internal crypto failures
1373
+
1158
1374
  """
1159
1375
 
1160
1376
 
1161
1377
  @runtime_checkable
1162
- class Decryptor(Protocol): # pylint: disable=too-few-public-methods
1378
+ class Decryptor(Protocol):
1163
1379
  """Abstract interface for a class that has decryption (see contract/notes in Encryptor)."""
1164
1380
 
1165
- @abc.abstractmethod
1381
+ @abstract.abstractmethod
1166
1382
  def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
1167
1383
  """Decrypt `ciphertext` and return the original `plaintext`.
1168
1384
 
@@ -1176,16 +1392,18 @@ class Decryptor(Protocol): # pylint: disable=too-few-public-methods
1176
1392
  Raises:
1177
1393
  InputError: invalid inputs
1178
1394
  CryptoError: internal crypto failures, authentication failure, key mismatch, etc
1395
+
1179
1396
  """
1180
1397
 
1181
1398
 
1182
1399
  @runtime_checkable
1183
- class Verifier(Protocol): # pylint: disable=too-few-public-methods
1400
+ class Verifier(Protocol):
1184
1401
  """Abstract interface for asymmetric signature verify. (see contract/notes in Encryptor)."""
1185
1402
 
1186
- @abc.abstractmethod
1403
+ @abstract.abstractmethod
1187
1404
  def Verify(
1188
- self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None) -> bool:
1405
+ self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None
1406
+ ) -> bool:
1189
1407
  """Verify a `signature` for `message`. True if OK; False if failed verification.
1190
1408
 
1191
1409
  Args:
@@ -1199,14 +1417,15 @@ class Verifier(Protocol): # pylint: disable=too-few-public-methods
1199
1417
  Raises:
1200
1418
  InputError: invalid inputs
1201
1419
  CryptoError: internal crypto failures, authentication failure, key mismatch, etc
1420
+
1202
1421
  """
1203
1422
 
1204
1423
 
1205
1424
  @runtime_checkable
1206
- class Signer(Protocol): # pylint: disable=too-few-public-methods
1425
+ class Signer(Protocol):
1207
1426
  """Abstract interface for asymmetric signing. (see contract/notes in Encryptor)."""
1208
1427
 
1209
- @abc.abstractmethod
1428
+ @abstract.abstractmethod
1210
1429
  def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
1211
1430
  """Sign `message` and return the `signature`.
1212
1431
 
@@ -1222,13 +1441,20 @@ class Signer(Protocol): # pylint: disable=too-few-public-methods
1222
1441
  Raises:
1223
1442
  InputError: invalid inputs
1224
1443
  CryptoError: internal crypto failures
1444
+
1225
1445
  """
1226
1446
 
1227
1447
 
1228
- def Serialize( # pylint:disable=too-many-arguments
1229
- python_obj: Any, /, *, file_path: str | None = None,
1230
- compress: int | None = 3, key: Encryptor | None = None, silent: bool = False,
1231
- pickler: Callable[[Any], bytes] = PickleGeneric) -> bytes:
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:
1232
1458
  """Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
1233
1459
 
1234
1460
  Data path is:
@@ -1240,14 +1466,14 @@ def Serialize( # pylint:disable=too-many-arguments
1240
1466
 
1241
1467
  Compression levels / speed can be controlled by `compress`. Use this as reference:
1242
1468
 
1243
- | Level | Speed | Compression ratio | Typical use case |
1244
- | -------- | ------------| --------------------------------- | --------------------------------------- |
1245
- | -5 to -1 | Fastest | Poor (better than no compression) | Real-time or very latency-sensitive |
1246
- | 0…3 | Very fast | Good ratio | Default CLI choice, safe baseline |
1247
- | 4…6 | Moderate | Better ratio | Good compromise for general persistence |
1248
- | 7…10 | Slower | Marginally better ratio | Only if storage space is precious |
1249
- | 11…15 | Much slower | Slight gains | Large archives, not for runtime use |
1250
- | 16…22 | Very slow | Tiny gains | Archival-only, multi-GB datasets |
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 |
1251
1477
 
1252
1478
  Args:
1253
1479
  python_obj (Any): serializable Python object
@@ -1262,6 +1488,7 @@ def Serialize( # pylint:disable=too-many-arguments
1262
1488
 
1263
1489
  Returns:
1264
1490
  bytes: serialized binary data corresponding to obj + (compression) + (encryption)
1491
+
1265
1492
  """
1266
1493
  messages: list[str] = []
1267
1494
  with Timer('Serialization complete', emit_log=False) as tm_all:
@@ -1272,8 +1499,8 @@ def Serialize( # pylint:disable=too-many-arguments
1272
1499
  messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
1273
1500
  # compress, if needed
1274
1501
  if compress is not None:
1275
- compress = -22 if compress < -22 else compress
1276
- compress = 22 if compress > 22 else compress
1502
+ compress = max(compress, -22)
1503
+ compress = min(compress, 22)
1277
1504
  with Timer(f'COMPRESS@{compress}', emit_log=False) as tm_compress:
1278
1505
  obj = zstandard.ZstdCompressor(level=compress).compress(obj)
1279
1506
  if not silent:
@@ -1287,21 +1514,24 @@ def Serialize( # pylint:disable=too-many-arguments
1287
1514
  # optionally save to disk
1288
1515
  if file_path is not None:
1289
1516
  with Timer('SAVE', emit_log=False) as tm_save:
1290
- with open(file_path, 'wb') as file_obj:
1291
- file_obj.write(obj)
1517
+ pathlib.Path(file_path).write_bytes(obj)
1292
1518
  if not silent:
1293
1519
  messages.append(f' {tm_save}, to {file_path!r}')
1294
1520
  # log and return
1295
1521
  if not silent:
1296
- logging.info(f'{tm_all}; parts:\n' + '\n'.join(messages))
1522
+ logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
1297
1523
  return obj
1298
1524
 
1299
1525
 
1300
- def DeSerialize(
1301
- *, data: bytes | None = None, file_path: str | None = None,
1302
- key: Decryptor | None = None, silent: bool = False,
1303
- unpickler: Callable[[bytes], Any] = UnpickleGeneric) -> Any:
1304
- """Loads (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
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.
1305
1535
 
1306
1536
  Data path is:
1307
1537
 
@@ -1312,15 +1542,17 @@ def DeSerialize(
1312
1542
  Compression versus no compression will be automatically detected.
1313
1543
 
1314
1544
  Args:
1315
- data (bytes, optional): if given, use this as binary data string (input);
1316
- if you use this option, `file_path` will be ignored
1317
- file_path (str, optional): if given, use this as file path to load binary data string (input);
1318
- if you use this option, `data` will be ignored
1319
- key (Decryptor, optional): if given will key.Decrypt() data before decompressing/loading
1320
- silent (bool, optional): if True will not log; default is False (will log)
1321
- pickler (Callable[[bytes], Any], optional): if not given, will just be the `pickle` module;
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;
1322
1553
  if given will be a method to convert a `bytes` representation back to a Python object;
1323
- UnpickleGeneric is the default, but another useful value is UnpickleJSON
1554
+ UnpickleGeneric is the default, but another useful value is UnpickleJSON.
1555
+ Defaults to UnpickleGeneric.
1324
1556
 
1325
1557
  Returns:
1326
1558
  De-Serialized Python object corresponding to data
@@ -1328,24 +1560,24 @@ def DeSerialize(
1328
1560
  Raises:
1329
1561
  InputError: invalid inputs
1330
1562
  CryptoError: internal crypto failures, authentication failure, key mismatch, etc
1331
- """
1563
+
1564
+ """ # noqa: DOC502
1332
1565
  # test inputs
1333
1566
  if (data is None and file_path is None) or (data is not None and file_path is not None):
1334
1567
  raise InputError('you must provide only one of either `data` or `file_path`')
1335
- if file_path and not os.path.exists(file_path):
1568
+ if file_path and not pathlib.Path(file_path).exists():
1336
1569
  raise InputError(f'invalid file_path: {file_path!r}')
1337
- if data and len(data) < 4:
1570
+ if data and len(data) < 4: # noqa: PLR2004
1338
1571
  raise InputError('invalid data: too small')
1339
1572
  # start the pipeline
1340
- obj: bytes = data if data else b''
1573
+ obj: bytes = data or b''
1341
1574
  messages: list[str] = [f'DATA: {HumanizedBytes(len(obj))}'] if data and not silent else []
1342
1575
  with Timer('De-Serialization complete', emit_log=False) as tm_all:
1343
1576
  # optionally load from disk
1344
1577
  if file_path:
1345
- assert not obj, 'should never happen: if we have a file obj should be empty'
1578
+ assert not obj, 'should never happen: if we have a file obj should be empty' # noqa: S101
1346
1579
  with Timer('LOAD', emit_log=False) as tm_load:
1347
- with open(file_path, 'rb') as file_obj:
1348
- obj = file_obj.read()
1580
+ obj = pathlib.Path(file_path).read_bytes()
1349
1581
  if not silent:
1350
1582
  messages.append(f' {tm_load}, {HumanizedBytes(len(obj))}, from {file_path!r}')
1351
1583
  # decrypt, if needed
@@ -1355,16 +1587,19 @@ def DeSerialize(
1355
1587
  if not silent:
1356
1588
  messages.append(f' {tm_crypto}, {HumanizedBytes(len(obj))}')
1357
1589
  # decompress: we try to detect compression to determine if we must call zstandard
1358
- if (len(obj) >= 4 and
1359
- (((magic := int.from_bytes(obj[:4], 'little')) == _ZSTD_MAGIC_FRAME) or
1360
- (_ZSTD_MAGIC_SKIPPABLE_MIN <= magic <= _ZSTD_MAGIC_SKIPPABLE_MAX))):
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
+ ):
1361
1597
  with Timer('DECOMPRESS', emit_log=False) as tm_decompress:
1362
1598
  obj = zstandard.ZstdDecompressor().decompress(obj)
1363
1599
  if not silent:
1364
1600
  messages.append(f' {tm_decompress}, {HumanizedBytes(len(obj))}')
1365
- else:
1366
- if not silent:
1367
- messages.append(' (no compression detected)')
1601
+ elif not silent:
1602
+ messages.append(' (no compression detected)')
1368
1603
  # create the actual object = unpickle
1369
1604
  with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
1370
1605
  python_obj: Any = unpickler(obj)
@@ -1372,7 +1607,7 @@ def DeSerialize(
1372
1607
  messages.append(f' {tm_unpickle}')
1373
1608
  # log and return
1374
1609
  if not silent:
1375
- logging.info(f'{tm_all}; parts:\n' + '\n'.join(messages))
1610
+ logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
1376
1611
  return python_obj
1377
1612
 
1378
1613
 
@@ -1390,6 +1625,7 @@ class PublicBid512(CryptoKey):
1390
1625
  Attributes:
1391
1626
  public_key (bytes): 512-bits random value
1392
1627
  public_hash (bytes): SHA-512 hash of (public_key || private_key || secret_bid)
1628
+
1393
1629
  """
1394
1630
 
1395
1631
  public_key: bytes
@@ -1400,9 +1636,9 @@ class PublicBid512(CryptoKey):
1400
1636
 
1401
1637
  Raises:
1402
1638
  InputError: invalid inputs
1639
+
1403
1640
  """
1404
- super(PublicBid512, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
1405
- if len(self.public_key) != 64 or len(self.public_hash) != 64:
1641
+ if len(self.public_key) != 64 or len(self.public_hash) != 64: # noqa: PLR2004
1406
1642
  raise InputError(f'invalid public_key or public_hash: {self}')
1407
1643
 
1408
1644
  def __str__(self) -> str:
@@ -1410,10 +1646,13 @@ class PublicBid512(CryptoKey):
1410
1646
 
1411
1647
  Returns:
1412
1648
  string representation of PublicBid
1649
+
1413
1650
  """
1414
- return ('PublicBid512('
1415
- f'public_key={BytesToEncoded(self.public_key)}, '
1416
- f'public_hash={BytesToHex(self.public_hash)})')
1651
+ return (
1652
+ 'PublicBid512('
1653
+ f'public_key={BytesToEncoded(self.public_key)}, '
1654
+ f'public_hash={BytesToHex(self.public_hash)})'
1655
+ )
1417
1656
 
1418
1657
  def VerifyBid(self, private_key: bytes, secret: bytes, /) -> bool:
1419
1658
  """Verify a bid. True if OK; False if failed verification.
@@ -1425,21 +1664,30 @@ class PublicBid512(CryptoKey):
1425
1664
  Returns:
1426
1665
  True if bid is valid, False otherwise
1427
1666
 
1428
- Raises:
1429
- InputError: invalid inputs
1430
1667
  """
1431
1668
  try:
1432
1669
  # creating the PrivateBid object will validate everything; InputError we allow to propagate
1433
1670
  PrivateBid512(
1434
- public_key=self.public_key, public_hash=self.public_hash,
1435
- private_key=private_key, secret_bid=secret)
1671
+ public_key=self.public_key,
1672
+ public_hash=self.public_hash,
1673
+ private_key=private_key,
1674
+ secret_bid=secret,
1675
+ )
1436
1676
  return True # if we got here, all is good
1437
1677
  except CryptoError:
1438
1678
  return False # bid does not match the public commitment
1439
1679
 
1440
1680
  @classmethod
1441
1681
  def Copy(cls, other: PublicBid512, /) -> Self:
1442
- """Initialize a public bid by taking the public parts of a public/private bid."""
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
+ """
1443
1691
  return cls(public_key=other.public_key, public_hash=other.public_hash)
1444
1692
 
1445
1693
 
@@ -1450,6 +1698,7 @@ class PrivateBid512(PublicBid512):
1450
1698
  Attributes:
1451
1699
  private_key (bytes): 512-bits random value
1452
1700
  secret_bid (bytes): Any number of bytes (≥1) to bid on (e.g., UTF-8 encoded string)
1701
+
1453
1702
  """
1454
1703
 
1455
1704
  private_key: bytes
@@ -1461,9 +1710,10 @@ class PrivateBid512(PublicBid512):
1461
1710
  Raises:
1462
1711
  InputError: invalid inputs
1463
1712
  CryptoError: bid does not match the public commitment
1713
+
1464
1714
  """
1465
- super(PrivateBid512, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
1466
- if len(self.private_key) != 64 or len(self.secret_bid) < 1:
1715
+ super(PrivateBid512, self).__post_init__()
1716
+ if len(self.private_key) != 64 or len(self.secret_bid) < 1: # noqa: PLR2004
1467
1717
  raise InputError(f'invalid private_key or secret_bid: {self}')
1468
1718
  if self.public_hash != Hash512(self.public_key + self.private_key + self.secret_bid):
1469
1719
  raise CryptoError(f'inconsistent bid: {self}')
@@ -1473,11 +1723,14 @@ class PrivateBid512(PublicBid512):
1473
1723
 
1474
1724
  Returns:
1475
1725
  string representation of PrivateBid without leaking secrets
1726
+
1476
1727
  """
1477
- return ('PrivateBid512('
1478
- f'{super(PrivateBid512, self).__str__()}, ' # pylint: disable=super-with-arguments
1479
- f'private_key={ObfuscateSecret(self.private_key)}, '
1480
- f'secret_bid={ObfuscateSecret(self.secret_bid)})')
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
+ )
1481
1734
 
1482
1735
  @classmethod
1483
1736
  def New(cls, secret: bytes, /) -> Self:
@@ -1491,199 +1744,175 @@ class PrivateBid512(PublicBid512):
1491
1744
 
1492
1745
  Raises:
1493
1746
  InputError: invalid inputs
1747
+
1494
1748
  """
1495
1749
  # test inputs
1496
1750
  if len(secret) < 1:
1497
1751
  raise InputError(f'invalid secret length: {len(secret)}')
1498
1752
  # generate random values
1499
- public_key: bytes = RandBytes(64) # 512 bits
1753
+ public_key: bytes = RandBytes(64) # 512 bits
1500
1754
  private_key: bytes = RandBytes(64) # 512 bits
1501
1755
  # build object
1502
1756
  return cls(
1503
- public_key=public_key,
1504
- public_hash=Hash512(public_key + private_key + secret),
1505
- private_key=private_key,
1506
- secret_bid=secret)
1507
-
1508
-
1509
- def _FlagNames(a: argparse.Action, /) -> list[str]:
1510
- # Positional args have empty 'option_strings'; otherwise use them (e.g., ['-v','--verbose'])
1511
- if a.option_strings:
1512
- return list(a.option_strings)
1513
- if a.nargs:
1514
- if isinstance(a.metavar, str) and a.metavar:
1515
- # e.g., nargs=2, metavar='FILE'
1516
- return [a.metavar]
1517
- if isinstance(a.metavar, tuple):
1518
- # e.g., nargs=2, metavar=('FILE1', 'FILE2')
1519
- return list(a.metavar)
1520
- # Otherwise, it’s a positional arg with no flags, so return the destination name
1521
- return [a.dest]
1522
-
1523
-
1524
- def _ActionIsSubparser(a: argparse.Action, /) -> bool:
1525
- return isinstance(a, argparse._SubParsersAction) # type: ignore[attr-defined] # pylint: disable=protected-access
1526
-
1527
-
1528
- def _FormatDefault(a: argparse.Action, /) -> str:
1529
- if a.default is argparse.SUPPRESS:
1530
- return ''
1531
- if isinstance(a.default, bool):
1532
- return ' (default: on)' if a.default else ''
1533
- if a.default in (None, '', 0, False):
1534
- return ''
1535
- return f' (default: {a.default})'
1536
-
1537
-
1538
- def _FormatChoices(a: argparse.Action, /) -> str:
1539
- return f' choices: {list(a.choices)}' if getattr(a, 'choices', None) else '' # type:ignore
1540
-
1541
-
1542
- def _FormatType(a: argparse.Action, /) -> str:
1543
- t: Any | None = getattr(a, 'type', None)
1544
- if t is None:
1545
- return ''
1546
- # Show clean type names (int, str, float); for callables, just say 'custom'
1547
- return f' type: {t.__name__ if hasattr(t, "__name__") else "custom"}'
1548
-
1549
-
1550
- def _FormatNArgs(a: argparse.Action, /) -> str:
1551
- return f' nargs: {a.nargs}' if getattr(a, 'nargs', None) not in (None, 0) else ''
1552
-
1553
-
1554
- def _RowsForActions(actions: Sequence[argparse.Action], /) -> list[tuple[str, str]]:
1555
- rows: list[tuple[str, str]] = []
1556
- for a in actions:
1557
- if _ActionIsSubparser(a):
1558
- continue
1559
- # skip the built-in help action; it’s implied
1560
- if getattr(a, 'help', '') == argparse.SUPPRESS or isinstance(a, argparse._HelpAction): # type: ignore[attr-defined] # pylint: disable=protected-access
1561
- continue
1562
- flags: str = ', '.join(_FlagNames(a))
1563
- meta: str = ''.join(
1564
- (_FormatType(a), _FormatNArgs(a), _FormatChoices(a), _FormatDefault(a))).strip()
1565
- desc: str = (a.help or '').strip()
1566
- if meta:
1567
- desc = f'{desc} [{meta}]' if desc else f'[{meta}]'
1568
- rows.append((flags, desc))
1569
- return rows
1570
-
1571
-
1572
- def _MarkdownTable(
1573
- rows: Sequence[tuple[str, str]],
1574
- headers: tuple[str, str] = ('Option/Arg', 'Description'), /) -> str:
1575
- if not rows:
1576
- return ''
1577
- out: list[str] = ['| ' + headers[0] + ' | ' + headers[1] + ' |', '|---|---|']
1578
- for left, right in rows:
1579
- out.append(f'| `{left}` | {right} |')
1580
- return '\n'.join(out)
1581
-
1582
-
1583
- def _WalkSubcommands(
1584
- parser: argparse.ArgumentParser, path: list[str] | None = None, /) -> list[
1585
- tuple[list[str], argparse.ArgumentParser, Any]]:
1586
- path = path or []
1587
- items: list[tuple[list[str], argparse.ArgumentParser, Any]] = []
1588
- # sub_action = None
1589
- name: str
1590
- sp: argparse.ArgumentParser
1591
- for action in parser._actions: # type: ignore[attr-defined] # pylint: disable=protected-access
1592
- if _ActionIsSubparser(action):
1593
- # sub_action = a # type: ignore[assignment]
1594
- for name, sp in action.choices.items(): # type:ignore
1595
- items.append((path + [name], sp, action)) # type:ignore
1596
- items.extend(_WalkSubcommands(sp, path + [name])) # type:ignore
1597
- return items
1598
-
1599
-
1600
- def _HelpText(sub_parser: argparse.ArgumentParser, parent_sub_action: Any, /) -> str:
1601
- if parent_sub_action is not None:
1602
- for choice_action in parent_sub_action._choices_actions: # type: ignore # pylint: disable=protected-access
1603
- if choice_action.dest == sub_parser.prog.split()[-1]:
1604
- return choice_action.help or ''
1605
- return ''
1606
-
1607
-
1608
- def GenerateCLIMarkdown( # pylint:disable=too-many-locals,too-many-statements
1609
- prog: str, parser: argparse.ArgumentParser, /, *, description: str = '') -> str: # pylint: disable=too-many-locals
1610
- """Return a Markdown doc section that reflects the current _BuildParser() tree.
1611
-
1612
- Will treat epilog strings as examples, splitting on '$$' to get multiple examples.
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.
1613
1865
 
1614
1866
  Args:
1615
- prog (str): name of app, eg. 'transcrypto' or 'transcrypto.py'
1616
- parser (argparse.ArgumentParser): parser to use for data
1617
- description (str, optional): app description to use as intro
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").
1618
1871
 
1619
1872
  Returns:
1620
- str: markdown
1873
+ Markdown string.
1621
1874
 
1622
- Raises:
1623
- InputError: invalid app name
1624
1875
  """
1625
- prog, description = prog.strip(), description.strip()
1626
- if not prog or prog not in parser.prog:
1627
- raise InputError(f'invalid prog/parser.prog: {prog=}, {parser.prog=}')
1628
- lines: list[str] = ['']
1629
- lines.append('<!-- cspell:disable -->')
1630
- lines.append('<!-- auto-generated; do not edit -->\n')
1631
- # Header + global flags
1632
- lines.append(f'# `{prog}` Command-Line Interface\n')
1633
- lines.append(description + '\n')
1634
- lines.append('Invoke with:\n')
1635
- lines.append('```bash')
1636
- lines.append(f'{parser.prog} <command> [sub-command] [options...]')
1637
- lines.append('```\n')
1638
- # Global options table
1639
- global_rows: list[tuple[str, str]] = _RowsForActions(parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
1640
- if global_rows:
1641
- lines.append('## Global Options\n')
1642
- lines.append(_MarkdownTable(global_rows))
1643
- lines.append('')
1644
- # Top-level commands summary
1645
- lines.append('## Top-Level Commands\n')
1646
- # Find top-level subparsers to list available commands
1647
- top_subs: list[argparse.Action] = [a for a in parser._actions if _ActionIsSubparser(a)] # type: ignore[attr-defined] # pylint: disable=protected-access
1648
- for action in top_subs:
1649
- for name, sp in action.choices.items(): # type: ignore[union-attr]
1650
- help_text: str = ( # type:ignore
1651
- sp.description or ' '.join(i.strip() for i in sp.format_usage().splitlines())).strip() # type:ignore
1652
- short: str = (sp.help if hasattr(sp, 'help') else '') or '' # type:ignore
1653
- help_text = short or help_text # type:ignore
1654
- help_text = help_text.replace('usage: ', '').strip() # type:ignore
1655
- lines.append(f'- **`{name}`** — `{help_text}`')
1656
- lines.append('')
1657
- if parser.epilog:
1658
- lines.append('```bash')
1659
- lines.append(parser.epilog)
1660
- lines.append('```\n')
1661
- # Detailed sections per (sub)command
1662
- for path, sub_parser, parent_sub_action in _WalkSubcommands(parser):
1663
- if len(path) == 1:
1664
- lines.append('---\n') # horizontal rule between top-level commands
1665
- header: str = ' '.join(path)
1666
- lines.append(f'##{"" if len(path) == 1 else "#"} `{header}`') # (header level 3 or 4)
1667
- # Usage block
1668
- help_text = _HelpText(sub_parser, parent_sub_action)
1669
- if help_text:
1670
- lines.append(f'\n{help_text}')
1671
- usage: str = sub_parser.format_usage().replace('usage: ', '').strip()
1672
- lines.append('\n```bash')
1673
- lines.append(str(usage))
1674
- lines.append('```\n')
1675
- # Options/args table
1676
- rows: list[tuple[str, str]] = _RowsForActions(sub_parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
1677
- if rows:
1678
- lines.append(_MarkdownTable(rows))
1679
- lines.append('')
1680
- # Examples (if any) - stored in epilog argument
1681
- epilog: str = sub_parser.epilog.strip() if sub_parser.epilog else ''
1682
- if epilog:
1683
- lines.append('**Example:**\n')
1684
- lines.append('```bash')
1685
- for epilog_line in epilog.split('$$'):
1686
- lines.append(f'$ {parser.prog} {epilog_line.strip()}')
1687
- lines.append('```\n')
1688
- # join all lines as the markdown string
1689
- return ('\n'.join(lines)).strip()
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()