Coverage for arguably/_context.py: 89%
352 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-10 01:01 +0000
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-10 01:01 +0000
1from __future__ import annotations
3import argparse
4import enum
5import inspect
6from contextlib import contextmanager
7from dataclasses import dataclass
8from typing import Any, TextIO, Union, Optional, List, Dict, Type, Tuple, Callable, Iterator, cast
10from ._argparse_extensions import HelpFormatter, FlagAction, ArgumentParser
11from ._commands import CommandDecoratorInfo, SubtypeDecoratorInfo, Command, CommandArg, InputMethod
12from ._modifiers import TupleModifier, ListModifier
13from ._util import (
14 logger,
15 log_args,
16 ArguablyException,
17 normalize_name,
18 NoDefault,
19 get_type_hints,
20 info_for_flags,
21 get_ancestors,
22 get_parser_name,
23 warn,
24 func_or_class_info,
25)
28@dataclass
29class _ContextOptions:
30 name: Optional[str]
32 # Behavior options
33 always_subcommand: bool
34 version_flag: Union[bool, Tuple[str], Tuple[str, str]]
35 strict: bool
37 # Formatting options
38 show_defaults: bool
39 show_types: bool
40 command_metavar: str
41 max_description_offset: int
42 max_width: int
43 output: Optional[TextIO]
45 def __post_init__(self) -> None:
46 # When running as a module, show the script name as the module path.
47 # Otherwise, use default argparse behavior
48 if self.name is None:
49 try:
50 import importlib.util
52 self.name = importlib.util.find_spec("__main__").name # type: ignore[union-attr]
53 except (ValueError, AttributeError):
54 self.name = None
56 def get_version_flags(self) -> Union[Tuple[()], Tuple[str], Tuple[str, str]]:
57 if self.version_flag is False:
58 return cast(Tuple[()], tuple())
59 elif self.version_flag is True:
60 return ("--version",)
61 else:
62 return self.version_flag
65class _Context:
66 """Singleton, used for storing arguably state."""
68 def __init__(self) -> None:
69 # These are `None` right now, they're set during `run()`. No methods making use of them are called before then.
70 self._options: _ContextOptions = None # type: ignore[assignment]
71 self._extra_argparser_options: Dict[str, Any] = None # type: ignore[assignment]
73 # Info for all invocations of `@arguably.command`
74 self._command_decorator_info: List[CommandDecoratorInfo] = list()
76 # Info for all invocations of `@arguably.subtype`
77 self._subtype_init_info: List[SubtypeDecoratorInfo] = list()
79 # Stores mapping from normalized names for an enum type to an enum value
80 self._enum_mapping: Dict[Type[enum.Enum], Dict[str, enum.Enum]] = dict()
82 # Stores which flag arguments have had their default value cleared
83 self._enum_flag_default_cleared: set[Tuple[argparse.ArgumentParser, str]] = set()
85 # Are we currently calling the targeted command (or just an ancestor?)
86 self._is_calling_target = True
88 # Used for handling `error()`, keeps a reference to the parser for the current command
89 self._current_parser: Optional[argparse.ArgumentParser] = None
91 # These are really only set and used in the run() method
92 self._commands: Dict[str, Command] = dict()
93 self._command_aliases: Dict[str, str] = dict()
94 self._parsers: Dict[str, argparse.ArgumentParser] = dict()
95 self._subparsers: Dict[str, Any] = dict()
97 def reset(self) -> None:
98 self.__dict__.clear()
99 self.__init__() # type: ignore[misc]
101 def add_command(self, **kwargs: Any) -> None:
102 """Invoked by `@arguably.command`, saves info about a command to include when the parser is set up."""
103 info = CommandDecoratorInfo(**kwargs)
104 self._command_decorator_info.append(info)
106 def add_subtype(self, **kwargs: Any) -> None:
107 """Invoked by `@arguably.subtype`, saves info about a how to construct a type."""
108 type_ = SubtypeDecoratorInfo(**kwargs)
109 self._subtype_init_info.append(type_)
111 def find_subtype(self, func_arg_type: type) -> List[SubtypeDecoratorInfo]:
112 return [bi for bi in self._subtype_init_info if issubclass(bi.type_, func_arg_type)]
114 def is_target(self) -> bool:
115 """
116 Only useful if `invoke_ancestors=True`. Returns `True` if the targeted command is being executed and `False` if
117 not. This is safe to call even if `arguably` is not being used, since it returns `True` if `arguably.run()` is
118 not being used.
120 Returns:
121 `False` if `arguably.run()` was called and the currently running command is not the targeted command, `True`
122 in every other case.
124 Examples:
125 ```python
126 import arguably
128 @arguably.command
129 def __root__(*, config_file=None):
130 print(f"Using config {config_file}")
131 if not arguably.is_target():
132 return
133 print("__root__ is the target!")
135 @arguably.command
136 def hi():
137 print("hi is the target!")
139 @arguably.command
140 def bye():
141 print("bye is the target!")
143 if __name__ == "__main__":
144 arguably.run()
145 ```
147 ```console
148 user@machine:~$ python3 is_target.py --config-file foo.yml
149 Using config foo.yml
150 __root__ is the target!
151 ```
153 ```console
154 user@machine:~$ python3 is_target.py --config-file foo.yml hi
155 Using config foo.yml
156 hi is the target!
157 ```
158 """
159 return self._is_calling_target
161 def check_and_set_enum_flag_default_status(self, parser: argparse.ArgumentParser, cli_arg_name: str) -> bool:
162 key = (parser, cli_arg_name)
163 present = key in self._enum_flag_default_cleared
164 self._enum_flag_default_cleared.add(key)
165 return present
167 def _formatter(self, prog: str) -> HelpFormatter:
168 """HelpFormatter for argparse, hooks up our max_name_width and max_width options."""
169 return HelpFormatter(
170 prog, max_help_position=self._options.max_description_offset, width=self._options.max_width
171 )
173 def set_up_enum(
174 self, enum_type: Type[enum.Enum], members: Optional[List[enum.Enum]] = None
175 ) -> Dict[str, enum.Enum]:
176 if enum_type not in self._enum_mapping:
177 enum_name_dict: Dict[str, enum.Enum] = dict()
178 self._enum_mapping[enum_type] = enum_name_dict
180 for enum_item in enum_type:
181 if members is not None and enum_item not in members:
182 continue
183 enum_name = normalize_name(enum_item.name, spaces=False)
184 if enum_name in enum_name_dict:
185 raise ArguablyException(
186 f"Normalized name {enum_name} already taken for enum {enum_type.__name__} by "
187 f"{enum_name_dict[enum_name]}"
188 )
189 enum_name_dict[enum_name] = enum_item
191 return self._enum_mapping[enum_type]
193 def get_enum_mapping(self, enum_type: Type[enum.Enum]) -> Dict[str, enum.Enum]:
194 assert enum_type in self._enum_mapping
195 return self._enum_mapping[enum_type]
197 def _validate_args(self, cmd: Command, is_root_cmd: bool) -> None:
198 """Validates all arguments that will be added to the parser for a given command"""
200 for arg_ in cmd.args:
201 arg_aliases = arg_.get_options()
203 # Validate no conflict with `--version` flag (or whatever it was set to)
204 if is_root_cmd:
205 version_flags = self._options.get_version_flags()
206 conflicts = [opt for opt in arg_aliases if opt in version_flags]
207 if len(conflicts) > 0:
208 raise ArguablyException(
209 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` conflicts with version flag."
210 f"Conflicting items: {', '.join(conflicts)}"
211 )
213 # Validate no conflicts with `-h/--help`
214 if cmd.add_help:
215 help_flags = ("-h", "--help")
216 conflicts = [opt for opt in arg_aliases if opt in help_flags]
217 if len(conflicts) > 0:
218 raise ArguablyException(
219 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` conflicts with help flag."
220 f"Conflicting items: {', '.join(conflicts)}"
221 )
223 # Validate positional arg names
224 if arg_.input_method.is_positional:
225 if arg_.func_arg_name == self._options.command_metavar:
226 raise ArguablyException(
227 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is named the same as "
228 f"`command_metavar`. Either change the parameter name or set the `command_metavar` option to "
229 f"something other than `{arg_.func_arg_name}` when calling arguably.run()"
230 )
232 # Validate `enum.Flag`
233 if issubclass(arg_.arg_value_type, enum.Flag):
234 if arg_.input_method.is_positional:
235 raise ArguablyException(
236 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is both positional and an enum.Flag."
237 f" Positional enum flags are unsupported, since they are turned into options."
238 )
239 if arg_.default is NoDefault:
240 raise ArguablyException(
241 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is an enum.Flag. Due to "
242 f"implementation limitations, all enum.Flag parameters must have a default value."
243 )
245 # Validate `bool`
246 if issubclass(arg_.arg_value_type, bool):
247 if arg_.input_method is not InputMethod.OPTION or arg_.default is NoDefault:
248 raise ArguablyException(
249 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is a `bool`. Boolean parameters "
250 f"must have a default value and be an optional, not a positional, argument."
251 )
253 def _set_up_args(self, cmd: Command) -> None:
254 """Adds all arguments to the parser for a given command"""
256 parser = self._parsers[cmd.name]
258 for arg_ in cmd.args:
259 # Short-circuit, different path for enum.Flag. We add multiple options, one for each flag entry
260 if issubclass(arg_.arg_value_type, enum.Flag):
261 parser.set_defaults(**{arg_.cli_arg_name: arg_.default})
262 for entry in info_for_flags(arg_.cli_arg_name, arg_.arg_value_type):
263 argspec = log_args(
264 logger.debug,
265 f"Parser({repr(get_parser_name(parser.prog))}).",
266 parser.add_argument.__name__,
267 # Args for the call are below:
268 *entry.option,
269 action=FlagAction,
270 const=entry,
271 nargs=0,
272 help=entry.description,
273 )
274 parser.add_argument(*argspec.args, **argspec.kwargs)
275 continue
277 # Optional kwargs for parser.add_argument
278 add_arg_kwargs: Dict[str, Any] = dict(type=arg_.arg_value_type, action="store")
280 arg_description = arg_.description
281 description_extras = []
283 # Show arg type?
284 if self._options.show_types:
285 list_modifiers = [m for m in arg_.modifiers if isinstance(m, ListModifier)]
286 tuple_modifiers = [m for m in arg_.modifiers if isinstance(m, TupleModifier)]
287 if len(tuple_modifiers) > 0:
288 assert len(tuple_modifiers) == 1
289 type_name = f"({','.join(t.__name__ for t in tuple_modifiers[0].tuple_arg)})"
290 else:
291 type_name = arg_.arg_value_type.__name__
292 if len(list_modifiers) > 0:
293 assert len(list_modifiers) == 1
294 type_name = f"list[{type_name}]"
295 description_extras.append(f"type: {type_name}")
297 # `default` value?
298 if arg_.input_method.is_optional and arg_.default is not NoDefault:
299 add_arg_kwargs.update(default=arg_.default)
300 if self._options.show_defaults:
301 if isinstance(arg_.default, enum.Enum):
302 description_extras.append(f"default: {normalize_name(arg_.default.name, spaces=False)}")
303 elif isinstance(arg_.default, str):
304 str_default = arg_.default
305 # Use the string repr if it contains spaces, contains a newline, or is zero-length
306 if (" " in str_default) or ("\n" in str_default) or (len(str_default) == 0):
307 str_default = repr(str_default)
308 description_extras.append(f"default: {str_default}")
309 else:
310 description_extras.append(f"default: {arg_.default}")
312 # Number of arguments `nargs`?
313 if arg_.is_variadic:
314 add_arg_kwargs.update(nargs="*", default=list())
315 elif arg_.input_method is InputMethod.OPTIONAL_POSITIONAL:
316 add_arg_kwargs.update(nargs="?")
318 # Any specified `metavar`s?
319 if arg_.metavars is not None:
320 if len(arg_.metavars) == 1:
321 add_arg_kwargs.update(metavar=arg_.metavars[0])
322 else:
323 add_arg_kwargs.update(metavar=tuple(arg_.metavars))
325 # Possible choices `choices`?
326 if issubclass(arg_.arg_value_type, enum.Enum):
327 mapping = self.set_up_enum(arg_.arg_value_type)
328 add_arg_kwargs.update(choices=[n for n in mapping])
330 name_spec: Tuple[str, ...] = (arg_.func_arg_name,)
332 # Special handling for optional arguments
333 if arg_.input_method is InputMethod.OPTION:
334 name_spec = arg_.get_options()
335 add_arg_kwargs.update(dest=arg_.func_arg_name)
337 # `bool` should be flags
338 if issubclass(arg_.arg_value_type, bool):
339 # Use `store_true` or `store_false` for bools
340 add_arg_kwargs.update(action="store_true" if arg_.default is False else "store_false")
341 if "type" in add_arg_kwargs:
342 del add_arg_kwargs["type"]
344 # Set the help description
345 if len(description_extras) > 0:
346 if len(arg_description) > 0:
347 arg_description += " "
348 arg_description += f"({', '.join(description_extras)})"
349 add_arg_kwargs.update(help=arg_description)
351 # Run modifiers for this arg
352 for modifier in arg_.modifiers:
353 modifier.modify_arg_dict(cmd, arg_, add_arg_kwargs)
355 if (
356 "choices" not in add_arg_kwargs
357 and "metavar" not in add_arg_kwargs
358 and add_arg_kwargs["action"] not in ["store_true", "store_false", "count", "help", "version"]
359 ):
360 if arg_.input_method is InputMethod.OPTION:
361 add_arg_kwargs.update(metavar=arg_.cli_arg_name.upper())
362 else:
363 add_arg_kwargs.update(metavar=arg_.cli_arg_name)
365 # Add the argument to the parser
366 argspec = log_args(
367 logger.debug,
368 f"Parser({repr(get_parser_name(parser.prog))}).",
369 parser.add_argument.__name__,
370 # Args for the call are below:
371 *name_spec,
372 **add_arg_kwargs,
373 )
374 parser.add_argument(*argspec.args, **argspec.kwargs)
376 def _build_subparser_tree(self, command_decorator_info: CommandDecoratorInfo) -> str:
377 """Builds up the subparser tree for a given `_CommandDecoratorInfo`. Inserts dummy entries to `self._parsers`
378 and `self._commands` if necessary. Returns the name of the parent for this command."""
380 prev_ancestor = "__root__"
382 # Create tree of parsers and subparsers for ancestors
383 ancestor_names = get_ancestors(command_decorator_info.name)
384 for ancestor in ancestor_names:
385 required_subparser = False
386 if ancestor not in self._commands:
387 # Dummy command - this ancestor doesn't have a function of its own, it's just a path.
388 self._commands[ancestor] = Command(lambda *_, **__: None, ancestor, [])
389 if ancestor not in self._parsers:
390 # Dummy parser - since there's nothing to run, require the subparser.
391 required_subparser = True
392 argspec = log_args(
393 logger.debug,
394 f"Subparsers({repr(prev_ancestor)}).",
395 self._subparsers[prev_ancestor].add_parser.__name__,
396 # Args for the call are below:
397 ancestor.split(" ")[-1],
398 help="",
399 formatter_class=self._formatter,
400 **self._extra_argparser_options,
401 )
402 self._parsers[ancestor] = self._subparsers[prev_ancestor].add_parser(*argspec.args, **argspec.kwargs)
403 if ancestor not in self._subparsers:
404 # Add subparser to the parent command's parser.
405 ancestor_cmd = self._commands[ancestor]
406 if any(arg.input_method.is_positional for arg in ancestor_cmd.args):
407 raise ArguablyException(
408 f"Command `{ancestor}` cannot have both subcommands and positional arguments."
409 )
410 argspec = log_args(
411 logger.debug,
412 f"Parser({repr(ancestor)}).",
413 self._parsers[ancestor].add_subparsers.__name__,
414 # Args for the call are below:
415 parser_class=ArgumentParser,
416 dest=ancestor_cmd.get_subcommand_metavar(self._options.command_metavar),
417 metavar=self._options.command_metavar,
418 required=required_subparser,
419 )
420 self._subparsers[ancestor] = self._parsers[ancestor].add_subparsers(*argspec.args, **argspec.kwargs)
421 prev_ancestor = ancestor
422 return prev_ancestor
424 @contextmanager
425 def current_parser(self, parser: argparse.ArgumentParser) -> Iterator[None]:
426 last_parser = self._current_parser
427 self._current_parser = parser
428 try:
429 yield
430 finally:
431 self._current_parser = last_parser
433 def error(self, message: str) -> None:
434 """
435 Prints an error message and exits. Should be used when a CLI input is not of the correct form. `arguably`
436 handles converting values to the correct type, but if extra validation is performed and fails, you should call
437 this.
439 Args:
440 message: A message to be printed to the console indicating why the input is wrong.
442 Raises:
443 SystemExit: The script will exit.
445 Examples:
446 ```python
447 #!/usr/bin/env python3
448 import arguably
450 @arguably.command
451 def high_five(*people):
452 if len(people) > 5:
453 arguably.error("Too many people to high-five!")
454 for person in people:
455 print(f"High five, {person}!")
457 if __name__ == "__main__":
458 arguably.run()
459 ```
461 ```console
462 user@machine:~$ python3 error.py Graham John Terry Eric Terry Michael
463 usage: error.py [-h] [people ...]
464 error.py: error: Too many people to high-five!
465 ```
466 """
467 if self._current_parser is None:
468 raise ArguablyException("Unknown current parser.")
469 self._current_parser.error(message) # This will exit the script
471 def _soft_failure(self, msg: str, function: Optional[Callable] = None) -> None:
472 if self._options.strict:
473 if function is not None:
474 info = func_or_class_info(function)
475 if info is not None:
476 source_file, source_file_line = info
477 msg = f"({source_file}:{source_file_line}) {function.__name__}: {msg}"
478 raise ArguablyException(msg)
479 else:
480 warn(msg, function)
482 def run(
483 self,
484 name: Optional[str] = None,
485 always_subcommand: bool = False,
486 version_flag: Union[bool, Tuple[str], Tuple[str, str]] = False,
487 strict: bool = True,
488 show_defaults: bool = True,
489 show_types: bool = True,
490 max_description_offset: int = 60,
491 max_width: int = 120,
492 command_metavar: str = "command",
493 output: Optional[TextIO] = None,
494 ) -> Any:
495 """
496 Set up the argument parser, parse argv, and run the appropriate command(s)
498 Args:
499 name: Name of the script/program. Defaults to the filename or module name, depending on how the script is
500 run. `$ python3 my/script.py` yields `script.py`, and `python3 -m my.script` yeilds `script`.
501 always_subcommand: If true, will force a subcommand interface to be used, even if there's only one command.
502 version_flag: If true, adds an option to show the script version using the value of `__version__` in the
503 invoked script. If a tuple of one or two strings is passed in, like `("-V", "--ver")`, those are used
504 instead of the default `--version`.
505 strict: Will prevent the script from running if there are any `ArguablyException`s raised during CLI
506 initialization.
507 show_defaults: Show the default value (if any) for each argument at the end of its help string.
508 show_types: Show the type of each argument at the end of its help string.
509 max_description_offset: The maximum number of columns before argument descriptions are printed. Equivalent
510 to `max_help_position` in argparse.
511 max_width: The total maximum width of text to be displayed in the terminal. Equivalent to `width` in
512 argparse.
513 command_metavar: The name shown in the usage string for taking in a subcommand. Change this if you have a
514 conflicting argument name.
515 output: Where argparse output should be written - can write to a file, stderr, or anything similar.
517 Returns:
518 The return value from the called function.
520 Examples:
521 ```python
522 #!/usr/bin/env python3
523 \"\"\"description for this script\"\"\"
524 from io import StringIO
526 import arguably
528 __version__ = "1.2.3"
530 @arguably.command
531 def example(): ...
533 if __name__ == "__main__":
534 output = StringIO()
535 try:
536 arguably.run(
537 name="myname",
538 always_subcommand=True,
539 version_flag=True,
540 command_metavar="mycmd",
541 output=output
542 )
543 finally:
544 print(f"Captured output length: {len(output.getvalue())=}")
545 print()
546 print(output.getvalue(), end="")
547 ```
549 ```console
550 user@machine:~$ python3 run.py -h
551 Captured output length: len(output.getvalue())=222
553 usage: myname [-h] [--version] mycmd ...
555 description for this script
557 positional arguments:
558 mycmd
559 example
561 options:
562 -h, --help show this help message and exit
563 --version show program's version number and exit
564 ```
566 ```console
567 user@machine:~$ python3 run.py --version
568 Captured output length: len(output.getvalue())=13
570 myname 1.2.3
571 ```
572 """
574 # Set options
575 self._options = _ContextOptions(**{k: v for k, v in locals().items() if k != "self"})
576 self._extra_argparser_options = dict(output=self._options.output)
578 self._is_calling_target = False
580 only_one_cmd = (len(self._command_decorator_info) == 1) and not self._options.always_subcommand
582 # Grab the description
583 import __main__
585 description = "" if __main__.__doc__ is None else __main__.__doc__.partition("\n\n\n")[0]
587 # TODO: Rewrite this code to remove the need for this line
588 add_root_help = next(
589 iter(info.help for info in self._command_decorator_info if info.name == "__root__" or only_one_cmd), True
590 )
592 # Set up the root parser
593 argspec = log_args(
594 logger.debug,
595 f"Initializing {repr('__root__')} parser: ",
596 ArgumentParser.__name__,
597 # Args for the call are below:
598 prog=self._options.name,
599 description=description,
600 formatter_class=self._formatter,
601 add_help=add_root_help,
602 **self._extra_argparser_options,
603 )
604 root_parser = ArgumentParser(*argspec.args, **argspec.kwargs)
605 self._parsers["__root__"] = root_parser
607 # Add version flags if necessary
608 argparse_version_flags: Union[tuple, Tuple[str], Tuple[str, str]] = tuple()
609 if self._options.version_flag:
610 if not hasattr(__main__, "__version__"):
611 self._soft_failure("__version__ must be defined if version_flag is set")
612 else:
613 argparse_version_flags = self._options.get_version_flags()
614 version_string = f"%(prog)s {__main__.__version__}"
615 argspec = log_args(
616 logger.debug,
617 f"Parser({repr('__root__')}).",
618 root_parser.add_argument.__name__,
619 # Args for the call are below:
620 *argparse_version_flags,
621 action="version",
622 version=version_string,
623 )
624 root_parser.add_argument(*argspec.args, **argspec.kwargs)
626 # Check the number of commands we have
627 if len(self._command_decorator_info) == 0:
628 raise ArguablyException("At least one command is required")
630 for command_decorator_info in sorted(
631 self._command_decorator_info, key=lambda v: (v.name != "__root__", v.name.count(" "))
632 ):
633 if only_one_cmd:
634 parent_name = "__root__"
635 self._parsers[command_decorator_info.name] = self._parsers["__root__"]
636 else:
637 parent_name = self._build_subparser_tree(command_decorator_info)
639 is_root = only_one_cmd or command_decorator_info.name == "__root__"
640 cmd = command_decorator_info.command
642 try:
643 self._validate_args(cmd, is_root)
644 except ArguablyException as e:
645 self._soft_failure(str(e), cmd.function)
646 continue
648 # Save command and its alias to the dicts
649 if cmd.name in self._commands:
650 self._soft_failure(f"Name `{cmd.name}` is already taken", cmd.function)
651 continue
652 if cmd.alias is not None:
653 if cmd.alias in self._command_aliases:
654 self._soft_failure(
655 f"Alias `{cmd.alias}` for `{cmd.name}` is already taken by "
656 f"`{self._command_aliases[cmd.alias]}`",
657 cmd.function,
658 )
659 continue
660 self._command_aliases[cmd.alias] = cmd.name
661 self._commands[cmd.name] = cmd
663 # Add the parser for the command
664 if not only_one_cmd and cmd.name != "__root__":
665 argspec = log_args(
666 logger.debug,
667 f"Subparsers({repr(parent_name)}).",
668 self._subparsers[parent_name].add_parser.__name__,
669 # Args for the call are below:
670 cmd.name.split(" ")[-1],
671 aliases=[cmd.alias] if cmd.alias is not None else [],
672 help=cmd.description,
673 description=cmd.description,
674 formatter_class=self._formatter,
675 add_help=cmd.add_help,
676 **self._extra_argparser_options,
677 )
678 self._parsers[cmd.name] = self._subparsers[parent_name].add_parser(*argspec.args, **argspec.kwargs)
680 # Add the arguments to the command's parser
681 self._set_up_args(cmd)
683 # Use the function description, not the __main__ docstring, if only one command
684 if only_one_cmd:
685 self._parsers["__root__"].description = next(iter(self._commands.values())).description
687 # Make the magic happen
688 parsed_args = vars(root_parser.parse_args())
690 # Resolve the command that needs to be called
691 if only_one_cmd:
692 cmd = next(iter(self._commands.values()))
693 self._current_parser = self._parsers["__root__"]
694 else:
695 # Find the actual command we need to execute by traversing the subparser tree. Call each stop along the way.
696 path = "__root__"
697 while path in self._subparsers:
698 # Find the variable name for this subparser's command metavar and read the value. If it's none, run the
699 # current stop of our path in the tree.
700 subcmd_metavar = self._commands[path].get_subcommand_metavar(self._options.command_metavar)
701 subcmd_name = parsed_args[subcmd_metavar]
702 if subcmd_name is None:
703 break
705 # Resolve any command aliases
706 if subcmd_name in self._command_aliases:
707 subcmd_name = self._command_aliases[subcmd_name]
709 self._is_calling_target = False
710 self._current_parser = self._parsers[path]
711 self._commands[path].call(parsed_args)
713 # Update the path and continue
714 if path == "__root__":
715 path = subcmd_name
716 else:
717 path += f" {subcmd_name}"
719 # If the command is unknown, print the help for the most recent parent
720 if path not in self._commands:
721 self._parsers[path.rsplit(" ", 1)[0]].print_help(file=self._options.output)
722 return None
723 if path == "__root__" and self._commands["__root__"].function.__name__ == "<lambda>":
724 root_parser.print_help(file=self._options.output)
725 return None
727 # Found command
728 self._current_parser = self._parsers[path]
729 cmd = self._commands[path]
731 self._is_calling_target = True
732 result = cmd.call(parsed_args)
733 self._current_parser = None
734 return result
736 def _build_subtype(
737 self, parent_func_arg_name: str, subtype_info: SubtypeDecoratorInfo, build_kwargs: Dict[str, Any]
738 ) -> Any:
739 type_ = subtype_info.type_
740 factory = subtype_info.factory or type_.__call__
741 template = subtype_info.factory or type_.__init__ # type: ignore[misc]
742 hints = get_type_hints(template, include_extras=True)
743 normalized_kwargs: Dict[str, Any] = dict()
745 params: Dict[str, inspect.Parameter] = {
746 k: v
747 for k, v in inspect.signature(template).parameters.items()
748 if (v.kind != v.VAR_POSITIONAL) and (v.kind != v.VAR_KEYWORD)
749 }
751 missing_required_keys = [
752 normalize_name(n)
753 for i, (n, p) in enumerate(params.items())
754 if (n not in build_kwargs) and (i != 0 or p.name != "self")
755 ]
756 if len(missing_required_keys) > 0:
757 missing_specs = list()
758 for key in missing_required_keys:
759 arg_value_type, modifiers = CommandArg.normalize_type(type_.__name__, params[key], hints)
760 missing_specs.append(f"{key} ({arg_value_type.__name__})")
761 self.error(f"the following keys are required for {parent_func_arg_name}: {', '.join(missing_specs)}")
763 # Iterate over all parameters
764 for func_arg_name, param in params.items():
765 try:
766 func_arg_name = normalize_name(func_arg_name)
767 if func_arg_name == "self":
768 continue
769 param_value = build_kwargs[func_arg_name]
770 del build_kwargs[func_arg_name]
771 arg_value_type, modifiers = CommandArg.normalize_type(type_.__name__, param, hints)
772 except ArguablyException:
773 raise ArguablyException(f"Error processing parameter {func_arg_name} of subtype {type_.__name__}")
774 if len(modifiers) > 0:
775 raise ArguablyException(
776 f"Error processing parameter {func_arg_name} of subtype {type_.__name__}: Cannot use modifiers "
777 f"on subtypes"
778 )
779 normalized_kwargs[func_arg_name] = arg_value_type(param_value)
781 # The calls to .error() cause an exit
782 if len(build_kwargs) > 1:
783 self.error(f"unexpected keys for {parent_func_arg_name}: {', '.join(build_kwargs)}")
784 elif len(build_kwargs) > 0:
785 self.error(f"unexpected key for {parent_func_arg_name}: {next(iter(build_kwargs))}")
787 return factory(**normalized_kwargs)
789 def resolve_subtype(
790 self, func_arg_name: str, arg_value_type: type, subtype_: Optional[str], build_kwargs: Dict[str, Any]
791 ) -> Any:
792 options = self.find_subtype(arg_value_type)
793 if len(options) == 0:
794 options = [SubtypeDecoratorInfo(arg_value_type)]
795 if len(options) == 1:
796 return self._build_subtype(func_arg_name, options[0], build_kwargs)
797 matches = [op for op in options if op.alias == subtype_]
798 if len(matches) == 0:
799 self.error(f"unknown subtype `{subtype_}` for {func_arg_name}")
800 if len(matches) > 1:
801 raise ArguablyException(f"More than one match for subtype `{subtype_}` of type {arg_value_type}")
802 return self._build_subtype(func_arg_name, matches[0], build_kwargs)
805context = _Context()
808########################################################################################################################
809# Exposed for API
812run = context.run
813is_target = context.is_target
814error = context.error
817def command(
818 func: Optional[Callable] = None,
819 /,
820 *,
821 # Arguments below are passed through to `CommandDecoratorInfo`
822 alias: Optional[str] = None,
823 help: bool = True,
824) -> Callable:
825 """
826 Mark a function as a command that should appear on the CLI. If multiple functions are decorated with this, they will
827 all be available as subcommands. If only one function is decorated, it is automatically selected - no need to
828 specify it on the CLI.
830 Args:
831 func: The target function.
832 alias: An alias for this function. For example, `@arguably.command(alias="h")` would alias `h` to the function
833 that follows.
834 help: If `False`, the help flag `-h/--help` will not automatically be added to this function.
836 Returns:
837 If called with parens `@arguably.command(...)`, returns the decorated function. If called without parens
838 `@arguably.command`, returns the function `wrap(func_)`, which returns `func_`.
840 Examples:
841 ```python
842 #!/usr/bin/env python3
843 import arguably
845 @arguably.command
846 def some_function(required, not_required=2, *others: int, option: float = 3.14):
847 \"\"\"
848 this function is on the command line!
850 Args:
851 required: a required argument
852 not_required: this one isn't required, since it has a default value
853 *others: all the other positional arguments go here
854 option: [-x] keyword-only args are options, short name is in brackets
855 \"\"\"
857 if __name__ == "__main__":
858 arguably.run()
859 ```
861 ```console
862 user@machine:~$ python3 intro.py -h
863 usage: intro.py [-h] [-x OPTION] required [not-required] [others ...]
865 this function is on the command line!
867 positional arguments:
868 required a required argument (type: str)
869 not-required this one isn't required, since it has a default value (type: int, default: 2)
870 others all the other positional arguments go here (type: int)
872 options:
873 -h, --help show this help message and exit
874 -x, --option OPTION keyword-only args are options, short name is in brackets (type: float, default: 3.14)
875 ```
877 Or, with multiple commands:
879 ```python
880 #!/usr/bin/env python3
881 import arguably
883 @arguably.command(alias="f")
884 def first(): ...
886 @arguably.command(alias="s")
887 def second(): ...
889 @arguably.command
890 def second__subcmd1(): ...
892 def second__subcmd2(): ...
893 arguably.command(second__subcmd2) # Can also be invoked this way
895 if __name__ == "__main__":
896 arguably.run()
897 ```
899 ```console
900 user@machine:~$ python3 command.py -h
901 usage: command-example-2.py [-h] command ...
903 positional arguments:
904 command
905 first (f)
906 second (s)
908 options:
909 -h, --help show this help message and exit
910 ```
912 ```console
913 user@machine:~$ python3 command.py s -h
914 usage: command-example-2.py second [-h] command ...
916 positional arguments:
917 command
918 subcmd1
919 subcmd2
921 options:
922 -h, --help show this help message and exit
923 ```
924 """
926 def wrap(func_: Callable) -> Callable:
927 context.add_command(function=func_, alias=alias, help=help)
928 return func_
930 # Handle being called as either @arguably.command or @arguably.command()
931 # We have type: ignore due to https://github.com/python/mypy/issues/10740
932 return wrap if func is None else wrap(func) # type: ignore[return-value]
935def subtype(
936 cls: Optional[type] = None,
937 /,
938 *,
939 # Arguments below are passed through to `SubtypeDecoratorInfo`
940 alias: str,
941 factory: Callable | None = None,
942) -> Union[Callable[[type], type], type]:
943 """
944 Mark a decorated class as a subtype that should be buildable for a parameter using arg.builder(). The alias
945 parameter is required.
947 Args:
948 cls: The target class.
949 alias: An alias for this class. For example, `@arguably.subtype(alias="foo")` would cause this class to be built
950 any time an applicable arg is given a string starting with `foo,...`
951 factory: What should be called to actually build the subtype. This should only be needed if the default behavior
952 doesn't work.
954 Returns:
955 If called with parens `@arguably.subtype(...)`, returns the decorated class. If called without parens
956 `@arguably.subtype`, returns the function `wrap(cls_)`, which returns `cls_`.
958 Examples:
959 ```python
960 import arguably
961 from dataclasses import dataclass
962 from typing import Annotated
964 class Nic: ...
966 @arguably.subtype(alias="tap")
967 @dataclass
968 class TapNic(Nic):
969 model: str
971 @dataclass
972 class UserNic(Nic):
973 hostfwd: str
975 arguably.subtype(UserNic, alias="user") # Can also be called like this
977 @arguably.command
978 def qemu_style(*, nic: Annotated[list[Nic], arguably.arg.builder()]):
979 print(f"{nic=}")
981 if __name__ == "__main__":
982 arguably.run()
983 ```
985 ```console
986 user@machine:~$ python3 subtype.py --nic tap,model=e1000 --nic user,hostfwd=tcp::10022-:22
987 nic=[TapNic(model='e1000'), UserNic(hostfwd='tcp::10022-:22')]
988 ```
989 """
991 def wrap(cls_: type) -> type:
992 if not isinstance(cls_, type):
993 raise ArguablyException(
994 f"Decorated value {cls_} is not a type, which is required for `@arguably.subtype()`"
995 )
996 context.add_subtype(type_=cls_, alias=alias, factory=factory)
997 return cls_
999 # Handle being called as either @arguably.subtype or @arguably.subtype()
1000 return wrap if cls is None else wrap(cls)