cli-command-parser 2025.6.14__tar.gz → 2025.7.13__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 (68) hide show
  1. {cli_command_parser-2025.6.14/lib/cli_command_parser.egg-info → cli_command_parser-2025.7.13}/PKG-INFO +6 -6
  2. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/__version__.py +1 -1
  3. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/context.py +5 -3
  4. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/choices.py +10 -2
  5. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/nargs.py +2 -19
  6. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/actions.py +36 -21
  7. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/base.py +8 -0
  8. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/choice_map.py +17 -12
  9. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/options.py +12 -13
  10. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parse_tree.py +0 -4
  11. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parser.py +55 -41
  12. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13/lib/cli_command_parser.egg-info}/PKG-INFO +6 -6
  13. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/readme.rst +5 -5
  14. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/LICENSE +0 -0
  15. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/MANIFEST.in +0 -0
  16. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/entry_points.txt +0 -0
  17. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/__init__.py +0 -0
  18. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/__main__.py +0 -0
  19. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/annotations.py +0 -0
  20. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/command_parameters.py +0 -0
  21. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/commands.py +0 -0
  22. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/compat.py +0 -0
  23. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/config.py +0 -0
  24. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/__init__.py +0 -0
  25. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/__main__.py +0 -0
  26. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/argparse_ast.py +0 -0
  27. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/argparse_utils.py +0 -0
  28. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/cli.py +0 -0
  29. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/command_builder.py +0 -0
  30. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/utils.py +0 -0
  31. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/conversion/visitor.py +0 -0
  32. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/core.py +0 -0
  33. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/documentation.py +0 -0
  34. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/error_handling/__init__.py +0 -0
  35. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/error_handling/base.py +0 -0
  36. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/error_handling/other.py +0 -0
  37. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/error_handling/windows.py +0 -0
  38. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/exceptions.py +0 -0
  39. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/formatting/__init__.py +0 -0
  40. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/formatting/commands.py +0 -0
  41. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/formatting/params.py +0 -0
  42. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/formatting/restructured_text.py +0 -0
  43. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/formatting/utils.py +0 -0
  44. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/__init__.py +0 -0
  45. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/base.py +0 -0
  46. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/exceptions.py +0 -0
  47. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/files.py +0 -0
  48. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/numeric.py +0 -0
  49. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/patterns.py +0 -0
  50. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/time.py +0 -0
  51. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/inputs/utils.py +0 -0
  52. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/metadata.py +0 -0
  53. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/__init__.py +0 -0
  54. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/groups.py +0 -0
  55. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/option_strings.py +0 -0
  56. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/pass_thru.py +0 -0
  57. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/parameters/positionals.py +0 -0
  58. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/testing.py +0 -0
  59. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/typing.py +0 -0
  60. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser/utils.py +0 -0
  61. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser.egg-info/SOURCES.txt +0 -0
  62. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser.egg-info/dependency_links.txt +0 -0
  63. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser.egg-info/entry_points.txt +0 -0
  64. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser.egg-info/requires.txt +0 -0
  65. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/lib/cli_command_parser.egg-info/top_level.txt +0 -0
  66. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/pyproject.toml +0 -0
  67. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/requirements-dev.txt +0 -0
  68. {cli_command_parser-2025.6.14 → cli_command_parser-2025.7.13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli_command_parser
3
- Version: 2025.6.14
3
+ Version: 2025.7.13
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
@@ -58,11 +58,11 @@ CLI Command Parser is a class-based CLI argument parser that defines parameters
58
58
  tools to quickly and easily get started with basic CLIs, and it scales well to support even very large and complex
59
59
  CLIs while remaining readable and easy to maintain.
60
60
 
61
- The primary goals of this project:
62
- - Make it easy to define subcommands and actions in an clean and organized manner
63
- - Allow for inheritance so that common parameters don't need to be repeated
64
- - Make it easy to handle common initialization tasks for all actions / subcommands once
65
- - Reduce the amount of boilerplate code that is necessary for setting up parsing and handling argument values
61
+ Some of the primary goals and key features of this project:
62
+ - Minimal boilerplate code is necessary to define CLI parameters and access their parsed values
63
+ - Easy to use type annotations for CLI parameters
64
+ - Subcommands can inherit common parameters so they don't need to be repeated
65
+ - Easy to handle common initialization tasks for all actions / subcommands once
66
66
 
67
67
 
68
68
  Example Program
@@ -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.06.14'
4
+ __version__ = '2025.07.13'
5
5
  __author__ = 'Doug Skrypa'
6
6
  __author_email__ = 'dskrypa@gmail.com'
7
7
  __license__ = 'Apache 2.0'
@@ -24,7 +24,7 @@ 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, Option, Parameter
27
+ from .parameters import ActionFlag, BaseOption, Option, Parameter
28
28
  from .typing import AnyConfig, Bool, CommandObj, CommandType, OptStr, ParamOrGroup, PathLike, StrSeq # noqa
29
29
 
30
30
  __all__ = ['Context', 'ctx', 'get_current_context', 'get_or_create_context', 'get_context', 'get_parsed', 'get_raw_arg']
@@ -250,9 +250,11 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
250
250
  """Not intended to be called by users. Used during parsing to determine if any Parameters are missing."""
251
251
  return [p for p in self.params.required_check_params() if not self._provided[p]]
252
252
 
253
- def missing_options_with_env_var(self) -> Iterator[Option]:
253
+ def missing_options_with_env_var(self) -> Iterator[BaseOption]:
254
254
  """Yields Option parameters that have an environment variable configured, and did not have any CLI values."""
255
- yield from (p for p in self.params.options if p.env_var and not self._provided[p])
255
+ for param in self.params.options:
256
+ if param.env_var and not self._provided[param]:
257
+ yield param
256
258
 
257
259
  # endregion
258
260
 
@@ -36,7 +36,12 @@ class _ChoicesBase(InputType[T], ABC):
36
36
  return True
37
37
 
38
38
  def _type_str(self) -> str:
39
- return f'type={self.type.__name__}, ' if self.type is not None else ''
39
+ if self.type is not None:
40
+ try:
41
+ return f'type={self.type.__name__}, '
42
+ except AttributeError: # type is not a class
43
+ pass
44
+ return ''
40
45
 
41
46
  def __repr__(self) -> str:
42
47
  cls_name = self.__class__.__name__
@@ -100,7 +105,10 @@ class Choices(_ChoicesBase[T]):
100
105
  self.case_sensitive = case_sensitive
101
106
 
102
107
  def _choices_repr(self, delim: str = ',') -> str:
103
- return delim.join(map(repr, sorted(self.choices)))
108
+ try:
109
+ return delim.join(map(repr, sorted(self.choices)))
110
+ except TypeError: # The choice values are not sortable
111
+ return delim.join(sorted(map(repr, self.choices)))
104
112
 
105
113
  def __call__(self, value: str) -> T:
106
114
  choices = self.choices
@@ -6,9 +6,9 @@ Helpers for handling ``nargs=...`` for Parameters.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Any, Collection, FrozenSet, Iterable, Optional, Sequence, Set, Tuple, Union
9
+ from typing import Any, Collection, FrozenSet, Optional, Sequence, Set, Tuple, Union
10
10
 
11
- __all__ = ['Nargs', 'NargsValue', 'REMAINDER', 'nargs_min_and_max_sums']
11
+ __all__ = ['Nargs', 'NargsValue', 'REMAINDER']
12
12
 
13
13
  REMAINDER = type('REMAINDER', (), {})()
14
14
  _UNBOUND = (None, REMAINDER)
@@ -177,20 +177,3 @@ class Nargs:
177
177
  @property
178
178
  def upper_bound(self) -> Union[int, float]:
179
179
  return self.max if self._has_upper_bound else float('inf')
180
-
181
-
182
- def nargs_min_and_max_sums(nargs_objects: Iterable[Nargs]) -> tuple[int, Union[int, float]]:
183
- min_sum, max_sum = 0, 0
184
- iter_nargs = iter(nargs_objects)
185
- for obj in iter_nargs:
186
- min_sum += obj.min
187
- if obj._has_upper_bound:
188
- max_sum += obj.max
189
- else:
190
- max_sum = float('inf')
191
- break
192
-
193
- for obj in iter_nargs: # If any had no upper bound, then this loop will complete the min total
194
- min_sum += obj.min # Otherwise, it will not have anything to iterate over
195
-
196
- return min_sum, max_sum
@@ -6,7 +6,7 @@ variables.
6
6
  from __future__ import annotations
7
7
 
8
8
  from abc import ABC, abstractmethod
9
- from typing import TYPE_CHECKING, Callable, Iterable, Iterator, NoReturn, Sequence, TypeVar, Union
9
+ from typing import TYPE_CHECKING, Callable, Generic, Iterable, Iterator, NoReturn, Sequence, TypeVar, Union
10
10
 
11
11
  from ..context import ctx
12
12
  from ..exceptions import BadArgument, InvalidChoice, MissingArgument, ParamConflict, ParamUsageError, TooManyArguments
@@ -15,7 +15,8 @@ from ..nargs import Nargs
15
15
  from ..utils import _NotSet, camel_to_snake_case
16
16
 
17
17
  if TYPE_CHECKING:
18
- from ..typing import Bool, CommandObj, Param, T_co
18
+ from .base import Parameter # noqa
19
+ from ..typing import Bool, CommandObj, T_co
19
20
 
20
21
  __all__ = [
21
22
  'ParamAction',
@@ -31,20 +32,19 @@ __all__ = [
31
32
 
32
33
  _PANotSet = object()
33
34
 
34
- TD = TypeVar('TD')
35
+ Param = TypeVar('Param', bound='Parameter')
35
36
  Found = Union[int, NoReturn]
36
37
 
37
38
 
38
- class ParamAction(ABC):
39
+ class ParamAction(ABC, Generic[Param]):
39
40
  __slots__ = ('param',)
40
41
  name: str
41
- default: TD = _NotSet
42
+ param: Param
43
+ default = _NotSet
42
44
  accepts_values: bool = False
43
45
  accepts_consts: bool = False
44
46
 
45
- def __init_subclass__(
46
- cls, default: TD = _PANotSet, accepts_values: bool = None, accepts_consts: bool = None, **kwargs
47
- ):
47
+ def __init_subclass__(cls, default=_PANotSet, accepts_values: bool = None, accepts_consts: bool = None, **kwargs):
48
48
  super().__init_subclass__(**kwargs)
49
49
  cls.name = camel_to_snake_case(cls.__name__)
50
50
  if default is not _PANotSet:
@@ -111,6 +111,15 @@ class ParamAction(ABC):
111
111
  return False
112
112
  return self.param.is_valid_arg(normalized)
113
113
 
114
+ def would_accept_all(self, values: list[str], combo: bool = False) -> bool:
115
+ prepare_validation_value, is_valid_arg = self.param.prepare_validation_value, self.param.is_valid_arg
116
+ try:
117
+ valid_values = all(is_valid_arg(prepare_validation_value(value, combo)) for value in values)
118
+ except BadArgument: # Potentially raised by prepare_validation_value; is_valid_arg handles exceptions
119
+ return False
120
+ else:
121
+ return valid_values and len(values) in self.param.nargs
122
+
114
123
  # Note: Not used yet
115
124
  # def _prep_and_validate(self, values: Sequence[str], combo: bool) -> Iterator[T_co]:
116
125
  # prepare_value, validate = self.param.prepare_value, self.param.validate
@@ -123,10 +132,10 @@ class ParamAction(ABC):
123
132
 
124
133
  # region Backtracking
125
134
 
126
- def get_maybe_poppable_counts(self) -> list[int]:
135
+ def get_maybe_poppable_values(self) -> list[list[str]]:
127
136
  """
128
- :return: The indexes on which the parsed values may be split such that the remaining number of values will
129
- still be acceptable for the Parameter's nargs.
137
+ Groups of args that may be removed from this Parameter's parsed values such that the remaining number will
138
+ still be acceptable for its nargs.
130
139
  """
131
140
  return []
132
141
 
@@ -162,7 +171,7 @@ class ParamAction(ABC):
162
171
 
163
172
  class ValueMixin:
164
173
  __slots__ = ()
165
- param: Param
174
+ param: Param # noqa
166
175
  get_default: Callable
167
176
 
168
177
  def set_value(self, value):
@@ -195,7 +204,7 @@ class ValueMixin:
195
204
 
196
205
  class ConstMixin:
197
206
  __slots__ = ()
198
- param: Param
207
+ param: Param # noqa
199
208
  get_default: Callable
200
209
  add_const: Callable
201
210
  add_value: Callable
@@ -264,8 +273,8 @@ class Store(ValueMixin, ParamAction, default=None, accepts_values=True):
264
273
  # ctx.record_action(self.param)
265
274
  # if not values:
266
275
  # raise MissingArgument(self.param)
267
- # elif (val_count := len(values)) not in (nargs := self.param.nargs):
268
- # raise BadArgument(self.param, f'expected {nargs=} values but found {val_count}')
276
+ # elif (val_count := len(values)) not in self.param.nargs:
277
+ # raise BadArgument(self.param, f'expected nargs={self.param.nargs} values but found {val_count}')
269
278
  #
270
279
  # self.set_value([value for value in self._prep_and_validate(values, combo)])
271
280
  # return val_count
@@ -320,17 +329,13 @@ class Append(ValueMixin, ParamAction, accepts_values=True):
320
329
 
321
330
  # region Backtracking
322
331
 
323
- def get_maybe_poppable_counts(self) -> list[int]:
324
- """
325
- :return: The indexes on which the parsed values may be split such that the remaining number of values will
326
- still be acceptable for the Parameter's nargs.
327
- """
332
+ def get_maybe_poppable_values(self) -> list[list[str]]:
328
333
  if not self.param.nargs.variable or self.param.type not in (None, str):
329
334
  return []
330
335
  elif (values := ctx.get_parsed_value(self.param)) is not _NotSet:
331
336
  n_values = len(values)
332
337
  satisfied = self.param.nargs.satisfied
333
- return [i for i in range(1, n_values) if satisfied(n_values - i)]
338
+ return [values[-i:] for i in range(1, n_values) if satisfied(n_values - i)]
334
339
  else:
335
340
  return []
336
341
 
@@ -398,6 +403,9 @@ class BasicConstAction(ConstMixin, ParamAction, ABC, accepts_consts=True):
398
403
  def would_accept(self, value: str, combo: bool = False) -> bool:
399
404
  return False
400
405
 
406
+ def would_accept_all(self, values: list[str], combo: bool = False) -> bool:
407
+ return False
408
+
401
409
  # endregion
402
410
 
403
411
 
@@ -507,6 +515,13 @@ class Concatenate(Append):
507
515
 
508
516
  # endregion
509
517
 
518
+ # region Parsing
519
+
520
+ def would_accept_all(self, values: list[str], combo: bool = False) -> bool:
521
+ return self.param.is_valid_arg(values)
522
+
523
+ # endregion
524
+
510
525
  # region Parsed Value / Default Finalization
511
526
 
512
527
  def finalize_default(self, value):
@@ -96,6 +96,14 @@ class ParamBase(ABC):
96
96
 
97
97
  # endregion
98
98
 
99
+ def __eq__(self, other: ParamBase) -> bool:
100
+ return (
101
+ self.__class__ == other.__class__
102
+ and self._attr_name == other._attr_name
103
+ and self._name == other._name
104
+ and self.command == other.command
105
+ )
106
+
99
107
  def __hash__(self) -> int:
100
108
  return hash(self.__class__) ^ hash(self._attr_name) ^ hash(self._name) ^ hash(self.command)
101
109
 
@@ -9,13 +9,13 @@ from __future__ import annotations
9
9
  from functools import partial
10
10
  from string import printable, whitespace
11
11
  from types import MethodType
12
- from typing import TYPE_CHECKING, Callable, Collection, Generic, Mapping, NoReturn, Optional, Type, TypeVar, Union
12
+ from typing import TYPE_CHECKING, Callable, Collection, Generic, Mapping, NoReturn, Sequence, Type, TypeVar
13
13
 
14
14
  from ..context import ctx
15
15
  from ..exceptions import BadArgument, CommandDefinitionError, InvalidChoice, ParameterDefinitionError
16
16
  from ..formatting.utils import format_help_entry
17
17
  from ..nargs import Nargs
18
- from ..typing import Bool, CommandCls, CommandObj
18
+ from ..typing import CommandCls
19
19
  from ..utils import _NotSet, camel_to_snake_case, short_repr
20
20
  from .actions import Concatenate
21
21
  from .base import BasePositional
@@ -23,12 +23,12 @@ from .base import BasePositional
23
23
  if TYPE_CHECKING:
24
24
  from ..formatting.params import ChoiceMapHelpFormatter
25
25
  from ..metadata import ProgramMetadata
26
+ from ..typing import Bool, CommandObj, OptStr
26
27
 
27
28
  __all__ = ['SubCommand', 'Action', 'Choice', 'ChoiceMap']
28
29
 
29
30
  T = TypeVar('T')
30
31
  TD = TypeVar('TD')
31
- OptStr = Optional[str]
32
32
  # TODO: Combine SubCommand and Action, replacing `local_choices` with stackable decorators on the target method,
33
33
  # optionally injecting the selected choice into positional args for the decorated method, which may be main?
34
34
 
@@ -142,7 +142,7 @@ class ChoiceMap(BasePositional[str], Generic[T], actions=(Concatenate,)):
142
142
  def _register_choice(
143
143
  self,
144
144
  choice: OptStr,
145
- target: Optional[T] = _NotSet,
145
+ target: T | None = _NotSet,
146
146
  help: str = None, # noqa
147
147
  local: bool = False,
148
148
  ):
@@ -162,21 +162,26 @@ class ChoiceMap(BasePositional[str], Generic[T], actions=(Concatenate,)):
162
162
 
163
163
  # region Argument Handling
164
164
 
165
- def validate(self, value: str, joined: Bool = False):
165
+ def validate(self, value: str | Sequence[str], joined: Bool = False):
166
166
  if not self.choices:
167
167
  self._no_choices_error()
168
168
 
169
169
  parsed = ctx.get_parsed_value(self)
170
- values = (value,) if parsed is _NotSet else (*parsed, value)
170
+ if parsed is _NotSet:
171
+ values = (value,) if isinstance(value, str) else value
172
+ else:
173
+ values = (*parsed, value) if isinstance(value, str) else (*parsed, *value)
174
+
171
175
  if (choice := ' '.join(values)) in self.choices:
172
176
  return
173
177
  elif len(values) > self.nargs.max:
174
178
  raise BadArgument(self, 'too many values')
179
+
175
180
  prefix = choice + ' '
176
181
  if not any(c.startswith(prefix) for c in self.choices if c):
177
182
  raise InvalidChoice(self, prefix[:-1], self.choices)
178
183
 
179
- def result(self, command: CommandObj | None = None, missing_default: TD = _NotSet) -> Union[OptStr, TD]:
184
+ def result(self, command: CommandObj | None = None, missing_default: TD = _NotSet) -> OptStr | TD:
180
185
  if not self.choices:
181
186
  self._no_choices_error()
182
187
  return super().result(command, missing_default)
@@ -211,7 +216,7 @@ class SubCommand(ChoiceMap[CommandCls], title='Subcommands', choice_validation_e
211
216
  *,
212
217
  required: Bool = True,
213
218
  default_help: str = None,
214
- local_choices: Optional[Union[Mapping[str, str], Collection[str]]] = None,
219
+ local_choices: Mapping[str, str] | Collection[str] | None = None,
215
220
  **kwargs,
216
221
  ):
217
222
  """
@@ -237,7 +242,7 @@ class SubCommand(ChoiceMap[CommandCls], title='Subcommands', choice_validation_e
237
242
  def has_local_choices(self) -> bool:
238
243
  return None in self.choices or any(c.target is None for c in self.choices.values())
239
244
 
240
- def _register_local_choices(self, local_choices: Union[Mapping[str, str], Collection[str]]):
245
+ def _register_local_choices(self, local_choices: Mapping[str, str] | Collection[str]):
241
246
  try:
242
247
  choice_help_iter = local_choices.items()
243
248
  except AttributeError:
@@ -273,7 +278,7 @@ class SubCommand(ChoiceMap[CommandCls], title='Subcommands', choice_validation_e
273
278
 
274
279
  def register(
275
280
  self,
276
- command_or_choice: Union[str, CommandCls] = None,
281
+ command_or_choice: str | CommandCls | None = None,
277
282
  *,
278
283
  choice: str = None,
279
284
  help: str = None, # noqa
@@ -344,12 +349,12 @@ class Action(ChoiceMap[MethodType], title='Actions'):
344
349
 
345
350
  def register(
346
351
  self,
347
- method_or_choice: Union[str, MethodType] = None,
352
+ method_or_choice: str | MethodType | None = None,
348
353
  *,
349
354
  choice: str = None,
350
355
  help: str = None, # noqa
351
356
  default: Bool = False,
352
- ) -> Union[MethodType, Callable[[MethodType], MethodType]]:
357
+ ) -> MethodType | Callable[[MethodType], MethodType]:
353
358
  """
354
359
  Decorator that registers the wrapped method to be called when the given choice is specified for this parameter.
355
360
  Methods may also be registered by decorating them with the instantiated Action parameter directly - doing so
@@ -7,9 +7,8 @@ Optional Parameters
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
- from abc import ABC
11
10
  from functools import partial, update_wrapper
12
- from typing import TYPE_CHECKING, Any, Callable, Literal, NoReturn, Optional, TypeVar, Union
11
+ from typing import TYPE_CHECKING, Any, Callable, Literal, NoReturn, TypeVar, Union
13
12
 
14
13
  from ..exceptions import BadArgument, CommandDefinitionError, ParameterDefinitionError, ParamUsageError, ParserExit
15
14
  from ..inputs import normalize_input_type
@@ -188,7 +187,7 @@ class Flag(BaseOption[Union[TD, TC]], actions=(StoreConst, AppendConst)):
188
187
  def register_default_cb(self, method):
189
188
  raise ParameterDefinitionError(f'{self.__class__.__name__}s do not support default callback methods')
190
189
 
191
- def get_env_const(self, value: str, env_var: str) -> tuple[Union[TC, TD], bool]:
190
+ def get_env_const(self, value: str, env_var: str) -> tuple[TC | TD, bool]:
192
191
  try:
193
192
  parsed = self.type(value)
194
193
  except Exception as e:
@@ -198,7 +197,7 @@ class Flag(BaseOption[Union[TD, TC]], actions=(StoreConst, AppendConst)):
198
197
  return parsed, self.use_env_value
199
198
 
200
199
 
201
- class TriFlag(BaseOption[Union[TD, TC, TA]], ABC, actions=(StoreConst, AppendConst)):
200
+ class TriFlag(BaseOption[Union[TD, TC, TA]], actions=(StoreConst, AppendConst)):
202
201
  """
203
202
  A trinary / ternary Flag. While :class:`.Flag` only supports 1 constant when provided, with 1 default if not
204
203
  provided, this class accepts a pair of constants for the primary and alternate values to store, along with a
@@ -298,13 +297,13 @@ class TriFlag(BaseOption[Union[TD, TC, TA]], ABC, actions=(StoreConst, AppendCon
298
297
  self.default = _NotSet # The default was set by __init__ - remove it so the method can be registered
299
298
  return super().register_default_cb(method)
300
299
 
301
- def get_const(self, opt_str: OptStr = None) -> Union[TC, TA]:
300
+ def get_const(self, opt_str: OptStr = None) -> TC | TA:
302
301
  if opt_str in self.option_strs.alt_allowed:
303
302
  return self.consts[1]
304
303
  else:
305
304
  return self.consts[0]
306
305
 
307
- def get_env_const(self, value: str, env_var: str) -> tuple[Union[TC, TA, TD], bool]:
306
+ def get_env_const(self, value: str, env_var: str) -> tuple[TC | TA | TD, bool]:
308
307
  try:
309
308
  parsed = self.type(value)
310
309
  except Exception as e:
@@ -342,7 +341,7 @@ class ActionFlag(Flag, repr_attrs=('order', 'before_main')):
342
341
  def __init__(
343
342
  self,
344
343
  *option_strs: str,
345
- order: Union[int, float] = 1,
344
+ order: int | float = 1,
346
345
  func: Callable = None,
347
346
  before_main: Bool = True, # noqa # pylint: disable=W0621
348
347
  always_available: Bool = False,
@@ -364,7 +363,7 @@ class ActionFlag(Flag, repr_attrs=('order', 'before_main')):
364
363
  return self._func
365
364
 
366
365
  @func.setter
367
- def func(self, func: Optional[Callable]):
366
+ def func(self, func: Callable | None):
368
367
  self._func = func
369
368
  if func is not None:
370
369
  if self.help is None:
@@ -403,7 +402,7 @@ class ActionFlag(Flag, repr_attrs=('order', 'before_main')):
403
402
  self.func = func
404
403
  return self
405
404
 
406
- def __get__(self, command: Optional[CommandObj], owner: CommandCls) -> Union[ActionFlag, Callable]:
405
+ def __get__(self, command: CommandObj | None, owner: CommandCls) -> ActionFlag | Callable:
407
406
  # Allow the method to be called, regardless of whether it was specified
408
407
  if command is None:
409
408
  return self
@@ -424,12 +423,12 @@ class ActionFlag(Flag, repr_attrs=('order', 'before_main')):
424
423
  action_flag = ActionFlag # pylint: disable=C0103
425
424
 
426
425
 
427
- def before_main(*option_strs: str, order: Union[int, float] = 1, func: Callable = None, **kwargs) -> ActionFlag:
426
+ def before_main(*option_strs: str, order: int | float = 1, func: Callable = None, **kwargs) -> ActionFlag:
428
427
  """An ActionFlag that will be executed before :meth:`.Command.main`"""
429
428
  return ActionFlag(*option_strs, order=order, func=func, before_main=True, **kwargs)
430
429
 
431
430
 
432
- def after_main(*option_strs: str, order: Union[int, float] = 1, func: Callable = None, **kwargs) -> ActionFlag:
431
+ def after_main(*option_strs: str, order: int | float = 1, func: Callable = None, **kwargs) -> ActionFlag:
433
432
  """An ActionFlag that will be executed after :meth:`.Command.main`"""
434
433
  return ActionFlag(*option_strs, order=order, func=func, before_main=False, **kwargs)
435
434
 
@@ -500,7 +499,7 @@ class Counter(BaseOption[int], actions=(Count,)):
500
499
  self.default_cb = None
501
500
  return super().register_default_cb(method)
502
501
 
503
- def prepare_value(self, value: Optional[str], short_combo: bool = False, env_var: str = None) -> int:
502
+ def prepare_value(self, value: str | None, short_combo: bool = False, env_var: str = None) -> int:
504
503
  try:
505
504
  return self.type(value)
506
505
  except (ValueError, TypeError) as e:
@@ -516,7 +515,7 @@ class Counter(BaseOption[int], actions=(Count,)):
516
515
  if value is None or isinstance(value, self.type):
517
516
  return
518
517
  try:
519
- value = self.type(value)
518
+ self.type(value)
520
519
  except (ValueError, TypeError) as e:
521
520
  raise BadArgument(self, f'invalid {value=} (expected an integer)') from e
522
521
  else:
@@ -7,7 +7,6 @@ from __future__ import annotations
7
7
  from typing import TYPE_CHECKING, Collection, Iterable, Iterator, MutableMapping, Optional, Union
8
8
 
9
9
  from .exceptions import AmbiguousParseTree
10
- from .nargs import nargs_min_and_max_sums
11
10
  from .utils import _parse_tree_target_repr
12
11
 
13
12
  if TYPE_CHECKING:
@@ -89,9 +88,6 @@ class PosNode(MutableMapping[Word, 'PosNode']):
89
88
  for node in self.values():
90
89
  yield from node._link_params(_has_upper_bound(node))
91
90
 
92
- def nargs_min_and_max(self) -> tuple[int, Union[int, float]]:
93
- return nargs_min_and_max_sums(p.nargs for p in self.link_params(True))
94
-
95
91
  # region AnyWord Methods
96
92
 
97
93
  @property
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import logging
10
10
  from collections import deque
11
11
  from os import environ
12
- from typing import TYPE_CHECKING, Deque, Iterable, Optional
12
+ from typing import TYPE_CHECKING, Deque, Sequence
13
13
 
14
14
  from .context import ActionPhase, Context
15
15
  from .core import get_parent
@@ -22,7 +22,7 @@ from .exceptions import (
22
22
  ParamUsageError,
23
23
  UsageError,
24
24
  )
25
- from .nargs import REMAINDER, nargs_min_and_max_sums
25
+ from .nargs import REMAINDER
26
26
  from .parameters.base import BaseOption, BasePositional, Parameter
27
27
  from .parse_tree import PosNode
28
28
 
@@ -45,12 +45,12 @@ class CommandParser:
45
45
 
46
46
  __slots__ = ('_last', 'arg_deque', 'ctx', 'config', 'deferred', 'params', 'positionals')
47
47
 
48
- arg_deque: Optional[Deque[str]]
48
+ arg_deque: Deque[str] | None
49
49
  config: CommandConfig
50
- deferred: Optional[list[str]]
50
+ deferred: list[str] | None
51
51
  params: CommandParameters
52
52
  positionals: list[BasePositional]
53
- _last: Optional[Parameter]
53
+ _last: Parameter | None
54
54
 
55
55
  def __init__(self, ctx: Context, params: CommandParameters, config: CommandConfig):
56
56
  self._last = None
@@ -62,7 +62,7 @@ class CommandParser:
62
62
  PosNode.build_tree(ctx.command_cls)
63
63
 
64
64
  @classmethod
65
- def parse_args_and_get_next_cmd(cls, ctx: Context) -> Optional[CommandType]:
65
+ def parse_args_and_get_next_cmd(cls, ctx: Context) -> CommandType | None:
66
66
  try:
67
67
  return cls(ctx, ctx.params, ctx.config).get_next_cmd(ctx)
68
68
  except UsageError:
@@ -70,7 +70,7 @@ class CommandParser:
70
70
  raise
71
71
  return None
72
72
 
73
- def get_next_cmd(self, ctx: Context) -> Optional[CommandType]:
73
+ def get_next_cmd(self, ctx: Context) -> CommandType | None:
74
74
  self._parse_args(ctx)
75
75
  self._validate_groups()
76
76
  missing = ctx.get_missing()
@@ -273,7 +273,7 @@ class CommandParser:
273
273
  if len(self.positionals) == 1 and 0 in self.positionals[0].nargs:
274
274
  raise NextCommand
275
275
  else:
276
- raise ParamUsageError(param, 'subcommand arguments must be provided after the subcommand')
276
+ raise ParamUsageError(param, 'subcommand arguments must be provided after the subcommand') # noqa
277
277
 
278
278
  # region Backtracking
279
279
 
@@ -287,26 +287,58 @@ class CommandParser:
287
287
  :param found: The number of values that were consumed by the given Parameter
288
288
  :return: The updated found count, if backtracking was possible, otherwise the unmodified found count
289
289
  """
290
- if self.positionals:
291
- can_pop = param.action.get_maybe_poppable_counts()
292
- if rollback_count := _to_pop(self.positionals, can_pop, found - 1):
293
- self.arg_deque.extendleft(reversed(self.ctx.roll_back_parsed_values(param, rollback_count)))
294
- return found - rollback_count
295
- return found
290
+ if not self.positionals:
291
+ return found
292
+ elif rollback_count := self._get_backtrack_count(param):
293
+ self.arg_deque.extendleft(reversed(self.ctx.roll_back_parsed_values(param, rollback_count)))
294
+ return found - rollback_count
295
+ else:
296
+ return found
297
+
298
+ def _get_backtrack_count(
299
+ self, param: Parameter, extras: Sequence[str] = (), positionals: Sequence[BasePositional] = ()
300
+ ) -> int:
301
+ if poppable_groups := param.action.get_maybe_poppable_values():
302
+ return next((len(g) for g in poppable_groups if self._should_backtrack(g, extras, positionals)), 0)
303
+ return 0
304
+
305
+ def _should_backtrack(
306
+ self, group: list[str], extras: Sequence[str] = (), positionals: Sequence[BasePositional] = ()
307
+ ) -> bool:
308
+ args = [*group, *extras, *self.arg_deque]
309
+ for pos_param in positionals or self.positionals:
310
+ n = pos_param.nargs.min
311
+ if not n and 1 in pos_param.nargs:
312
+ n = 1
313
+
314
+ param_args = args[:n]
315
+ if len(param_args) != n or not pos_param.action.would_accept_all(param_args):
316
+ return False
317
+
318
+ args = args[n:]
296
319
 
297
- def _maybe_backtrack_last(self, param: BasePositional, found: int):
320
+ return True
321
+
322
+ def _maybe_backtrack_last_positional(self, param: BasePositional):
298
323
  """
299
324
  Similar to :meth:`._maybe_backtrack`, but allows backtracking even after starting to process a Positional.
325
+
326
+ By the time this method is called, it has already been discovered that `found` does not satisfy `param`'s
327
+ nargs requirements.
300
328
  """
301
329
  if not self.config.allow_backtrack:
302
- # This method is called relatively rarely & it's cleaner to have this check here than in _finalize_consume
330
+ # This method is called extremely rarely & it's cleaner to have this check here than in _finalize_consume
303
331
  return
304
332
 
305
- can_pop = self._last.action.get_maybe_poppable_counts()
306
- # It is extremely unlikely for this point to be reached without this resulting in triggering a backtrack
307
- if rollback_count := _to_pop((param, *self.positionals), can_pop, max(can_pop, default=0) + found, found):
333
+ parsed = self.ctx.get_parsed_value(param, ())
334
+ # It is extremely unlikely for this point to be reached without this resulting in triggering backtrack
335
+ if num := self._get_backtrack_count(self._last, parsed, (param, *self.positionals)):
336
+ # log.debug(f'Rolling back {num} parsed values from {self._last=} / {param=} and triggering Backtrack')
337
+ # Reset all of this param's parsed args because the previous param's roll back args need to be injected
338
+ # before them so they can be processed by this parameter.
308
339
  self.arg_deque.extendleft(reversed(self.ctx.pop_parsed_value(param)))
309
- self.arg_deque.extendleft(reversed(self.ctx.roll_back_parsed_values(self._last, rollback_count)))
340
+ # Roll back a subset of the previous param's parsed args
341
+ self.arg_deque.extendleft(reversed(self.ctx.roll_back_parsed_values(self._last, num)))
310
342
  raise Backtrack
311
343
 
312
344
  # endregion
@@ -353,15 +385,13 @@ class CommandParser:
353
385
  # log.debug(f'{value=} was rejected by {param=}', exc_info=True)
354
386
  return self._finalize_consume(param, value, found, e)
355
387
 
356
- # TODO: Positional(nargs='?') with no values will steal values intended for an Option(nargs='+')
357
- # (likely occurs with nargs='*' for the Positional as well)
358
-
359
388
  # log.debug(f'Ran out of values in deque while processing {param=}')
360
389
  if found >= 2 and self.config.allow_backtrack:
361
390
  found = self._maybe_backtrack(param, found)
362
391
  return self._finalize_consume(param, None, found)
363
392
 
364
- def _finalize_consume(self, param: Parameter, value: OptStr, found: int, exc: Optional[Exception] = None) -> int:
393
+ def _finalize_consume(self, param: Parameter, value: OptStr, found: int, exc: Exception | None = None) -> int:
394
+ # log.debug(f'Finalizing arg consumption for {param=}, {value=}, {found=}, {exc=}')
365
395
  nargs = param.nargs
366
396
  if nargs.satisfied(found):
367
397
  # Even if an exception was passed to this method, if the found number of values is acceptable, then it
@@ -373,7 +403,7 @@ class CommandParser:
373
403
  elif exc:
374
404
  raise exc
375
405
  elif self._last and isinstance(param, BasePositional) and param.action.can_reset():
376
- self._maybe_backtrack_last(param, found)
406
+ self._maybe_backtrack_last_positional(param)
377
407
 
378
408
  s = '' if nargs.min == 1 else 's'
379
409
  raise MissingArgument(param, f'expected {nargs.min} value{s}, but only found {found}')
@@ -382,22 +412,6 @@ class CommandParser:
382
412
  parse_args_and_get_next_cmd = CommandParser.parse_args_and_get_next_cmd
383
413
 
384
414
 
385
- def _to_pop(positionals: Iterable[BasePositional], can_pop: list[int], available: int, req_mod: int = 0) -> int:
386
- if not can_pop:
387
- return 0
388
-
389
- required, acceptable = nargs_min_and_max_sums(p.nargs for p in positionals)
390
- if available < required:
391
- return 0
392
-
393
- required -= req_mod
394
- for n in can_pop:
395
- if required <= n <= acceptable:
396
- return n
397
-
398
- return 0
399
-
400
-
401
415
  def get_opt_prefix(text: str) -> OptStr:
402
416
  if not text or text[0] != '-':
403
417
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli_command_parser
3
- Version: 2025.6.14
3
+ Version: 2025.7.13
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
@@ -58,11 +58,11 @@ CLI Command Parser is a class-based CLI argument parser that defines parameters
58
58
  tools to quickly and easily get started with basic CLIs, and it scales well to support even very large and complex
59
59
  CLIs while remaining readable and easy to maintain.
60
60
 
61
- The primary goals of this project:
62
- - Make it easy to define subcommands and actions in an clean and organized manner
63
- - Allow for inheritance so that common parameters don't need to be repeated
64
- - Make it easy to handle common initialization tasks for all actions / subcommands once
65
- - Reduce the amount of boilerplate code that is necessary for setting up parsing and handling argument values
61
+ Some of the primary goals and key features of this project:
62
+ - Minimal boilerplate code is necessary to define CLI parameters and access their parsed values
63
+ - Easy to use type annotations for CLI parameters
64
+ - Subcommands can inherit common parameters so they don't need to be repeated
65
+ - Easy to handle common initialization tasks for all actions / subcommands once
66
66
 
67
67
 
68
68
  Example Program
@@ -26,11 +26,11 @@ CLI Command Parser is a class-based CLI argument parser that defines parameters
26
26
  tools to quickly and easily get started with basic CLIs, and it scales well to support even very large and complex
27
27
  CLIs while remaining readable and easy to maintain.
28
28
 
29
- The primary goals of this project:
30
- - Make it easy to define subcommands and actions in an clean and organized manner
31
- - Allow for inheritance so that common parameters don't need to be repeated
32
- - Make it easy to handle common initialization tasks for all actions / subcommands once
33
- - Reduce the amount of boilerplate code that is necessary for setting up parsing and handling argument values
29
+ Some of the primary goals and key features of this project:
30
+ - Minimal boilerplate code is necessary to define CLI parameters and access their parsed values
31
+ - Easy to use type annotations for CLI parameters
32
+ - Subcommands can inherit common parameters so they don't need to be repeated
33
+ - Easy to handle common initialization tasks for all actions / subcommands once
34
34
 
35
35
 
36
36
  Example Program