From 64f607fbadeb16af80a79089b46890001eec5f70 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Mon, 9 Dec 2024 14:23:36 -0800 Subject: [PATCH] signficant shellcompletion refactor, move to inhouse completer classes, fix #156 --- django_typer/management/__init__.py | 3 +- .../management/commands/shellcompletion.py | 728 ++++++++---------- .../management/commands/shells/__init__.py | 23 - .../management/commands/shells/bash.py | 49 +- .../commands/shells/{bash.tmpl => bash.sh} | 2 +- .../commands/shells/{fish.tmpl => fish.fish} | 0 .../management/commands/shells/fish.py | 37 +- .../{powershell.tmpl => powershell.ps1} | 0 .../management/commands/shells/powershell.py | 81 +- .../management/commands/shells/zsh.py | 70 +- .../management/commands/shells/zsh.sh | 72 ++ .../management/commands/shells/zsh.tmpl | 30 - django_typer/utils.py | 2 +- doc/source/changelog.rst | 36 +- doc/source/shell_completion.rst | 2 + justfile | 10 +- pyproject.toml | 3 + tests/fallback.py | 14 +- tests/test_examples.py | 36 +- tests/test_parser_completers.py | 274 +++---- tests/test_poll_example.py | 4 +- 21 files changed, 838 insertions(+), 638 deletions(-) rename django_typer/management/commands/shells/{bash.tmpl => bash.sh} (89%) rename django_typer/management/commands/shells/{fish.tmpl => fish.fish} (100%) rename django_typer/management/commands/shells/{powershell.tmpl => powershell.ps1} (100%) create mode 100644 django_typer/management/commands/shells/zsh.sh delete mode 100644 django_typer/management/commands/shells/zsh.tmpl diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index 8b03fb1..61137cc 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -458,8 +458,7 @@ def __init__( if param.name }, ) - else: - assert parent + elif parent: self.django_command = parent.django_command if supplied_params: diff --git a/django_typer/management/commands/shellcompletion.py b/django_typer/management/commands/shellcompletion.py index 20f04ba..787499e 100644 --- a/django_typer/management/commands/shellcompletion.py +++ b/django_typer/management/commands/shellcompletion.py @@ -18,37 +18,38 @@ be specified. """ -# A needed refactoring here would be to provide root hooks for completion logic in Django core that -# base classes can register for. This would provide a coordinated way for libraries like -# django-typer to plug in their own completion logic. - +import collections.abc as cabc import contextlib -import inspect import io import os import re import sys import typing as t +import warnings from functools import cached_property from importlib.resources import files from pathlib import Path +from types import ModuleType +from click.core import Command as ClickCommand from click.shell_completion import ( CompletionItem, + ShellComplete, add_completion_class, - get_completion_class, split_arg_string, ) from django.core.management import CommandError, ManagementUtility from django.template import Context, Engine +from django.utils.functional import classproperty from django.utils.module_loading import import_string from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from shellingham import ShellDetectionFailure, detect_shell -from typer import Argument, Option, echo +from typer import Argument, Option +from typer.main import get_command as get_typer_command +from django_typer.completers import these_strings from django_typer.management import TyperCommand, command, get_command, initialize -from django_typer.management.commands.shells import Shells, completion_init from django_typer.types import COMMON_PANEL from django_typer.utils import get_usage_script @@ -59,8 +60,177 @@ except (ShellDetectionFailure, RuntimeError): pass -DJANGO_COMMAND = Path(__file__).name.split(".")[0] +class DjangoTyperShellCompleter(ShellComplete): + """ + An extension to the click shell completion classes that provides a Django specific + shell completion implementation. If you wish to support a new shell, you must + derive from this class, implement all of the abstract methods, and register the + class with + :func:`~django_typer.management.commands.shellcompletion.register_completion_class` + """ + + SCRIPT: Path + """ + The path to the shell completion script template. + """ + + color: bool = False + """ + By default, allow or disallow color in the completion output. + """ + + supports_scripts: bool = False + """ + Does the shell support completions for uninstalled scripts? (i.e. not on the path) + """ + + complete_var: str = "" + + command: "Command" + command_str: str + command_args: t.List[str] + + def __init__( + self, + cli: t.Optional[ClickCommand] = None, + ctx_args: cabc.MutableMapping[str, t.Any] = {}, + prog_name: str = "", + complete_var: str = "", + command: t.Optional["Command"] = None, + command_str: t.Optional[str] = None, + command_args: t.Optional[t.List[str]] = None, + **kwargs, + ): + # we don't always need the initialization parameters during completion + self.prog_name = kwargs.pop("prog_name", "") + if command: + self.command = command + if command_str is not None: + self.command_str = command_str + if command_args is not None: + self.command_args = split_arg_string(command_str) if command_str else [] + + if cli: + super().__init__( + cli=cli, + ctx_args=ctx_args, + complete_var=complete_var, + prog_name=prog_name, + **kwargs, + ) + + @classproperty + def source_template(self) -> str: # type: ignore + return self.SCRIPT.read_text() + + def get_completions( + self, args: t.List[str], incomplete: str + ) -> t.List[CompletionItem]: + """ + need to remove the django command name from the arg completions + """ + if self.command.fallback: + return self.command.fallback(args, incomplete) + return super().get_completions(args[1:], incomplete) + + def get_completion_args(self) -> t.Tuple[t.List[str], str]: + cwords = self.command_args + if self.command_str and self.command_str[-1].isspace(): + cwords.append("") + # allow users to not specify the manage script, but allow for it + # if they do by lopping it off - same behavior as upstream classes + if cwords: + try: + if cwords[0] == self.command.manage_script_name: + cwords = cwords[1:] + elif Path(cwords[0]).resolve() == Path(sys.argv[0]).resolve(): + cwords = cwords[1:] + except (TypeError, ValueError, OSError): # pragma: no cover + pass + return ( + cwords[:-1], + cwords[-1] if cwords else "", + ) + + def source_vars(self) -> t.Dict[str, t.Any]: + return { + **super().source_vars(), + "manage_script": self.command.manage_script, + "python": sys.executable, + "django_command": self.command.__module__.split(".")[-1], + "color": "--no-color" + if self.command.no_color + else "--force-color" + if self.command.force_color + else "", + "fallback": f" --fallback {self.command.fallback.__module__}.{self.command.fallback.__name__}" + if self.command.fallback + else "", + "is_installed": not isinstance(self.command.manage_script, Path), + "rich": "--rich" if self.command.allow_rich else "", + } + + @cached_property + def template_engine(self): + """ + Django template engine that will find and render completer script templates. + """ + return Engine( + dirs=[str(files("django_typer.management.commands").joinpath("shells"))], + libraries={ + "default": "django.template.defaulttags", + "filter": "django.template.defaultfilters", + }, + ) + + def source(self) -> str: + """ + Render the completion script template to a string. + """ + return self.template_engine.get_template(str(self.SCRIPT)).render( + Context(self.source_vars()) + ) + + def install(self): + raise NotImplementedError + + def uninstall(self): + raise NotImplementedError + + +_completers: t.Dict[str, t.Type[DjangoTyperShellCompleter]] = {} + + +def register_completion_class(cls: t.Type[DjangoTyperShellCompleter]) -> None: + """ + Register a shell completion class for use with the Django shellcompletion command. + """ + _completers[cls.name] = cls + add_completion_class(cls) + + +def django_autocomplete(args: t.List[str], incomplete: str) -> t.List[CompletionItem]: + # 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) + else: # pragma: no cover + pass + 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() + with contextlib.redirect_stdout(capture_completions): + try: + dj_manager.autocomplete() + except SystemExit: + pass + return [ + CompletionItem(item) for item in capture_completions.getvalue().split() if item + ] class Command(TyperCommand): @@ -99,33 +269,38 @@ class Command(TyperCommand): "verbosity", } - no_color: t.Optional[bool] = None # type: ignore + allow_rich: bool = False - _shell: Shells - - COMPLETE_VAR = "_COMPLETE_INSTRUCTION" + _shell: t.Optional[str] = DETECTED_SHELL + shell_module: ModuleType ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - SHELL_COLOR_SUPPORT = { - Shells.bash: False, - Shells.zsh: True, - Shells.fish: True, - Shells.powershell: True, - Shells.pwsh: True, - } + _fallback: t.Optional[t.Callable[[t.List[str], str], t.List[CompletionItem]]] = None - @cached_property - def template_engine(self): - """ - Django template engine that will find and render completer script templates. - """ - return Engine( - dirs=[str(files("django_typer.management.commands").joinpath("shells"))], - libraries={ - "default": "django.template.defaulttags", - "filter": "django.template.defaultfilters", - }, + @property + def fallback( + self, + ) -> t.Optional[t.Callable[[t.List[str], str], t.List[CompletionItem]]]: + return self._fallback + + @fallback.setter + def fallback(self, fb: t.Optional[str]): + try: + self._fallback = import_string(fb) if fb else django_autocomplete + except ImportError as err: + raise CommandError( + gettext("Unable to import fallback completion function: {err}").format( + err=str(err) + ) + ) from err + + @property + def fallback_import(self) -> t.Optional[str]: + return ( + f"{self.fallback.__module__}.{self.fallback.__name__}" + if self.fallback + else None ) @cached_property @@ -158,98 +333,52 @@ def manage_script_name(self) -> str: return str(getattr(self.manage_script, "name", self.manage_script)) @property - def shell(self) -> Shells: + def shell(self) -> str: """ 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 None - ), - ) or Shells(detect_shell()[0]) + assert self._shell + return self._shell @shell.setter - def shell(self, shell: t.Optional[Shells]): + def shell(self, shell: t.Optional[str]): """Set the shell to install autocompletion for.""" - if shell is None: - try: - self._shell = Shells(detect_shell()[0]) - except (ShellDetectionFailure, RuntimeError) as err: - raise CommandError( - gettext( - "Please specify the shell to install or remove " - "autocompletion for. Unable to detect shell: {err}" - ).format(err=str(err)) - ) from err - else: - 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, # pylint: disable=import-outside-toplevel - ) - - shell = shell or self.shell - - fallback = f" --fallback {fallback}" if fallback else "" - - def render(shell: Shells, **context) -> str: - template = self.template_engine.get_template(f"{shell.value}.tmpl") - return template.render(Context(context)) - - context = { - "manage_script": self.manage_script, - "django_command": DJANGO_COMMAND, - "color": "--no-color" - if self.no_color - else "--force-color" - if self.force_color - else "", - } - if shell in [Shells.powershell, Shells.pwsh]: - typer_scripts._completion_scripts[shell.value] = render( - Shells.powershell, **context - ) - elif shell in [Shells.bash, Shells.zsh, Shells.fish]: - typer_scripts._completion_scripts[shell.value] = render(shell, **context) - else: # pragma: no cover - raise NotImplementedError( - gettext("Unsupported shell: {shell}").format(shell=shell.value) + if shell: + self._shell = shell + if self._shell is None: + raise CommandError( + gettext( + "Please specify the shell to install or remove " + "autocompletion for. Unable to detect shell." + ) ) + @property + def shell_class(self): + global _completers + try: + return _completers[self.shell] + except KeyError as err: + raise CommandError( + gettext("Unsupported shell: {shell}").format(shell=self.shell) + ) from err + @initialize() def init( self, + shell: t.Annotated[ + t.Optional[str], + Option( + help=t.cast( + str, + _("The shell to use."), + ), + metavar="SHELL", + shell_complete=these_strings(_completers.keys()), + ), + ] = DETECTED_SHELL, no_color: t.Annotated[ t.Optional[bool], Option( @@ -258,22 +387,24 @@ def init( rich_help_panel=COMMON_PANEL, ), ] = None, - ): - self.no_color = no_color # pyright: ignore[reportAttributeAccessIssue] + allow_rich: t.Annotated[ + bool, + Option( + "--rich", help=t.cast(str, _("Allow rich output in completion text.")) + ), + ] = allow_rich, + ) -> "Command": + self.shell = shell # type: ignore[assignment] + assert self.shell + self.no_color = not self.shell_class.color if no_color is None else no_color + self.allow_rich = allow_rich + return self @command( help=t.cast(str, _("Install autocompletion for the current or given shell.")) ) def install( self, - shell: t.Annotated[ - t.Optional[Shells], - Argument( - help=t.cast( - str, _("Specify the shell to install or remove autocompletion for.") - ) - ), - ] = DETECTED_SHELL, manage_script: t.Annotated[ t.Optional[str], Option( @@ -309,45 +440,46 @@ def install( :width: 85 :convert-png: latex """ - # 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.fallback = fallback # type: ignore[assignment] + if ( + isinstance(self.manage_script, Path) + and not self.shell_class.supports_scripts + ): + if not self.shell_class.supports_scripts: + raise CommandError( + gettext( + "Shell {shell} does not support autocompletion for scripts that are not " + "installed on the path. You must create an entry point for {script_name}. " + "See {link}." + ).format( + shell=self.shell, + script_name=self.manage_script_name, + link="https://setuptools.pypa.io/en/latest/userguide/entry_point.html", + ) + ) + else: + warnings.warn( + gettext( + "It is not recommended to install tab completion for a script not on the path." + ) + ) - self.shell = shell # type: ignore - assert self.shell - self.no_color = ( # pyright: ignore[reportIncompatibleVariableOverride] - not self.SHELL_COLOR_SUPPORT.get(self.shell, False) - if self.no_color is None - else self.no_color - ) - self.patch_script(fallback=fallback) - install_path = install( - shell=self.shell.value, - prog_name=str(manage_script or self.manage_script_name), - complete_var=self.COMPLETE_VAR, - )[1] + install_path = self.shell_class( + prog_name=str(manage_script or self.manage_script_name), command=self + ).install() self.stdout.write( - self.style.SUCCESS( # pylint: disable=no-member + self.style.SUCCESS( gettext("Installed autocompletion for {shell} @ {install_path}").format( - shell=self.shell.value, install_path=install_path + shell=self.shell, install_path=install_path ) ) ) @command( - help=t.cast(str, _("Remove autocompletion for the current or given shell.")) + help=t.cast(str, _("Uninstall autocompletion for the current or given shell.")) ) - def remove( + def uninstall( self, - shell: t.Annotated[ - t.Optional[Shells], - Argument( - help=t.cast( - str, - _("Specify the shell to install or remove shell completion for."), - ) - ), - ] = DETECTED_SHELL, manage_script: t.Annotated[ t.Optional[str], Option( @@ -374,69 +506,13 @@ def remove( :convert-png: latex """ - # 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 - - # its less brittle to install and use the returned path to uninstall - self.shell = shell # type: ignore - prog_name = str(manage_script or self.manage_script_name) - installed_path = install( - shell=self.shell.value if self.shell else None, prog_name=prog_name - )[1] - if self.shell in [Shells.pwsh, Shells.powershell]: - # annoyingly, powershell has one profile script for all completion commands - # so we have to find our entry and remove it - edited_lines = [] - mark = None - with open(installed_path, "rt", encoding="utf-8") as pwr_sh: - for line in pwr_sh.readlines(): - edited_lines.append(line) - if line.startswith("Import-Module PSReadLine"): - mark = len(edited_lines) - 1 - elif ( - mark is not None - and line.startswith("Register-ArgumentCompleter") - and f" {prog_name} " in line - ): - edited_lines = edited_lines[:mark] - mark = None - - if edited_lines: - with open(installed_path, "wt", encoding="utf-8") as pwr_sh: - pwr_sh.writelines(edited_lines) - else: - installed_path.unlink() - - else: - installed_path.unlink() - rc_file = { - None: None, - Shells.bash: Path("~/.bashrc").expanduser(), - Shells.zsh: Path("~/.zshrc").expanduser(), - }.get(self.shell, None) - if rc_file and rc_file.is_file(): - edited: t.List[str] = [] - with open(rc_file, "rt", encoding="utf-8") as rc: - for line in rc.readlines(): - if ( - self.shell is Shells.bash - and line.strip() == f"source {installed_path}" - ): - continue - edited.append(line) - # remove empty lines from the end of the file, the typer install scripts add - # extra newlines - while edited and not edited[-1].strip(): - edited.pop() - edited.append("") # add one back on - with open(rc_file, "wt", encoding="utf-8") as rc: - rc.writelines(edited) - + self.shell_class( + prog_name=str(manage_script or self.manage_script_name), command=self + ).uninstall() self.stdout.write( - self.style.WARNING( # pylint: disable=no-member - gettext("Removed autocompletion for {shell}.").format( - shell=self.shell.value if self.shell else "" + self.style.WARNING( + gettext("Uninstalled autocompletion for {shell}.").format( + shell=self.shell ) ) ) @@ -446,26 +522,14 @@ def remove( ) def complete( self, - cmd_str: t.Annotated[ - t.Optional[str], + command: t.Annotated[ + str, Argument( - metavar="command", help=t.cast( str, _("The command string to generate completion suggestions for.") ), ), - ] = None, - shell: t.Annotated[ - t.Optional[Shells], - Option( - help=t.cast( - str, - _( - "Specify the shell to fetch completion for, default will autodetect." - ), - ) - ), - ] = None, + ] = "", fallback: t.Annotated[ t.Optional[str], Option( @@ -500,81 +564,10 @@ def complete( :width: 80 :convert-png: latex """ - from django_typer import completers - - os.environ[self.COMPLETE_VAR] = ( - f"complete_{shell.value}" - if shell - else os.environ.get(self.COMPLETE_VAR, f"complete_{self.shell.value}") - ) - self.shell = Shells(os.environ[self.COMPLETE_VAR].partition("_")[2]) - if self.shell in [Shells.powershell, Shells.pwsh]: - completers.PATH_SEPARATOR = "\\" - else: - completers.PATH_SEPARATOR = "/" - - completion_init() - CompletionClass = get_completion_class( # pylint: disable=C0103 - self.shell.value - ) - assert CompletionClass, gettext( - 'No completion implementation for "{shell}"' - ).format(shell=self.shell.value) - if cmd_str: - # 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(cmd_str) - if cmd_str[-1].isspace(): - cwords.append("") - # 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 (TypeError, ValueError, OSError): # pragma: no cover - pass - return ( - cwords[:-1], - cwords[-1] if cwords else "", - ) - - CompletionClass.get_completion_args = get_completion_args # type: ignore - - # guard against double patching - if not getattr(CompletionClass, "_dt_patched", False): - _get_completions = CompletionClass.get_completions - - def get_completions(self, args, incomplete): - """ - need to remove the django command name from the arg completions - """ - return _get_completions(self, args[1:], incomplete) - - CompletionClass.get_completions = get_completions # type: ignore - - add_completion_class(CompletionClass, self.shell.value) - CompletionClass._dt_patched = True # type: ignore + args = split_arg_string(command) - args = CompletionClass( - cli=self.noop_command.click_command, - ctx_args={}, - prog_name=sys.argv[0], - complete_var=self.COMPLETE_VAR, - ).get_completion_args()[0] - - def call_fallback(fb: t.Optional[str]) -> None: - fallback = import_string(fb) if fb else self.django_fallback - if cmd_str and inspect.signature(fallback).parameters: - fallback(cmd_str) # type: ignore - else: - fallback() - - def get_completion() -> None: - if not args: - call_fallback(fallback) - else: + def get_completion() -> str: + if args: # we first need to figure out which command is being invoked # but we cant be sure which index the command name is at so # we try to fetch each in order and assume the first arg @@ -588,30 +581,29 @@ def get_completion() -> None: cmd = get_command(args[cmd_idx]) except KeyError: pass - except IndexError as err: - if cmd_str: - # if we're here a user is probably trying to debug a completion - # notify them that the command is not recognized - raise CommandError( - gettext('Unrecognized command: "{cmd_str}"').format( - cmd_str=cmd_str - ) - ) from err - return # otherwise nowhere to go - - if isinstance(cmd, TyperCommand): # type: ignore[unreachable] + except IndexError: + if command.endswith(" "): + # unrecognized command + return "" + # fall through to fallback + + if isinstance(cmd, TyperCommand): # this will exit out so no return is needed here - cmd.typer_app( - args=args[cmd_idx + 1 :], - standalone_mode=True, - django_command=cmd, - complete_var=self.COMPLETE_VAR, - prog_name=( - f"{' '.join(sys.argv[0:cmd_idx or None])} " - f"{self.typer_app.info.name}", - ), - ) - call_fallback(fallback) + return self.shell_class( + cli=get_typer_command(cmd.typer_app), + ctx_args={"django_command": cmd}, + prog_name="", + complete_var="", + command=self, + command_str=command, + command_args=args, + ).complete() + + # only try to set the fallback if we have to use it + self.fallback = fallback # type: ignore[assignment] + return self.shell_class( + command=self, command_str=command, command_args=args + ).complete() def strip_color(text: str) -> str: """ @@ -621,82 +613,16 @@ def strip_color(text: str) -> str: return self.ANSI_ESCAPE_RE.sub("", text) return text - buffer = io.StringIO() - try: - with contextlib.redirect_stdout(buffer): - # typer's internal behavior is to print and exit, we need - # to intercept this workflow to do our post processing and - # honor the configured stdout - get_completion() - - # leave this in, incase the interface changes to not exit - return strip_color(buffer.getvalue()) # pragma: no cover - except SystemExit: - completion_str = buffer.getvalue() - if completion_str: - return strip_color(completion_str) - if cmd_str: - return "" - raise - - 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( # pylint: disable=C0103 - self.shell.value - ) - assert CompletionClass, gettext( - 'No completion implementation for "{shell}"' - ).format(shell=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) - else: # pragma: no cover - pass - 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() - with contextlib.redirect_stdout(capture_completions): - try: - dj_manager.autocomplete() - except SystemExit: - pass - return [ - CompletionItem(item) - for item in capture_completions.getvalue().split() - if item - ] - - CompletionClass.get_completions = get_completions # type: ignore - echo( - CompletionClass( - cli=self.noop_command.click_command, - ctx_args={}, - prog_name=self.manage_script_name, - complete_var=self.COMPLETE_VAR, - ).complete() - ) + return strip_color(get_completion()) - @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 access errors when we spoof - completion logic - """ + +from .shells.bash import BashComplete # noqa: E402 +from .shells.fish import FishComplete # noqa: E402 +from .shells.powershell import PowerShellComplete, PwshComplete # noqa: E402 +from .shells.zsh import ZshComplete # noqa: E402 + +register_completion_class(ZshComplete) +register_completion_class(BashComplete) +register_completion_class(PowerShellComplete) +register_completion_class(PwshComplete) +register_completion_class(FishComplete) diff --git a/django_typer/management/commands/shells/__init__.py b/django_typer/management/commands/shells/__init__.py index ac55865..e69de29 100644 --- a/django_typer/management/commands/shells/__init__.py +++ b/django_typer/management/commands/shells/__init__.py @@ -1,23 +0,0 @@ -from enum import Enum -from click.shell_completion import add_completion_class - - -class Shells(str, Enum): - bash = "bash" - zsh = "zsh" - fish = "fish" - powershell = "powershell" - pwsh = "pwsh" - - -def completion_init(): - from .bash import BashComplete - from .fish import FishComplete - from .powershell import PowerShellComplete - from .zsh import ZshComplete - - add_completion_class(BashComplete, Shells.bash) - add_completion_class(ZshComplete, Shells.zsh) - add_completion_class(FishComplete, Shells.fish) - add_completion_class(PowerShellComplete, Shells.powershell) - add_completion_class(PowerShellComplete, Shells.pwsh) diff --git a/django_typer/management/commands/shells/bash.py b/django_typer/management/commands/shells/bash.py index 48875c0..536a725 100644 --- a/django_typer/management/commands/shells/bash.py +++ b/django_typer/management/commands/shells/bash.py @@ -1,6 +1,49 @@ -from click.shell_completion import BashComplete as ClickBashComplete +from functools import cached_property +from pathlib import Path +from click.shell_completion import CompletionItem -class BashComplete(ClickBashComplete): - pass +from ..shellcompletion import DjangoTyperShellCompleter + +class BashComplete(DjangoTyperShellCompleter): + """ + https://github.com/scop/bash-completion#faq + """ + + name = "bash" + SCRIPT = Path(__file__).parent / "bash.sh" + + @cached_property + def install_dir(self) -> Path: + install_dir = Path.home() / ".bash_completions" + install_dir.mkdir(parents=True, exist_ok=True) + return install_dir + + def format_completion(self, item: CompletionItem) -> str: + return f"{item.type},{item.value}" + + def install_bash(self) -> Path: + assert self.prog_name + Path.home().mkdir(parents=True, exist_ok=True) + script = self.install_dir / f"{self.prog_name}.sh" + bashrc = Path.home() / ".bashrc" + bashrc_source = bashrc.read_text() if bashrc.is_file() else "" + source_line = f"source {script}" + if source_line not in bashrc_source: + bashrc_source += f"\n{source_line}\n" + bashrc.write_text(bashrc_source) + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text(self.source()) + return script + + def uninstall(self): + assert self.prog_name + script = self.install_dir / f"{self.prog_name}.sh" + if script.is_file(): + script.unlink() + + bashrc = Path.home() / ".bashrc" + if bashrc.is_file(): + bashrc_source = bashrc.read_text() + bashrc.write_text(bashrc_source.replace(f"source {script}\n", "")) diff --git a/django_typer/management/commands/shells/bash.tmpl b/django_typer/management/commands/shells/bash.sh similarity index 89% rename from django_typer/management/commands/shells/bash.tmpl rename to django_typer/management/commands/shells/bash.sh index db81c75..4d2e2f9 100644 --- a/django_typer/management/commands/shells/bash.tmpl +++ b/django_typer/management/commands/shells/bash.sh @@ -25,7 +25,7 @@ COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \ COMP_CWORD=$COMP_CWORD \ - %(autocomplete_var)s=complete_bash $1 {{ django_command }} ${settings_option:+${settings_option}} ${pythonpath_option:+${pythonpath_option}} {{ color }} complete ) ) + %(autocomplete_var)s=complete_bash $1 {{ django_command }} --shell bash ${settings_option:+${settings_option}} ${pythonpath_option:+${pythonpath_option}} {{ color }} complete ) ) return 0 } diff --git a/django_typer/management/commands/shells/fish.tmpl b/django_typer/management/commands/shells/fish.fish similarity index 100% rename from django_typer/management/commands/shells/fish.tmpl rename to django_typer/management/commands/shells/fish.fish diff --git a/django_typer/management/commands/shells/fish.py b/django_typer/management/commands/shells/fish.py index d4e6e98..7e3fcad 100644 --- a/django_typer/management/commands/shells/fish.py +++ b/django_typer/management/commands/shells/fish.py @@ -1,6 +1,37 @@ -from click.shell_completion import FishComplete as ClickFishComplete +from functools import cached_property +from pathlib import Path +from click.shell_completion import CompletionItem -class FishComplete(ClickFishComplete): - pass +from ..shellcompletion import DjangoTyperShellCompleter + +class FishComplete(DjangoTyperShellCompleter): + name = "fish" + SCRIPT = Path(__file__).parent / "fish.fish" + + @cached_property + def install_dir(self) -> Path: + """ + The directory where completer scripts will be installed. + """ + install_dir = Path.home() / ".config/fish/completions" + install_dir.mkdir(parents=True, exist_ok=True) + return install_dir + + def format_completion(self, item: CompletionItem) -> str: + if item.help: + return f"{item.type},{item.value}\t{item.help}" + return f"{item.type},{item.value}" + + def install(self) -> Path: + assert self.prog_name + script = self.install_dir / f"{self.prog_name}.fish" + script.write_text(self.source()) + return script + + def uninstall(self): + assert self.prog_name + script = self.install_dir / f"{self.prog_name}.fish" + if script.is_file(): + script.unlink() diff --git a/django_typer/management/commands/shells/powershell.tmpl b/django_typer/management/commands/shells/powershell.ps1 similarity index 100% rename from django_typer/management/commands/shells/powershell.tmpl rename to django_typer/management/commands/shells/powershell.ps1 diff --git a/django_typer/management/commands/shells/powershell.py b/django_typer/management/commands/shells/powershell.py index fb1dafb..f91912f 100644 --- a/django_typer/management/commands/shells/powershell.py +++ b/django_typer/management/commands/shells/powershell.py @@ -1,6 +1,81 @@ -from click.shell_completion import ShellComplete +import subprocess +from pathlib import Path +from click.shell_completion import CompletionItem +from django.utils.translation import gettext as _ -class PowerShellComplete(ShellComplete): - pass +from ..shellcompletion import DjangoTyperShellCompleter + +class PowerShellComplete(DjangoTyperShellCompleter): + name = "powershell" + SCRIPT = Path(__file__).parent / "powershell.ps1" + + def format_completion(self, item: CompletionItem) -> str: + return f"{item.value}:::{item.help or ' '}" + + def set_execution_policy(self) -> None: + subprocess.run( + [ + self.name, + "-Command", + "Set-ExecutionPolicy", + "Unrestricted", + "-Scope", + "CurrentUser", + ] + ) + + def get_user_profile(self) -> Path: + result = subprocess.run( + [self.name, "-NoProfile", "-Command", "echo", "$profile"], + check=True, + stdout=subprocess.PIPE, + ) + if result.returncode == 0: + for encoding in ["windows-1252", "utf8", "cp850"]: + try: + return Path(result.stdout.decode(encoding).strip()) + except UnicodeDecodeError: + pass + raise RuntimeError(_("Unable to find the PowerShell user profile.")) + + def install(self) -> Path: + assert self.prog_name + self.set_execution_policy() + profile = self.get_user_profile() + profile.parent.mkdir(parents=True, exist_ok=True) + with profile.open(mode="a") as f: + f.writelines([self.source()]) + return profile + + def uninstall(self): + # annoyingly, powershell has one profile script for all completion commands + # so we have to find our entry and remove it + assert self.prog_name + self.set_execution_policy() + profile = self.get_user_profile() + edited_lines = [] + mark = None + with open(profile, "rt", encoding="utf-8") as pwr_sh: + for line in pwr_sh.readlines(): + edited_lines.append(line) + if line.startswith("Import-Module PSReadLine"): + mark = len(edited_lines) - 1 + elif ( + mark is not None + and line.startswith("Register-ArgumentCompleter") + and f" {self.prog_name} " in line + ): + edited_lines = edited_lines[:mark] + mark = None + + if edited_lines: + with open(profile, "wt", encoding="utf-8") as pwr_sh: + pwr_sh.writelines(edited_lines) + else: + profile.unlink() + + +class PwshComplete(PowerShellComplete): + name = "pwsh" diff --git a/django_typer/management/commands/shells/zsh.py b/django_typer/management/commands/shells/zsh.py index 7e109eb..9552a36 100644 --- a/django_typer/management/commands/shells/zsh.py +++ b/django_typer/management/commands/shells/zsh.py @@ -1,5 +1,69 @@ -from click.shell_completion import ZshComplete as ClickZshComplete +from functools import cached_property +from pathlib import Path +from click.shell_completion import CompletionItem -class ZshComplete(ClickZshComplete): - pass +from ..shellcompletion import DjangoTyperShellCompleter + + +class ZshComplete(DjangoTyperShellCompleter): + name = "zsh" + SCRIPT = Path(__file__).parent / "zsh.sh" + + supports_scripts = True + + @cached_property + def install_dir(self) -> Path: + """ + The directory where completer scripts will be installed. + """ + install_dir = Path.home() / ".zfunc" + install_dir.mkdir(parents=True, exist_ok=True) + return install_dir + + def format_completion(self, item: CompletionItem) -> str: + def escape(s: str) -> str: + return ( + s.replace('"', '""') + .replace("'", "''") + .replace("$", "\\$") + .replace("`", "\\`") + .replace(":", r"\\:") + ) + + return f"{item.type}\n{escape(item.value)}\n{escape(item.help) if item.help else '_'}" + + def install(self) -> Path: + assert self.prog_name + Path.home().mkdir(parents=True, exist_ok=True) + zshrc = Path.home() / ".zshrc" + zshrc_source = "" + if zshrc.is_file(): + zshrc_source = zshrc.read_text() + if "fpath+=~/.zfunc" not in zshrc_source: + zshrc_source += "fpath+=~/.zfunc\n" + if "autoload -Uz compinit" not in zshrc_source: + zshrc_source += "autoload -Uz compinit\n" + if "compinit" not in zshrc_source: + zshrc_source += "compinit\n" + style = f"zstyle ':completion:*:*:{self.prog_name}:*' menu select" + if style not in zshrc_source: + zshrc_source += f"{style}\n" + zshrc.write_text(zshrc_source) + script = self.install_dir / f"_{self.prog_name}" + script.write_text(self.source()) + return script + + def uninstall(self): + script = self.install_dir / f"_{self.prog_name}" + if script.is_file(): + script.unlink() + + zshrc = Path.home() / ".zshrc" + if zshrc.is_file(): + zshrc_source = zshrc.read_text() + zshrc.write_text( + zshrc_source.replace( + f"zstyle ':completion:*:*:{self.prog_name}:*' menu select\n", "" + ) + ) diff --git a/django_typer/management/commands/shells/zsh.sh b/django_typer/management/commands/shells/zsh.sh new file mode 100644 index 0000000..d3a4491 --- /dev/null +++ b/django_typer/management/commands/shells/zsh.sh @@ -0,0 +1,72 @@ +{% if is_installed %} +#compdef {{ manage_script }} +{% else %} +#compdef {{ manage_script.absolute }} {{ manage_script.name }} ./{{ manage_script.name }} */{{ manage_script.name }} +{% endif %} + +{% if is_installed %} +{% endif %} + +{{ complete_func }}() { + local -a completions + local -a completions_with_descriptions + local -a response + + # Extract --settings and --pythonpath options and their values if present becase + # we need to pass these to the complete script - they may be necessary to find the command! + local settings_option="" + local pythonpath_option="" + + {% if is_installed %} + (( ! $+commands[%(prog_name)s] )) && return 1 + {% endif %} + + for ((i=1; i<$CURRENT; i++)); do + case "${words[i]}" in + --settings) + # Only pass settings to completion script if we're sure it's value does not itself need completion! + if (( i + 1 < CURRENT )) && [[ -n "${words[i+1]}" ]] && [[ "${words[i+1]}" != --* ]]; then + settings_option="--settings=${words[i+1]}" + fi + ;; + --pythonpath) + # Only pass pythonpath to completion script if we're sure it's value does not itself need completion! + if (( i + 1 < CURRENT )) && [[ -n "${words[i+1]}" ]] && [[ "${words[i+1]}" != --* ]]; then + pythonpath_option="--pythonpath=${words[i+1]}" + fi + ;; + esac + done + + response=("${(@f)$({% if is_installed %}{{ manage_script }}{% else %}{{ python }} {{ manage_script.absolute }}{% endif %} {{ django_command }} --shell zsh ${settings_option:+${settings_option}} ${pythonpath_option:+${pythonpath_option}} {{ color }} {{ rich }} complete "${words[*]}")}") + + for type key descr in ${response}; do + if [[ "$type" == "plain" ]]; then + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + elif [[ "$type" == "dir" ]]; then + _path_files -/ + elif [[ "$type" == "file" ]]; then + _path_files -f + fi + done + + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U + fi + + if [ -n "$completions" ]; then + compadd -U -V unsorted -a completions + fi +} + +if [[ $zsh_eval_context[-1] == loadautofunc ]]; then + # autoload from fpath, call function directly + {{ complete_func }} "$@" +else + # eval/source/. command, register function for later + compdef {{ complete_func }} {% if is_installed %}{{ manage_script }}{% else %}'*{{ manage_script.name }}'{% endif %} +fi diff --git a/django_typer/management/commands/shells/zsh.tmpl b/django_typer/management/commands/shells/zsh.tmpl deleted file mode 100644 index 263bf0d..0000000 --- a/django_typer/management/commands/shells/zsh.tmpl +++ /dev/null @@ -1,30 +0,0 @@ -#compdef %(prog_name)s - -%(complete_func)s() { - - # Extract --settings and --pythonpath options and their values if present becase - # we need to pass these to the complete script - they may be necessary to find the command! - local settings_option="" - local pythonpath_option="" - - for ((i=1; i<$CURRENT; i++)); do - case "${words[i]}" in - --settings) - # Only pass settings to completion script if we're sure it's value does not itself need completion! - if (( i + 1 < CURRENT )) && [[ -n "${words[i+1]}" ]] && [[ "${words[i+1]}" != --* ]]; then - settings_option="--settings=${words[i+1]}" - fi - ;; - --pythonpath) - # Only pass pythonpath to completion script if we're sure it's value does not itself need completion! - if (( i + 1 < CURRENT )) && [[ -n "${words[i+1]}" ]] && [[ "${words[i+1]}" != --* ]]; then - pythonpath_option="--pythonpath=${words[i+1]}" - fi - ;; - esac - done - - eval $(env _TYPER_COMPLETE_ARGS="${words[1,$CURRENT]}" %(autocomplete_var)s=complete_zsh ${words[0,1]} {{ django_command }} ${settings_option:+${settings_option}} ${pythonpath_option:+${pythonpath_option}} {{ color }} complete) -} - -compdef %(complete_func)s %(prog_name)s diff --git a/django_typer/utils.py b/django_typer/utils.py index f192dc6..b2dda44 100644 --- a/django_typer/utils.py +++ b/django_typer/utils.py @@ -202,7 +202,7 @@ def accepts_var_kwargs(func: t.Callable[..., t.Any]) -> bool: """ Determines if the given function accepts variable keyword arguments. """ - for param in reversed(inspect.signature(func).parameters.values()): + for param in reversed(list(inspect.signature(func).parameters.values())): return param.kind is inspect.Parameter.VAR_KEYWORD return False diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index c309374..d8452b1 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -7,6 +7,7 @@ Change Log v3.0.0 (202X-XX-XX) =================== +* Implemented `Use in-house shell completer classes. `_ * Implemented `Add precommit hook to fix safe lint and format issues `_ * BREAKING `Remove name parameter from initialize()/callback(). `_ * Implemented `Run full test suite on mac osx `_ @@ -23,11 +24,40 @@ v3.0.0 (202X-XX-XX) Migrating from 2.x to 3.x ------------------------- +* Imports from the django_typer namespace have been removed. You should now import from + django_typer.management. + * The `name` parameter has been removed from :func:`django_typer.management.initialize()` and :func:`django_typer.management.Typer.callback()`. - This change was forced by [upstream changes](https://github.com/fastapi/typer/pull/1052) in - Typer_ that will allow :func:`django_typer.management.Typer.add_typer` to extend apps. - + This change was forced by `upstream changes `_ in + Typer_ that will allow :func:`django_typer.management.Typer.add_typer` to define commands across + multiple files. + +* If you are using shell tab completions you will need to reinstall the completion scripts. using + the `shellcompletion install` command. + +* The interface to shellcompletion has changed. ``--shell`` is now an initialization option. I.e.: + + .. code-block:: + + # old interface + manage shellcompletion complete --shell zsh "command string" + + # new interface + manage shellcompletion --shell zsh complete "command string" + +* The function signature for :ref:`shellcompletion fallbacks ` has changed. + The fallback signature is now: + + .. code-block:: + + import typing as t + from click.shell_complete import CompletionItem + + def fallback(args: t.List[str], incomplete: str) -> t.List[CompletionItem]: + ... + + v2.6.0 (2024-12-03) =================== diff --git a/doc/source/shell_completion.rst b/doc/source/shell_completion.rst index 3897822..25de535 100644 --- a/doc/source/shell_completion.rst +++ b/doc/source/shell_completion.rst @@ -133,6 +133,8 @@ installation *should still work*, but you may need to always invoke the script f Fish_ may not work at all in this mode. +.. _completion_fallbacks: + Integrating with Other CLI Completion Libraries ----------------------------------------------- diff --git a/justfile b/justfile index 96997f6..cf45bb0 100644 --- a/justfile +++ b/justfile @@ -66,7 +66,7 @@ build: build-docs-html poetry build open-docs: - poetry run python ./scripts/open-docs.py + poetry run python -c "import webbrowser; webbrowser.open('file://$(pwd)/doc/build/html/index.html')" docs: build-docs-html open-docs @@ -88,14 +88,14 @@ check-format: check-readme: poetry run python -m readme_renderer ./README.md -o /tmp/README.html -format: +sort-imports: + poetry run ruff check --fix --select I + +format: sort-imports just --fmt --unstable poetry run ruff format poetry run ruff format --line-length 80 examples -sort-imports: - poetry run ruff check --fix --select I - lint: sort-imports poetry run ruff check --fix diff --git a/pyproject.toml b/pyproject.toml index d2d8c93..3630ed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ classifiers = [ "Changelog" = "https://django-typer.readthedocs.io/en/latest/changelog.html" "Code_of_Conduct" = "https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md" +[tool.poetry.scripts] +manage = "tests.manage:main" + [tool.poetry.dependencies] python = ">=3.9,<4.0" Django = ">=3.2,<6.0" diff --git a/tests/fallback.py b/tests/fallback.py index b01f6d4..17a6af9 100644 --- a/tests/fallback.py +++ b/tests/fallback.py @@ -1,6 +1,12 @@ -def custom_fallback(): - print("custom_fallback") +import typing as t +from click.shell_completion import CompletionItem -def custom_fallback_cmd_str(cmd_str: str): - print(cmd_str) +def custom_fallback(args: t.List[str], incomplete: str) -> t.List[CompletionItem]: + return [CompletionItem("custom_fallback")] + + +def custom_fallback_cmd_str( + args: t.List[str], incomplete: str +) -> t.List[CompletionItem]: + return [CompletionItem(" ".join(args) + incomplete)] diff --git a/tests/test_examples.py b/tests/test_examples.py index 96195f4..15248c7 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -472,29 +472,23 @@ def test_app_labels_completer(self): from django_typer.management.commands.shellcompletion import ( Command as ShellCompletion, ) - from django_typer.management.commands.shellcompletion import ( - Shells, - ) - - shellcompletion = get_command("shellcompletion", ShellCompletion) - completions = shellcompletion.complete( - f"{self.app_labels_cmd} ", shell=Shells.zsh - ) - - self.assertTrue('"contenttypes"' in completions) - self.assertTrue('"completers"' in completions) - self.assertTrue('"django_typer"' in completions) - self.assertTrue('"admin"' in completions) - self.assertTrue('"auth"' in completions) - self.assertTrue('"sessions"' in completions) - self.assertTrue('"messages"' in completions) - completions = shellcompletion.complete( - f"{self.app_labels_cmd} a", shell=Shells.zsh + shellcompletion = get_command("shellcompletion", ShellCompletion).init( + shell="zsh" ) - - self.assertTrue('"admin"' in completions) - self.assertTrue('"auth"' in completions) + completions = shellcompletion.complete(f"{self.app_labels_cmd} ") + self.assertTrue("contenttypes" in completions) + self.assertTrue("completers" in completions) + self.assertTrue("django_typer" in completions) + self.assertTrue("admin" in completions) + self.assertTrue("auth" in completions) + self.assertTrue("sessions" in completions) + self.assertTrue("messages" in completions) + + completions = shellcompletion.complete(f"{self.app_labels_cmd} a") + + self.assertTrue("admin" in completions) + self.assertTrue("auth" in completions) stdout, stderr, retcode = run_command( self.app_labels_cmd, diff --git a/tests/test_parser_completers.py b/tests/test_parser_completers.py index c269096..1917a33 100644 --- a/tests/test_parser_completers.py +++ b/tests/test_parser_completers.py @@ -743,7 +743,7 @@ def test_id_field(self): result = result.getvalue() for id in ids: - self.assertTrue(f'"{id}"' in result) + self.assertTrue(f"{id}" in result) for start_char in start_chars: expected = starts[start_char] @@ -752,17 +752,16 @@ def test_id_field(self): with contextlib.redirect_stdout(result): call_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", f"model_fields test --id {start_char}", ) result = result.getvalue() - for id in expected: - self.assertTrue(f'"{id}"' in result) - for id in unexpected: - self.assertFalse(f'"{id}"' in result) + for comp in result.split("\n")[1::3]: + self.assertTrue(comp in expected) + self.assertTrue(comp not in unexpected) for id in ids: self.assertEqual( @@ -775,16 +774,16 @@ def test_id_field(self): with contextlib.redirect_stdout(result): call_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "model_fields test --id-limit ", ) result = result.getvalue() for id in ids[0:5]: - self.assertTrue(f'"{id}"' in result) + self.assertTrue(f"{id}" in result) for id in ids[5:]: - self.assertFalse(f'"{id}"' in result) + self.assertFalse(f"{id}" in result) def test_float_field(self): values = [1.1, 1.12, 2.2, 2.3, 2.4, 3.0, 4.0] @@ -960,19 +959,21 @@ def test_option_complete(self): with contextlib.redirect_stdout(result): call_command("shellcompletion", "complete", "noarg cmd ", shell="zsh") result = result.getvalue() - self.assertTrue(result) - self.assertFalse("--" in result) + self.assertFalse(result) result = StringIO() with contextlib.redirect_stdout(result): call_command("shellcompletion", "complete", "noarg cmd -", shell="zsh") result = result.getvalue() - self.assertTrue(result) + self.assertFalse(result) self.assertFalse("--" in result) # test what happens if we try to complete a non existing command - with self.assertRaises(CommandError): + result = StringIO() + with contextlib.redirect_stdout(result): call_command("shellcompletion", "complete", "noargs cmd ", shell="zsh") + result = result.getvalue() + self.assertFalse(result) def test_unsupported_field(self): from django_typer.completers import ModelObjectCompleter @@ -980,7 +981,7 @@ def test_unsupported_field(self): with self.assertRaises(ValueError): ModelObjectCompleter(ShellCompleteTester, "binary_field") - def test_shellcompletion_no_detection(self): + def test_shellcompletion_unsupported_shell(self): from django_typer.management.commands import shellcompletion def raise_error(): @@ -989,7 +990,8 @@ def raise_error(): shellcompletion.detect_shell = raise_error cmd = get_command("shellcompletion") with self.assertRaises(CommandError): - cmd.shell = None + cmd.shell = "DNE" + cmd.shell_class def test_shellcompletion_complete_cmd(self): # test that we can leave preceeding script off the complete argument @@ -1018,62 +1020,68 @@ def test_custom_fallback(self): "tests.fallback.custom_fallback_cmd_str", "shell ", )[0] - self.assertTrue("shell " in result) + self.assertTrue("shell" in result) def test_import_path_completer(self): result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --settings " + "shellcompletion", "--shell", "zsh", "complete", "multi --settings " )[0] - self.assertIn('"importlib"', result) - self.assertIn('"django_typer"', result) - self.assertIn('"typer"', result) + self.assertIn("importlib", result) + self.assertIn("django_typer", result) + self.assertIn("typer", result) self.assertNotIn(".django_typer", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --settings " + "shellcompletion", "--shell", "zsh", "complete", "multi --settings " )[0] - self.assertIn('"importlib"', result) - self.assertIn('"django_typer"', result) - self.assertIn('"typer"', result) + self.assertIn("importlib", result) + self.assertIn("django_typer", result) + self.assertIn("typer", result) self.assertNotIn(".django_typer", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --settings djan" + "shellcompletion", "--shell", "zsh", "complete", "multi --settings djan" )[0] - self.assertNotIn('"importlib"', result) - self.assertIn('"django"', result) - self.assertIn('"django_typer"', result) - self.assertNotIn('"typer"', result) - self.assertNotIn(".django_typer", result) + self.assertIn("django", result) + self.assertIn("django_typer", result) + for comp in result.split("\n")[1::3]: + self.assertTrue(comp.startswith("djan"), f"{comp} does not start with djan") result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --settings django_ty", )[0] - self.assertNotIn('"importlib"', result) - self.assertNotIn('"typer"', result) + self.assertNotIn("importlib", result) self.assertNotIn(".django_typer", result) + for comp in result.split("\n")[1::3]: + self.assertTrue( + comp.startswith("django_ty"), f"{comp} does not start with django_ty" + ) - self.assertIn('"django_typer.completers"', result) - self.assertIn('"django_typer.management"', result) - self.assertIn('"django_typer.parsers"', result) - self.assertIn('"django_typer.patch"', result) - self.assertIn('"django_typer.types"', result) - self.assertIn('"django_typer.utils"', result) + self.assertIn("django_typer.completers", result) + self.assertIn("django_typer.management", result) + self.assertIn("django_typer.parsers", result) + self.assertIn("django_typer.patch", result) + self.assertIn("django_typer.types", result) + self.assertIn("django_typer.utils", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --settings tests.settings.", )[0] - self.assertNotIn('"importlib"', result) - self.assertNotIn('"typer"', result) - self.assertNotIn(".django_typer", result) + + for comp in result.split("\n")[1::3]: + self.assertTrue( + comp.startswith("tests.settings."), + f"{comp} does not start with tests.settings.", + ) + settings_expected = [ "tests.settings.adapted", "tests.settings.adapted1", @@ -1094,19 +1102,19 @@ def test_import_path_completer(self): "tests.settings.typer_examples", ] for mod in settings_expected: - self.assertIn(f'"{mod}"', result) + self.assertIn(f"{mod}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --settings tests.settings.typer_examples", )[0] for mod in settings_expected[:-1]: - self.assertNotIn(f'"{mod}"', result) + self.assertNotIn(f"{mod}", result) - self.assertIn(f'"{settings_expected[-1]}"', result) + self.assertIn(f"{settings_expected[-1]}", result) def test_pythonpath_completer(self): local_dirs = [ @@ -1114,36 +1122,36 @@ def test_pythonpath_completer(self): ] local_files = [Path(f).as_posix() for f in os.listdir() if not Path(f).is_dir()] result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --pythonpath " + "shellcompletion", "--shell", "zsh", "complete", "multi --pythonpath " )[0] for pth in local_dirs: - self.assertIn(f'"{pth}"', result) + self.assertIn(f"{pth}", result) for pth in local_files: - self.assertNotIn(f'"{pth}"', result) + self.assertNotIn(f"{pth}", result) for incomplete in [".", "./"]: result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", f"multi --pythonpath {incomplete}", )[0] for pth in local_dirs: - self.assertIn(f'"./{pth}"', result) + self.assertIn(f"./{pth}", result) for pth in local_files: - self.assertNotIn(f'"./{pth}"', result) + self.assertNotIn(f"./{pth}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --pythonpath ./d" + "shellcompletion", "--shell", "zsh", "complete", "multi --pythonpath ./d" )[0] - self.assertIn('"./doc"', result) - self.assertIn('"./django_typer"', result) + self.assertIn("./doc", result) + self.assertIn("./django_typer", result) for pth in [ *local_files, *[pth for pth in local_dirs if not pth.startswith("d")], ]: - self.assertNotIn(f'"./{pth}"', result) + self.assertNotIn(f"./{pth}", result) local_dirs = [ (Path("django_typer") / d).as_posix() @@ -1156,89 +1164,89 @@ def test_pythonpath_completer(self): if not (Path("django_typer") / f).is_dir() ] result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --pythonpath dj" + "shellcompletion", "--shell", "zsh", "complete", "multi --pythonpath dj" )[0] for pth in local_dirs: - self.assertIn(f'"{pth}"', result) + self.assertIn(f"{pth}", result) for pth in local_files: - self.assertNotIn(f'"{pth}"', result) + self.assertNotIn(f"{pth}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --pythonpath ./dj" + "shellcompletion", "--shell", "zsh", "complete", "multi --pythonpath ./dj" )[0] for pth in local_dirs: - self.assertIn(f'"./{pth}"', result) + self.assertIn(f"./{pth}", result) for pth in local_files: - self.assertNotIn(f'"./{pth}"', result) + self.assertNotIn(f"./{pth}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --pythonpath ./django_typer", )[0] - self.assertIn('"./django_typer/management"', result) - self.assertIn('"./django_typer/locale"', result) - self.assertNotIn('"./django_typer/__init__.py"', result) + self.assertIn("./django_typer/management", result) + self.assertIn("./django_typer/locale", result) + self.assertNotIn("./django_typer/__init__.py", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --pythonpath django_typer/", )[0] - self.assertIn('"django_typer/management"', result) - self.assertIn('"django_typer/locale"', result) - self.assertNotIn('"django_typer/__init__.py"', result) + self.assertIn("django_typer/management", result) + self.assertIn("django_typer/locale", result) + self.assertNotIn("django_typer/__init__.py", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --pythonpath django_typer/man", )[0] - self.assertIn('"django_typer/management/commands"', result) - self.assertNotIn('"django_typer/examples"', result) - self.assertNotIn('"django_typer/locale"', result) - self.assertNotIn('"django_typer/management/__init__.py"', result) + self.assertIn("django_typer/management/commands", result) + self.assertNotIn("django_typer/examples", result) + self.assertNotIn("django_typer/locale", result) + self.assertNotIn("django_typer/management/__init__.py", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "multi --pythonpath /" + "shellcompletion", "--shell", "zsh", "complete", "multi --pythonpath /" )[0] for pth in os.listdir("/"): if pth.startswith("$"): continue # TODO weird case of /\\$Recycle.Bin on windows if Path(f"/{pth}").is_dir(): - self.assertIn(f'"/{pth}"', result) + self.assertIn(f"/{pth}", result) else: - self.assertNotIn(f'"/{pth}"', result) + self.assertNotIn(f"/{pth}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --pythonpath django_typer/completers.py", ) - self.assertNotIn('"django_typer/completers.py"', result) + self.assertNotIn("django_typer/completers.py", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --pythonpath django_typer/does_not_exist", )[0] self.assertNotIn("django_typer", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "multi --pythonpath does_not_exist/does_not_exist", )[0] self.assertNotIn("django_typer", result) @@ -1246,39 +1254,39 @@ def test_pythonpath_completer(self): def test_path_completer(self): local_paths = [Path(pth).as_posix() for pth in os.listdir()] result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "completion --path " + "shellcompletion", "--shell", "zsh", "complete", "completion --path " )[0] for pth in local_paths: - self.assertIn(f'"{pth}"', result) + self.assertIn(f"{pth}", result) for incomplete in [".", "./"]: result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", f"completion --path {incomplete}", )[0] for pth in local_paths: - self.assertIn(f'"./{pth}"', result) + self.assertIn(f"./{pth}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "completion --path ./d" + "shellcompletion", "--shell", "zsh", "complete", "completion --path ./d" )[0] - self.assertIn('"./doc"', result) - self.assertIn('"./django_typer"', result) + self.assertIn("./doc", result) + self.assertIn("./django_typer", result) for pth in [ *[pth for pth in local_paths if not pth.startswith("d")], ]: - self.assertNotIn(f'"./{pth}"', result) + self.assertNotIn(f"./{pth}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "completion --path ./p" + "shellcompletion", "--shell", "zsh", "complete", "completion --path ./p" )[0] for pth in [ *[pth for pth in local_paths if not pth.startswith("p")], ]: - self.assertNotIn(f'"./{pth}"', result) + self.assertNotIn(f"./{pth}", result) local_paths = [ (Path("django_typer") / d).as_posix() @@ -1286,82 +1294,82 @@ def test_path_completer(self): if (Path("django_typer") / d).is_dir() ] result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "completion --path dj" + "shellcompletion", "--shell", "zsh", "complete", "completion --path dj" )[0] for pth in local_paths: - self.assertIn(f'"{pth}"', result) + self.assertIn(f"{pth}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "completion --path ./dj" + "shellcompletion", "--shell", "zsh", "complete", "completion --path ./dj" )[0] for pth in local_paths: - self.assertIn(f'"./{pth}"', result) + self.assertIn(f"./{pth}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --path ./django_typer", )[0] - self.assertIn('"./django_typer/management"', result) - self.assertIn('"./django_typer/locale"', result) - self.assertIn('"./django_typer/__init__.py"', result) + self.assertIn("./django_typer/management", result) + self.assertIn("./django_typer/locale", result) + self.assertIn("./django_typer/__init__.py", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --path django_typer/", )[0] - self.assertIn('"django_typer/management"', result) - self.assertIn('"django_typer/locale"', result) - self.assertIn('"django_typer/__init__.py"', result) + self.assertIn("django_typer/management", result) + self.assertIn("django_typer/locale", result) + self.assertIn("django_typer/__init__.py", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --path django_typer/man", )[0] - self.assertIn('"django_typer/management/__init__.py"', result) - self.assertIn('"django_typer/management/commands"', result) - self.assertNotIn('"django_typer/examples"', result) - self.assertNotIn('"django_typer/locale"', result) + self.assertIn("django_typer/management/__init__.py", result) + self.assertIn("django_typer/management/commands", result) + self.assertNotIn("django_typer/examples", result) + self.assertNotIn("django_typer/locale", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", "completion --path /" + "shellcompletion", "--shell", "zsh", "complete", "completion --path /" )[0] for pth in os.listdir("/"): if pth.startswith("$"): continue # TODO weird case of /\\$Recycle.Bin on windows - self.assertIn(f'"/{pth}"', result) + self.assertIn(f"/{pth}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --path django_typer/completers.py", )[0] - self.assertIn('"django_typer/completers.py"', result) + self.assertIn("django_typer/completers.py", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --path django_typer/does_not_exist", )[0] self.assertNotIn("django_typer", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --path does_not_exist/does_not_exist", )[0] self.assertNotIn("django_typer", result) @@ -1369,65 +1377,65 @@ def test_path_completer(self): def test_these_strings_completer(self): for opt in ["--str", "--dup"]: result = run_command( - "shellcompletion", "complete", "--shell", "zsh", f"completion {opt} " + "shellcompletion", "--shell", "zsh", "complete", f"completion {opt} " )[0] for s in ["str1", "str2", "ustr"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", f"completion {opt} s" + "shellcompletion", "--shell", "zsh", "complete", f"completion {opt} s" )[0] - self.assertNotIn('"ustr"', result) + self.assertNotIn("ustr", result) for s in ["str1", "str2"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) result = run_command( - "shellcompletion", "complete", "--shell", "zsh", f"completion {opt} str" + "shellcompletion", "--shell", "zsh", "complete", f"completion {opt} str" )[0] - self.assertNotIn('"ustr"', result) + self.assertNotIn("ustr", result) for s in ["str1", "str2"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --str str1 --str ", )[0] - self.assertNotIn('"str1"', result) + self.assertNotIn("str1", result) for s in ["str2", "ustr"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --dup str1 --dup ", )[0] for s in ["str1", "str2", "ustr"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --str str1 --dup ", )[0] for s in ["str1", "str2", "ustr"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) result = run_command( "shellcompletion", - "complete", "--shell", "zsh", + "complete", "completion --dup str1 --str ", )[0] for s in ["str1", "str2", "ustr"]: - self.assertIn(f'"{s}"', result) + self.assertIn(f"{s}", result) def test_chain_and_commands_completer(self): result = run_command("shellcompletion", "complete", "completion --cmd dj")[ diff --git a/tests/test_poll_example.py b/tests/test_poll_example.py index 4e283f1..9070993 100644 --- a/tests/test_poll_example.py +++ b/tests/test_poll_example.py @@ -57,7 +57,7 @@ def test_poll_complete(self): "shellcompletion", "complete", shell=shell, - cmd_str=f"closepoll{self.typer} ", + command=f"closepoll{self.typer} ", ) result2 = StringIO() with contextlib.redirect_stdout(result2): @@ -65,7 +65,7 @@ def test_poll_complete(self): "shellcompletion", "complete", shell=shell, - cmd_str=f"./manage.py closepoll{self.typer} ", + command=f"./manage.py closepoll{self.typer} ", ) result = result1.getvalue()