-
Notifications
You must be signed in to change notification settings - Fork 129
feat: Add Typer/Click support as alternative to argparse #1612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -204,6 +204,14 @@ def __init__(self, msg: str = '') -> None: | |
| suggest_similar, | ||
| ) | ||
|
|
||
| try: | ||
| from .typer_custom import TyperParser | ||
| except ImportError: | ||
|
|
||
| class TyperParser: # type: ignore[no-redef] | ||
| """Sentinel: isinstance checks always return False when typer is not installed.""" | ||
|
|
||
|
|
||
| if TYPE_CHECKING: # pragma: no cover | ||
| StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser] | ||
| ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser] | ||
|
|
@@ -229,14 +237,16 @@ class _CommandParsers: | |
| """Create and store all command method argument parsers for a given Cmd instance. | ||
|
|
||
| Parser creation and retrieval are accomplished through the get() method. | ||
| Supports both argparse-based and Typer/Click-based commands. | ||
| """ | ||
|
|
||
| def __init__(self, cmd: 'Cmd') -> None: | ||
| self._cmd = cmd | ||
|
|
||
| # Keyed by the fully qualified method names. This is more reliable than | ||
| # the methods themselves, since wrapping a method will change its address. | ||
| self._parsers: dict[str, argparse.ArgumentParser] = {} | ||
| # Values are argparse.ArgumentParser for argparse commands or TyperParser (click.Command) for Typer commands. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't need this extra comment - the updated type hint should suffice |
||
| self._parsers: dict[str, argparse.ArgumentParser | TyperParser] = {} | ||
|
|
||
| @staticmethod | ||
| def _fully_qualified_name(command_method: CommandFunc) -> str: | ||
|
|
@@ -250,15 +260,16 @@ def __contains__(self, command_method: CommandFunc) -> bool: | |
| """Return whether a given method's parser is in self. | ||
|
|
||
| If the parser does not yet exist, it will be created if applicable. | ||
| This is basically for checking if a method is argarse-based. | ||
| This is basically for checking if a method uses argparse or Typer. | ||
| """ | ||
| parser = self.get(command_method) | ||
| return bool(parser) | ||
|
|
||
| def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None: | ||
| """Return a given method's parser or None if the method is not argparse-based. | ||
| def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | TyperParser | None: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a def get(self, command_method: CommandFunc) -> Cmd2ArgParser | None: |
||
| """Return a given method's parser or None if the method is not parser-based. | ||
|
|
||
| If the parser does not yet exist, it will be created. | ||
| Handles both argparse and Typer/Click commands. | ||
| """ | ||
| full_method_name = self._fully_qualified_name(command_method) | ||
| if not full_method_name: | ||
|
|
@@ -269,6 +280,14 @@ def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None: | |
| return None | ||
| command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :] | ||
|
|
||
| # Check for Typer command | ||
| if getattr(command_method, constants.CMD_ATTR_TYPER_FUNC, None) is not None: | ||
| from .typer_custom import build_typer_command | ||
|
|
||
| parent = self._cmd.find_commandset_for_command(command) or self._cmd | ||
| self._parsers[full_method_name] = build_typer_command(parent, command_method) | ||
| return self._parsers[full_method_name] | ||
|
|
||
| parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None) | ||
| if parser_builder is None: | ||
| return None | ||
|
|
@@ -1030,7 +1049,7 @@ def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None: | |
| # is the actual command since command synonyms don't own it. | ||
| if cmd_func_name == command_method.__name__: | ||
| command_parser = self._command_parsers.get(command_method) | ||
| if command_parser is not None: | ||
| if isinstance(command_parser, argparse.ArgumentParser): | ||
| check_parser_uninstallable(command_parser) | ||
|
|
||
| def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: | ||
|
|
@@ -1075,7 +1094,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: | |
| if command_func is None: | ||
| raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") | ||
| command_parser = self._command_parsers.get(command_func) | ||
| if command_parser is None: | ||
| if not isinstance(command_parser, argparse.ArgumentParser): | ||
| raise CommandSetRegistrationError( | ||
| f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" | ||
| ) | ||
|
|
@@ -1161,7 +1180,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: | |
| # but keeping in case it does for some strange reason | ||
| raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") | ||
| command_parser = self._command_parsers.get(command_func) | ||
| if command_parser is None: # pragma: no cover | ||
| if not isinstance(command_parser, argparse.ArgumentParser): # pragma: no cover | ||
| # This really shouldn't be possible since _register_subcommands would prevent this from happening | ||
| # but keeping in case it does for some strange reason | ||
| raise CommandSetRegistrationError( | ||
|
|
@@ -2333,11 +2352,11 @@ def _perform_completion( | |
| if func_attr is not None: | ||
| completer_func = func_attr | ||
| else: | ||
| # There's no completer function, next see if the command uses argparse | ||
| # There's no completer function, next see if the command uses a parser | ||
| func = self.cmd_func(command) | ||
| argparser = None if func is None else self._command_parsers.get(func) | ||
|
|
||
| if func is not None and argparser is not None: | ||
| if func is not None and isinstance(argparser, argparse.ArgumentParser): | ||
| # Get arguments for complete() | ||
| preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES) | ||
| cmd_set = self.find_commandset_for_command(command) | ||
|
|
@@ -2349,6 +2368,16 @@ def _perform_completion( | |
| completer_func = functools.partial( | ||
| completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set | ||
| ) | ||
| elif func is not None and isinstance(argparser, TyperParser): | ||
| from .typer_custom import typer_complete | ||
|
|
||
| preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES) | ||
| completer_func = functools.partial( | ||
| typer_complete, | ||
| self, | ||
| command_func=func, | ||
| args=raw_tokens[1:] if preserve_quotes else tokens[1:], | ||
| ) | ||
| else: | ||
| completer_func = self.completedefault # type: ignore[assignment] | ||
|
|
||
|
|
@@ -4097,11 +4126,30 @@ def complete_help_subcommands( | |
| return Completions() | ||
|
|
||
| # Check if this command uses argparse | ||
| if (func := self.cmd_func(command)) is None or (argparser := self._command_parsers.get(func)) is None: | ||
| func = self.cmd_func(command) | ||
| if func is None: | ||
| return Completions() | ||
|
|
||
| completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) | ||
| return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) | ||
| argparser = self._command_parsers.get(func) | ||
| if isinstance(argparser, argparse.ArgumentParser): | ||
| completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) | ||
| return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) | ||
|
|
||
| # Typer/Click subcommand help completion | ||
| if isinstance(argparser, TyperParser): | ||
| from .typer_custom import typer_complete_subcommand_help | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add tests to cover this |
||
|
|
||
| return typer_complete_subcommand_help( | ||
| self, | ||
| text, | ||
| line, | ||
| begidx, | ||
| endidx, | ||
| command_func=func, | ||
| subcommands=arg_tokens['subcommands'], | ||
| ) | ||
|
|
||
| return Completions() | ||
|
|
||
| def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]: | ||
| """Categorizes and sorts visible commands and help topics for display. | ||
|
|
@@ -4211,10 +4259,21 @@ def do_help(self, args: argparse.Namespace) -> None: | |
| argparser = None if func is None else self._command_parsers.get(func) | ||
|
|
||
| # If the command function uses argparse, then use argparse's help | ||
| if func is not None and argparser is not None: | ||
| if func is not None and isinstance(argparser, argparse.ArgumentParser): | ||
| completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self) | ||
| completer.print_help(args.subcommands, self.stdout) | ||
| # If the command function uses Typer, then use Click's help | ||
| elif func is not None and isinstance(argparser, TyperParser): | ||
| from .typer_custom import resolve_typer_subcommand | ||
|
|
||
| try: | ||
| target_command, resolved_names = resolve_typer_subcommand(argparser, args.subcommands) | ||
| except KeyError: | ||
| target_command, resolved_names = argparser, [] | ||
| info_name = ' '.join([args.command, *resolved_names]) | ||
| ctx = target_command.make_context(info_name, [], resilient_parsing=True) | ||
| with contextlib.redirect_stdout(self.stdout): | ||
| target_command.get_help(ctx) | ||
| # If the command has a custom help function, then call it | ||
| elif help_func is not None: | ||
| help_func() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| """Decorators for ``cmd2`` commands.""" | ||
|
|
||
| import argparse | ||
| import inspect | ||
| import sys | ||
| from collections.abc import ( | ||
| Callable, | ||
| Sequence, | ||
|
|
@@ -295,9 +297,9 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: | |
|
|
||
| # Pass cmd_wrapper instead of func, since it contains the parser info. | ||
| arg_parser = cmd2_app._command_parsers.get(cmd_wrapper) | ||
| if arg_parser is None: | ||
| if not isinstance(arg_parser, argparse.ArgumentParser): | ||
| # This shouldn't be possible to reach | ||
| raise ValueError(f'No argument parser found for {command_name}') # pragma: no cover | ||
| raise TypeError(f'No argument parser found for {command_name}') # pragma: no cover | ||
|
|
||
| if ns_provider is None: | ||
| namespace = None | ||
|
|
@@ -344,6 +346,120 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: | |
| return arg_decorator | ||
|
|
||
|
|
||
| def with_typer( | ||
| func_arg: CommandFunc | Any | None = None, | ||
| *, | ||
| preserve_quotes: bool = False, | ||
| context_settings: dict[str, Any] | None = None, | ||
| ) -> RawCommandFuncOptionalBoolReturn[CmdOrSet] | Callable[[CommandFunc], RawCommandFuncOptionalBoolReturn[CmdOrSet]]: | ||
| """Decorate a ``do_*`` method to process its arguments using Typer/Click. | ||
|
|
||
| :param func_arg: Single-element positional argument list containing ``do_*`` method | ||
| this decorator is wrapping, or an explicit Typer app | ||
| :param preserve_quotes: if ``True``, then argument quotes will not be stripped | ||
| :param context_settings: optional dict of Click context settings passed to Typer | ||
| :return: function that gets passed Typer-parsed arguments | ||
|
|
||
| Example: | ||
| ```py | ||
| class MyApp(cmd2.Cmd): | ||
| @cmd2.with_typer | ||
| def do_add( | ||
| self, | ||
| a: int, | ||
| b: Annotated[int, typer.Option("--b")] = 2, | ||
| ) -> None: | ||
| self.poutput(str(a + b)) | ||
| ``` | ||
|
|
||
| """ | ||
| import functools | ||
|
|
||
| try: | ||
| import typer | ||
| except ModuleNotFoundError as exc: | ||
| raise ImportError("Typer support requires the 'typer' package. Install it with: pip install cmd2[typer]") from exc | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add test to cover this case |
||
|
|
||
| explicit_app = None | ||
| if isinstance(func_arg, typer.Typer): | ||
| explicit_app = func_arg | ||
|
|
||
| def arg_decorator(func: CommandFunc) -> RawCommandFuncOptionalBoolReturn[CmdOrSet]: | ||
| """Decorate function that ingests a command function and returns a raw command function. | ||
|
|
||
| The returned function will process the raw input through Typer/Click to be passed to the wrapped function. | ||
|
|
||
| :param func: The defined Typer command function | ||
| :return: Function that takes raw input and converts to Typer-parsed arguments passed to the wrapped function. | ||
| """ | ||
|
|
||
| @functools.wraps(func) | ||
| def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: | ||
| """Command function wrapper which translates command line into Typer-parsed arguments and command function. | ||
|
|
||
| :param args: All positional arguments to this function. We're expecting there to be: | ||
| cmd2_app, statement: Union[Statement, str] | ||
| contiguously somewhere in the list | ||
| :param kwargs: any keyword arguments being passed to command function | ||
| :return: return value of command function | ||
| :raises Cmd2ArgparseError: if Typer/Click has error parsing command line | ||
| """ | ||
| cmd2_app, statement_arg = _parse_positionals(args) | ||
| _, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement_arg, preserve_quotes) | ||
|
|
||
| # Pass cmd_wrapper instead of func, since it contains the typer attribute info. | ||
| import click | ||
|
|
||
| parser = cmd2_app._command_parsers.get(cmd_wrapper) | ||
| if not isinstance(parser, click.Command): | ||
| raise TypeError(f'No Typer command found for {command_name}') # pragma: no cover | ||
|
|
||
| try: | ||
| setattr(cmd_wrapper, constants.CMD_ATTR_TYPER_KWARGS, dict(kwargs)) | ||
| result = parser.main( | ||
| args=parsed_arglist, | ||
| prog_name=command_name, | ||
| standalone_mode=False, | ||
| ) | ||
| except Exception as exc: | ||
| if isinstance(exc, click.ClickException): | ||
| exc.show(file=sys.stderr) | ||
| raise Cmd2ArgparseError from exc | ||
| if isinstance(exc, (click.exceptions.Exit, SystemExit)): | ||
| raise Cmd2ArgparseError from exc | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add tests to cover both this |
||
| raise | ||
| finally: | ||
| if hasattr(cmd_wrapper, constants.CMD_ATTR_TYPER_KWARGS): | ||
| delattr(cmd_wrapper, constants.CMD_ATTR_TYPER_KWARGS) | ||
|
|
||
| # clicks command return Any type | ||
| return result # type: ignore[no-any-return] | ||
|
|
||
| command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] | ||
|
|
||
| # Typer relies on signature + annotations to build its CLI; strip self. | ||
| sig = inspect.signature(func) | ||
| params = list(sig.parameters.values()) | ||
| if params and params[0].name == "self": | ||
| params = params[1:] | ||
| cmd_wrapper.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined] | ||
| annotations = dict(getattr(func, "__annotations__", {})) | ||
| annotations.pop("self", None) | ||
| cmd_wrapper.__annotations__ = annotations | ||
|
|
||
| # Set some custom attributes for this command | ||
| setattr(cmd_wrapper, constants.CMD_ATTR_TYPER, explicit_app) | ||
| setattr(cmd_wrapper, constants.CMD_ATTR_TYPER_FUNC, func) | ||
| setattr(cmd_wrapper, constants.CMD_ATTR_TYPER_CONTEXT_SETTINGS, context_settings) | ||
| setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) | ||
|
|
||
| return cmd_wrapper | ||
|
|
||
| if explicit_app is None and callable(func_arg): | ||
| return arg_decorator(func_arg) | ||
| return arg_decorator | ||
|
|
||
|
|
||
| def as_subcommand_to( | ||
| command: str, | ||
| subcommand: str, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what this is attempting to do, but it won't work as-is.
Even if
typerisn't present, you can still importTyperParserfrom.typer_customerwithout an import error so long asclickis present since it is aTypeAliasforclick.Command. A great many things in the Python ecosystem depend uponclick, so it wouldn't be uncommon for that to be present whentyperisn't - for examplezensicalwhichcmd2uses for documentation depends uponclick.Even if both
clickandtyperare not present, it will raise aModuleNotFoundErroron all non-EOL modern Python versions and not anImportError.One option might be to directly import
typerintyper_custom.pyand change this except to catch aModuleNotFoundError. Another might be able to define a module-level boolean variable called something likehas_typerbased on whether or nottyperis importable.However we fix it, I think we should also configure a
TypeAliashere called something likeCmd2Parserthat is just aliased toargparse.ArgumentParserwhentyperisn't present andargparse.ArgumentParser | TyperParserwhen it is. Then that type alias should be used in other type hints in this file as appropriate.Something along these lines:
Then down below we can use something like: