transcrypto 1.2.0__py3-none-any.whl → 1.4.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
@@ -7,23 +7,32 @@
7
7
  from __future__ import annotations
8
8
 
9
9
  import abc
10
+ import argparse
10
11
  import base64
12
+ import codecs
11
13
  import dataclasses
12
14
  # import datetime
15
+ import enum
13
16
  import functools
14
17
  import hashlib
18
+ import json
15
19
  import logging
16
20
  import math
17
21
  import os.path
18
22
  import pickle
19
23
  # import pdb
20
24
  import secrets
25
+ import sys
21
26
  import time
22
- from typing import Any, Callable, final, MutableSequence, Protocol, runtime_checkable, Self, TypeVar
27
+ from typing import Any, Callable, final, MutableSequence, Protocol, runtime_checkable
28
+ from typing import Sequence, Self, TypeVar
29
+
30
+ import numpy as np
31
+ from scipy import stats # type:ignore
23
32
  import zstandard
24
33
 
25
34
  __author__ = 'balparda@github.com'
26
- __version__ = '1.2.0' # 2025-09-05, Fri
35
+ __version__ = '1.4.0' # 2026-01-13, Tue
27
36
  __version_tuple__: tuple[int, ...] = tuple(int(v) for v in __version__.split('.'))
28
37
 
29
38
  # MIN_TM = int( # minimum allowed timestamp
@@ -41,15 +50,44 @@ EncodedToBytes: Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.en
41
50
 
42
51
  PadBytesTo: Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b'\x00')
43
52
 
53
+ # SI prefix table, powers of 1000
54
+ _SI_PREFIXES: dict[int, str] = {
55
+ -6: 'a', # atto
56
+ -5: 'f', # femto
57
+ -4: 'p', # pico
58
+ -3: 'n', # nano
59
+ -2: 'µ', # micro (unicode U+00B5)
60
+ -1: 'm', # milli
61
+ 0: '', # base
62
+ 1: 'k', # kilo
63
+ 2: 'M', # mega
64
+ 3: 'G', # giga
65
+ 4: 'T', # tera
66
+ 5: 'P', # peta
67
+ 6: 'E', # exa
68
+ }
44
69
 
45
70
  # these control the pickling of data, do NOT ever change, or you will break all databases
46
71
  # <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
47
72
  _PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
73
+ PickleGeneric: Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
74
+ UnpickleGeneric: Callable[[bytes], Any] = pickle.loads
75
+ PickleJSON: Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
76
+ d, separators=(',', ':')).encode('utf-8')
77
+ UnpickleJSON: Callable[[bytes], dict[str, Any]] = lambda b: json.loads(b.decode('utf-8'))
48
78
  _PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
49
79
  # these help find compressed files, do NOT change unless zstandard changes
50
80
  _ZSTD_MAGIC_FRAME = 0xFD2FB528
51
81
  _ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
52
82
  _ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
83
+ # JSON
84
+ _JSON_DATACLASS_TYPES: set[str] = {
85
+ # native support
86
+ 'int', 'float', 'str', 'bool',
87
+ 'list[int]', 'list[float]', 'list[str]', 'list[bool]',
88
+ # need conversion/encoding
89
+ 'bytes',
90
+ }
53
91
 
54
92
 
55
93
  class Error(Exception):
@@ -64,14 +102,18 @@ class CryptoError(Error):
64
102
  """Cryptographic exception (TransCrypto)."""
65
103
 
66
104
 
67
- def HumanizedBytes(inp_sz: int, /) -> str: # pylint: disable=too-many-return-statements
105
+ class ImplementationError(Error, NotImplementedError):
106
+ """This feature is not implemented yet (TransCrypto)."""
107
+
108
+
109
+ def HumanizedBytes(inp_sz: int | float, /) -> str: # pylint: disable=too-many-return-statements
68
110
  """Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
69
111
 
70
112
  Scales the input size by powers of 1024, returning a value with the
71
113
  appropriate IEC binary unit suffix: `B`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, `EiB`.
72
114
 
73
115
  Args:
74
- inp_sz (int): Size in bytes. Must be a non-negative integer.
116
+ inp_sz (int | float): Size in bytes. Must be non-negative.
75
117
 
76
118
  Returns:
77
119
  str: Formatted size string with up to two decimal places for units above bytes.
@@ -100,72 +142,79 @@ def HumanizedBytes(inp_sz: int, /) -> str: # pylint: disable=too-many-return-st
100
142
  if inp_sz < 0:
101
143
  raise InputError(f'input should be >=0 and got {inp_sz}')
102
144
  if inp_sz < 1024:
103
- return f'{inp_sz} B'
145
+ return f'{inp_sz} B' if isinstance(inp_sz, int) else f'{inp_sz:0.3f} B'
104
146
  if inp_sz < 1024 * 1024:
105
- return f'{(inp_sz / 1024):0.2f} KiB'
147
+ return f'{(inp_sz / 1024):0.3f} KiB'
106
148
  if inp_sz < 1024 * 1024 * 1024:
107
- return f'{(inp_sz / (1024 * 1024)):0.2f} MiB'
149
+ return f'{(inp_sz / (1024 * 1024)):0.3f} MiB'
108
150
  if inp_sz < 1024 * 1024 * 1024 * 1024:
109
- return f'{(inp_sz / (1024 * 1024 * 1024)):0.2f} GiB'
151
+ return f'{(inp_sz / (1024 * 1024 * 1024)):0.3f} GiB'
110
152
  if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024:
111
- return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.2f} TiB'
153
+ return f'{(inp_sz / (1024 * 1024 * 1024 * 1024)):0.3f} TiB'
112
154
  if inp_sz < 1024 * 1024 * 1024 * 1024 * 1024 * 1024:
113
- return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.2f} PiB'
114
- return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.2f} EiB'
155
+ return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024)):0.3f} PiB'
156
+ return f'{(inp_sz / (1024 * 1024 * 1024 * 1024 * 1024 * 1024)):0.3f} EiB'
115
157
 
116
158
 
117
- def HumanizedDecimal(inp_sz: int | float, unit: str = '', /) -> str: # pylint: disable=too-many-return-statements
118
- """Convert a numeric value into a human-readable string using metric prefixes (powers of 1000).
159
+ def HumanizedDecimal(inp_sz: int | float, /, *, unit: str = '') -> str:
160
+ """Convert a numeric value into a human-readable string using SI metric prefixes.
119
161
 
120
162
  Scales the input value by powers of 1000, returning a value with the
121
- appropriate SI metric unit prefix: `k`, `M`, `G`, `T`, `P`, `E`. The caller
122
- can optionally specify a base unit (e.g., `'Hz'`, `'m'`).
123
-
124
- Args:
125
- inp_sz (int | float): Quantity to convert. Must be finite and non-negative.
126
- unit (str, optional): Base unit to append to the result (e.g., `'Hz'`).
127
- If given, it will be separated by a space for values <1000 and appended
128
- without a space for scaled values.
129
-
130
- Returns:
131
- str: Formatted string with up to two decimal places for scaled values
132
- and up to four decimal places for small floats.
133
-
134
- Raises:
135
- InputError: If `inp_sz` is negative or not finite.
163
+ appropriate SI unit prefix. Supports both large multiples (kilo, mega,
164
+ giga, exa) and small sub-multiples (milli, micro, nano, pico, femto, atto).
136
165
 
137
166
  Notes:
138
- - Uses decimal multiples: 1 k = 1000 units.
139
- - Values <1000 are returned as-is (integer) or with four decimal places (float).
140
- - Unit string is stripped of surrounding whitespace before use.
167
+ Uses decimal multiples: 1 k = 1000 units, 1 m = 1/1000 units.
168
+ Supported large prefixes: k, M, G, T, P, E.
169
+ Supported small prefixes: m, µ, n, p, f, a.
170
+ • Unit string is stripped of surrounding whitespace before use.
171
+ • Zero is returned as '0' plus unit (no prefix).
141
172
 
142
173
  Examples:
143
174
  >>> HumanizedDecimal(950)
144
175
  '950'
145
176
  >>> HumanizedDecimal(1500)
146
177
  '1.50 k'
147
- >>> HumanizedDecimal(1500, ' Hz ')
148
- '1.50 kHz'
149
- >>> HumanizedDecimal(0.123456, 'V')
150
- '0.1235 V'
178
+ >>> HumanizedDecimal(0.123456, unit='V')
179
+ '123.456 mV'
180
+ >>> HumanizedDecimal(3.2e-7, unit='F')
181
+ '320.000 nF'
182
+ >>> HumanizedDecimal(9.14e18, unit='Hz')
183
+ '9.14 EHz'
184
+
185
+ Args:
186
+ inp_sz (int | float): Quantity to convert. Must be finite.
187
+ unit (str, optional): Base unit to append to the result (e.g., 'Hz', 'm').
188
+ If given, it will be separated by a space for unscaled values and
189
+ concatenated to the prefix for scaled values.
190
+
191
+ Returns:
192
+ str: Formatted string with a few decimal places
193
+
194
+ Raises:
195
+ InputError: If `inp_sz` is not finite.
151
196
  """
152
- if not math.isfinite(inp_sz) or inp_sz < 0:
153
- raise InputError(f'input should be >=0 and got {inp_sz} / {unit!r}')
197
+ if not math.isfinite(inp_sz):
198
+ raise InputError(f'input should finite; got {inp_sz!r}')
154
199
  unit = unit.strip()
155
- if inp_sz < 1000:
156
- return (f'{inp_sz:0.4f}{" " + unit if unit else ""}' if isinstance(inp_sz, float) else
157
- f'{inp_sz}{" " + unit if unit else ""}')
158
- if inp_sz < 1000 * 1000:
159
- return f'{(inp_sz / 1000):0.2f} k{unit}'
160
- if inp_sz < 1000 * 1000 * 1000:
161
- return f'{(inp_sz / (1000 * 1000)):0.2f} M{unit}'
162
- if inp_sz < 1000 * 1000 * 1000 * 1000:
163
- return f'{(inp_sz / (1000 * 1000 * 1000)):0.2f} G{unit}'
164
- if inp_sz < 1000 * 1000 * 1000 * 1000 * 1000:
165
- return f'{(inp_sz / (1000 * 1000 * 1000 * 1000)):0.2f} T{unit}'
166
- if inp_sz < 1000 * 1000 * 1000 * 1000 * 1000 * 1000:
167
- return f'{(inp_sz / (1000 * 1000 * 1000 * 1000 * 1000)):0.2f} P{unit}'
168
- return f'{(inp_sz / (1000 * 1000 * 1000 * 1000 * 1000 * 1000)):0.2f} E{unit}'
200
+ pad_unit: str = ' ' + unit if unit else ''
201
+ if inp_sz == 0:
202
+ return '0' + pad_unit
203
+ neg: str = '-' if inp_sz < 0 else ''
204
+ inp_sz = abs(inp_sz)
205
+ # Find exponent of 1000 that keeps value in [1, 1000)
206
+ exp: int
207
+ exp = int(math.floor(math.log10(abs(inp_sz)) / 3))
208
+ exp = max(min(exp, max(_SI_PREFIXES)), min(_SI_PREFIXES)) # clamp to supported range
209
+ if not exp:
210
+ # No scaling: use int or 4-decimal float
211
+ if isinstance(inp_sz, int) or inp_sz.is_integer():
212
+ return f'{neg}{int(inp_sz)}{pad_unit}'
213
+ return f'{neg}{inp_sz:0.3f}{pad_unit}'
214
+ # scaled
215
+ scaled: float = inp_sz / (1000 ** exp)
216
+ prefix: str = _SI_PREFIXES[exp]
217
+ return f'{neg}{scaled:0.3f} {prefix}{unit}'
169
218
 
170
219
 
171
220
  def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-many-return-statements
@@ -183,11 +232,7 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
183
232
  inp_secs (int | float): Time interval in seconds. Must be finite and non-negative.
184
233
 
185
234
  Returns:
186
- str: Human-readable string with the duration and unit. Precision depends
187
- on the chosen unit:
188
- - µs / ms: 3 decimal places
189
- - seconds ≥1: 2 decimal places
190
- - minutes, hours, days: 2 decimal places
235
+ str: Human-readable string with the duration and unit
191
236
 
192
237
  Raises:
193
238
  InputError: If `inp_secs` is negative or not finite.
@@ -217,19 +262,115 @@ def HumanizedSeconds(inp_secs: int | float, /) -> str: # pylint: disable=too-ma
217
262
  if not math.isfinite(inp_secs) or inp_secs < 0:
