cli-command-parser 2023.10.20__tar.gz → 2024.4.20__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 (65) hide show
  1. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/PKG-INFO +3 -1
  2. cli_command_parser-2024.4.20/entry_points.txt +2 -0
  3. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/__init__.py +1 -1
  4. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/__version__.py +1 -1
  5. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/annotations.py +1 -0
  6. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/command_parameters.py +7 -7
  7. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/commands.py +127 -16
  8. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/config.py +2 -2
  9. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/context.py +7 -7
  10. cli_command_parser-2024.4.20/lib/cli_command_parser/conversion/cli.py +56 -0
  11. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/visitor.py +28 -6
  12. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/exceptions.py +1 -0
  13. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/formatting/commands.py +6 -7
  14. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/formatting/params.py +1 -0
  15. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/choices.py +1 -0
  16. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/files.py +3 -0
  17. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/patterns.py +2 -2
  18. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/time.py +6 -6
  19. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/metadata.py +108 -11
  20. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/base.py +24 -25
  21. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/choice_map.py +7 -9
  22. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/groups.py +8 -0
  23. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/option_strings.py +3 -3
  24. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/options.py +1 -7
  25. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parser.py +12 -9
  26. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/testing.py +6 -8
  27. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/utils.py +10 -2
  28. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser.egg-info/PKG-INFO +4 -2
  29. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser.egg-info/SOURCES.txt +3 -0
  30. cli_command_parser-2024.4.20/lib/cli_command_parser.egg-info/entry_points.txt +2 -0
  31. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/setup.cfg +3 -0
  32. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/LICENSE +0 -0
  33. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/MANIFEST.in +0 -0
  34. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/__main__.py +0 -0
  35. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/compat.py +0 -0
  36. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/__init__.py +0 -0
  37. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/__main__.py +0 -0
  38. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/argparse_ast.py +0 -0
  39. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/argparse_utils.py +0 -0
  40. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/command_builder.py +0 -0
  41. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/conversion/utils.py +0 -0
  42. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/core.py +0 -0
  43. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/documentation.py +0 -0
  44. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/error_handling.py +0 -0
  45. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/formatting/__init__.py +0 -0
  46. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/formatting/restructured_text.py +0 -0
  47. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/formatting/utils.py +0 -0
  48. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/__init__.py +0 -0
  49. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/base.py +0 -0
  50. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/exceptions.py +0 -0
  51. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/numeric.py +0 -0
  52. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/inputs/utils.py +0 -0
  53. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/nargs.py +0 -0
  54. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/__init__.py +0 -0
  55. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/actions.py +0 -0
  56. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/pass_thru.py +0 -0
  57. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parameters/positionals.py +0 -0
  58. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/parse_tree.py +0 -0
  59. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser/typing.py +0 -0
  60. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser.egg-info/dependency_links.txt +0 -0
  61. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser.egg-info/requires.txt +0 -0
  62. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/lib/cli_command_parser.egg-info/top_level.txt +0 -0
  63. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/pyproject.toml +0 -0
  64. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/readme.rst +0 -0
  65. {cli_command_parser-2023.10.20 → cli_command_parser-2024.4.20}/requirements-dev.txt +0 -0
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli_command_parser
3
- Version: 2023.10.20
3
+ Version: 2024.4.20
4
4
  Summary: CLI Command Parser
5
5
  Home-page: https://github.com/dskrypa/cli_command_parser
6
6
  Author: Doug Skrypa
7
7
  Author-email: dskrypa@gmail.com
8
8
  License: Apache 2.0
9
9
  Project-URL: Source, https://github.com/dskrypa/cli_command_parser
10
+ Project-URL: Documentation, https://dskrypa.github.io/cli_command_parser
11
+ Project-URL: Issues, https://github.com/dskrypa/cli_command_parser/issues
10
12
  Classifier: Development Status :: 5 - Production/Stable
11
13
  Classifier: Environment :: Console
12
14
  Classifier: Intended Audience :: Developers
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ argparse_to_command.py = cli_command_parser.conversion.cli:main
@@ -12,7 +12,7 @@ from .config import (
12
12
  AmbiguousComboMode,
13
13
  AllowLeadingDash,
14
14
  )
15
- from .commands import Command, main
15
+ from .commands import Command, AsyncCommand, main
16
16
  from .context import Context, get_current_context, ctx, get_parsed, get_context, get_raw_arg
