Skip to content

feat: Add Typer/Click support as alternative to argparse#1612

Closed
KelvinChung2000 wants to merge 2 commits intopython-cmd2:mainfrom
KelvinChung2000:feat/typer-parser
Closed

feat: Add Typer/Click support as alternative to argparse#1612
KelvinChung2000 wants to merge 2 commits intopython-cmd2:mainfrom
KelvinChung2000:feat/typer-parser

Conversation

@KelvinChung2000
Copy link

Summary

  • Add @cmd2.with_typer decorator for defining commands using type annotations and Typer/Click instead of argparse
  • Support two patterns: auto-built from method signature (simple commands) and explicit typer.Typer() app (subcommands)
  • Integrate with cmd2 help, tab completion, error handling, and CommandSet

What's included

  • cmd2/typer_custom.py — Typer-specific logic for building Click commands, completion, and subcommand resolution
  • cmd2/decorators.py — @with_typer decorator
  • cmd2/cmd2.py — Extended _CommandParsers and help/completion paths to handle both argparse and Typer commands
  • tests/test_typer.py — 35 tests covering execution, help, completion, CommandSet, and subcommands
  • docs/features/typer.md — Documentation with usage examples
  • examples/typer_example.py — Working example app
  • Optional dependency via pip install cmd2[typer]

Test plan

  • make check passes (ruff, mypy)
  • All existing tests pass (no regressions)
  • python examples/typer_example.py runs, and tab completion works

The following is a screenshot of the typer_example.py

image

@tleonhardt tleonhardt self-assigned this Mar 21, 2026
@tleonhardt tleonhardt added this to the 4.0.0 milestone Mar 21, 2026
@codecov
Copy link

codecov bot commented Mar 21, 2026

Codecov Report

❌ Patch coverage is 85.09615% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.90%. Comparing base (53a5c0f) to head (8d49b9a).

Files with missing lines Patch % Lines
cmd2/typer_custom.py 82.60% 20 Missing ⚠️
cmd2/cmd2.py 84.61% 6 Missing ⚠️
cmd2/decorators.py 90.00% 5 Missing ⚠️
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     
Flag Coverage Δ
unittests 98.90% <85.09%> (-0.61%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

<div class="grid cards" markdown>
<!--intro-start-->
- [Argument Processing](argument_processing.md)
- [Typer Support](typer.md)
Copy link
Member

Choose a reason for hiding this comment

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

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

Choose a reason for hiding this comment

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

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.

Copy link
Author

Choose a reason for hiding this comment

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

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

Choose a reason for hiding this comment

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

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

)

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] = {}

try:
parser = cmd._command_parsers.get(command_func)
if not isinstance(parser, click.Command):
return Completions()
Copy link
Member

Choose a reason for hiding this comment

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

Add unit test to cover this line

]
return Completions(items=items)
except Exception as exc: # noqa: BLE001
return Completions(error=cmd.format_exception(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 unit test to cover this line

return current, resolved_names


def 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.

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

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


# 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

@tleonhardt
Copy link
Member

@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.

@kmvanbrunt
Copy link
Member

kmvanbrunt commented Mar 22, 2026

@KelvinChung2000

How would I create a typer-based command with the following?

  1. An argument completes its values with Cmd.path_complete?
  2. An argument completes its values with a choices_provider defined in a CommandSet?

@KelvinChung2000
Copy link
Author

KelvinChung2000 commented Mar 23, 2026

@KelvinChung2000

How would I create a typer-based command with the following?

  1. An argument completes its values with Cmd.path_complete?
  2. An argument completes its values with a choices_provider defined in a CommandSet?

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."""
        pass

which the autocompletion will detect the function's signature. So if it is a self, text, line, begidx, endidx is a completer. With only self, then it is a choices_provider. The current version only supports an unbounded completer.

But in this specific Path case, typer/click can infer the completion because it knows it is a Path type and provides that completion; this is also true for things like choice/enum. But the choices_provider case needs extra handling.

@tleonhardt
Copy link
Member

My biggest concern is that the Typer support lacks a lot of features that the argparse completion has. It also complicates logic having to check for different kinds of parsers. Thus a secondary concern is that it could potentially become a maintenance headache.

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 Typer generated argparse parsers instead of Click commands, this would be a dramatically cleaner integration.

@KelvinChung2000 @kmvanbrunt What do you think?

@kmvanbrunt
Copy link
Member

@tleonhardt I agree this feature adds complications for very little payoff. argparse is still a good library and cmd2 uses it extensively enough that supporting another parsing library results in maintenance headaches.

@KelvinChung2000
Copy link
Author

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 self handeling. But I agree that maintaining two completion systems is difficult.

The better approach might actually be not to use Typer directly, but to offer the user the same Annotated syntax, which is constructed this way. This can drop all the Typer dependency altogether. Then this becomes an extension of the existing argparse completer, since in argparse we can define type anyway and make completer inference happen from there.

@tleonhardt
Copy link
Member

I would be very open to a solution that supported the Annotated syntax and used it to auto-generate argparse-based Argument parser. That would immediately play nicely with the rest of the cmd2 ecosystem.

@KelvinChung2000
Copy link
Author

I will close this PR, and open a new one, since now we are completely changing the approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants