Skip to content

Commit c2632bf

Browse files
committed
fix #189
1 parent fe0ed31 commit c2632bf

File tree

11 files changed

+283
-41
lines changed

11 files changed

+283
-41
lines changed

doc/source/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Change Log
77
v3.1.0 (2024-03-xx)
88
===================
99

10+
* Implemented `Prompt before writing to dotfiles when installing completions <https://github.com/django-commons/django-typer/issues/189>`_
1011
* Fixed `Shell completion tests let failures through in CI <https://github.com/django-commons/django-typer/issues/194>`_
1112
* Fixed `fish completion installs should respect XDG_CONFIG_HOME <https://github.com/django-commons/django-typer/issues/193>`_
1213
* Fixed `zsh completion installs should respect ZDOTDIR <https://github.com/django-commons/django-typer/issues/192>`_

doc/source/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
from sphinx.ext.autodoc import between
66
import shutil
77
import django
8+
from django.conf import settings
89

910
sys.path.append(str(Path(__file__).parent.parent.parent))
1011
sys.path.append(str(Path(__file__).parent / 'ext'))
1112

1213
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings.base')
14+
settings.configure()
1315
django.setup()
1416

1517
import django_typer

doc/source/shell_completion.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ project simply run the install command:
134134
135135
./manage.py shellcompletion install
136136
137+
**The install command will list the precise edits to dotfiles and shell configuration files that it
138+
will make and ask for permission before proceeding. To skip the prompt use** ``--no-prompt``.
139+
137140
.. note::
138141

139142
The manage script may be named differently in your project - this is fine. The only requirement

justfile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,28 @@ check-docs-links: _link_check
160160
check-docs:
161161
@just run doc8 --ignore-path ./doc/build --max-line-length 100 -q ./doc
162162

163+
# fetch the intersphinx references for the given package
164+
[script]
165+
fetch-refs LIB: install-docs
166+
import os
167+
from pathlib import Path
168+
import logging as _logging
169+
import sys
170+
import runpy
171+
from sphinx.ext.intersphinx import inspect_main
172+
_logging.basicConfig()
173+
174+
libs = runpy.run_path(Path(os.getcwd()) / "doc/source/conf.py").get("intersphinx_mapping")
175+
url = libs.get("{{ LIB }}", None)
176+
if not url:
177+
sys.exit(f"Unrecognized {{ LIB }}, must be one of: {', '.join(libs.keys())}")
178+
if url[1] is None:
179+
url = f"{url[0].rstrip('/')}/objects.inv"
180+
else:
181+
url = url[1]
182+
183+
raise SystemExit(inspect_main([url]))
184+
163185
# lint the code
164186
check-lint:
165187
@just run ruff check --select I
@@ -242,26 +264,38 @@ list-missed-tests: install log-tests test-all
242264
[script("bash")]
243265
test-bash:
244266
source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_shell_resolution.py::TestShellResolution::test_bash tests/test_parser_completers.py tests/shellcompletion/test_bash.py
267+
uv pip uninstall rich
268+
source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_bash.py::BashExeTests::test_prompt_install
245269

246270
# test zsh shell completions
247271
[script("zsh")]
248272
test-zsh:
249273
source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_shell_resolution.py::TestShellResolution::test_zsh tests/test_parser_completers.py tests/shellcompletion/test_zsh.py
274+
uv pip uninstall rich
275+
source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_zsh.py::ZshExeTests::test_prompt_install
250276

251277
# test powershell shell completions
252278
[script("powershell")]
253279
test-powershell:
254280
.venv/Scripts/activate.ps1; pytest --cov-append tests/shellcompletion/test_shell_resolution.py::TestShellResolution::test_powershell tests/test_parser_completers.py tests/test_parser_completers.py tests/shellcompletion/test_powershell.py::PowerShellTests tests/shellcompletion/test_powershell.py::PowerShellExeTests
281+
# TODO - not implemented on windows
282+
# uv pip uninstall rich
283+
# source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_powershell.py::PowerShellExeTests::test_prompt_install
255284

