cli-command-parser 2025.9.27__tar.gz → 2026.2.1__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.
Files changed (70) hide show
  1. {cli_command_parser-2025.9.27/lib/cli_command_parser.egg-info → cli_command_parser-2026.2.1}/PKG-INFO +7 -10
  2. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/__version__.py +1 -1
  3. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/annotations.py +7 -11
  4. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/command_parameters.py +52 -48
  5. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/config.py +8 -8
  6. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/context.py +12 -12
  7. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/argparse_ast.py +5 -5
  8. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/command_builder.py +4 -4
  9. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/utils.py +3 -3
  10. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/visitor.py +2 -2
  11. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/core.py +8 -8
  12. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/error_handling/base.py +4 -4
  13. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/exceptions.py +3 -3
  14. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/formatting/commands.py +3 -3
  15. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/formatting/restructured_text.py +2 -2
  16. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/formatting/utils.py +2 -2
  17. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/__init__.py +19 -25
  18. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/base.py +11 -2
  19. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/choices.py +3 -3
  20. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/files.py +4 -5
  21. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/numeric.py +127 -13
  22. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/patterns.py +7 -7
  23. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/time.py +33 -37
  24. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/utils.py +3 -3
  25. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/metadata.py +7 -6
  26. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/nargs.py +63 -58
  27. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/base.py +17 -11
  28. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/choice_map.py +11 -8
  29. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/groups.py +8 -2
  30. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/option_strings.py +7 -7
  31. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/options.py +4 -4
  32. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parse_tree.py +9 -10
  33. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parser.py +1 -0
  34. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/testing.py +8 -8
  35. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1/lib/cli_command_parser.egg-info}/PKG-INFO +7 -10
  36. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser.egg-info/SOURCES.txt +0 -2
  37. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/pyproject.toml +54 -1
  38. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/readme.rst +3 -3
  39. cli_command_parser-2026.2.1/requirements-dev.txt +16 -0
  40. cli_command_parser-2026.2.1/setup.cfg +4 -0
  41. cli_command_parser-2025.9.27/lib/cli_command_parser.egg-info/entry_points.txt +0 -2
  42. cli_command_parser-2025.9.27/requirements-dev.txt +0 -12
  43. cli_command_parser-2025.9.27/setup.cfg +0 -50
  44. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/LICENSE +0 -0
  45. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/MANIFEST.in +0 -0
  46. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/__init__.py +0 -0
  47. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/__main__.py +0 -0
  48. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/commands.py +0 -0
  49. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/compat.py +0 -0
  50. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/__init__.py +0 -0
  51. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/__main__.py +0 -0
  52. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/argparse_utils.py +0 -0
  53. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/conversion/cli.py +0 -0
  54. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/documentation.py +0 -0
  55. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/error_handling/__init__.py +0 -0
  56. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/error_handling/other.py +0 -0
  57. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/error_handling/windows.py +0 -0
  58. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/formatting/__init__.py +0 -0
  59. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/formatting/params.py +0 -0
  60. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/inputs/exceptions.py +0 -0
  61. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/__init__.py +0 -0
  62. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/actions.py +0 -0
  63. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/pass_thru.py +0 -0
  64. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/parameters/positionals.py +0 -0
  65. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/typing.py +0 -0
  66. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser/utils.py +0 -0
  67. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser.egg-info/dependency_links.txt +0 -0
  68. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1/lib/cli_command_parser.egg-info}/entry_points.txt +0 -0
  69. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser.egg-info/requires.txt +0 -0
  70. {cli_command_parser-2025.9.27 → cli_command_parser-2026.2.1}/lib/cli_command_parser.egg-info/top_level.txt +0 -0
@@ -1,11 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli_command_parser
3
- Version: 2025.9.27
3
+ Version: 2026.2.1
4
4
  Summary: CLI Command Parser
5
- Home-page: https://github.com/dskrypa/cli_command_parser
6
- Author: Doug Skrypa
7
- Author-email: dskrypa@gmail.com
8
- License: Apache 2.0
5
+ Author-email: Doug Skrypa <dskrypa@gmail.com>
9
6
  Project-URL: Source, https://github.com/dskrypa/cli_command_parser
10
7
  Project-URL: Documentation, https://dskrypa.github.io/cli_command_parser
11
8
  Project-URL: Issues, https://github.com/dskrypa/cli_command_parser/issues
@@ -16,14 +13,14 @@ Classifier: License :: OSI Approved :: Apache Software License
16
13
  Classifier: Operating System :: OS Independent
17
14
  Classifier: Programming Language :: Python
18
15
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.9
20
16
  Classifier: Programming Language :: Python :: 3.10
21
17
  Classifier: Programming Language :: Python :: 3.11