218
263
  raise InputError(f'input should be >=0 and got {inp_secs}')
219
264
  if inp_secs == 0:
220
- return '0.00 s'
265
+ return '0.000 s'
221
266
  inp_secs = float(inp_secs)
222
267
  if inp_secs < 0.001:
223
268
  return f'{inp_secs * 1000 * 1000:0.3f} µs'
224
269
  if inp_secs < 1:
225
270
  return f'{inp_secs * 1000:0.3f} ms'
226
271
  if inp_secs < 60:
227
- return f'{inp_secs:0.2f} s'
272
+ return f'{inp_secs:0.3f} s'
228
273
  if inp_secs < 60 * 60:
229
- return f'{(inp_secs / 60):0.2f} min'
274
+ return f'{(inp_secs / 60):0.3f} min'
230
275
  if inp_secs < 24 * 60 * 60:
231
- return f'{(inp_secs / (60 * 60)):0.2f} h'
232
- return f'{(inp_secs / (24 * 60 * 60)):0.2f} d'
276
+ return f'{(inp_secs / (60 * 60)):0.3f} h'
277
+ return f'{(inp_secs / (24 * 60 * 60)):0.3f} d'
278
+
279
+
280
+ def MeasurementStats(
281
+ data: list[int | float], /, *,
282
+ confidence: float = 0.95) -> tuple[int, float, float, float, tuple[float, float], float]:
283
+ """Compute descriptive statistics for repeated measurements.
284
+
285
+ Given N ≥ 1 measurements, this function computes the sample mean, the
286
+ standard error of the mean (SEM), and the symmetric error estimate for
287
+ the chosen confidence interval using Student's t distribution.
288
+
289
+ Notes:
290
+ • If only one measurement is given, SEM and error are reported as +∞ and
291
+ the confidence interval is (-∞, +∞).
292
+ • This function assumes the underlying distribution is approximately
293
+ normal, or n is large enough for the Central Limit Theorem to apply.
294
+
295
+ Args:
296
+ data (list[int | float]): Sequence of numeric measurements.
297
+ confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
298
+ defaults to 0.95 (95% confidence interval).
299
+
300
+ Returns:
301
+ tuple:
302
+ - n (int): number of measurements.
303
+ - mean (float): arithmetic mean of the data
304
+ - sem (float): standard error of the mean, sigma / √n
305
+ - error (float): half-width of the confidence interval (mean ± error)
306
+ - ci (tuple[float, float]): lower and upper confidence interval bounds
307
+ - confidence (float): the confidence level used
308
+
309
+ Raises:
310
+ InputError: if the input list is empty.
311
+ """
312
+ # test inputs
313
+ n: int = len(data)
314
+ if not n:
315
+ raise InputError('no data')
316
+ if not 0.5 <= confidence < 1.0:
317
+ raise InputError(f'invalid confidence: {confidence=}')
318
+ # solve trivial case
319
+ if n == 1:
320
+ return (n, float(data[0]), math.inf, math.inf, (-math.inf, math.inf), confidence)
321
+ # call scipy for the science data
322
+ np_data = np.array(data)
323
+ mean = np.mean(np_data)
324
+ sem = stats.sem(np_data) # type:ignore
325
+ ci = stats.t.interval(confidence, n - 1, loc=mean, scale=sem) # type:ignore
326
+ t_crit = stats.t.ppf((1.0 + confidence) / 2.0, n - 1) # type:ignore
327
+ error = t_crit * sem # half-width of the CI # type:ignore
328
+ return (n, float(mean), float(sem), float(error), (float(ci[0]), float(ci[1])), confidence) # type:ignore
329
+
330
+
331
+ def HumanizedMeasurements(
332
+ data: list[int | float], /, *,
333
+ unit: str = '', parser: Callable[[float], str] | None = None,
334
+ clip_negative: bool = True, confidence: float = 0.95) -> str:
335
+ """Render measurement statistics as a human-readable string.
336
+
337
+ Uses `MeasurementStats()` to compute mean and uncertainty, and formats the
338
+ result with units, sample count, and confidence interval. Negative values
339
+ can optionally be clipped to zero and marked with a leading “*”.
340
+
341
+ Notes:
342
+ • For a single measurement, error is displayed as “± ?”.
343
+ • The output includes the number of samples (@n) and the confidence
344
+ interval unless a different confidence was requested upstream.
345
+
346
+ Args:
347
+ data (list[int | float]): Sequence of numeric measurements.
348
+ unit (str, optional): Unit of measurement to append, e.g. "ms" or "s".
349
+ Defaults to '' (no unit).
350
+ parser (Callable[[float], str] | None, optional): Custom float-to-string
351
+ formatter. If None, values are formatted with 3 decimal places.
352
+ clip_negative (bool, optional): If True (default), negative values are
353
+ clipped to 0.0 and prefixed with '*'.
354
+ confidence (float, optional): Confidence level for the interval, 0.5 <= confidence < 1;
355
+ defaults to 0.95 (95% confidence interval).
356
+
357
+ Returns:
358
+ str: A formatted summary string, e.g.: '9.720 ± 1.831 ms [5.253 … 14.187]95%CI@5'
359
+ """
360
+ n: int
361
+ mean: float
362
+ error: float
363
+ ci: tuple[float, float]
364
+ conf: float
365
+ unit = unit.strip()
366
+ n, mean, _, error, ci, conf = MeasurementStats(data, confidence=confidence)
367
+ f: Callable[[float], str] = lambda x: (
368
+ ('*0' if clip_negative and x < 0.0 else str(x)) if parser is None else
369
+ (f'*{parser(0.0)}' if clip_negative and x < 0.0 else parser(x)))
370
+ if n == 1:
371
+ return f'{f(mean)}{unit} ±? @1'
372
+ pct = int(round(conf * 100))
373
+ return f'{f(mean)}{unit} ± {f(error)}{unit} [{f(ci[0])}{unit} … {f(ci[1])}{unit}]{pct}%CI@{n}'
233
374
 
234
375
 
235
376
  class Timer:
@@ -254,15 +395,13 @@ class Timer:
254
395
  print(tm)
255
396
 
256
397
  Attributes:
