log21 3.5.0__tar.gz → 3.7.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.7.0}/PKG-INFO +1 -1
- {log21-3.5.0 → log21-3.7.0}/pyproject.toml +1 -1
- {log21-3.5.0 → log21-3.7.0}/src/log21/__init__.py +1 -1
- {log21-3.5.0 → log21-3.7.0}/src/log21/argumentify.py +416 -25
- log21-3.7.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.7.0}/README.md +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/_argparse.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/_module_helper.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/argparse.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/colors.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/crash_reporter/__init__.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/crash_reporter/formatters.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/crash_reporter/reporters.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/file_handler.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/formatters.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/levels.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/logger.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/logging_window.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/manager.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/pprint.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/progress_bar.py +0 -0
- {log21-3.5.0 → log21-3.7.0}/src/log21/stream_handler.py +0 -0
- {log21-3.5.0 → log21-3.7.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.7.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]]:
|
|
@@ -552,11 +720,114 @@ def _apply_repeatable_defaults(cli_args: _Any, info: FunctionInfo) -> None:
|
|
|
552
720
|
setattr(cli_args, argument.name, argument.default)
|
|
553
721
|
|
|
554
722
|
|
|
723
|
+
def _normalize_common(common: _Any) -> _Dict[str, Argument]:
|
|
724
|
+
"""Normalize the `common` mapping into synthetic flag arguments.
|
|
725
|
+
|
|
726
|
+
Each entry is either a plain default (`{"force": False}`, type inferred
|
|
727
|
+
as `bool`/`str`/`int`/`float`, `None` for untyped) or a spec mapping with
|
|
728
|
+
`default`, `annotation` (`type` accepted as an alias), and `help` keys.
|
|
729
|
+
Every entry becomes an optional `--flag` on leaves that don't declare the
|
|
730
|
+
name themselves.
|
|
731
|
+
|
|
732
|
+
:param common: The raw `common` value.
|
|
733
|
+
:raises TypeError: If `common` is not a mapping.
|
|
734
|
+
:raises ValueError: If a name or spec is malformed, or a spec is
|
|
735
|
+
`Secret`-typed (declare `Secret[...]` in the entry-point signatures
|
|
736
|
+
that need it; `env_prefix` already applies globally).
|
|
737
|
+
:return: `{name: Argument}` shared flag definitions.
|
|
738
|
+
"""
|
|
739
|
+
if common is None:
|
|
740
|
+
return {}
|
|
741
|
+
if not isinstance(common, _Dict):
|
|
742
|
+
raise TypeError(
|
|
743
|
+
'common must be a mapping of parameter name to default or spec '
|
|
744
|
+
f'mapping, not {type(common).__name__}.'
|
|
745
|
+
)
|
|
746
|
+
normalized: _Dict[str, Argument] = {}
|
|
747
|
+
for name, spec in common.items():
|
|
748
|
+
if not isinstance(name, str) or not name.isidentifier():
|
|
749
|
+
raise ValueError(
|
|
750
|
+
'Common parameter names must be valid identifiers, '
|
|
751
|
+
f'got: {name!r}'
|
|
752
|
+
)
|
|
753
|
+
if isinstance(spec, _Dict):
|
|
754
|
+
unknown = set(spec) - {'default', 'annotation', 'type', 'help'}
|
|
755
|
+
if unknown:
|
|
756
|
+
raise ValueError(
|
|
757
|
+
f'Unknown keys for common parameter {name!r}: '
|
|
758
|
+
f'{sorted(unknown)!r} (allowed: default, annotation, help).'
|
|
759
|
+
)
|
|
760
|
+
annotation = spec.get('annotation', spec.get('type'))
|
|
761
|
+
if 'annotation' in spec and 'type' in spec and spec['annotation'] is not spec['type']:
|
|
762
|
+
raise ValueError(
|
|
763
|
+
f"Common parameter {name!r} sets both 'annotation' and "
|
|
764
|
+
"'type' to different values."
|
|
765
|
+
)
|
|
766
|
+
default = spec.get('default')
|
|
767
|
+
has_default = 'default' in spec
|
|
768
|
+
help_text = spec.get('help')
|
|
769
|
+
else:
|
|
770
|
+
if isinstance(spec, list):
|
|
771
|
+
raise ValueError(
|
|
772
|
+
f'Common parameter {name!r} needs a dict spec with an '
|
|
773
|
+
"'annotation' for list values, e.g. "
|
|
774
|
+
f'{{"{name}": {{"annotation": list[str], "default": []}}}}.'
|
|
775
|
+
)
|
|
776
|
+
if spec is not None and not isinstance(spec, (bool, str, int, float)):
|
|
777
|
+
raise ValueError(
|
|
778
|
+
f'Unsupported default for common parameter {name!r}: '
|
|
779
|
+
f'{type(spec).__name__} (use a bool/str/int/float/None '
|
|
780
|
+
'default or a dict spec).'
|
|
781
|
+
)
|
|
782
|
+
annotation = None if spec is None else type(spec)
|
|
783
|
+
default, has_default, help_text = spec, True, None
|
|
784
|
+
is_secret, _, _, _, _ = _secret_params(annotation)
|
|
785
|
+
if not is_secret:
|
|
786
|
+
_, element = _list_element_type(annotation)
|
|
787
|
+
is_secret = _secret_params(element)[0] if element is not None else False
|
|
788
|
+
if is_secret:
|
|
789
|
+
raise ValueError(
|
|
790
|
+
f'Common parameter {name!r} must not be Secret-typed: declare '
|
|
791
|
+
'Secret[...] in the entry-point signatures that need it '
|
|
792
|
+
'(they take precedence, and env_prefix applies globally).'
|
|
793
|
+
)
|
|
794
|
+
normalized[name] = Argument(
|
|
795
|
+
name=name,
|
|
796
|
+
kind=_inspect._ParameterKind.POSITIONAL_OR_KEYWORD,
|
|
797
|
+
annotation=annotation,
|
|
798
|
+
default=default,
|
|
799
|
+
help=help_text,
|
|
800
|
+
has_default=has_default,
|
|
801
|
+
)
|
|
802
|
+
return normalized
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _validate_common_names(
|
|
806
|
+
common: _Dict[str, Argument], leaf_infos: _Dict[str, FunctionInfo]
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Reject common parameters declared by no leaf command at all.
|
|
809
|
+
|
|
810
|
+
Without this, a typo'd (or not-yet-adopted) name would silently do
|
|
811
|
+
nothing everywhere.
|
|
812
|
+
|
|
813
|
+
:param common: Normalized common flag definitions.
|
|
814
|
+
:param leaf_infos: `{dotted_path: FunctionInfo}` for every leaf.
|
|
815
|
+
:raises ValueError: If a name matches no command.
|
|
816
|
+
"""
|
|
817
|
+
for name in common:
|
|
818
|
+
if not any(name in info.arguments for info in leaf_infos.values()):
|
|
819
|
+
raise ValueError(
|
|
820
|
+
f'Common parameter {name!r} matches no command: no entry '
|
|
821
|
+
'point declares it.'
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
555
825
|
def _add_arguments(
|
|
556
826
|
parser: _Union[_argparse.ColorizingArgumentParser, _argparse._ArgumentGroup],
|
|
557
827
|
info: FunctionInfo,
|
|
558
828
|
reserved_flags: _Optional[_Set[str]] = None,
|
|
559
|
-
exclusive_groups: _Optional[_List[_Tuple[_List[str], bool]]] = None
|
|
829
|
+
exclusive_groups: _Optional[_List[_Tuple[_List[str], bool]]] = None,
|
|
830
|
+
common: _Optional[_Dict[str, Argument]] = None
|
|
560
831
|
) -> None:
|
|
561
832
|
"""Add the arguments to the parser.
|
|
562
833
|
|
|
@@ -571,6 +842,14 @@ def _add_arguments(
|
|
|
571
842
|
:param reserved_flags: The reserved flags.
|
|
572
843
|
:param exclusive_groups: Normalized `(names, required)` groups applying
|
|
573
844
|
to this parser.
|
|
845
|
+
:param common: Normalized shared flag definitions. Entries whose name the
|
|
846
|
+
function declares itself are skipped (the local declaration wins);
|
|
847
|
+
the rest are added first, in mapping order, so shared flags claim
|
|
848
|
+
stable short forms across commands and per-command parameters fall
|
|
849
|
+
back deterministically. Common flags are always optional and never
|
|
850
|
+
join mutually exclusive groups; values for undeclared names are
|
|
851
|
+
accepted but not delivered (functions only receive declared keyword
|
|
852
|
+
arguments).
|
|
574
853
|
"""
|
|
575
854
|
if reserved_flags is None:
|
|
576
855
|
reserved_flags = RESERVED_FLAGS.copy()
|
|
@@ -596,24 +875,55 @@ def _add_arguments(
|
|
|
596
875
|
unsupported_arg=argument.name
|
|
597
876
|
)
|
|
598
877
|
|
|
599
|
-
# Add the arguments
|
|
878
|
+
# Add the arguments. Shared flags go first, in mapping order, so they
|
|
879
|
+
# claim stable short forms across commands; explicitly declared
|
|
880
|
+
# parameters with the same names keep precedence and follow in mapping
|
|
881
|
+
# order too. Positional parameters always stay in signature order
|
|
882
|
+
# (`argparse` positionals are order-sensitive). The rest follows in
|
|
883
|
+
# signature order. Without `common`, this is exactly signature order.
|
|
884
|
+
common = common or {}
|
|
885
|
+
declared = [
|
|
886
|
+
info.arguments[name] for name in common if name in info.arguments
|
|
887
|
+
and info.arguments[name].kind
|
|
888
|
+
not in (_inspect._ParameterKind.POSITIONAL_ONLY,
|
|
889
|
+
_inspect._ParameterKind.VAR_POSITIONAL)
|
|
890
|
+
]
|
|
891
|
+
missing = [spec for name, spec in common.items() if name not in info.arguments]
|
|
892
|
+
common_names = {spec.name for spec in missing}
|
|
893
|
+
rest = [
|
|
894
|
+
argument for argument in info.arguments.values()
|
|
895
|
+
if argument not in declared
|
|
896
|
+
]
|
|
600
897
|
containers: _Dict[int, _Any] = {}
|
|
601
898
|
for index, (_, required) in enumerate(exclusive_groups or []):
|
|
602
899
|
containers[index] = parser.add_mutually_exclusive_group(required=required)
|
|
603
|
-
for argument in
|
|
900
|
+
for argument in declared + missing + rest:
|
|
604
901
|
config: _Dict[str, _Any] = {
|
|
605
902
|
'action': 'store',
|
|
606
903
|
'dest': argument.name,
|
|
607
904
|
'help': argument.help
|
|
608
905
|
}
|
|
609
906
|
flags = generate_flag(argument, reserved_flags=reserved_flags)
|
|
907
|
+
is_secret, secret_type, _, _, _ = _secret_params(argument.annotation)
|
|
610
908
|
if argument.annotation is bool:
|
|
611
909
|
config['action'] = 'store_true'
|
|
910
|
+
elif is_secret and not _is_repeatable_flag(argument):
|
|
911
|
+
# Direct secret: converted with redacted errors. Registered with
|
|
912
|
+
# an internal `None` default; flag value, environment, prompt and
|
|
913
|
+
# declared default are resolved by `_resolve_secrets` after
|
|
914
|
+
# parsing, so secrets are never argparse-required (a required
|
|
915
|
+
# flag would pre-empt the environment/prompt fallbacks).
|
|
916
|
+
config['type'] = _secret_converter(secret_type)
|
|
917
|
+
config['default'] = None
|
|
612
918
|
elif _is_repeatable_flag(argument):
|
|
613
919
|
_, element = _list_element_type(argument.annotation)
|
|
614
920
|
config['action'] = 'append'
|
|
615
921
|
if element is not None:
|
|
616
|
-
|
|
922
|
+
element_secret, element_type, _, _, _ = _secret_params(element)
|
|
923
|
+
if element_secret:
|
|
924
|
+
config['type'] = _secret_converter(element_type)
|
|
925
|
+
else:
|
|
926
|
+
config['type'] = element
|
|
617
927
|
# Registered with an internal `None` default so `argparse` never
|
|
618
928
|
# mutates the signature's default list; the declared default is
|
|
619
929
|
# restored by `_apply_repeatable_defaults` after parsing.
|
|
@@ -622,11 +932,25 @@ def _add_arguments(
|
|
|
622
932
|
config['type'] = argument.annotation
|
|
623
933
|
if argument.kind == _inspect._ParameterKind.POSITIONAL_ONLY:
|
|
624
934
|
flags = [config.pop('dest')]
|
|
625
|
-
is_list,
|
|
626
|
-
if is_list
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
935
|
+
is_list, element = _list_element_type(argument.annotation)
|
|
936
|
+
if is_list:
|
|
937
|
+
element_secret, element_type, _, _, _ = _secret_params(element)
|
|
938
|
+
if element_secret:
|
|
939
|
+
# `list[Secret[T]]` positionals: convert each item with
|
|
940
|
+
# redacted errors. The converter replaces the annotation,
|
|
941
|
+
# so nargs is set here instead of `_validate_func_type`.
|
|
942
|
+
config['type'] = _secret_converter(element_type)
|
|
943
|
+
if config.get('nargs') is None:
|
|
944
|
+
config['nargs'] = '*' if argument.default is not None else '+'
|
|
945
|
+
elif config.get('nargs') is None and argument.default is not None:
|
|
946
|
+
# A declared default (e.g. `= []`) makes the multi-value
|
|
947
|
+
# positional optional instead of requiring 1+ values.
|
|
948
|
+
config['nargs'] = '*'
|
|
949
|
+
if is_secret:
|
|
950
|
+
# Secret positionals accept zero values so the
|
|
951
|
+
# environment/prompt chain in `_resolve_secrets` can fire;
|
|
952
|
+
# missing required secrets become parser errors there.
|
|
953
|
+
config['nargs'] = '?'
|
|
630
954
|
if any(argument.name in names for names, _ in exclusive_groups or []):
|
|
631
955
|
# `argparse` only accepts optional actions in mutually
|
|
632
956
|
# exclusive groups: single-value positionals become `nargs='?'`
|
|
@@ -639,9 +963,15 @@ def _add_arguments(
|
|
|
639
963
|
if argument.kind == _inspect._ParameterKind.VAR_POSITIONAL:
|
|
640
964
|
config['nargs'] = '*'
|
|
641
965
|
flags = [config.pop('dest')]
|
|
642
|
-
|
|
966
|
+
if is_secret:
|
|
967
|
+
config['type'] = _secret_converter(secret_type)
|
|
968
|
+
if (argument.kind == _inspect._ParameterKind.POSITIONAL_OR_KEYWORD
|
|
969
|
+
and keyword_only_exists and not is_secret
|
|
970
|
+
and argument.name not in common_names):
|
|
971
|
+
# Shared flags are always optional (non-breaking adoption).
|
|
643
972
|
config['required'] = True
|
|
644
|
-
if argument.default is not None and config.get('action') != 'append'
|
|
973
|
+
if (argument.default is not None and config.get('action') != 'append'
|
|
974
|
+
and not is_secret):
|
|
645
975
|
config['default'] = argument.default
|
|
646
976
|
target = parser
|
|
647
977
|
for index, (names, _) in enumerate(exclusive_groups or []):
|
|
@@ -652,7 +982,11 @@ def _add_arguments(
|
|
|
652
982
|
|
|
653
983
|
|
|
654
984
|
def _argumentify_one(
|
|
655
|
-
func: Callable,
|
|
985
|
+
func: Callable,
|
|
986
|
+
mutually_exclusive: _Any = None,
|
|
987
|
+
env_prefix: _Optional[str] = None,
|
|
988
|
+
secret_prompt: bool = True,
|
|
989
|
+
common: _Optional[_Dict[str, _Any]] = None
|
|
656
990
|
) -> None:
|
|
657
991
|
"""This function argumentifies one function as the entry point of the script.
|
|
658
992
|
|
|
@@ -660,6 +994,10 @@ def _argumentify_one(
|
|
|
660
994
|
:param mutually_exclusive: A list of mutually exclusive groups. Each
|
|
661
995
|
group is a list of parameter names or a
|
|
662
996
|
`{"names": [...], "required": bool}` mapping.
|
|
997
|
+
:param env_prefix: App-wide environment prefix for `Secret` parameters.
|
|
998
|
+
:param secret_prompt: Whether missing secrets may prompt securely.
|
|
999
|
+
:param common: Shared `{name: default-or-spec}` flags added unless the
|
|
1000
|
+
function declares the name itself.
|
|
663
1001
|
"""
|
|
664
1002
|
info = FunctionInfo(func)
|
|
665
1003
|
|
|
@@ -669,9 +1007,12 @@ def _argumentify_one(
|
|
|
669
1007
|
groups = _resolve_command_groups(
|
|
670
1008
|
mutually_exclusive, None, set(info.arguments)
|
|
671
1009
|
)
|
|
672
|
-
|
|
1010
|
+
shared = _normalize_common(common)
|
|
1011
|
+
_validate_common_names(shared, {'': info})
|
|
1012
|
+
_add_arguments(parser, info, exclusive_groups=groups, common=shared)
|
|
673
1013
|
cli_args = parser.parse_args()
|
|
674
1014
|
_apply_repeatable_defaults(cli_args, info)
|
|
1015
|
+
_resolve_secrets(cli_args, info, parser, env_prefix, secret_prompt)
|
|
675
1016
|
args = []
|
|
676
1017
|
kwargs = {}
|
|
677
1018
|
for argument in info.arguments.values():
|
|
@@ -852,13 +1193,15 @@ def _add_command_level(
|
|
|
852
1193
|
path: _Tuple[str, ...],
|
|
853
1194
|
leaf_infos: _Dict[str, FunctionInfo],
|
|
854
1195
|
mutually_exclusive: _Any = None,
|
|
855
|
-
leaf_paths: _Optional[_List[str]] = None
|
|
1196
|
+
leaf_paths: _Optional[_List[str]] = None,
|
|
1197
|
+
common: _Optional[_Dict[str, Argument]] = None
|
|
856
1198
|
) -> None:
|
|
857
1199
|
"""Add one command-tree level to a subparsers action (recursively).
|
|
858
1200
|
|
|
859
1201
|
Leaf commands get annotation-driven arguments (plus any applicable
|
|
860
|
-
mutually exclusive groups); group nodes get a
|
|
861
|
-
level of their own, so `prog group --help` lists
|
|
1202
|
+
mutually exclusive groups and shared `common` flags); group nodes get a
|
|
1203
|
+
nested `add_subparsers` level of their own, so `prog group --help` lists
|
|
1204
|
+
the group's commands.
|
|
862
1205
|
"""
|
|
863
1206
|
for name, child in node.items():
|
|
864
1207
|
if callable(child):
|
|
@@ -869,19 +1212,23 @@ def _add_command_level(
|
|
|
869
1212
|
mutually_exclusive, dotted, name, set(info.arguments),
|
|
870
1213
|
leaf_paths or [dotted]
|
|
871
1214
|
)
|
|
872
|
-
_add_arguments(subparser, info, exclusive_groups=groups)
|
|
1215
|
+
_add_arguments(subparser, info, exclusive_groups=groups, common=common)
|
|
873
1216
|
subparser.set_defaults(func=info.function)
|
|
874
1217
|
else:
|
|
875
1218
|
group_parser = subparsers.add_parser(name)
|
|
876
1219
|
nested = group_parser.add_subparsers(required=True)
|
|
877
1220
|
_add_command_level(
|
|
878
1221
|
nested, child, (*path, name), leaf_infos, mutually_exclusive,
|
|
879
|
-
leaf_paths
|
|
1222
|
+
leaf_paths, common
|
|
880
1223
|
)
|
|
881
1224
|
|
|
882
1225
|
|
|
883
1226
|
def _argumentify(
|
|
884
|
-
functions: _Dict[str, _Any],
|
|
1227
|
+
functions: _Dict[str, _Any],
|
|
1228
|
+
mutually_exclusive: _Any = None,
|
|
1229
|
+
env_prefix: _Optional[str] = None,
|
|
1230
|
+
secret_prompt: bool = True,
|
|
1231
|
+
common: _Optional[_Dict[str, _Any]] = None
|
|
885
1232
|
) -> None:
|
|
886
1233
|
"""This function argumentifies one or more functions as the entry point of the
|
|
887
1234
|
script.
|
|
@@ -895,6 +1242,12 @@ def _argumentify(
|
|
|
895
1242
|
`{command_name: groups}` mapping for targeted groups. Each group is a
|
|
896
1243
|
list of parameter names or a `{"names": [...], "required": bool}`
|
|
897
1244
|
mapping.
|
|
1245
|
+
:param env_prefix: App-wide environment prefix for `Secret` parameters,
|
|
1246
|
+
e.g. `"PDF_HELPER"` derives `PDF_HELPER_PASSWORD` from `--password`.
|
|
1247
|
+
:param secret_prompt: Whether missing secrets may fall back to a secure
|
|
1248
|
+
prompt (per-parameter `Secret[..., prompt]` overrides this).
|
|
1249
|
+
:param common: Shared `{name: default-or-spec}` flags added to every leaf
|
|
1250
|
+
that doesn't declare the name itself.
|
|
898
1251
|
:raises RuntimeError:
|
|
899
1252
|
"""
|
|
900
1253
|
tree = _normalize_command_node(functions)
|
|
@@ -922,8 +1275,11 @@ def _argumentify(
|
|
|
922
1275
|
leaf_paths = [dotted for dotted, _, _ in leaves]
|
|
923
1276
|
_validate_mutex_keys(mutually_exclusive, leaf_paths)
|
|
924
1277
|
_validate_broadcast_groups(mutually_exclusive, leaf_infos)
|
|
1278
|
+
shared = _normalize_common(common)
|
|
1279
|
+
_validate_common_names(shared, leaf_infos)
|
|
925
1280
|
_add_command_level(
|
|
926
|
-
subparsers, tree, (), leaf_infos, mutually_exclusive, leaf_paths
|
|
1281
|
+
subparsers, tree, (), leaf_infos, mutually_exclusive, leaf_paths,
|
|
1282
|
+
shared
|
|
927
1283
|
)
|
|
928
1284
|
cli_args = parser.parse_args()
|
|
929
1285
|
args = []
|
|
@@ -935,6 +1291,7 @@ def _argumentify(
|
|
|
935
1291
|
else:
|
|
936
1292
|
raise RuntimeError('No function found for the given arguments.')
|
|
937
1293
|
_apply_repeatable_defaults(cli_args, info)
|
|
1294
|
+
_resolve_secrets(cli_args, info, parser, env_prefix, secret_prompt)
|
|
938
1295
|
for argument in info.arguments.values():
|
|
939
1296
|
if argument.kind in (_inspect._ParameterKind.POSITIONAL_ONLY,
|
|
940
1297
|
_inspect._ParameterKind.POSITIONAL_OR_KEYWORD):
|
|
@@ -954,7 +1311,10 @@ def _argumentify(
|
|
|
954
1311
|
|
|
955
1312
|
def argumentify(
|
|
956
1313
|
entry_point: _Union[Callable, _List[Callable], _Dict[str, _Any]],
|
|
957
|
-
mutually_exclusive: _Optional[_Any] = None
|
|
1314
|
+
mutually_exclusive: _Optional[_Any] = None,
|
|
1315
|
+
env_prefix: _Optional[str] = None,
|
|
1316
|
+
secret_prompt: bool = True,
|
|
1317
|
+
common: _Optional[_Dict[str, _Any]] = None
|
|
958
1318
|
) -> _Union[Callable, _List[Callable], _Dict[str, _Any]]:
|
|
959
1319
|
"""This function argumentifies one or more functions as the entry point of the
|
|
960
1320
|
script.
|
|
@@ -1030,17 +1390,48 @@ def argumentify(
|
|
|
1030
1390
|
chosen. A list of functions may be used wherever a mapping is expected;
|
|
1031
1391
|
empty groups raise `ValueError`.
|
|
1032
1392
|
|
|
1393
|
+
Parameters annotated with `Secret[T]` (`log21.helper_types.Secret`) are
|
|
1394
|
+
secrets: values resolve from the flag, then an environment variable, then
|
|
1395
|
+
a secure (hidden-input) prompt, then the declared default. The variable
|
|
1396
|
+
is a per-parameter override (`Secret[str, "PDF_HELPER_PASSWORD"]`) or
|
|
1397
|
+
derived from `env_prefix` (`argumentify(..., env_prefix="PDF_HELPER")`
|
|
1398
|
+
derives `PDF_HELPER_PASSWORD` from `--password`). Prompting happens for
|
|
1399
|
+
missing secrets unless disabled globally (`secret_prompt=False`) or
|
|
1400
|
+
per-parameter (`Secret[str, "ENV", False]`); required secrets missing
|
|
1401
|
+
after the whole chain are errors. Secrets never appear in log21 errors:
|
|
1402
|
+
|
|
1403
|
+
from log21.helper_types import Secret
|
|
1404
|
+
|
|
1405
|
+
def encrypt(in_path, out_path, /, password: Secret[str] | None = None): ...
|
|
1406
|
+
argumentify(encrypt, env_prefix="PDF_HELPER")
|
|
1407
|
+
|
|
1408
|
+
Flags shared by every command can be declared once with `common`
|
|
1409
|
+
(`{name: default}` or `{name: {"default": ..., "annotation": ...,
|
|
1410
|
+
"help": ...}}`). Each flag is added to every leaf that doesn't declare
|
|
1411
|
+
the name itself (an explicit local declaration always wins), claims a
|
|
1412
|
+
stable short form across commands, and is always optional. Values reach
|
|
1413
|
+
only functions declaring a matching parameter; the rest accept the flag
|
|
1414
|
+
but ignore it, so adopting `common` never breaks a command:
|
|
1415
|
+
|
|
1416
|
+
argumentify(tree, common={"force": False, "verbose": False})
|
|
1417
|
+
|
|
1033
1418
|
:param entry_point: The function(s) to argumentify.
|
|
1034
1419
|
:param mutually_exclusive: Mutually exclusive parameter groups.
|
|
1420
|
+
:param env_prefix: App-wide environment prefix for `Secret` parameters.
|
|
1421
|
+
:param secret_prompt: Whether missing secrets may prompt securely.
|
|
1422
|
+
:param common: Shared `{name: default-or-spec}` flags.
|
|
1035
1423
|
:raises TypeError: A function must be a function or a list of functions or a
|
|
1036
|
-
dictionary of functions.
|
|
1037
|
-
:raises ValueError: A group is malformed or references unknown parameters
|
|
1424
|
+
dictionary of functions, or `common` is not a mapping.
|
|
1425
|
+
:raises ValueError: A group is malformed or references unknown parameters,
|
|
1426
|
+
or a `common` name/spec is malformed or matches no command.
|
|
1038
1427
|
"""
|
|
1039
1428
|
|
|
1040
1429
|
functions = {}
|
|
1041
1430
|
# Check the types
|
|
1042
1431
|
if callable(entry_point):
|
|
1043
|
-
_argumentify_one(
|
|
1432
|
+
_argumentify_one(
|
|
1433
|
+
entry_point, mutually_exclusive, env_prefix, secret_prompt, common
|
|
1434
|
+
)
|
|
1044
1435
|
return entry_point
|
|
1045
1436
|
if isinstance(entry_point, _List):
|
|
1046
1437
|
for func in entry_point:
|
|
@@ -1058,5 +1449,5 @@ def argumentify(
|
|
|
1058
1449
|
"dictionary of functions."
|
|
1059
1450
|
)
|
|
1060
1451
|
|
|
1061
|
-
_argumentify(functions, mutually_exclusive)
|
|
1452
|
+
_argumentify(functions, mutually_exclusive, env_prefix, secret_prompt, common)
|
|
1062
1453
|
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
|