256285
# test pwsh shell completions
257286
[script("pwsh")]
258287
test-pwsh:
259288
.venv/Scripts/activate.ps1; pytest --cov-append tests/shellcompletion/test_shell_resolution.py::TestShellResolution::test_pwsh tests/test_parser_completers.py tests/shellcompletion/test_powershell.py::PWSHTests tests/shellcompletion/test_powershell.py::PWSHExeTests
289+
# TODO - not implemented on windows
290+
# uv pip uninstall rich
291+
# source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_powershell.py::PWSHExeTests::test_prompt_install
260292

261293
# test fish shell completions
262294
[script("fish")]
263295
test-fish:
264296
source .venv/bin/activate.fish && pytest --cov-append tests/shellcompletion/test_shell_resolution.py::TestShellResolution::test_fish tests/test_parser_completers.py tests/shellcompletion/test_fish.py
297+
uv pip uninstall rich
298+
source .venv/bin/activate && pytest --cov-append tests/shellcompletion/test_fish.py::FishExeTests::test_prompt_install
265299

266300
# run tests
267301
test *TESTS:

src/django_typer/management/commands/shellcompletion.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from pathlib import Path
3030
from types import ModuleType
3131

32+
from click import get_current_context
33+
from click.core import ParameterSource
3234
from click.parser import split_arg_string
3335
from click.shell_completion import CompletionItem
3436
from django.core.management import CommandError, ManagementUtility
@@ -122,6 +124,8 @@ class Command(TyperCommand):
122124
_fallback: t.Optional[t.Callable[[t.List[str], str], t.List[CompletionItem]]] = None
123125
_manage_script: t.Optional[t.Union[str, Path]] = None
124126

127+
color_default: bool = True
128+
125129
@property
126130
def fallback(
127131
self,
@@ -264,6 +268,15 @@ def init(
264268
self.no_color = (not self.shell_class.color) if no_color is None else no_color
265269
if self.force_color:
266270
self.no_color = False
271+
ctx = get_current_context(silent=True)
272+
self.color_default = (
273+
(
274+
ctx.get_parameter_source("no_color") is ParameterSource.DEFAULT
275+
and ctx.get_parameter_source("force_color") is ParameterSource.DEFAULT
276+
)
277+
if ctx
278+
else False
279+
)
267280
return self
268281

269282
@command(
@@ -309,7 +322,17 @@ def install(
309322
# todo - add template name completer - see django-render-static
310323
),
311324
] = None,
312-
):
325+
prompt: t.Annotated[
326+
bool,
327+
Option(
328+
"--no-prompt",
329+
help=t.cast(
330+
str,
331+
_("Do not ask for conformation before editing dotfiles."),
332+
),
333+
),
334+
] = True,
335+
) -> t.Optional[t.List[Path]]:
313336
"""
314337
Install autocompletion for the given shell. If the shell is not specified, it
315338
will try to detect the shell. If the shell is not detected, it will fail.
@@ -319,6 +342,8 @@ def install(
319342
.. typer:: django_typer.management.commands.shellcompletion.Command:typer_app:install
320343
:width: 85
321344
:convert-png: latex
345+
346+
Returns the list of edited and/or created paths or None if no edits were made.
322347
"""
323348
self.fallback = fallback # type: ignore[assignment]
324349
self.manage_script = manage_script # type: ignore[assignment]
@@ -340,17 +365,26 @@ def install(
340365
)
341366
)
342367