22
18
  Classifier: Programming Language :: Python :: 3.12
23
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
24
21
  Classifier: Topic :: Software Development :: User Interfaces
25
22
  Classifier: Topic :: Text Processing
26
- Requires-Python: >=3.9
23
+ Requires-Python: >=3.10
27
24
  Description-Content-Type: text/x-rst
28
25
  License-File: LICENSE
29
26
  Provides-Extra: wcwidth
@@ -35,7 +32,7 @@ CLI Command Parser
35
32
 
36
33
  |downloads| |py_version| |coverage_badge| |build_status| |Ruff| |OpenSSF Best Practices|
37
34
 
38
- .. |py_version| image:: https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20-blue
35
+ .. |py_version| image:: https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14%20-blue
39
36
  :target: https://pypi.org/project/cli-command-parser/
40
37
 
41
38
  .. |coverage_badge| image:: https://codecov.io/gh/dskrypa/cli_command_parser/branch/main/graph/badge.svg
@@ -120,8 +117,8 @@ with optional dependencies::
120
117
  Python Version Compatibility
121
118
  ============================
122
119
 
123
- Python versions 3.9 and above are currently supported. The last release of CLI Command Parser that supported 3.8 was
124
- 2024-09-07. Support for Python 3.8 `officially ended on 2024-10-07 <https://devguide.python.org/versions/>`__.
120
+ Python versions 3.10 and above are currently supported. The last release of CLI Command Parser that supported 3.9 was
121
+ 2025-09-27. Support for Python 3.9 `officially ended on 2025-10-31 <https://devguide.python.org/versions/>`__.
125
122
 
126
123
 
127
124
  Links
@@ -1,7 +1,7 @@
1
1
  __title__ = 'cli_command_parser'
2
2
  __description__ = 'CLI Command Parser'
3
3
  __url__ = 'https://github.com/dskrypa/cli_command_parser'
4
- __version__ = '2025.09.27'
4
+ __version__ = '2026.02.01'
5
5
  __author__ = 'Doug Skrypa'
6
6
  __author_email__ = 'dskrypa@gmail.com'
7
7
  __license__ = 'Apache 2.0'
@@ -7,19 +7,15 @@ Utilities for extracting types from annotations.
7
7
  from collections.abc import Collection, Iterable
8
8
  from functools import lru_cache
9
9
  from inspect import isclass
10
- from typing import Optional, Union, get_args, get_origin, get_type_hints as _get_type_hints
11
-
12
- try:
13
- from types import NoneType
14
- except ImportError: # Added in 3.10
15
- NoneType = type(None)
10
+ from types import NoneType, UnionType
11
+ from typing import Union, get_args, get_origin, get_type_hints as _get_type_hints
16
12
 
17
13
  __all__ = ['get_descriptor_value_type']
18
14
 
19
15
  get_type_hints = lru_cache()(_get_type_hints) # Cache the attr:annotation mapping for each Command class
20
16
 
21
17
 
22
- def get_descriptor_value_type(command_cls: type, attr: str) -> Optional[type]:
18
+ def get_descriptor_value_type(command_cls: type, attr: str) -> type | None:
23
19
  try:
24
20
  annotation = get_type_hints(command_cls)[attr]
25
21
  except (KeyError, NameError): # KeyError due to attr missing; NameError for forward references
@@ -30,19 +26,19 @@ def get_descriptor_value_type(command_cls: type, attr: str) -> Optional[type]:
30
26
  return get_annotation_value_type(annotation)
31
27
 
32
28
 
33
- def get_annotation_value_type(annotation, from_union: bool = True, from_collection: bool = True) -> Optional[type]:
29
+ def get_annotation_value_type(annotation, from_union: bool = True, from_collection: bool = True) -> type | None:
34
30
  origin = get_origin(annotation)
35
31
  # Note: get_origin returns `list` for `List[str]`, `List`, and `list[str]`; it returns `None` for `list`
36
32
  if origin is None and isinstance(annotation, type):
37
33
  return annotation
38
34
  elif from_collection and isclass(origin) and issubclass(origin, (Collection, Iterable)):
39
35
  return _type_from_collection(origin, annotation)
40
- elif from_union and origin is Union:
36
+ elif from_union and (origin is Union or origin is UnionType):
41
37
  return _type_from_union(annotation)
42
38
  return None
43
39
 
44
40
 
45
- def _type_from_union(annotation) -> Optional[type]:
41
+ def _type_from_union(annotation) -> type | None:
46
42
  args = get_args(annotation)
47
43
  # Note: Unions of a single argument return the argument; i.e., Union[T] returns T, so the len can never be 1
48
44
  if len(args) == 2 and NoneType in args:
@@ -50,7 +46,7 @@ def _type_from_union(annotation) -> Optional[type]:
50
46
  return None
51
47
 
52
48
 
53
- def _type_from_collection(origin, annotation) -> Optional[type]:
49
+ def _type_from_collection(origin, annotation) -> type | None:
54
50
  if not (args := get_args(annotation)):
55
51
  return origin # The annotation was a collection with no content types specified
56
52
  n_args = len(args)
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  from collections import defaultdict
14
14
  from functools import cached_property
15
- from typing import TYPE_CHECKING, Collection, Iterator, Optional
15
+ from typing import TYPE_CHECKING, Collection, Iterator
16
16
 
17
17
  from .config import AmbiguousComboMode, CommandConfig
18
18
  from .exceptions import AmbiguousCombo, AmbiguousShortForm, CommandDefinitionError, ParameterDefinitionError
@@ -34,11 +34,10 @@ class CommandParameters:
34
34
  # fmt: off
35
35
  command: CommandCls #: The Command associated with this CommandParameters object
36
36
  formatter: CommandHelpFormatter #: The formatter used for this Command's help text
37
- command_parent: Optional[CommandCls] #: The parent Command, if any
38
- parent: Optional[CommandParameters] #: The parent Command's CommandParameters
39
- action: Optional[Action] = None #: An Action Parameter, if specified
40
- _pass_thru: Optional[PassThru] = None #: A PassThru Parameter, if specified
41
- sub_command: Optional[SubCommand] = None #: A SubCommand Parameter, if specified
37
+ parent: CommandParameters | None #: The parent Command's CommandParameters
38
+ action: Action | None = None #: An Action Parameter, if specified
39
+ _pass_thru: PassThru | None = None #: A PassThru Parameter, if specified
40
+ sub_command: SubCommand | None = None #: A SubCommand Parameter, if specified
42
41
  action_flags: ActionFlags #: List of action flags
43
42
  split_action_flags: tuple[ActionFlags, ActionFlags] #: Action flags split by before/after main
44
43
  options: list[BaseOption] #: List of optional Parameters
@@ -49,15 +48,8 @@ class CommandParameters:
49
48
  option_map: OptionMap #: Mapping of {--opt / -opt: Parameter}
50
49
  # fmt: on
51
50
 
52
- def __init__(
53
- self,
54
- command: CommandCls,
55
- command_parent: Optional[CommandCls],
56
- parent_params: Optional[CommandParameters],
57
- config: CommandConfig,
58
- ):
51
+ def __init__(self, command: CommandCls, parent_params: CommandParameters | None, config: CommandConfig):
59
52
  self.command = command
60
- self.command_parent = command_parent
61
53
  self.parent = parent_params
62
54
  self.config = config
63
55
  self._process_parameters()
@@ -65,13 +57,12 @@ class CommandParameters:
65
57
  def __repr__(self) -> str:
66
58
  positionals = len(self.positionals)
67
59
  options = len(self.options)
68
- cls_name = self.__class__.__name__
69
- return f'<{cls_name}[command={self.command.__name__}, {positionals=}, {options=}]>'
60
+ return f'<{self.__class__.__name__}[command={self.command.__name__}, {positionals=}, {options=}]>'
70
61
 
71
62
  # region PassThru Properties
72
63
 
73
64
  @property
74
- def pass_thru(self) -> Optional[PassThru]:
65
+ def pass_thru(self) -> PassThru | None:
75
66
  if self._pass_thru:
76
67
  return self._pass_thru
77
68
  elif self.parent:
@@ -143,29 +134,32 @@ class CommandParameters:
143
134
  groups = set()
144
135
 
145
136
  for param in self._iter_parameters():
146
- if isinstance(param, BasePositional):
147
- positionals.append(param)
148
- elif isinstance(param, BaseOption):
149
- options.append(param)
150
- elif isinstance(param, ParamGroup):
151
- # Groups will only be discovered here when defined with `as` - ex: `with ParamGroup(...) as foo:`
152
- # Group members will always be discovered at the top level since context managers share the outer scope
153
- groups.add(param)
154
- elif isinstance(param, PassThru):
155
- if self.pass_thru:
156
- raise CommandDefinitionError(f'Invalid PassThru {param=} - it cannot follow another PassThru param')
157
- self._pass_thru = param
158
- else:
159
- raise CommandDefinitionError(
160
- f'Unexpected type={param.__class__} for {param=} - custom parameters must extend'
161
- ' BasePositional, BaseOption, or ParamGroup'
162
- )
137
+ match param:
138
+ case BasePositional():
139
+ positionals.append(param)
140
+ case BaseOption():
141
+ options.append(param)
142
+ case ParamGroup():
143
+ # Groups will only be discovered here when defined with `as` - ex: `with ParamGroup(...) as foo:`
144
+ # Members will always be discovered at the top level since context managers share the outer scope
145
+ groups.add(param)
146
+ case PassThru():
147
+ if self.pass_thru:
148
+ raise CommandDefinitionError(
149
+ f'Invalid PassThru {param=} - it cannot follow another PassThru param'
150
+ )
151
+ self._pass_thru = param
152
+ case _:
153
+ raise CommandDefinitionError(
154
+ f'Unexpected type={param.__class__} for {param=} - custom parameters must extend'
155
+ ' BasePositional, BaseOption, or ParamGroup'
156
+ )
163
157
 