257
- label (str): Timer label
258
- emit_print (bool): If True will print() the timer, else will logging.info() the timer
259
- start (float | None): Start time
260
- end (float | None): End time
261
- elapsed (float | None): Time delta
398
+ label (str, optional): Timer label
399
+ emit_log (bool, optional): If True (default) will logging.info() the timer, else will not
400
+ emit_print (bool, optional): If True will print() the timer, else (default) will not
262
401
  """
263
402
 
264
403
  def __init__(
265
- self, label: str = 'Elapsed time', /, *,
404
+ self, label: str = '', /, *,
266
405
  emit_log: bool = True, emit_print: bool = False) -> None:
267
406
  """Initialize the Timer.
268
407
 
@@ -277,19 +416,27 @@ class Timer:
277
416
  self.emit_log: bool = emit_log
278
417
  self.emit_print: bool = emit_print
279
418
  self.label: str = label.strip()
280
- if not self.label:
281
- raise InputError('Empty label')
282
419
  self.start: float | None = None
283
420
  self.end: float | None = None
284
- self.elapsed: float | None = None
421
+
422
+ @property
423
+ def elapsed(self) -> float:
424
+ """Elapsed time. Will be zero until a measurement is available with start/end."""
425
+ if self.start is None or self.end is None:
426
+ return 0.0
427
+ delta: float = self.end - self.start
428
+ if delta <= 0.0:
429
+ raise Error(f'negative/zero delta: {delta}')
430
+ return delta
285
431
 
286
432
  def __str__(self) -> str:
287
433
  """Current timer value."""
288
434
  if self.start is None:
289
- return f'{self.label}: <UNSTARTED>'
290
- if self.end is None or self.elapsed is None:
291
- return f'{self.label}: <PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}'
292
- return f'{self.label}: {HumanizedSeconds(self.elapsed)}'
435
+ return f'{self.label}: <UNSTARTED>' if self.label else '<UNSTARTED>'
436
+ if self.end is None:
437
+ return ((f'{self.label}: ' if self.label else '') +
438
+ f'<PARTIAL> {HumanizedSeconds(time.perf_counter() - self.start)}')
439
+ return (f'{self.label}: ' if self.label else '') + f'{HumanizedSeconds(self.elapsed)}'
293
440
 
294
441
  def Start(self) -> None:
295
442
  """Start the timer."""
@@ -306,10 +453,9 @@ class Timer:
306
453
  """Stop the timer and emit logging.info with timer message."""
307
454
  if self.start is None:
308
455
  raise Error('Stopping an unstarted timer')
309
- if self.end is not None or self.elapsed is not None:
456
+ if self.end is not None:
310
457
  raise Error('Re-stopping timer is forbidden')
311
458
  self.end = time.perf_counter()
312
- self.elapsed = self.end - self.start
313
459
  message: str = str(self)
314
460
  if self.emit_log:
315
461
  logging.info(message)
@@ -578,6 +724,134 @@ def ObfuscateSecret(data: str | bytes | int, /) -> str:
578
724
  return BytesToHex(Hash512(data))[:8] + '…'
579
725
 
580
726
 
727
+ class CryptoInputType(enum.StrEnum):
728
+ """Types of inputs that can represent arbitrary bytes."""
729
+ # prefixes; format prefixes are all 4 bytes
730
+ PATH = '@' # @path on disk → read bytes from a file
731
+ STDIN = '@-' # stdin
732
+ HEX = 'hex:' # hex:deadbeef → decode hex
733
+ BASE64 = 'b64:' # b64:... → decode base64
734
+ STR = 'str:' # str:hello → UTF-8 encode the literal
735
+ RAW = 'raw:' # raw:... → byte literals via \\xNN escapes (rare but handy)
736
+
737
+
738
+ def BytesToRaw(b: bytes, /) -> str:
739
+ """Convert bytes to double-quoted string with \\xNN escapes where needed.
740
+
741
+ 1. map bytes 0..255 to same code points (latin1)
742
+ 2. escape non-printables/backslash/quotes via unicode_escape
743
+
744
+ Args:
745
+ b (bytes): input
746
+
747
+ Returns:
748
+ str: double-quoted string with \\xNN escapes where needed
749
+ """
750
+ inner: str = b.decode('latin1').encode('unicode_escape').decode('ascii')
751
+ return f'"{inner.replace('"', r'\"')}"'
752
+
753
+
754
+ def RawToBytes(s: str, /) -> bytes:
755
+ """Convert double-quoted string with \\xNN escapes where needed to bytes.
756
+
757
+ Args:
758
+ s (str): input (expects a double-quoted string; parses \\xNN, \n, \\ etc)
759
+
760
+ Returns:
761
+ bytes: data
762
+ """
763
+ if len(s) >= 2 and s[0] == s[-1] == '"':
764
+ s = s[1:-1]
765
+ # decode backslash escapes to code points, then map 0..255 -> bytes
766
+ return codecs.decode(s, 'unicode_escape').encode('latin1')
767
+
768
+
769
+ def DetectInputType(data_str: str, /) -> CryptoInputType | None:
770
+ """Auto-detect `data_str` type, if possible.
771
+
772
+ Args:
773
+ data_str (str): data to process, putatively a bytes blob
774
+
775
+ Returns:
776
+ CryptoInputType | None: type if has a known prefix, None otherwise
777
+
778
+ Raises:
779
+ InputError: unexpected type or conversion error
780
+ """
781
+ data_str = data_str.strip()
782
+ if data_str == CryptoInputType.STDIN:
783
+ return CryptoInputType.STDIN
784
+ for t in (
785
+ CryptoInputType.PATH, CryptoInputType.STR, CryptoInputType.HEX,
786
+ CryptoInputType.BASE64, CryptoInputType.RAW):
787
+ if data_str.startswith(t):
788
+ return t
789
+ return None
790
+
791
+
792
+ def BytesFromInput(data_str: str, /, *, expect: CryptoInputType | None = None) -> bytes: # pylint:disable=too-many-return-statements
793
+ """Parse input `data_str` into `bytes`. May auto-detect or enforce a type of input.
794
+
795
+ Can load from disk ('@'). Can load from stdin ('@-').
796
+
797
+ Args:
798
+ data_str (str): data to process, putatively a bytes blob
799
+ expect (CryptoInputType | None, optional): If not given (None) will try to auto-detect the
800
+ input type by looking at the prefix on `data_str` and if none is found will suppose
801
+ a 'str:' was given; if one of the supported CryptoInputType is given then will enforce
802
+ that specific type prefix or no prefix
803
+
804
+ Returns:
805
+ bytes: data
806
+
807
+ Raises:
808
+ InputError: unexpected type or conversion error
809
+ """
810
+ data_str = data_str.strip()
811
+ # auto-detect
812
+ detected_type: CryptoInputType | None = DetectInputType(data_str)
813
+ expect = CryptoInputType.STR if expect is None and detected_type is None else expect
814
+ if detected_type is not None and expect is not None and detected_type != expect:
815
+ raise InputError(f'Expected type {expect=} is different from detected type {detected_type=}')
816
+ # now we know they don't conflict, so unify them; remove prefix if we have it
817
+ expect = detected_type if expect is None else expect
818
+ assert expect is not None, 'should never happen: type should be known here'
819
+ data_str = data_str[len(expect):] if data_str.startswith(expect) else data_str
820
+ # for every type something different will happen now
821
+ try:
822
+ match expect:
823
+ case CryptoInputType.STDIN:
824
+ # read raw bytes from stdin: prefer the binary buffer; if unavailable,
825
+ # fall back to text stream encoded as UTF-8 (consistent with str: policy).
826
+ stream = getattr(sys.stdin, 'buffer', None)
827
+ if stream is None:
828
+ text: str = sys.stdin.read()
829
+ if not isinstance(text, str): # type:ignore
830
+ raise InputError('sys.stdin.read() produced non-text data')
831
+ return text.encode('utf-8')
832
+ data: bytes = stream.read()
833
+ if not isinstance(data, bytes): # type:ignore
834
+ raise InputError('sys.stdin.buffer.read() produced non-binary data')
835
+ return data
836
+ case CryptoInputType.PATH:
837
+ if not os.path.exists(data_str):
838
+ raise InputError(f'cannot find file {data_str!r}')
839
+ with open(data_str, 'rb') as file_obj:
840
+ return file_obj.read()
841
+ case CryptoInputType.STR:
842
+ return data_str.encode('utf-8')
843
+ case CryptoInputType.HEX:
844
+ return HexToBytes(data_str)
845
+ case CryptoInputType.BASE64:
846
+ return EncodedToBytes(data_str)
847
+ case CryptoInputType.RAW:
848
+ return RawToBytes(data_str)
849
+ case _:
850
+ raise InputError(f'invalid type {expect!r}')
851
+ except Exception as err:
852
+ raise InputError(f'invalid input: {err}') from err
853
+
854
+
581
855
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
582
856
  class CryptoKey(abc.ABC):
