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.
@@ -0,0 +1,334 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Balparda's TransCrypto CLI: Bid secret and SSS commands."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import glob
8
+
9
+ import typer
10
+
11
+ from transcrypto import base, sss, transcrypto
12
+ from transcrypto.cli import clibase
13
+
14
+ # ================================== "BID" COMMAND =================================================
15
+
16
+
17
+ bid_app = typer.Typer(
18
+ no_args_is_help=True,
19
+ help=(
20
+ 'Bidding on a `secret` so that you can cryptographically convince a neutral '
21
+ 'party that the `secret` that was committed to previously was not changed. '
22
+ 'All methods require file key(s) as `-p`/`--key-path` (see provided examples). '
23
+ 'All non-int inputs are raw, or you can use `--input-format <hex|b64|bin>`. '
24
+ 'No measures are taken here to prevent timing attacks.'
25
+ ),
26
+ )
27
+ transcrypto.app.add_typer(bid_app, name='bid')
28
+
29
+
30
+ @bid_app.command(
31
+ 'new',
32
+ help=('Generate the bid files for `secret`.'),
33
+ epilog=(
34
+ 'Example:\n\n\n\n'
35
+ '$ poetry run transcrypto -i bin -p my-bid bid new "tomorrow it will rain"\n\n'
36
+ "Bid private/public commitments saved to 'my-bid.priv/.pub'"
37
+ ),
38
+ )
39
+ @clibase.CLIErrorGuard
40
+ def BidNew( # documentation is help/epilog/args # noqa: D103
41
+ *,
42
+ ctx: typer.Context,
43
+ secret: str = typer.Argument(..., help='Input data to bid to, the protected "secret"'),
44
+ ) -> None:
45
+ config: transcrypto.TransConfig = ctx.obj
46
+ base_path: str = transcrypto.RequireKeyPath(config, 'bid')
47
+ secret_bytes: bytes = transcrypto.BytesFromText(secret, config.input_format)
48
+ bid_priv: base.PrivateBid512 = base.PrivateBid512.New(secret_bytes)
49
+ bid_pub: base.PublicBid512 = base.PublicBid512.Copy(bid_priv)
50
+ transcrypto.SaveObj(bid_priv, base_path + '.priv', config.protect)
51
+ transcrypto.SaveObj(bid_pub, base_path + '.pub', config.protect)
52
+ config.console.print(f'Bid private/public commitments saved to {base_path + ".priv/.pub"!r}')
53
+
54
+
55
+ @bid_app.command(
56
+ 'verify',
57
+ help=('Verify the bid files for correctness and reveal the `secret`.'),
58
+ epilog=(
59
+ 'Example:\n\n\n\n'
60
+ '$ poetry run transcrypto -o bin -p my-bid bid verify\n\n'
61
+ 'Bid commitment: OK\n\n'
62
+ 'Bid secret:\n\n'
63
+ 'tomorrow it will rain'
64
+ ),
65
+ )
66
+ @clibase.CLIErrorGuard
67
+ def BidVerify(*, ctx: typer.Context) -> None: # documentation is help/epilog/args # noqa: D103
68
+ config: transcrypto.TransConfig = ctx.obj
69
+ base_path: str = transcrypto.RequireKeyPath(config, 'bid')
70
+ bid_priv: base.PrivateBid512 = transcrypto.LoadObj(
71
+ base_path + '.priv', config.protect, base.PrivateBid512
72
+ )
73
+ bid_pub: base.PublicBid512 = transcrypto.LoadObj(
74
+ base_path + '.pub', config.protect, base.PublicBid512
75
+ )
76
+ bid_pub_expect: base.PublicBid512 = base.PublicBid512.Copy(bid_priv)
77
+ config.console.print(
78
+ 'Bid commitment: '
79
+ + (
80
+ '[green]OK[/]'
81
+ if (
82
+ bid_pub.VerifyBid(bid_priv.private_key, bid_priv.secret_bid) and bid_pub == bid_pub_expect
83
+ )
84
+ else '[red]INVALID[/]'
85
+ )
86
+ )
87
+ config.console.print('Bid secret:')
88
+ config.console.print(transcrypto.BytesToText(bid_priv.secret_bid, config.output_format))
89
+
90
+
91
+ # ================================== "SSS" COMMAND =================================================
92
+
93
+
94
+ sss_app = typer.Typer(
95
+ no_args_is_help=True,
96
+ help=(
97
+ 'SSS (Shamir Shared Secret) secret sharing crypto scheme. '
98
+ 'All methods require file key(s) as `-p`/`--key-path` (see provided examples). '
99
+ 'All non-int inputs are raw, or you can use `--input-format <hex|b64|bin>`. '
100
+ 'No measures are taken here to prevent timing attacks.'
101
+ ),
102
+ )
103
+ transcrypto.app.add_typer(sss_app, name='sss')
104
+
105
+
106
+ @sss_app.command(
107
+ 'new',
108
+ help=(
109
+ 'Generate the private keys with `bits` prime modulus size and so that at least a '
110
+ '`minimum` number of shares are needed to recover the secret. '
111
+ 'This key will be used to generate the shares later (with the `shares` command).'
112
+ ),
113
+ epilog=(
114
+ 'Example:\n\n\n\n'
115
+ '$ poetry run transcrypto -p sss-key sss new 3 --bits 64 '
116
+ '# NEVER use such a small key: example only!\n\n'
117
+ "SSS private/public keys saved to 'sss-key.priv/.pub'"
118
+ ),
119
+ )
120
+ @clibase.CLIErrorGuard
121
+ def SSSNew( # documentation is help/epilog/args # noqa: D103
122
+ *,
123
+ ctx: typer.Context,
124
+ minimum: int = typer.Argument(
125
+ ..., min=2, help='Minimum number of shares required to recover secret, ≥ 2'
126
+ ),
127
+ bits: int = typer.Option(
128
+ 1024,
129
+ '-b',
130
+ '--bits',
131
+ min=16,
132
+ help=(
133
+ 'Prime modulus (`p`) size in bits, ≥16; the default (1024) is a safe size ***IFF*** you '
134
+ 'are protecting symmetric keys; the number of bits should be comfortably larger '
135
+ 'than the size of the secret you want to protect with this scheme'
136
+ ),
137
+ ),
138
+ ) -> None:
139
+ config: transcrypto.TransConfig = ctx.obj
140
+ base_path: str = transcrypto.RequireKeyPath(config, 'sss')
141
+ sss_priv: sss.ShamirSharedSecretPrivate = sss.ShamirSharedSecretPrivate.New(minimum, bits)
142
+ sss_pub: sss.ShamirSharedSecretPublic = sss.ShamirSharedSecretPublic.Copy(sss_priv)
143
+ transcrypto.SaveObj(sss_priv, base_path + '.priv', config.protect)
144
+ transcrypto.SaveObj(sss_pub, base_path + '.pub', config.protect)
145
+ config.console.print(f'SSS private/public keys saved to {base_path + ".priv/.pub"!r}')
146
+
147
+
148
+ @sss_app.command(
149
+ 'rawshares',
150
+ help=(
151
+ 'Raw shares: Issue `count` private shares for an *integer* `secret` '
152
+ '(BEWARE: no modern message wrapping, padding or validation).'
153
+ ),
154
+ epilog=(
155
+ 'Example:\n\n\n\n'
156
+ '$ poetry run transcrypto -p sss-key sss rawshares 999 5\n\n'
157
+ "SSS 5 individual (private) shares saved to 'sss-key.share.1…5'\n\n"
158
+ '$ rm sss-key.share.2 sss-key.share.4 # this is to simulate only having shares 1,3,5'
159
+ ),
160
+ )
161
+ @clibase.CLIErrorGuard
162
+ def SSSRawShares( # documentation is help/epilog/args # noqa: D103
163
+ *,
164
+ ctx: typer.Context,
165
+ secret: str = typer.Argument(..., help='Integer secret to be protected, 1≤`secret`<*modulus*'),
166
+ count: int = typer.Argument(
167
+ ...,
168
+ min=1,
169
+ help=(
170
+ 'How many shares to produce; must be ≥ `minimum` used in `new` command or else the '
171
+ '`secret` would become unrecoverable'
172
+ ),
173
+ ),
174
+ ) -> None:
175
+ config: transcrypto.TransConfig = ctx.obj
176
+ base_path: str = transcrypto.RequireKeyPath(config, 'sss')
177
+ sss_priv: sss.ShamirSharedSecretPrivate = transcrypto.LoadObj(
178
+ base_path + '.priv', config.protect, sss.ShamirSharedSecretPrivate
179
+ )
180
+ if count < sss_priv.minimum:
181
+ raise base.InputError(
182
+ f'count ({count}) must be >= minimum ({sss_priv.minimum}) to allow secret recovery'
183
+ )
184
+ secret_i: int = transcrypto.ParseInt(secret, min_value=1)
185
+ for i, share in enumerate(sss_priv.RawShares(secret_i, max_shares=count)):
186
+ transcrypto.SaveObj(share, f'{base_path}.share.{i + 1}', config.protect)
187
+ config.console.print(
188
+ f'SSS {count} individual (private) shares saved to {base_path + ".share.1…" + str(count)!r}'
189
+ )
190
+
191
+
192
+ @sss_app.command(
193
+ 'rawrecover',
194
+ help=(
195
+ 'Raw recover *integer* secret from shares; will use any available shares '
196
+ 'that were found (BEWARE: no modern message wrapping, padding or validation).'
197
+ ),
198
+ epilog=(
199
+ 'Example:\n\n\n\n'
200
+ '$ poetry run transcrypto -p sss-key sss rawrecover\n\n'
201
+ "Loaded SSS share: 'sss-key.share.3'\n\n"
202
+ "Loaded SSS share: 'sss-key.share.5'\n\n"
203
+ "Loaded SSS share: 'sss-key.share.1' # using only 3 shares: number 2/4 are missing\n\n"
204
+ 'Secret:\n\n'
205
+ '999'
206
+ ),
207
+ )
208
+ @clibase.CLIErrorGuard
209
+ def SSSRawRecover(*, ctx: typer.Context) -> None: # documentation is help/epilog/args # noqa: D103
210
+ config: transcrypto.TransConfig = ctx.obj
211
+ base_path: str = transcrypto.RequireKeyPath(config, 'sss')
212
+ sss_pub: sss.ShamirSharedSecretPublic = transcrypto.LoadObj(
213
+ base_path + '.pub', config.protect, sss.ShamirSharedSecretPublic
214
+ )
215
+ subset: list[sss.ShamirSharePrivate] = []
216
+ for fname in glob.glob(base_path + '.share.*'): # noqa: PTH207
217
+ subset.append(transcrypto.LoadObj(fname, config.protect, sss.ShamirSharePrivate))
218
+ config.console.print(f'Loaded SSS share: {fname!r}')
219
+ config.console.print('Secret:')
220
+ config.console.print(sss_pub.RawRecoverSecret(subset))
221
+
222
+
223
+ @sss_app.command(
224
+ 'rawverify',
225
+ help=(
226
+ 'Raw verify shares against a secret (private params; '
227
+ 'BEWARE: no modern message wrapping, padding or validation).'
228
+ ),
229
+ epilog=(
230
+ 'Example:\n\n\n\n'
231
+ '$ poetry run transcrypto -p sss-key sss rawverify 999\n\n'
232
+ "SSS share 'sss-key.share.3' verification: OK\n\n"
233
+ "SSS share 'sss-key.share.5' verification: OK\n\n"
234
+ "SSS share 'sss-key.share.1' verification: OK\n\n"
235
+ '$ poetry run transcrypto -p sss-key sss rawverify 998\n\n'
236
+ "SSS share 'sss-key.share.3' verification: INVALID\n\n"
237
+ "SSS share 'sss-key.share.5' verification: INVALID\n\n"
238
+ "SSS share 'sss-key.share.1' verification: INVALID"
239
+ ),
240
+ )
241
+ @clibase.CLIErrorGuard
242
+ def SSSRawVerify( # documentation is help/epilog/args # noqa: D103
243
+ *,
244
+ ctx: typer.Context,
245
+ secret: str = typer.Argument(..., help='Integer secret used to generate the shares, ≥ 1'),
246
+ ) -> None:
247
+ config: transcrypto.TransConfig = ctx.obj
248
+ base_path: str = transcrypto.RequireKeyPath(config, 'sss')
249
+ sss_priv: sss.ShamirSharedSecretPrivate = transcrypto.LoadObj(
250
+ base_path + '.priv', config.protect, sss.ShamirSharedSecretPrivate
251
+ )
252
+ secret_i: int = transcrypto.ParseInt(secret, min_value=1)
253
+ for fname in glob.glob(base_path + '.share.*'): # noqa: PTH207
254
+ share: sss.ShamirSharePrivate = transcrypto.LoadObj(
255
+ fname, config.protect, sss.ShamirSharePrivate
256
+ )
257
+ config.console.print(
258
+ f'SSS share {fname!r} verification: '
259
+ f'{"OK" if sss_priv.RawVerifyShare(secret_i, share) else "INVALID"}'
260
+ )
261
+
262
+
263
+ @sss_app.command(
264
+ 'shares',
265
+ help='Shares: Issue `count` private shares for a `secret`.',
266
+ epilog=(
267
+ 'Example:\n\n\n\n'
268
+ '$ poetry run transcrypto -i bin -p sss-key sss shares "abcde" 5\n\n'
269
+ "SSS 5 individual (private) shares saved to 'sss-key.share.1…5'\n\n"
270
+ '$ rm sss-key.share.2 sss-key.share.4 # this is to simulate only having shares 1,3,5'
271
+ ),
272
+ )
273
+ @clibase.CLIErrorGuard
274
+ def SSSShares( # documentation is help/epilog/args # noqa: D103
275
+ *,
276
+ ctx: typer.Context,
277
+ secret: str = typer.Argument(..., help='Secret to be protected'),
278
+ count: int = typer.Argument(
279
+ ...,
280
+ help=(
281
+ 'How many shares to produce; must be ≥ `minimum` used in `new` command or else the '
282
+ '`secret` would become unrecoverable'
283
+ ),
284
+ ),
285
+ ) -> None:
286
+ config: transcrypto.TransConfig = ctx.obj
287
+ base_path: str = transcrypto.RequireKeyPath(config, 'sss')
288
+ sss_priv: sss.ShamirSharedSecretPrivate = transcrypto.LoadObj(
289
+ base_path + '.priv', config.protect, sss.ShamirSharedSecretPrivate
290
+ )
291
+ if count < sss_priv.minimum:
292
+ raise base.InputError(
293
+ f'count ({count}) must be >= minimum ({sss_priv.minimum}) to allow secret recovery'
294
+ )
295
+ pt: bytes = transcrypto.BytesFromText(secret, config.input_format)
296
+ for i, data_share in enumerate(sss_priv.MakeDataShares(pt, count)):
297
+ transcrypto.SaveObj(data_share, f'{base_path}.share.{i + 1}', config.protect)
298
+ config.console.print(
299
+ f'SSS {count} individual (private) shares saved to {base_path + ".share.1…" + str(count)!r}'
300
+ )
301
+
302
+
303
+ @sss_app.command(
304
+ 'recover',
305
+ help='Recover secret from shares; will use any available shares that were found.',
306
+ epilog=(
307
+ 'Example:\n\n\n\n'
308
+ '$ poetry run transcrypto -o bin -p sss-key sss recover\n\n'
309
+ "Loaded SSS share: 'sss-key.share.3'\n\n"
310
+ "Loaded SSS share: 'sss-key.share.5'\n\n"
311
+ "Loaded SSS share: 'sss-key.share.1' # using only 3 shares: number 2/4 are missing\n\n"
312
+ 'Secret:\n\n'
313
+ 'abcde'
314
+ ),
315
+ )
316
+ @clibase.CLIErrorGuard
317
+ def SSSRecover(*, ctx: typer.Context) -> None: # documentation is help/epilog/args # noqa: D103
318
+ config: transcrypto.TransConfig = ctx.obj
319
+ base_path: str = transcrypto.RequireKeyPath(config, 'sss')
320
+ subset: list[sss.ShamirSharePrivate] = []
321
+ data_share: sss.ShamirShareData | None = None
322
+ for fname in glob.glob(base_path + '.share.*'): # noqa: PTH207
323
+ share: sss.ShamirSharePrivate = transcrypto.LoadObj(
324
+ fname, config.protect, sss.ShamirSharePrivate
325
+ )
326
+ subset.append(share)
327
+ if isinstance(share, sss.ShamirShareData):
328
+ data_share = share
329
+ config.console.print(f'Loaded SSS share: {fname!r}')
330
+ if data_share is None:
331
+ raise base.InputError('no data share found among the available shares')
332
+ pt: bytes = data_share.RecoverData(subset)
333
+ config.console.print('Secret:')
334
+ config.console.print(transcrypto.BytesToText(pt, config.output_format))
@@ -0,0 +1,303 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Balparda's CLI base library."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import dataclasses
8
+ import functools
9
+ import logging
10
+ import os
11
+ import threading
12
+ from collections import abc
13
+ from typing import cast
14
+
15
+ import click
16
+ import typer
17
+ from click import testing as click_testing
18
+ from rich import console as rich_console
19
+ from rich import logging as rich_logging
20
+
21
+ from transcrypto import base
22
+
23
+ # Logging
24
+ _LOG_FORMAT_NO_PROCESS: str = '%(funcName)s: %(message)s'
25
+ _LOG_FORMAT_WITH_PROCESS: str = '%(processName)s/' + _LOG_FORMAT_NO_PROCESS
26
+ _LOG_FORMAT_DATETIME: str = '[%Y%m%d-%H:%M:%S]' # e.g., [20240131-13:45:30]
27
+ _LOG_LEVELS: dict[int, int] = {
28
+ 0: logging.ERROR,
29
+ 1: logging.WARNING,
30
+ 2: logging.INFO,
31
+ 3: logging.DEBUG,
32
+ }
33
+ _LOG_COMMON_PROVIDERS: set[str] = {
34
+ 'werkzeug',
35
+ 'gunicorn.error',
36
+ 'gunicorn.access',
37
+ 'uvicorn',
38
+ 'uvicorn.error',
39
+ 'uvicorn.access',
40
+ 'django.server',
41
+ }
42
+
43
+ __console_lock: threading.RLock = threading.RLock()
44
+ __console_singleton: rich_console.Console | None = None
45
+
46
+
47
+ def Console() -> rich_console.Console:
48
+ """Get the global console instance.
49
+
50
+ Returns:
51
+ rich.console.Console: The global console instance.
52
+
53
+ """
54
+ with __console_lock:
55
+ if __console_singleton is None:
56
+ return rich_console.Console() # fallback console if InitLogging hasn't been called yet
57
+ return __console_singleton
58
+
59
+
60
+ def ResetConsole() -> None:
61
+ """Reset the global console instance."""
62
+ global __console_singleton # noqa: PLW0603
63
+ with __console_lock:
64
+ __console_singleton = None
65
+
66
+
67
+ def InitLogging(
68
+ verbosity: int,
69
+ /,
70
+ *,
71
+ include_process: bool = False,
72
+ soft_wrap: bool = False,
73
+ color: bool | None = False,
74
+ ) -> tuple[rich_console.Console, int, bool]:
75
+ """Initialize logger (with RichHandler) and get a rich.console.Console singleton.
76
+
77
+ This method will also return the actual decided values for verbosity and color use.
78
+ If you have a CLI app that uses this, its pytests should call `ResetConsole()` in a fixture, like:
79
+
80
+ from transcrypto import logging
81
+ @pytest.fixture(autouse=True)
82
+ def _reset_base_logging() -> Generator[None, None, None]: # type: ignore
83
+ logging.ResetConsole()
84
+ yield # stop
85
+
86
+ Args:
87
+ verbosity (int): Logging verbosity level: 0==ERROR, 1==WARNING, 2==INFO, 3==DEBUG
88
+ include_process (bool, optional): Whether to include process name in log output.
89
+ soft_wrap (bool, optional): Whether to enable soft wrapping in the console.
90
+ Default is False, and it means rich will hard-wrap long lines (by adding line breaks).
91
+ color (bool | None, optional): Whether to enable/disable color output in the console.
92
+ If None, respects NO_COLOR env var.
93
+
94
+ Returns:
95
+ tuple[rich_console.Console, int, bool]:
96
+ (The initialized console instance, actual log level, actual color use)
97
+
98
+ Raises:
99
+ RuntimeError: if you call this more than once
100
+
101
+ """
102
+ global __console_singleton # noqa: PLW0603
103
+ with __console_lock:
104
+ if __console_singleton is not None:
105
+ raise RuntimeError(
106
+ 'calling InitLogging() more than once is forbidden; '
107
+ 'use Console() to get a console after first creation'
108
+ )
109
+ # set level
110
+ logging_level: int = _LOG_LEVELS.get(min(verbosity, 3), logging.ERROR)
111
+ # respect NO_COLOR unless the caller has already decided (treat env presence as "disable color")
112
+ no_color: bool = (
113
+ False
114
+ if (os.getenv('NO_COLOR') is None and color is None)
115
+ else ((os.getenv('NO_COLOR') is not None) if color is None else (not color))
116
+ )
117
+ # create console and configure logging
118
+ console = rich_console.Console(soft_wrap=soft_wrap, no_color=no_color)
119
+ logging.basicConfig(
120
+ level=logging_level,
121
+ format=_LOG_FORMAT_WITH_PROCESS if include_process else _LOG_FORMAT_NO_PROCESS,
122
+ datefmt=_LOG_FORMAT_DATETIME,
123
+ handlers=[
124
+ rich_logging.RichHandler( # we show name/line, but want time & level
125
+ console=console,
126
+ rich_tracebacks=True,
127
+ show_time=True,
128
+ show_level=True,
129
+ show_path=True,
130
+ ),
131
+ ],
132
+ force=True, # force=True to override any previous logging config
133
+ )
134
+ # configure common loggers
135
+ logging.captureWarnings(True)
136
+ for name in _LOG_COMMON_PROVIDERS:
137
+ log: logging.Logger = logging.getLogger(name)
138
+ log.handlers.clear()
139
+ log.propagate = True
140
+ log.setLevel(logging_level)
141
+ __console_singleton = console # need a global statement to re-bind this one
142
+ logging.info(
143
+ f'Logging initialized at level {logging.getLevelName(logging_level)} / '
144
+ f'{"NO " if no_color else ""}COLOR'
145
+ )
146
+ return (console, logging_level, not no_color)
147
+
148
+
149
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
150
+ class CLIConfig:
151
+ """CLI global context, storing the configuration."""
152
+
153
+ console: rich_console.Console
154
+ verbose: int
155
+ color: bool | None
156
+
157
+
158
+ def CLIErrorGuard[**P](fn: abc.Callable[P, None], /) -> abc.Callable[P, None]:
159
+ """Guard CLI command functions.
160
+
161
+ Returns:
162
+ A wrapped function that catches expected user-facing errors and prints them consistently.
163
+
164
+ """
165
+
166
+ @functools.wraps(fn)
167
+ def _Wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
168
+ try:
169
+ # call the actual function
170
+ fn(*args, **kwargs)
171
+ except (base.Error, ValueError) as err:
172
+ # get context
173
+ ctx: object | None = dict(kwargs).get('ctx')
174
+ if not isinstance(ctx, typer.Context):
175
+ ctx = next((a for a in args if isinstance(a, typer.Context)), None)
176
+ # print error nicely
177
+ if isinstance(ctx, typer.Context):
178
+ # we have context
179
+ obj: CLIConfig = cast('CLIConfig', ctx.obj)
180
+ if obj.verbose >= 2: # verbose >= 2 means INFO level or more verbose # noqa: PLR2004
181
+ obj.console.print_exception() # print full traceback
182
+ else:
183
+ obj.console.print(str(err)) # print only error message
184
+ # no context
185
+ elif logging.getLogger().getEffectiveLevel() < logging.INFO:
186
+ Console().print(str(err)) # print only error message (DEBUG level is verbose already)
187
+ else:
188
+ Console().print_exception() # print full traceback (less verbose mode needs it)
189
+
190
+ return _Wrapper
191
+
192
+
193
+ def _ClickWalk(
194
+ command: click.Command,
195
+ ctx: typer.Context,
196
+ path: list[str],
197
+ /,
198
+ ) -> abc.Iterator[tuple[list[str], click.Command, typer.Context]]:
199
+ """Recursively walk Click commands/groups.
200
+
201
+ Yields:
202
+ tuple[list[str], click.Command, typer.Context]: path, command, ctx
203
+
204
+ """
205
+ yield (path, command, ctx) # yield self
206
+ # now walk subcommands, if any
207
+ sub_cmd: click.Command | None
208
+ sub_ctx: typer.Context
209
+ # prefer the explicit `.commands` mapping when present; otherwise fall back to
210
+ # click's `list_commands()`/`get_command()` for dynamic groups
211
+ if not isinstance(command, click.Group):
212
+ return
213
+ # explicit commands mapping
214
+ if command.commands:
215
+ for name, sub_cmd in sorted(command.commands.items()):
216
+ sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
217
+ yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
218
+ return
219
+ # dynamic commands
220
+ for name in sorted(command.list_commands(ctx)):
221
+ sub_cmd = command.get_command(ctx, name)
222
+ if sub_cmd is None:
223
+ continue # skip invalid subcommands
224
+ sub_ctx = typer.Context(sub_cmd, info_name=name, parent=ctx)
225
+ yield from _ClickWalk(sub_cmd, sub_ctx, [*path, name])
226
+
227
+
228
+ def GenerateTyperHelpMarkdown(
229
+ typer_app: typer.Typer,
230
+ /,
231
+ *,
232
+ prog_name: str,
233
+ heading_level: int = 1,
234
+ code_fence_language: str = 'text',
235
+ ) -> str:
236
+ """Capture `--help` for a Typer CLI and all subcommands as Markdown.
237
+
238
+ This function converts a Typer app to its underlying Click command tree and then:
239
+ - invokes `--help` for the root ("Main") command
240
+ - walks commands/subcommands recursively
241
+ - invokes `--help` for each command path
242
+
243
+ It emits a Markdown document with a heading per command and a fenced block
244
+ containing the exact `--help` output.
245
+
246
+ Notes:
247
+ - This uses Click's `CliRunner().invoke(...)` for faithful output.
248
+ - The walk is generic over Click `MultiCommand`/`Group` structures.
249
+ - If a command cannot be loaded, it is skipped.
250
+
251
+ Args:
252
+ typer_app: The Typer app (e.g. `app`).
253
+ prog_name: Program name used in usage strings (e.g. "profiler").
254
+ heading_level: Markdown heading level for each command section.
255
+ code_fence_language: Language tag for fenced blocks (default: "text").
256
+
257
+ Returns:
258
+ Markdown string.
259
+
260
+ """
261
+ # prepare Click root command and context
262
+ click_root: click.Command = typer.main.get_command(typer_app)
263
+ root_ctx: typer.Context = typer.Context(click_root, info_name=prog_name)
264
+ runner = click_testing.CliRunner()
265
+ parts: list[str] = []
266
+ for path, _, _ in _ClickWalk(click_root, root_ctx, []):
267
+ # build command path
268
+ command_path: str = ' '.join([prog_name, *path]).strip()
269
+ heading_prefix: str = '#' * max(1, heading_level + len(path))
270
+ ResetConsole() # ensure clean state for each command (also it raises on duplicate loggers)
271
+ # invoke --help for this command path
272
+ result: click_testing.Result = runner.invoke(
273
+ click_root,
274
+ [*path, '--help'],
275
+ prog_name=prog_name,
276
+ color=False,
277
+ )
278
+ if result.exit_code != 0 and not result.output:
279
+ continue # skip invalid commands
280
+ # build markdown section
281
+ global_prefix: str = ( # only for the top-level command
282
+ (
283
+ '<!-- cspell:disable -->\n'
284
+ '<!-- auto-generated; DO NOT EDIT! see base.GenerateTyperHelpMarkdown() -->\n\n'
285
+ )
286
+ if not path
287
+ else ''
288
+ )
289
+ extras: str = ( # type of command, by level
290
+ ('Command-Line Interface' if not path else 'Command') if len(path) <= 1 else 'Sub-Command'
291
+ )
292
+ parts.extend(
293
+ (
294
+ f'{global_prefix}{heading_prefix} `{command_path}` {extras}',
295
+ '',
296
+ f'```{code_fence_language}',
297
+ result.output.strip(),
298
+ '```',
299
+ '',
300
+ )
301
+ )
302
+ # join all parts and return
303
+ return '\n'.join(parts).rstrip()