164
158
  param_group = param
165
159
  while param_group := param_group.group:
166
160
  groups.add(param_group)
167
161
 
168
- if self.config.add_help and self.command_parent is not None and (not self.parent or not self.parent._has_help):
162
+ if self.config.add_help and self.parent is not None and not self.parent._has_help:
169
163
  options.append(help_action)
170
164
 
171
165
  self._process_positionals(positionals)
@@ -204,7 +198,7 @@ class CommandParameters:
204
198
  if param.has_choices and 0 in param.nargs: # It has local choices or is not required
205
199
  unfollowable = param
206
200
  else: # It's an Action
207
- self.action = action_or_sub_cmd = param
201
+ self.action = action_or_sub_cmd = param # type: ignore
208
202
  if not param.has_choices:
209
203
  raise CommandDefinitionError(f'No choices were registered for {self.action}')
210
204
  elif 0 in param.nargs or (param.nargs.variable and not param.has_choices):
@@ -264,7 +258,7 @@ class CommandParameters:
264
258
  for param in action_flags:
265
259
  if param.func is None:
266
260
  raise ParameterDefinitionError(f'No function was registered for {param=}')
267
- grouped_ordered_flags[param.before_main][param.order].append(param) # noqa # PyCharm infers the wrong type
261
+ grouped_ordered_flags[param.before_main][param.order].append(param)
268
262
 
269
263
  found_non_always = False
270
264
  invalid = {}
@@ -301,6 +295,7 @@ class CommandParameters:
301
295
 
302
296
  @cached_property
303
297
  def _classified_combo_options(self) -> tuple[OptionMap, OptionMap]:
298
+ """Tuple of (single char short:Option map, multi-char short:Option map) for options available in this command"""
304
299
  multi_char_combos = {}
305
300
  items = self.combo_option_map.items()
306
301
  for combo, param in items:
@@ -315,14 +310,9 @@ class CommandParameters:
315
310
 
316
311
  @cached_property
317
312
  def _nested_potentially_ambiguous_combo_options(self):
318
- single_char_combos, multi_char_combos = (xcc.copy() for xcc in self._classified_combo_options)
319
- for params in self._iter_nested_params():
320
- nested_single_char_combos, nested_multi_char_combos = params._classified_combo_options
321
- single_char_combos.update(nested_single_char_combos)
322
- multi_char_combos.update(nested_multi_char_combos)
323
- return _find_ambiguous_combos(single_char_combos, multi_char_combos)
313
+ return _find_ambiguous_combos(*self.nested_single_and_multi_char_short_options)
324
314
 
325
- def _is_combo_potentially_ambiguous(self, option: str) -> Optional[bool]:
315
+ def _is_combo_potentially_ambiguous(self, option: str) -> bool | None:
326
316
  # Called by short_option_to_param_value_pairs after ensuring the length is > 1
327
317
  to_check = option[1:] # Strip leading '-'
328
318
  # Note: len(to_check) will never be 2 here - this is only called if len(option) > 2
@@ -345,21 +335,34 @@ class CommandParameters:
345
335
 
346
336
  # endregion
347
337
 
338
+ @cached_property
339
+ def nested_single_and_multi_char_short_options(self) -> tuple[OptionMap, OptionMap]:
340
+ single_char_shorts, multi_char_shorts = (xcs.copy() for xcs in self._classified_combo_options)
341
+ for params in self._iter_nested_params():
342
+ nested_single_char_shorts, nested_multi_char_shorts = params._classified_combo_options
343
+ single_char_shorts |= nested_single_char_shorts
344
+ multi_char_shorts |= nested_multi_char_shorts
345
+
346
+ return single_char_shorts, multi_char_shorts
347
+
348
348
  def _iter_nested_params(self) -> Iterator[CommandParameters]:
349
349
  if not self.sub_command:
350
350
  return
351
+
351
352
  get_params = self.command.__class__.params
353
+ seen = set()
352
354
  for choice in self.sub_command.choices.values():
