cli-command-parser 2023.4.15__tar.gz → 2023.4.16__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 (61) hide show
  1. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/PKG-INFO +11 -1
  2. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/__version__.py +1 -1
  3. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/command_parameters.py +21 -17
  4. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/commands.py +2 -2
  5. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/context.py +7 -24
  6. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/metadata.py +94 -19
  7. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/base.py +5 -5
  8. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/choice_map.py +24 -17
  9. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/options.py +3 -3
  10. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parser.py +26 -49
  11. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser.egg-info/PKG-INFO +11 -1
  12. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser.egg-info/requires.txt +3 -0
  13. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/readme.rst +10 -0
  14. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/setup.cfg +2 -0
  15. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/LICENSE +0 -0
  16. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/MANIFEST.in +0 -0
  17. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/__init__.py +0 -0
  18. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/__main__.py +0 -0
  19. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/actions.py +0 -0
  20. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/annotations.py +0 -0
  21. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/compat.py +0 -0
  22. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/config.py +0 -0
  23. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/__init__.py +0 -0
  24. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/__main__.py +0 -0
  25. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/argparse_ast.py +0 -0
  26. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/argparse_utils.py +0 -0
  27. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/command_builder.py +0 -0
  28. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/utils.py +0 -0
  29. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/conversion/visitor.py +0 -0
  30. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/core.py +0 -0
  31. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/documentation.py +0 -0
  32. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/error_handling.py +0 -0
  33. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/exceptions.py +0 -0
  34. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/formatting/__init__.py +0 -0
  35. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/formatting/commands.py +0 -0
  36. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/formatting/params.py +0 -0
  37. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/formatting/restructured_text.py +0 -0
  38. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/formatting/utils.py +0 -0
  39. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/__init__.py +0 -0
  40. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/base.py +0 -0
  41. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/choices.py +0 -0
  42. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/exceptions.py +0 -0
  43. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/files.py +0 -0
  44. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/numeric.py +0 -0
  45. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/time.py +0 -0
  46. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/inputs/utils.py +0 -0
  47. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/nargs.py +0 -0
  48. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/__init__.py +0 -0
  49. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/groups.py +0 -0
  50. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/option_strings.py +0 -0
  51. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/pass_thru.py +0 -0
  52. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parameters/positionals.py +0 -0
  53. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/parse_tree.py +0 -0
  54. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/testing.py +0 -0
  55. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/typing.py +0 -0
  56. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser/utils.py +0 -0
  57. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser.egg-info/SOURCES.txt +0 -0
  58. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser.egg-info/dependency_links.txt +0 -0
  59. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/lib/cli_command_parser.egg-info/top_level.txt +0 -0
  60. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/pyproject.toml +0 -0
  61. {cli_command_parser-2023.4.15 → cli_command_parser-2023.4.16}/requirements-dev.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli_command_parser
3
- Version: 2023.4.15
3
+ Version: 2023.4.16
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
@@ -110,6 +110,16 @@ with optional dependencies::
110
110
 
111
111
  $ pip install -U cli-command-parser[wcwidth]
112
112
 
113
+
114
+ Python Version Compatibility
115
+ ============================
116
+
117
+ Python versions 3.7 and above are currently supported. CLI Command Parser will no longer support 3.7 after 2023-04-30,
118
+ ahead of the `official end of support for 3.7 on 2023-06-27 <https://devguide.python.org/versions/>`__.
119
+
120
+ When using 3.7 or 3.8, some additional packages that backport functionality that was added in later Python versions
121
+ are required for compatibility.
122
+
113
123
  To use the argparse to cli-command-parser conversion script with Python 3.7 or 3.8, there is a dependency on
114
124
  `astunparse <https://astunparse.readthedocs.io>`__. If you are using Python 3.9 or above, then ``astunparse`` is not
115
125
  necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
@@ -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__ = '2023.04.15'
4
+ __version__ = '2023.04.16'
5
5
  __author__ = 'Doug Skrypa'
6
6
  __author_email__ = 'dskrypa@gmail.com'
7
7
  __license__ = 'Apache 2.0'
@@ -50,6 +50,7 @@ class CommandParameters:
50
50
  combo_option_map: OptionMap #: Mapping of {short opt: Parameter} (no dash characters)
51
51
  groups: List[ParamGroup] #: List of ParamGroup objects
52
52
  positionals: List[BasePositional] #: List of positional Parameters
53
+ _deferred_positionals: List[BasePositional] = () #: Positional Parameters that are deferred to sub commands
53
54
  option_map: OptionMap #: Mapping of {--opt / -opt: Parameter}
