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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: log21
3
- Version: 3.4.0
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
  ![version](https://img.shields.io/pypi/v/log21)
31
31
  [![pipeline status](https://gitlab.com/CodeWriter21/log21/badges/master/pipeline.svg)](https://gitlab.com/CodeWriter21/log21/-/commits/master)
32
- [![repo size](https://img.shields.io/gitlab/repo-size/CodeWriter21/log21)](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
  ![version](https://img.shields.io/pypi/v/log21)
5
5
  [![pipeline status](https://gitlab.com/CodeWriter21/log21/badges/master/pipeline.svg)](https://gitlab.com/CodeWriter21/log21/-/commits/master)
6
- [![repo size](https://img.shields.io/gitlab/repo-size/CodeWriter21/log21)](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.
@@ -23,7 +23,7 @@ dependencies = [
23
23
  "webcolors",
24
24
  "docstring-parser"
25
25
  ]
26
- version = "3.4.0"
26
+ version = "3.6.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.4.0'
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
- config['type'] = element
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, _ = _list_element_type(argument.annotation)
613
- if is_list and config.get('nargs') is None and argument.default is not None:
614
- # A declared default (e.g. `= []`) makes the multi-value
615
- # positional optional instead of requiring 1+ values.
616
- config['nargs'] = '*'
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
- if argument.kind == _inspect._ParameterKind.POSITIONAL_OR_KEYWORD and keyword_only_exists:
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, mutually_exclusive: _Any = None
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, Callable], mutually_exclusive: _Any = None
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
- functions_info: _Dict[str, _Tuple[Callable, FunctionInfo]] = {}
696
- for name, function in functions.items():
697
- functions_info[name] = (function, FunctionInfo(function))
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 functions_info[name][1].arguments.values():
1124
+ for argument in info.arguments.values():
702
1125
  if argument.kind == _inspect._ParameterKind.VAR_KEYWORD:
703
1126
  raise ArgumentTypeError(
704
- f"Function {name} has `**{argument.name}` argument, "
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 name, (_, info) in functions_info.items():
711
- subparser = subparsers.add_parser(name, help=info.docstring.description)
712
- groups = _resolve_command_groups(
713
- mutually_exclusive, name, set(info.arguments)
714
- )
715
- _add_arguments(subparser, info, exclusive_groups=groups)
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 _name, (function, info) in functions_info.items(): # noqa: B007
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, Callable]],
746
- mutually_exclusive: _Optional[_Any] = None
747
- ) -> _Union[Callable, _List[Callable], _Dict[str, Callable]]:
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. Positional parameters in a group accept zero values (single
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