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