54
55
 
55
56
  def __init__(self, command: CommandCls, command_parent: Optional[CommandCls], config: CommandConfig):
@@ -169,6 +170,7 @@ class CommandParameters:
169
170
  options.append(param)
170
171
  elif isinstance(param, ParamGroup):
171
172
  # Groups will only be discovered here when defined with `as` - ex: `with ParamGroup(...) as foo:`
173
+ # Group members will always be discovered at the top level since context managers share the outer scope
172
174
  groups.add(param)
173
175
  elif isinstance(param, PassThru):
174
176
  if self.pass_thru:
@@ -200,36 +202,37 @@ class CommandParameters:
200
202
  self.groups = sorted(groups)
201
203
 
202
204
  def _process_positionals(self, params: List[BasePositional]):
203
- var_nargs_param = None
204
- for param in params:
205
- if self.sub_command:
206
- raise CommandDefinitionError(
207
- f'Positional param={param!r} may not follow the sub command {self.sub_command} - re-order the'
208
- ' positionals, move it into the sub command(s), or convert it to an optional parameter'
209
- )
210
- elif var_nargs_param:
205
+ var_nargs_param = action_or_sub_cmd = split_index = None
206
+ for i, param in enumerate(params):
207
+ if var_nargs_param:
211
208
  raise CommandDefinitionError(
212
209
  f'Additional Positional parameters cannot follow {var_nargs_param} because it accepts'
213
210
  f' a variable number of arguments with no specific choices defined - param={param!r} is invalid'
214
211
  )
215
-
216
- if isinstance(param, (SubCommand, Action)):
217
- if self.action: # self.sub_command being already defined is handled above
212
+ elif isinstance(param, (SubCommand, Action)):
213
+ if action_or_sub_cmd:
218
214
  raise CommandDefinitionError(
219
215
  f'Only 1 Action xor SubCommand is allowed in a given Command - {self.command.__name__} cannot'
220
- f' contain both {self.action} and {param}'
216
+ f' contain both {action_or_sub_cmd} and {param}'
221
217
  )
222
218
  elif isinstance(param, SubCommand):
223
- self.sub_command = param
219
+ self.sub_command = action_or_sub_cmd = param
220
+ split_index = i + 1
224
221
  else:
225
- self.action = param
222
+ self.action = action_or_sub_cmd = param
226
223
  if not param.has_choices:
227
224
  raise CommandDefinitionError(f'No choices were registered for {self.action}')
228
-
229
- if param.nargs.variable and not param.has_choices:
225
+ elif param.nargs.variable and not param.has_choices:
230
226
  var_nargs_param = param
231
227
 
232
- self.positionals = params
228
+ if split_index:
229
+ params, self._deferred_positionals = params[:split_index], params[split_index:]
230
+
231
+ parent = self.parent
232
+ if parent and parent._deferred_positionals:
233
+ self.positionals = parent._deferred_positionals + params
234
+ else:
235
+ self.positionals = params
233
236
 
234
237
  def _process_options(self, params: Collection[BaseOption]):
235
238
  parent = self.parent
@@ -459,6 +462,7 @@ class CommandParameters:
459
462
  raise exc
460
463
 
461
464
  def try_env_params(self, ctx: Context) -> Iterator[Option]:
465
+ """Yields Option parameters that have an environment variable configured, and did not have any CLI values."""
462
466
  for param in self.options:
463
467
  try:
464
468
  param.env_var # noqa
@@ -108,11 +108,11 @@ class Command(ABC, metaclass=CommandMeta):
108
108
  cmd_cls = cls
109
109
  with ExitStack() as stack:
110
110
  stack.enter_context(ctx)
111
- sub_cmd = CommandParser.parse_args(ctx)
111
+ sub_cmd = CommandParser.parse_args_and_get_next_cmd(ctx)
112
112
  while sub_cmd:
113
113
  cmd_cls = sub_cmd
114
114
  ctx = stack.enter_context(ctx._sub_context(cmd_cls))
115
- sub_cmd = CommandParser.parse_args(ctx)
115
+ sub_cmd = CommandParser.parse_args_and_get_next_cmd(ctx)
116
116
 
117
117
  return cmd_cls()
118
118
 
@@ -32,31 +32,12 @@ if TYPE_CHECKING:
32
32
  from .parameters import Parameter, ActionFlag
33
33
  from .typing import Bool, ParamOrGroup, CommandType, AnyConfig, OptStr, PathLike
34
34
 
