Skip to content

Commit

Permalink
add tests for terminal formatting in shell completion #156 #152
Browse files Browse the repository at this point in the history
  • Loading branch information
bckohan committed Dec 19, 2024
1 parent 775fe15 commit d6401e9
Show file tree
Hide file tree
Showing 5 changed files with 85 additions and 27 deletions.
16 changes: 15 additions & 1 deletion django_typer/management/commands/shellcompletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/apps/test_app/management/commands/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 58 additions & 8 deletions tests/shellcompletion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -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)

Expand All @@ -75,7 +80,7 @@ def setUp(self):
super().setUp()

def tearDown(self):
self.remove()
# self.remove()
super().tearDown()

def verify_install(self, script=None):
Expand All @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -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", " ")
Expand All @@ -193,14 +202,41 @@ 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)
self.assertIn("adapted", completions)
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()
Expand All @@ -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):
"""
Expand Down
4 changes: 2 additions & 2 deletions tests/shellcompletion/test_zsh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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"
24 changes: 9 additions & 15 deletions tests/test_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit d6401e9

Please sign in to comment.