diff --git a/django_typer/management/commands/shellcompletion.py b/django_typer/management/commands/shellcompletion.py index 0787f91..718ee3a 100644 --- a/django_typer/management/commands/shellcompletion.py +++ b/django_typer/management/commands/shellcompletion.py @@ -431,10 +431,24 @@ def init( rich_help_panel=COMMON_PANEL, ), ] = None, + force_color: t.Annotated[ + bool, + Option( + "--force-color", + help=t.cast( + str, + _( + "Allow terminal formatting control sequences in completion text." + ), + ), + rich_help_panel=COMMON_PANEL, + ), + ] = False, ) -> "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.force_color = force_color + self.no_color = (not self.shell_class.color) if no_color is None else no_color if self.force_color: self.no_color = False return self diff --git a/tests/apps/test_app/management/commands/completion.py b/tests/apps/test_app/management/commands/completion.py index 1fb008e..3b3d7cf 100644 --- a/tests/apps/test_app/management/commands/completion.py +++ b/tests/apps/test_app/management/commands/completion.py @@ -82,7 +82,7 @@ def handle( typer.Option( "--cmd-first", help=_( - "A list of [yellow][underline]commands[/underline][/yellow] by import path or name." + "A list of [yellow][underline]commands[/underline][/yellow] by either import path or name." ), shell_complete=completers.chain( completers.complete_import_path, diff --git a/tests/shellcompletion/__init__.py b/tests/shellcompletion/__init__.py index 5087f39..fbaa421 100644 --- a/tests/shellcompletion/__init__.py +++ b/tests/shellcompletion/__init__.py @@ -10,11 +10,16 @@ import time import typing as t from pathlib import Path +import pytest +from functools import cached_property from shellingham import detect_shell +from django.test import TestCase +from django_typer.utils import with_typehint from django_typer.management import get_command from django_typer.management.commands.shellcompletion import Command as ShellCompletion +from ..utils import rich_installed default_shell = None @@ -55,7 +60,7 @@ def scrub(output: str) -> str: ) -class _DefaultCompleteTestCase: +class _DefaultCompleteTestCase(with_typehint(TestCase)): shell = None manage_script = "manage.py" launch_script = "./manage.py" @@ -66,7 +71,7 @@ def interactive_opt(self): # this includes zsh, bash, fish and powershell return "-i" - @property + @cached_property def command(self) -> ShellCompletion: return get_command("shellcompletion", ShellCompletion) @@ -75,7 +80,7 @@ def setUp(self): super().setUp() def tearDown(self): - self.remove() + # self.remove() super().tearDown() def verify_install(self, script=None): @@ -84,14 +89,16 @@ def verify_install(self, script=None): def verify_remove(self, script=None): pass - def install(self, script=None): + def install(self, script=None, force_color=False, no_color=None): if not script: script = self.manage_script + init_kwargs = {"force_color": force_color, "no_color": no_color} kwargs = {} if script: kwargs["manage_script"] = script if self.shell: - self.command.init(shell=self.shell) + init_kwargs["shell"] = self.shell + self.command.init(**init_kwargs) self.command.install(**kwargs) self.verify_install(script=script) @@ -113,7 +120,7 @@ def set_environment(self, fd): f'DJANGO_SETTINGS_MODULE={os.environ["DJANGO_SETTINGS_MODULE"]}\n'.encode(), ) - def get_completions(self, *cmds: str) -> t.List[str]: + def get_completions(self, *cmds: str, scrub_output=True) -> str: def read(fd): """Function to read from a file descriptor.""" return os.read(fd, 1024 * 1024).decode() @@ -172,7 +179,9 @@ def read(fd): process.terminate() process.wait() # remove bell character which can show up in some terminals where we hit tab - return scrub(output) + if scrub_output: + return scrub(output) + return output def run_app_completion(self): completions = self.get_completions(self.launch_script, "completion", " ") @@ -193,7 +202,7 @@ def run_bad_command_completion(self): def run_command_completion(self): completions = self.get_completions(self.launch_script, "complet") - # annoingly in CI there are some spaces inserted between the incomplete phrase + # annoyingly in CI there are some spaces inserted between the incomplete phrase # and the completion on linux in bash specifically self.assertTrue(re.match(r".*complet\s*ion.*", completions)) completions = self.get_completions(self.launch_script) @@ -201,6 +210,33 @@ def run_command_completion(self): self.assertIn("help_precedence", completions) self.assertIn("closepoll", completions) + def run_rich_option_completion(self, rich_output_expected: bool): + completions = self.get_completions( + self.launch_script, "completion", "--cmd", scrub_output=False + ) + self.assertIn("--cmd", completions) + self.assertIn("--cmd-first", completions) + self.assertIn("--cmd-dup", completions) + if not rich_installed: + self.assertIn("[bold]", completions) + self.assertIn("[/bold]", completions) + self.assertIn("[reverse]", completions) + self.assertIn("[/reverse]", completions) + self.assertIn("[underline]", completions) + self.assertIn("[/underline]", completions) + self.assertIn("[yellow]", completions) + self.assertIn("[/yellow]", completions) + elif rich_output_expected: + self.assertIn("\x1b[7mcommands\x1b[0m", completions) + self.assertIn("\x1b[4;33mcommands\x1b[0m", completions) + self.assertIn("\x1b[1mimport path\x1b[0m", completions) + self.assertIn("\x1b[1mname\x1b[0m", completions) + else: + self.assertNotIn("\x1b[7mcommands\x1b[0m", completions) + self.assertNotIn("\x1b[4;33mcommands\x1b[0m", completions) + self.assertNotIn("\x1b[1mimport path\x1b[0m", completions) + self.assertNotIn("\x1b[1mname\x1b[0m", completions) + def test_shell_complete(self): with self.assertRaises(AssertionError): self.run_app_completion() @@ -213,6 +249,20 @@ def test_shell_complete(self): self.run_app_completion() self.install() + @pytest.mark.rich + @pytest.mark.no_rich + def test_rich_output(self): + self.install(force_color=True) + self.run_rich_option_completion(rich_output_expected=True) + self.remove() + + @pytest.mark.rich + @pytest.mark.skipif(not rich_installed, reason="Rich not installed") + def test_no_rich_output(self): + self.install(no_color=True) + self.run_rich_option_completion(rich_output_expected=False) + # self.remove() + class _InstalledScriptTestCase(_DefaultCompleteTestCase): """ diff --git a/tests/shellcompletion/test_zsh.py b/tests/shellcompletion/test_zsh.py index 9c79711..4a1f6e5 100644 --- a/tests/shellcompletion/test_zsh.py +++ b/tests/shellcompletion/test_zsh.py @@ -8,7 +8,7 @@ @pytest.mark.skipif(shutil.which("zsh") is None, reason="Z-Shell not available") -class ZshShellTests(_DefaultCompleteTestCase, TestCase): +class ZshTests(_DefaultCompleteTestCase, TestCase): shell = "zsh" directory = Path("~/.zfunc").expanduser() @@ -24,5 +24,5 @@ def verify_remove(self, script=None): @pytest.mark.skipif(shutil.which("zsh") is None, reason="Z-Shell not available") -class ZshExeShellTests(_InstalledScriptTestCase, ZshShellTests, TestCase): +class ZshExeTests(_InstalledScriptTestCase, ZshTests, TestCase): shell = "zsh" diff --git a/tests/test_groups.py b/tests/test_groups.py index 85b1bf4..c8f6542 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -110,21 +110,15 @@ def test_helps(self, app="test_app"): cmd.print_help("./manage.py", *cmds) hlp = buffer.getvalue() helps_dir = "helps" if rich_installed else "helps_no_rich" - try: - self.assertGreater( - sim := similarity( - hlp, - ( - TESTS_DIR / "apps" / app / helps_dir / f"{cmds[-1]}.txt" - ).read_text(encoding="utf-8"), - ), - 0.99, # width inconsistences drive this number < 1 - ) - except AssertionError: - import pdb - - pdb.set_trace() - raise + self.assertGreater( + sim := similarity( + hlp, + ( + TESTS_DIR / "apps" / app / helps_dir / f"{cmds[-1]}.txt" + ).read_text(encoding="utf-8"), + ), + 0.99, # width inconsistences drive this number < 1 + ) print(f'{app}: {" ".join(cmds)} = {sim:.2f}') cmd = get_command(cmds[0], stdout=buffer, force_color=True)