From 93124989dd630597409b39f63ccb5dc0ac489a20 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 18 Dec 2024 11:35:39 -0800 Subject: [PATCH] more work on #156 --- django_typer/completers.py | 2 +- .../management/commands/shellcompletion.py | 180 ++++++++++++------ .../management/commands/shells/bash.py | 2 +- .../management/commands/shells/fish.py | 2 +- .../management/commands/shells/powershell.py | 2 +- .../management/commands/shells/zsh.py | 5 +- .../shell_complete}/bash.sh | 0 .../shell_complete}/fish.fish | 0 .../shell_complete}/powershell.ps1 | 0 .../shell_complete}/zsh.sh | 26 +-- doc/source/changelog.rst | 5 +- justfile | 1 + .../management/commands/completion.py | 10 +- tests/shellcompletion/__init__.py | 12 +- 14 files changed, 161 insertions(+), 86 deletions(-) rename django_typer/{management/commands/shells => templates/shell_complete}/bash.sh (100%) rename django_typer/{management/commands/shells => templates/shell_complete}/fish.fish (100%) rename django_typer/{management/commands/shells => templates/shell_complete}/powershell.ps1 (100%) rename django_typer/{management/commands/shells => templates/shell_complete}/zsh.sh (72%) diff --git a/django_typer/completers.py b/django_typer/completers.py index 9b7729c..99ddcad 100644 --- a/django_typer/completers.py +++ b/django_typer/completers.py @@ -463,7 +463,7 @@ def complete_import_path( completions.append( CompletionItem( f'{module_import}{"." if module_import else ""}{module.name}', - type="dir", + type="plain", ) ) if len(completions) == 1 and not completions[0].value.endswith("."): diff --git a/django_typer/management/commands/shellcompletion.py b/django_typer/management/commands/shellcompletion.py index 787499e..0530694 100644 --- a/django_typer/management/commands/shellcompletion.py +++ b/django_typer/management/commands/shellcompletion.py @@ -25,7 +25,6 @@ import re import sys import typing as t -import warnings from functools import cached_property from importlib.resources import files from pathlib import Path @@ -40,7 +39,9 @@ ) from django.core.management import CommandError, ManagementUtility from django.template import Context, Engine -from django.utils.functional import classproperty +from django.template.backends.django import Template as DjangoTemplate +from django.template.base import Template as BaseTemplate +from django.template.loader import TemplateDoesNotExist, get_template from django.utils.module_loading import import_string from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ @@ -70,14 +71,14 @@ class with :func:`~django_typer.management.commands.shellcompletion.register_completion_class` """ - SCRIPT: Path + template: str """ - The path to the shell completion script template. + The name of the shell completion function script template. """ color: bool = False """ - By default, allow or disallow color in the completion output. + By default, allow or disallow color and formatting in the completion output. """ supports_scripts: bool = False @@ -91,6 +92,9 @@ class with command_str: str command_args: t.List[str] + console = None # type: ignore[var-annotated] + console_buffer: io.StringIO + def __init__( self, cli: t.Optional[ClickCommand] = None, @@ -100,16 +104,34 @@ def __init__( command: t.Optional["Command"] = None, command_str: t.Optional[str] = None, command_args: t.Optional[t.List[str]] = None, + template: t.Optional[str] = None, + color: t.Optional[bool] = None, **kwargs, ): # we don't always need the initialization parameters during completion - self.prog_name = kwargs.pop("prog_name", "") + self.prog_name = 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 [] + self.command_args = command_args + if template is not None: + self.template = template + if color is not None: + self.color = color + + self.console_buffer = io.StringIO() + try: + from rich.console import Console + + self.console = Console( + color_system="auto" if self.color else None, + force_terminal=True, + file=self.console_buffer, + ) + except ImportError: + pass if cli: super().__init__( @@ -120,9 +142,9 @@ def __init__( **kwargs, ) - @classproperty + @property def source_template(self) -> str: # type: ignore - return self.SCRIPT.read_text() + return Path(self.load_template().origin.name).read_text() def get_completions( self, args: t.List[str], incomplete: str @@ -135,19 +157,13 @@ def get_completions( return super().get_completions(args[1:], incomplete) def get_completion_args(self) -> t.Tuple[t.List[str], str]: + """ + Return the list of completion arguments and the incomplete string. + """ cwords = self.command_args if self.command_str and self.command_str[-1].isspace(): + # if the command string ends with a space, the incomplete string is empty 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 "", @@ -157,40 +173,47 @@ def source_vars(self) -> t.Dict[str, t.Any]: return { **super().source_vars(), "manage_script": self.command.manage_script, + "manage_script_name": self.command.manage_script_name, "python": sys.executable, "django_command": self.command.__module__.split(".")[-1], - "color": "--no-color" - if self.command.no_color - else "--force-color" + "color": "--force-color" + if self.color + else "--no-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): + def load_template(self) -> t.Union[BaseTemplate, DjangoTemplate]: """ - Django template engine that will find and render completer script templates. + Return a compiled Template object for the completion script template. """ - return Engine( - dirs=[str(files("django_typer.management.commands").joinpath("shells"))], - libraries={ - "default": "django.template.defaulttags", - "filter": "django.template.defaultfilters", - }, - ) + try: + return get_template(self.template) # type: ignore + except TemplateDoesNotExist: + # handle case where templating is not configured to find our default + # templates + return Engine( + dirs=[str(files("django_typer").joinpath("templates"))], + libraries={ + "default": "django.template.defaulttags", + "filter": "django.template.defaultfilters", + }, + ).get_template(self.template) 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()) - ) + try: + return self.load_template().render(self.source_vars()) # type: ignore + except TypeError: + # it is annoying that get_template() and DjangoEngine.get_template() return different + # interfaces + return self.load_template().render(Context(self.source_vars())) # type: ignore def install(self): raise NotImplementedError @@ -198,6 +221,19 @@ def install(self): def uninstall(self): raise NotImplementedError + def process_rich_text(self, text: str) -> str: + if self.console: + if self.color: + self.console_buffer.truncate(0) + self.console_buffer.seek(0) + self.console.print(text, end="") + return self.console_buffer.getvalue() + else: + return "".join( + segment.text for segment in self.console.render(text) + ).rstrip("\n") + return text + _completers: t.Dict[str, t.Type[DjangoTyperShellCompleter]] = {} @@ -269,8 +305,6 @@ class Command(TyperCommand): "verbosity", } - allow_rich: bool = False - _shell: t.Optional[str] = DETECTED_SHELL shell_module: ModuleType @@ -383,21 +417,21 @@ def init( t.Optional[bool], Option( "--no-color", - help=t.cast(str, _("Filter color codes out of completion text.")), + help=t.cast( + str, + _( + "Filter terminal formatting control sequences out of completion text." + ), + ), rich_help_panel=COMMON_PANEL, ), ] = None, - 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 + if self.force_color: + self.no_color = False return self @command( @@ -429,6 +463,17 @@ def install( ) ), ] = None, + template: t.Annotated[ + t.Optional[str], + Option( + help=t.cast( + str, + _( + "The name of the template to use for the shell completion script." + ), + ) + ), + ] = None, ): """ Install autocompletion for the given shell. If the shell is not specified, it will @@ -441,10 +486,7 @@ def install( :convert-png: latex """ self.fallback = fallback # type: ignore[assignment] - if ( - isinstance(self.manage_script, Path) - and not self.shell_class.supports_scripts - ): + if isinstance(self.manage_script, Path): if not self.shell_class.supports_scripts: raise CommandError( gettext( @@ -458,14 +500,25 @@ def install( ) ) else: - warnings.warn( - gettext( - "It is not recommended to install tab completion for a script not on the path." + self.stdout.write( + self.style.WARNING( + gettext( + "It is not recommended to install tab completion for a script not on " + "the path because completions will likely only work if the script is " + "invoked from the same location and using the same relative path. You " + "may wish to create an entry point for {script_name}. See {link}." + ).format( + script_name=self.manage_script_name, + link="https://setuptools.pypa.io/en/latest/userguide/entry_point.html", + ), ) ) install_path = self.shell_class( - prog_name=str(manage_script or self.manage_script_name), command=self + prog_name=str(manage_script or self.manage_script_name), + command=self, + template=template, + color=not self.no_color or self.force_color, ).install() self.stdout.write( self.style.SUCCESS( @@ -507,7 +560,9 @@ def uninstall( """ self.shell_class( - prog_name=str(manage_script or self.manage_script_name), command=self + prog_name=str(manage_script or self.manage_script_name), + command=self, + color=not self.no_color or self.force_color, ).uninstall() self.stdout.write( self.style.WARNING( @@ -565,6 +620,15 @@ def complete( :convert-png: latex """ args = split_arg_string(command) + if args: + try: + # lop the manage script off the front if it's there + if args[0] == self.manage_script_name: + args = args[1:] + elif Path(args[0]).resolve() == Path(sys.argv[0]).resolve(): + args = args[1:] + except (TypeError, ValueError, OSError): # pragma: no cover + pass def get_completion() -> str: if args: @@ -597,12 +661,16 @@ def get_completion() -> str: command=self, command_str=command, command_args=args, + color=not self.no_color or self.force_color, ).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 + command=self, + command_str=command, + command_args=args, + color=not self.no_color or self.force_color, ).complete() def strip_color(text: str) -> str: diff --git a/django_typer/management/commands/shells/bash.py b/django_typer/management/commands/shells/bash.py index 536a725..38a7d3a 100644 --- a/django_typer/management/commands/shells/bash.py +++ b/django_typer/management/commands/shells/bash.py @@ -12,7 +12,7 @@ class BashComplete(DjangoTyperShellCompleter): """ name = "bash" - SCRIPT = Path(__file__).parent / "bash.sh" + template = "shell_complete/bash.sh" @cached_property def install_dir(self) -> Path: diff --git a/django_typer/management/commands/shells/fish.py b/django_typer/management/commands/shells/fish.py index 7e3fcad..bdda3e6 100644 --- a/django_typer/management/commands/shells/fish.py +++ b/django_typer/management/commands/shells/fish.py @@ -8,7 +8,7 @@ class FishComplete(DjangoTyperShellCompleter): name = "fish" - SCRIPT = Path(__file__).parent / "fish.fish" + template = "shell_complete/fish.fish" @cached_property def install_dir(self) -> Path: diff --git a/django_typer/management/commands/shells/powershell.py b/django_typer/management/commands/shells/powershell.py index f91912f..ee8f1ec 100644 --- a/django_typer/management/commands/shells/powershell.py +++ b/django_typer/management/commands/shells/powershell.py @@ -9,7 +9,7 @@ class PowerShellComplete(DjangoTyperShellCompleter): name = "powershell" - SCRIPT = Path(__file__).parent / "powershell.ps1" + template = "shell_complete/powershell.ps1" def format_completion(self, item: CompletionItem) -> str: return f"{item.value}:::{item.help or ' '}" diff --git a/django_typer/management/commands/shells/zsh.py b/django_typer/management/commands/shells/zsh.py index 9552a36..2b52133 100644 --- a/django_typer/management/commands/shells/zsh.py +++ b/django_typer/management/commands/shells/zsh.py @@ -8,8 +8,7 @@ class ZshComplete(DjangoTyperShellCompleter): name = "zsh" - SCRIPT = Path(__file__).parent / "zsh.sh" - + template = "shell_complete/zsh.sh" supports_scripts = True @cached_property @@ -31,7 +30,7 @@ def escape(s: str) -> str: .replace(":", r"\\:") ) - return f"{item.type}\n{escape(item.value)}\n{escape(item.help) if item.help else '_'}" + return f"{item.type}\n{escape(self.process_rich_text(item.value))}\n{escape(self.process_rich_text(item.help)) if item.help else '_'}" def install(self) -> Path: assert self.prog_name diff --git a/django_typer/management/commands/shells/bash.sh b/django_typer/templates/shell_complete/bash.sh similarity index 100% rename from django_typer/management/commands/shells/bash.sh rename to django_typer/templates/shell_complete/bash.sh diff --git a/django_typer/management/commands/shells/fish.fish b/django_typer/templates/shell_complete/fish.fish similarity index 100% rename from django_typer/management/commands/shells/fish.fish rename to django_typer/templates/shell_complete/fish.fish diff --git a/django_typer/management/commands/shells/powershell.ps1 b/django_typer/templates/shell_complete/powershell.ps1 similarity index 100% rename from django_typer/management/commands/shells/powershell.ps1 rename to django_typer/templates/shell_complete/powershell.ps1 diff --git a/django_typer/management/commands/shells/zsh.sh b/django_typer/templates/shell_complete/zsh.sh similarity index 72% rename from django_typer/management/commands/shells/zsh.sh rename to django_typer/templates/shell_complete/zsh.sh index d3a4491..3bfbde8 100644 --- a/django_typer/management/commands/shells/zsh.sh +++ b/django_typer/templates/shell_complete/zsh.sh @@ -1,18 +1,11 @@ -{% 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 %} +#compdef {{ manage_script_name }} {{ complete_func }}() { local -a completions local -a completions_with_descriptions local -a response - # Extract --settings and --pythonpath options and their values if present becase + # Extract --settings and --pythonpath options and their values if present because # we need to pass these to the complete script - they may be necessary to find the command! local settings_option="" local pythonpath_option="" @@ -38,7 +31,17 @@ 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[*]}")}") + {% if not is_installed %} + if [[ ${words[2]} == *{{manage_script_name}} ]]; then + cmd="${words[1]} ${words[2]}" + else + cmd="${words[1]}" + fi + {% else %} + cmd = "{{ manage_script_name }}" + {% endif %} + + response=("${(@f)$("${cmd}" {{ django_command }} --shell zsh ${settings_option:+${settings_option}} ${pythonpath_option:+${pythonpath_option}} {{ color }} complete "${words[*]}")}") for type key descr in ${response}; do if [[ "$type" == "plain" ]]; then @@ -67,6 +70,5 @@ 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 %} + compdef {{ complete_func }} {{ manage_script_name }} fi diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index d8452b1..a3d75e8 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -36,15 +36,18 @@ Migrating from 2.x to 3.x * 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.: +* The interface to shellcompletion has changed. ``--shell`` is now an initialization option and + ``remove`` was renamed to ``uninstall``.: .. code-block:: # old interface manage shellcompletion complete --shell zsh "command string" + manage shellcompletion remove # new interface manage shellcompletion --shell zsh complete "command string" + manage shellcompletion uninstall * The function signature for :ref:`shellcompletion fallbacks ` has changed. The fallback signature is now: diff --git a/justfile b/justfile index cf45bb0..9ab448c 100644 --- a/justfile +++ b/justfile @@ -46,6 +46,7 @@ clean-env: clean-git-ignored: git clean -fdX +# remove all non repository artifacts clean: clean-docs clean-env clean-git-ignored build-docs-html: install-docs diff --git a/tests/apps/test_app/management/commands/completion.py b/tests/apps/test_app/management/commands/completion.py index 23258b3..1fb008e 100644 --- a/tests/apps/test_app/management/commands/completion.py +++ b/tests/apps/test_app/management/commands/completion.py @@ -11,7 +11,7 @@ from django_typer import completers, parsers -class Command(TyperCommand): +class Command(TyperCommand, rich_markup_mode="rich"): def handle( self, django_apps: Annotated[ @@ -59,7 +59,7 @@ def handle( t.List[str], typer.Option( "--cmd", - help=_("A command by import path or name."), + help=_("A command by [bold]import path[/bold] or [bold]name[/bold]."), shell_complete=completers.chain( completers.complete_import_path, completers.commands() ), @@ -69,7 +69,7 @@ def handle( t.List[str], typer.Option( "--cmd-dup", - help=_("A list of commands by import path or name."), + help=_("A list of [reverse]commands[/reverse] by import path or name."), shell_complete=completers.chain( completers.complete_import_path, completers.commands(allow_duplicates=True), @@ -81,7 +81,9 @@ def handle( t.List[str], typer.Option( "--cmd-first", - help=_("A list of commands by import path or name."), + help=_( + "A list of [yellow][underline]commands[/underline][/yellow] by import path or name." + ), shell_complete=completers.chain( completers.complete_import_path, completers.commands(allow_duplicates=True), diff --git a/tests/shellcompletion/__init__.py b/tests/shellcompletion/__init__.py index 63a3c89..6ff88bf 100644 --- a/tests/shellcompletion/__init__.py +++ b/tests/shellcompletion/__init__.py @@ -68,7 +68,7 @@ def interactive_opt(self): @property def command(self) -> ShellCompletion: - return get_command("shellcompletion") + return get_command("shellcompletion", ShellCompletion) def setUp(self): self.remove() @@ -88,10 +88,10 @@ def install(self, script=None): if not script: script = self.manage_script kwargs = {} - if self.shell: - kwargs["shell"] = self.shell if script: kwargs["manage_script"] = script + if self.shell: + self.command.init(shell=self.shell) self.command.install(**kwargs) self.verify_install(script=script) @@ -99,11 +99,11 @@ def remove(self, script=None): if not script: script = self.manage_script kwargs = {} - if self.shell: - kwargs["shell"] = self.shell if script: kwargs["manage_script"] = script - self.command.remove(**kwargs) + if self.shell: + self.command.init(shell=self.shell) + self.command.uninstall(**kwargs) self.verify_remove(script=script) def set_environment(self, fd):