Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ On all operating systems, the latest stable version of `cmd2` can be installed u
pip install -U cmd2
```

To enable Typer-based argument parsing, install the optional extra:

```bash
pip install -U cmd2[typer]
```

cmd2 works with Python 3.10+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party
dependencies. It works with both conventional CPython and free-threaded variants.

Expand Down
2 changes: 2 additions & 0 deletions cmd2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
with_argparser,
with_argument_list,
with_category,
with_typer,
)
from .exceptions import (
Cmd2ArgparseError,
Expand Down Expand Up @@ -84,6 +85,7 @@
'with_category',
'with_default_category',
'as_subcommand_to',
'with_typer',
# Exceptions
'Cmd2ArgparseError',
'CommandSetRegistrationError',
Expand Down
85 changes: 72 additions & 13 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ def __init__(self, msg: str = '') -> None:
suggest_similar,
)

try:
from .typer_custom import TyperParser
Copy link
Member

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 typer isn't present, you can still import TyperParser from .typer_customer without an import error so long as click is present since it is a TypeAlias for click.Command. A great many things in the Python ecosystem depend upon click, so it wouldn't be uncommon for that to be present when typer isn't - for example zensical which cmd2 uses for documentation depends upon click.

Even if both click and typer are not present, it will raise a ModuleNotFoundError on all non-EOL modern Python versions and not an ImportError.

One option might be to directly import typer in typer_custom.py and change this except to catch a ModuleNotFoundError. Another might be able to define a module-level boolean variable called something like has_typer based on whether or not typer is importable.

However we fix it, I think we should also configure a TypeAlias here called something like Cmd2Parser that is just aliased to argparse.ArgumentParser when typer isn't present and argparse.ArgumentParser | TyperParser when it is. Then that type alias should be used in other type hints in this file as appropriate.

Something along these lines:

try:
    import typer
    from .typer_custom import TyperParser
    Cmd2ArgParser: TypeAlias = argparse.ArgumentParser | TyperParser
except ModuleNotFoundError:
    Cmd2ArgParser: TypeAlias = argparse.ArgumentParser 
    class TyperParser:  # type: ignore[no-redef]
        """Sentinel: isinstance checks always return False when typer is not installed."""

Then down below we can use something like:

self._parsers: dict[str, Cmd2ArgParser] = {}

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]
Expand All @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The 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:
Expand All @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a Cmd2Parser type alias is defined above as suggested, can replace type hint with it here:

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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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]

Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tests to cover this if case and the generic return for the default else case after it


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.
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions cmd2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
# The argparse parser for the command
CMD_ATTR_ARGPARSER = 'argparser'

# The typer app for the command (or None if auto-built from function signature)
CMD_ATTR_TYPER = 'typer'
CMD_ATTR_TYPER_FUNC = 'typer_func'
CMD_ATTR_TYPER_KWARGS = 'typer_kwargs'
CMD_ATTR_TYPER_CONTEXT_SETTINGS = 'typer_context_settings'

# Whether or not tokens are unquoted before sending to argparse
CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes'

Expand Down
120 changes: 118 additions & 2 deletions cmd2/decorators.py
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tests to cover both this raise and the empty one below

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,
Expand Down
Loading
Loading