feat: Add Typer/Click support as alternative to argparse#1612
feat: Add Typer/Click support as alternative to argparse#1612KelvinChung2000 wants to merge 2 commits intopython-cmd2:mainfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1612 +/- ##
==========================================
- Coverage 99.51% 98.90% -0.61%
==========================================
Files 21 22 +1
Lines 4735 4933 +198
==========================================
+ Hits 4712 4879 +167
- Misses 23 54 +31
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
| <div class="grid cards" markdown> | ||
| <!--intro-start--> | ||
| - [Argument Processing](argument_processing.md) | ||
| - [Typer Support](typer.md) |
There was a problem hiding this comment.
Please keep this in alphabetical order
| - `help <command>` uses Click's help output for Typer-based commands, not argparse help. | ||
| - Parse errors use Click's error formatting and are caught so they do not exit the REPL. | ||
| - Completion for Typer-based commands is provided by Click's `shell_complete` API. | ||
| - Typer support is optional and requires installing the `typer` extra. |
There was a problem hiding this comment.
I think we need to put this in a !!! warning or something similar. The fact that Typer-based commands use different help and completion mechanisms is something people should be aware of.
The help formatting isn't bad I don't mind it, but it may feel odd when an app contains inconsistent help formatting for built-in commands versus Typer-based ones. One thing that would help is if all the Typer-based commands automatically supported a -h/--help argument. The fact that doesn't work feels badly inconsistent. Perhaps if we put all Typer-based commands in a different default category if none is specified that could at least give users a reasonable expectation of different behavior?
The completion also mostly works fine, but there are a few little annoying differences. One thing that would help is if when a user hits <TAB> after a command it would show a hint similar to what cmd2 does for argparse-based commands. - e.g. if you type history <TAB> it shows a hint for the required argument. But when you are using the typer_example.py example, if you type assign <TAB> nothing happens at all.
There was a problem hiding this comment.
After you pointed it out, I personally really dislike the mismatch. I am considering building an argparser alongside, so all text rendering will use the rich-argparse pipeline. Then add an extra flag to allow the user to opt out of rich-argparse and back to using click.
The main problem is having two sets of parsing systems, and things cannot be reused between them. The best solution I came up with is to use a flag to choose which parser format the built-in command uses. But this means will need to update/rewrite to decouple the built-in command from a specific parser to allow using either parser format
| prompt = "typer-demo> " | ||
|
|
||
| # 1. Simple positional argument + option with default | ||
| @cmd2.with_typer |
There was a problem hiding this comment.
I'd recommend putting all of the Typer-based commands in a different category to make it easier to see from help or help -v which the custom commands are.
| # 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. |
There was a problem hiding this comment.
Don't need this extra comment - the updated type hint should suffice
| ) | ||
|
|
||
| try: | ||
| from .typer_custom import TyperParser |
There was a problem hiding this comment.
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] = {}| try: | ||
| parser = cmd._command_parsers.get(command_func) | ||
| if not isinstance(parser, click.Command): | ||
| return Completions() |
There was a problem hiding this comment.
Add unit test to cover this line
| ] | ||
| return Completions(items=items) | ||
| except Exception as exc: # noqa: BLE001 | ||
| return Completions(error=cmd.format_exception(exc)) |
There was a problem hiding this comment.
Add unit test to cover this line
| return current, resolved_names | ||
|
|
||
|
|
||
| def typer_complete_subcommand_help( |
There was a problem hiding this comment.
This commend has no test coverage. Add tests to cover it.
| try: | ||
| import typer | ||
| except ModuleNotFoundError as exc: | ||
| raise ImportError("Typer support requires the 'typer' package. Install it with: pip install cmd2[typer]") from exc |
| exc.show(file=sys.stderr) | ||
| raise Cmd2ArgparseError from exc | ||
| if isinstance(exc, (click.exceptions.Exit, SystemExit)): | ||
| raise Cmd2ArgparseError from exc |
There was a problem hiding this comment.
Add tests to cover both this raise and the empty one below
|
|
||
| # Typer/Click subcommand help completion | ||
| if isinstance(argparser, TyperParser): | ||
| from .typer_custom import typer_complete_subcommand_help |
There was a problem hiding this comment.
Add tests to cover this if case and the generic return for the default else case after it
|
@KelvinChung2000 Thanks for the PR. I reviewed and left a number of comments. Please let me know if you have any questions. @kmvanbrunt Given this is a large change, I very much want your input on it. |
|
How would I create a typer-based command with the following?
|
This is something I missed. I plan to support it like the following def do_something(
self,
text: Annotated[str, typer.Argument(autocompletion=Cmd.path_complete)],
) -> None:
"""something."""
passwhich the But in this specific |
|
My biggest concern is that the I do like being able to specify arguments based on type hints and think that is a very clean and readable way of doing it. If @KelvinChung2000 @kmvanbrunt What do you think? |
|
@tleonhardt I agree this feature adds complications for very little payoff. |
|
In my current version, the rebuilding solution is what I am going with. def _populate_mirror_parser(
click_command: click.Command,
parser: argparse.ArgumentParser,
cmd2_completers: dict[str, Callable[..., Any]] | None = None,
) -> None:
"""Populate an argparse parser with arguments mirroring a Click command's params.
Wires Typer autocompletion callbacks as cmd2 ``choices_provider`` or
``completer`` callables so that ``ArgparseCompleter`` handles all tab
completion.
cmd2-style callbacks (extracted earlier by :func:`_extract_cmd2_completers`)
are passed directly so that cmd2's ``_resolve_func_self`` handles instance
binding at completion time. Standard Click/Typer callbacks are wrapped via
:func:`_wrap_click_shell_complete`.
:param click_command: the Click Command to mirror
:param parser: the argparse parser to populate
:param cmd2_completers: dict mapping param names to cmd2-style completers
extracted before Typer processed the command
"""
if cmd2_completers is None:
cmd2_completers = {}
for param in click_command.params:
# Skip the help option — the mirror parser has its own -h/--help
if param.name == 'help':
continue
help_text = getattr(param, 'help', None)
shell_complete_func = getattr(param, '_custom_shell_complete', None)
cmd2_func = cmd2_completers.get(param.name) if param.name else None
action: argparse.Action | None = None
if isinstance(param, click.Argument):
kwargs: dict[str, Any] = {}
if help_text:
kwargs['help'] = help_text
if param.nargs == -1:
kwargs['nargs'] = '+' if param.required else '*'
elif param.nargs > 1:
kwargs['nargs'] = param.nargs
_apply_click_type_kwargs(kwargs, param.type)
action = parser.add_argument(param.human_readable_name, **kwargs)
elif isinstance(param, click.Option):
...This ensures that all completions use the same argparse version. To be fair, I think Typer already has a lot of the completion out of the box, like taking advantage of enum types to auto-infer choices, which covers most of the "simple" cases. The big one missing is the The better approach might actually be not to use Typer directly, but to offer the user the same |
|
I would be very open to a solution that supported the |
|
I will close this PR, and open a new one, since now we are completely changing the approach. |
Summary
@cmd2.with_typerdecorator for defining commands using type annotations and Typer/Click instead of argparsetyper.Typer()app (subcommands)What's included
@with_typerdecoratorpip install cmd2[typer]Test plan
The following is a screenshot of the typer_example.py