diff --git a/django_typer/__init__.py b/django_typer/__init__.py index 496f79b..5ff61cd 100644 --- a/django_typer/__init__.py +++ b/django_typer/__init__.py @@ -13,7 +13,6 @@ import sys import typing as t from copy import deepcopy -from dataclasses import dataclass from importlib import import_module from types import MethodType, SimpleNamespace @@ -21,7 +20,9 @@ from django.conf import settings from django.core.management import get_commands from django.core.management.base import BaseCommand +from django.core.management.color import no_style from django.utils.translation import gettext_lazy as _ +from django.utils.functional import lazy from typer import Typer from typer.core import TyperCommand as CoreTyperCommand from typer.core import TyperGroup as CoreTyperGroup @@ -77,6 +78,15 @@ behavior should align with native django commands """ +# def get_color_system(default): +# ctx = click.get_current_context(silent=True) +# if ctx: +# return None if ctx.django_command.style == no_style() else default +# return default + +# COLOR_SYSTEM = lazy(get_color_system, str) +# rich_utils.COLOR_SYSTEM = COLOR_SYSTEM(rich_utils.COLOR_SYSTEM) + def traceback_config(): cfg = getattr(settings, "DT_RICH_TRACEBACK_CONFIG", {"show_locals": True}) @@ -147,6 +157,14 @@ def _get_kwargs(self): return {"args": self.args, **COMMON_DEFAULTS} +# class _Augment: +# pass + + +# def augment(cls): +# return type('', (_Augment, cls), {}) + + class Context(TyperContext): """ An extension of the click.Context class that adds a reference to @@ -576,7 +594,6 @@ def __new__( 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 = Default(True), @@ -626,7 +643,7 @@ def __new__( add_help_option=add_help_option, hidden=hidden, deprecated=deprecated, - add_completion=add_completion, + add_completion=False, # see autocomplete command instead! rich_markup_mode=rich_markup_mode, rich_help_panel=rich_help_panel, pretty_exceptions_enable=pretty_exceptions_enable, @@ -713,14 +730,35 @@ def get_ctor(attr): class TyperParser: - @dataclass(frozen=True) + class Action: - dest: str + + param: click.Parameter required: bool = False + def __init__(self, param: click.Parameter): + self.param = param + + @property + def dest(self): + return self.param.name + + @property + def nargs(self): + return ( + 0 + if getattr(self.param, 'is_flag', False) else + self.param.nargs + ) + @property def option_strings(self): - return [self.dest] + return ( + list(self.param.opts) + if isinstance(self.param, click.Option) else + [] + ) + _actions: t.List[t.Any] _mutually_exclusive_groups: t.List[t.Any] = [] @@ -737,7 +775,7 @@ def __init__(self, django_command: "TyperCommand", prog_name, subcommand): def populate_params(node): for param in node.command.params: - self._actions.append(self.Action(param.name)) + self._actions.append(self.Action(param)) for child in node.children.values(): populate_params(child) diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/management/commands/__init__.py b/django_typer/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/management/commands/shellcomplete.py b/django_typer/management/commands/shellcomplete.py new file mode 100644 index 0000000..f06c614 --- /dev/null +++ b/django_typer/management/commands/shellcomplete.py @@ -0,0 +1,433 @@ +import sys +import os +from click.shell_completion import get_completion_class, CompletionItem, add_completion_class +from django_typer import TyperCommand, command, get_command +from django.core.management import CommandError, ManagementUtility +from django.utils.translation import gettext_lazy as _ +from django.utils.module_loading import import_string +from django.utils.functional import cached_property +import typing as t +from typer import Argument, Option, echo +from typer.completion import Shells, completion_init +from click.parser import split_arg_string +from pathlib import Path +import contextlib +import io +import inspect +import subprocess + +try: + from shellingham import detect_shell + detected_shell = detect_shell()[0] +except Exception: + detected_shell = None + + +DJANGO_COMMAND = Path(__file__).name.split('.')[0] + + +class Command(TyperCommand): + """ + This command installs autocompletion for the current shell. This command uses the typer/click + autocompletion scripts to generate the autocompletion items, but monkey patches the scripts + to invoke our bundled shell complete script which fails over to the django autocomplete function + when the command being completed is not a TyperCommand. When the django autocomplete function + is used we also wrap it so that it works for any supported click/typer shell, not just bash. + + We also provide a remove command to easily remove the installed script. + + Great pains are taken to use the upstream dependency's shell completion logic. This is so advances + and additional shell support implemented upstream should just work. However, it would be possible + to add support for new shells here using the pluggable logic that click provides. It is + probably a better idea however to add support for new shells at the typer level. + + Shell autocompletion can be brittle with every shell having its own quirks and nuances. We + make a good faith effort here to support all the shells that typer/click support, but there + can easily be system specific configuration issues that prevent this from working. In those + cases users should refer to the online documentation for their specific shell to troubleshoot. + """ + + help = _('Install autocompletion for the current shell.') + + # disable the system checks - no reason to run these for this one-off command + requires_system_checks = [] + requires_migrations_checks = False + + # remove unnecessary django command base parameters - these just clutter the help + django_params = [ + cmd for cmd in + TyperCommand.django_params + if cmd not in [ + 'version', 'skip_checks', + 'no_color', 'force_color' + ] + ] + + _shell: Shells + + COMPLETE_VAR = '_COMPLETE_INSTRUCTION' + + @cached_property + def manage_script(self) -> t.Union[str, Path]: + """ + The name of the django manage command script to install autocompletion for. We do + not want to hardcode this as 'manage.py' because users are free to rename and implement + their own manage scripts! The safest way to do this is therefore to require this install + script to be a management command itself and then fetch the name of the script that invoked + it. + + Get the manage script as either the name of it as a command available from the shell's path if + it is or as an absolute path to it as a script if it is not a command available on the path. If + the script is invoked via python, a CommandError is thrown. + + Most shell's completion infrastructure works best if the commands are available on the path. + However, it is common for Django development to be done in a virtual environment with a manage.py + script being invoked directly as a script. Completion should work in this case as well, but it + does complicate the installation for some shell's so we must first figure out which mode we are + in. + """ + cmd_pth = Path(sys.argv[0]) + if cmd_pth.resolve() == Path(sys.executable).resolve(): + raise CommandError(_( + "Unable to install shell completion when invoked via python. It is best to install the " + "manage script as a command on your shell's path, but shell completion should also work if " + "you invoke the manage script directly." + ) + ) + if cmd_pth.exists(): + # manage.py might happen to be on the current path, but it might also be installed as + # a command - we test it here by invoking it to be sure + try: + subprocess.run( + [cmd_pth.name, '--help'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + except Exception: + return cmd_pth.absolute() + return cmd_pth.name + + @cached_property + def manage_script_name(self) -> str: + """ + Get the name of the manage script as a command available from the shell's path. + """ + return getattr(self.manage_script, 'name', self.manage_script) + + @cached_property + def manage_command(self): + if self.manage_installed: + return sys.argv[0] + return Path(sys.argv[0]).absolute() + + @property + def shell(self): + """ + Get the active shell. If not explicitly set, it first tries to find the shell + in the environment variable shell complete scripts set and failing that it will try + to autodetect the shell. + """ + return getattr( + self, + '_shell', + Shells( + os.environ[self.COMPLETE_VAR].partition('_')[2] + if self.COMPLETE_VAR in os.environ else + detect_shell()[0] + ) + ) + + @shell.setter + def shell(self, shell): + """Set the shell to install autocompletion for.""" + if shell is None: + raise CommandError(_( + 'Unable to detect shell. Please specify the shell to install or remove ' + 'autocompletion for.' + )) + self._shell = shell if isinstance(shell, Shells) else Shells(shell) + + @cached_property + def noop_command(self): + """ + This is a no-op command that is used to bootstrap click Completion classes. It + has no use other than to avoid any potential attribute errors when we emulate + upstream completion logic + """ + return self.get_subcommand('noop') + + def patch_script( + self, + shell: t.Optional[Shells] = None, + fallback: t.Optional[str] = None + ) -> None: + """ + We have to monkey patch the typer completion scripts to point to our custom + shell complete script. This is potentially brittle but thats why we have robust + CI! + + :param shell: The shell to patch the completion script for. + :param fallback: The python import path to a fallback autocomplete function to use when + the completion command is not a TyperCommand. Defaults to None, which means the bundled + complete script will fallback to the django autocomplete function, but wrap it so it works + for all supported shells other than just bash. + """ + # do not import this private stuff until we need it - avoids it tanking the whole + # library if these imports change + from typer import _completion_shared as typer_scripts + shell = shell or self.shell + + fallback = f' --fallback {fallback}' if fallback else '' + + def replace(s: str, old: str, new: str, occurrences: list[int]) -> str: + """ + :param s: The string to modify + :param old: The string to replace + :param new: The string to replace with + :param occurrences: A list of occurrences of the old string to replace with the + new string, where the occurrence number is the zero-based count of the old + strings appearance in the string when counting from the front. + """ + count = 0 + result = "" + start = 0 + + for end in range(len(s)): + if s[start:end+1].endswith(old): + if count in occurrences: + result += f'{s[start:end+1-len(old)]}{new}' + start = end + 1 + else: + result += s[start:end+1] + start = end + 1 + count += 1 + + result += s[start:] + return result + + if shell is Shells.bash: + typer_scripts._completion_scripts[Shells.bash.value] = replace( + typer_scripts.COMPLETION_SCRIPT_BASH, + '$1', + f'$1 {DJANGO_COMMAND} complete', + [0] + ) + elif shell is Shells.zsh: + typer_scripts._completion_scripts[Shells.zsh.value] = replace( + typer_scripts.COMPLETION_SCRIPT_ZSH, + '%(prog_name)s', + f'${{words[0,1]}} {DJANGO_COMMAND} complete', + [1] + ) + elif shell is Shells.fish: + typer_scripts._completion_scripts[Shells.fish.value] = replace( + typer_scripts.COMPLETION_SCRIPT_FISH, + '%(prog_name)s', + f'{self.manage_script} {DJANGO_COMMAND} complete', + [1, 2] + ) + elif shell in [Shells.pwsh, Shells.powershell]: + script = replace( + typer_scripts.COMPLETION_SCRIPT_POWER_SHELL, + '%(prog_name)s', + f'{self.manage_script} {DJANGO_COMMAND} complete', + [0] + ) + typer_scripts._completion_scripts[Shells.powershell.value] = script + typer_scripts._completion_scripts[Shells.pwsh.value] = script + + @command(help=_('Install autocompletion.')) + def install( + self, + shell: t.Annotated[ + t.Optional[Shells], + Argument(help=_('Specify the shell to install or remove autocompletion for.')) + ] = detected_shell, + manage_script: t.Annotated[ + t.Optional[str], + Option(help=_('The name of the django manage script to install autocompletion for if different.') + )] = None, + fallback: t.Annotated[ + t.Optional[str], + Option(help=_( + 'The python import path to a fallback complete function to use when ' + 'the completion command is not a TyperCommand.' + ) + )] = None + ): + # do not import this private stuff until we need it - avoids tanking the whole + # library if these imports change + from typer._completion_shared import install + self.shell = shell + self.patch_script(fallback=fallback) + install_path = install( + shell=self.shell.value, + prog_name=manage_script or self.manage_script_name, + complete_var=self.COMPLETE_VAR + )[1] + self.stdout.write(self.style.SUCCESS( + _('Installed autocompletion for %(shell)s @ %(install_path)s') % { + 'shell': shell.value, 'install_path': install_path + } + )) + + @command(help=_('Remove autocompletion.')) + def remove( + self, + shell: t.Annotated[ + t.Optional[Shells], + Argument(help=_('Specify the shell to install or remove shell completion for.')) + ] = detected_shell, + manage_script: t.Annotated[ + t.Optional[str], + Option(help=_( + 'The name of the django manage script to remove shell completion for if different.' + ) + )] = None, + ): + from typer._completion_shared import install + # its less brittle to install and use the returned path to uninstall + self.shell = shell + stdout = self.stdout + self.stdout = io.StringIO() + installed_path = install(shell=self.shell.value, prog_name=manage_script or self.manage_script_name)[1] + installed_path.unlink() + self.stdout = stdout + self.stdout.write(self.style.WARNING( + _('Removed autocompletion for %(shell)s.') % {'shell': shell.value} + )) + + @command(help=_('Generate autocompletion for command string.'), hidden=False) + def complete( + self, + command: t.Annotated[ + t.Optional[str], + Argument( + help=_('The command string to generate completion suggestions for.') + ) + ] = None, + fallback: t.Annotated[ + t.Optional[str], + Option( + help=_( + 'The python import path to a fallback complete function to use when ' + 'the completion command is not a TyperCommand. By default, the builtin ' + 'django autocomplete function is used.' + ) + ) + ] = None + ): + """ + We implement the shell complete generation script as a Django command because the + Django environment needs to be bootstrapped for it to work. This also allows + us to test autocompletions in a platform agnostic way. + """ + completion_init() + CompletionClass = get_completion_class(self.shell.value) + if command: + # when the command is given, this is a user testing their autocompletion, + # so we need to override the completion classes get_completion_args logic + # because our entry point was not an installed completion script + def get_completion_args(self) -> t.Tuple[t.List[str], str]: + cwords = split_arg_string(command) + # allow users to not specify the manage script, but allow for it + # if they do by lopping it off - same behavior as upstream classes + try: + if Path(cwords[0]).resolve() == Path(sys.argv[0]).resolve(): + cwords = cwords[1:] + except Exception: + pass + return cwords, cwords[-1] if len(cwords) and not command[-1].isspace() else '' + + CompletionClass.get_completion_args = get_completion_args + add_completion_class(self.shell.value, CompletionClass) + + args, incomplete = CompletionClass( + cli=self.noop_command, + ctx_args=self.noop_command, + prog_name=sys.argv[0], + complete_var=self.COMPLETE_VAR + ).get_completion_args() + + def call_fallback(fb): + fallback = import_string(fb) if fb else self.django_fallback + if command and inspect.signature(fallback).parameters: + fallback(command) + else: + fallback() + + if not args: + call_fallback(fallback) + else: + try: + os.environ[self.COMPLETE_VAR] = os.environ.get( + self.COMPLETE_VAR, + f'complete_{self.shell.value}' + ) + cmd = get_command(args[0]) + if isinstance(cmd, TyperCommand): + # invoking the command will trigger the autocompletion? + cmd.command_tree.command._main_shell_completion( + ctx_args={}, + prog_name=f'{sys.argv[0]} {cmd.command_tree.command.name}', + complete_var=self.COMPLETE_VAR + ) + return + else: + call_fallback(fallback) + except Exception as e: + call_fallback(fallback) + + def django_fallback(self): + """ + Run django's builtin bash autocomplete function. We wrap the click + completion class to make it work for all supported shells, not just + bash. + """ + CompletionClass = get_completion_class(self.shell.value) + def get_completions(self, args, incomplete): + # spoof bash environment variables + # the first one is lopped off, so we insert a placeholder 0 + args = ['0', *args] + if args[-1] != incomplete: + args.append(incomplete) + os.environ['COMP_WORDS'] = ' '.join(args) + os.environ['COMP_CWORD'] = str(args.index(incomplete)) + os.environ['DJANGO_AUTO_COMPLETE'] = '1' + dj_manager = ManagementUtility(args) + capture_completions = io.StringIO() + try: + with contextlib.redirect_stdout(capture_completions): + dj_manager.autocomplete() + except SystemExit: + return [ + CompletionItem(item) + for item in capture_completions.getvalue().split() + if item + ] + CompletionClass.get_completions = get_completions + echo( + CompletionClass( + cli=self.noop_command, + ctx_args={}, + prog_name=self.manage_script_name, + complete_var=self.COMPLETE_VAR + ).complete() + ) + + @command( + hidden=True, + context_settings={ + 'ignore_unknown_options': True, + 'allow_extra_args': True, + 'allow_interspersed_args': True + } + ) + def noop(self): + """ + This is a no-op command that is used to bootstrap click Completion classes. It + has no use other than to avoid any potential attribute errors when we spoof + completion logic + """ + pass diff --git a/django_typer/tests/adapted.py b/django_typer/tests/adapted.py new file mode 100644 index 0000000..826caee --- /dev/null +++ b/django_typer/tests/adapted.py @@ -0,0 +1,7 @@ +from .settings import * + +INSTALLED_APPS = [ + "django_typer.tests.adapter1", + "django_typer.tests.adapter2", + *INSTALLED_APPS +] diff --git a/django_typer/tests/adapter1/__init__.py b/django_typer/tests/adapter1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/adapter1/apps.py b/django_typer/tests/adapter1/apps.py new file mode 100644 index 0000000..fdd4b65 --- /dev/null +++ b/django_typer/tests/adapter1/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class Adapter1Config(AppConfig): + name = "django_typer.tests.adapter1" + label = name.replace(".", "_") + verbose_name = "Adapter 1" + + def ready(self): + print(self.label) diff --git a/django_typer/tests/adapter1/management/__init__.py b/django_typer/tests/adapter1/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/adapter1/management/commands/__init__.py b/django_typer/tests/adapter1/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/adapter1/management/commands/_groups.py b/django_typer/tests/adapter1/management/commands/_groups.py new file mode 100644 index 0000000..df0330c --- /dev/null +++ b/django_typer/tests/adapter1/management/commands/_groups.py @@ -0,0 +1,72 @@ +# import typing as t + +# from django.conf import settings +# from django.utils.translation import gettext_lazy as _ +# from typer import Argument, Option + +# from django_typer import command, group, initialize, types, augment +# from django_typer.tests.test_app.management.commands.groups import ( +# Command as GroupsCommand, +# ) + + +# class Command(augment(GroupsCommand)): +# help = "Test groups command inheritance." + +# precision = 2 +# verbosity = 1 + +# @initialize() +# def init(self, verbosity: types.Verbosity = verbosity): +# """ +# Initialize the command. +# """ +# assert self.__class__ is Command +# self.verbosity = verbosity + +# @command() +# def echo( +# self, +# message: str, +# echoes: t.Annotated[ +# int, Argument(help="Number of times to echo the message.") +# ] = 1, +# ): +# """ +# Echo the given message the given number of times. +# """ +# assert self.__class__ is Command +# return " ".join([message] * echoes) + +# # test override base class command and remove arguments +# @GroupsCommand.case.command() +# def upper(self): +# return super().upper(0, None) + +# @GroupsCommand.string.command() +# def strip(self): +# """Strip white space off the ends of the string""" +# return self.op_string.strip() + +# @group() +# def setting( +# self, setting: t.Annotated[str, Argument(help=_("The setting variable name."))] +# ): +# """ +# Get or set Django settings. +# """ +# assert self.__class__ is Command +# self.setting = setting + +# @setting.command() +# def print( +# self, +# safe: t.Annotated[bool, Option(help=_("Do not assume the setting exists."))], +# ): +# """ +# Print the setting value. +# """ +# assert self.__class__ is Command +# if safe: +# return getattr(settings, self.setting, None) +# return getattr(settings, self.setting) diff --git a/django_typer/tests/adapter2/__init__.py b/django_typer/tests/adapter2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/adapter2/apps.py b/django_typer/tests/adapter2/apps.py new file mode 100644 index 0000000..ef2aed8 --- /dev/null +++ b/django_typer/tests/adapter2/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class Adapter2Config(AppConfig): + name = "django_typer.tests.adapter2" + label = name.replace(".", "_") + verbose_name = "Adapter 2" + + def ready(self): + print(self.label) diff --git a/django_typer/tests/adapter2/management/__init__.py b/django_typer/tests/adapter2/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/adapter2/management/commands/__init__.py b/django_typer/tests/adapter2/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_typer/tests/adapter2/management/commands/_groups.py b/django_typer/tests/adapter2/management/commands/_groups.py new file mode 100644 index 0000000..6b2fd65 --- /dev/null +++ b/django_typer/tests/adapter2/management/commands/_groups.py @@ -0,0 +1 @@ +Command = None diff --git a/django_typer/tests/test_app/apps.py b/django_typer/tests/test_app/apps.py index d57b73e..960dedc 100644 --- a/django_typer/tests/test_app/apps.py +++ b/django_typer/tests/test_app/apps.py @@ -26,3 +26,5 @@ class TestAppConfig(AppConfig): def ready(self): if getattr(settings, "DJANGO_TYPER_THROW_TEST_EXCEPTION", False): raise Exception("Test ready exception") + + # print(self.label) diff --git a/django_typer/tests/test_app/helps/groups.txt b/django_typer/tests/test_app/helps/groups.txt index d1aa287..772e7d8 100644 --- a/django_typer/tests/test_app/helps/groups.txt +++ b/django_typer/tests/test_app/helps/groups.txt @@ -1,20 +1,10 @@ - Usage: ./manage.py groups [OPTIONS] COMMAND [ARGS]... Test multiple groups commands and callbacks ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --install-completion [bash|zsh|fish|powershe Install completion for │ -│ ll|pwsh] the specified shell. │ -│ [default: None] │ -│ --show-completion [bash|zsh|fish|powershe Show completion for the │ -│ ll|pwsh] specified shell, to │ -│ copy it or customize │ -│ the installation. │ -│ [default: None] │ -│ --help Show this message and │ -│ exit. │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Django ─────────────────────────────────────────────────────────────────────╮ │ --version Show program's version number │ diff --git a/django_typer/tests/test_app/management/commands/test_command1.py b/django_typer/tests/test_app/management/commands/test_command1.py index b589eff..3956888 100644 --- a/django_typer/tests/test_app/management/commands/test_command1.py +++ b/django_typer/tests/test_app/management/commands/test_command1.py @@ -3,7 +3,7 @@ from django_typer import TyperCommand, command, initialize -class Command(TyperCommand, add_completion=False): +class Command(TyperCommand): help = "This is a test help message" @initialize(epilog="This is a test callback epilog") diff --git a/django_typer/tests/test_app/management/commands/test_tb_overrides.py b/django_typer/tests/test_app/management/commands/test_tb_overrides.py index a084cd5..5f86195 100644 --- a/django_typer/tests/test_app/management/commands/test_tb_overrides.py +++ b/django_typer/tests/test_app/management/commands/test_tb_overrides.py @@ -5,7 +5,6 @@ class Command( TyperCommand, - add_completion=False, pretty_exceptions_enable=True, pretty_exceptions_show_locals=False, pretty_exceptions_short=False, diff --git a/django_typer/tests/test_app2/apps.py b/django_typer/tests/test_app2/apps.py index 601c599..37539a4 100644 --- a/django_typer/tests/test_app2/apps.py +++ b/django_typer/tests/test_app2/apps.py @@ -5,3 +5,6 @@ class TestApp2Config(AppConfig): name = "django_typer.tests.test_app2" label = name.replace(".", "_") verbose_name = "Test App2" + + # def ready(self): + # print(self.label) diff --git a/django_typer/tests/test_app2/management/commands/groups.py b/django_typer/tests/test_app2/management/commands/groups.py index 2f46364..5398335 100644 --- a/django_typer/tests/test_app2/management/commands/groups.py +++ b/django_typer/tests/test_app2/management/commands/groups.py @@ -10,7 +10,7 @@ ) -class Command(GroupsCommand, add_completion=False, epilog="Overridden from test_app."): +class Command(GroupsCommand, epilog="Overridden from test_app."): help = "Test groups command inheritance." precision = 2 diff --git a/django_typer/tests/tests.py b/django_typer/tests/tests.py index a8cc4fa..1ab086f 100644 --- a/django_typer/tests/tests.py +++ b/django_typer/tests/tests.py @@ -190,6 +190,7 @@ def test_typer_command_interface_matches(self): typer_command_params = set(get_named_arguments(_TyperCommandMeta.__new__)) typer_params = set(get_named_arguments(typer.Typer.__init__)) typer_params.remove("name") + typer_params.remove("add_completion") self.assertFalse(typer_command_params.symmetric_difference(typer_params)) def test_group_interface_matches(self): @@ -337,7 +338,7 @@ class CallbackTests(TestCase): def test_helps(self, top_level_only=False): buffer = StringIO() - cmd = get_command(self.cmd_name, stdout=buffer) + cmd = get_command(self.cmd_name, stdout=buffer, no_color=True) help_output_top = run_command(self.cmd_name, "--help") cmd.print_help("./manage.py", self.cmd_name) diff --git a/django_typer/tests/typer_test.py b/django_typer/tests/typer_test.py index ca8ff12..0313d42 100755 --- a/django_typer/tests/typer_test.py +++ b/django_typer/tests/typer_test.py @@ -1,8 +1,6 @@ #!/usr/bin/env python import typer -from django_typer import TyperCommandWrapper, _common_options - app = typer.Typer(name="test") state = {"verbose": False} diff --git a/pyproject.toml b/pyproject.toml index 9c57282..a44b7a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,11 +38,15 @@ packages = [ ] exclude = ["django_typer/tests"] +[tool.poetry.scripts] +django_complete = "django_typer.autocomplete:main" + [tool.poetry.dependencies] python = ">=3.9,<4.0" Django = ">=3.2,<6.0" typer = "^0.9.0" rich = ">=10.11.0,<14.0.0" # this should track typer's rich dependency +shellingham = "^1.5.4" [tool.poetry.group.dev.dependencies] ipdb = "^0.13.13" diff --git a/test.py b/test.py deleted file mode 100644 index 9c7abb4..0000000 --- a/test.py +++ /dev/null @@ -1,21 +0,0 @@ - -from types import MethodType - - -class Container: - - class MethodWrapper: - - def __call__(self, container, *args, **kwargs): - print(f'{container} {args} {kwargs}') - - def method(self): - print(str(self)) - - method2 = MethodWrapper() - -container = Container() -container.method2 = MethodType(container.method2, container) -container.method2('arg1', 'arg2', flag=5) -import ipdb -ipdb.set_trace()