35
- __all__ = [
36
- 'Context',
37
- 'ctx',
38
- 'get_current_context',
39
- 'get_or_create_context',
40
- 'get_context',
41
- 'get_parsed',
42
- 'get_raw_arg',
43
- 'ParseState',
44
- ]
35
+ __all__ = ['Context', 'ctx', 'get_current_context', 'get_or_create_context', 'get_context', 'get_parsed', 'get_raw_arg']
45
36
 
46
37
  _context_stack = ContextVar('cli_command_parser.context.stack', default=[])
47
38
  _TERMINAL = Terminal()
48
39
 
49
40
 
50
- class ParseState(Enum):
51
- INITIAL = 1
52
- COMPLETE = 2
53
- FAILED = 3
54
-
55
- @property
56
- def done(self) -> bool:
57
- return self._value_ > 1
58
-
59
-
60
41
  class Context(AbstractContextManager): # Extending AbstractContextManager to make PyCharm's type checker happy
61
42
  """
62
43
  The parsing context.
@@ -69,6 +50,7 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
69
50
  prog: OptStr = None
70
51
  _terminal_width: Optional[int]
71
52
  allow_argv_prog: Bool = True
53
+ _provided: Dict[ParamOrGroup, int]
72
54
 
73
55
  def __init__(
74
56
  self,
@@ -82,7 +64,6 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
82
64
  ):
83
65
  self.command = command
84
66
  self.parent = parent
85
- self.state = ParseState.INITIAL
86
67
  self.config = _normalize_config(config, kwargs, parent, command)
87
68
  if parent is not None:
88
69
  self._set_argv(parent.prog, argv)
@@ -128,8 +109,7 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
128
109
  return self.__class__(argv, command, parent=self, **kwargs)
129
110
 
130
111
  def __repr__(self) -> str:
131
- cmd_name = getattr(self.command, '__name__', None)
132
- return f'<{self.__class__.__name__}[state={self.state}, command={cmd_name}]>'
112
+ return f'<{self.__class__.__name__}[command={getattr(self.command, "__name__", None)}]>'
133
113
 
134
114
  def __enter__(self) -> Context:
135
115
  _context_stack.get().append(self)
@@ -220,7 +200,7 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
220
200
  try:
221
201
  return self._parsed[param]
222
202
  except KeyError:
223
- self._parsed[param] = value = param._init_value_factory(self.state)
203
+ self._parsed[param] = value = param._init_value_factory()
224
204
  return value
225
205
 
226
206
  def set_parsed_value(self, param: Parameter, value: Any):
@@ -237,6 +217,9 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
237
217
  """Not intended to be called by users. Used by Parameters during parsing to handle nargs."""
238
218
  return self._provided[param]
239
219
 
220
+ def get_missing(self) -> List[Parameter]:
221
+ return [p for p in self.params.required_check_params() if self._provided[p] == 0]
222
+
240
223
  # endregion
241
224
 
242
225
  # region Actions
@@ -7,12 +7,20 @@ Program metadata introspection for use in usage, help text, and documentation.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections import defaultdict
10
11
  from inspect import getmodule
11
12
  from pathlib import Path
13
+ from sys import modules
12
14
  from textwrap import dedent
13
15
  from typing import TYPE_CHECKING, Any, Type, Optional, Union, Tuple, Dict
14
16
  from urllib.parse import urlparse
15
17
 
18
+ try:
19
+ from importlib.metadata import entry_points, EntryPoint
20
+ except ImportError: # Python 3.7
21
+ from importlib_metadata import entry_points, EntryPoint
22
+
23
+ from .compat import cached_property
16
24
  from .context import ctx, NoActiveContext
17
25
 
18
26
  if TYPE_CHECKING:
@@ -61,7 +69,7 @@ class ProgramMetadata:
61
69
  module: str = Metadata(None)
62
70
  command: str = Metadata(None)
63
71
  prog: str = Metadata(None)
64
- prog_from_sys_argv: bool = Metadata(None)
72
+ prog_src: str = Metadata(None)
65
73
  url: str = Metadata(None)
66
74
  docs_url: str = Metadata(None)
67
75
  email: str = Metadata(None)
@@ -111,7 +119,7 @@ class ProgramMetadata:
111
119
  else:
112
120
  doc = doc_str = None
113
121
 
114
- prog, prog_from_sys_argv = _prog(prog, path, parent, no_sys_argv)
122
+ prog, prog_src = _prog_finder.normalize(prog, path, parent, no_sys_argv, command)
115
123
  return cls(
116
124
  parent=parent,
117
125
  path=path,
@@ -119,7 +127,7 @@ class ProgramMetadata:
119
127
  module=g.get('__module__'),
120
128
  command=command.__qualname__,
121
129
  prog=prog,
122
- prog_from_sys_argv=prog_from_sys_argv,
130
+ prog_src=prog_src,
123
131
  url=url or g.get('__url__'),
124
132
  docs_url=docs_url or _docs_url_from_repo_url(url) or _docs_url_from_repo_url(g.get('__url__')),
125
133
  email=email or g.get('__author_email__'),
@@ -164,7 +172,7 @@ def _repr(obj, indent=0) -> str:
164
172
  if not isinstance(obj, ProgramMetadata):
165
173
  return repr(obj)
166
174
 
167
- field_dict = {field: getattr(obj, field) for field in obj._fields}
175
+ field_dict = {field: getattr(obj, field) for field in sorted(obj._fields)}
168
176
  prev_str = ' ' * indent
169
177
  indent += 4
170
178
  indent_str = ' ' * indent
@@ -172,32 +180,99 @@ def _repr(obj, indent=0) -> str:
172
180
  return f'<{obj.__class__.__name__}(\n{fields_str}\n{prev_str})>'
173
181
 
174
182
 
175
- def _prog(prog: OptStr, cmd_path: Path, parent: Optional[ProgramMetadata], no_sys_argv: Bool) -> Tuple[OptStr, bool]:
176
- # TODO: Attempt to detect the name to use via importlib.metadata.entry_points? 3.8+, with return value changes
177
- # after 3.9
178
- if prog:
179
- return prog, False
180
- if no_sys_argv is None:
183
+ class ProgFinder:
184
+ @cached_property
185
+ def mod_obj_prog_map(self) -> Dict[str, Dict[str, str]]:
186
+ mod_obj_prog_map = defaultdict(dict)
187
+ for entry_point in self._get_console_scripts():
188
+ module, obj = map(str.strip, entry_point.value.split(':', 1))
189
+ obj = obj.split('[', 1)[0].strip() # Strip extras, if any
190
+ mod_obj_prog_map[module][obj] = entry_point.name
191
+
192
+ mod_obj_prog_map.default_factory = None # Disable automatic defaults
193
+ return mod_obj_prog_map
194
+
195
+ @classmethod
196
+ def _get_console_scripts(cls) -> Tuple[EntryPoint, ...]:
181
197
  try:
182
- no_sys_argv = not ctx.allow_argv_prog
183
- except NoActiveContext:
184
- no_sys_argv = False
198
+ return entry_points(group='console_scripts')
199
+ except TypeError: # Python 3.8 or 3.9
200
+ return entry_points()['console_scripts']
201
+
202
+ def normalize(
203
+ self,
204
+ prog: OptStr,
205
+ cmd_path: Path,
206
+ parent: Optional[ProgramMetadata],
207
+ no_sys_argv: Bool,
208
+ command: CommandType,
209
+ ) -> Tuple[OptStr, str]:
210
+ if prog:
211
+ return prog, 'class kwargs'
212
+
213
+ ep_name = self._from_entry_point(command)
214
+ if ep_name:
215
+ return ep_name, 'entry_points'
216
+
217
+ if no_sys_argv is None:
218
+ try:
219
+ no_sys_argv = not ctx.allow_argv_prog
220
+ except NoActiveContext:
221
+ no_sys_argv = False
222
+
223
+ # if parent and parent.prog != parent.path.name and (not no_sys_argv or not parent.prog_from_sys_argv):
224
+ # return parent.prog, parent.prog_from_sys_argv
225
+ if parent and parent.prog != parent.path.name and (not no_sys_argv or parent.prog_src != 'sys.argv'):
226
+ return parent.prog, parent.prog_src
227
+ elif not no_sys_argv:
228
+ argv_name = self._from_sys_argv()
229
+ if argv_name:
230
+ return argv_name, 'sys.argv'
231
+
232
+ return cmd_path.name, 'path'
233
+
234
+ def _from_entry_point(self, command: CommandType) -> OptStr:
235
+ main_mod = 'cli_command_parser.commands'
236
+ for prog, obj, obj_mod, obj_name in self._iter_entry_point_candidates(command):
237
+ if obj is command or (obj_mod == main_mod and obj_name == 'main'):
238
+ return prog
239
+
240
+ return None
185
241
 
186
- if parent and parent.prog != parent.path.name and (not no_sys_argv or not parent.prog_from_sys_argv):
187
- return parent.prog, parent.prog_from_sys_argv
188
- elif not no_sys_argv:
242
+ def _iter_entry_point_candidates(self, command: CommandType):
243
+ try:
244
+ # TODO: This likely won't work for a base command in one module, sub commands defined in separate modules,
245
+ # and main imported from cli_command_parser in the package's __init__/__main__ module...
246
+ obj_prog_map = self.mod_obj_prog_map[command.__module__]
247
+ module = modules[command.__module__]
248
+ except KeyError as e:
249
+ pass
250
+ else:
251
+ for obj_name, prog in obj_prog_map.items():
252
+ base_name = obj_name.split('.', 1)[0]
253
+ try:
254
+ obj = getattr(module, base_name)
255
+ except AttributeError:
256
+ pass
257
+ else:
258
+ yield prog, obj, getattr(obj, '__module__', ''), getattr(obj, '__name__', '')
259
+
260
+ def _from_sys_argv(self) -> OptStr:
189
261
  try:
190
262
  ctx_prog = ctx.prog
191
263
  except NoActiveContext:
192
- ctx_prog = None
264
+ return None
193
265
 
194
266
  if ctx_prog:
195
267
  path = Path(ctx_prog)
196
268
  # Windows allows invocation without .exe - assume a file with an extension is a match
197
269
  if path.exists() or next(path.parent.glob(f'{path.name}.???'), None) is not None:
198
- return path.name, True
270
+ return path.name
271
+
272
+ return None
273
+
199
274
 
200
- return cmd_path.name, False
275
+ _prog_finder = ProgFinder()
201
276
 
202
277
 
203
278
  def _path_and_globals(command: CommandType, path: Path = None) -> Tuple[Path, Dict[str, Any]]:
@@ -22,7 +22,7 @@ except ImportError:
22
22
 
23
23
  from ..annotations import get_descriptor_value_type
24
24
  from ..config import CommandConfig, OptionNameMode, AllowLeadingDash
25
- from ..context import Context, ctx, get_current_context, ParseState
25
+ from ..context import Context, ctx, get_current_context
26
26
  from ..exceptions import ParameterDefinitionError, BadArgument, MissingArgument, InvalidChoice
27
27
  from ..exceptions import ParamUsageError, NoActiveContext, UnsupportedAction
28
28
  from ..inputs import InputType, normalize_input_type
@@ -277,7 +277,7 @@ class Parameter(ParamBase, Generic[T_co], ABC):
277
277
  if show_default is not None:
278
278
  self.show_default = show_default
279
279
 
280
- def _init_value_factory(self, state: ParseState):
280
+ def _init_value_factory(self):
281
281
  return _NotSet
282
282
 
283
283
  def __set_name__(self, command: CommandCls, name: str):
@@ -489,10 +489,10 @@ class BasicActionMixin:
489
489
  nargs: Nargs
490
490
  type: Optional[Callable]
491
491
 
492
- def _init_value_factory(self, state: ParseState):
492
+ def _init_value_factory(self):
493
493
  if self.action == 'append':
494
494
  return []
495
- return super()._init_value_factory(state) # noqa
495
+ return super()._init_value_factory() # noqa
496
496
 
497
497
  @parameter_action
498
498
  def store(self: Parameter, value: T_co):
@@ -524,7 +524,7 @@ class BasicActionMixin:
524
524
  if not values:
525
525
  return values
526
526
 
527
- ctx.set_parsed_value(self, self._init_value_factory(ctx.state))
527
+ ctx.set_parsed_value(self, self._init_value_factory())
528
528
  ctx._provided[self] = 0
529
529
  return values
530
530
 
@@ -8,10 +8,10 @@ from __future__ import annotations
8
8
 
9
9
  from functools import partial
10
10
  from string import whitespace, printable
11
- from typing import Type, TypeVar, Generic, Optional, Callable, Union, Collection, Mapping, Dict
11
+ from typing import Type, TypeVar, Generic, Optional, Callable, Union, Collection, Mapping, NoReturn, Dict
12
12
  from types import MethodType
13
13
 
14
- from ..context import ctx, ParseState
14
+ from ..context import ctx
15
15
  from ..exceptions import ParameterDefinitionError, BadArgument, MissingArgument, InvalidChoice, CommandDefinitionError
16
16
  from ..formatting.utils import format_help_entry
17
17
  from ..nargs import Nargs
@@ -100,7 +100,7 @@ class ChoiceMap(BasePositional[str], Generic[T]):
100
100
  self.description = description
101
101
  self.choices = {}
102
102
 
103
- def _init_value_factory(self, state: ParseState):
103
+ def _init_value_factory(self):
104
104
  return []
105
105
 
106
106
  # region Choice Registration
@@ -134,6 +134,9 @@ class ChoiceMap(BasePositional[str], Generic[T]):
134
134
  prefix = 'Invalid default' if choice is None else f'Invalid choice={choice!r} for'
135
135
  raise CommandDefinitionError(f'{prefix} target={target!r} - already assigned to {existing}')
136
136
 
137
+ def _no_choices_error(self) -> NoReturn:
138
+ raise CommandDefinitionError(f'No choices were registered for {self}')
139
+
137
140
  # endregion
138
141
 
139
142
  # region Argument Handling
@@ -151,33 +154,34 @@ class ChoiceMap(BasePositional[str], Generic[T]):
151
154
 
152
155
  def validate(self, value: str):
153
156
  choices = self.choices
154
- if choices:
155
- values = (*ctx.get_parsed_value(self), value)
156
- choice = ' '.join(values)
157
- if choice in choices:
158
- return
159
- elif len(values) > self.nargs.max:
160
- raise BadArgument(self, 'too many values')
161
- prefix = choice + ' '
162
- if not any(c.startswith(prefix) for c in choices if c):
163
- raise InvalidChoice(self, prefix[:-1], choices)
164
- elif value.startswith('-'):
165
- # Note: choices with a leading dash are rejected by `_validate_positional`
166
- raise BadArgument(self, f'invalid value={value!r}')
157
+ if not choices:
158
+ self._no_choices_error()
159
+
160
+ values = (*ctx.get_parsed_value(self), value)
161
+ choice = ' '.join(values)
162
+ if choice in choices:
163
+ return
164
+ elif len(values) > self.nargs.max:
165
+ raise BadArgument(self, 'too many values')
166
+ prefix = choice + ' '
167
+ if not any(c.startswith(prefix) for c in choices if c):
168
+ raise InvalidChoice(self, prefix[:-1], choices)
167
169
 
168
170
  def result_value(self) -> OptStr:
169
171
  choices = self.choices
170
172
  if not choices:
171
- raise CommandDefinitionError(f'No choices were registered for {self}')
173
+ self._no_choices_error()
172
174
 
173
175
  values = ctx.get_parsed_value(self)
174
176
  if not values:
175
177
  if None in choices:
176
178
  return None
177
179
  raise MissingArgument(self)
180
+
178
181
  val_count = len(values)
179
182
  if val_count not in self.nargs:
180
183
  raise BadArgument(self, f'expected nargs={self.nargs} values but found {val_count}')
184
+
181
185
  choice = ' '.join(values)
182
186
  if choice not in choices:
183
187
  raise InvalidChoice(self, choice, choices)
@@ -293,6 +297,9 @@ class SubCommand(ChoiceMap[CommandCls], title='Subcommands', choice_validation_e
293
297
  else:
294
298
  return self.register_command(choice, command_or_choice, help=help) # noqa
295
299
 
300
+ def _no_choices_error(self) -> NoReturn:
301
+ raise CommandDefinitionError(f'{ctx.command}.{self.name} = {self} has no sub Commands')
302
+
296
303
 
297
304
  class Action(ChoiceMap[MethodType], title='Actions'):
298
305
  """
