log21 3.3.2__tar.gz → 3.4.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.3.2
3
+ Version: 3.4.0
4
4
  Summary: A simple logging package
5
5
  Keywords: python,log,colorize,color,logging,Python3,CodeWriter21
6
6
  Author: CodeWriter21(Mehrad Pooryoussof)
@@ -18,19 +18,18 @@ Classifier: Operating System :: MacOS :: MacOS X
18
18
  Requires-Dist: webcolors
19
19
  Requires-Dist: docstring-parser
20
20
  Requires-Python: >=3.9
21
- Project-URL: Donations, https://github.com/MPCodeWriter21/log21/blob/master/DONATE.md
22
- Project-URL: Homepage, https://github.com/MPCodeWriter21/log21
23
- Project-URL: Source, https://github.com/MPCodeWriter21/log21
21
+ Project-URL: Donations, https://gitlab.com/CodeWriter21/log21/-/blob/master/DONATE.md
22
+ Project-URL: Homepage, https://gitlab.com/CodeWriter21/log21
23
+ Project-URL: Repository, https://gitlab.com/CodeWriter21/log21
24
+ Project-URL: Source, https://gitlab.com/CodeWriter21/log21
24
25
  Description-Content-Type: text/markdown
25
26
 
26
27
  log21
27
28
  =====
28
29
 
29
30
  ![version](https://img.shields.io/pypi/v/log21)
30
- ![stars](https://img.shields.io/github/stars/MPCodeWriter21/log21)
31
- ![forks](https://img.shields.io/github/forks/MPCodeWriter21/log21)
32
- ![repo size](https://img.shields.io/github/repo-size/MPCodeWriter21/log21)
33
- [![CodeFactor](https://www.codefactor.io/repository/github/mpcodewriter21/log21/badge)](https://www.codefactor.io/repository/github/mpcodewriter21/log21)
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)
34
33
 
35
34
  A simple logging package that helps you log colorized messages in Windows console and
36
35
  other operating systems.
@@ -59,11 +58,11 @@ Features
59
58
  reporter functions and use them instead!
60
59
  + Argumentify : You can use the argumentify feature to decrease the number of lines you
61
60
  need to write to parse command-line arguments. It's colored by the way!
62
- + Any idea? Feel free to [open an issue](https://github.com/MPCodeWriter21/log21/issues)
63
- or submit a pull request.
61
+ + Any idea? Feel free to [open an issue](https://gitlab.com/CodeWriter21/log21/-/issues)
62
+ or submit a merge request.
64
63
 
65
- ![Issues](https://img.shields.io/github/issues/MPCodeWriter21/log21)
66
- ![contributors](https://img.shields.io/github/contributors/MPCodeWriter21/log21)
64
+ [![Issues](https://img.shields.io/gitlab/issues/open/CodeWriter21/log21)](https://gitlab.com/CodeWriter21/log21/-/issues)
65
+ [![contributors](https://img.shields.io/gitlab/contributors/CodeWriter21/log21)](https://gitlab.com/CodeWriter21/log21/-/graphs/master)
67
66
 
68
67
  Installation
69
68
  ------------
@@ -79,7 +78,7 @@ Then you can install log21 using pip module:
79
78
  python -m pip install log21 -U
80
79
  ```
81
80
 
82
- Or you can clone [the repository](https://github.com/MPCodeWriter21/log21) and run:
81
+ Or you can clone [the repository](https://gitlab.com/CodeWriter21/log21) and run:
83
82
 
84
83
  ```bash
85
84
  pip install .
@@ -88,7 +87,7 @@ pip install .
88
87
  Or let the pip get it using git:
89
88
 
90
89
  ```bash
91
- pip install git+https://github.com/MPCodeWriter21/log21
90
+ pip install git+https://gitlab.com/CodeWriter21/log21.git
92
91
  ```
93
92
 
94
93
  Changelog
@@ -155,25 +154,25 @@ if __name__ == "__main__":
155
154
  log21.argumentify(show_percentage)
156
155
  ```
157
156
 
158
- [Full CHANGELOG](https://github.com/MPCodeWriter21/log21/blob/master/CHANGELOG.md)
157
+ [Full CHANGELOG](https://gitlab.com/CodeWriter21/log21/-/blob/master/CHANGELOG.md)
159
158
 
160
159
  Usage Examples
161
160
  ---------------
162
161
 
163
- See [EXAMPLES.md](https://github.com/MPCodeWriter21/log21/blob/master/EXAMPLES.md)
162
+ See [EXAMPLES.md](https://gitlab.com/CodeWriter21/log21/-/blob/master/EXAMPLES.md)
164
163
 
165
164
  About
166
165
  -----
167
166
 
168
167
  Author: CodeWriter21 (Mehrad Pooryoussof)
169
168
 
170
- GitHub: [MPCodeWriter21](https://github.com/MPCodeWriter21)
169
+ GitLab: [CodeWriter21](https://gitlab.com/CodeWriter21)
171
170
 
172
171
  Telegram Channel: [@CodeWriter21](https://t.me/CodeWriter21)
173
172
 
174
173
  ### License
175
174
 
176
- ![License](https://img.shields.io/github/license/MPCodeWriter21/log21)
175
+ [![License](https://img.shields.io/gitlab/license/CodeWriter21/log21)](https://gitlab.com/CodeWriter21/log21/-/blob/master/LICENSE.txt)
177
176
 
178
177
  [apache-2.0](http://www.apache.org/licenses/LICENSE-2.0)
179
178
 
@@ -181,10 +180,10 @@ Telegram Channel: [@CodeWriter21](https://t.me/CodeWriter21)
181
180
 
182
181
  In order to support this project you can donate some crypto of your choice 8D
183
182
 
184
- [Donate Addresses](https://github.com/MPCodeWriter21/log21/blob/master/DONATE.md)
183
+ [Donate Addresses](https://gitlab.com/CodeWriter21/log21/-/blob/master/DONATE.md)
185
184
 
186
- Or if you can't, give [this project](https://github.com/MPCodeWriter21/log21) a star on
187
- GitHub :)
185
+ Or if you can't, give [this project](https://gitlab.com/CodeWriter21/log21) a star on
186
+ GitLab :)
188
187
 
189
188
  References
190
189
  ----------
@@ -2,10 +2,8 @@ log21
2
2
  =====
3
3
 
4
4
  ![version](https://img.shields.io/pypi/v/log21)
5
- ![stars](https://img.shields.io/github/stars/MPCodeWriter21/log21)
6
- ![forks](https://img.shields.io/github/forks/MPCodeWriter21/log21)
7
- ![repo size](https://img.shields.io/github/repo-size/MPCodeWriter21/log21)
8
- [![CodeFactor](https://www.codefactor.io/repository/github/mpcodewriter21/log21/badge)](https://www.codefactor.io/repository/github/mpcodewriter21/log21)
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)
9
7
 
10
8
  A simple logging package that helps you log colorized messages in Windows console and
11
9
  other operating systems.
@@ -34,11 +32,11 @@ Features
34
32
  reporter functions and use them instead!
35
33
  + Argumentify : You can use the argumentify feature to decrease the number of lines you
36
34
  need to write to parse command-line arguments. It's colored by the way!
37
- + Any idea? Feel free to [open an issue](https://github.com/MPCodeWriter21/log21/issues)
38
- or submit a pull request.
35
+ + Any idea? Feel free to [open an issue](https://gitlab.com/CodeWriter21/log21/-/issues)
36
+ or submit a merge request.
39
37
 
40
- ![Issues](https://img.shields.io/github/issues/MPCodeWriter21/log21)
41
- ![contributors](https://img.shields.io/github/contributors/MPCodeWriter21/log21)
38
+ [![Issues](https://img.shields.io/gitlab/issues/open/CodeWriter21/log21)](https://gitlab.com/CodeWriter21/log21/-/issues)
39
+ [![contributors](https://img.shields.io/gitlab/contributors/CodeWriter21/log21)](https://gitlab.com/CodeWriter21/log21/-/graphs/master)
42
40
 
43
41
  Installation
44
42
  ------------
@@ -54,7 +52,7 @@ Then you can install log21 using pip module:
54
52
  python -m pip install log21 -U
55
53
  ```
56
54
 
57
- Or you can clone [the repository](https://github.com/MPCodeWriter21/log21) and run:
55
+ Or you can clone [the repository](https://gitlab.com/CodeWriter21/log21) and run:
58
56
 
59
57
  ```bash
60
58
  pip install .
@@ -63,7 +61,7 @@ pip install .
63
61
  Or let the pip get it using git:
64
62
 
65
63
  ```bash
66
- pip install git+https://github.com/MPCodeWriter21/log21
64
+ pip install git+https://gitlab.com/CodeWriter21/log21.git
67
65
  ```
68
66
 
69
67
  Changelog
@@ -130,25 +128,25 @@ if __name__ == "__main__":
130
128
  log21.argumentify(show_percentage)
131
129
  ```
132
130
 
133
- [Full CHANGELOG](https://github.com/MPCodeWriter21/log21/blob/master/CHANGELOG.md)
131
+ [Full CHANGELOG](https://gitlab.com/CodeWriter21/log21/-/blob/master/CHANGELOG.md)
134
132
 
135
133
  Usage Examples
136
134
  ---------------
137
135
 
138
- See [EXAMPLES.md](https://github.com/MPCodeWriter21/log21/blob/master/EXAMPLES.md)
136
+ See [EXAMPLES.md](https://gitlab.com/CodeWriter21/log21/-/blob/master/EXAMPLES.md)
139
137
 
140
138
  About
141
139
  -----
142
140
 
143
141
  Author: CodeWriter21 (Mehrad Pooryoussof)
144
142
 
145
- GitHub: [MPCodeWriter21](https://github.com/MPCodeWriter21)
143
+ GitLab: [CodeWriter21](https://gitlab.com/CodeWriter21)
146
144
 
147
145
  Telegram Channel: [@CodeWriter21](https://t.me/CodeWriter21)
148
146
 
149
147
  ### License
150
148
 
151
- ![License](https://img.shields.io/github/license/MPCodeWriter21/log21)
149
+ [![License](https://img.shields.io/gitlab/license/CodeWriter21/log21)](https://gitlab.com/CodeWriter21/log21/-/blob/master/LICENSE.txt)
152
150
 
153
151
  [apache-2.0](http://www.apache.org/licenses/LICENSE-2.0)
154
152
 
@@ -156,10 +154,10 @@ Telegram Channel: [@CodeWriter21](https://t.me/CodeWriter21)
156
154
 
157
155
  In order to support this project you can donate some crypto of your choice 8D
158
156
 
159
- [Donate Addresses](https://github.com/MPCodeWriter21/log21/blob/master/DONATE.md)
157
+ [Donate Addresses](https://gitlab.com/CodeWriter21/log21/-/blob/master/DONATE.md)
160
158
 
161
- Or if you can't, give [this project](https://github.com/MPCodeWriter21/log21) a star on
162
- GitHub :)
159
+ Or if you can't, give [this project](https://gitlab.com/CodeWriter21/log21) a star on
160
+ GitLab :)
163
161
 
164
162
  References
165
163
  ----------
@@ -23,16 +23,17 @@ dependencies = [
23
23
  "webcolors",
24
24
  "docstring-parser"
25
25
  ]
26
- version = "3.3.2"
26
+ version = "3.4.0"
27
27
 
28
28
  [build-system]
29
29
  requires = ["uv_build>=0.8.15,<0.9.0"]
30
30
  build-backend = "uv_build"
31
31
 
32
32
  [project.urls]
33
- Homepage = "https://github.com/MPCodeWriter21/log21"
34
- Donations = "https://github.com/MPCodeWriter21/log21/blob/master/DONATE.md"
35
- Source = "https://github.com/MPCodeWriter21/log21"
33
+ Homepage = "https://gitlab.com/CodeWriter21/log21"
34
+ Repository = "https://gitlab.com/CodeWriter21/log21"
35
+ Donations = "https://gitlab.com/CodeWriter21/log21/-/blob/master/DONATE.md"
36
+ Source = "https://gitlab.com/CodeWriter21/log21"
36
37
 
37
38
  [tool.yapf]
38
39
  column_limit = 88
@@ -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.3.2'
36
+ __version__ = '3.4.0'
37
37
  __github__ = 'https://GitHub.com/MPCodeWriter21/log21'
38
38
  __all__ = [
39
39
  'ColorizingStreamHandler', 'DecolorizingFileHandler', 'ColorizingFormatter',
@@ -575,9 +575,23 @@ class _ActionsContainer(_argparse._ActionsContainer): # novm
575
575
  and not isinstance(func_type, (_types.UnionType, tuple))):
576
576
  raise ValueError(f'{func_type} is not callable; level={level}')
577
577
 
578
- # Handle `UnionType` as a type (e.g. `int|str`)
578
+ # Handle `UnionType` as a type (e.g. `int|str` and `int|None`)
579
579
  if hasattr(_types, 'UnionType') and isinstance(func_type, _types.UnionType):
580
- func_type = func_type.__args__ # type: ignore
580
+ # `X | None` is the PEP 604 spelling of `Optional[X]` and must
581
+ # behave identically: mark the action as not required and unwrap
582
+ # to `X`. `types.UnionType` exposes `__args__` just like
583
+ # `typing.Optional`, e.g. `(str | None).__args__ == (str, NoneType)`.
584
+ args = func_type.__args__ # type: ignore
585
+ if (hasattr(_types, 'NoneType') and len(args) == 2
586
+ and args[1] is _types.NoneType):
587
+ action.required = False
588
+ func_type = args[0]
589
+ elif (hasattr(_types, 'NoneType') and len(args) == 2
590
+ and args[0] is _types.NoneType):
591
+ action.required = False
592
+ func_type = args[1]
593
+ else:
594
+ func_type = args
581
595
 
582
596
  # Handle `Literal` as a type (e.g. `Literal[1, 2, 3]`)
583
597
  elif (hasattr(_typing, '_LiteralGenericAlias')
@@ -598,13 +612,17 @@ class _ActionsContainer(_argparse._ActionsContainer): # novm
598
612
  else:
599
613
  func_type = func_type.__args__ # type: ignore
600
614
 
601
- # Handle `List` as a type (e.g. `List[int]`)
615
+ # Handle `List` as a type (e.g. `List[int]` and builtin `list[int]`)
602
616
  elif (hasattr(_typing, '_GenericAlias')
603
617
  and isinstance(func_type, _typing._GenericAlias) # type: ignore
604
618
  and func_type.__origin__ is list) or (
605
619
  hasattr(_typing, '_GenericAlias')
606
620
  and isinstance(func_type, _typing._GenericAlias) # type: ignore
607
- and func_type.__origin__ is collections.abc.Sequence):
621
+ and func_type.__origin__ is collections.abc.Sequence) or (
622
+ hasattr(_types, 'GenericAlias')
623
+ and isinstance(func_type, _types.GenericAlias)
624
+ and getattr(func_type, '__origin__', None) in
625
+ (list, collections.abc.Sequence)):
608
626
  func_type = func_type.__args__[0]
609
627
  if kwargs.get('nargs') is None:
610
628
  action.nargs = '+'
@@ -654,6 +672,15 @@ class _ActionsContainer(_argparse._ActionsContainer): # novm
654
672
  _types.UnionType,
655
673
  ))):
656
674
  func_type = self._validate_func_type(action, func_type, kwargs, level + 1)
675
+ elif (hasattr(_types, 'GenericAlias')
676
+ and isinstance(func_type, _types.GenericAlias)
677
+ and getattr(func_type, '__origin__', None) in
678
+ (list, collections.abc.Sequence)):
679
+ # Builtin `list[T]` unwrapped from a PEP 604 optional (e.g.
680
+ # `list[str] | None`): route it through the `List` branch above.
681
+ # Restricted to list/Sequence origins so other generics (e.g.
682
+ # `dict[str, int]`) cannot recurse forever.
683
+ func_type = self._validate_func_type(action, func_type, kwargs, level + 1)
657
684
  else:
658
685
  func_type = (func_type, )
659
686
 
@@ -7,6 +7,8 @@ import re as _re
7
7
  import string as _string
8
8
  import asyncio as _asyncio
9
9
  import inspect as _inspect
10
+ import types as _stdlib_types
11
+ import collections.abc as _collections_abc
10
12
  from typing import (Any as _Any, Set as _Set, Dict as _Dict, List as _List,
11
13
  Tuple as _Tuple, Union as _Union, Callable as _Callable,
12
14
  Optional as _Optional, Awaitable as _Awaitable,
@@ -331,16 +333,231 @@ def generate_flag( # pylint: disable=too-many-branches
331
333
  return flags
332
334
 
333
335
 
336
+ def _list_element_type(annotation: _Any) -> _Tuple[bool, _Any]:
337
+ """Check whether an annotation is a `list[T]` and return its element type.
338
+
339
+ Handles `list[T]`, `typing.List[T]`, `Sequence[T]` as well as
340
+ `Optional[...]` / `X | None` wrapped variants of those. Bare `list`
341
+ (without a subscript) returns `(True, None)`, meaning no per-item
342
+ conversion.
343
+
344
+ :param annotation: The parameter annotation to inspect.
345
+ :return: A `(is_list, element_type)` tuple.
346
+ """
347
+ if annotation is None:
348
+ return False, None
349
+ # Unwrap `Optional[X]` / `X | None` (both `typing` and PEP 604 spellings).
350
+ args = getattr(annotation, '__args__', None)
351
+ none_type = getattr(_stdlib_types, 'NoneType', None)
352
+ if (args is not None and none_type is not None and len(args) == 2
353
+ and (args[0] is none_type or args[1] is none_type)):
354
+ annotation = args[1] if args[0] is none_type else args[0]
355
+ args = getattr(annotation, '__args__', None)
356
+ if annotation is list:
357
+ return True, None
358
+ origin = getattr(annotation, '__origin__', None)
359
+ if origin is list or origin is _collections_abc.Sequence:
360
+ if args:
361
+ return True, args[0]
362
+ return True, None
363
+ if (hasattr(_stdlib_types, 'GenericAlias')
364
+ and isinstance(annotation, _stdlib_types.GenericAlias)
365
+ and getattr(annotation, '__origin__', None) in
366
+ (list, _collections_abc.Sequence)):
367
+ args = getattr(annotation, '__args__', None) or ()
368
+ return True, args[0] if args else None
369
+ return False, None
370
+
371
+
372
+ def _is_repeatable_flag(argument: Argument) -> bool:
373
+ """Check whether an argument maps to a repeatable `--flag` option.
374
+
375
+ Only non-positional parameters annotated with `list[T]` are repeatable
376
+ flags. Positional-only `list[T]` parameters become multi-value
377
+ positionals instead, and `*args` is handled separately.
378
+ """
379
+ if argument.kind in (_inspect._ParameterKind.POSITIONAL_ONLY,
380
+ _inspect._ParameterKind.VAR_POSITIONAL,
381
+ _inspect._ParameterKind.VAR_KEYWORD):
382
+ return False
383
+ is_list, _ = _list_element_type(argument.annotation)
384
+ return is_list
385
+
386
+
387
+ def _normalize_exclusive_groups(
388
+ mutually_exclusive: _Any
389
+ ) -> _List[_Tuple[_List[str], bool]]:
390
+ """Normalize the `mutually_exclusive` argument into `(names, required)` groups.
391
+
392
+ Each group is either a list/tuple of parameter names (an optional group)
393
+ or a `{"names": [...], "required": bool}` mapping. Groups must name at
394
+ least two parameters.
395
+
396
+ :param mutually_exclusive: The raw `mutually_exclusive` value.
397
+ :raises ValueError: If a group is malformed.
398
+ :return: A list of `(names, required)` tuples.
399
+ """
400
+ if mutually_exclusive is None:
401
+ return []
402
+ if isinstance(mutually_exclusive, _Dict):
403
+ if 'names' in mutually_exclusive:
404
+ # A single group passed without a wrapping list.
405
+ mutually_exclusive = [mutually_exclusive]
406
+ else:
407
+ raise ValueError(
408
+ 'mutually_exclusive must be a list of groups, not a mapping. '
409
+ 'To target groups at specific commands, pass a '
410
+ '{command_name: groups} mapping as documented in `argumentify`.'
411
+ )
412
+ groups: _List[_Tuple[_List[str], bool]] = []
413
+ if all(isinstance(item, str) for item in mutually_exclusive):
414
+ raise ValueError(
415
+ 'mutually_exclusive must be a list of groups, e.g. '
416
+ f'[{list(mutually_exclusive)!r}], not a bare list of names.'
417
+ )
418
+ for group in mutually_exclusive:
419
+ required = False
420
+ if isinstance(group, _Dict):
421
+ names = group.get('names', [])
422
+ required = bool(group.get('required', False))
423
+ elif isinstance(group, (list, tuple)):
424
+ names = list(group)
425
+ else:
426
+ raise ValueError(
427
+ 'Each mutually exclusive group must be a list of parameter '
428
+ f'names or a {{"names": [...], "required": bool}} mapping, '
429
+ f'got: {group!r}'
430
+ )
431
+ names = list(names)
432
+ if any(not isinstance(name, str) for name in names):
433
+ raise ValueError(
434
+ 'Mutually exclusive group members must be parameter names '
435
+ f'(strings), got: {names!r}'
436
+ )
437
+ if len(names) < 2:
438
+ raise ValueError(
439
+ 'Each mutually exclusive group must name at least two '
440
+ f'parameters, got: {names!r}'
441
+ )
442
+ if len(set(names)) != len(names):
443
+ raise ValueError(
444
+ 'Duplicate parameter names in mutually exclusive group: '
445
+ f'{names!r}'
446
+ )
447
+ groups.append((names, required))
448
+ return groups
449
+
450
+
451
+ def _resolve_command_groups(
452
+ mutually_exclusive: _Any, command: _Optional[str],
453
+ argument_names: _Set[str]
454
+ ) -> _List[_Tuple[_List[str], bool]]:
455
+ """Resolve which exclusive groups apply to a single command parser.
456
+
457
+ `mutually_exclusive` may be a plain list of groups (broadcast to every
458
+ command that defines all named parameters) or a `{command_name: groups}`
459
+ mapping for targeted groups. Unknown parameter names and groups that
460
+ match no command raise `ValueError`.
461
+
462
+ :param mutually_exclusive: The raw `mutually_exclusive` value.
463
+ :param command: The command name, or `None` for a single-function parser.
464
+ :param argument_names: The parameter names of the command.
465
+ :raises ValueError: If a group references unknown parameters.
466
+ :return: The groups applying to this command.
467
+ """
468
+ if mutually_exclusive is None:
469
+ return []
470
+ if isinstance(mutually_exclusive, _Dict):
471
+ if command is None:
472
+ raise ValueError(
473
+ 'mutually_exclusive as a {command_name: groups} mapping is '
474
+ 'only supported for multiple entry points; pass a plain list '
475
+ 'of groups for a single function.'
476
+ )
477
+ if command not in mutually_exclusive:
478
+ return []
479
+ groups = _normalize_exclusive_groups(mutually_exclusive[command])
480
+ for names, _ in groups:
481
+ unknown = [name for name in names if name not in argument_names]
482
+ if unknown:
483
+ raise ValueError(
484
+ f'Mutually exclusive group {names!r} references unknown '
485
+ f'parameters of command {command!r}: {unknown!r}'
486
+ )
487
+ _check_group_overlap(groups, command)
488
+ return groups
489
+ groups = _normalize_exclusive_groups(mutually_exclusive)
490
+ applicable = [
491
+ group for group in groups
492
+ if all(name in argument_names for name in group[0])
493
+ ]
494
+ if groups and not applicable:
495
+ # A broadcast list that matches nowhere is almost certainly a typo:
496
+ # fail fast instead of silently ignoring it.
497
+ missing = [
498
+ name for names, _ in groups for name in names
499
+ if name not in argument_names
500
+ ]
501
+ label = f'command {command!r} ' if command is not None else ''
502
+ raise ValueError(
503
+ f'Mutually exclusive groups reference unknown parameters for '
504
+ f'{label}: {sorted(set(missing))!r}'
505
+ )
506
+ _check_group_overlap(applicable, command)
507
+ return applicable
508
+
509
+
510
+ def _check_group_overlap(
511
+ groups: _List[_Tuple[_List[str], bool]], command: _Optional[str]
512
+ ) -> None:
513
+ """Reject parameters shared between two groups of the same parser."""
514
+ seen: _Dict[str, _List[str]] = {}
515
+ for names, _ in groups:
516
+ for name in names:
517
+ seen.setdefault(name, []).append(names)
518
+ for name, owners in seen.items():
519
+ if len(owners) > 1:
520
+ label = f'command {command!r} ' if command is not None else ''
521
+ raise ValueError(
522
+ f'Parameter {name!r} for {label}is in more than one mutually '
523
+ 'exclusive group.'
524
+ )
525
+
526
+
527
+ def _apply_repeatable_defaults(cli_args: _Any, info: FunctionInfo) -> None:
528
+ """Restore declared `list[T]` defaults for absent repeatable flags.
529
+
530
+ Repeatable flags are registered with an internal `None` default (so
531
+ `argparse` never mutates the function signature's default list via
532
+ `action="append"`). When the flag was not used and the parameter declares
533
+ a default, substitute it back here.
534
+ """
535
+ for argument in info.arguments.values():
536
+ if _is_repeatable_flag(argument) and getattr(
537
+ cli_args, argument.name, None
538
+ ) is None and argument.default is not None:
539
+ setattr(cli_args, argument.name, argument.default)
540
+
541
+
334
542
  def _add_arguments(
335
543
  parser: _Union[_argparse.ColorizingArgumentParser, _argparse._ArgumentGroup],
336
544
  info: FunctionInfo,
337
- reserved_flags: _Optional[_Set[str]] = None
545
+ reserved_flags: _Optional[_Set[str]] = None,
546
+ exclusive_groups: _Optional[_List[_Tuple[_List[str], bool]]] = None
338
547
  ) -> None:
339
548
  """Add the arguments to the parser.
340
549
 
550
+ `list[T]` parameters become multi-value arguments: positional-only ones
551
+ accept several values (`nargs='+'`, or `'*'` when a default is declared),
552
+ while other parameters become repeatable `--flag` options
553
+ (`action="append"`, each occurrence converted to `T`). Parameters listed
554
+ in `exclusive_groups` are added to `argparse` mutually exclusive groups.
555
+
341
556
  :param parser: The parser to add the arguments to.
342
557
  :param info: The function info.
343
558
  :param reserved_flags: The reserved flags.
559
+ :param exclusive_groups: Normalized `(names, required)` groups applying
560
+ to this parser.
344
561
  """
345
562
  if reserved_flags is None:
346
563
  reserved_flags = RESERVED_FLAGS.copy()
@@ -367,6 +584,9 @@ def _add_arguments(
367
584
  )
368
585
 
369
586
  # Add the arguments
587
+ containers: _Dict[int, _Any] = {}
588
+ for index, (_, required) in enumerate(exclusive_groups or []):
589
+ containers[index] = parser.add_mutually_exclusive_group(required=required)
370
590
  for argument in info.arguments.values():
371
591
  config: _Dict[str, _Any] = {
372
592
  'action': 'store',
@@ -376,32 +596,69 @@ def _add_arguments(
376
596
  flags = generate_flag(argument, reserved_flags=reserved_flags)
377
597
  if argument.annotation is bool:
378
598
  config['action'] = 'store_true'
599
+ elif _is_repeatable_flag(argument):
600
+ _, element = _list_element_type(argument.annotation)
601
+ config['action'] = 'append'
602
+ if element is not None:
603
+ config['type'] = element
604
+ # Registered with an internal `None` default so `argparse` never
605
+ # mutates the signature's default list; the declared default is
606
+ # restored by `_apply_repeatable_defaults` after parsing.
607
+ config['default'] = None
379
608
  elif argument.annotation:
380
609
  config['type'] = argument.annotation
381
610
  if argument.kind == _inspect._ParameterKind.POSITIONAL_ONLY:
382
611
  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'] = '*'
617
+ if any(argument.name in names for names, _ in exclusive_groups or []):
618
+ # `argparse` only accepts optional actions in mutually
619
+ # exclusive groups: single-value positionals become `nargs='?'`
620
+ # and multi-value ones `nargs='*'` (group-level `required`
621
+ # still enforces "exactly one" when requested).
622
+ if config.get('nargs') is None:
623
+ config['nargs'] = '?'
624
+ elif config.get('nargs') == '+':
625
+ config['nargs'] = '*'
383
626
  if argument.kind == _inspect._ParameterKind.VAR_POSITIONAL:
384
627
  config['nargs'] = '*'
385
628
  flags = [config.pop('dest')]
386
629
  if argument.kind == _inspect._ParameterKind.POSITIONAL_OR_KEYWORD and keyword_only_exists:
387
630
  config['required'] = True
388
- if argument.default is not None:
631
+ if argument.default is not None and config.get('action') != 'append':
389
632
  config['default'] = argument.default
390
- parser.add_argument(*flags, **config)
633
+ target = parser
634
+ for index, (names, _) in enumerate(exclusive_groups or []):
635
+ if argument.name in names:
636
+ target = containers[index]
637
+ break
638
+ target.add_argument(*flags, **config)
391
639
 
392
640
 
393
- def _argumentify_one(func: Callable) -> None:
641
+ def _argumentify_one(
642
+ func: Callable, mutually_exclusive: _Any = None
643
+ ) -> None:
394
644
  """This function argumentifies one function as the entry point of the script.
395
645
 
396
646
  :param function: The function to argumentify.
647
+ :param mutually_exclusive: A list of mutually exclusive groups. Each
648
+ group is a list of parameter names or a
649
+ `{"names": [...], "required": bool}` mapping.
397
650
  """
398
651
  info = FunctionInfo(func)
399
652
 
400
653
  # Create the parser
401
654
  parser = _argparse.ColorizingArgumentParser(description=info.docstring.description)
402
655
  # Add the arguments
403
- _add_arguments(parser, info)
656
+ groups = _resolve_command_groups(
657
+ mutually_exclusive, None, set(info.arguments)
658
+ )
659
+ _add_arguments(parser, info, exclusive_groups=groups)
404
660
  cli_args = parser.parse_args()
661
+ _apply_repeatable_defaults(cli_args, info)
405
662
  args = []
406
663
  kwargs = {}
407
664
  for argument in info.arguments.values():
@@ -421,11 +678,18 @@ def _argumentify_one(func: Callable) -> None:
421
678
  parser.error(error.message)
422
679
 
423
680
 
424
- def _argumentify(functions: _Dict[str, Callable]) -> None:
681
+ def _argumentify(
682
+ functions: _Dict[str, Callable], mutually_exclusive: _Any = None
683
+ ) -> None:
425
684
  """This function argumentifies one or more functions as the entry point of the
426
685
  script.
427
686
 
428
687
  :param functions: A dictionary of functions to argumentify.
688
+ :param mutually_exclusive: A list of mutually exclusive groups broadcast
689
+ to every command defining all named parameters, or a
690
+ `{command_name: groups}` mapping for targeted groups. Each group is a
691
+ list of parameter names or a `{"names": [...], "required": bool}`
692
+ mapping.
429
693
  :raises RuntimeError:
430
694
  """
431
695
  functions_info: _Dict[str, _Tuple[Callable, FunctionInfo]] = {}
@@ -445,7 +709,10 @@ def _argumentify(functions: _Dict[str, Callable]) -> None:
445
709
  subparsers = parser.add_subparsers(required=True)
446
710
  for name, (_, info) in functions_info.items():
447
711
  subparser = subparsers.add_parser(name, help=info.docstring.description)
448
- _add_arguments(subparser, info)
712
+ groups = _resolve_command_groups(
713
+ mutually_exclusive, name, set(info.arguments)
714
+ )
715
+ _add_arguments(subparser, info, exclusive_groups=groups)
449
716
  subparser.set_defaults(func=info.function)
450
717
  cli_args = parser.parse_args()
451
718
  args = []
@@ -456,6 +723,7 @@ def _argumentify(functions: _Dict[str, Callable]) -> None:
456
723
  break
457
724
  else:
458
725
  raise RuntimeError('No function found for the given arguments.')
726
+ _apply_repeatable_defaults(cli_args, info)
459
727
  for argument in info.arguments.values():
460
728
  if argument.kind in (_inspect._ParameterKind.POSITIONAL_ONLY,
461
729
  _inspect._ParameterKind.POSITIONAL_OR_KEYWORD):
@@ -474,7 +742,8 @@ def _argumentify(functions: _Dict[str, Callable]) -> None:
474
742
 
475
743
 
476
744
  def argumentify(
477
- entry_point: _Union[Callable, _List[Callable], _Dict[str, Callable]]
745
+ entry_point: _Union[Callable, _List[Callable], _Dict[str, Callable]],
746
+ mutually_exclusive: _Optional[_Any] = None
478
747
  ) -> _Union[Callable, _List[Callable], _Dict[str, Callable]]:
479
748
  """This function argumentifies one or more functions as the entry point of the
480
749
  script.
@@ -498,15 +767,48 @@ def argumentify(
498
767
  $ python argumentified.py Mehrad Pooryoussof
499
768
  Mehrad Pooryoussof is not yet born.
500
769
 
770
+ Parameters annotated with `list[T]` accept multiple values:
771
+ positional-only ones take several values (`items: list[str], /` called as
772
+ `prog a b c`), while other parameters become repeatable `--flag` options
773
+ (`defines: list[str]` called as `prog --defines a=1 --defines b=2`, each
774
+ occurrence converted to `T`). A declared default (e.g. `= []`) is used
775
+ when the argument is omitted. As with other parameters, if the function
776
+ also defines keyword-only parameters, positional-or-keyword `list[T]`
777
+ parameters become required flags; declare them keyword-only (after `*`)
778
+ to keep them optional.
779
+
780
+ Mutually exclusive parameters can be declared with `mutually_exclusive`.
781
+ Each group is a list of parameter names or a
782
+ `{"names": [...], "required": bool}` mapping (`required=True` means
783
+ exactly one of them must be given):
784
+
785
+ def run(recipe_path: str = None, /, *, builtin: str = None) -> None: ...
786
+ argumentify(run, mutually_exclusive=[["recipe_path", "builtin"]])
787
+
788
+ For multiple entry points, pass a `{command_name: groups}` mapping, or a
789
+ plain list of groups which applies to every command defining all named
790
+ parameters. Positional parameters in a group accept zero values (single
791
+ ones behave like `nargs='?'`, multi-value ones like `nargs='*'`) and
792
+ receive their default (or `None`) when omitted; pass
793
+ `{"names": [...], "required": True}` when exactly one of them must be
794
+ given:
795
+
796
+ argumentify(
797
+ {"run-recipe": run_recipe},
798
+ mutually_exclusive={"run-recipe": [["recipe_path", "builtin"]]},
799
+ )
800
+
501
801
  :param entry_point: The function(s) to argumentify.
802
+ :param mutually_exclusive: Mutually exclusive parameter groups.
502
803
  :raises TypeError: A function must be a function or a list of functions or a
503
804
  dictionary of functions.
805
+ :raises ValueError: A group is malformed or references unknown parameters.
504
806
  """
505
807
 
506
808
  functions = {}
507
809
  # Check the types
508
810
  if callable(entry_point):
509
- _argumentify_one(entry_point)
811
+ _argumentify_one(entry_point, mutually_exclusive)
510
812
  return entry_point
511
813
  if isinstance(entry_point, _List):
512
814
  for func in entry_point:
@@ -530,5 +832,5 @@ def argumentify(
530
832
  "dictionary of functions."
531
833
  )
532
834
 
533
- _argumentify(functions)
835
+ _argumentify(functions, mutually_exclusive)
534
836
  return entry_point
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