From 15ab5ac4f1060c78fc49979d72d439cd0808707e Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Mon, 25 Dec 2023 22:29:41 -0800 Subject: [PATCH] work work work --- django_typer/__init__.py | 360 +++++++++++++----- django_typer/tests/click_test.py | 31 +- .../test_app/management/commands/callback2.py | 33 ++ django_typer/tests/tests.py | 279 ++++++++++---- django_typer/tests/typer_test.py | 34 +- pyproject.toml | 3 +- 6 files changed, 528 insertions(+), 212 deletions(-) create mode 100644 django_typer/tests/test_app/management/commands/callback2.py diff --git a/django_typer/__init__.py b/django_typer/__init__.py index a2b21cc..ba3f75a 100644 --- a/django_typer/__init__.py +++ b/django_typer/__init__.py @@ -9,21 +9,23 @@ """ import sys -from types import SimpleNamespace -from typing import Any, Callable, Dict, List, Optional, Type, Union +from types import SimpleNamespace, MethodType +import typing as t import click import typer +from importlib import import_module from django.core.management.base import BaseCommand +from django.core.management import get_commands from typer import Typer -from typer.core import TyperArgument from typer.core import TyperCommand as CoreTyperCommand from typer.core import TyperGroup as CoreTyperGroup -from typer.main import get_command, get_params_convertors_ctx_param_name_from_function +from typer.main import get_command as get_typer_command, MarkupMode, get_params_convertors_ctx_param_name_from_function from typer.models import CommandFunctionType from typer.models import Context as TyperContext from typer.models import Default -from typer.testing import CliRunner +from dataclasses import dataclass +import contextlib from .types import ( ForceColor, @@ -52,8 +54,26 @@ "TyperCommandWrapper", "callback", "command", + "get_command" ] +def get_command( + command_name: str, + *subcommand: str, + stdout: t.Optional[t.IO[str]]=None, + stderr: t.Optional[t.IO[str]]=None, + no_color: bool=False, + force_color: bool=False +): + # todo - add a __call__ method to the command class if it is not a TyperCommand and has no + # __call__ method - this will allow this interface to be used for standard commands + module = import_module(f'{get_commands()[command_name]}.management.commands.{command_name}') + cmd = module.Command(stdout=stdout, stderr=stderr, no_color=no_color, force_color=force_color) + if subcommand: + method = cmd.get_subcommand(*subcommand).command._callback.__wrapped__ + return MethodType(method, cmd) # return the bound method + return cmd + class _ParsedArgs(SimpleNamespace): # pylint: disable=too-few-public-methods def __init__(self, args, **kwargs): @@ -75,13 +95,14 @@ class Context(TyperContext): """ django_command: "TyperCommand" + children: t.List["Context"] def __init__( self, command: click.Command, # pylint: disable=redefined-outer-name - parent: Optional["Context"] = None, - django_command: Optional["TyperCommand"] = None, - _resolved_params: Optional[Dict[str, Any]] = None, + parent: t.Optional["Context"] = None, + django_command: t.Optional["TyperCommand"] = None, + _resolved_params: t.Optional[t.Dict[str, t.Any]] = None, **kwargs, ): super().__init__(command, **kwargs) @@ -89,18 +110,21 @@ def __init__( if not django_command and parent: self.django_command = parent.django_command self.params.update(_resolved_params or {}) + self.children = [] + if parent: + parent.children.append(self) class DjangoAdapterMixin: # pylint: disable=too-few-public-methods - context_class: Type[click.Context] = Context + context_class: t.Type[click.Context] = Context def __init__( self, *args, - callback: Optional[ # pylint: disable=redefined-outer-name - Callable[..., Any] + callback: t.Optional[ # pylint: disable=redefined-outer-name + t.Callable[..., t.Any] ] = None, - params: Optional[List[click.Parameter]] = None, + params: t.Optional[t.List[click.Parameter]] = None, **kwargs, ): params = params or [] @@ -108,7 +132,7 @@ def __init__( expected = [param.name for param in params[1:]] self_arg = params[0].name if params else "self" - def do_callback(*args, **kwargs): + def call_with_self(*args, **kwargs): if callback: return callback( *args, @@ -116,9 +140,7 @@ def do_callback(*args, **kwargs): param: val for param, val in kwargs.items() if param in expected }, **{ - self_arg: getattr( - click.get_current_context(), "django_command", None - ) + self_arg: getattr(click.get_current_context(), "django_command", None) }, ) return None @@ -129,7 +151,7 @@ def do_callback(*args, **kwargs): *params[1:], *[param for param in COMMON_PARAMS if param.name not in expected], ], - callback=do_callback, + callback=call_with_self, **kwargs, ) @@ -142,31 +164,31 @@ class TyperGroupWrapper(DjangoAdapterMixin, CoreTyperGroup): pass -def callback( # pylint: disable=too-many-local-variables - name: Optional[str] = Default(None), +def callback( # pylint: disable=too-mt.Any-local-variables + name: t.Optional[str] = Default(None), *, - cls: Type[TyperGroupWrapper] = TyperGroupWrapper, + cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper, invoke_without_command: bool = Default(False), no_args_is_help: bool = Default(False), - subcommand_metavar: Optional[str] = Default(None), + subcommand_metavar: t.Optional[str] = Default(None), chain: bool = Default(False), - result_callback: Optional[Callable[..., Any]] = Default(None), + result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), # Command - context_settings: Optional[Dict[Any, Any]] = Default(None), - help: Optional[str] = Default(None), - epilog: Optional[str] = Default(None), - short_help: Optional[str] = Default(None), + context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), + help: t.Optional[str] = Default(None), + epilog: t.Optional[str] = Default(None), + short_help: t.Optional[str] = Default(None), options_metavar: str = Default("[OPTIONS]"), add_help_option: bool = Default(True), hidden: bool = Default(False), deprecated: bool = Default(False), # Rich settings - rich_help_panel: Union[str, None] = Default(None), + rich_help_panel: t.Union[str, None] = Default(None), **kwargs, ): def decorator(func: CommandFunctionType): func._typer_constructor_ = lambda cmd, **extra: cmd.typer_app.callback( - name=name, + name=name or extra.pop("name", None), cls=cls, invoke_without_command=invoke_without_command, subcommand_metavar=subcommand_metavar, @@ -191,25 +213,25 @@ def decorator(func: CommandFunctionType): def command( - name: Optional[str] = None, + name: t.Optional[str] = None, *args, - cls: Type[TyperCommandWrapper] = TyperCommandWrapper, - context_settings: Optional[Dict[Any, Any]] = None, - help: Optional[str] = None, - epilog: Optional[str] = None, - short_help: Optional[str] = None, + cls: t.Type[TyperCommandWrapper] = TyperCommandWrapper, + context_settings: t.Optional[t.Dict[t.Any, t.Any]] = None, + help: t.Optional[str] = None, + epilog: t.Optional[str] = None, + short_help: t.Optional[str] = None, options_metavar: str = "[OPTIONS]", add_help_option: bool = True, no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, # Rich settings - rich_help_panel: Union[str, None] = Default(None), + rich_help_panel: t.Union[str, None] = Default(None), **kwargs, ): def decorator(func: CommandFunctionType): func._typer_constructor_ = lambda cmd, **extra: cmd.typer_app.command( - name=name, + name=name or extra.pop("name", None), *args, cls=cls, context_settings=context_settings, @@ -232,15 +254,59 @@ def decorator(func: CommandFunctionType): class _TyperCommandMeta(type): - def __new__(mcs, name, bases, attrs, **kwargs): + def __new__( + mcs, + name, + bases, + attrs, + cls: t.Optional[t.Type[CoreTyperGroup]] = TyperGroupWrapper, + invoke_without_command: bool = Default(False), + no_args_is_help: bool = Default(False), + subcommand_metavar: t.Optional[str] = Default(None), + chain: bool = Default(False), + result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), + context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), + callback: t.Optional[t.Callable[..., t.Any]] = Default(None), + help: t.Optional[str] = Default(None), + epilog: t.Optional[str] = Default(None), + short_help: t.Optional[str] = Default(None), + options_metavar: str = Default("[OPTIONS]"), + add_help_option: bool = Default(True), + hidden: bool = Default(False), + deprecated: bool = Default(False), + add_completion: bool = True, + rich_markup_mode: MarkupMode = None, + rich_help_panel: t.Union[str, None] = Default(None), + pretty_exceptions_enable: bool = True, + pretty_exceptions_show_locals: bool = True, + pretty_exceptions_short: bool = True + ): """ This method is called when a new class is created. """ typer_app = Typer( name=mcs.__module__.rsplit(".", maxsplit=1)[-1], - cls=TyperGroupWrapper, - help=attrs.get("help", typer.models.Default(None)), - **kwargs, + cls=cls, + help=help or attrs.get("help", typer.models.Default(None)), + invoke_without_command=invoke_without_command, + no_args_is_help=no_args_is_help, + subcommand_metavar=subcommand_metavar, + chain=chain, + result_callback=result_callback, + context_settings=context_settings, + callback=callback, + epilog=epilog, + short_help=short_help, + options_metavar=options_metavar, + add_help_option=add_help_option, + hidden=hidden, + deprecated=deprecated, + add_completion=add_completion, + rich_markup_mode=rich_markup_mode, + rich_help_panel=rich_help_panel, + pretty_exceptions_enable=pretty_exceptions_enable, + pretty_exceptions_show_locals=pretty_exceptions_show_locals, + pretty_exceptions_short=pretty_exceptions_short ) def handle(self, *args, **options): @@ -263,7 +329,13 @@ def handle(self, *args, **options): }, ) - def __init__(cls, name, bases, attrs, **kwargs): + def __init__( + cls, + name, + bases, + attrs, + **kwargs + ): """ This method is called after a new class is created. """ @@ -285,63 +357,63 @@ def __init__(cls, name, bases, attrs, **kwargs): super().__init__(name, bases, attrs, **kwargs) -class _TyperParserAdapter: - _actions: List[Any] = [] - _mutually_exclusive_groups: List[Any] = [] +class TyperParser: + + @dataclass(frozen=True) + class Action: + dest: str + required: bool = False + + @property + def option_strings(self): + return [self.dest] + + _actions: t.List[t.Any] + _mutually_exclusive_groups: t.List[t.Any] = [] django_command: "TyperCommand" prog_name: str subcommand: str def __init__(self, django_command: "TyperCommand", prog_name, subcommand): + self._actions = [] self.django_command = django_command self.prog_name = prog_name self.subcommand = subcommand - - def print_help(self): - typer.echo(CliRunner().invoke(self.django_command.typer_app, ["--help"]).output) + + def populate_params(node): + for param in node.command.params: + self._actions.append(self.Action(param.name)) + for child in node.children.values(): + populate_params(child) + + populate_params(self.django_command.command_tree) + + def print_help(self, *command_path: str): + self.django_command.command_tree.context.info_name = f'{self.prog_name} {self.subcommand}' + command_node = self.django_command.get_subcommand(*command_path) + with contextlib.redirect_stdout(self.django_command.stdout): + command_node.print_help() def parse_args(self, args=None, namespace=None): try: - cmd = get_command(self.django_command.typer_app) + cmd = get_typer_command(self.django_command.typer_app) with cmd.make_context( - f"{self.prog_name} {self.subcommand}", - list(args or []), + info_name=f'{self.prog_name} {self.subcommand}', django_command=self.django_command, + args=list(args or []) ) as ctx: - if ctx.protected_args: - p_args = [*ctx.protected_args, *ctx.args] - if not cmd.chain: # type: ignore - (cmd_name, cmd, c_args) = cmd.resolve_command( # type: ignore - ctx, p_args - ) - assert cmd is not None - sub_ctx = cmd.make_context( - cmd_name, - c_args, - parent=ctx, - django_command=self.django_command, - ) - - c_args = [] - for param in ctx.command.params: - if isinstance(param, TyperArgument): - import ipdb - - ipdb.set_trace() - c_args.append(ctx.params.pop(param.name)) - - return _ParsedArgs( - args=[*c_args, *p_args], - **{**_common_options(), **ctx.params, **sub_ctx.params}, - ) - else: - pass - else: - return _ParsedArgs( - args=args or [], **{**_common_options(), **ctx.params} - ) - + params = ctx.params + def discover_parsed_args(ctx): + for child in ctx.children: + discover_parsed_args(child) + params.update(child.params) + + discover_parsed_args(ctx) + + return _ParsedArgs( + args=args or [], **{**_common_options(), **params} + ) except click.exceptions.Exit: sys.exit() @@ -387,7 +459,7 @@ class TyperCommand(BaseCommand, metaclass=_TyperCommandMeta): This means that the BaseCommand interface is preserved and the Typer interface is added on top of it. This means that this code base is more robust to changes in the Django management command system - because most - of the base class functionality is preserved but many typer and click + of the base class functionality is preserved but mt.Any typer and click internals are used directly to achieve this. We rely on robust CI to catch breaking changes in the click/typer dependencies. @@ -397,28 +469,124 @@ class TyperCommand(BaseCommand, metaclass=_TyperCommandMeta): does this so it can be broken apart and be interface compatible with Django. Also when are callbacks invoked, etc - during make_context? or invoke? There is a complexity here with execute(). + + TODO - lazy loaded command overrides. + Should be able to attach to another TyperCommand like this and conflicts would resolve + based on INSTALLED_APP precedence. + + class Command(TyperCommand, attach='app_label.command_name.subcommand1.subcommand2'): + ... """ + class CommandNode: + + name: str + command: t.Union[TyperCommandWrapper, TyperGroupWrapper] + context: TyperContext + children: t.Dict[str, "CommandNode"] + + def __init__( + self, + name: str, + command: t.Union[TyperCommandWrapper, TyperGroupWrapper], + context: TyperContext + ): + self.name = name + self.command = command + self.context = context + self.children = {} + + def print_help(self): + self.command.get_help(self.context) + + def get_command(self, *command_path: str): + if not command_path: + return self + try: + return self.children[command_path[0]].get_command(*command_path[1:]) + except KeyError: + raise ValueError(f'No such command "{command_path[0]}"') + typer_app: Typer - @property - def stealth_options(self): - """ - This is the only way to inject the set of valid parameters into - call_command because it does its own parameter validation - otherwise - TypeErrors are thrown. - """ - return tuple(COMMON_PARAM_NAMES) + command_tree: CommandNode + + def __init__( + self, + stdout: t.Optional[t.IO[str]]=None, + stderr: t.Optional[t.IO[str]]=None, + no_color: bool=False, + force_color: bool=False, + **kwargs + ): + super().__init__(stdout=stdout, stderr=stderr, no_color=no_color, force_color=force_color, **kwargs) + self.command_tree = self._build_cmd_tree( + get_typer_command(self.typer_app) + ) + + def get_subcommand(self, *command_path: str): + return self.command_tree.get_command(*command_path) + + def _filter_commands( + self, ctx: TyperContext, cmd_filter: t.Optional[t.List[str]] = None + ): + return sorted( + [ + cmd + for name, cmd in getattr( + ctx.command, + 'commands', + { + name: ctx.command.get_command(ctx, name) + for name in getattr( + ctx.command, 'list_commands', lambda _: [] + )(ctx) + or cmd_filter or [] + }, + ).items() + if not cmd_filter or name in cmd_filter + ], + key=lambda item: item.name, + ) + + def _build_cmd_tree( + self, + cmd: CoreTyperCommand, + parent: t.Optional[Context] = None, + info_name: t.Optional[str] = None, + node: t.Optional[CommandNode] = None + ): + ctx = Context( + cmd, + info_name=info_name, + parent=parent, + django_command=self + ) + current = self.CommandNode(cmd.name, cmd, ctx) + if node: + node.children[cmd.name] = current + for cmd in self._filter_commands(ctx): + self._build_cmd_tree(cmd, ctx, info_name=cmd.name, node=current) + return current + def __init_subclass__(cls, **_): """Avoid passing typer arguments up the subclass init chain""" return super().__init_subclass__() - def create_parser(self, prog_name, subcommand, **_): - return _TyperParserAdapter(self, prog_name, subcommand) + def create_parser(self, prog_name: str, subcommand: str, **_): + return TyperParser(self, prog_name, subcommand) + + def print_help(self, prog_name: str, subcommand: str, *cmd_path: str): + """ + Print the help message for this command, derived from + ``self.usage()``. + """ + parser = self.create_parser(prog_name, subcommand) + parser.print_help(*cmd_path) - def handle(self, *args: Any, **options: Any) -> str | None: - ... + def handle(self, *args: t.Any, **options: t.Any) -> t.Any: + pass # pragma: no cover def __call__(self, *args, **kwargs): """ diff --git a/django_typer/tests/click_test.py b/django_typer/tests/click_test.py index 9edeebe..0a00894 100644 --- a/django_typer/tests/click_test.py +++ b/django_typer/tests/click_test.py @@ -1,24 +1,29 @@ import click +from pprint import pprint -@click.group() -def cli(): +params = {} + +@click.group(context_settings={'allow_interspersed_args': True, 'ignore_unknown_options': True}) +@click.argument("name") +@click.option("--verbose", "-v", is_flag=True, help="Enables verbose mode.") +def main(name: str, verbose: bool = False): """ Help text for the main command """ - print("cli()") - - -@cli.command() -def command1(): - click.echo("command1") + global params + params = {"name": name, "verbose": verbose} -@cli.command() -@click.argument("model") -def command2(model): - click.echo("model: {}".format(model), fg="red") +@main.command() +@click.argument("arg1") +@click.argument("arg2") +@click.option("--flag1", "-f", is_flag=True, help="A flag.") +def command1(arg1, arg2, flag1=False): + global params + params.update({"arg1": arg1, "arg2": arg2, "flag1": flag1}) + pprint(params) if __name__ == "__main__": - cli() + main() diff --git a/django_typer/tests/test_app/management/commands/callback2.py b/django_typer/tests/test_app/management/commands/callback2.py new file mode 100644 index 0000000..b02f447 --- /dev/null +++ b/django_typer/tests/test_app/management/commands/callback2.py @@ -0,0 +1,33 @@ +import json +from django_typer import TyperCommand, callback, command + + +class Command(TyperCommand, invoke_without_command=True): + help = "Test basic callback command." + + parameters = {} + + @callback( + context_settings={ + 'allow_interspersed_args': True, + 'ignore_unknown_options': True + } + ) + def init(self, p1: int, flag1: bool = False, flag2: bool = True): + """ + The callback to initialize the command. + """ + assert self.__class__ == Command + self.parameters = {"p1": p1, "flag1": flag1, "flag2": flag2} + return json.dumps(self.parameters) + + @command( + context_settings={ + 'allow_interspersed_args': True, + 'ignore_unknown_options': True + } + ) + def handle(self, arg1: str, arg2: str, arg3: float = 0.5, arg4: int = 1): + assert self.__class__ == Command + self.parameters.update({"arg1": arg1, "arg2": arg2, "arg3": arg3, "arg4": arg4}) + return json.dumps(self.parameters) diff --git a/django_typer/tests/tests.py b/django_typer/tests/tests.py index 41e0d02..426f0f2 100644 --- a/django_typer/tests/tests.py +++ b/django_typer/tests/tests.py @@ -4,10 +4,12 @@ import sys from io import StringIO from pathlib import Path +import os import django import typer -from django.core.management import call_command, get_commands, load_command_class +from django_typer import get_command +from django.core.management import call_command from django.test import TestCase manage_py = Path(__file__).parent.parent.parent / "manage.py" @@ -23,21 +25,25 @@ def get_named_arguments(function): def run_command(command, *args): - result = subprocess.run( - [sys.executable, manage_py, command, *args], capture_output=True, text=True - ) - - # Check the return code to ensure the script ran successfully - if result.returncode != 0: - raise RuntimeError(result.stderr) + cwd = os.getcwd() + try: + os.chdir(manage_py.parent) + result = subprocess.run( + [sys.executable, f'./{manage_py.name}', command, *args], capture_output=True, text=True + ) - # Parse the output - if result.stdout: - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - return result.stdout + # Check the return code to ensure the script ran successfully + if result.returncode != 0: + raise RuntimeError(result.stderr) + # Parse the output + if result.stdout: + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return result.stdout + finally: + os.chdir(cwd) class BasicTests(TestCase): def test_command_line(self): @@ -72,7 +78,7 @@ def test_get_version(self): ) def test_call_direct(self): - basic = load_command_class(get_commands()["basic"], "basic") + basic = get_command('basic') self.assertEqual( json.loads(basic.handle("a1", "a2")), {"arg1": "a1", "arg2": "a2", "arg3": 0.5, "arg4": 1}, @@ -186,7 +192,7 @@ def test_get_version(self): ) def test_call_direct(self): - multi = load_command_class(get_commands()["multi"], "multi") + multi = get_command("multi") self.assertEqual( json.loads(multi.cmd1(["/path/one", "/path/two"])), @@ -202,11 +208,55 @@ def test_call_direct(self): self.assertEqual(json.loads(multi.cmd3()), {}) +class TestGetCommand(TestCase): + + def test_get_command(self): + from django_typer.tests.test_app.management.commands.basic import Command as Basic + basic = get_command('basic') + assert basic.__class__ == Basic + + from django_typer.tests.test_app.management.commands.multi import Command as Multi + multi = get_command('multi') + assert multi.__class__ == Multi + cmd1 = get_command('multi', 'cmd1') + assert cmd1.__func__ is multi.cmd1.__func__ + sum = get_command('multi', 'sum') + assert sum.__func__ is multi.sum.__func__ + cmd3 = get_command('multi', 'cmd3') + assert cmd3.__func__ is multi.cmd3.__func__ + + from django_typer.tests.test_app.management.commands.callback1 import Command as Callback1 + callback1 = get_command('callback1') + assert callback1.__class__ == Callback1 + + # callbacks are not commands + with self.assertRaises(ValueError): + get_command('callback1', 'init') + class CallbackTests(TestCase): + + cmd_name = 'callback1' + + def test_helps(self, top_level_only=False): + buffer = StringIO() + cmd = get_command(self.cmd_name, stdout=buffer) + + help_output_top = run_command(self.cmd_name, '--help') + cmd.print_help('./manage.py', self.cmd_name) + self.assertEqual(help_output_top.strip(), buffer.getvalue().strip()) + + if not top_level_only: + buffer.truncate(0) + buffer.seek(0) + callback_help = run_command(self.cmd_name, '5', self.cmd_name, '--help') + cmd.print_help('./manage.py', self.cmd_name, self.cmd_name) + self.assertEqual(callback_help.strip(), buffer.getvalue().strip()) + + def test_command_line(self): self.assertEqual( - run_command("callback1", "5", "callback1", "a1", "a2"), + run_command(self.cmd_name, "5", self.cmd_name, "a1", "a2"), { "p1": 5, "flag1": False, @@ -220,11 +270,11 @@ def test_command_line(self): self.assertEqual( run_command( - "callback1", - "6", + self.cmd_name, "--flag1", "--no-flag2", - "callback1", + "6", + self.cmd_name, "a1", "a2", "--arg3", @@ -243,8 +293,14 @@ def test_command_line(self): }, ) - def test_call_command(self): - ret = json.loads(call_command("callback1", ["5", "callback1", "a1", "a2"])) + def test_call_command(self, should_raise=True): + ret = json.loads( + call_command( + self.cmd_name, + *["5", self.cmd_name, "a1", "a2"], + **{'p1': 5, 'arg1': 'a1', 'arg2': 'a2'} + ) + ) self.assertEqual( ret, { @@ -260,12 +316,12 @@ def test_call_command(self): ret = json.loads( call_command( - "callback1", - [ - "6", + self.cmd_name, + *[ "--flag1", "--no-flag2", - "callback1", + "6", + self.cmd_name, "a1", "a2", "--arg3", @@ -288,60 +344,117 @@ def test_call_command(self): }, ) - # def test_call_command_stdout(self): - # out = StringIO() - # call_command('multi', ['cmd1', '/path/one', '/path/two'], stdout=out) - # self.assertEqual(json.loads(out.getvalue()), {'files': ['/path/one', '/path/two'], 'flag1': False}) - - # out = StringIO() - # call_command('multi', ['cmd1', '/path/four', '/path/three', '--flag1'], stdout=out) - # self.assertEqual(json.loads(out.getvalue()), {'files': ['/path/four', '/path/three'], 'flag1': True}) - - # out = StringIO() - # call_command('multi', ['sum', '1.2', '3.5', ' -12.3'], stdout=out) - # self.assertEqual(json.loads(out.getvalue()), sum([1.2, 3.5, -12.3])) - - # out = StringIO() - # call_command('multi', ['cmd3'], stdout=out) - # self.assertEqual(json.loads(out.getvalue()), {}) - - # def test_get_version(self): - # self.assertEqual( - # run_command('multi', '--version').strip(), - # django.get_version() - # ) - # self.assertEqual( - # run_command('multi', 'cmd1', '--version').strip(), - # django.get_version() - # ) - # self.assertEqual( - # run_command('multi', 'sum', '--version').strip(), - # django.get_version() - # ) - # self.assertEqual( - # run_command('multi', 'cmd3', '--version').strip(), - # django.get_version() - # ) - - # def test_call_direct(self): - # multi = load_command_class(get_commands()['multi'], 'multi') - - # self.assertEqual( - # json.loads(multi.cmd1(['/path/one', '/path/two'])), - # {'files': ['/path/one', '/path/two'], 'flag1': False} - # ) - - # self.assertEqual( - # json.loads(multi.cmd1(['/path/four', '/path/three'], flag1=True)), - # {'files': ['/path/four', '/path/three'], 'flag1': True} - # ) - - # self.assertEqual( - # float(multi.sum([1.2, 3.5, -12.3])), - # sum([1.2, 3.5, -12.3]) - # ) - - # self.assertEqual( - # json.loads(multi.cmd3()), - # {} - # ) + # show that order matters args vs options + interspersed = [ + lambda: call_command( + self.cmd_name, + *[ + "6", + "--flag1", + "--no-flag2", + self.cmd_name, + "n1", + "n2", + "--arg3", + "0.2", + "--arg4", + "9", + ] + ), + lambda: call_command( + self.cmd_name, + *[ + "--no-flag2", + "6", + "--flag1", + self.cmd_name, + "--arg4", + "9", + "n1", + "n2", + "--arg3", + "0.2" + ] + ) + ] + expected = { + "p1": 6, + "flag1": True, + "flag2": False, + "arg1": "n1", + "arg2": "n2", + "arg3": 0.2, + "arg4": 9 + } + if should_raise: + for call_cmd in interspersed: + if should_raise: + with self.assertRaises(BaseException): + call_cmd() + else: + self.assertEqual(json.loads(call_cmd()), expected) + + + def test_call_command_stdout(self): + out = StringIO() + call_command( + self.cmd_name, + [ + "--flag1", + "--no-flag2", + "6", + self.cmd_name, + "a1", + "a2", + "--arg3", + "0.75", + "--arg4", + "2" + ], + stdout=out + ) + + self.assertEqual( + json.loads(out.getvalue()), + { + "p1": 6, + "flag1": True, + "flag2": False, + "arg1": "a1", + "arg2": "a2", + "arg3": 0.75, + "arg4": 2, + }, + ) + + def test_get_version(self): + self.assertEqual( + run_command(self.cmd_name, '--version').strip(), + django.get_version() + ) + self.assertEqual( + run_command(self.cmd_name, '6', self.cmd_name, '--version').strip(), + django.get_version() + ) + + def test_call_direct(self): + cmd = get_command(self.cmd_name) + + self.assertEqual( + json.loads(cmd(arg1='a1', arg2='a2', arg3=0.2)), + {'arg1': 'a1', 'arg2': 'a2', 'arg3': 0.2, 'arg4': 1} + ) + + +class Callback2Tests(CallbackTests): + + cmd_name = 'callback2' + + def test_call_command(self): + super().test_call_command(should_raise=False) + + def test_helps(self, top_level_only=False): + # we only run the top level help comparison because when + # interspersed args are allowed its impossible to get the + # subcommand to print its help + super().test_helps(top_level_only=True) diff --git a/django_typer/tests/typer_test.py b/django_typer/tests/typer_test.py index ca04e3e..76523c6 100755 --- a/django_typer/tests/typer_test.py +++ b/django_typer/tests/typer_test.py @@ -1,12 +1,13 @@ +#!/usr/bin/env python import typer from django_typer import TyperCommandWrapper, _common_options -app = typer.Typer() +app = typer.Typer(name='test') state = {"verbose": False} -@app.command(context_settings={"allow_interspersed_args": True}) +@app.command() def create(username: str, flag: bool = False): if state["verbose"]: print("About to create a user") @@ -16,17 +17,17 @@ def create(username: str, flag: bool = False): print(f"flag: {flag}") -@app.command(epilog="Delete Epilog") -def delete(username: str): - if state["verbose"]: - print("About to delete a user") - print(f"Deleting user: {username}") - if state["verbose"]: - print("Just deleted a user") +# @app.command(epilog="Delete Epilog") +# def delete(username: str): +# if state["verbose"]: +# print("About to delete a user") +# print(f"Deleting user: {username}") +# if state["verbose"]: +# print("Just deleted a user") @app.callback(epilog="Main Epilog") -def main(verbose: bool = False): +def main(arg: int, verbose: bool = False): """ Manage users in the awesome CLI app. """ @@ -35,17 +36,14 @@ def main(verbose: bool = False): state["verbose"] = True -app.command(name="common")(_common_options) +# app.command(name="common")(_common_options) -@app.command(cls=TyperCommandWrapper) -def wrapped(name: str): - """This is a wrapped command""" - print("wrapped(%s)" % name) +# @app.command(cls=TyperCommandWrapper) +# def wrapped(name: str): +# """This is a wrapped command""" +# print("wrapped(%s)" % name) if __name__ == "__main__": - import ipdb - - ipdb.set_trace() app() diff --git a/pyproject.toml b/pyproject.toml index 68b806e..ecd1661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,8 +113,7 @@ addopts = [ "--cov-fail-under=70" ] -[tool.coverage] -# dont exempt tests from coverage - useful to make sure they're being run +[tool.coverage.run] omit = [ "django_typer/tests/**/*py" ]