@@ -10,7 +10,7 @@ from abc import ABC
10
10
  from functools import partial, update_wrapper
11
11
  from typing import Any, Optional, Callable, Sequence, Iterator, Union, TypeVar, Tuple
12
12
 
13
- from ..context import ctx, ParseState
13
+ from ..context import ctx
14
14
  from ..exceptions import ParameterDefinitionError, BadArgument, CommandDefinitionError, ParamUsageError
15
15
  from ..inputs import normalize_input_type
16
16
  from ..nargs import Nargs, NargsValue
@@ -116,7 +116,7 @@ class _Flag(BaseOption[T_co], ABC):
116
116
  raise TypeError(f"{self.__class__.__name__}.__init__() got an unexpected keyword argument: 'metavar'")
117
117
  super().__init__(*option_strs, **kwargs)
118
118
 
119
- def _init_value_factory(self, state: ParseState):
119
+ def _init_value_factory(self):
120
120
  if self.action == 'store_const':
121
121
  return self.default
122
122
  else:
@@ -397,7 +397,7 @@ class Counter(BaseOption[int], accepts_values=True, accepts_none=True):
397
397
  super().__init__(*option_strs, action=action, default=default, **kwargs)
398
398
  self.const = const
399
399
 
400
- def _init_value_factory(self, state: ParseState):
400
+ def _init_value_factory(self):
401
401
  return self.default
