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 +1 -1
- transcrypto/base.py +58 -339
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +368 -0
- transcrypto/cli/bidsecret.py +334 -0
- transcrypto/cli/clibase.py +303 -0
- transcrypto/cli/intmath.py +427 -0
- transcrypto/cli/publicalgos.py +877 -0
- transcrypto/profiler.py +10 -7
- transcrypto/transcrypto.py +40 -1986
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/METADATA +12 -10
- transcrypto-1.8.0.dist-info/RECORD +23 -0
- transcrypto-1.7.0.dist-info/RECORD +0 -17
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/entry_points.txt +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|