transcrypto 1.7.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/__init__.py CHANGED
@@ -3,5 +3,5 @@
3
3
  """Basic cryptography primitives implementation."""
4
4
 
5
5
  __all__: list[str] = ['__author__', '__version__']
6
- __version__ = '1.7.0' # remember to also update pyproject.toml
6
+ __version__ = '1.8.0' # remember to also update pyproject.toml
7
7
  __author__ = 'Daniel Balparda <balparda@github.com>'
transcrypto/base.py CHANGED
@@ -15,12 +15,10 @@ import hashlib
15
15
  import json
16
16
  import logging
17
17
  import math
18
- import os
19
18
  import pathlib
20
19
  import pickle # noqa: S403
21
20
  import secrets
22
21
  import sys
23
- import threading
24
22
  import time
25
23
  from collections import abc
26
24
  from types import TracebackType
@@ -28,23 +26,39 @@ from typing import (
28
26
  Any,
29
27
  Protocol,
30
28
  Self,
31
- TypeVar,
32
29
  cast,
33
30
  final,
34
31
  runtime_checkable,
35
32
  )
36
33
 
37
- import click
38
34
  import numpy as np
39
- import typer
40
35
  import zstandard
41
- from click import testing as click_testing
42
- from rich import console as rich_console
43
- from rich import logging as rich_logging
44
36
  from scipy import stats
45
37
 
46
38
  # Data conversion utils
47
39
 
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
+
48
62
  BytesToHex: abc.Callable[[bytes], str] = lambda b: b.hex()
49
63
  BytesToInt: abc.Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=False)
50
64
  BytesToEncoded: abc.Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