402
402
 
403
403
  def prepare_value(self, value: Optional[str], short_combo: bool = False, pre_action: bool = False) -> int:
@@ -11,15 +11,16 @@ from collections import deque
11
11
  from os import environ
12
12
  from typing import TYPE_CHECKING, Optional, Union, Any, Deque, List
13
13
 
14
- from .context import ActionPhase, Context, ParseState
14
+ from .context import ActionPhase, Context
15
15
  from .exceptions import UsageError, ParamUsageError, NoSuchOption, MissingArgument, ParamsMissing
16
- from .exceptions import CommandDefinitionError, Backtrack, UnsupportedAction
16
+ from .exceptions import Backtrack, UnsupportedAction
17
17
  from .nargs import REMAINDER, nargs_max_sum, nargs_min_sum
18
18
  from .parse_tree import PosNode
19
19
  from .parameters.base import BasicActionMixin, Parameter, BasePositional, BaseOption
20
20
 
21
21
  if TYPE_CHECKING:
22
22
  from .command_parameters import CommandParameters
23
+ from .config import CommandConfig
23
24
  from .typing import CommandType
24
25
 
25
26
  __all__ = ['CommandParser']
@@ -29,72 +30,48 @@ __all__ = ['CommandParser']
29
30
  class CommandParser:
30
31
  """Stateful parser used for a single pass of argument parsing"""
31
32
 
32
- __slots__ = ('_last', 'arg_deque', 'ctx', 'deferred', 'params', 'positionals')
33
+ __slots__ = ('_last', 'arg_deque', 'config', 'deferred', 'params', 'positionals')
33
34
 
34
35
  arg_deque: Optional[Deque[str]]
36
+ config: CommandConfig
35
37
  deferred: Optional[List[str]]
36
38
  params: CommandParameters
37
39
  positionals: List[BasePositional]
38
40
  _last: Optional[Parameter]
39
41
 
40
- def __init__(self, ctx: Context):
42
+ def __init__(self, ctx: Context, params: CommandParameters, config: CommandConfig):
41
43
  self._last = None
42
- self.ctx = ctx
43
- self.params = ctx.params
44
- self.positionals = ctx.params.all_positionals.copy()
45
- if ctx.config.reject_ambiguous_pos_combos:
44
+ self.params = params
45
+ self.positionals = params.all_positionals.copy()
46
+ self.config = config
47
+ if config.reject_ambiguous_pos_combos:
46
48
  PosNode.build_tree(ctx.command)
47
49
 
48
50
  @classmethod
49
- def parse_args(cls, ctx: Context) -> Optional[CommandType]:
51
+ def parse_args_and_get_next_cmd(cls, ctx: Context) -> Optional[CommandType]:
50
52
  try:
