Skip to content

Commit 7afa1ab

Browse files
feat: typer support
1 parent 53a5c0f commit 7afa1ab

12 files changed

Lines changed: 1236 additions & 15 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ On all operating systems, the latest stable version of `cmd2` can be installed u
8686
pip install -U cmd2
8787
```
8888

89+
To enable Typer-based argument parsing, install the optional extra:
90+
91+
```bash
92+
pip install -U cmd2[typer]
93+
```
94+
8995
cmd2 works with Python 3.10+ on Windows, macOS, and Linux. It is pure Python code with few 3rd-party
9096
dependencies. It works with both conventional CPython and free-threaded variants.
9197

cmd2/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
with_argparser,
3939
with_argument_list,
4040
with_category,
41+
with_typer,
4142
)
4243
from .exceptions import (
4344
Cmd2ArgparseError,
@@ -84,6 +85,7 @@
8485
'with_category',
8586
'with_default_category',
8687
'as_subcommand_to',
88+
'with_typer',
8789
# Exceptions
8890
'Cmd2ArgparseError',
8991
'CommandSetRegistrationError',

cmd2/cmd2.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ def __init__(self, msg: str = '') -> None:
204204
suggest_similar,
205205
)
206206

207+
try:
208+
from .typer_custom import TyperParser
209+
except ImportError:
210+
211+
class TyperParser: # type: ignore[no-redef]
212+
"""Sentinel: isinstance checks always return False when typer is not installed."""
213+
214+
207215
if TYPE_CHECKING: # pragma: no cover
208216
StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
209217
ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser]
@@ -229,14 +237,16 @@ class _CommandParsers:
229237
"""Create and store all command method argument parsers for a given Cmd instance.
230238
231239
Parser creation and retrieval are accomplished through the get() method.
240+
Supports both argparse-based and Typer/Click-based commands.
232241
"""
233242

234243
def __init__(self, cmd: 'Cmd') -> None:
235244
self._cmd = cmd
236245

237246
# Keyed by the fully qualified method names. This is more reliable than
238247
# the methods themselves, since wrapping a method will change its address.
239-
self._parsers: dict[str, argparse.ArgumentParser] = {}
248+
# Values are argparse.ArgumentParser for argparse commands or TyperParser (click.Command) for Typer commands.
249+
self._parsers: dict[str, argparse.ArgumentParser | TyperParser] = {}
240250

241251
@staticmethod
242252
def _fully_qualified_name(command_method: CommandFunc) -> str:
@@ -250,15 +260,16 @@ def __contains__(self, command_method: CommandFunc) -> bool:
250260
"""Return whether a given method's parser is in self.
251261
252262
If the parser does not yet exist, it will be created if applicable.
253-
This is basically for checking if a method is argarse-based.
263+
This is basically for checking if a method uses argparse or Typer.
254264
"""
255265
parser = self.get(command_method)
256266
return bool(parser)
257267

258-
def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None:
259-
"""Return a given method's parser or None if the method is not argparse-based.
268+
def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | TyperParser | None:
269+
"""Return a given method's parser or None if the method is not parser-based.
260270
261271
If the parser does not yet exist, it will be created.
272+
Handles both argparse and Typer/Click commands.
262273
"""
263274
full_method_name = self._fully_qualified_name(command_method)
264275
if not full_method_name:
@@ -269,6 +280,14 @@ def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None:
269280
return None
270281
command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :]
271282

283+
# Check for Typer command
284+
if getattr(command_method, constants.CMD_ATTR_TYPER_FUNC, None) is not None:
285+
from .typer_custom import build_typer_command
286+
287+
parent = self._cmd.find_commandset_for_command(command) or self._cmd
288+
self._parsers[full_method_name] = build_typer_command(parent, command_method)
289+
return self._parsers[full_method_name]
290+
272291
parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
273292
if parser_builder is None:
274293
return None
@@ -1030,7 +1049,7 @@ def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
10301049
# is the actual command since command synonyms don't own it.
10311050
if cmd_func_name == command_method.__name__:
10321051
command_parser = self._command_parsers.get(command_method)
1033-
if command_parser is not None:
1052+
if isinstance(command_parser, argparse.ArgumentParser):
10341053
check_parser_uninstallable(command_parser)
10351054

10361055
def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
@@ -1075,7 +1094,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
10751094
if command_func is None:
10761095
raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
10771096
command_parser = self._command_parsers.get(command_func)
1078-
if command_parser is None:
1097+
if not isinstance(command_parser, argparse.ArgumentParser):
10791098
raise CommandSetRegistrationError(
10801099
f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
10811100
)
@@ -1161,7 +1180,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
11611180
# but keeping in case it does for some strange reason
11621181
raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
11631182
command_parser = self._command_parsers.get(command_func)
1164-
if command_parser is None: # pragma: no cover
1183+
if not isinstance(command_parser, argparse.ArgumentParser): # pragma: no cover
11651184
# This really shouldn't be possible since _register_subcommands would prevent this from happening
11661185
# but keeping in case it does for some strange reason
11671186
raise CommandSetRegistrationError(
@@ -2333,11 +2352,11 @@ def _perform_completion(
23332352
if func_attr is not None:
23342353
completer_func = func_attr
23352354
else:
2336-
# There's no completer function, next see if the command uses argparse
2355+
# There's no completer function, next see if the command uses a parser
23372356
func = self.cmd_func(command)
23382357
argparser = None if func is None else self._command_parsers.get(func)
23392358

2340-
if func is not None and argparser is not None:
2359+
if func is not None and isinstance(argparser, argparse.ArgumentParser):
23412360
# Get arguments for complete()
23422361
preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)
23432362
cmd_set = self.find_commandset_for_command(command)
@@ -2349,6 +2368,16 @@ def _perform_completion(
23492368
completer_func = functools.partial(
23502369
completer.complete, tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], cmd_set=cmd_set
23512370
)
2371+
elif func is not None and isinstance(argparser, TyperParser):
2372+
from .typer_custom import typer_complete
2373+
2374+
preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)
2375+
completer_func = functools.partial(
2376+
typer_complete,
2377+
self,
2378+
command_func=func,
2379+
args=raw_tokens[1:] if preserve_quotes else tokens[1:],
2380+
)
23522381
else:
23532382
completer_func = self.completedefault # type: ignore[assignment]
23542383

@@ -4097,11 +4126,30 @@ def complete_help_subcommands(
40974126
return Completions()
40984127

40994128
# Check if this command uses argparse
4100-
if (func := self.cmd_func(command)) is None or (argparser := self._command_parsers.get(func)) is None:
4129+
func = self.cmd_func(command)
4130+
if func is None:
41014131
return Completions()
41024132

4103-
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
4104-
return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
4133+
argparser = self._command_parsers.get(func)
4134+
if isinstance(argparser, argparse.ArgumentParser):
4135+
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
4136+
return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
4137+
4138+
# Typer/Click subcommand help completion
4139+
if isinstance(argparser, TyperParser):
4140+
from .typer_custom import typer_complete_subcommand_help
4141+
4142+
return typer_complete_subcommand_help(
4143+
self,
4144+
text,
4145+
line,
4146+
begidx,
4147+
endidx,
4148+
command_func=func,
4149+
subcommands=arg_tokens['subcommands'],
4150+
)
4151+
4152+
return Completions()
41054153

41064154
def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
41074155
"""Categorizes and sorts visible commands and help topics for display.
@@ -4211,10 +4259,21 @@ def do_help(self, args: argparse.Namespace) -> None:
42114259
argparser = None if func is None else self._command_parsers.get(func)
42124260

42134261
# If the command function uses argparse, then use argparse's help
4214-
if func is not None and argparser is not None:
4262+
if func is not None and isinstance(argparser, argparse.ArgumentParser):
42154263
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
42164264
completer.print_help(args.subcommands, self.stdout)
4265+
# If the command function uses Typer, then use Click's help
4266+
elif func is not None and isinstance(argparser, TyperParser):
4267+
from .typer_custom import resolve_typer_subcommand
42174268

4269+
try:
4270+
target_command, resolved_names = resolve_typer_subcommand(argparser, args.subcommands)
4271+
except KeyError:
4272+
target_command, resolved_names = argparser, []
4273+
info_name = ' '.join([args.command, *resolved_names])
4274+
ctx = target_command.make_context(info_name, [], resilient_parsing=True)
4275+
with contextlib.redirect_stdout(self.stdout):
4276+
target_command.get_help(ctx)
42184277
# If the command has a custom help function, then call it
42194278
elif help_func is not None:
42204279
help_func()

cmd2/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
# The argparse parser for the command
3939
CMD_ATTR_ARGPARSER = 'argparser'
4040

41+
# The typer app for the command (or None if auto-built from function signature)
42+
CMD_ATTR_TYPER = 'typer'
43+
CMD_ATTR_TYPER_FUNC = 'typer_func'
44+
CMD_ATTR_TYPER_KWARGS = 'typer_kwargs'
45+
CMD_ATTR_TYPER_CONTEXT_SETTINGS = 'typer_context_settings'
46+
4147
# Whether or not tokens are unquoted before sending to argparse
4248
CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes'
4349

cmd2/decorators.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Decorators for ``cmd2`` commands."""
22

33
import argparse
4+
import inspect
5+
import sys
46
from collections.abc import (
57
Callable,
68
Sequence,
@@ -295,9 +297,9 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None:
295297

296298
# Pass cmd_wrapper instead of func, since it contains the parser info.
297299
arg_parser = cmd2_app._command_parsers.get(cmd_wrapper)
298-
if arg_parser is None:
300+
if not isinstance(arg_parser, argparse.ArgumentParser):
299301
# This shouldn't be possible to reach
300-
raise ValueError(f'No argument parser found for {command_name}') # pragma: no cover
302+
raise TypeError(f'No argument parser found for {command_name}') # pragma: no cover
301303

302304
if ns_provider is None:
303305
namespace = None
@@ -344,6 +346,120 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None:
344346
return arg_decorator
345347

346348

349+
def with_typer(
350+
func_arg: CommandFunc | Any | None = None,
351+
*,
352+
preserve_quotes: bool = False,
353+
context_settings: dict[str, Any] | None = None,
354+
) -> RawCommandFuncOptionalBoolReturn[CmdOrSet] | Callable[[CommandFunc], RawCommandFuncOptionalBoolReturn[CmdOrSet]]:
355+
"""Decorate a ``do_*`` method to process its arguments using Typer/Click.
356+
357+
:param func_arg: Single-element positional argument list containing ``do_*`` method
358+
this decorator is wrapping, or an explicit Typer app
359+
:param preserve_quotes: if ``True``, then argument quotes will not be stripped
360+
:param context_settings: optional dict of Click context settings passed to Typer
361+
:return: function that gets passed Typer-parsed arguments
362+
363+
Example:
364+
```py
365+
class MyApp(cmd2.Cmd):
366+
@cmd2.with_typer
367+
def do_add(
368+
self,
369+
a: int,
370+
b: Annotated[int, typer.Option("--b")] = 2,
371+
) -> None:
372+
self.poutput(str(a + b))
373+
```
374+
375+
"""
376+
import functools
377+
378+
try:
379+
import typer
380+
except ModuleNotFoundError as exc:
381+
raise ImportError("Typer support requires the 'typer' package. Install it with: pip install cmd2[typer]") from exc
382+
383+
explicit_app = None
384+
if isinstance(func_arg, typer.Typer):
385+
explicit_app = func_arg
386+
387+
def arg_decorator(func: CommandFunc) -> RawCommandFuncOptionalBoolReturn[CmdOrSet]:
388+
"""Decorate function that ingests a command function and returns a raw command function.
389+
390+
The returned function will process the raw input through Typer/Click to be passed to the wrapped function.
391+
392+
:param func: The defined Typer command function
393+
:return: Function that takes raw input and converts to Typer-parsed arguments passed to the wrapped function.
394+
"""
395+
396+
@functools.wraps(func)
397+
def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None:
398+
"""Command function wrapper which translates command line into Typer-parsed arguments and command function.
399+
400+
:param args: All positional arguments to this function. We're expecting there to be:
401+
cmd2_app, statement: Union[Statement, str]
402+
contiguously somewhere in the list
403+
:param kwargs: any keyword arguments being passed to command function
404+
:return: return value of command function
405+
:raises Cmd2ArgparseError: if Typer/Click has error parsing command line
406+
"""
407+
cmd2_app, statement_arg = _parse_positionals(args)
408+
_, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement_arg, preserve_quotes)
409+
410+
# Pass cmd_wrapper instead of func, since it contains the typer attribute info.
411+
import click
412+
413+
parser = cmd2_app._command_parsers.get(cmd_wrapper)
414+
if not isinstance(parser, click.Command):
415+
raise TypeError(f'No Typer command found for {command_name}') # pragma: no cover
416+
417+
try:
418+
setattr(cmd_wrapper, constants.CMD_ATTR_TYPER_KWARGS, dict(kwargs))
419+
result = parser.main(
420+
args=parsed_arglist,
421+
prog_name=command_name,
422+
standalone_mode=False,
423+
)
424+
except Exception as exc:
425+
if isinstance(exc, click.ClickException):
426+
exc.show(file=sys.stderr)
427+
raise Cmd2ArgparseError from exc
428+
if isinstance(exc, (click.exceptions.Exit, SystemExit)):
429+
raise Cmd2ArgparseError from exc
430+
raise
431+
finally:
432+
if hasattr(cmd_wrapper, constants.CMD_ATTR_TYPER_KWARGS):
433+
delattr(cmd_wrapper, constants.CMD_ATTR_TYPER_KWARGS)
434+
435+
# clicks command return Any type
436+
return result # type: ignore[no-any-return]
437+
438+
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
439+
440+
# Typer relies on signature + annotations to build its CLI; strip self.
441+
sig = inspect.signature(func)
442+
params = list(sig.parameters.values())
443+
if params and params[0].name == "self":
444+
params = params[1:]
445+
cmd_wrapper.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined]
446+
annotations = dict(getattr(func, "__annotations__", {}))
447+
annotations.pop("self", None)
448+
cmd_wrapper.__annotations__ = annotations
449+
450+
# Set some custom attributes for this command
451+
setattr(cmd_wrapper, constants.CMD_ATTR_TYPER, explicit_app)
452+
setattr(cmd_wrapper, constants.CMD_ATTR_TYPER_FUNC, func)
453+
setattr(cmd_wrapper, constants.CMD_ATTR_TYPER_CONTEXT_SETTINGS, context_settings)
454+
setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
455+
456+
return cmd_wrapper
457+
458+
if explicit_app is None and callable(func_arg):
459+
return arg_decorator(func_arg)
460+
return arg_decorator
461+
462+
347463
def as_subcommand_to(
348464
command: str,
349465
subcommand: str,

0 commit comments

Comments
 (0)