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