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?
|
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