import collections.abc as cabc
import io
import sys
import typing as t
from abc import abstractmethod
from functools import cached_property
from importlib.resources import files
from pathlib import Path
from click.core import Command as ClickCommand
from click.shell_completion import CompletionItem, ShellComplete, add_completion_class
from django.template import Context, Engine
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.translation import gettext as _
__all__ = ["DjangoTyperShellCompleter", "register_completion_class"]
if t.TYPE_CHECKING: # pragma: no cover
from django_typer.management.commands.shellcompletion import (
Command as ShellCompletion,
)
[docs]
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`
:param cli: The click command object to complete
:param ctx_args: Additional context arguments to pass to the typer/click context of
the management command being completed
:param prog_name: The name of the command to complete
:param complete_var: The name of the environment variable to pass the completion
string (unused)
:param command: The Django shellcompletion command
:param command_str: The command string to complete
:param command_args: The command arguments to complete
:param template: The name of the shell completion script template
:param color: Allow or disallow color and formatting in the completion output
"""
template: str
"""
The name of the shell completion function script template.
"""
color: bool = False
"""
By default, allow or disallow color and formatting 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: "ShellCompletion"
command_str: str
command_args: t.List[str]
console = None # type: ignore[var-annotated]
rich_console = None # type: ignore[var-annotated]
console_buffer: io.StringIO
color_default: bool = True
[docs]
def __init__(
self,
cli: t.Optional[ClickCommand] = None,
ctx_args: cabc.MutableMapping[str, t.Any] = {},
prog_name: str = "",
complete_var: str = "",
command: t.Optional["ShellCompletion"] = 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,
color_default: bool = color_default,
**kwargs,
):
# we don't always need the initialization parameters during completion
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 = command_args
if template is not None:
self.template = template
if color is not None:
self.color = color
self.color_default = color_default
self.console_buffer = io.StringIO()
try:
from rich.console import Console
self.console = Console(
# do not disable color output if not explicitly disabled
color_system="auto" if self.color_default or self.color else None,
force_terminal=True,
file=command.stdout if command else None, # type: ignore[arg-type]
stderr=command.stderr if command else None, # type: ignore[arg-type]
)
self.rich_console = Console(
color_system="auto" if self.color else None,
force_terminal=True,
file=self.console_buffer,
)
except ImportError:
pass
if cli:
super().__init__(
cli=cli,
ctx_args=ctx_args,
complete_var=complete_var,
prog_name=prog_name,
**kwargs,
)
@property
def source_template(self) -> str: # type: ignore
"""
The contents of the shell's completion script template.
"""
return Path(self.load_template().origin.name).read_text()
[docs]
def get_completions(
self, args: t.List[str], incomplete: str
) -> t.List[CompletionItem]:
"""
Get the completions for the current command string and incomplete string.
"""
if self.command.fallback:
return self.command.fallback(args, incomplete)
return super().get_completions(args[1:], incomplete)
[docs]
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("")
return (
cwords[:-1],
cwords[-1] if cwords else "",
)
[docs]
def source_vars(self) -> t.Dict[str, t.Any]:
"""
This returns the context that will be used to render the completion script
template.
From the base class we inherit the following variables:
* **complete_func**: the name to use for the shell's completion function
* **complete_var**: unused because we do not use environment variables
to pass the completion string
* **prog_name**: the name of the command to complete
We add the following variables:
* **manage_script**: the manage script object - will be either a string or a
Path, if it is a Path it will be absolute and this indicates that the script
is not installed on the path
* **manage_script_name**: the name of the Django manage script
* **python**: the path to the python interpreter that is running the
shellcompletion command
* **django_command**: the name of the Django shellcompletion command - you may
change this to something other than 'shellcompletion' to provide custom
complete logic
* **color**: the color flag to pass to shellcompletion i.e. --(force|no)-color
* **fallback**: the fallback option to pass to ``shellcompletion complete``
* **is_installed**: whether or not the manage script is a command on the path
* **shell**: the name of the shell
"""
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": "--force-color"
if self.color
else "--no-color"
if self.command.force_color
else "",
"fallback": f"--fallback={self.command.fallback_import}"
if self.command.fallback
else "",
"is_installed": self.is_installed,
"shell": self.name,
}
@cached_property
def is_installed(self) -> bool:
"""
Whether or not the manage script is a command on the path.
"""
return not isinstance(self.command.manage_script, Path)
[docs]
def load_template(self) -> t.Union[BaseTemplate, DjangoTemplate]:
"""
Return a compiled Template object for the completion script template.
"""
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)
[docs]
def source(self) -> str:
"""
Render the completion script template to a string.
"""
try:
return self.load_template().render(self.source_vars()) # type: ignore
except (AttributeError, TypeError, ValueError):
# it is annoying that get_template() and DjangoEngine.get_template() return
# different interfaces
return self.load_template().render(Context(self.source_vars())) # type: ignore
[docs]
@abstractmethod
def install(self, prompt: bool = True) -> t.List[Path]:
"""
Deriving classes must implement this method to install the completion script.
This method should return the path to the installed script.
:param prompt: If True, prompt the user for confirmation before installing
the completion script.
:return: The paths that were created or edited or None if the user declined
installation or no changes were made.
"""
[docs]
@abstractmethod
def uninstall(self):
"""
Deriving classes must implement this method to uninstall the completion script.
"""
[docs]
@abstractmethod
def get_user_profile(self) -> Path:
"""
Most shells have a profile script that is sourced when the interactive shell
starts. Deriving classes should implement this method to return the location
of that script.
"""
[docs]
def prompt(
self,
source: str,
file: Path,
prompt: bool = True,
start_line: int = 0,
) -> bool:
"""
Prompt the user for confirmation before editing the file with the given
source edits.
:param source: The source string that will be written to the file.
:param file: The path of the file that will be created or edited.
:param prompt: Prompt toggle, if False, will not prompt the user and return True
:param start_line: The line number the edit will start at.
:return: True if the user confirmed the edit, False otherwise.
"""
if not prompt:
return True
prompt_text = (
_("Append the above contents to {file}?").format(file=file)
if start_line
else _("Create {file} with the above contents?").format(file=file)
)
if self.console:
from rich.prompt import Confirm
from rich.syntax import Syntax
syntax = Syntax(
source,
self.name,
theme="monokai",
start_line=start_line,
line_numbers=False,
)
self.console.print(syntax)
return Confirm.ask(prompt_text, console=self.console)
else:
print(source)
return input(prompt_text + " [y/N] ").lower() in {"y", "yes"}
[docs]
def process_rich_text(self, text: str) -> str:
"""
Removes rich text markup from a string if color is disabled, otherwise it
will render the rich markup to ansi control codes. If rich is not installed,
none of this happens and the markup will be passed through as is.
"""
if self.rich_console:
if self.color:
self.console_buffer.truncate(0)
self.console_buffer.seek(0)
self.rich_console.print(text, end="")
return self.console_buffer.getvalue()
else:
return "".join(
segment.text for segment in self.rich_console.render(text)
).rstrip("\n")
return text
_completers: t.Dict[str, t.Type[DjangoTyperShellCompleter]] = {}
[docs]
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)