583
857
  """A cryptographic key."""
@@ -593,7 +867,6 @@ class CryptoKey(abc.ABC):
593
867
  string representation of the key without leaking secrets
594
868
  """
595
869
  # every sub-class of CryptoKey has to implement its own version of __str__()
596
- # TODO: make printing a part of the CLI
597
870
 
598
871
  @final
599
872
  def __repr__(self) -> str:
@@ -626,23 +899,110 @@ class CryptoKey(abc.ABC):
626
899
 
627
900
  @final
628
901
  @property
629
- def blob(self) -> bytes:
630
- """Serial (bytes) representation of the object.
902
+ def _json_dict(self) -> dict[str, Any]:
903
+ """Dictionary representation of the object suitable for JSON conversion.
631
904
 
632
905
  Returns:
633
- bytes, pickled, representation of the object
906
+ dict[str, Any]: representation of the object suitable for JSON conversion
907
+
908
+ Raises:
909
+ ImplementationError: object has types that are not supported in JSON
634
910
  """
635
- return Serialize(self, compress=-2, silent=True)
911
+ self_dict: dict[str, Any] = dataclasses.asdict(self)
912
+ for field in dataclasses.fields(self):
913
+ # check the type is OK
914
+ if field.type not in _JSON_DATACLASS_TYPES:
915
+ raise ImplementationError(
916
+ f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}')
917
+ # convert types that we accept but JSON does not
918
+ if field.type == 'bytes':
919
+ self_dict[field.name] = BytesToEncoded(self_dict[field.name])
920
+ return self_dict
636
921
 
637
922
  @final
638
923
  @property
639
- def encoded(self) -> str:
640
- """Base-64 representation of the object.
924
+ def json(self) -> str:
925
+ """JSON representation of the object, tightly packed, not for humans.
641
926
 
642
927
  Returns:
643
- str, pickled, base64, representation of the object
928
+ str: JSON representation of the object, tightly packed
929
+
930
+ Raises:
931
+ ImplementationError: object has types that are not supported in JSON
644
932
  """
645
- return BytesToEncoded(self.blob)
933
+ return json.dumps(self._json_dict, separators=(',', ':'))
934
+
935
+ @final
936
+ @property
937
+ def formatted_json(self) -> str:
938
+ """JSON representation of the object formatted for humans.
939
+
940
+ Returns:
941
+ str: JSON representation of the object formatted for humans
942
+
943
+ Raises:
944
+ ImplementationError: object has types that are not supported in JSON
945
+ """
946
+ return json.dumps(self._json_dict, indent=4, sort_keys=True)
947
+
948
+ @final
949
+ @classmethod
950
+ def _FromJSONDict(cls, json_dict: dict[str, Any], /) -> Self:
951
+ """Create object from JSON representation.
952
+
953
+ Args:
954
+ json_dict (dict[str, Any]): JSON dict
955
+
956
+ Returns:
957
+ a CryptoKey object ready for use
958
+
959
+ Raises:
960
+ InputError: unexpected type/fields
961
+ """
962
+ # check we got exactly the fields we needed
963
+ cls_fields: set[str] = set(f.name for f in dataclasses.fields(cls))
964
+ json_fields: set[str] = set(json_dict)
965
+ if cls_fields != json_fields:
966
+ raise InputError(f'JSON data decoded to unexpected fields: {cls_fields=} / {json_fields=}')
967
+ # reconstruct the types we meddled with inside self._json_dict
968
+ for field in dataclasses.fields(cls):
969
+ if field.type not in _JSON_DATACLASS_TYPES:
970
+ raise ImplementationError(
971
+ f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}')
972
+ if field.type == 'bytes':
973
+ json_dict[field.name] = EncodedToBytes(json_dict[field.name])
974
+ # build the object
975
+ return cls(**json_dict)
976
+
977
+ @final
978
+ @classmethod
979
+ def FromJSON(cls, json_data: str, /) -> Self:
980
+ """Create object from JSON representation.
981
+
982
+ Args:
983
+ json_data (str): JSON string
984
+
985
+ Returns:
986
+ a CryptoKey object ready for use
987
+
988
+ Raises:
989
+ InputError: unexpected type/fields
990
+ """
991
+ # get the dict back
992
+ json_dict: dict[str, Any] = json.loads(json_data)
993
+ if not isinstance(json_dict, dict): # type:ignore
994
+ raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
995
+ return cls._FromJSONDict(json_dict)
996
+
997
+ @final
998
+ @property
999
+ def blob(self) -> bytes:
1000
+ """Serial (bytes) representation of the object.
1001
+
1002
+ Returns:
1003
+ bytes, pickled, representation of the object
1004
+ """
1005
+ return self.Blob()
646
1006
 
647
1007
  @final
648
1008
  def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
@@ -655,7 +1015,17 @@ class CryptoKey(abc.ABC):
655
1015
  Returns:
656
1016
  bytes, pickled, representation of the object
657
1017
  """