51
- parsed = cls.__parse_args(ctx)
53
+ return cls(ctx, ctx.params, ctx.config).get_next_cmd(ctx)
52
54
  except UsageError:
53
- ctx.state = ParseState.FAILED
54
55
  if not ctx.categorized_action_flags[ActionPhase.PRE_INIT]:
55
56
  raise
56
57
  return None
57
- except Exception:
58
- ctx.state = ParseState.FAILED
59
- raise
60
- else:
61
- if ctx.state == ParseState.INITIAL:
62
- ctx.state = ParseState.COMPLETE
63
- return parsed
64
-
65
- @classmethod
66
- def __parse_args(cls, ctx: Context) -> Optional[CommandType]:
67
- params = ctx.params
68
- sub_cmd_param = params.sub_command
69
- if sub_cmd_param and not sub_cmd_param.choices:
70
- raise CommandDefinitionError(f'{ctx.command}.{sub_cmd_param.name} = {sub_cmd_param} has no sub Commands')
71
58
 
72
- cls(ctx)._parse_args(ctx)
59
+ def get_next_cmd(self, ctx: Context) -> Optional[CommandType]:
60
+ self._parse_args(ctx)
61
+ params = self.params
73
62
  params.validate_groups()
74
-
75
- if sub_cmd_param:
76
- next_cmd = sub_cmd_param.target() # type: CommandType
77
- missing = cls._missing(params, ctx)
78
- if missing and next_cmd.__class__.parent(next_cmd) is not ctx.command:
79
- ctx.state = ParseState.FAILED
80
- if ctx.categorized_action_flags[ActionPhase.PRE_INIT]:
81
- return None
63
+ missing = ctx.get_missing()
64
+ no_pre_init_action = not ctx.categorized_action_flags[ActionPhase.PRE_INIT]
65
+ next_cmd = params.sub_command.target() if params.sub_command else None
66
+ if next_cmd is not None:
67
+ if missing and no_pre_init_action and next_cmd.__class__.parent(next_cmd) is not ctx.command:
82
68
  raise ParamsMissing(missing)
