log21 3.4.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.4.0 → log21-3.6.0}/PKG-INFO +1 -2
- {log21-3.4.0 → log21-3.6.0}/README.md +0 -1
- {log21-3.4.0 → log21-3.6.0}/pyproject.toml +1 -1
- {log21-3.4.0 → log21-3.6.0}/src/log21/__init__.py +1 -1
- {log21-3.4.0 → log21-3.6.0}/src/log21/argumentify.py +496 -38
- log21-3.6.0/src/log21/helper_types.py +252 -0
- log21-3.4.0/src/log21/helper_types.py +0 -126
- {log21-3.4.0 → log21-3.6.0}/src/log21/_argparse.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/_module_helper.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/argparse.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/colors.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/crash_reporter/__init__.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/crash_reporter/formatters.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/crash_reporter/reporters.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/file_handler.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/formatters.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/levels.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/logger.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/logging_window.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/manager.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/pprint.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/progress_bar.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/stream_handler.py +0 -0
- {log21-3.4.0 → log21-3.6.0}/src/log21/tree_print.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: log21
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.6.0
|
|
4
4
|
Summary: A simple logging package
|
|
5
5
|
Keywords: python,log,colorize,color,logging,Python3,CodeWriter21
|
|
6
6
|
Author: CodeWriter21(Mehrad Pooryoussof)
|
|
@@ -29,7 +29,6 @@ log21
|
|
|
29
29
|
|
|
30
30
|

