diff --git a/django_typer/__init__.py b/django_typer/__init__.py index ba3f75a..989c459 100644 --- a/django_typer/__init__.py +++ b/django_typer/__init__.py @@ -8,24 +8,26 @@ """ +import contextlib import sys -from types import SimpleNamespace, MethodType import typing as t +from dataclasses import dataclass +from importlib import import_module +from types import MethodType, SimpleNamespace import click import typer -from importlib import import_module -from django.core.management.base import BaseCommand from django.core.management import get_commands +from django.core.management.base import BaseCommand from typer import Typer from typer.core import TyperCommand as CoreTyperCommand from typer.core import TyperGroup as CoreTyperGroup -from typer.main import get_command as get_typer_command, MarkupMode, get_params_convertors_ctx_param_name_from_function +from typer.main import MarkupMode +from typer.main import get_command as get_typer_command +from typer.main import 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 dataclasses import dataclass -import contextlib from .types import ( ForceColor, @@ -54,21 +56,26 @@ "TyperCommandWrapper", "callback", "command", - "get_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 + *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) + 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 @@ -140,7 +147,9 @@ def call_with_self(*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 @@ -279,7 +288,7 @@ def __new__( 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 + pretty_exceptions_short: bool = True, ): """ This method is called when a new class is created. @@ -306,7 +315,7 @@ def __new__( 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 + pretty_exceptions_short=pretty_exceptions_short, ) def handle(self, *args, **options): @@ -329,13 +338,7 @@ 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. """ @@ -358,7 +361,6 @@ def __init__( class TyperParser: - @dataclass(frozen=True) class Action: dest: str @@ -380,7 +382,7 @@ def __init__(self, django_command: "TyperCommand", prog_name, subcommand): self.django_command = django_command self.prog_name = prog_name self.subcommand = subcommand - + def populate_params(node): for param in node.command.params: self._actions.append(self.Action(param.name)) @@ -388,9 +390,11 @@ def populate_params(node): 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}' + 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() @@ -399,21 +403,20 @@ def parse_args(self, args=None, namespace=None): try: cmd = get_typer_command(self.django_command.typer_app) with cmd.make_context( - info_name=f'{self.prog_name} {self.subcommand}', + info_name=f"{self.prog_name} {self.subcommand}", django_command=self.django_command, - args=list(args or []) + args=list(args or []), ) as ctx: 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} - ) + + return _ParsedArgs(args=args or [], **{**_common_options(), **params}) except click.exceptions.Exit: sys.exit() @@ -473,13 +476,12 @@ class TyperCommand(BaseCommand, metaclass=_TyperCommandMeta): 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 @@ -489,7 +491,7 @@ def __init__( self, name: str, command: t.Union[TyperCommandWrapper, TyperGroupWrapper], - context: TyperContext + context: TyperContext, ): self.name = name self.command = command @@ -513,20 +515,24 @@ def get_command(self, *command_path: str): 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 + 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) + 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 ): @@ -535,13 +541,14 @@ def _filter_commands( cmd for name, cmd in getattr( ctx.command, - 'commands', + "commands", { name: ctx.command.get_command(ctx, name) - for name in getattr( - ctx.command, 'list_commands', lambda _: [] - )(ctx) - or cmd_filter or [] + for name in getattr(ctx.command, "list_commands", lambda _: [])( + ctx + ) + or cmd_filter + or [] }, ).items() if not cmd_filter or name in cmd_filter @@ -554,14 +561,9 @@ def _build_cmd_tree( cmd: CoreTyperCommand, parent: t.Optional[Context] = None, info_name: t.Optional[str] = None, - node: t.Optional[CommandNode] = None + node: t.Optional[CommandNode] = None, ): - ctx = Context( - cmd, - info_name=info_name, - parent=parent, - django_command=self - ) + 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 @@ -569,14 +571,13 @@ def _build_cmd_tree( 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: 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 diff --git a/django_typer/tests/click_test.py b/django_typer/tests/click_test.py index 0a00894..003dd0b 100644 --- a/django_typer/tests/click_test.py +++ b/django_typer/tests/click_test.py @@ -1,10 +1,13 @@ -import click from pprint import pprint +import click params = {} -@click.group(context_settings={'allow_interspersed_args': True, 'ignore_unknown_options': True}) + +@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): diff --git a/django_typer/tests/test_app/management/commands/callback2.py b/django_typer/tests/test_app/management/commands/callback2.py index b02f447..d6b73ac 100644 --- a/django_typer/tests/test_app/management/commands/callback2.py +++ b/django_typer/tests/test_app/management/commands/callback2.py @@ -1,4 +1,5 @@ import json + from django_typer import TyperCommand, callback, command @@ -9,8 +10,8 @@ class Command(TyperCommand, invoke_without_command=True): @callback( context_settings={ - 'allow_interspersed_args': True, - 'ignore_unknown_options': True + "allow_interspersed_args": True, + "ignore_unknown_options": True, } ) def init(self, p1: int, flag1: bool = False, flag2: bool = True): @@ -20,11 +21,11 @@ def init(self, p1: int, flag1: bool = False, flag2: bool = True): 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 + "allow_interspersed_args": True, + "ignore_unknown_options": True, } ) def handle(self, arg1: str, arg2: str, arg3: float = 0.5, arg4: int = 1): diff --git a/django_typer/tests/tests.py b/django_typer/tests/tests.py index 426f0f2..2ff2d22 100644 --- a/django_typer/tests/tests.py +++ b/django_typer/tests/tests.py @@ -1,17 +1,18 @@ import inspect import json +import os import subprocess import sys from io import StringIO from pathlib import Path -import os import django import typer -from django_typer import get_command from django.core.management import call_command from django.test import TestCase +from django_typer import get_command + manage_py = Path(__file__).parent.parent.parent / "manage.py" @@ -29,7 +30,9 @@ def run_command(command, *args): try: os.chdir(manage_py.parent) result = subprocess.run( - [sys.executable, f'./{manage_py.name}', command, *args], capture_output=True, text=True + [sys.executable, f"./{manage_py.name}", command, *args], + capture_output=True, + text=True, ) # Check the return code to ensure the script ran successfully @@ -45,6 +48,7 @@ def run_command(command, *args): finally: os.chdir(cwd) + class BasicTests(TestCase): def test_command_line(self): self.assertEqual( @@ -78,7 +82,7 @@ def test_get_version(self): ) def test_call_direct(self): - basic = get_command('basic') + basic = get_command("basic") self.assertEqual( json.loads(basic.handle("a1", "a2")), {"arg1": "a1", "arg2": "a2", "arg3": 0.5, "arg4": 1}, @@ -208,51 +212,58 @@ def test_call_direct(self): self.assertEqual(json.loads(multi.cmd3()), {}) -class TestGetCommand(TestCase): +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') + 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') + 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') + cmd1 = get_command("multi", "cmd1") assert cmd1.__func__ is multi.cmd1.__func__ - sum = get_command('multi', 'sum') + sum = get_command("multi", "sum") assert sum.__func__ is multi.sum.__func__ - cmd3 = get_command('multi', 'cmd3') + 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') + 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') - + get_command("callback1", "init") -class CallbackTests(TestCase): - cmd_name = 'callback1' +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) + 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) + 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( @@ -298,7 +309,7 @@ def test_call_command(self, should_raise=True): call_command( self.cmd_name, *["5", self.cmd_name, "a1", "a2"], - **{'p1': 5, 'arg1': 'a1', 'arg2': 'a2'} + **{"p1": 5, "arg1": "a1", "arg2": "a2"}, ) ) self.assertEqual( @@ -359,7 +370,7 @@ def test_call_command(self, should_raise=True): "0.2", "--arg4", "9", - ] + ], ), lambda: call_command( self.cmd_name, @@ -373,9 +384,9 @@ def test_call_command(self, should_raise=True): "n1", "n2", "--arg3", - "0.2" - ] - ) + "0.2", + ], + ), ] expected = { "p1": 6, @@ -384,7 +395,7 @@ def test_call_command(self, should_raise=True): "arg1": "n1", "arg2": "n2", "arg3": 0.2, - "arg4": 9 + "arg4": 9, } if should_raise: for call_cmd in interspersed: @@ -393,7 +404,6 @@ def test_call_command(self, should_raise=True): call_cmd() else: self.assertEqual(json.loads(call_cmd()), expected) - def test_call_command_stdout(self): out = StringIO() @@ -409,9 +419,9 @@ def test_call_command_stdout(self): "--arg3", "0.75", "--arg4", - "2" + "2", ], - stdout=out + stdout=out, ) self.assertEqual( @@ -429,26 +439,24 @@ def test_call_command_stdout(self): def test_get_version(self): self.assertEqual( - run_command(self.cmd_name, '--version').strip(), - django.get_version() + 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() + 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} + 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' + cmd_name = "callback2" def test_call_command(self): super().test_call_command(should_raise=False) diff --git a/django_typer/tests/typer_test.py b/django_typer/tests/typer_test.py index 76523c6..ca8ff12 100755 --- a/django_typer/tests/typer_test.py +++ b/django_typer/tests/typer_test.py @@ -3,7 +3,7 @@ from django_typer import TyperCommandWrapper, _common_options -app = typer.Typer(name='test') +app = typer.Typer(name="test") state = {"verbose": False}