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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: log21
3
- Version: 3.5.0
3
+ Version: 3.7.0
4
4
  Summary: A simple logging package
5
5
  Keywords: python,log,colorize,color,logging,Python3,CodeWriter21
6
6
  Author: CodeWriter21(Mehrad Pooryoussof)
@@ -23,7 +23,7 @@ dependencies = [
23
23
  "webcolors",
24
24
  "docstring-parser"
25
25
  ]
26
- version = "3.5.0"
26
+ version = "3.7.0"
27
27
 
28
28
  [build-system]
29
29
  requires = ["uv_build>=0.8.15,<0.9.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.5.0'
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 info.arguments.values():
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
- config['type'] = element
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, _ = _list_element_type(argument.annotation)
626
- if is_list and config.get('nargs') is None and argument.default is not None:
627
- # A declared default (e.g. `= []`) makes the multi-value
628
- # positional optional instead of requiring 1+ values.
629
- config['nargs'] = '*'
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
- if argument.kind == _inspect._ParameterKind.POSITIONAL_OR_KEYWORD and keyword_only_exists:
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, mutually_exclusive: _Any = None
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
- _add_arguments(parser, info, exclusive_groups=groups)
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 nested `add_subparsers`
861
- level of their own, so `prog group --help` lists the group's commands.
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], mutually_exclusive: _Any = None
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(entry_point, mutually_exclusive)
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