log21 3.4.0__tar.gz → 3.5.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.5.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.5.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.5.0'
37
37
  __github__ = 'https://GitHub.com/MPCodeWriter21/log21'
38
38
  __all__ = [
39
39
  'ColorizingStreamHandler', 'DecolorizingFileHandler', 'ColorizingFormatter',
@@ -450,18 +450,21 @@ def _normalize_exclusive_groups(
450
450
 
451
451
  def _resolve_command_groups(
452
452
  mutually_exclusive: _Any, command: _Optional[str],
453
- argument_names: _Set[str]
453
+ argument_names: _Set[str], strict: bool = True
454
454
  ) -> _List[_Tuple[_List[str], bool]]:
455
455
  """Resolve which exclusive groups apply to a single command parser.
456
456
 
457
457
  `mutually_exclusive` may be a plain list of groups (broadcast to every
458
458
  command that defines all named parameters) or a `{command_name: groups}`
459
459
  mapping for targeted groups. Unknown parameter names and groups that
460
- match no command raise `ValueError`.
460
+ match no command raise `ValueError` (unless `strict` is disabled, in
461
+ which case non-matching groups are silently skipped for this command —
462
+ used for nested trees where a global check runs instead).
461
463
 
462
464
  :param mutually_exclusive: The raw `mutually_exclusive` value.
463
465
  :param command: The command name, or `None` for a single-function parser.
464
466
  :param argument_names: The parameter names of the command.
467
+ :param strict: Raise when a broadcast list matches this command nowhere.
465
468
  :raises ValueError: If a group references unknown parameters.
466
469
  :return: The groups applying to this command.
467
470
  """
@@ -486,12 +489,22 @@ def _resolve_command_groups(
486
489
  )
487
490
  _check_group_overlap(groups, command)
488
491
  return groups
492
+ return _resolve_broadcast_groups(
493
+ mutually_exclusive, command, argument_names, strict=strict
494
+ )
495
+
496
+
497
+ def _resolve_broadcast_groups(
498
+ mutually_exclusive: _Any, command: _Optional[str],
499
+ argument_names: _Set[str], strict: bool = True
500
+ ) -> _List[_Tuple[_List[str], bool]]:
501
+ """Resolve broadcast groups against one command's parameters."""
489
502
  groups = _normalize_exclusive_groups(mutually_exclusive)
490
503
  applicable = [
491
504
  group for group in groups
492
505
  if all(name in argument_names for name in group[0])
493
506
  ]
494
- if groups and not applicable:
507
+ if strict and groups and not applicable:
495
508
  # A broadcast list that matches nowhere is almost certainly a typo:
496
509
  # fail fast instead of silently ignoring it.
497
510
  missing = [
@@ -678,12 +691,204 @@ def _argumentify_one(
678
691
  parser.error(error.message)
679
692
 
680
693
 
694
+ def _normalize_command_node(node: _Any, path: _Tuple[str, ...] = ()) -> _Dict[str, _Any]:
695
+ """Validate an entry-point node and normalize it to a command tree.
696
+
697
+ Leaves are callables, inner nodes are (possibly nested) mappings or lists
698
+ of callables. Returns `{name: callable-or-mapping}` with lists converted
699
+ to mappings keyed by function name.
700
+
701
+ :param node: The mapping, list or callable to normalize.
702
+ :param path: The dotted path of `node` within the tree (for errors).
703
+ :raises TypeError: If a value is neither a function nor a mapping/list.
704
+ :raises ValueError: If a group defines no subcommands.
705
+ :return: The normalized `{name: ...}` mapping.
706
+ """
707
+ label = '.'.join(path) if path else '<root>'
708
+ if isinstance(node, _List):
709
+ normalized: _Dict[str, _Any] = {}
710
+ for func in node:
711
+ if not callable(func):
712
+ raise TypeError(
713
+ "argumentify: func must be a function or a list of functions or a "
714
+ "dictionary of functions."
715
+ )
716
+ normalized[func.__name__] = func
717
+ return normalized
718
+ if isinstance(node, _Dict):
719
+ normalized = {}
720
+ for name, child in node.items():
721
+ if callable(child):
722
+ normalized[name] = child
723
+ elif isinstance(child, (_Dict, _List)):
724
+ sub = _normalize_command_node(child, (*path, name))
725
+ if not sub:
726
+ raise ValueError(
727
+ f'Command group {".".join((*path, name))!r} defines '
728
+ 'no subcommands.'
729
+ )
730
+ normalized[name] = sub
731
+ else:
732
+ raise TypeError(
733
+ "argumentify: func must be a function or a list of functions or a "
734
+ "dictionary of functions."
735
+ )
736
+ return normalized
737
+ raise TypeError(
738
+ "argumentify: func must be a function or a list of functions or a "
739
+ f"dictionary of functions (got {label!r})."
740
+ )
741
+
742
+
743
+ def _collect_leaves(
744
+ node: _Dict[str, _Any], path: _Tuple[str, ...] = ()
745
+ ) -> _List[_Tuple[str, str, Callable]]:
746
+ """Collect `(dotted_path, name, function)` leaves of a command tree."""
747
+ leaves: _List[_Tuple[str, str, Callable]] = []
748
+ for name, child in node.items():
749
+ if callable(child):
750
+ leaves.append(('.'.join((*path, name)), name, child))
751
+ else:
752
+ leaves.extend(_collect_leaves(child, (*path, name)))
753
+ return leaves
754
+
755
+
756
+ def _validate_mutex_keys(mutually_exclusive: _Any, leaf_paths: _List[str]) -> None:
757
+ """Reject mutex mapping keys that match no leaf command.
758
+
759
+ Keys may be full dotted paths (`"metadata.edit"`) or plain leaf names
760
+ (`"edit"`).
761
+
762
+ :param mutually_exclusive: The raw `mutually_exclusive` value.
763
+ :param leaf_paths: Dotted paths of all leaf commands.
764
+ :raises ValueError: If a key matches nothing.
765
+ """
766
+ if not isinstance(mutually_exclusive, _Dict):
767
+ return
768
+ leaf_names = {path.split('.')[-1] for path in leaf_paths}
769
+ for key in mutually_exclusive:
770
+ if key in leaf_paths or key in leaf_names:
771
+ continue
772
+ raise ValueError(
773
+ f'mutually_exclusive references unknown command {key!r} '
774
+ f'(known commands: {sorted(leaf_paths)}).'
775
+ )
776
+
777
+
778
+ def _resolve_leaf_groups(
779
+ mutually_exclusive: _Any, dotted: str, leaf: str, argument_names: _Set[str],
780
+ leaf_paths: _List[str]
781
+ ) -> _List[_Tuple[_List[str], bool]]:
782
+ """Resolve the exclusive groups applying to one leaf command.
783
+
784
+ Plain lists broadcast to every leaf defining all named parameters. For
785
+ mappings, an exact dotted-path key wins; otherwise a leaf-name key
786
+ applies when it is unique across the tree (ambiguous leaf names raise
787
+ `ValueError` suggesting dotted paths).
788
+
789
+ :param mutually_exclusive: The raw `mutually_exclusive` value.
790
+ :param dotted: The dotted path of the leaf (e.g. `"metadata.edit"`).
791
+ :param leaf: The leaf command name.
792
+ :param argument_names: The parameter names of the leaf.
793
+ :param leaf_paths: Dotted paths of all leaf commands.
794
+ :raises ValueError: If a group references unknown parameters.
795
+ :return: The groups applying to this leaf.
796
+ """
797
+ if mutually_exclusive is None:
798
+ return []
799
+ if isinstance(mutually_exclusive, _Dict):
800
+ if dotted in mutually_exclusive:
801
+ raw = mutually_exclusive[dotted]
802
+ elif leaf in mutually_exclusive:
803
+ same = sorted(p for p in leaf_paths if p.split('.')[-1] == leaf)
804
+ if len(same) > 1:
805
+ raise ValueError(
806
+ f'Mutually exclusive key {leaf!r} is ambiguous between '
807
+ f'commands {same}; use dotted paths instead.'
808
+ )
809
+ raw = mutually_exclusive[leaf]
810
+ else:
811
+ return []
812
+ groups = _normalize_exclusive_groups(raw)
813
+ for names, _ in groups:
814
+ unknown = [name for name in names if name not in argument_names]
815
+ if unknown:
816
+ raise ValueError(
817
+ f'Mutually exclusive group {names!r} references unknown '
818
+ f'parameters of command {dotted!r}: {unknown!r}'
819
+ )
820
+ _check_group_overlap(groups, dotted)
821
+ return groups
822
+ return _resolve_broadcast_groups(
823
+ mutually_exclusive, dotted, argument_names, strict=False
824
+ )
825
+
826
+
827
+ def _validate_broadcast_groups(
828
+ mutually_exclusive: _Any, leaf_infos: _Dict[str, FunctionInfo]
829
+ ) -> None:
830
+ """Reject broadcast groups that match no leaf command at all.
831
+
832
+ Called once per tree (nested parsers skip non-matching groups silently,
833
+ so without this a typo'd group would vanish without a trace).
834
+
835
+ :param mutually_exclusive: The raw `mutually_exclusive` value.
836
+ :param leaf_infos: `{dotted_path: FunctionInfo}` for every leaf.
837
+ :raises ValueError: If a group matches nowhere.
838
+ """
839
+ if mutually_exclusive is None or isinstance(mutually_exclusive, _Dict):
840
+ return
841
+ arg_sets = [set(info.arguments) for info in leaf_infos.values()]
842
+ for names, _ in _normalize_exclusive_groups(mutually_exclusive):
843
+ if not any(all(name in arg_set for name in names) for arg_set in arg_sets):
844
+ raise ValueError(
845
+ f'Mutually exclusive group {names!r} matches no command.'
846
+ )
847
+
848
+
849
+ def _add_command_level(
850
+ subparsers: _Any,
851
+ node: _Dict[str, _Any],
852
+ path: _Tuple[str, ...],
853
+ leaf_infos: _Dict[str, FunctionInfo],
854
+ mutually_exclusive: _Any = None,
855
+ leaf_paths: _Optional[_List[str]] = None
856
+ ) -> None:
857
+ """Add one command-tree level to a subparsers action (recursively).
858
+
859
+ 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.
862
+ """
863
+ for name, child in node.items():
864
+ if callable(child):
865
+ dotted = '.'.join((*path, name))
866
+ info = leaf_infos[dotted]
867
+ subparser = subparsers.add_parser(name, help=info.docstring.description)
868
+ groups = _resolve_leaf_groups(
869
+ mutually_exclusive, dotted, name, set(info.arguments),
870
+ leaf_paths or [dotted]
871
+ )
872
+ _add_arguments(subparser, info, exclusive_groups=groups)
873
+ subparser.set_defaults(func=info.function)
874
+ else:
875
+ group_parser = subparsers.add_parser(name)
876
+ nested = group_parser.add_subparsers(required=True)
877
+ _add_command_level(
878
+ nested, child, (*path, name), leaf_infos, mutually_exclusive,
879
+ leaf_paths
880
+ )
881
+
882
+
681
883
  def _argumentify(
682
- functions: _Dict[str, Callable], mutually_exclusive: _Any = None
884
+ functions: _Dict[str, _Any], mutually_exclusive: _Any = None
683
885
  ) -> None:
684
886
  """This function argumentifies one or more functions as the entry point of the
685
887
  script.
686
888
 
889
+ `functions` is a command tree: values are either callables (leaf commands)
890
+ or nested mappings/lists (command groups), at any depth.
891
+
687
892
  :param functions: A dictionary of functions to argumentify.
688
893
  :param mutually_exclusive: A list of mutually exclusive groups broadcast
689
894
  to every command defining all named parameters, or a
@@ -692,33 +897,39 @@ def _argumentify(
692
897
  mapping.
693
898
  :raises RuntimeError:
694
899
  """
695
- functions_info: _Dict[str, _Tuple[Callable, FunctionInfo]] = {}
696
- for name, function in functions.items():
697
- functions_info[name] = (function, FunctionInfo(function))
900
+ tree = _normalize_command_node(functions)
901
+ if not tree:
902
+ raise ValueError('argumentify: no entry points defined.')
903
+ leaves = _collect_leaves(tree)
904
+ leaf_infos: _Dict[str, FunctionInfo] = {}
905
+ ordered: _List[_Tuple[Callable, FunctionInfo]] = []
906
+ for dotted, name, function in leaves:
907
+ info = FunctionInfo(function)
908
+ leaf_infos[dotted] = info
698
909
 
699
910
  # Check if the function has a VAR_KEYWORD argument
700
911
  # Raises a ArgumentTypeError if it does
701
- for argument in functions_info[name][1].arguments.values():
912
+ for argument in info.arguments.values():
702
913
  if argument.kind == _inspect._ParameterKind.VAR_KEYWORD:
703
914
  raise ArgumentTypeError(
704
- f"Function {name} has `**{argument.name}` argument, "
915
+ f"Function {dotted} has `**{argument.name}` argument, "
705
916
  "which is not supported.",
706
917
  unsupported_arg=argument.name
707
918
  )
919
+ ordered.append((function, info))
708
920
  parser = _argparse.ColorizingArgumentParser()
709
921
  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)
922
+ leaf_paths = [dotted for dotted, _, _ in leaves]
923
+ _validate_mutex_keys(mutually_exclusive, leaf_paths)
924
+ _validate_broadcast_groups(mutually_exclusive, leaf_infos)
925
+ _add_command_level(
926
+ subparsers, tree, (), leaf_infos, mutually_exclusive, leaf_paths
927
+ )
717
928
  cli_args = parser.parse_args()
718
929
  args = []
719
930
  kwargs = {}
720
931
  info = None
721
- for _name, (function, info) in functions_info.items(): # noqa: B007
932
+ for function, info in ordered:
722
933
  if function == cli_args.func:
723
934
  break
724
935
  else:
@@ -742,9 +953,9 @@ def _argumentify(
742
953
 
743
954
 
744
955
  def argumentify(
745
- entry_point: _Union[Callable, _List[Callable], _Dict[str, Callable]],
956
+ entry_point: _Union[Callable, _List[Callable], _Dict[str, _Any]],
746
957
  mutually_exclusive: _Optional[_Any] = None
747
- ) -> _Union[Callable, _List[Callable], _Dict[str, Callable]]:
958
+ ) -> _Union[Callable, _List[Callable], _Dict[str, _Any]]:
748
959
  """This function argumentifies one or more functions as the entry point of the
749
960
  script.
750
961
 
@@ -787,7 +998,9 @@ def argumentify(
787
998
 
788
999
  For multiple entry points, pass a `{command_name: groups}` mapping, or a
789
1000
  plain list of groups which applies to every command defining all named
790
- parameters. Positional parameters in a group accept zero values (single
1001
+ parameters. With nested commands, mapping keys may be dotted paths
1002
+ (`"metadata.edit"`) or — when unique across the tree — plain leaf names.
1003
+ Positional parameters in a group accept zero values (single
791
1004
  ones behave like `nargs='?'`, multi-value ones like `nargs='*'`) and
792
1005
  receive their default (or `None`) when omitted; pass
793
1006
  `{"names": [...], "required": True}` when exactly one of them must be
@@ -798,6 +1011,25 @@ def argumentify(
798
1011
  mutually_exclusive={"run-recipe": [["recipe_path", "builtin"]]},
799
1012
  )
800
1013
 
1014
+ Related commands can be grouped under a group verb by nesting mappings
1015
+ (to any depth), giving `git`-style `prog <group> <action>` interfaces
1016
+ where each level gets its own subcommands and `--help`:
1017
+
1018
+ argumentify({
1019
+ "bundle": bundle_entry_point,
1020
+ "metadata": {
1021
+ "show": show_entry_point,
1022
+ "clear": clear_entry_point,
1023
+ "edit": edit_entry_point,
1024
+ },
1025
+ })
1026
+
1027
+ $ pdf-helper metadata edit in.pdf out.pdf --title "Report"
1028
+
1029
+ Group nodes take no function: nothing executes until a leaf command is
1030
+ chosen. A list of functions may be used wherever a mapping is expected;
1031
+ empty groups raise `ValueError`.
1032
+
801
1033
  :param entry_point: The function(s) to argumentify.
802
1034
  :param mutually_exclusive: Mutually exclusive parameter groups.
803
1035
  :raises TypeError: A function must be a function or a list of functions or a
@@ -819,12 +1051,6 @@ def argumentify(
819
1051
  )
820
1052
  functions[func.__name__] = func
821
1053
  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
1054
  functions = entry_point
829
1055
  else:
830
1056
  raise TypeError(
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