658
- return Serialize(self, compress=-2, key=key, silent=silent)
1018
+ return Serialize(self._json_dict, compress=-2, key=key, silent=silent, pickler=PickleJSON)
1019
+
1020
+ @final
1021
+ @property
1022
+ def encoded(self) -> str:
1023
+ """Base-64 representation of the object.
1024
+
1025
+ Returns:
1026
+ str, pickled, base64, representation of the object
1027
+ """
1028
+ return self.Encoded()
659
1029
 
660
1030
  @final
661
1031
  def Encoded(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
@@ -668,7 +1038,53 @@ class CryptoKey(abc.ABC):
668
1038
  Returns:
669
1039
  str, pickled, base64, representation of the object
670
1040
  """
671
- return BytesToEncoded(self.Blob(key=key, silent=silent))
1041
+ return CryptoInputType.BASE64 + BytesToEncoded(self.Blob(key=key, silent=silent))
1042
+
1043
+ @final
1044
+ @property
1045
+ def hex(self) -> str:
1046
+ """Hexadecimal representation of the object.
1047
+
1048
+ Returns:
1049
+ str, pickled, hexadecimal, representation of the object
1050
+ """
1051
+ return self.Hex()
1052
+
1053
+ @final
1054
+ def Hex(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
1055
+ """Hexadecimal representation of the object with more options, including encryption.
1056
+
1057
+ Args:
1058
+ key (Encryptor, optional): if given will key.Encrypt() data before saving
1059
+ silent (bool, optional): if True (default) will not log
1060
+
1061
+ Returns:
1062
+ str, pickled, hexadecimal, representation of the object
1063
+ """
1064
+ return CryptoInputType.HEX + BytesToHex(self.Blob(key=key, silent=silent))
1065
+
1066
+ @final
1067
+ @property
1068
+ def raw(self) -> str:
1069
+ """Raw escaped binary representation of the object.
1070
+
1071
+ Returns:
1072
+ str, pickled, raw escaped binary, representation of the object
1073
+ """
1074
+ return self.Raw()
1075
+
1076
+ @final
1077
+ def Raw(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
1078
+ """Raw escaped binary representation of the object with more options, including encryption.
1079
+
1080
+ Args:
1081
+ key (Encryptor, optional): if given will key.Encrypt() data before saving
1082
+ silent (bool, optional): if True (default) will not log
1083
+
1084
+ Returns:
1085
+ str, pickled, raw escaped binary, representation of the object
1086
+ """
1087
+ return CryptoInputType.RAW + BytesToRaw(self.Blob(key=key, silent=silent))
672
1088
 
673
1089
  @final
674
1090
  @classmethod
@@ -687,13 +1103,14 @@ class CryptoKey(abc.ABC):
687
1103
  """
688
1104
  # if this is a string, then we suppose it is base64
689
1105
  if isinstance(data, str):
690
- data = EncodedToBytes(data)
1106
+ data = BytesFromInput(data)
691
1107
  # we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
692
- obj: CryptoKey = DeSerialize(data=data, key=key, silent=silent)
693
- # make sure we've got an object that makes sense
694
- if not isinstance(obj, CryptoKey): # type:ignore
695
- raise InputError(f'serialized data is not a CryptoKey: {type(obj)}')
696
- return obj # type:ignore
1108
+ try:
1109
+ json_dict: dict[str, Any] = DeSerialize(
1110
+ data=data, key=key, silent=silent, unpickler=UnpickleJSON)
1111
+ return cls._FromJSONDict(json_dict)
1112
+ except Exception as err:
1113
+ raise InputError(f'input decode error: {err}') from err
697
1114
 
698
1115
 
699
1116
  @runtime_checkable
@@ -799,14 +1216,15 @@ class Signer(Protocol): # pylint: disable=too-few-public-methods
799
1216
  """
800
1217
 
801
1218
 
802
- def Serialize(
1219
+ def Serialize( # pylint:disable=too-many-arguments
803
1220
  python_obj: Any, /, *, file_path: str | None = None,
804
- compress: int | None = 3, key: Encryptor | None = None, silent: bool = False) -> bytes:
1221
+ compress: int | None = 3, key: Encryptor | None = None, silent: bool = False,
1222
+ pickler: Callable[[Any], bytes] = PickleGeneric) -> bytes:
805
1223
  """Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
806
1224
 
807
1225
  Data path is:
808
1226
 
809
- `obj` => pickle => (compress) => (encrypt) => (save to `file_path`) => return
1227
+ `obj` => [pickler] => (compress) => (encrypt) => (save to `file_path`) => return
810
1228
 
811
1229
  At every step of the data path the data will be measured, in bytes.
812
1230
  Every data conversion will be timed. The measurements/times will be logged (once).
@@ -829,6 +1247,9 @@ def Serialize(
829
1247
  None is no compression; default is 3, which is fast, see table above for other values
830
1248
  key (Encryptor, optional): if given will key.Encrypt() data before saving
831
1249
  silent (bool, optional): if True will not log; default is False (will log)
1250
+ pickler (Callable[[Any], bytes], optional): if not given, will just be the `pickle` module;
1251
+ if given will be a method to convert any Python object to its `bytes` representation;
1252
+ PickleGeneric is the default, but another useful value is PickleJSON
832
1253
 
833
1254
  Returns:
834
1255
  bytes: serialized binary data corresponding to obj + (compression) + (encryption)
@@ -837,7 +1258,7 @@ def Serialize(
837
1258
  with Timer('Serialization complete', emit_log=False) as tm_all:
838
1259
  # pickle
839
1260
  with Timer('PICKLE', emit_log=False) as tm_pickle:
840
- obj: bytes = pickle.dumps(python_obj, protocol=_PICKLE_PROTOCOL)
1261
+ obj: bytes = pickler(python_obj)
841
1262
  if not silent:
842
1263
  messages.append(f' {tm_pickle}, {HumanizedBytes(len(obj))}')
843
1264
  # compress, if needed
@@ -869,12 +1290,13 @@ def Serialize(
869
1290
 
870
1291
  def DeSerialize(
871
1292
  *, data: bytes | None = None, file_path: str | None = None,
872
- key: Decryptor | None = None, silent: bool = False) -> Any:
1293
+ key: Decryptor | None = None, silent: bool = False,
1294
+ unpickler: Callable[[bytes], Any] = UnpickleGeneric) -> Any:
873
1295
  """Loads (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
874
1296
 
875
1297
  Data path is:
876
1298
 
877
- `data` or `file_path` => (decrypt) => (decompress) => unpickle => return object
1299
+ `data` or `file_path` => (decrypt) => (decompress) => [unpickler] => return object
878
1300
 
879
1301
  At every step of the data path the data will be measured, in bytes.
880
1302
  Every data conversion will be timed. The measurements/times will be logged (once).
@@ -887,6 +1309,9 @@ def DeSerialize(
887
1309
  if you use this option, `data` will be ignored
888
1310
  key (Decryptor, optional): if given will key.Decrypt() data before decompressing/loading
889
1311
  silent (bool, optional): if True will not log; default is False (will log)
1312
+ pickler (Callable[[bytes], Any], optional): if not given, will just be the `pickle` module;
1313
+ if given will be a method to convert a `bytes` representation back to a Python object;
1314
+ UnpickleGeneric is the default, but another useful value is UnpickleJSON
890
1315
 
891
1316
  Returns:
892
1317
  De-Serialized Python object corresponding to data
@@ -933,7 +1358,7 @@ def DeSerialize(
933
1358
  messages.append(' (no compression detected)')
934
1359
  # create the actual object = unpickle
935
1360
  with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
936
- python_obj: Any = pickle.loads(obj)
1361
+ python_obj: Any = unpickler(obj)
937
1362
  if not silent:
938
1363
  messages.append(f' {tm_unpickle}')
939
1364
  # log and return
@@ -1070,3 +1495,186 @@ class PrivateBid512(PublicBid512):
1070
1495
  public_hash=Hash512(public_key + private_key + secret),
1071
1496
  private_key=private_key,
1072
1497
  secret_bid=secret)
1498
+
1499
+
1500
+ def _FlagNames(a: argparse.Action, /) -> list[str]:
1501
+ # Positional args have empty 'option_strings'; otherwise use them (e.g., ['-v','--verbose'])
1502
+ if a.option_strings:
1503
+ return list(a.option_strings)
1504
+ if a.nargs:
1505
+ if isinstance(a.metavar, str) and a.metavar:
1506
+ # e.g., nargs=2, metavar='FILE'
1507
+ return [a.metavar]
1508
+ if isinstance(a.metavar, tuple):
1509
+ # e.g., nargs=2, metavar=('FILE1', 'FILE2')
1510
+ return list(a.metavar)
1511
+ # Otherwise, it’s a positional arg with no flags, so return the destination name
1512
+ return [a.dest]
1513
+
1514
+
1515
+ def _ActionIsSubparser(a: argparse.Action, /) -> bool:
1516
+ return isinstance(a, argparse._SubParsersAction) # type: ignore[attr-defined] # pylint: disable=protected-access
1517
+
1518
+
1519
+ def _FormatDefault(a: argparse.Action, /) -> str:
1520
+ if a.default is argparse.SUPPRESS:
1521
+ return ''
1522
+ if isinstance(a.default, bool):
1523
+ return ' (default: on)' if a.default else ''
1524
+ if a.default in (None, '', 0, False):
1525
+ return ''
1526
+ return f' (default: {a.default})'
1527
+
1528
+
1529
+ def _FormatChoices(a: argparse.Action, /) -> str:
1530
+ return f' choices: {list(a.choices)}' if getattr(a, 'choices', None) else '' # type:ignore
1531
+
1532
+
1533
+ def _FormatType(a: argparse.Action, /) -> str:
1534
+ t: Any | None = getattr(a, 'type', None)
1535
+ if t is None:
1536
+ return ''
1537
+ # Show clean type names (int, str, float); for callables, just say 'custom'
1538
+ return f' type: {t.__name__ if hasattr(t, "__name__") else "custom"}'
1539
+
1540
+
1541
+ def _FormatNArgs(a: argparse.Action, /) -> str:
1542
+ return f' nargs: {a.nargs}' if getattr(a, 'nargs', None) not in (None, 0) else ''
1543
+
1544
+
1545
+ def _RowsForActions(actions: Sequence[argparse.Action], /) -> list[tuple[str, str]]:
1546
+ rows: list[tuple[str, str]] = []
1547
+ for a in actions:
1548
+ if _ActionIsSubparser(a):
1549
+ continue
1550
+ # skip the built-in help action; it’s implied
1551
+ if getattr(a, 'help', '') == argparse.SUPPRESS or isinstance(a, argparse._HelpAction): # type: ignore[attr-defined] # pylint: disable=protected-access
1552
+ continue
1553
+ flags: str = ', '.join(_FlagNames(a))
1554
+ meta: str = ''.join(
1555
+ (_FormatType(a), _FormatNArgs(a), _FormatChoices(a), _FormatDefault(a))).strip()
1556
+ desc: str = (a.help or '').strip()
1557
+ if meta:
1558
+ desc = f'{desc} [{meta}]' if desc else f'[{meta}]'
1559
+ rows.append((flags, desc))
1560
+ return rows
1561
+
1562
+
1563
+ def _MarkdownTable(
1564
+ rows: Sequence[tuple[str, str]],
1565
+ headers: tuple[str, str] = ('Option/Arg', 'Description'), /) -> str:
1566
+ if not rows:
1567
+ return ''
1568
+ out: list[str] = ['| ' + headers[0] + ' | ' + headers[1] + ' |', '|---|---|']
1569
+ for left, right in rows:
1570
+ out.append(f'| `{left}` | {right} |')
1571
+ return '\n'.join(out)
1572
+
1573
+
1574
+ def _WalkSubcommands(
1575
+ parser: argparse.ArgumentParser, path: list[str] | None = None, /) -> list[
1576
+ tuple[list[str], argparse.ArgumentParser, Any]]:
1577
+ path = path or []
1578
+ items: list[tuple[list[str], argparse.ArgumentParser, Any]] = []
1579
+ # sub_action = None
1580
+ name: str
1581
+ sp: argparse.ArgumentParser
1582
+ for action in parser._actions: # type: ignore[attr-defined] # pylint: disable=protected-access
1583
+ if _ActionIsSubparser(action):
1584
+ # sub_action = a # type: ignore[assignment]
1585
+ for name, sp in action.choices.items(): # type:ignore
1586
+ items.append((path + [name], sp, action)) # type:ignore
1587
+ items.extend(_WalkSubcommands(sp, path + [name])) # type:ignore
1588
+ return items
1589
+
1590
+
1591
+ def _HelpText(sub_parser: argparse.ArgumentParser, parent_sub_action: Any, /) -> str:
1592
+ if parent_sub_action is not None:
1593
+ for choice_action in parent_sub_action._choices_actions: # type: ignore # pylint: disable=protected-access
1594
+ if choice_action.dest == sub_parser.prog.split()[-1]:
1595
+ return choice_action.help or ''
1596
+ return ''
1597
+
1598
+
1599
+ def GenerateCLIMarkdown( # pylint:disable=too-many-locals,too-many-statements
1600
+ prog: str, parser: argparse.ArgumentParser, /, *, description: str = '') -> str: # pylint: disable=too-many-locals
1601
+ """Return a Markdown doc section that reflects the current _BuildParser() tree.
1602
+
1603
+ Will treat epilog strings as examples, splitting on '$$' to get multiple examples.
1604
+
1605
+ Args:
1606
+ prog (str): name of app, eg. 'transcrypto' or 'transcrypto.py'
1607
+ parser (argparse.ArgumentParser): parser to use for data
1608
+ description (str, optional): app description to use as intro
1609
+
1610
+ Returns:
1611
+ str: markdown
1612
+
1613
+ Raises:
1614
+ InputError: invalid app name
1615
+ """
1616
+ prog, description = prog.strip(), description.strip()
1617
+ if not prog or prog not in parser.prog:
1618
+ raise InputError(f'invalid prog/parser.prog: {prog=}, {parser.prog=}')
1619
+ lines: list[str] = ['']
1620
+ lines.append('<!-- cspell:disable -->')
1621
+ lines.append('<!-- auto-generated; do not edit -->\n')
1622
+ # Header + global flags
1623
+ lines.append(f'# `{prog}` Command-Line Interface\n')
1624
+ lines.append(description + '\n')
1625
+ lines.append('Invoke with:\n')
1626
+ lines.append('```bash')
1627
+ lines.append(f'{parser.prog} <command> [sub-command] [options...]')
1628
+ lines.append('```\n')
1629
+ # Global options table
1630
+ global_rows: list[tuple[str, str]] = _RowsForActions(parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
1631
+ if global_rows:
1632
+ lines.append('## Global Options\n')
1633
+ lines.append(_MarkdownTable(global_rows))
1634
+ lines.append('')
1635
+ # Top-level commands summary
1636
+ lines.append('## Top-Level Commands\n')
1637
+ # Find top-level subparsers to list available commands
1638
+ top_subs: list[argparse.Action] = [a for a in parser._actions if _ActionIsSubparser(a)] # type: ignore[attr-defined] # pylint: disable=protected-access
1639
+ for action in top_subs:
1640
+ for name, sp in action.choices.items(): # type: ignore[union-attr]
1641
+ help_text: str = ( # type:ignore
1642
+ sp.description or ' '.join(i.strip() for i in sp.format_usage().splitlines())).strip() # type:ignore
1643
+ short: str = (sp.help if hasattr(sp, 'help') else '') or '' # type:ignore
1644
+ help_text = short or help_text # type:ignore
1645
+ help_text = help_text.replace('usage: ', '').strip() # type:ignore
1646
+ lines.append(f'- **`{name}`** — `{help_text}`')
1647
+ lines.append('')
1648
+ if parser.epilog:
1649
+ lines.append('```bash')
1650
+ lines.append(parser.epilog)
1651
+ lines.append('```\n')
1652
+ # Detailed sections per (sub)command
1653
+ for path, sub_parser, parent_sub_action in _WalkSubcommands(parser):
1654
+ if len(path) == 1:
1655
+ lines.append('---\n') # horizontal rule between top-level commands
1656
+ header: str = ' '.join(path)
1657
+ lines.append(f'##{"" if len(path) == 1 else "#"} `{header}`') # (header level 3 or 4)
1658
+ # Usage block
1659
+ help_text = _HelpText(sub_parser, parent_sub_action)
1660
+ if help_text:
1661
+ lines.append(f'\n{help_text}')
1662
+ usage: str = sub_parser.format_usage().replace('usage: ', '').strip()
1663
+ lines.append('\n```bash')
1664
+ lines.append(str(usage))
1665
+ lines.append('```\n')
1666
+ # Options/args table
1667
+ rows: list[tuple[str, str]] = _RowsForActions(sub_parser._actions) # type: ignore[attr-defined] # pylint: disable=protected-access
1668
+ if rows:
1669
+ lines.append(_MarkdownTable(rows))
1670
+ lines.append('')
1671
+ # Examples (if any) - stored in epilog argument
1672
+ epilog: str = sub_parser.epilog.strip() if sub_parser.epilog else ''
1673
+ if epilog:
1674
+ lines.append('**Example:**\n')
1675
+ lines.append('```bash')
1676
+ for epilog_line in epilog.split('$$'):
1677
+ lines.append(f'$ {parser.prog} {epilog_line.strip()}')
1678
+ lines.append('```\n')
1679
+ # join all lines as the markdown string
1680
+ return ('\n'.join(lines)).strip()