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.
- transcrypto/__init__.py +1 -1
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +370 -0
- transcrypto/cli/bidsecret.py +336 -0
- transcrypto/cli/clibase.py +183 -0
- transcrypto/cli/intmath.py +429 -0
- transcrypto/cli/publicalgos.py +878 -0
- transcrypto/core/__init__.py +3 -0
- transcrypto/{aes.py → core/aes.py} +17 -29
- transcrypto/core/bid.py +161 -0
- transcrypto/{dsa.py → core/dsa.py} +28 -27
- transcrypto/{elgamal.py → core/elgamal.py} +33 -32
- transcrypto/core/hashes.py +96 -0
- transcrypto/core/key.py +735 -0
- transcrypto/{modmath.py → core/modmath.py} +91 -17
- transcrypto/{rsa.py → core/rsa.py} +51 -50
- transcrypto/{sss.py → core/sss.py} +27 -26
- transcrypto/profiler.py +29 -13
- transcrypto/transcrypto.py +60 -1996
- transcrypto/utils/__init__.py +3 -0
- transcrypto/utils/base.py +72 -0
- transcrypto/utils/human.py +278 -0
- transcrypto/utils/logging.py +139 -0
- transcrypto/utils/saferandom.py +102 -0
- transcrypto/utils/stats.py +360 -0
- transcrypto/utils/timer.py +175 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/METADATA +111 -109
- transcrypto-2.0.0.dist-info/RECORD +33 -0
- transcrypto/base.py +0 -1918
- transcrypto-1.7.0.dist-info/RECORD +0 -17
- /transcrypto/{constants.py → core/constants.py} +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/entry_points.txt +0 -0
- {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()
|