@@ -67,26 +81,6 @@ TimeStr: abc.Callable[[int | float | None], str] = lambda tm: (
67
81
  Now: abc.Callable[[], int] = lambda: int(time.time())
68
82
  StrNow: abc.Callable[[], str] = lambda: TimeStr(Now())
69
83
 
70
- # Logging
71
- _LOG_FORMAT_NO_PROCESS: str = '%(funcName)s: %(message)s'
72
- _LOG_FORMAT_WITH_PROCESS: str = '%(processName)s/' + _LOG_FORMAT_NO_PROCESS
73
- _LOG_FORMAT_DATETIME: str = '[%Y%m%d-%H:%M:%S]' # e.g., [20240131-13:45:30]
74
- _LOG_LEVELS: dict[int, int] = {
75
- 0: logging.ERROR,
76
- 1: logging.WARNING,
77
- 2: logging.INFO,
78
- 3: logging.DEBUG,
79
- }
80
- _LOG_COMMON_PROVIDERS: set[str] = {
81
- 'werkzeug',
82
- 'gunicorn.error',
83
- 'gunicorn.access',
84
- 'uvicorn',
85
- 'uvicorn.error',
86
- 'uvicorn.access',
87
- 'django.server',
88
- }
89
-
90
84
  # SI prefix table, powers of 1000
91
85
  _SI_PREFIXES: dict[int, str] = {
92
86
  -6: 'a', # atto
@@ -109,29 +103,15 @@ _SI_PREFIXES: dict[int, str] = {
109
103
  _PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
110
104
  PickleGeneric: abc.Callable[[Any], bytes] = lambda o: pickle.dumps(o, protocol=_PICKLE_PROTOCOL)
111
105
  UnpickleGeneric: abc.Callable[[bytes], Any] = pickle.loads # noqa: S301
112
- PickleJSON: abc.Callable[[dict[str, Any]], bytes] = lambda d: json.dumps(
113
- d, separators=(',', ':')
114
- ).encode('utf-8')
115
- UnpickleJSON: abc.Callable[[bytes], dict[str, Any]] = lambda b: json.loads(b.decode('utf-8'))
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'))
116
110
  _PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
117
111
  # these help find compressed files, do NOT change unless zstandard changes
118
112
  _ZSTD_MAGIC_FRAME = 0xFD2FB528
119
113
  _ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
120
114
  _ZSTD_MAGIC_SKIPPABLE_MAX = 0x184D2A5F
121
- # JSON
122
- _JSON_DATACLASS_TYPES: set[str] = {
123
- # native support
124
- 'int',
125
- 'float',
126
- 'str',
127
- 'bool',
128
- 'list[int]',
129
- 'list[float]',
130
- 'list[str]',
131
- 'list[bool]',
132
- # need conversion/encoding
133
- 'bytes',
134
- }
135
115
 
136
116
 
137
117
  class Error(Exception):
@@ -150,112 +130,6 @@ class ImplementationError(Error, NotImplementedError):
150
130
  """Feature is not implemented yet (TransCrypto)."""
151
131
 
152
132
 
153
- __console_lock: threading.RLock = threading.RLock()
154
- __console_singleton: rich_console.Console | None = None
155
-
156
-
157
- def Console() -> rich_console.Console:
158
- """Get the global console instance.
159
-
160
- Returns:
161
- rich.console.Console: The global console instance.
162
-
163
- """
164
- with __console_lock:
165
- if __console_singleton is None:
166
- return rich_console.Console() # fallback console if InitLogging hasn't been called yet
167
- return __console_singleton
168
-
169
-
170
- def ResetConsole() -> None:
171
- """Reset the global console instance."""
172
- global __console_singleton # noqa: PLW0603
173
- with __console_lock:
174
- __console_singleton = None
175
-
176
-
177
- def InitLogging(
178
- verbosity: int,
179
- /,
180
- *,
181
- include_process: bool = False,
182
- soft_wrap: bool = False,
183
- color: bool | None = False,
184
- ) -> tuple[rich_console.Console, int, bool]:
185
- """Initialize logger (with RichHandler) and get a rich.console.Console singleton.
186
-
187
- This method will also return the actual decided values for verbosity and color use.
188
- If you have a CLI app that uses this, its pytests should call `ResetConsole()` in a fixture, like:
189
-
190
- from transcrypto import logging
191
- @pytest.fixture(autouse=True)
192
- def _reset_base_logging() -> Generator[None, None, None]: # type: ignore
193
- logging.ResetConsole()
194
- yield # stop
195
-
196
- Args:
197
- verbosity (int): Logging verbosity level: 0==ERROR, 1==WARNING, 2==INFO, 3==DEBUG
198
- include_process (bool, optional): Whether to include process name in log output.
199
- soft_wrap (bool, optional): Whether to enable soft wrapping in the console.
200
- Default is False, and it means rich will hard-wrap long lines (by adding line breaks).
201
- color (bool | None, optional): Whether to enable/disable color output in the console.
202
- If None, respects NO_COLOR env var.
203
-
204
- Returns:
205
- tuple[rich_console.Console, int, bool]:
206
- (The initialized console instance, actual log level, actual color use)
207
-
208
- Raises:
209
- RuntimeError: if you call this more than once
210
-
211
- """
212
- global __console_singleton # noqa: PLW0603
213
- with __console_lock:
214
- if __console_singleton is not None:
215
- raise RuntimeError(
216
- 'calling InitLogging() more than once is forbidden; '
217
- 'use Console() to get a console after first creation'
218
- )
219
- # set level
220
- logging_level: int = _LOG_LEVELS.get(min(verbosity, 3), logging.ERROR)
221
- # respect NO_COLOR unless the caller has already decided (treat env presence as "disable color")
222
- no_color: bool = (
223
- False
224
- if (os.getenv('NO_COLOR') is None and color is None)
225
- else ((os.getenv('NO_COLOR') is not None) if color is None else (not color))
226
- )
227
- # create console and configure logging
228
- console = rich_console.Console(soft_wrap=soft_wrap, no_color=no_color)
229
- logging.basicConfig(
230
- level=logging_level,
231
- format=_LOG_FORMAT_WITH_PROCESS if include_process else _LOG_FORMAT_NO_PROCESS,
232
- datefmt=_LOG_FORMAT_DATETIME,
233
- handlers=[
234
- rich_logging.RichHandler( # we show name/line, but want time & level
235
- console=console,
236
- rich_tracebacks=True,
237
- show_time=True,
238
- show_level=True,
239
- show_path=True,
240
- ),
241
- ],
242
- force=True, # force=True to override any previous logging config
243
- )
244
- # configure common loggers
245
- logging.captureWarnings(True)
246
- for name in _LOG_COMMON_PROVIDERS:
247
- log: logging.Logger = logging.getLogger(name)
248
- log.handlers.clear()
249
- log.propagate = True
250
- log.setLevel(logging_level)
251
- __console_singleton = console # need a global statement to re-bind this one
252
- logging.info(
253
- f'Logging initialized at level {logging.getLevelName(logging_level)} / '
254
- f'{"NO " if no_color else ""}COLOR'
255
- )
256
- return (console, logging_level, not no_color)
257
-
258
-
259
133
  def HumanizedBytes(inp_sz: float, /) -> str: # noqa: PLR0911
260
134
  """Convert a byte count into a human-readable string using binary prefixes (powers of 1024).
261
135
 
@@ -562,18 +436,24 @@ class Timer:
562
436
  """
563
437
 
564
438
  def __init__(
565
- self, label: str = '', /, *, emit_log: bool = True, emit_print: bool = False
439
+ self,
440
+ label: str = '',
441
+ /,
442
+ *,
443
+ emit_log: bool = True,
444
+ emit_print: abc.Callable[[str], None] | None = None,
566
445
  ) -> None:
567
446
  """Initialize the Timer.
568
447
 
569
448
  Args:
570
449
  label (str, optional): A description or name for the timed block or function
571
450
  emit_log (bool, optional): Emit a log message when finished; default is True
572
- emit_print (bool, optional): Emit a print() message when finished; default is False
451
+ emit_print (Callable[[str], None] | None, optional): Emit a print() message when
452
+ finished using the provided callable; default is None
573
453
 
574
454
  """
575
455
  self.emit_log: bool = emit_log
576
- self.emit_print: bool = emit_print
456
+ self.emit_print: abc.Callable[[str], None] | None = emit_print
577
457
  self.label: str = label.strip()
578
458
  self.start: float | None = None
579
459
  self.end: float | None = None
@@ -647,8 +527,8 @@ class Timer:
647
527
  message: str = str(self)
648
528
  if self.emit_log:
649
529
  logging.info(message)
650
- if self.emit_print:
651
- Console().print(message)
530
+ if self.emit_print is not None:
531
+ self.emit_print(message)
652
532
 
653
533
  def __exit__(
654
534
  self,
@@ -659,9 +539,7 @@ class Timer:
659
539
  """Stop the timer when exiting the context."""
660
540
  self.Stop()
661
541
 
662
- _F = TypeVar('_F', bound=abc.Callable[..., Any])
663
-
664
- def __call__(self, func: Timer._F) -> Timer._F:
542
+ def __call__[**F, R](self, func: abc.Callable[F, R]) -> abc.Callable[F, R]:
665
543
  """Allow the Timer to be used as a decorator.
666
544
 
667
545
  Args:
@@ -673,11 +551,11 @@ class Timer:
673
551
  """
674
552
 
675
553
  @functools.wraps(func)
676
- def _Wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
554
+ def _Wrapper(*args: F.args, **kwargs: F.kwargs) -> R:
677
555
  with self.__class__(self.label, emit_log=self.emit_log, emit_print=self.emit_print):
678
556
  return func(*args, **kwargs)
679
557
 
680
- return _Wrapper # type:ignore
558
+ return _Wrapper
681
559
 
682
560
 
683
561
  def RandBits(n_bits: int, /) -> int:
@@ -731,7 +609,7 @@ def RandInt(min_int: int, max_int: int, /) -> int:
731
609
  return n
732
610
 
733
611
 
734
- def RandShuffle[T: Any](seq: abc.MutableSequence[T], /) -> None:
612
+ def RandShuffle[T](seq: abc.MutableSequence[T], /) -> None:
735
613
  """In-place Crypto-random shuffle order for `seq` mutable sequence.
736
614
 
737
615
  Args:
@@ -1108,17 +986,17 @@ class CryptoKey(abstract.ABC):
1108
986
 
1109
987
  @final
1110
988
  @property
1111
- def _json_dict(self) -> dict[str, Any]:
989
+ def _json_dict(self) -> JSONDict:
1112
990
  """Dictionary representation of the object suitable for JSON conversion.
1113
991
 
1114
992
  Returns:
1115
- dict[str, Any]: representation of the object suitable for JSON conversion
993
+ JSONDict: representation of the object suitable for JSON conversion
1116
994
 
1117
995
  Raises:
1118
996
  ImplementationError: object has types that are not supported in JSON
1119
997
 
1120
998
  """
1121
- self_dict: dict[str, Any] = dataclasses.asdict(self)
999
+ self_dict: CryptDict = dataclasses.asdict(self)
1122
1000
  for field in dataclasses.fields(self):
1123
1001
  # check the type is OK
1124
1002
  if field.type not in _JSON_DATACLASS_TYPES:
@@ -1127,8 +1005,8 @@ class CryptoKey(abstract.ABC):
1127
1005
  )
1128
1006
  # convert types that we accept but JSON does not
1129
1007
  if field.type == 'bytes':
1130
- self_dict[field.name] = BytesToEncoded(self_dict[field.name])
1131
- return self_dict
1008
+ self_dict[field.name] = BytesToEncoded(cast('bytes', self_dict[field.name]))
1009
+ return cast('JSONDict', self_dict)
1132
1010
 
1133
1011
  @final
1134
1012
  @property
@@ -1154,11 +1032,11 @@ class CryptoKey(abstract.ABC):
1154
1032
 
1155
1033
  @final
1156
1034
  @classmethod
1157
- def _FromJSONDict(cls, json_dict: dict[str, Any], /) -> Self:
1035
+ def _FromJSONDict(cls, json_dict: JSONDict, /) -> Self:
1158
1036
  """Create object from JSON representation.
1159
1037
 
1160
1038
  Args:
1161
- json_dict (dict[str, Any]): JSON dict
1039
+ json_dict (JSONDict): JSON dict
1162
1040
 
1163
1041
  Returns:
1164
1042
  a CryptoKey object ready for use
@@ -1180,7 +1058,7 @@ class CryptoKey(abstract.ABC):
1180
1058
  f'Unsupported JSON field {field.name!r}/{field.type} not in {_JSON_DATACLASS_TYPES}'
1181
1059
  )
1182
1060
  if field.type == 'bytes':
1183
- json_dict[field.name] = EncodedToBytes(json_dict[field.name])
1061
+ json_dict[field.name] = EncodedToBytes(json_dict[field.name]) # type: ignore[assignment, arg-type]
1184
1062
  # build the object
1185
1063
  return cls(**json_dict)
1186
1064
 
@@ -1200,7 +1078,7 @@ class CryptoKey(abstract.ABC):
1200
1078
 
1201
1079
  """
1202
1080
  # get the dict back
1203
- json_dict: dict[str, Any] = json.loads(json_data)
1081
+ json_dict: JSONDict = json.loads(json_data)
1204
1082
  if not isinstance(json_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
1205
1083
  raise InputError(f'JSON data decoded to unexpected type: {type(json_dict)}')
1206
1084
  return cls._FromJSONDict(json_dict)
@@ -1328,9 +1206,7 @@ class CryptoKey(abstract.ABC):
1328
1206
  data = BytesFromInput(data)
1329
1207
  # we now have bytes and we suppose it came from CryptoKey.blob()/CryptoKey.CryptoBlob()
1330
1208
  try:
1331
- json_dict: dict[str, Any] = DeSerialize(
1332
- data=data, key=key, silent=silent, unpickler=UnpickleJSON
1333
- )
1209
+ json_dict: JSONDict = DeSerialize(data=data, key=key, silent=silent, unpickler=UnpickleJSON)
1334
1210
  return cls._FromJSONDict(json_dict)
1335
1211
  except Exception as err:
1336
1212
  raise InputError(f'input decode error: {err}') from err
@@ -1445,15 +1321,15 @@ class Signer(Protocol):
1445
1321
  """
1446
1322
 
1447
1323
 
1448
- def Serialize(
1449
- python_obj: Any, # noqa: ANN401
1324
+ def Serialize[T](
1325
+ python_obj: T,
1450
1326
  /,
1451
1327
  *,
1452
1328
  file_path: str | None = None,
1453
1329
  compress: int | None = 3,
1454
1330
  key: Encryptor | None = None,
1455
1331
  silent: bool = False,
1456
- pickler: abc.Callable[[Any], bytes] = PickleGeneric,
1332
+ pickler: abc.Callable[[T], bytes] = PickleGeneric,
1457
1333
  ) -> bytes:
1458
1334
  """Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
1459
1335
 
@@ -1523,14 +1399,14 @@ def Serialize(
1523
1399
  return obj
1524
1400
 
1525
1401
 
1526
- def DeSerialize( # noqa: C901
1402
+ def DeSerialize[T]( # noqa: C901
1527
1403
  *,
1528
1404
  data: bytes | None = None,
1529
1405
  file_path: str | None = None,
1530
1406
  key: Decryptor | None = None,
1531
1407
  silent: bool = False,
1532
- unpickler: abc.Callable[[bytes], Any] = UnpickleGeneric,
1533
- ) -> Any: # noqa: ANN401
1408
+ unpickler: abc.Callable[[bytes], T] = UnpickleGeneric,
1409
+ ) -> T:
1534
1410
  """Load (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
1535
1411
 
1536
1412
  Data path is:
@@ -1602,7 +1478,7 @@ def DeSerialize( # noqa: C901
1602
1478
  messages.append(' (no compression detected)')
1603
1479
  # create the actual object = unpickle
1604
1480
  with Timer('UNPICKLE', emit_log=False) as tm_unpickle:
1605
- python_obj: Any = unpickler(obj)
1481
+ python_obj: T = unpickler(obj)
1606
1482
  if not silent:
1607
1483
  messages.append(f' {tm_unpickle}')
1608
1484
  # log and return
@@ -1759,160 +1635,3 @@ class PrivateBid512(PublicBid512):
1759
1635
  private_key=private_key,
1760
1636
  secret_bid=secret,
1761
1637
  )
1762
-
1763
-
1764
- @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
1765
- class CLIConfig:
1766
- """CLI global context, storing the configuration."""
1767
-
1768
- console: rich_console.Console
1769
- verbose: int
1770
- color: bool | None
1771
-
1772
-
1773
- def CLIErrorGuard[**P](fn: abc.Callable[P, None], /) -> abc.Callable[P, None]:
1774
- """Guard CLI command functions.
1775
-
1776
- Returns:
1777
- A wrapped function that catches expected user-facing errors and prints them consistently.
1778
-
1779
- """
1780
-
1781
- @functools.wraps(fn)
1782
- def _Wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
1783
- try:
1784
- # call the actual function
1785
- fn(*args, **kwargs)
1786
- except (Error, ValueError) as err:
1787
- # get context
1788
- ctx: object | None = dict(kwargs).get('ctx')
1789
- if not isinstance(ctx, typer.Context):
1790
- ctx = next((a for a in args if isinstance(a, typer.Context)), None)
1791
- # print error nicely
1792
- if isinstance(ctx, typer.Context):
1793
- # we have context
1794
- obj: CLIConfig = cast('CLIConfig', ctx.obj)
1795
- if obj.verbose >= 2: # verbose >= 2 means INFO level or more verbose # noqa: PLR2004
1796
- obj.console.print_exception() # print full traceback
1797
- else:
1798
- obj.console.print(str(err)) # print only error message
1799
- # no context
1800
- elif logging.getLogger().getEffectiveLevel() < logging.INFO:
1801
- Console().print(str(err)) # print only error message (DEBUG level is verbose already)
1802
- else:
1803
- Console().print_exception() # print full traceback (less verbose mode needs it)
1804
-
1805
- return _Wrapper
1806
-
1807
-
1808
- def _ClickWalk(
1809
- command: click.Command,
1810
- ctx: typer.Context,
1811
- path: list[str],
1812
- /,
1813
- ) -> abc.Iterator[tuple[list[str], click.Command, typer.Context]]:
1814
- """Recursively walk Click commands/groups.
1815
-
1816
- Yields:
1817
- tuple[list[str], click.Command, typer.Context]: path, command, ctx
1818
-
1819
- """
1820
- yield (path, command, ctx) # yield self
1821
- # now walk subcommands, if any
1822
- sub_cmd: click.Command | None
1823
- sub_ctx: typer.Context
1824
- # prefer the explicit `.commands` mapping when present; otherwise fall back to
1825
- # click's `list_commands()`/`get_command()` for dynamic groups
1826
- if not isinstance(command, click.Group):
1827
- return
1828
- # explicit commands mapping
1829
- if command.commands:
1830
- for name, sub_cmd in sorted(command.commands.items()):
1831
- sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
1832
- yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
1833
- return
1834
- # dynamic commands
1835
- for name in sorted(command.list_commands(ctx)):
1836
- sub_cmd = command.get_command(ctx, name)
1837
- if sub_cmd is None:
1838
- continue # skip invalid subcommands
1839
- sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
1840
- yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
1841
-
1842
-
1843
- def GenerateTyperHelpMarkdown(
1844
- typer_app: typer.Typer,
1845
- /,
1846
- *,
1847
- prog_name: str,
1848
- heading_level: int = 1,
1849
- code_fence_language: str = 'text',
1850
- ) -> str:
1851
- """Capture `--help` for a Typer CLI and all subcommands as Markdown.
1852
-
1853
- This function converts a Typer app to its underlying Click command tree and then:
1854
- - invokes `--help` for the root ("Main") command
1855
- - walks commands/subcommands recursively
1856
- - invokes `--help` for each command path
1857
-
1858
- It emits a Markdown document with a heading per command and a fenced block
1859
- containing the exact `--help` output.
1860
-
1861
- Notes:
1862
- - This uses Click's `CliRunner().invoke(...)` for faithful output.
1863
- - The walk is generic over Click `MultiCommand`/`Group` structures.
1864
- - If a command cannot be loaded, it is skipped.
1865
-
1866
- Args:
1867
- typer_app: The Typer app (e.g. `app`).
1868
- prog_name: Program name used in usage strings (e.g. "profiler").
1869
- heading_level: Markdown heading level for each command section.
1870
- code_fence_language: Language tag for fenced blocks (default: "text").
1871
-
1872
- Returns:
1873
- Markdown string.
1874
-
1875
- """
1876
- # prepare Click root command and context
1877
- click_root: click.Command = typer.main.get_command(typer_app)
1878
- root_ctx: typer.Context = typer.Context(click_root, info_name=prog_name)
1879
- runner = click_testing.CliRunner()
1880
- parts: list[str] = []
1881
- for path, _, _ in _ClickWalk(click_root, root_ctx, []):
1882
- # build command path
1883
- command_path: str = ' '.join([prog_name, *path]).strip()
1884
- heading_prefix: str = '#' * max(1, heading_level + len(path))
1885
- ResetConsole() # ensure clean state for each command (also it raises on duplicate loggers)
1886
- # invoke --help for this command path
1887
- result: click_testing.Result = runner.invoke(
1888
- click_root,
1889
- [*path, '--help'],
1890
- prog_name=prog_name,
1891
- color=False,
1892
- )
1893
- if result.exit_code != 0 and not result.output:
1894
- continue # skip invalid commands
1895
- # build markdown section
1896
- global_prefix: str = ( # only for the top-level command
1897
- (
1898
- '<!-- cspell:disable -->\n'
1899
- '<!-- auto-generated; DO NOT EDIT! see base.GenerateTyperHelpMarkdown() -->\n\n'
1900
- )
1901
- if not path
1902
- else ''
1903
- )
1904
- extras: str = ( # type of command, by level
1905
- ('Command-Line Interface' if not path else 'Command') if len(path) <= 1 else 'Sub-Command'
1906
- )
1907
- parts.extend(
1908
- (
1909
- f'{global_prefix}{heading_prefix} `{command_path}` {extras}',
1910
- '',
1911
- f'```{code_fence_language}',
1912
- result.output.strip(),
1913
- '```',
1914
- '',
1915
- )
1916
- )
1917
- # join all parts and return
1918
- return '\n'.join(parts).rstrip()
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """CLI logic."""