353
- if choice.target is not None: # None indicates it's a subcommand's local choice
355
+ # choice.target is the (sub-)Command class that will be used if that choice was selected. Being None
356
+ # indicates it's a subcommand's local choice (i.e., get_params would return this CommandParameters object)
357
+ if choice.target is not None and choice.target not in seen:
358
+ seen.add(choice.target) # Some choices may be aliases for the same target Command
354
359
  params: CommandParameters = get_params(choice.target)
355
360
  yield params
356
361
  yield from params._iter_nested_params()
357
362
 
358
363
  # region Option Processing
359
364
 
360
- def short_option_to_param_value_pairs(
361
- self, option: str
362
- ) -> tuple[list[tuple[str, BaseOption, Optional[str]]], bool]:
365
+ def short_option_to_param_value_pairs(self, option: str) -> tuple[list[tuple[str, BaseOption, str | None]], bool]:
363
366
  option, eq, value = option.partition('=')
364
367
  if eq: # An `=` was present in the string
365
368
  # Note: if the option is not in this Command's option_map, the KeyError is handled by CommandParser
@@ -393,6 +396,7 @@ class CommandParameters:
393
396
  else:
394
397
  yield from self.all_positionals
395
398
  yield from self.options
399
+
396
400
  if self.pass_thru and self.pass_thru not in exclude:
397
401
  yield self.pass_thru
398
402
 
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from collections import ChainMap
10
10
  from enum import Enum
11
11
  from string import whitespace
12
- from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, Sequence, Type, TypeVar, Union, overload
12
+ from typing import TYPE_CHECKING, Any, Callable, Generic, Sequence, Type, TypeVar, overload
13
13
 
14
14
  from .exceptions import CommandDefinitionError
15
15
  from .utils import FixedFlag, MissingMixin, _NotSet, positive_int