343-
install_path = self.shell_class(
368+
install_paths = self.shell_class(
344369
prog_name=str(manage_script or self.manage_script_name),
345370
command=self,
346371
template=template,
347372
color=not self.no_color or self.force_color,
348-
).install()
349-
self.stdout.write(
350-
self.style.SUCCESS(
351-
f"Installed autocompletion for {self.shell} @ {install_path}"
373+
color_default=self.color_default,
374+
).install(prompt=prompt)
375+
if install_paths:
376+
self.stdout.write(
377+
self.style.SUCCESS(
378+
_("Installed autocompletion for {shell}").format(shell=self.shell)
379+
)
352380
)
353-
)
381+
else:
382+
self.stdout.write(
383+
self.style.ERROR(
384+
t.cast(str, _("Aborted shell completion installation."))
385+
)
386+
)
387+
return install_paths or None
354388

355389
@command(
356390
help=t.cast(str, _("Uninstall autocompletion for the current or given shell."))
@@ -389,6 +423,7 @@ def uninstall(
389423
prog_name=str(manage_script or self.manage_script_name),
390424
command=self,
391425
color=not self.no_color or self.force_color,
426+
color_default=self.color_default,
392427
).uninstall()
393428
self.stdout.write(
394429
self.style.WARNING(f"Uninstalled autocompletion for {self.shell}.")
@@ -490,6 +525,7 @@ def get_completion() -> str:
490525
command_str=command,
491526
command_args=args,
492527
color=not self.no_color or self.force_color,
528+
color_default=self.color_default,
493529
).complete()
494530

495531
# only try to set the fallback if we have to use it
@@ -499,6 +535,7 @@ def get_completion() -> str:
499535
command_str=command,
500536
command_args=args,
501537
color=not self.no_color or self.force_color,
538+
color_default=self.color_default,
502539
).complete()
503540

504541
def strip_color(text: str) -> str:

src/django_typer/shells/__init__.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.template.backends.django import Template as DjangoTemplate
1414
from django.template.base import Template as BaseTemplate
1515
from django.template.loader import TemplateDoesNotExist, get_template
16+
from django.utils.translation import gettext as _
1617

1718
if t.TYPE_CHECKING: # pragma: no cover
1819
from django_typer.management.commands.shellcompletion import (
@@ -63,8 +64,12 @@ class with
6364
command_args: t.List[str]
6465

6566
console = None # type: ignore[var-annotated]
67+
68+
rich_console = None # type: ignore[var-annotated]
6669
console_buffer: io.StringIO
6770

71+
color_default: bool = True
72+
6873
def __init__(
6974
self,
7075
cli: t.Optional[ClickCommand] = None,
@@ -76,6 +81,7 @@ def __init__(
7681
command_args: t.Optional[t.List[str]] = None,
7782
template: t.Optional[str] = None,
7883
color: t.Optional[bool] = None,
84+
color_default: bool = color_default,
7985
**kwargs,
8086
):
8187
# we don't always need the initialization parameters during completion
@@ -90,12 +96,20 @@ def __init__(
9096
self.template = template
9197
if color is not None:
9298
self.color = color
99+
self.color_default = color_default
93100

94101
self.console_buffer = io.StringIO()
95102
try:
96103
from rich.console import Console
97104

98105
self.console = Console(
106+
# do not disable color output if not explicitly disabled
107+
color_system="auto" if self.color_default or self.color else None,
108+
force_terminal=True,
109+
file=command.stdout if command else None, # type: ignore[arg-type]
110+
stderr=command.stderr if command else None, # type: ignore[arg-type]
111+
)
112+
self.rich_console = Console(
99113
color_system="auto" if self.color else None,
100114
force_terminal=True,
101115
file=self.console_buffer,
@@ -224,11 +238,16 @@ def source(self) -> str:
224238
return self.load_template().render(Context(self.source_vars())) # type: ignore
225239

226240
@abstractmethod
227-
def install(self) -> Path:
241+
def install(self, prompt: bool = True) -> t.List[Path]:
228242
"""
229243
Deriving classes must implement this method to install the completion script.
230244
231245
This method should return the path to the installed script.
246+
247+
:param prompt: If True, prompt the user for confirmation before installing
248+
the completion script.
249+
:return: The paths that were created or edited or None if the user declined
250+
installation or no changes were made.
232251
"""
233252

234253
@abstractmethod
@@ -245,21 +264,63 @@ def get_user_profile(self) -> Path:
245264
of that script.
246265
"""
247266

267+
def prompt(
268+
self,
269+
source: str,
270+
file: Path,
271+
prompt: bool = True,
272+
start_line: int = 0,
273+
) -> bool:
274+
"""
275+
Prompt the user for confirmation before editing the file with the given
276+
source edits.
277+
278+
:param source: The source string that will be written to the file.
279+
:param file: The path of the file that will be created or edited.
280+
:param prompt: Prompt toggle, if False, will not prompt the user and return True
281+
:param start_line: The line number the edit will start at.
282+
:return: True if the user confirmed the edit, False otherwise.
283+
"""
284+
if not prompt:
285+
return True
286+
287+
prompt_text = (
288+
_("Append the above contents to {file}?").format(file=file)
289+
if start_line
290+
else _("Create {file} with the above contents?").format(file=file)
291+
)
292+
if self.console:
293+
from rich.prompt import Confirm
294+
from rich.syntax import Syntax
295+
296+
syntax = Syntax(
297+
source,
298+
self.name,
299+
theme="monokai",
300+
start_line=start_line,
301+
line_numbers=False,
302+
)
303+
self.console.print(syntax)
304+
return Confirm.ask(prompt_text)
305+
else:
306+
print(source)
307+
return input(prompt_text + " [y/N] ").lower() in {"y", "yes"}
308+
248309
def process_rich_text(self, text: str) -> str:
249310
"""
250311
Removes rich text markup from a string if color is disabled, otherwise it
251312
will render the rich markup to ansi control codes. If rich is not installed,
252313
none of this happens and the markup will be passed through as is.
253314
"""
254-
if self.console:
315+
if self.rich_console:
255316
if self.color:
256317
self.console_buffer.truncate(0)
257318
self.console_buffer.seek(0)
258-
self.console.print(text, end="")
319+
self.rich_console.print(text, end="")
259320
return self.console_buffer.getvalue()
260321
else:
261322
return "".join(
262-
segment.text for segment in self.console.render(text)
323+
segment.text for segment in self.rich_console.render(text)
263324
).rstrip("\n")
264325
return text
265326

src/django_typer/shells/bash.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,32 @@ def source_vars(self) -> t.Dict[str, t.Any]:
8686
def format_completion(self, item: CompletionItem) -> str:
8787
return f"{item.type},{item.value}"
8888

89-
def install(self) -> Path:
89+
def install(self, prompt: bool = True) -> t.List[Path]:
9090
assert self.prog_name
91-
Path.home().mkdir(parents=True, exist_ok=True)
91+
edited = []
9292
script = self.install_dir / f"{self.prog_name}.sh"
9393
bashrc = self.get_user_profile()
9494
bashrc_source = bashrc.read_text() if bashrc.is_file() else ""
9595
source_line = f"source {script}"
9696
if source_line not in bashrc_source:
9797
bashrc_source += f"\n{source_line}\n"
98-
bashrc.write_text(bashrc_source)
99-
script.parent.mkdir(parents=True, exist_ok=True)
100-
script.write_text(self.source())
101-
return script
98+
if self.prompt(
99+
prompt=prompt,
100+
source=source_line,
101+
file=bashrc,
102+
start_line=bashrc_source.count("\n") + 1,
103+
):
104+
Path.home().mkdir(parents=True, exist_ok=True)
105+
with open(bashrc, "a") as f:
106+
f.write(f"\n{source_line}\n")
107+
edited.append(bashrc)
108+
109+
source = self.source()
110+
if self.prompt(prompt=prompt, source=source, file=script):
111+
script.parent.mkdir(parents=True, exist_ok=True)
112+
script.write_text(self.source())
113+
edited.append(script)
114+
return edited
102115

103116
def uninstall(self):
104117
assert self.prog_name

0 commit comments

Comments
 (0)