log21 3.5.0__tar.gz → 3.6.0__tar.gz
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.
- {log21-3.5.0 → log21-3.6.0}/PKG-INFO +1 -1
- {log21-3.5.0 → log21-3.6.0}/pyproject.toml +1 -1
- {log21-3.5.0 → log21-3.6.0}/src/log21/__init__.py +1 -1
- {log21-3.5.0 → log21-3.6.0}/src/log21/argumentify.py +245 -13
- log21-3.6.0/src/log21/helper_types.py +252 -0
- log21-3.5.0/src/log21/helper_types.py +0 -126
- {log21-3.5.0 → log21-3.6.0}/README.md +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/_argparse.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/_module_helper.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/argparse.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/colors.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/crash_reporter/__init__.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/crash_reporter/formatters.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/crash_reporter/reporters.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/file_handler.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/formatters.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/levels.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/logger.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/logging_window.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/manager.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/pprint.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/progress_bar.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/stream_handler.py +0 -0
- {log21-3.5.0 → log21-3.6.0}/src/log21/tree_print.py +0 -0
|
@@ -33,7 +33,7 @@ from .stream_handler import StreamHandler, ColorizingStreamHandler
|
|
|
33
33
|
# yapf: enable
|
|
34
34
|
|
|
35
35
|
__author__ = 'CodeWriter21 (Mehrad Pooryoussof)'
|
|
36
|
-
__version__ = '3.
|
|
36
|
+
__version__ = '3.6.0'
|
|
37
37
|
__github__ = 'https://GitHub.com/MPCodeWriter21/log21'
|
|
38
38
|
__all__ = [
|
|
39
39
|
'ColorizingStreamHandler', 'DecolorizingFileHandler', 'ColorizingFormatter',
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
# yapf: disable
|
|
5
5
|
|
|
6
6
|
import re as _re
|
|
7
|
+
import os as _os
|
|
7
8
|
import string as _string
|
|
8
9
|
import asyncio as _asyncio
|
|
9
10
|
import inspect as _inspect
|
|
10
11
|
import types as _stdlib_types
|
|
11
12
|
import collections.abc as _collections_abc
|
|
13
|
+
from getpass import getpass as _stdlib_getpass
|
|
12
14
|
from typing import (Any as _Any, Set as _Set, Dict as _Dict, List as _List,
|
|
13
15
|
Tuple as _Tuple, Union as _Union, Callable as _Callable,
|
|
14
16
|
Optional as _Optional, Awaitable as _Awaitable,
|
|
@@ -18,6 +20,8 @@ from dataclasses import field as _field, dataclass as _dataclass
|
|
|
18
20
|
from docstring_parser import Docstring as _Docstring, parse as _parse
|
|
19
21
|
|
|
20
22
|
import log21.argparse as _argparse
|
|
23
|
+
import log21._argparse as _vendored_argparse
|
|
24
|
+
from log21.helper_types import Secret as _Secret
|
|
21
25
|
|
|
22
26
|
# yapf: enable
|
|
23
27
|
|
|
@@ -228,6 +232,7 @@ class Argument:
|
|
|
228
232
|
annotation: _Any = _inspect._empty
|
|
229
233
|
default: _Any = _inspect._empty
|
|
230
234
|
help: _Optional[str] = None
|
|
235
|
+
has_default: bool = False
|
|
231
236
|
|
|
232
237
|
def __post_init__(self) -> None:
|
|
233
238
|
"""Sets the some values to None if they are empty."""
|
|
@@ -262,6 +267,7 @@ class FunctionInfo:
|
|
|
262
267
|
kind=parameter.kind,
|
|
263
268
|
default=parameter.default,
|
|
264
269
|
annotation=parameter.annotation,
|
|
270
|
+
has_default=parameter.default is not _inspect._empty,
|
|
265
271
|
)
|
|
266
272
|
|
|
267
273
|
self.docstring = _parse(self.function.__doc__ or '')
|
|
@@ -384,6 +390,168 @@ def _is_repeatable_flag(argument: Argument) -> bool:
|
|
|
384
390
|
return is_list
|
|
385
391
|
|
|
386
392
|
|
|
393
|
+
def _secret_params(
|
|
394
|
+
annotation: _Any
|
|
395
|
+
) -> _Tuple[bool, _Any, _Optional[str], _Optional[bool], bool]:
|
|
396
|
+
"""Inspect an annotation for `Secret` parameters.
|
|
397
|
+
|
|
398
|
+
Unwraps `Optional[...]` / `... | None` spellings first, so
|
|
399
|
+
`Secret[str] | None` behaves like `Optional[Secret[str]]`.
|
|
400
|
+
|
|
401
|
+
:param annotation: The parameter annotation to inspect.
|
|
402
|
+
:return: An `(is_secret, value_type, env_var, prompt, optional)` tuple.
|
|
403
|
+
"""
|
|
404
|
+
if annotation is None:
|
|
405
|
+
return False, str, None, None, False
|
|
406
|
+
optional = False
|
|
407
|
+
args = getattr(annotation, '__args__', None)
|
|
408
|
+
none_type = getattr(_stdlib_types, 'NoneType', None)
|
|
409
|
+
if (args is not None and none_type is not None and len(args) == 2
|
|
410
|
+
and (args[0] is none_type or args[1] is none_type)):
|
|
411
|
+
optional = True
|
|
412
|
+
annotation = args[1] if args[0] is none_type else args[0]
|
|
413
|
+
# Parameterized secrets are `Secret` subclasses, so `Optional[...]` and
|
|
414
|
+
# `... | None` keep working natively.
|
|
415
|
+
if isinstance(annotation, type) and issubclass(annotation, _Secret):
|
|
416
|
+
return (
|
|
417
|
+
True, annotation.value_type, annotation.env_var, annotation.prompt,
|
|
418
|
+
optional
|
|
419
|
+
)
|
|
420
|
+
return False, str, None, None, optional
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _is_required_secret(argument: Argument) -> bool:
|
|
424
|
+
"""Check whether a secret parameter must resolve to a value.
|
|
425
|
+
|
|
426
|
+
Only bare `Secret[...]` annotations without a declared default are
|
|
427
|
+
required; `Optional` spellings and declared defaults make them optional.
|
|
428
|
+
"""
|
|
429
|
+
is_secret, _, _, _, optional = _secret_params(argument.annotation)
|
|
430
|
+
return is_secret and not optional and not argument.has_default
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _secret_converter(value_type: type) -> _Callable[[str], _Secret]:
|
|
434
|
+
"""Build an `argparse` type converter producing `Secret` values.
|
|
435
|
+
|
|
436
|
+
Conversion failures raise `ArgumentTypeError` with a fixed message, so
|
|
437
|
+
neither `argparse` nor tracebacks ever echo the secret value.
|
|
438
|
+
|
|
439
|
+
:param value_type: The type each raw value is converted to.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def convert(raw: str) -> _Secret:
|
|
443
|
+
try:
|
|
444
|
+
return _Secret(raw, value_type=value_type)
|
|
445
|
+
except (ValueError, TypeError):
|
|
446
|
+
raise _vendored_argparse.ArgumentTypeError('invalid Secret value') from None
|
|
447
|
+
|
|
448
|
+
convert.__name__ = 'Secret'
|
|
449
|
+
return convert
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _prompt_secret(prompt_text: str) -> _Optional[str]:
|
|
453
|
+
"""Securely prompt for a secret value (hidden input).
|
|
454
|
+
|
|
455
|
+
Separated for testability; returns `None` when input is unavailable
|
|
456
|
+
instead of raising.
|
|
457
|
+
|
|
458
|
+
:param prompt_text: The prompt to display.
|
|
459
|
+
:return: The entered value, or `None` on end-of-file / OS errors.
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
return _stdlib_getpass(prompt_text)
|
|
463
|
+
except (EOFError, OSError):
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _env_var_for(
|
|
468
|
+
argument_name: str, params_env_var: _Optional[str],
|
|
469
|
+
env_prefix: _Optional[str]
|
|
470
|
+
) -> _Optional[str]:
|
|
471
|
+
"""Derive the environment variable for a secret parameter.
|
|
472
|
+
|
|
473
|
+
A per-parameter override wins; otherwise `{PREFIX}_{NAME}` is derived
|
|
474
|
+
from the app-wide prefix. With neither, no environment lookup happens.
|
|
475
|
+
"""
|
|
476
|
+
if params_env_var:
|
|
477
|
+
return params_env_var
|
|
478
|
+
if env_prefix:
|
|
479
|
+
return f'{env_prefix}_{argument_name.upper()}'
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _resolve_secrets(
|
|
484
|
+
cli_args: _Any,
|
|
485
|
+
info: FunctionInfo,
|
|
486
|
+
parser: _Any,
|
|
487
|
+
env_prefix: _Optional[str] = None,
|
|
488
|
+
secret_prompt: bool = True
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Resolve secret parameters: flag value, environment, prompt, default.
|
|
491
|
+
|
|
492
|
+
For every `Secret`-annotated parameter still `None` after parsing (flag
|
|
493
|
+
absent), consult the environment variable, then a secure prompt (unless
|
|
494
|
+
disabled globally or per-parameter), then the declared default. Required
|
|
495
|
+
secrets missing after the whole chain are parser errors. Values are
|
|
496
|
+
always wrapped as `Secret`, and no error message ever includes one.
|
|
497
|
+
|
|
498
|
+
Repeatable (`list[Secret[str]]`) flags only get per-item conversion at
|
|
499
|
+
parse time; the chain does not apply to them.
|
|
500
|
+
|
|
501
|
+
:param cli_args: The parsed arguments namespace.
|
|
502
|
+
:param info: The function info.
|
|
503
|
+
:param parser: The parser (for redacted errors).
|
|
504
|
+
:param env_prefix: App-wide environment prefix, e.g. `"PDF_HELPER"`.
|
|
505
|
+
:param secret_prompt: Whether missing secrets may prompt securely.
|
|
506
|
+
"""
|
|
507
|
+
for argument in info.arguments.values():
|
|
508
|
+
if _is_repeatable_flag(argument):
|
|
509
|
+
continue
|
|
510
|
+
is_secret, value_type, params_env, params_prompt, _ = _secret_params(
|
|
511
|
+
argument.annotation
|
|
512
|
+
)
|
|
513
|
+
if not is_secret:
|
|
514
|
+
continue
|
|
515
|
+
value = getattr(cli_args, argument.name, None)
|
|
516
|
+
if value is not None:
|
|
517
|
+
if not isinstance(value, _Secret):
|
|
518
|
+
try:
|
|
519
|
+
value = _Secret(value, value_type=value_type)
|
|
520
|
+
setattr(cli_args, argument.name, value)
|
|
521
|
+
except (ValueError, TypeError):
|
|
522
|
+
parser.error('invalid Secret value')
|
|
523
|
+
continue
|
|
524
|
+
env_name = _env_var_for(argument.name, params_env, env_prefix)
|
|
525
|
+
env_value = _os.environ.get(env_name) if env_name else None
|
|
526
|
+
if env_value is not None:
|
|
527
|
+
try:
|
|
528
|
+
value = _Secret(env_value, value_type=value_type)
|
|
529
|
+
except (ValueError, TypeError):
|
|
530
|
+
parser.error('invalid Secret value')
|
|
531
|
+
else:
|
|
532
|
+
want_prompt = params_prompt if params_prompt is not None else secret_prompt
|
|
533
|
+
if want_prompt:
|
|
534
|
+
given = _prompt_secret(f'Enter {argument.name}: ')
|
|
535
|
+
if given is not None:
|
|
536
|
+
try:
|
|
537
|
+
value = _Secret(given, value_type=value_type)
|
|
538
|
+
except (ValueError, TypeError):
|
|
539
|
+
parser.error('invalid Secret value')
|
|
540
|
+
if value is None:
|
|
541
|
+
if argument.default is not None:
|
|
542
|
+
try:
|
|
543
|
+
value = _Secret(argument.default, value_type=value_type)
|
|
544
|
+
except (ValueError, TypeError):
|
|
545
|
+
parser.error('invalid Secret value')
|
|
546
|
+
elif _is_required_secret(argument):
|
|
547
|
+
hint = f' (or set {env_name})' if env_name else ''
|
|
548
|
+
parser.error(
|
|
549
|
+
'the following arguments are required: '
|
|
550
|
+
f'{argument.name}{hint}'
|
|
551
|
+
)
|
|
552
|
+
setattr(cli_args, argument.name, value)
|
|
553
|
+
|
|
554
|
+
|
|
387
555
|
def _normalize_exclusive_groups(
|
|
388
556
|
mutually_exclusive: _Any
|
|
389
557
|
) -> _List[_Tuple[_List[str], bool]]:
|
|
@@ -607,13 +775,26 @@ def _add_arguments(
|
|
|
607
775
|
'help': argument.help
|
|
608
776
|
}
|
|
609
777
|
flags = generate_flag(argument, reserved_flags=reserved_flags)
|
|
778
|
+
is_secret, secret_type, _, _, _ = _secret_params(argument.annotation)
|
|
610
779
|
if argument.annotation is bool:
|
|
611
780
|
config['action'] = 'store_true'
|
|
781
|
+
elif is_secret and not _is_repeatable_flag(argument):
|
|
782
|
+
# Direct secret: converted with redacted errors. Registered with
|
|
783
|
+
# an internal `None` default; flag value, environment, prompt and
|
|
784
|
+
# declared default are resolved by `_resolve_secrets` after
|
|
785
|
+
# parsing, so secrets are never argparse-required (a required
|
|
786
|
+
# flag would pre-empt the environment/prompt fallbacks).
|
|
787
|
+
config['type'] = _secret_converter(secret_type)
|
|
788
|
+
config['default'] = None
|
|
612
789
|
elif _is_repeatable_flag(argument):
|
|
613
790
|
_, element = _list_element_type(argument.annotation)
|
|
614
791
|
config['action'] = 'append'
|
|
615
792
|
if element is not None:
|
|
616
|
-
|
|
793
|
+
element_secret, element_type, _, _, _ = _secret_params(element)
|
|
794
|
+
if element_secret:
|
|
795
|
+
config['type'] = _secret_converter(element_type)
|
|
796
|
+
else:
|
|
797
|
+
config['type'] = element
|
|
617
798
|
# Registered with an internal `None` default so `argparse` never
|
|
618
799
|
# mutates the signature's default list; the declared default is
|
|
619
800
|
# restored by `_apply_repeatable_defaults` after parsing.
|
|
@@ -622,11 +803,25 @@ def _add_arguments(
|
|
|
622
803
|
config['type'] = argument.annotation
|
|
623
804
|
if argument.kind == _inspect._ParameterKind.POSITIONAL_ONLY:
|
|
624
805
|
flags = [config.pop('dest')]
|
|
625
|
-
is_list,
|
|
626
|
-
if is_list
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
806
|
+
is_list, element = _list_element_type(argument.annotation)
|
|
807
|
+
if is_list:
|
|
808
|
+
element_secret, element_type, _, _, _ = _secret_params(element)
|
|
809
|
+
if element_secret:
|
|
810
|
+
# `list[Secret[T]]` positionals: convert each item with
|
|
811
|
+
# redacted errors. The converter replaces the annotation,
|
|
812
|
+
# so nargs is set here instead of `_validate_func_type`.
|
|
813
|
+
config['type'] = _secret_converter(element_type)
|
|
814
|
+
if config.get('nargs') is None:
|
|
815
|
+
config['nargs'] = '*' if argument.default is not None else '+'
|
|
816
|
+
elif config.get('nargs') is None and argument.default is not None:
|
|
817
|
+
# A declared default (e.g. `= []`) makes the multi-value
|
|
818
|
+
# positional optional instead of requiring 1+ values.
|
|
819
|
+
config['nargs'] = '*'
|
|
820
|
+
if is_secret:
|
|
821
|
+
# Secret positionals accept zero values so the
|
|
822
|
+
# environment/prompt chain in `_resolve_secrets` can fire;
|
|
823
|
+
# missing required secrets become parser errors there.
|
|
824
|
+
config['nargs'] = '?'
|
|
630
825
|
if any(argument.name in names for names, _ in exclusive_groups or []):
|
|
631
826
|
# `argparse` only accepts optional actions in mutually
|
|
632
827
|
# exclusive groups: single-value positionals become `nargs='?'`
|
|
@@ -639,9 +834,13 @@ def _add_arguments(
|
|
|
639
834
|
if argument.kind == _inspect._ParameterKind.VAR_POSITIONAL:
|
|
640
835
|
config['nargs'] = '*'
|
|
641
836
|
flags = [config.pop('dest')]
|
|
642
|
-
|
|
837
|
+
if is_secret:
|
|
838
|
+
config['type'] = _secret_converter(secret_type)
|
|
839
|
+
if (argument.kind == _inspect._ParameterKind.POSITIONAL_OR_KEYWORD
|
|
840
|
+
and keyword_only_exists and not is_secret):
|
|
643
841
|
config['required'] = True
|
|
644
|
-
if argument.default is not None and config.get('action') != 'append'
|
|
842
|
+
if (argument.default is not None and config.get('action') != 'append'
|
|
843
|
+
and not is_secret):
|
|
645
844
|
config['default'] = argument.default
|
|
646
845
|
target = parser
|
|
647
846
|
for index, (names, _) in enumerate(exclusive_groups or []):
|
|
@@ -652,7 +851,10 @@ def _add_arguments(
|
|
|
652
851
|
|
|
653
852
|
|
|
654
853
|
def _argumentify_one(
|
|
655
|
-
func: Callable,
|
|
854
|
+
func: Callable,
|
|
855
|
+
mutually_exclusive: _Any = None,
|
|
856
|
+
env_prefix: _Optional[str] = None,
|
|
857
|
+
secret_prompt: bool = True
|
|
656
858
|
) -> None:
|
|
657
859
|
"""This function argumentifies one function as the entry point of the script.
|
|
658
860
|
|
|
@@ -660,6 +862,8 @@ def _argumentify_one(
|
|
|
660
862
|
:param mutually_exclusive: A list of mutually exclusive groups. Each
|
|
661
863
|
group is a list of parameter names or a
|
|
662
864
|
`{"names": [...], "required": bool}` mapping.
|
|
865
|
+
:param env_prefix: App-wide environment prefix for `Secret` parameters.
|
|
866
|
+
:param secret_prompt: Whether missing secrets may prompt securely.
|
|
663
867
|
"""
|
|
664
868
|
info = FunctionInfo(func)
|
|
665
869
|
|
|
@@ -672,6 +876,7 @@ def _argumentify_one(
|
|
|
672
876
|
_add_arguments(parser, info, exclusive_groups=groups)
|
|
673
877
|
cli_args = parser.parse_args()
|
|
674
878
|
_apply_repeatable_defaults(cli_args, info)
|
|
879
|
+
_resolve_secrets(cli_args, info, parser, env_prefix, secret_prompt)
|
|
675
880
|
args = []
|
|
676
881
|
kwargs = {}
|
|
677
882
|
for argument in info.arguments.values():
|
|
@@ -881,7 +1086,10 @@ def _add_command_level(
|
|
|
881
1086
|
|
|
882
1087
|
|
|
883
1088
|
def _argumentify(
|
|
884
|
-
functions: _Dict[str, _Any],
|
|
1089
|
+
functions: _Dict[str, _Any],
|
|
1090
|
+
mutually_exclusive: _Any = None,
|
|
1091
|
+
env_prefix: _Optional[str] = None,
|
|
1092
|
+
secret_prompt: bool = True
|
|
885
1093
|
) -> None:
|
|
886
1094
|
"""This function argumentifies one or more functions as the entry point of the
|
|
887
1095
|
script.
|
|
@@ -895,6 +1103,10 @@ def _argumentify(
|
|
|
895
1103
|
`{command_name: groups}` mapping for targeted groups. Each group is a
|
|
896
1104
|
list of parameter names or a `{"names": [...], "required": bool}`
|
|
897
1105
|
mapping.
|
|
1106
|
+
:param env_prefix: App-wide environment prefix for `Secret` parameters,
|
|
1107
|
+
e.g. `"PDF_HELPER"` derives `PDF_HELPER_PASSWORD` from `--password`.
|
|
1108
|
+
:param secret_prompt: Whether missing secrets may fall back to a secure
|
|
1109
|
+
prompt (per-parameter `Secret[..., prompt]` overrides this).
|
|
898
1110
|
:raises RuntimeError:
|
|
899
1111
|
"""
|
|
900
1112
|
tree = _normalize_command_node(functions)
|
|
@@ -935,6 +1147,7 @@ def _argumentify(
|
|
|
935
1147
|
else:
|
|
936
1148
|
raise RuntimeError('No function found for the given arguments.')
|
|
937
1149
|
_apply_repeatable_defaults(cli_args, info)
|
|
1150
|
+
_resolve_secrets(cli_args, info, parser, env_prefix, secret_prompt)
|
|
938
1151
|
for argument in info.arguments.values():
|
|
939
1152
|
if argument.kind in (_inspect._ParameterKind.POSITIONAL_ONLY,
|
|
940
1153
|
_inspect._ParameterKind.POSITIONAL_OR_KEYWORD):
|
|
@@ -954,7 +1167,9 @@ def _argumentify(
|
|
|
954
1167
|
|
|
955
1168
|
def argumentify(
|
|
956
1169
|
entry_point: _Union[Callable, _List[Callable], _Dict[str, _Any]],
|
|
957
|
-
mutually_exclusive: _Optional[_Any] = None
|
|
1170
|
+
mutually_exclusive: _Optional[_Any] = None,
|
|
1171
|
+
env_prefix: _Optional[str] = None,
|
|
1172
|
+
secret_prompt: bool = True
|
|
958
1173
|
) -> _Union[Callable, _List[Callable], _Dict[str, _Any]]:
|
|
959
1174
|
"""This function argumentifies one or more functions as the entry point of the
|
|
960
1175
|
script.
|
|
@@ -1030,8 +1245,25 @@ def argumentify(
|
|
|
1030
1245
|
chosen. A list of functions may be used wherever a mapping is expected;
|
|
1031
1246
|
empty groups raise `ValueError`.
|
|
1032
1247
|
|
|
1248
|
+
Parameters annotated with `Secret[T]` (`log21.helper_types.Secret`) are
|
|
1249
|
+
secrets: values resolve from the flag, then an environment variable, then
|
|
1250
|
+
a secure (hidden-input) prompt, then the declared default. The variable
|
|
1251
|
+
is a per-parameter override (`Secret[str, "PDF_HELPER_PASSWORD"]`) or
|
|
1252
|
+
derived from `env_prefix` (`argumentify(..., env_prefix="PDF_HELPER")`
|
|
1253
|
+
derives `PDF_HELPER_PASSWORD` from `--password`). Prompting happens for
|
|
1254
|
+
missing secrets unless disabled globally (`secret_prompt=False`) or
|
|
1255
|
+
per-parameter (`Secret[str, "ENV", False]`); required secrets missing
|
|
1256
|
+
after the whole chain are errors. Secrets never appear in log21 errors:
|
|
1257
|
+
|
|
1258
|
+
from log21.helper_types import Secret
|
|
1259
|
+
|
|
1260
|
+
def encrypt(in_path, out_path, /, password: Secret[str] | None = None): ...
|
|
1261
|
+
argumentify(encrypt, env_prefix="PDF_HELPER")
|
|
1262
|
+
|
|
1033
1263
|
:param entry_point: The function(s) to argumentify.
|
|
1034
1264
|
:param mutually_exclusive: Mutually exclusive parameter groups.
|
|
1265
|
+
:param env_prefix: App-wide environment prefix for `Secret` parameters.
|
|
1266
|
+
:param secret_prompt: Whether missing secrets may prompt securely.
|
|
1035
1267
|
:raises TypeError: A function must be a function or a list of functions or a
|
|
1036
1268
|
dictionary of functions.
|
|
1037
1269
|
:raises ValueError: A group is malformed or references unknown parameters.
|
|
@@ -1040,7 +1272,7 @@ def argumentify(
|
|
|
1040
1272
|
functions = {}
|
|
1041
1273
|
# Check the types
|
|
1042
1274
|
if callable(entry_point):
|
|
1043
|
-
_argumentify_one(entry_point, mutually_exclusive)
|
|
1275
|
+
_argumentify_one(entry_point, mutually_exclusive, env_prefix, secret_prompt)
|
|
1044
1276
|
return entry_point
|
|
1045
1277
|
if isinstance(entry_point, _List):
|
|
1046
1278
|
for func in entry_point:
|
|
@@ -1058,5 +1290,5 @@ def argumentify(
|
|
|
1058
1290
|
"dictionary of functions."
|
|
1059
1291
|
)
|
|
1060
1292
|
|
|
1061
|
-
_argumentify(functions, mutually_exclusive)
|
|
1293
|
+
_argumentify(functions, mutually_exclusive, env_prefix, secret_prompt)
|
|
1062
1294
|
return entry_point
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# log21.helper_types.py
|
|
2
|
+
# CodeWriter21
|
|
3
|
+
"""A collection of useful types meant for using with argument parser to parse CLI
|
|
4
|
+
arguments to more usable formats.
|
|
5
|
+
|
|
6
|
+
+ FileSize: Can take `str` and `int` values. Will convert human inputs such as "121 KB",
|
|
7
|
+
"21MiB", or "4.56 GB" to bytes. Can also be used to represent bytes value in more
|
|
8
|
+
human-readable formats.
|
|
9
|
+
+ Secret: Wraps a sensitive value (e.g. a password) so it is never echoed back:
|
|
10
|
+
`str()` and `repr()` are redacted and conversion errors never include the value.
|
|
11
|
+
`Secret[T]`, `Secret[T, "ENV_VAR"]` and `Secret[T, "ENV_VAR", prompt]` describe
|
|
12
|
+
the value type, an environment-variable fallback and prompting for `argumentify`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# yapf: disable
|
|
16
|
+
|
|
17
|
+
import re as _re
|
|
18
|
+
from math import log as _log
|
|
19
|
+
from functools import lru_cache as _lru_cache
|
|
20
|
+
from typing import Union as _Union, SupportsInt as _SupportsInt, Any as _Any
|
|
21
|
+
|
|
22
|
+
# yapf: enable
|
|
23
|
+
|
|
24
|
+
__all__ = ["FileSize", "Secret"]
|
|
25
|
+
|
|
26
|
+
POWERS = "KMGTPEZYRQ"
|
|
27
|
+
FILE_SIZE_PATTERN = _re.compile(rf"^([+-]?[0-9]+(?:\.[0-9]+)?)\s*(|[{POWERS}])(i?)B$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileSize:
|
|
31
|
+
|
|
32
|
+
def __init__(self, value: _Union[int, str]) -> None:
|
|
33
|
+
"""An interface for converting different inputs to file-size (bytes).
|
|
34
|
+
|
|
35
|
+
:param value: int value in bytes or a string such as "100 KB", "20MiB", or "1.23
|
|
36
|
+
GB"
|
|
37
|
+
:raises TypeError: If the value is not of type int or str
|
|
38
|
+
:raises ValueError: If the str value does not match the file-size pattern:
|
|
39
|
+
^([+-]?[0-9]+(?:\\.[0-9]+)?)\\s*(|[KMGTPEZYRQ])(i?)B$
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(value, int):
|
|
42
|
+
self.bytes = value
|
|
43
|
+
elif isinstance(value, str):
|
|
44
|
+
match = FILE_SIZE_PATTERN.match(value)
|
|
45
|
+
if not match:
|
|
46
|
+
raise ValueError(f"Input does not match the file-size pattern: {value}")
|
|
47
|
+
val, prefix, binary = match.groups()
|
|
48
|
+
power = POWERS.index(prefix) + 1
|
|
49
|
+
assert power is not None
|
|
50
|
+
self.bytes = int(float(val) * (1024 if binary else 1000)**power)
|
|
51
|
+
else:
|
|
52
|
+
raise TypeError(f"Input to FileSize() can be int or str, not {type(value)}")
|
|
53
|
+
|
|
54
|
+
def humanize(
|
|
55
|
+
self,
|
|
56
|
+
binary: bool = False,
|
|
57
|
+
gnu: bool = False,
|
|
58
|
+
fmt: str = "%.2f",
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Returns the size in a human readable way."""
|
|
61
|
+
base = 1024 if (gnu or binary) else 1000
|
|
62
|
+
abs_bytes = abs(self.bytes)
|
|
63
|
+
|
|
64
|
+
if abs_bytes == 1 and not gnu:
|
|
65
|
+
return f"{self.bytes} Byte"
|
|
66
|
+
|
|
67
|
+
if abs_bytes < base:
|
|
68
|
+
return f"{self.bytes}B" if gnu else f"{self.bytes} Bytes"
|
|
69
|
+
|
|
70
|
+
power = int(min(_log(abs_bytes, base), len(POWERS)))
|
|
71
|
+
result: str = fmt % (self.bytes / (base**power))
|
|
72
|
+
if gnu:
|
|
73
|
+
return result + POWERS[power - 1]
|
|
74
|
+
result += " " + POWERS[power - 1]
|
|
75
|
+
if binary:
|
|
76
|
+
result += "i"
|
|
77
|
+
result += "B"
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def KB(self) -> float:
|
|
82
|
+
return self.bytes / 1000
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def MB(self) -> float:
|
|
86
|
+
return self.bytes / 1000_000
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def GB(self) -> float:
|
|
90
|
+
return self.bytes / 1000_000_000
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def KiB(self) -> float:
|
|
94
|
+
return self.bytes / 1024
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def MiB(self) -> float:
|
|
98
|
+
return self.bytes / 1048576
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def GiB(self) -> float:
|
|
102
|
+
return self.bytes / 1073741824
|
|
103
|
+
|
|
104
|
+
def __int__(self) -> int:
|
|
105
|
+
return self.bytes
|
|
106
|
+
|
|
107
|
+
def __eq__(self, value: object) -> bool:
|
|
108
|
+
if not isinstance(value, _SupportsInt):
|
|
109
|
+
return False
|
|
110
|
+
return self.bytes == int(value)
|
|
111
|
+
|
|
112
|
+
def __lt__(self, value: _SupportsInt) -> bool:
|
|
113
|
+
return self.bytes < int(value)
|
|
114
|
+
|
|
115
|
+
def __le__(self, value: _SupportsInt) -> bool:
|
|
116
|
+
return self.bytes <= int(value)
|
|
117
|
+
|
|
118
|
+
def __gt__(self, value: _SupportsInt) -> bool:
|
|
119
|
+
return int(value) < self.bytes
|
|
120
|
+
|
|
121
|
+
def __ge__(self, value: _SupportsInt) -> bool:
|
|
122
|
+
return int(value) <= self.bytes
|
|
123
|
+
|
|
124
|
+
def __add__(self, value: _SupportsInt) -> "FileSize":
|
|
125
|
+
return FileSize(self.bytes + int(value))
|
|
126
|
+
|
|
127
|
+
def __str__(self) -> str:
|
|
128
|
+
return self.humanize(binary=True)
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
return f"<{self.__class__.__name__}: '{self!s}'>"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Secret:
|
|
135
|
+
"""A sensitive value (e.g. a password) that is never echoed back.
|
|
136
|
+
|
|
137
|
+
`str(secret)` and `repr(secret)` are redacted, and conversion errors never
|
|
138
|
+
include the value, so the secret cannot leak through logs, tracebacks, or
|
|
139
|
+
`argparse` error messages. Read the value via the `value` attribute.
|
|
140
|
+
|
|
141
|
+
Subscription describes a secret parameter and returns a `Secret`
|
|
142
|
+
subclass, so `Optional[...]` and `... | None` keep working natively:
|
|
143
|
+
`Secret[T]`, `Secret[T, "ENV_VAR"]` or `Secret[T, "ENV_VAR", prompt]`,
|
|
144
|
+
where `prompt` (default `None`, meaning "inherit the global setting")
|
|
145
|
+
controls whether a missing value falls back to a secure prompt.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
value_type: type = str
|
|
149
|
+
env_var: _Union[str, None] = None
|
|
150
|
+
prompt: _Union[bool, None] = None
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self, value: _Any = '', value_type: _Union[type, None] = None
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Wrap a value as a secret, converting it to `value_type`.
|
|
156
|
+
|
|
157
|
+
:param value: The secret value (usually a string from the CLI).
|
|
158
|
+
:param value_type: The type to convert the value to (default: the
|
|
159
|
+
class-level `value_type`, i.e. `str` for plain `Secret`).
|
|
160
|
+
:raises ValueError: If the value cannot be converted. The message
|
|
161
|
+
never includes the value itself.
|
|
162
|
+
"""
|
|
163
|
+
if value_type is None:
|
|
164
|
+
value_type = type(self).value_type
|
|
165
|
+
if isinstance(value, Secret):
|
|
166
|
+
value = value.value
|
|
167
|
+
try:
|
|
168
|
+
self._value = value if isinstance(value, value_type) else value_type(value)
|
|
169
|
+
except (ValueError, TypeError) as error:
|
|
170
|
+
raise ValueError('invalid Secret value') from error
|
|
171
|
+
self._value_type = value_type
|
|
172
|
+
|
|
173
|
+
def __class_getitem__(cls, params: _Any) -> type:
|
|
174
|
+
"""Describe a secret parameter: `Secret[T]`, `Secret[T, "ENV_VAR"]` or
|
|
175
|
+
`Secret[T, "ENV_VAR", prompt]`.
|
|
176
|
+
|
|
177
|
+
Returns a `Secret` subclass carrying the parameters, so the result
|
|
178
|
+
stays a real type (`Optional[...]`, `... | None`, `isinstance` and
|
|
179
|
+
`issubclass` all keep working).
|
|
180
|
+
"""
|
|
181
|
+
if not isinstance(params, tuple):
|
|
182
|
+
params = (params, )
|
|
183
|
+
if len(params) > 3:
|
|
184
|
+
raise TypeError(
|
|
185
|
+
'Secret accepts at most a value type, an environment variable '
|
|
186
|
+
'name and a prompt flag: Secret[T], Secret[T, "ENV_VAR"] or '
|
|
187
|
+
'Secret[T, "ENV_VAR", prompt].'
|
|
188
|
+
)
|
|
189
|
+
value_type = params[0] if len(params) > 0 else str
|
|
190
|
+
env_var = params[1] if len(params) > 1 else None
|
|
191
|
+
prompt = params[2] if len(params) > 2 else None
|
|
192
|
+
if not isinstance(value_type, type):
|
|
193
|
+
raise TypeError(
|
|
194
|
+
'Secret value type must be a type, '
|
|
195
|
+
f'not {type(value_type).__name__}.'
|
|
196
|
+
)
|
|
197
|
+
if env_var is not None and not isinstance(env_var, str):
|
|
198
|
+
raise TypeError(
|
|
199
|
+
'Secret environment variable name must be a string or None, '
|
|
200
|
+
f'not {type(env_var).__name__}.'
|
|
201
|
+
)
|
|
202
|
+
if prompt is not None and not isinstance(prompt, bool):
|
|
203
|
+
raise TypeError(
|
|
204
|
+
'Secret prompt flag must be a bool or None, '
|
|
205
|
+
f'not {type(prompt).__name__}.'
|
|
206
|
+
)
|
|
207
|
+
return _secret_subclass(value_type, env_var, prompt)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def value(self) -> _Any:
|
|
211
|
+
"""The wrapped (converted) secret value."""
|
|
212
|
+
return self._value
|
|
213
|
+
|
|
214
|
+
def __str__(self) -> str:
|
|
215
|
+
return '***'
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
return "Secret('***')"
|
|
219
|
+
|
|
220
|
+
def __bool__(self) -> bool:
|
|
221
|
+
return bool(self._value)
|
|
222
|
+
|
|
223
|
+
def __eq__(self, other: object) -> bool:
|
|
224
|
+
if isinstance(other, Secret):
|
|
225
|
+
return self._value == other._value
|
|
226
|
+
return self._value == other
|
|
227
|
+
|
|
228
|
+
def __hash__(self) -> int:
|
|
229
|
+
return hash((self._value_type, self._value))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@_lru_cache(maxsize=None)
|
|
233
|
+
def _secret_subclass(
|
|
234
|
+
value_type: type, env_var: _Union[str, None], prompt: _Union[bool, None]
|
|
235
|
+
) -> type:
|
|
236
|
+
"""Build (and cache) the `Secret` subclass for one parameter shape.
|
|
237
|
+
|
|
238
|
+
Cached so identical subscriptions return the identical class.
|
|
239
|
+
"""
|
|
240
|
+
parts = [value_type.__name__]
|
|
241
|
+
if env_var is not None or prompt is not None:
|
|
242
|
+
parts.append(repr(env_var))
|
|
243
|
+
if prompt is not None:
|
|
244
|
+
parts.append(repr(prompt))
|
|
245
|
+
name = f"Secret[{', '.join(parts)}]"
|
|
246
|
+
namespace = {
|
|
247
|
+
'value_type': value_type,
|
|
248
|
+
'env_var': env_var,
|
|
249
|
+
'prompt': prompt,
|
|
250
|
+
'__module__': __name__,
|
|
251
|
+
}
|
|
252
|
+
return type(name, (Secret, ), namespace)
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
# log21.helper_types.py
|
|
2
|
-
# CodeWriter21
|
|
3
|
-
"""A collection of useful types meant for using with argument parser to parse CLI
|
|
4
|
-
arguments to more usable formats.
|
|
5
|
-
|
|
6
|
-
+ FileSize: Can take `str` and `int` values. Will convert human inputs such as "121 KB",
|
|
7
|
-
"21MiB", or "4.56 GB" to bytes. Can also be used to represent bytes value in more
|
|
8
|
-
human-readable formats.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
# yapf: disable
|
|
12
|
-
|
|
13
|
-
import re as _re
|
|
14
|
-
from math import log as _log
|
|
15
|
-
from typing import Union as _Union, SupportsInt as _SupportsInt
|
|
16
|
-
|
|
17
|
-
# yapf: enable
|
|
18
|
-
|
|
19
|
-
__all__ = ["FileSize"]
|
|
20
|
-
|
|
21
|
-
POWERS = "KMGTPEZYRQ"
|
|
22
|
-
FILE_SIZE_PATTERN = _re.compile(rf"^([+-]?[0-9]+(?:\.[0-9]+)?)\s*(|[{POWERS}])(i?)B$")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class FileSize:
|
|
26
|
-
|
|
27
|
-
def __init__(self, value: _Union[int, str]) -> None:
|
|
28
|
-
"""An interface for converting different inputs to file-size (bytes).
|
|
29
|
-
|
|
30
|
-
:param value: int value in bytes or a string such as "100 KB", "20MiB", or "1.23
|
|
31
|
-
GB"
|
|
32
|
-
:raises TypeError: If the value is not of type int or str
|
|
33
|
-
:raises ValueError: If the str value does not match the file-size pattern:
|
|
34
|
-
^([+-]?[0-9]+(?:\\.[0-9]+)?)\\s*(|[KMGTPEZYRQ])(i?)B$
|
|
35
|
-
"""
|
|
36
|
-
if isinstance(value, int):
|
|
37
|
-
self.bytes = value
|
|
38
|
-
elif isinstance(value, str):
|
|
39
|
-
match = FILE_SIZE_PATTERN.match(value)
|
|
40
|
-
if not match:
|
|
41
|
-
raise ValueError(f"Input does not match the file-size pattern: {value}")
|
|
42
|
-
val, prefix, binary = match.groups()
|
|
43
|
-
power = POWERS.index(prefix) + 1
|
|
44
|
-
assert power is not None
|
|
45
|
-
self.bytes = int(float(val) * (1024 if binary else 1000)**power)
|
|
46
|
-
else:
|
|
47
|
-
raise TypeError(f"Input to FileSize() can be int or str, not {type(value)}")
|
|
48
|
-
|
|
49
|
-
def humanize(
|
|
50
|
-
self,
|
|
51
|
-
binary: bool = False,
|
|
52
|
-
gnu: bool = False,
|
|
53
|
-
fmt: str = "%.2f",
|
|
54
|
-
) -> str:
|
|
55
|
-
"""Returns the size in a human readable way."""
|
|
56
|
-
base = 1024 if (gnu or binary) else 1000
|
|
57
|
-
abs_bytes = abs(self.bytes)
|
|
58
|
-
|
|
59
|
-
if abs_bytes == 1 and not gnu:
|
|
60
|
-
return f"{self.bytes} Byte"
|
|
61
|
-
|
|
62
|
-
if abs_bytes < base:
|
|
63
|
-
return f"{self.bytes}B" if gnu else f"{self.bytes} Bytes"
|
|
64
|
-
|
|
65
|
-
power = int(min(_log(abs_bytes, base), len(POWERS)))
|
|
66
|
-
result: str = fmt % (self.bytes / (base**power))
|
|
67
|
-
if gnu:
|
|
68
|
-
return result + POWERS[power - 1]
|
|
69
|
-
result += " " + POWERS[power - 1]
|
|
70
|
-
if binary:
|
|
71
|
-
result += "i"
|
|
72
|
-
result += "B"
|
|
73
|
-
return result
|
|
74
|
-
|
|
75
|
-
@property
|
|
76
|
-
def KB(self) -> float:
|
|
77
|
-
return self.bytes / 1000
|
|
78
|
-
|
|
79
|
-
@property
|
|
80
|
-
def MB(self) -> float:
|
|
81
|
-
return self.bytes / 1000_000
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def GB(self) -> float:
|
|
85
|
-
return self.bytes / 1000_000_000
|
|
86
|
-
|
|
87
|
-
@property
|
|
88
|
-
def KiB(self) -> float:
|
|
89
|
-
return self.bytes / 1024
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def MiB(self) -> float:
|
|
93
|
-
return self.bytes / 1048576
|
|
94
|
-
|
|
95
|
-
@property
|
|
96
|
-
def GiB(self) -> float:
|
|
97
|
-
return self.bytes / 1073741824
|
|
98
|
-
|
|
99
|
-
def __int__(self) -> int:
|
|
100
|
-
return self.bytes
|
|
101
|
-
|
|
102
|
-
def __eq__(self, value: object) -> bool:
|
|
103
|
-
if not isinstance(value, _SupportsInt):
|
|
104
|
-
return False
|
|
105
|
-
return self.bytes == int(value)
|
|
106
|
-
|
|
107
|
-
def __lt__(self, value: _SupportsInt) -> bool:
|
|
108
|
-
return self.bytes < int(value)
|
|
109
|
-
|
|
110
|
-
def __le__(self, value: _SupportsInt) -> bool:
|
|
111
|
-
return self.bytes <= int(value)
|
|
112
|
-
|
|
113
|
-
def __gt__(self, value: _SupportsInt) -> bool:
|
|
114
|
-
return int(value) < self.bytes
|
|
115
|
-
|
|
116
|
-
def __ge__(self, value: _SupportsInt) -> bool:
|
|
117
|
-
return int(value) <= self.bytes
|
|
118
|
-
|
|
119
|
-
def __add__(self, value: _SupportsInt) -> "FileSize":
|
|
120
|
-
return FileSize(self.bytes + int(value))
|
|
121
|
-
|
|
122
|
-
def __str__(self) -> str:
|
|
123
|
-
return self.humanize(binary=True)
|
|
124
|
-
|
|
125
|
-
def __repr__(self) -> str:
|
|
126
|
-
return f"<{self.__class__.__name__}: '{self!s}'>"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|