17
17
  from .exceptions import (
18
18
  CommandParserException,
@@ -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.10.20'
4
+ __version__ = '2024.04.20'
5
5
  __author__ = 'Doug Skrypa'
6
6
  __author_email__ = 'dskrypa@gmail.com'
7
7
  __license__ = 'Apache 2.0'
@@ -17,6 +17,7 @@ __all__ = ['get_descriptor_value_type']
17
17
 
18
18
 
19
19
  def get_descriptor_value_type(command_cls: type, attr: str) -> Optional[type]:
20
+ # TODO: Optimize this to cache get_type_hints for a given class?
20
21
  try:
21
22
  annotation = get_type_hints(command_cls)[attr]
22
23
  except (KeyError, NameError): # KeyError due to attr missing; NameError for forward references
@@ -92,10 +92,10 @@ class CommandParameters:
92
92
  return self.positionals
93
93
 
94
94
  def get_positionals_to_parse(self, ctx: Context) -> List[BasePositional]:
95
- if positionals := self.all_positionals:
96
- for i, param in enumerate(positionals):
95
+ if self.all_positionals:
96
+ for i, param in enumerate(self.all_positionals):
97
97
  if not ctx.num_provided(param):
98
- return [p for p in positionals[i:]]
98
+ return [p for p in self.all_positionals[i:]]
99
99
 
100
100
  return []
101
101
 
@@ -391,15 +391,15 @@ class CommandParameters:
391
391
  else:
392
392
  yield from self.all_positionals
393
393
  yield from self.options
394
- if (pass_thru := self.pass_thru) and pass_thru not in exclude:
395
- yield pass_thru
394
+ if self.pass_thru and self.pass_thru not in exclude:
395
+ yield self.pass_thru
396
396
 
397
397
  def required_check_params(self) -> Iterator[Parameter]:
398
398
  ignore = SubCommand
399
399
  yield from (p for p in self.all_positionals if p.required and not p.group and not isinstance(p, ignore))
400
400
  yield from (p for p in self.options if p.required and not p.group)
401
- if (pass_thru := self._pass_thru) and pass_thru.required and not pass_thru.group:
402
- yield pass_thru
401
+ if self._pass_thru and self._pass_thru.required and not self._pass_thru.group:
402
+ yield self._pass_thru
403
403
 
404
404
 
405
405
  def _find_ambiguous_combos(
@@ -15,6 +15,7 @@ from .core import CommandMeta, get_top_level_commands, get_params
15
15
  from .context import Context, ActionPhase, get_or_create_context
16
16
  from .exceptions import ParamConflict
17
17
  from .parser import parse_args_and_get_next_cmd
18
+ from .utils import maybe_await
18
19
 
19
20
  if TYPE_CHECKING:
20
21
  from .typing import Bool, CommandObj
@@ -28,6 +29,9 @@ Argv = Sequence[str]
28
29
  class Command(ABC, metaclass=CommandMeta):
29
30
  """The main class that other Commands should extend."""
30
31
 
32
+ # TODO: Make the distinction between help/description clearer, or merge them?
33
+ # TODO: Pull help text from docstring for subcommands if not specified as help=?
34
+
31
35
  #: The parsing Context used for this Command. Provided here for convenience - this reference to it is not used by
32
36
  #: any CLI Command Parser internals, so it is safe for subclasses to redefine / overwrite it.
33
37
  ctx: Context
@@ -155,17 +159,7 @@ class Command(ABC, metaclass=CommandMeta):
155
159
 
156
160
  return ctx.actions_taken
157
161
 
158
- def _pre_init_actions_(self, *args, **kwargs):
159
- """
160
- The first method called by :meth:`.__call__` (before :meth:`.main` and others).
161
-
162
- Validates the number of ActionFlags that were specified, and calls all of the specified
163
- :func:`~.options.before_main` / :obj:`~.options.action_flag` actions such as ``--help`` that were
164
- defined with ``before_main=True`` and ``always_available=True`` in their configured order.
165
-
166
- :param args: Positional arguments to pass to the :obj:`~.options.action_flag` methods
167
- :param kwargs: Keyword arguments to pass to the :obj:`~.options.action_flag` methods
168
- """
162
+ def _check_param_conflicts_(self):
169
163
  # TODO: --help should take precedence over input validation - right now, if a Path input expecting a
170
164
  # non-existent file receives a file that exists, that error is reported instead of showing help text
171
165
  ctx = self.__ctx
@@ -178,9 +172,24 @@ class Command(ABC, metaclass=CommandMeta):
178
172
  if action is not None and not ctx.config.action_after_action_flags:
179
173
  raise ParamConflict([action, *before], 'combining an action with action flags is disabled')
180
174
 
181
- for param in ctx.iter_action_flags(ActionPhase.PRE_INIT):
175
+ def _run_actions_(self, phase: ActionPhase, args: tuple, kwargs: dict):
176
+ for param in self.__ctx.iter_action_flags(phase):
182
177
  param.func(self, *args, **kwargs)
183
178
 
179
+ def _pre_init_actions_(self, *args, **kwargs):
180
+ """
181
+ The first method called by :meth:`.__call__` (before :meth:`.main` and others).
182
+
183
+ Validates the number of ActionFlags that were specified, and calls all of the specified
184
+ :func:`~.options.before_main` / :obj:`~.options.action_flag` actions such as ``--help`` that were
185
+ defined with ``before_main=True`` and ``always_available=True`` in their configured order.
186
+
187
+ :param args: Positional arguments to pass to the :obj:`~.options.action_flag` methods
188
+ :param kwargs: Keyword arguments to pass to the :obj:`~.options.action_flag` methods
189
+ """
190
+ self._check_param_conflicts_()
191
+ self._run_actions_(ActionPhase.PRE_INIT, args, kwargs)
192
+
184
193
  def _init_command_(self, *args, **kwargs):
185
194
  """
186
195
  Called by :meth:`.__call__` after :meth:`._pre_init_actions_` and before :meth:`._before_main_`.
@@ -208,8 +217,7 @@ class Command(ABC, metaclass=CommandMeta):
208
217
  :param args: Positional arguments to pass to the :obj:`~.options.action_flag` methods
209
218
  :param kwargs: Keyword arguments to pass to the :obj:`~.options.action_flag` methods
210
219
  """
211
- for param in self.__ctx.iter_action_flags(ActionPhase.BEFORE_MAIN):
212
- param.func(self, *args, **kwargs)
220
+ self._run_actions_(ActionPhase.BEFORE_MAIN, args, kwargs)
213
221
 
214
222
  def main(self, *args, **kwargs) -> Optional[int]:
215
223
  """
@@ -245,8 +253,111 @@ class Command(ABC, metaclass=CommandMeta):
245
253
  :param args: Positional arguments to pass to the :obj:`~.options.action_flag` methods
246
254
  :param kwargs: Keyword arguments to pass to the :obj:`~.options.action_flag` methods
247
255
  """
248
- for param in self.__ctx.iter_action_flags(ActionPhase.AFTER_MAIN):
249
- param.func(self, *args, **kwargs)
256
+ self._run_actions_(ActionPhase.AFTER_MAIN, args, kwargs)
257
+
258
+
259
+ class AsyncCommand(Command, ABC):
260
+ """
261
+ Asynchronous version of the main class that other Commands should extend.
262
+
263
+ To run an AsyncCommand, both :func:`main` and :meth:`.parse_and_run` can be used as if running a synchronous
264
+ :class:`Command`. The asynchronous version of :meth:`.parse_and_run` handles calling :func:`python:asyncio.run`.
265
+
266
+ For applications that need more direct control over how the event loop is run, :meth:`.parse_and_await` can be
267
+ used instead.
268
+
269
+ All `_sunder_` methods supported by :class:`Command` may be overridden with either synchronous or async versions,
270
+ and :class:`~.choice_map.Action` target methods may similarly be defined either way as well.
271
+ """
272
+
273
+ @classmethod
274
+ def parse_and_run(cls, argv=None, **kwargs):
275
+ """
276
+ Asynchronous version of :meth:`Command.parse_and_run`. Argument parsing is handled synchronously, then
277
+ :func:`python:asyncio.run` is called with the parsed command's :meth:`.__call__` coroutine.
278
+
279
+ For applications that need more direct control over how the event loop is run, :meth:`.parse_and_await` can be
280
+ used instead.
281
+ """
282
+ import asyncio
283
+
284
+ ctx = get_or_create_context(cls, argv)
285
+ with ctx.get_error_handler():
286
+ self = cls.parse(argv)
287
+
288
+ try:
289
+ self
290
+ except UnboundLocalError: # There was an error handled during parsing, so self was not defined
291
+ return None
292
+ else:
293
+ asyncio.run(self(**kwargs))
294
+ return self
295
+
296
+ @classmethod
297
+ async def parse_and_await(cls, argv=None, **kwargs):
298
+ """
299
+ Coroutine alternative to :meth:`.parse_and_run`. This method does NOT call :func:`python:asyncio.run` - it is
300
+ meant to be used as ``await MyCommand.parse_and_await()`` with an existing event loop.
301
+
302
+ Simpler applications can likely use the easier :func:`main` function or :meth:`.parse_and_run` instead.
303
+ """
304
+ ctx = get_or_create_context(cls, argv)
305
+ with ctx.get_error_handler():
306
+ self = cls.parse(argv)
307
+
308
+ try:
309
+ self
310
+ except UnboundLocalError: # There was an error handled during parsing, so self was not defined
311
+ return None
312
+ else:
313
+ await maybe_await(self(**kwargs))
314
+ return self
315
+
316
+ async def __call__(self, *args, **kwargs) -> int:
317
+ """Asynchronous version of :meth:`Command.__call__`."""
318
+ with self._Command__ctx as ctx, ctx.get_error_handler(): # noqa
319
+ await maybe_await(self._pre_init_actions_(*args, **kwargs))
320
+ await maybe_await(self._init_command_(*args, **kwargs))
321
+ await maybe_await(self._before_main_(*args, **kwargs))
322
+ try:
323
+ await maybe_await(self.main(*args, **kwargs))
324
+ except BaseException:
325
+ if ctx.config.always_run_after_main:
326
+ log.debug('Caught exception - running _after_main_ before propagating', exc_info=True)
327
+ await maybe_await(self._after_main_(*args, **kwargs))
328
+ raise
329
+ else:
330
+ await maybe_await(self._after_main_(*args, **kwargs))
331
+
332
+ return ctx.actions_taken
333
+
334
+ async def _run_actions_(self, phase: ActionPhase, args: tuple, kwargs: dict):
335
+ """Asynchronous version of :meth:`Command._run_actions_`."""
336
+ for param in self._Command__ctx.iter_action_flags(phase): # noqa
337
+ await maybe_await(param.func(self, *args, **kwargs))
338
+
339
+ async def _pre_init_actions_(self, *args, **kwargs):
340
+ """Asynchronous version of :meth:`Command._pre_init_actions_`."""
341
+ self._check_param_conflicts_()
342
+ await self._run_actions_(ActionPhase.PRE_INIT, args, kwargs)
343
+
344
+ async def _before_main_(self, *args, **kwargs):
345
+ """Asynchronous version of :meth:`Command._before_main_`."""
346
+ await self._run_actions_(ActionPhase.BEFORE_MAIN, args, kwargs)
347
+
348
+ async def main(self, *args, **kwargs) -> Optional[int]:
349
+ """Asynchronous version of :meth:`Command.main`."""
350
+ with self._Command__ctx as ctx: # noqa
351
+ action = get_params(self).action
352
+ if action is not None and (ctx.actions_taken == 0 or ctx.config.action_after_action_flags):
353
+ ctx.actions_taken += 1
354
+ await maybe_await(action.target()(self, *args, **kwargs))
355
+
356
+ return ctx.actions_taken
357
+
358
+ async def _after_main_(self, *args, **kwargs):
359
+ """Asynchronous version of :meth:`Command._after_main_`."""
360
+ await self._run_actions_(ActionPhase.AFTER_MAIN, args, kwargs)
250
361
 
251
362
 
252
363
  def main(argv: Argv = None, return_command: Bool = False, **kwargs) -> Optional[CommandObj]:
@@ -244,8 +244,8 @@ class ConfigItem(Generic[CV, DV]):
244
244
  def __set__(self, instance: CommandConfig, value: ConfigValue):
245
245
  if instance._read_only:
246
246
  raise AttributeError(f'Unable to set attribute {self.name}={value!r} because {instance} is read-only')
247
- elif (type_func := self.type) is not None:
248
- value = type_func(value)
247
+ elif self.type is not None:
248
+ value = self.type(value)
249
249
  instance._data[self.name] = value
250
250
 
251
251
  def __delete__(self, instance: CommandConfig):
@@ -146,8 +146,8 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
146
146
  @property
147
147
  def terminal_width(self) -> int:
148
148
  """Returns the current terminal width as the number of characters that fit on a single line."""
149
- if (width := self._terminal_width) is not None:
150
- return width
149
+ if self._terminal_width is not None:
150
+ return self._terminal_width
151
151
  return _TERMINAL.width
152
152
 
153
153
  def get_parsed(
@@ -178,18 +178,18 @@ class Context(AbstractContextManager): # Extending AbstractContextManager to ma
178
178
  if command is None:
179
179
  command = self.command
180
180
  with self:
181
- if recursive and (parent := self.parent):
182
- parsed = parent.get_parsed(
181
+ if recursive and self.parent:
182
+ parsed = self.parent.get_parsed(
183
183
  command, exclude=exclude, recursive=recursive, default=default, include_defaults=include_defaults
184
184
  )
185
185
  else:
186
186
  parsed = {}
187
187
 
188
188
  # TODO: Add way to get a nested dict with ParamGroup names as the keys of the nested sections?
189
- if params := self.params:
190
- for param in params.iter_params(exclude):
189
+ if self.params:
190
+ for param in self.params.iter_params(exclude):
191
191
  if include_defaults or param in self._parsed:
192
- parsed[param.name] = param.result_value(command, default)
192
+ parsed[param.name] = param.result(command, default)
193
193
 
194
194
  return parsed
195
195
 
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from functools import cached_property
5
+ from pathlib import Path
6
+
7
+ from cli_command_parser import Command, Counter, Positional, Flag, ParamGroup, SubCommand, main
8
+ from cli_command_parser.inputs import Path as IPath
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ arg_parser = 'argparse.ArgumentParser'
13
+ cli_cp_cmd = 'cli-command-parser Command'
14
+
15
+
16
+ class ParserConverter(Command, description=f'Tool to convert an {arg_parser} into a {cli_cp_cmd}'):
17
+ action = SubCommand()
18
+ input: Path
19
+ no_smart_for = Flag('-S', help='Disable "smart" for loop handling that attempts to dedupe common subparser params')
20
+ with ParamGroup('Common'):
21
+ verbose = Counter('-v', help='Increase logging verbosity (can specify multiple times)')
22
+ dry_run = Flag('-D', help='Print the actions that would be taken instead of taking them')
23
+
24
+ def _init_command_(self):
25
+ log_fmt = '%(asctime)s %(levelname)s %(name)s %(lineno)d %(message)s' if self.verbose > 1 else '%(message)s'
26
+ logging.basicConfig(level=logging.DEBUG if self.verbose else logging.INFO, format=log_fmt)
27
+
28
+ @cached_property
29
+ def script(self):
30
+ from cli_command_parser.conversion import Script
31
+
32
+ script = Script(self.input.read_text(), not self.no_smart_for, path=self.input)
33
+ log.debug(f'Found {script=}')
34
+ return script
35
+
36
+
37
+ class Convert(ParserConverter):
38
+ input: Path = Positional(type=IPath(type='file', exists=True), help=f'A file containing an {arg_parser}')
39
+ add_methods = Flag('--no-methods', '-M', default=True, help='Do not include boilerplate methods in Commands')
40
+
41
+ def main(self):
42
+ from cli_command_parser.conversion import convert_script
43
+
44
+ print(convert_script(self.script, self.add_methods))
45
+
46
+
47
+ class Pprint(ParserConverter):
48
+ input: Path = Positional(type=IPath(type='file', exists=True), help=f'A file containing an {arg_parser}')
49
+
50
+ def main(self):
51
+ for parser in self.script.parsers:
52
+ parser.pprint()
53
+
54
+
55
+ if __name__ == '__main__':
56
+ main()
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import re
4
5
  from ast import NodeVisitor, AST, Assign, Call, For, Attribute, Name, Import, ImportFrom, expr
5
6
  from collections import ChainMap, defaultdict
6
7
  from functools import partial, wraps
@@ -43,7 +44,7 @@ class ScriptVisitor(NodeVisitor):
43
44
 
44
45
  def __init__(self, smart_loop_handling: bool = True, track_refs: Collection[TrackedRef] = ()):
45
46
  self.smart_loop_handling = smart_loop_handling
46
- self.scopes = ChainMap()
47
+ self.scopes = ChainMap() # ChainMap that tracks the var/class/func/etc names available in a given scope
47
48
  self._tracked_refs = set()
48
49
  self._mod_name_tracked_map = defaultdict(dict)
49
50
  for ref in track_refs:
@@ -74,11 +75,32 @@ class ScriptVisitor(NodeVisitor):
74
75
  self.scopes[f'{as_name}.{name}'] = tracked
75
76
 
76
77
  def visit_ImportFrom(self, node: ImportFrom):
77
- if name_tracked_map := self._mod_name_tracked_map.get(node.module):
78
- for name, as_name in imp_names(node):
79
- if tracked := name_tracked_map.get(name):
80
- log.debug(f'Found tracked import: {node.module}.{name} as {as_name}')
81
- self.scopes[as_name] = tracked
78
+ """
79
+ Processes a ``from module import names`` statement. If the module name matches one from which members were
80
+ registered to be tracked, then the imported names (and any ``as`` aliases) are processed. Members with
81
+ canonical names that match an item that was registered to be tracked are added to the current scope / variable
82
+ namespace.
83
+
84
+ Relative module imports are handled fuzzily - no attempt is made to determine the fully qualified module name
85
+ for the source file or to resolve what the relative import's fully qualified module name would be. This may
86
+ result in incorrect items being tracked if the name matched a tracked name in the matched tracked module.
87
+ """
88
+ if level := node.level:
89
+ # For absolute imports, the level is 0
90
+ # For relative imports, the level is a positive int, representing the number of relative package levels
91
+ matches = re.compile(r'[^.]+\.' * level + re.escape(node.module) + '$').search
92
+ for module, name_tracked_map in self._mod_name_tracked_map.items():
93
+ if matches(module):
94
+ log.debug(f'Found fuzzy relative name match for {"." * level + node.module!r} to {module=}')
95
+ self._maybe_track_import_from(node, name_tracked_map)
96
+ elif name_tracked_map := self._mod_name_tracked_map.get(node.module):
97
+ self._maybe_track_import_from(node, name_tracked_map)
98
+
99
+ def _maybe_track_import_from(self, node: ImportFrom, name_tracked_map):
100
+ for name, as_name in imp_names(node):
101
+ if tracked := name_tracked_map.get(name):
102
+ log.debug(f'Found tracked import: {node.module}.{name} as {as_name}')
103
+ self.scopes[as_name] = tracked
82
104
 
83
105
  # endregion
84
106
 
@@ -123,6 +123,7 @@ class UsageError(CommandParserException):
123
123
  """Base exception for user errors"""
124
124
 
125
125
  message: str = None
126
+ # TODO: Document that this is a recommended exception type to use when performing manual input validation
126
127
 
127
128
 
128
129
  class ParamUsageError(UsageError):
@@ -70,8 +70,8 @@ class CommandHelpFormatter:
70
70
  params = self.params
71
71
  yield from params.all_positionals
72
72
  yield from params.options
73
- if (pass_thru := params.pass_thru) is not None:
74
- yield pass_thru
73
+ if params.pass_thru is not None:
74
+ yield params.pass_thru
75
75
 
76
76
  def _usage_parts(self, sub_cmd_choice: str = None, allow_sys_argv: Bool = True) -> Iterator[str]:
77
77
  yield 'usage:'
@@ -191,8 +191,8 @@ class CommandHelpFormatter:
191
191
  # each choice value (should probably be configurable to explode or condense)
192
192
  choice_str = f'{choice_base} {cmd_name}' if choice_base else cmd_name
193
193
  yield from spaced_rst_header(f'Subcommand: {choice_str}', level)
194
- if choice_help := choice.help:
195
- yield choice_help
194
+ if choice.help:
195
+ yield choice.help
196
196
  yield ''
197
197
 
198
198
  if (command := choice.target) is None:
@@ -200,10 +200,9 @@ class CommandHelpFormatter:
200
200
  yield from self._cmd_rst_lines(config, choice_str, allow_sys_argv)
201
201
  else:
202
202
  params = get_params(command)
203
- formatter = params.formatter
204
- yield from formatter._cmd_rst_lines(config, choice_str, allow_sys_argv)
203
+ yield from params.formatter._cmd_rst_lines(config, choice_str, allow_sys_argv)
205
204
  if nested_sub_cmd := params.sub_command:
206
- yield from formatter._sub_cmds_rst_lines(
205
+ yield from params.formatter._sub_cmds_rst_lines(
207
206
  config, nested_sub_cmd, level, choice_str, depth + 1, allow_sys_argv
208
207
  )
209
208
 
@@ -447,6 +447,7 @@ class GroupHelpFormatter(ParamHelpFormatter, param_cls=ParamGroup): # noqa # p
447
447
  return f'{adjective} arguments'
448
448
 
449
449
  def _get_spacer(self) -> str:
450
+ # TODO: Config option to set these chars OR to have them just be spaces
450
451
  if self.param.mutually_exclusive:
451
452
  return '\u00A6 ' # BROKEN BAR
452
453
  elif self.param.mutually_dependent:
@@ -135,6 +135,7 @@ class ChoiceMap(Choices[T]):
135
135
 
136
136
  def __init__(self, choices: Mapping[Any, T], *args, **kwargs):
137
137
  super().__init__(choices, *args, **kwargs)
138
+ # TODO: Alternate ChoiceMap where values are used as help text, similar to SubCommand with local_choices
138
139
 
139
140
  def __call__(self, value: str) -> T:
140
141
  value = self._normalize(value)
@@ -200,6 +200,9 @@ class Json(Serialized):
200
200
  def __init__(self, *, mode: str = 'rb', **kwargs):
201
201
  import json
202
202
 
203
+ # TODO: catch JSONDecodeError and provide a standardized cleaner error message (with a way to disable this error handling)
204
+ # TODO: Update docs to not suggest importing `inputs as i`
205
+
203
206
  write = allows_write(mode, True)
204
207
  kwargs['pass_file'] = write # json.load just calls loads with f.read()
205
208
  super().__init__(json.dump if write else json.loads, mode=mode, **kwargs)
@@ -124,8 +124,8 @@ class Regex(PatternInput[RegexResult]):
124
124
  elif mode == RegexMode.GROUP:
125
125
  return m.group(self.group)
126
126
  elif mode == RegexMode.GROUPS:
127
- if groups := self.groups:
128
- return tuple(m.group(g) for g in groups)
127
+ if self.groups:
128
+ return tuple(m.group(g) for g in self.groups)
129
129
  return m.groups()
130
130
  else: # mode == RegexMode.DICT
131
131
  return m.groupdict()
@@ -56,7 +56,7 @@ class different_locale:
56
56
 
57
57
  def __enter__(self):
58
58
  self._lock.acquire()
59
- if not (locale := self.locale):
59
+ if not self.locale:
60
60
  return
61
61
  # locale.getlocale does not support LC_ALL, but `setlocale(LC_ALL)` with no locale to set will return a str
62
62
  # containing all of the current locale settings as `key1=val1;key2=val2;...;keyN=valN`
@@ -65,7 +65,7 @@ class different_locale:
65
65
  # to remain set to `English_United States.1252` on Windows 10, which resulted in incorrectly encoded results.
66
66
  # Using f'LC_CTYPE={locale};LC_TIME={locale}' seemed cleaner than setting LC_ALL in its entirety, but it
67
67
  # resulted in `locale.Error: unsupported locale setting` on Ubuntu/WSL.
68
- setlocale(LC_ALL, locale)
68
+ setlocale(LC_ALL, self.locale)
69
69
 
70
70
  def __exit__(self, exc_type, exc_val, exc_tb):
71
71
  if self.locale:
@@ -212,13 +212,13 @@ class CalendarUnitInput(DTInput[Union[str, int]], ABC):
212
212
  normalized = self.parse(value)
213
213
  if normalized < self._min_index:
214
214
  raise InvalidChoiceError(value, self.choices(), self.dt_type)
215
- elif (out_mode := self.out_format) in (DTFormatMode.NUMERIC, DTFormatMode.NUMERIC_ISO):
216
- return self._formats[out_mode][normalized]
217
- elif (names_or_abbreviations := self._formats.get(out_mode)) is not None:
215
+ elif self.out_format in (DTFormatMode.NUMERIC, DTFormatMode.NUMERIC_ISO):
216
+ return self._formats[self.out_format][normalized]
217
+ elif (names_or_abbreviations := self._formats.get(self.out_format)) is not None:
218
218
  with different_locale(self.out_locale):
219
219
  return names_or_abbreviations[normalized]
220
220
 
221
- raise ValueError(f'Unexpected output format={out_mode!r} for {self.dt_type}={normalized}')
221
+ raise ValueError(f'Unexpected output format={self.out_format!r} for {self.dt_type}={normalized}')
222
222
 
223
223
 
224
224
  class Day(CalendarUnitInput, dt_type='day of the week'):