@@ -33,7 +33,7 @@ __all__ = [
33
33
 
34
34
  CV = TypeVar('CV')
35
35
  DV = TypeVar('DV')
36
- ConfigValue = Union[CV, DV]
36
+ ConfigValue = CV | DV
37
37
 
38
38
 
39
39
  # region Config Option Enums
@@ -60,7 +60,7 @@ class ShowDefaults(FixedFlag):
60
60
  # fmt: on
61
61
 
62
62
  @classmethod
63
- def _missing_(cls, value: Union[str, int]) -> ShowDefaults:
63
+ def _missing_(cls, value: str | int) -> ShowDefaults:
64
64
  if isinstance(value, str):
65
65
  try:
66
66
  return cls._member_map_[value.upper().replace('-', '_')] # noqa
@@ -119,7 +119,7 @@ class OptionNameMode(FixedFlag):
119
119
  # fmt: on
120
120
 
121
121
  @classmethod
122
- def _missing_(cls, value: Union[str, int, None]) -> OptionNameMode:
122
+ def _missing_(cls, value: str | int | None) -> OptionNameMode:
123
123
  try:
124
124
  return OPT_NAME_MODE_ALIASES[value]
125
125
  except KeyError:
@@ -166,7 +166,7 @@ class SubcommandAliasHelpMode(MissingMixin, Enum):
166
166
  # fmt: on
167
167
 
168
168
 
169
- CmdAliasMode = Union[SubcommandAliasHelpMode, str]
169
+ CmdAliasMode = SubcommandAliasHelpMode | str
170
170
 
171
171
 
172
172
  class AmbiguousComboMode(MissingMixin, Enum):
@@ -313,7 +313,7 @@ class CommandConfig:
313
313
  # region Error Handling Options
314
314
 
315
315
  #: The :class:`.ErrorHandler` to be used by :meth:`.Command.__call__`
316
- error_handler: Optional[ErrorHandler] = ConfigItem(_NotSet)
316
+ error_handler: ErrorHandler | None = ConfigItem(_NotSet)
317
317
 
318
318
  #: Whether :meth:`.Command._after_main_` should always be called, even if an exception was raised in
319
319
  #: :meth:`.Command.main` (similar to a ``finally`` block)
@@ -443,7 +443,7 @@ class CommandConfig:
443
443
  strict_usage_column_width: bool = ConfigItem(False, bool)
444
444
 
445
445
  @config_item(False)
446
- def wrap_usage_str(self, value: Any) -> Union[int, bool]:
446
+ def wrap_usage_str(self, value: Any) -> int | bool:
447
447
  """
448
448
  Wrap the basic usage line after the specified number of characters, or automatically based on terminal size
449
449
  if ``True`` is specified instead.
@@ -467,7 +467,7 @@ class CommandConfig:
467
467
 
468
468
  # endregion
469
469
 
470
- def __init__(self, parent: Optional[CommandConfig] = None, read_only: bool = False, **kwargs):
470
+ def __init__(self, parent: CommandConfig | None = None, read_only: bool = False, **kwargs):
471
471
  self._data = parent._data.new_child() if parent else ChainMap()
472
472
  self._read_only = read_only
473
473
  if kwargs:
@@ -14,7 +14,7 @@ from contextvars import ContextVar
14
14
  from enum import Enum
15
15
  from functools import cached_property
16
16
  from inspect import Parameter as _Parameter, Signature
17
- from typing import TYPE_CHECKING, Any, Callable, Collection, Iterator, Optional, Sequence, Union, cast
17
+ from typing import TYPE_CHECKING, Any, Callable, Collection, Iterator, Sequence, cast
18
18
 
19
19
  from .config import DEFAULT_CONFIG, CommandConfig
20
20
  from .error_handling import ErrorHandler, NullErrorHandler, extended_error_handler
@@ -24,16 +24,16 @@ from .utils import Terminal, _NotSet
24
24
  if TYPE_CHECKING:
25
25
  from .command_parameters import CommandParameters
26
26
  from .commands import Command
27
- from .parameters import ActionFlag, BaseOption, Option, Parameter
27
+ from .parameters import ActionFlag, BaseOption, Parameter
28
28
  from .typing import AnyConfig, Bool, CommandObj, CommandType, OptStr, ParamOrGroup, PathLike, StrSeq # noqa
29
29
 
30
+ Argv = StrSeq | None
31
+
30
32
  __all__ = ['Context', 'ctx', 'get_current_context', 'get_or_create_context', 'get_context', 'get_parsed', 'get_raw_arg']
31
33
 
32
34
  _context_stack = ContextVar('cli_command_parser.context.stack')
33
35
  _TERMINAL = Terminal()
34
36
 
35
- Argv = Optional['StrSeq']
36
-
37
37
 
38
38
  class Context(AbstractContextManager): # Extending AbstractContextManager to make PyCharm's type checker happy
39
39
  """
@@ -47,19 +47,19 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
47
47
  prog: OptStr = None
48
48
  allow_argv_prog: Bool = True
49
49
  _command_obj: CommandObj = None
50
- _terminal_width: Optional[int]
50
+ _terminal_width: int | None
51
51
  _provided: dict[ParamOrGroup, int]
52
52
 
53
53
  def __init__(
54
54
  self,
55
55
  argv: Argv = None,
56
- command_cls: Optional[CommandType] = None,
56
+ command_cls: CommandType | None = None,
57
57
  *,
58
- parent: Optional[Context] = None,
58
+ parent: Context | None = None,
59
59
  config: AnyConfig = None,
60
60
  terminal_width: int = None,
61
61
  allow_argv_prog: Bool = None,
62
- command: Optional[CommandObj] = None,
62
+ command: CommandObj | None = None,
63
63
  **kwargs,
64
64
  ):
65
65
  self.command_cls = command_cls
@@ -125,7 +125,7 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
125
125
  def __exit__(self, exc_type, exc_val, exc_tb):
126
126
  _context_stack.get().pop()
127
127
 
128
- def __contains__(self, param: Union[ParamOrGroup, str, Any]) -> bool:
128
+ def __contains__(self, param: ParamOrGroup | str | Any) -> bool:
129
129
  try:
130
130
  self._parsed[param]
131
131
  except KeyError:
@@ -193,7 +193,7 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
193
193
  return parsed
194
194
 
195
195
  @cached_property
196
- def params(self) -> Optional[CommandParameters]:
196
+ def params(self) -> CommandParameters | None:
197
197
  """
198
198
  The :class:`.CommandParameters` object that contains the categorized Parameters from the Command associated
199
199
  with this Context.
@@ -202,7 +202,7 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
202
202
  return self.command_cls.__class__.params(self.command_cls)
203
203
  return None
204
204
 
205
- def get_error_handler(self) -> Union[ErrorHandler, NullErrorHandler]:
205
+ def get_error_handler(self) -> ErrorHandler | NullErrorHandler:
206
206
  """Returns the :class:`.ErrorHandler` configured to be used."""
207
207
  if (error_handler := self.config.error_handler) is _NotSet:
208
208
  return extended_error_handler
@@ -426,7 +426,7 @@ ctx: Context = cast(Context, ContextProxy())
426
426
  # region Public / Semi-Public Functions
427
427
 
428
428
 
429
- def get_current_context(silent: bool = False) -> Optional[Context]:
429
+ def get_current_context(silent: bool = False) -> Context | None:
430
430
  """
431
431
  Get the currently active parsing context.
432
432
 
@@ -8,7 +8,7 @@ from ast import AST, Assign, Call, withitem
8
8
  from functools import cached_property, partial
9
9
  from inspect import BoundArguments, Signature
10
10
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Callable, Collection, Generic, Iterator, Type, TypeVar, Union
11
+ from typing import TYPE_CHECKING, Callable, Collection, Generic, Iterator, Type, TypeVar
12
12
 
13
13
  from .argparse_utils import ArgumentParser as _ArgumentParser, SubParsersAction as _SubParsersAction
14
14
  from .utils import get_name_repr, iter_module_parents, unparse
@@ -21,8 +21,8 @@ if TYPE_CHECKING:
21
21
  __all__ = ['ParserArg', 'ArgGroup', 'MutuallyExclusiveGroup', 'AstArgumentParser', 'SubParser', 'Script']
22
22
  log = logging.getLogger(__name__)
23
23
 
24
- InitNode = Union[Call, Assign, withitem]
25
- OptCall = Union[Call, None]
24
+ InitNode = Call | Assign | withitem
25
+ OptCall = Call | None
26
26
  ParserCls = Type['AstArgumentParser']
27
27
  ParserObj = TypeVar('ParserObj', bound='AstArgumentParser')
28
28
  RepresentedCallable = TypeVar('RepresentedCallable', bound=Callable)
@@ -33,7 +33,7 @@ _NotSet = object()
33
33
 
34
34
  class Script:
35
35
  _parser_classes = {}
36
- path: Union[Path, None]
36
+ path: Path | None
37
37
 
38
38
  def __init__(self, src_text: str, smart_loop_handling: bool = True, path: PathLike = None):
39
39
  self.smart_loop_handling = smart_loop_handling
@@ -162,7 +162,7 @@ class AstCallable:
162
162
  def __repr__(self) -> str:
163
163
  return f'<{self.__class__.__name__}[{self.init_call_repr()}]>'
164
164
 
165
- def get_tracked_refs(self, module: str, name: str, default: D = _NotSet) -> Union[set[str], D]:
165
+ def get_tracked_refs(self, module: str, name: str, default: D = _NotSet) -> set[str] | D:
166
166
  for tracked_ref, refs in self._tracked_refs.items():
167
167
  if tracked_ref.module == module and tracked_ref.name == name:
168
168
  return refs
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  import keyword
4
4
  import logging
5
5
  from abc import ABC, abstractmethod
6
- from ast import Attribute, Constant, DictComp, GeneratorExp, ListComp, Name, SetComp, Str, Subscript, literal_eval
6
+ from ast import Attribute, Constant, DictComp, GeneratorExp, ListComp, Name, SetComp, Subscript, literal_eval
7
7
  from dataclasses import dataclass, fields
8
8
  from functools import cached_property
9
9
  from itertools import count
10
- from typing import TYPE_CHECKING, Generic, Iterable, Iterator, Optional, Type, TypeVar, Union
10
+ from typing import TYPE_CHECKING, Generic, Iterable, Iterator, Type, TypeVar
11
11
 
12
12
  from cli_command_parser.nargs import Nargs
13
13
 
@@ -45,12 +45,12 @@ class Converter(Generic[AC], ABC):
45
45
  if newline_between_members is not None:
46
46
  cls.newline_between_members = newline_between_members
47
47
 
48
- def __init__(self, ast_obj: Union[AC, Script], parent: Optional[Converter] = None):
48
+ def __init__(self, ast_obj: AC | Script, parent: Converter | None = None):
49
49
  self.ast_obj = ast_obj
50
50
  self.parent = parent
51
51
 
52
52
  @classmethod
53
- def for_ast_callable(cls, ast_obj: Union[AC, Type[AC]]) -> Type[Converter[AC]]:
53
+ def for_ast_callable(cls, ast_obj: AC | Type[AC]) -> Type[Converter[AC]]:
54
54
  if not isinstance(ast_obj, type):
55
55
  ast_obj = ast_obj.__class__
56
56
  try:
@@ -1,12 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from ast import AST, Attribute, Call, Dict, List, Name, Set, Tuple, expr, unparse
4
- from typing import Iterator, List as _List, Union
4
+ from typing import Iterator
5
5
 
6
6
  __all__ = ['get_name_repr', 'iter_module_parents', 'collection_contents']
7
7
 
8
8
 
9
- def get_name_repr(node: Union[AST, expr]) -> str:
9
+ def get_name_repr(node: AST | expr) -> str:
10
10
  if isinstance(node, Call):
11
11
  node = node.func
12
12
 
@@ -29,7 +29,7 @@ def iter_module_parents(module: str) -> Iterator[str]:
29
29
  module = parent
30
30
 
31
31
 
32
- def collection_contents(node: AST) -> _List[str]:
32
+ def collection_contents(node: AST) -> list[str]:
33
33
  if isinstance(node, Dict):
34
34
  return [unparse(key) for key in node.keys] # noqa
35
35
  elif isinstance(node, (List, Set, Tuple)):
@@ -5,7 +5,7 @@ import re
5
5
  from ast import AST, Assign, Attribute, Call, For, Import, ImportFrom, Name, NodeVisitor, expr
6
6
  from collections import ChainMap, defaultdict
7
7
  from functools import partial, wraps
8
- from typing import TYPE_CHECKING, Callable, Collection, Iterator, Union
8
+ from typing import TYPE_CHECKING, Callable, Collection, Iterator
9
9
 
10
10
  from .argparse_ast import AstArgumentParser
11
11
  from .utils import get_name_repr
@@ -156,7 +156,7 @@ class ScriptVisitor(NodeVisitor):
156
156
 
157
157
  # endregion
158
158
 
159
- def resolve_ref(self, name: Union[str, AST, Attribute, Name, expr]):
159
+ def resolve_ref(self, name: str | AST | Attribute | Name | expr):
160
160
  if isinstance(name, Attribute) and isinstance(name.value, Call):
161
161
  obj = self.visit_Call(name.value)
162
162
  attr = name.attr
@@ -8,7 +8,7 @@ top-level Command.
8
8
  from __future__ import annotations
9
9
 
10
10
  from abc import ABC, ABCMeta
11
- from typing import TYPE_CHECKING, Any, Callable, Collection, Iterable, Iterator, Mapping, Optional, TypeVar, Union
11
+ from typing import TYPE_CHECKING, Any, Callable, Collection, Iterable, Iterator, Mapping, TypeVar
12
12
  from warnings import warn
13
13
  from weakref import WeakSet
14
14
 
@@ -20,9 +20,9 @@ from .metadata import ProgramMetadata
20
20
  if TYPE_CHECKING:
21
21
  from .typing import AnyConfig, CommandAny, CommandCls, Config, OptStr
22
22
 
23
- Bases = Union[tuple[type, ...], Iterable[type]]
24
- Choices = Union[Mapping[str, Optional[str]], Collection[str]]
25
- OptChoices = Optional[Choices]
23
+ Bases = tuple[type, ...] | Iterable[type]
24
+ Choices = Mapping[str, str | None] | Collection[str]
25
+ OptChoices = Choices | None
26
26
  T = TypeVar('T')
27
27
 
28
28
  __all__ = ['CommandMeta', 'get_parent', 'get_config', 'get_params', 'get_metadata', 'get_top_level_commands']
@@ -134,7 +134,7 @@ class CommandMeta(ABCMeta, type):
134
134
  _no_choices_registered_warning(choice, choices, cls, 'it has no parent Command')
135
135
 
136
136
  @classmethod
137
- def _from_parent(mcs, meth: Callable[[CommandCls], T], bases: Bases) -> Optional[T]:
137
+ def _from_parent(mcs, meth: Callable[[CommandCls], T], bases: Bases) -> T | None:
138
138
  for base in bases:
139
139
  if isinstance(base, mcs):
140
140
  return meth(base)
@@ -159,7 +159,7 @@ class CommandMeta(ABCMeta, type):
159
159
  return None
160
160
 
161
161
  @classmethod
162
- def config(mcs, cls: CommandAny, default: T = None) -> Union[CommandConfig, T]:
162
+ def config(mcs, cls: CommandAny, default: T = None) -> CommandConfig | T:
163
163
  try:
164
164
  return cls.__config # This attr is not overwritten for every subclass
165
165
  except AttributeError: # This means that the Command and all of its parents have no custom config
@@ -170,7 +170,7 @@ class CommandMeta(ABCMeta, type):
170
170
  # region Metaclass-Managed Command Attributes
171
171
 
172
172
  @classmethod
173
- def parent(mcs, cls: CommandAny, include_abc: bool = True) -> Optional[CommandCls]:
173
+ def parent(mcs, cls: CommandAny, include_abc: bool = True) -> CommandCls | None:
174
174
  """
175
175
  :param cls: A Command class or object
176
176
  :param include_abc: If True, the first Command parent class in the given Command's mro will be returned,
@@ -211,7 +211,7 @@ class CommandMeta(ABCMeta, type):
211
211
  cls = cls.__class__
212
212
  parent = mcs.parent(cls, True)
213
213
  parent_params = mcs.params(parent) if parent is not None else None
214
- cls.__params = params = CommandParameters(cls, parent, parent_params, mcs.config(cls, DEFAULT_CONFIG))
214
+ cls.__params = params = CommandParameters(cls, parent_params, mcs.config(cls, DEFAULT_CONFIG))
215
215
  return params
216
216
 
217
217
  @classmethod