83
- return next_cmd
84
-
85
- missing = cls._missing(params, ctx)
86
- if missing and not ctx.config.allow_missing and (not params.action or params.action not in missing):
87
- # Action is excluded because it provides a better error message
88
- if not ctx.categorized_action_flags[ActionPhase.PRE_INIT]:
69
+ elif missing and not ctx.config.allow_missing and (not params.action or params.action not in missing):
70
+ if no_pre_init_action:
89
71
  raise ParamsMissing(missing)
90
72
  elif ctx.remaining and not ctx.config.ignore_unknown:
91
- raise NoSuchOption('unrecognized arguments: {}'.format(' '.join(ctx.remaining)))
92
-
93
- return None
94
-
95
- @classmethod
96
- def _missing(cls, params: CommandParameters, ctx: Context) -> List[Parameter]:
97
- return [p for p in params.required_check_params() if ctx.num_provided(p) == 0]
73
+ raise NoSuchOption(f'unrecognized arguments: {" ".join(ctx.remaining)}') from None
74
+ return next_cmd
98
75
 
99
76
  def _parse_args(self, ctx: Context):
100
77
  self.arg_deque = arg_deque = self.handle_pass_thru(ctx)
@@ -254,7 +231,7 @@ class CommandParser:
254
231
  :param found: The number of values that were consumed by the given Parameter
255
232
  :return: The updated found count, if backtracking was possible, otherwise the unmodified found count
256
233
  """
257
- if not self.ctx.config.allow_backtrack or not self.positionals or found < 2:
234
+ if not self.config.allow_backtrack or not self.positionals or found < 2:
258
235
  return found
259
236
 
260
237
  can_pop = param.can_pop_counts()
@@ -269,7 +246,7 @@ class CommandParser:
269
246
  """
270
247
  Similar to :meth:`._maybe_backtrack`, but allows backtracking even after starting to process a Positional.
271
248
  """
272
- if not self.ctx.config.allow_backtrack:
249
+ if not self.config.allow_backtrack:
273
250
  return
274
251
 
275
252
  can_pop = self._last.can_pop_counts()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli-command-parser
3
- Version: 2023.4.15
3
+ Version: 2023.4.16
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
@@ -110,6 +110,16 @@ with optional dependencies::
110
110
 
111
111
  $ pip install -U cli-command-parser[wcwidth]
112
112
 
113
+
114
+ Python Version Compatibility
115
+ ============================
116
+
117
+ Python versions 3.7 and above are currently supported. CLI Command Parser will no longer support 3.7 after 2023-04-30,
118
+ ahead of the `official end of support for 3.7 on 2023-06-27 <https://devguide.python.org/versions/>`__.
119
+
120
+ When using 3.7 or 3.8, some additional packages that backport functionality that was added in later Python versions
121
+ are required for compatibility.
122
+
113
123
  To use the argparse to cli-command-parser conversion script with Python 3.7 or 3.8, there is a dependency on
114
124
  `astunparse <https://astunparse.readthedocs.io>`__. If you are using Python 3.9 or above, then ``astunparse`` is not
115
125
  necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
@@ -1,4 +1,7 @@
1
1
 
2
+ [:python_version < "3.8"]
3
+ importlib_metadata
4
+
2
5
  [conversion]
3
6
 
4
7
  [conversion:python_version < "3.9"]
@@ -81,6 +81,16 @@ with optional dependencies::
81
81
 
82
82
  $ pip install -U cli-command-parser[wcwidth]
83
83
 
84
+
85
+ Python Version Compatibility
86
+ ============================
87
+
88
+ Python versions 3.7 and above are currently supported. CLI Command Parser will no longer support 3.7 after 2023-04-30,
89
+ ahead of the `official end of support for 3.7 on 2023-06-27 <https://devguide.python.org/versions/>`__.
90
+
91
+ When using 3.7 or 3.8, some additional packages that backport functionality that was added in later Python versions
92
+ are required for compatibility.
93
+
84
94
  To use the argparse to cli-command-parser conversion script with Python 3.7 or 3.8, there is a dependency on
85
95
  `astunparse <https://astunparse.readthedocs.io>`__. If you are using Python 3.9 or above, then ``astunparse`` is not
86
96
  necessary because the relevant code was added to the stdlib ``ast`` module. If you're unsure, you can install
@@ -33,6 +33,8 @@ packages = find:
33
33
  package_dir = = lib
34
34
  python_requires = >=3.7
35
35
  tests_require = testtools; coverage
36
+ install_requires =
37
+ importlib_metadata; python_version<"3.8"
36
38
 
37
39
  [options.packages.find]
38
40
  where = lib