|
|
31
31
|
[](https://gitlab.com/CodeWriter21/log21/-/commits/master)
|
|
32
|
-
[](https://gitlab.com/CodeWriter21/log21)
|
|
33
32
|
|
|
34
33
|
A simple logging package that helps you log colorized messages in Windows console and
|
|
35
34
|
other operating systems.
|
|
@@ -3,7 +3,6 @@ log21
|
|
|
3
3
|
|
|
4
4
|

|
|
5
5
|
[](https://gitlab.com/CodeWriter21/log21/-/commits/master)
|
|
6
|
-
[](https://gitlab.com/CodeWriter21/log21)
|
|
7
6
|
|
|
8
7
|
A simple logging package that helps you log colorized messages in Windows console and
|
|
9
8
|
other operating systems.
|
|
@@ -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]]:
|
|
@@ -450,18 +618,21 @@ def _normalize_exclusive_groups(
|
|
|
450
618
|
|
|
451
619
|
def _resolve_command_groups(
|
|
452
620
|
mutually_exclusive: _Any, command: _Optional[str],
|
|
453
|
-
argument_names: _Set[str]
|
|
621
|
+
argument_names: _Set[str], strict: bool = True
|
|
454
622
|
) -> _List[_Tuple[_List[str], bool]]:
|
|
455
623
|
"""Resolve which exclusive groups apply to a single command parser.
|
|
456
624
|
|
|
457
625
|
`mutually_exclusive` may be a plain list of groups (broadcast to every
|
|
458
626
|
command that defines all named parameters) or a `{command_name: groups}`
|
|
459
627
|
mapping for targeted groups. Unknown parameter names and groups that
|
|
460
|
-
match no command raise `ValueError
|
|
628
|
+
match no command raise `ValueError` (unless `strict` is disabled, in
|
|
629
|
+
which case non-matching groups are silently skipped for this command —
|
|
630
|
+
used for nested trees where a global check runs instead).
|
|
461
631
|
|
|
462
632
|
:param mutually_exclusive: The raw `mutually_exclusive` value.
|
|
463
633
|
:param command: The command name, or `None` for a single-function parser.
|
|
464
634
|
:param argument_names: The parameter names of the command.
|
|
635
|
+
:param strict: Raise when a broadcast list matches this command nowhere.
|
|
465
636
|
:raises ValueError: If a group references unknown parameters.
|
|
466
637
|
:return: The groups applying to this command.
|
|
467
638
|
"""
|
|
@@ -486,12 +657,22 @@ def _resolve_command_groups(
|
|
|
486
657
|
)
|
|
487
658
|
_check_group_overlap(groups, command)
|
|
488
659
|
return groups
|
|
660
|
+
return _resolve_broadcast_groups(
|
|
661
|
+
mutually_exclusive, command, argument_names, strict=strict
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _resolve_broadcast_groups(
|
|
666
|
+
mutually_exclusive: _Any, command: _Optional[str],
|
|
667
|
+
argument_names: _Set[str], strict: bool = True
|
|
668
|
+
) -> _List[_Tuple[_List[str], bool]]:
|
|
669
|
+
"""Resolve broadcast groups against one command's parameters."""
|
|
489
670
|
groups = _normalize_exclusive_groups(mutually_exclusive)
|
|
490
671
|
applicable = [
|
|
491
672
|
group for group in groups
|
|
492
673
|
if all(name in argument_names for name in group[0])
|
|
493
674
|
]
|
|
494
|
-
if groups and not applicable:
|
|
675
|
+
if strict and groups and not applicable:
|
|
495
676
|
# A broadcast list that matches nowhere is almost certainly a typo:
|
|
496
677
|
# fail fast instead of silently ignoring it.
|
|
497
678
|
missing = [
|
|
@@ -594,13 +775,26 @@ def _add_arguments(
|
|
|
594
775
|
'help': argument.help
|
|
595
776
|
}
|
|
596
777
|
flags = generate_flag(argument, reserved_flags=reserved_flags)
|
|
778
|
+
is_secret, secret_type, _, _, _ = _secret_params(argument.annotation)
|
|
597
779
|
if argument.annotation is bool:
|
|
598
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
|
|
599
789
|
elif _is_repeatable_flag(argument):
|
|
600
790
|
_, element = _list_element_type(argument.annotation)
|
|
601
791
|
config['action'] = 'append'
|
|
602
792
|
if element is not None:
|
|
603
|
-
|
|
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
|
|
604
798
|
# Registered with an internal `None` default so `argparse` never
|
|
605
799
|
# mutates the signature's default list; the declared default is
|
|
606
800
|
# restored by `_apply_repeatable_defaults` after parsing.
|
|
@@ -609,11 +803,25 @@ def _add_arguments(
|
|
|
609
803
|
config['type'] = argument.annotation
|
|
610
804
|
if argument.kind == _inspect._ParameterKind.POSITIONAL_ONLY:
|
|
611
805
|
flags = [config.pop('dest')]
|
|
612
|
-
is_list,
|
|
613
|
-
if is_list
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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'] = '?'
|
|
617
825
|
if any(argument.name in names for names, _ in exclusive_groups or []):
|
|
618
826
|
# `argparse` only accepts optional actions in mutually
|
|
619
827
|
# exclusive groups: single-value positionals become `nargs='?'`
|
|
@@ -626,9 +834,13 @@ def _add_arguments(
|
|
|
626
834
|
if argument.kind == _inspect._ParameterKind.VAR_POSITIONAL:
|
|
627
835
|
config['nargs'] = '*'
|
|
628
836
|
flags = [config.pop('dest')]
|
|
629
|
-
|
|
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):
|
|
630
841
|
config['required'] = True
|
|
631
|
-
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):
|
|
632
844
|
config['default'] = argument.default
|
|
633
845
|
target = parser
|
|
634
846
|
for index, (names, _) in enumerate(exclusive_groups or []):
|
|
@@ -639,7 +851,10 @@ def _add_arguments(
|
|
|
639
851
|
|
|
640
852
|
|
|
641
853
|
def _argumentify_one(
|
|
642
|
-
func: Callable,
|
|
854
|
+
func: Callable,
|
|
855
|
+
mutually_exclusive: _Any = None,
|
|
856
|
+
env_prefix: _Optional[str] = None,
|
|
857
|
+
secret_prompt: bool = True
|
|
643
858
|
) -> None:
|
|
644
859
|
"""This function argumentifies one function as the entry point of the script.
|
|
645
860
|
|
|
@@ -647,6 +862,8 @@ def _argumentify_one(
|
|
|
647
862
|
:param mutually_exclusive: A list of mutually exclusive groups. Each
|
|
648
863
|
group is a list of parameter names or a
|
|
649
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.
|
|
650
867
|
"""
|
|
651
868
|
info = FunctionInfo(func)
|
|
652
869
|
|
|
@@ -659,6 +876,7 @@ def _argumentify_one(
|
|
|
659
876
|
_add_arguments(parser, info, exclusive_groups=groups)
|
|
660
877
|
cli_args = parser.parse_args()
|
|
661
878
|
_apply_repeatable_defaults(cli_args, info)
|
|
879
|
+
_resolve_secrets(cli_args, info, parser, env_prefix, secret_prompt)
|
|
662
880
|
args = []
|
|
663
881
|
kwargs = {}
|
|
664
882
|
for argument in info.arguments.values():
|
|
@@ -678,52 +896,258 @@ def _argumentify_one(
|
|
|
678
896
|
parser.error(error.message)
|
|
679
897
|
|
|
680
898
|
|
|
899
|
+
def _normalize_command_node(node: _Any, path: _Tuple[str, ...] = ()) -> _Dict[str, _Any]:
|
|
900
|
+
"""Validate an entry-point node and normalize it to a command tree.
|
|
901
|
+
|
|
902
|
+
Leaves are callables, inner nodes are (possibly nested) mappings or lists
|
|
903
|
+
of callables. Returns `{name: callable-or-mapping}` with lists converted
|
|
904
|
+
to mappings keyed by function name.
|
|
905
|
+
|
|
906
|
+
:param node: The mapping, list or callable to normalize.
|
|
907
|
+
:param path: The dotted path of `node` within the tree (for errors).
|
|
908
|
+
:raises TypeError: If a value is neither a function nor a mapping/list.
|
|
909
|
+
:raises ValueError: If a group defines no subcommands.
|
|
910
|
+
:return: The normalized `{name: ...}` mapping.
|
|
911
|
+
"""
|
|
912
|
+
label = '.'.join(path) if path else '<root>'
|
|
913
|
+
if isinstance(node, _List):
|
|
914
|
+
normalized: _Dict[str, _Any] = {}
|
|
915
|
+
for func in node:
|
|
916
|
+
if not callable(func):
|
|
917
|
+
raise TypeError(
|
|
918
|
+
"argumentify: func must be a function or a list of functions or a "
|
|
919
|
+
"dictionary of functions."
|
|
920
|
+
)
|
|
921
|
+
normalized[func.__name__] = func
|
|
922
|
+
return normalized
|
|
923
|
+
if isinstance(node, _Dict):
|
|
924
|
+
normalized = {}
|
|
925
|
+
for name, child in node.items():
|
|
926
|
+
if callable(child):
|
|
927
|
+
normalized[name] = child
|
|
928
|
+
elif isinstance(child, (_Dict, _List)):
|
|
929
|
+
sub = _normalize_command_node(child, (*path, name))
|
|
930
|
+
if not sub:
|
|
931
|
+
raise ValueError(
|
|
932
|
+
f'Command group {".".join((*path, name))!r} defines '
|
|
933
|
+
'no subcommands.'
|
|
934
|
+
)
|
|
935
|
+
normalized[name] = sub
|
|
936
|
+
else:
|
|
937
|
+
raise TypeError(
|
|
938
|
+
"argumentify: func must be a function or a list of functions or a "
|
|
939
|
+
"dictionary of functions."
|
|
940
|
+
)
|
|
941
|
+
return normalized
|
|
942
|
+
raise TypeError(
|
|
943
|
+
"argumentify: func must be a function or a list of functions or a "
|
|
944
|
+
f"dictionary of functions (got {label!r})."
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _collect_leaves(
|
|
949
|
+
node: _Dict[str, _Any], path: _Tuple[str, ...] = ()
|
|
950
|
+
) -> _List[_Tuple[str, str, Callable]]:
|
|
951
|
+
"""Collect `(dotted_path, name, function)` leaves of a command tree."""
|
|
952
|
+
leaves: _List[_Tuple[str, str, Callable]] = []
|
|
953
|
+
for name, child in node.items():
|
|
954
|
+
if callable(child):
|
|
955
|
+
leaves.append(('.'.join((*path, name)), name, child))
|
|
956
|
+
else:
|
|
957
|
+
leaves.extend(_collect_leaves(child, (*path, name)))
|
|
958
|
+
return leaves
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _validate_mutex_keys(mutually_exclusive: _Any, leaf_paths: _List[str]) -> None:
|
|
962
|
+
"""Reject mutex mapping keys that match no leaf command.
|
|
963
|
+
|
|
964
|
+
Keys may be full dotted paths (`"metadata.edit"`) or plain leaf names
|
|
965
|
+
(`"edit"`).
|
|
966
|
+
|
|
967
|
+
:param mutually_exclusive: The raw `mutually_exclusive` value.
|
|
968
|
+
:param leaf_paths: Dotted paths of all leaf commands.
|
|
969
|
+
:raises ValueError: If a key matches nothing.
|
|
970
|
+
"""
|
|
971
|
+
if not isinstance(mutually_exclusive, _Dict):
|
|
972
|
+
return
|
|
973
|
+
leaf_names = {path.split('.')[-1] for path in leaf_paths}
|
|
974
|
+
for key in mutually_exclusive:
|
|
975
|
+
if key in leaf_paths or key in leaf_names:
|
|
976
|
+
continue
|
|
977
|
+
raise ValueError(
|
|
978
|
+
f'mutually_exclusive references unknown command {key!r} '
|
|
979
|
+
f'(known commands: {sorted(leaf_paths)}).'
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _resolve_leaf_groups(
|
|
984
|
+
mutually_exclusive: _Any, dotted: str, leaf: str, argument_names: _Set[str],
|
|
985
|
+
leaf_paths: _List[str]
|
|
986
|
+
) -> _List[_Tuple[_List[str], bool]]:
|
|
987
|
+
"""Resolve the exclusive groups applying to one leaf command.
|
|
988
|
+
|
|
989
|
+
Plain lists broadcast to every leaf defining all named parameters. For
|
|
990
|
+
mappings, an exact dotted-path key wins; otherwise a leaf-name key
|
|
991
|
+
applies when it is unique across the tree (ambiguous leaf names raise
|
|
992
|
+
`ValueError` suggesting dotted paths).
|
|
993
|
+
|
|
994
|
+
:param mutually_exclusive: The raw `mutually_exclusive` value.
|
|
995
|
+
:param dotted: The dotted path of the leaf (e.g. `"metadata.edit"`).
|
|
996
|
+
:param leaf: The leaf command name.
|
|
997
|
+
:param argument_names: The parameter names of the leaf.
|
|
998
|
+
:param leaf_paths: Dotted paths of all leaf commands.
|
|
999
|
+
:raises ValueError: If a group references unknown parameters.
|
|
1000
|
+
:return: The groups applying to this leaf.
|
|
1001
|
+
"""
|
|
1002
|
+
if mutually_exclusive is None:
|
|
1003
|
+
return []
|
|
1004
|
+
if isinstance(mutually_exclusive, _Dict):
|
|
1005
|
+
if dotted in mutually_exclusive:
|
|
1006
|
+
raw = mutually_exclusive[dotted]
|
|
1007
|
+
elif leaf in mutually_exclusive:
|
|
1008
|
+
same = sorted(p for p in leaf_paths if p.split('.')[-1] == leaf)
|
|
1009
|
+
if len(same) > 1:
|
|
1010
|
+
raise ValueError(
|
|
1011
|
+
f'Mutually exclusive key {leaf!r} is ambiguous between '
|
|
1012
|
+
f'commands {same}; use dotted paths instead.'
|
|
1013
|
+
)
|
|
1014
|
+
raw = mutually_exclusive[leaf]
|
|
1015
|
+
else:
|
|
1016
|
+
return []
|
|
1017
|
+
groups = _normalize_exclusive_groups(raw)
|
|
1018
|
+
for names, _ in groups:
|
|
1019
|
+
unknown = [name for name in names if name not in argument_names]
|
|
1020
|
+
if unknown:
|
|
1021
|
+
raise ValueError(
|
|
1022
|
+
f'Mutually exclusive group {names!r} references unknown '
|
|
1023
|
+
f'parameters of command {dotted!r}: {unknown!r}'
|
|
1024
|
+
)
|
|
1025
|
+
_check_group_overlap(groups, dotted)
|
|
1026
|
+
return groups
|
|
1027
|
+
return _resolve_broadcast_groups(
|
|
1028
|
+
mutually_exclusive, dotted, argument_names, strict=False
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _validate_broadcast_groups(
|
|
1033
|
+
mutually_exclusive: _Any, leaf_infos: _Dict[str, FunctionInfo]
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Reject broadcast groups that match no leaf command at all.
|
|
1036
|
+
|
|
1037
|
+
Called once per tree (nested parsers skip non-matching groups silently,
|
|
1038
|
+
so without this a typo'd group would vanish without a trace).
|
|
1039
|
+
|
|
1040
|
+
:param mutually_exclusive: The raw `mutually_exclusive` value.
|
|
1041
|
+
:param leaf_infos: `{dotted_path: FunctionInfo}` for every leaf.
|
|
1042
|
+
:raises ValueError: If a group matches nowhere.
|
|
1043
|
+
"""
|
|
1044
|
+
if mutually_exclusive is None or isinstance(mutually_exclusive, _Dict):
|
|
1045
|
+
return
|
|
1046
|
+
arg_sets = [set(info.arguments) for info in leaf_infos.values()]
|
|
1047
|
+
for names, _ in _normalize_exclusive_groups(mutually_exclusive):
|
|
1048
|
+
if not any(all(name in arg_set for name in names) for arg_set in arg_sets):
|
|
1049
|
+
raise ValueError(
|
|
1050
|
+
f'Mutually exclusive group {names!r} matches no command.'
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _add_command_level(
|
|
1055
|
+
subparsers: _Any,
|
|
1056
|
+
node: _Dict[str, _Any],
|
|
1057
|
+
path: _Tuple[str, ...],
|
|
1058
|
+
leaf_infos: _Dict[str, FunctionInfo],
|
|
1059
|
+
mutually_exclusive: _Any = None,
|
|
1060
|
+
leaf_paths: _Optional[_List[str]] = None
|
|
1061
|
+
) -> None:
|
|
1062
|
+
"""Add one command-tree level to a subparsers action (recursively).
|
|
1063
|
+
|
|
1064
|
+
Leaf commands get annotation-driven arguments (plus any applicable
|
|
1065
|
+
mutually exclusive groups); group nodes get a nested `add_subparsers`
|
|
1066
|
+
level of their own, so `prog group --help` lists the group's commands.
|
|
1067
|
+
"""
|
|
1068
|
+
for name, child in node.items():
|
|
1069
|
+
if callable(child):
|
|
1070
|
+
dotted = '.'.join((*path, name))
|
|
1071
|
+
info = leaf_infos[dotted]
|
|
1072
|
+
subparser = subparsers.add_parser(name, help=info.docstring.description)
|
|
1073
|
+
groups = _resolve_leaf_groups(
|
|
1074
|
+
mutually_exclusive, dotted, name, set(info.arguments),
|
|
1075
|
+
leaf_paths or [dotted]
|
|
1076
|
+
)
|
|
1077
|
+
_add_arguments(subparser, info, exclusive_groups=groups)
|
|
1078
|
+
subparser.set_defaults(func=info.function)
|
|
1079
|
+
else:
|
|
1080
|
+
group_parser = subparsers.add_parser(name)
|
|
1081
|
+
nested = group_parser.add_subparsers(required=True)
|
|
1082
|
+
_add_command_level(
|
|
1083
|
+
nested, child, (*path, name), leaf_infos, mutually_exclusive,
|
|
1084
|
+
leaf_paths
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
681
1088
|
def _argumentify(
|
|
682
|
-
functions: _Dict[str,
|
|
1089
|
+
functions: _Dict[str, _Any],
|
|
1090
|
+
mutually_exclusive: _Any = None,
|
|
1091
|
+
env_prefix: _Optional[str] = None,
|
|
1092
|
+
secret_prompt: bool = True
|
|
683
1093
|
) -> None:
|
|
684
1094
|
"""This function argumentifies one or more functions as the entry point of the
|
|
685
1095
|
script.
|
|
686
1096
|
|
|
1097
|
+
`functions` is a command tree: values are either callables (leaf commands)
|
|
1098
|
+
or nested mappings/lists (command groups), at any depth.
|
|
1099
|
+
|
|
687
1100
|
:param functions: A dictionary of functions to argumentify.
|
|
688
1101
|
:param mutually_exclusive: A list of mutually exclusive groups broadcast
|
|
689
1102
|
to every command defining all named parameters, or a
|
|
690
1103
|
`{command_name: groups}` mapping for targeted groups. Each group is a
|
|
691
1104
|
list of parameter names or a `{"names": [...], "required": bool}`
|
|
692
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).
|
|
693
1110
|
:raises RuntimeError:
|
|
694
1111
|
"""
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1112
|
+
tree = _normalize_command_node(functions)
|
|
1113
|
+
if not tree:
|
|
1114
|
+
raise ValueError('argumentify: no entry points defined.')
|
|
1115
|
+
leaves = _collect_leaves(tree)
|
|
1116
|
+
leaf_infos: _Dict[str, FunctionInfo] = {}
|
|
1117
|
+
ordered: _List[_Tuple[Callable, FunctionInfo]] = []
|
|
1118
|
+
for dotted, name, function in leaves:
|
|
1119
|
+
info = FunctionInfo(function)
|
|
1120
|
+
leaf_infos[dotted] = info
|
|
698
1121
|
|
|
699
1122
|
# Check if the function has a VAR_KEYWORD argument
|
|
700
1123
|
# Raises a ArgumentTypeError if it does
|
|
701
|
-
for argument in
|
|
1124
|
+
for argument in info.arguments.values():
|
|
702
1125
|
if argument.kind == _inspect._ParameterKind.VAR_KEYWORD:
|
|
703
1126
|
raise ArgumentTypeError(
|
|
704
|
-
f"Function {
|
|
1127
|
+
f"Function {dotted} has `**{argument.name}` argument, "
|
|
705
1128
|
"which is not supported.",
|
|
706
1129
|
unsupported_arg=argument.name
|
|
707
1130
|
)
|
|
1131
|
+
ordered.append((function, info))
|
|
708
1132
|
parser = _argparse.ColorizingArgumentParser()
|
|
709
1133
|
subparsers = parser.add_subparsers(required=True)
|
|
710
|
-
for
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
subparser.set_defaults(func=info.function)
|
|
1134
|
+
leaf_paths = [dotted for dotted, _, _ in leaves]
|
|
1135
|
+
_validate_mutex_keys(mutually_exclusive, leaf_paths)
|
|
1136
|
+
_validate_broadcast_groups(mutually_exclusive, leaf_infos)
|
|
1137
|
+
_add_command_level(
|
|
1138
|
+
subparsers, tree, (), leaf_infos, mutually_exclusive, leaf_paths
|
|
1139
|
+
)
|
|
717
1140
|
cli_args = parser.parse_args()
|
|
718
1141
|
args = []
|
|
719
1142
|
kwargs = {}
|
|
720
1143
|
info = None
|
|
721
|
-
for
|
|
1144
|
+
for function, info in ordered:
|
|
722
1145
|
if function == cli_args.func:
|
|
723
1146
|
break
|
|
724
1147
|
else:
|
|
725
1148
|
raise RuntimeError('No function found for the given arguments.')
|
|
726
1149
|
_apply_repeatable_defaults(cli_args, info)
|
|
1150
|
+
_resolve_secrets(cli_args, info, parser, env_prefix, secret_prompt)
|
|
727
1151
|
for argument in info.arguments.values():
|
|
728
1152
|
if argument.kind in (_inspect._ParameterKind.POSITIONAL_ONLY,
|
|
729
1153
|
_inspect._ParameterKind.POSITIONAL_OR_KEYWORD):
|
|
@@ -742,9 +1166,11 @@ def _argumentify(
|
|
|
742
1166
|
|
|
743
1167
|
|
|
744
1168
|
def argumentify(
|
|
745
|
-
entry_point: _Union[Callable, _List[Callable], _Dict[str,
|
|
746
|
-
mutually_exclusive: _Optional[_Any] = None
|
|
747
|
-
|
|
1169
|
+
entry_point: _Union[Callable, _List[Callable], _Dict[str, _Any]],
|
|
1170
|
+
mutually_exclusive: _Optional[_Any] = None,
|
|
1171
|
+
env_prefix: _Optional[str] = None,
|
|
1172
|
+
secret_prompt: bool = True
|
|
1173
|
+
) -> _Union[Callable, _List[Callable], _Dict[str, _Any]]:
|
|
748
1174
|
"""This function argumentifies one or more functions as the entry point of the
|
|
749
1175
|
script.
|
|
750
1176
|
|
|
@@ -787,7 +1213,9 @@ def argumentify(
|
|
|
787
1213
|
|
|
788
1214
|
For multiple entry points, pass a `{command_name: groups}` mapping, or a
|
|
789
1215
|
plain list of groups which applies to every command defining all named
|
|
790
|
-
parameters.
|
|
1216
|
+
parameters. With nested commands, mapping keys may be dotted paths
|
|
1217
|
+
(`"metadata.edit"`) or — when unique across the tree — plain leaf names.
|
|
1218
|
+
Positional parameters in a group accept zero values (single
|
|
791
1219
|
ones behave like `nargs='?'`, multi-value ones like `nargs='*'`) and
|
|
792
1220
|
receive their default (or `None`) when omitted; pass
|
|
793
1221
|
`{"names": [...], "required": True}` when exactly one of them must be
|
|
@@ -798,8 +1226,44 @@ def argumentify(
|
|
|
798
1226
|
mutually_exclusive={"run-recipe": [["recipe_path", "builtin"]]},
|
|
799
1227
|
)
|
|
800
1228
|
|
|
1229
|
+
Related commands can be grouped under a group verb by nesting mappings
|
|
1230
|
+
(to any depth), giving `git`-style `prog <group> <action>` interfaces
|
|
1231
|
+
where each level gets its own subcommands and `--help`:
|
|
1232
|
+
|
|
1233
|
+
argumentify({
|
|
1234
|
+
"bundle": bundle_entry_point,
|
|
1235
|
+
"metadata": {
|
|
1236
|
+
"show": show_entry_point,
|
|
1237
|
+
"clear": clear_entry_point,
|
|
1238
|
+
"edit": edit_entry_point,
|
|
1239
|
+
},
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
$ pdf-helper metadata edit in.pdf out.pdf --title "Report"
|
|
1243
|
+
|
|
1244
|
+
Group nodes take no function: nothing executes until a leaf command is
|
|
1245
|
+
chosen. A list of functions may be used wherever a mapping is expected;
|
|
1246
|
+
empty groups raise `ValueError`.
|
|
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
|
+
|
|
801
1263
|
:param entry_point: The function(s) to argumentify.
|
|
802
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.
|
|
803
1267
|
:raises TypeError: A function must be a function or a list of functions or a
|
|
804
1268
|
dictionary of functions.
|
|
805
1269
|
:raises ValueError: A group is malformed or references unknown parameters.
|
|
@@ -808,7 +1272,7 @@ def argumentify(
|
|
|
808
1272
|
functions = {}
|
|
809
1273
|
# Check the types
|
|
810
1274
|
if callable(entry_point):
|
|
811
|
-
_argumentify_one(entry_point, mutually_exclusive)
|
|
1275
|
+
_argumentify_one(entry_point, mutually_exclusive, env_prefix, secret_prompt)
|
|
812
1276
|
return entry_point
|
|
813
1277
|
if isinstance(entry_point, _List):
|
|
814
1278
|
for func in entry_point:
|
|
@@ -819,12 +1283,6 @@ def argumentify(
|
|
|
819
1283
|
)
|
|
820
1284
|
functions[func.__name__] = func
|
|
821
1285
|
elif isinstance(entry_point, _Dict):
|
|
822
|
-
for func in entry_point.values():
|
|
823
|
-
if not callable(func):
|
|
824
|
-
raise TypeError(
|
|
825
|
-
"argumentify: func must be a function or a list of functions or a "
|
|
826
|
-
"dictionary of functions."
|
|
827
|
-
)
|
|
828
1286
|
functions = entry_point
|
|
829
1287
|
else:
|
|
830
1288
|
raise TypeError(
|
|
@@ -832,5 +1290,5 @@ def argumentify(
|
|
|
832
1290
|
"dictionary of functions."
|
|
833
1291
|
)
|
|
834
1292
|
|
|
835
|
-
_argumentify(functions, mutually_exclusive)
|
|
1293
|
+
_argumentify(functions, mutually_exclusive, env_prefix, secret_prompt)
|
|
836
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
|