From f698a67f8014cb2aa90ebbe9afa5f6e6f8174ea2 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Mon, 10 May 2021 09:53:31 -0400 Subject: [PATCH] Add option for user to list to-be-deleted assets when doing a sync download --- dandi/download.py | 24 ++++++++++++++++++------ dandi/tests/test_download.py | 31 +++++++++++++++++++++++++++---- dandi/utils.py | 29 +++++++++++++++++++++++++++++ setup.cfg | 2 ++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/dandi/download.py b/dandi/download.py index bcc680f68..d0f8abf32 100644 --- a/dandi/download.py +++ b/dandi/download.py @@ -8,7 +8,6 @@ import sys import time -import click import humanize import requests @@ -18,6 +17,7 @@ from . import get_logger from .support.pyout import naturalsize from .utils import ( + abbrev_prompt, ensure_datetime, find_files, flattened, @@ -132,11 +132,23 @@ def download( a_path = a_path.replace("\\", "/") if a_path not in asset_paths: to_delete.append(p) - if to_delete and click.confirm( - f"Delete {pluralize(len(to_delete), 'local asset')}?" - ): - for p in to_delete: - os.unlink(p) + if to_delete: + while True: + opt = abbrev_prompt( + f"Delete {pluralize(len(to_delete), 'local asset')}?", + "yes", + "no", + "list", + ) + if opt == "list": + for p in to_delete: + print(p) + elif opt == "yes": + for p in to_delete: + os.unlink(p) + break + else: + break def download_generator( diff --git a/dandi/tests/test_download.py b/dandi/tests/test_download.py index fda8cb95c..787dbef3f 100644 --- a/dandi/tests/test_download.py +++ b/dandi/tests/test_download.py @@ -189,14 +189,16 @@ def test_download_sync(confirm, local_dandi_api, mocker, text_dandiset, tmp_path ) dspath = tmp_path / text_dandiset["dandiset_id"] os.rename(text_dandiset["dspath"], dspath) - confirm_mock = mocker.patch("click.confirm", return_value=confirm) + confirm_mock = mocker.patch( + "dandi.download.abbrev_prompt", return_value="yes" if confirm else "no" + ) download( f"dandi://{local_dandi_api['instance_id']}/{text_dandiset['dandiset_id']}", tmp_path, existing="overwrite", sync=True, ) - confirm_mock.assert_called_with("Delete 1 local asset?") + confirm_mock.assert_called_with("Delete 1 local asset?", "yes", "no", "list") if confirm: assert not (dspath / "file.txt").exists() else: @@ -210,13 +212,34 @@ def test_download_sync_folder(local_dandi_api, mocker, text_dandiset): text_dandiset["client"].delete_asset_bypath( text_dandiset["dandiset_id"], "draft", "subdir2/banana.txt" ) - confirm_mock = mocker.patch("click.confirm", return_value=True) + confirm_mock = mocker.patch("dandi.download.abbrev_prompt", return_value="yes") download( f"dandi://{local_dandi_api['instance_id']}/{text_dandiset['dandiset_id']}/subdir2/", text_dandiset["dspath"], existing="overwrite", sync=True, ) - confirm_mock.assert_called_with("Delete 1 local asset?") + confirm_mock.assert_called_with("Delete 1 local asset?", "yes", "no", "list") assert (text_dandiset["dspath"] / "file.txt").exists() assert not (text_dandiset["dspath"] / "subdir2" / "banana.txt").exists() + + +def test_download_sync_list(capsys, local_dandi_api, mocker, text_dandiset, tmp_path): + text_dandiset["client"].delete_asset_bypath( + text_dandiset["dandiset_id"], "draft", "file.txt" + ) + dspath = tmp_path / text_dandiset["dandiset_id"] + os.rename(text_dandiset["dspath"], dspath) + input_mock = mocker.patch("dandi.utils.input", side_effect=["list", "yes"]) + download( + f"dandi://{local_dandi_api['instance_id']}/{text_dandiset['dandiset_id']}", + tmp_path, + existing="overwrite", + sync=True, + ) + assert not (dspath / "file.txt").exists() + assert input_mock.call_args_list == [ + mocker.call("Delete 1 local asset? ([y]es/[n]o/[l]ist): "), + mocker.call("Delete 1 local asset? ([y]es/[n]o/[l]ist): "), + ] + assert capsys.readouterr().out.splitlines()[-1] == str(dspath / "file.txt") diff --git a/dandi/utils.py b/dandi/utils.py index 31d74f6e2..aa20c4a17 100644 --- a/dandi/utils.py +++ b/dandi/utils.py @@ -717,3 +717,32 @@ def pluralize(n: int, word: str, plural: Optional[str] = None) -> str: if plural is None: plural = word + "s" return f"{n} {plural}" + + +def abbrev_prompt(msg: str, *options: str) -> str: + """ + Prompt the user to input one of several options, which can be entered as + either a whole word or the first letter of a word. All input is handled + case-insensitively. Returns the complete word corresponding to the input, + lowercased. + + For example, ``abbrev_prompt("Delete assets?", "yes", "no", "list")`` + prompts the user with the message ``Delete assets? ([y]es/[n]o/[l]ist): `` + and accepts as input ``y`, ``yes``, ``n``, ``no``, ``l``, and ``list``. + """ + options_map = {} + optstrs = [] + for opt in options: + opt = opt.lower() + if opt in options_map: + raise ValueError(f"Repeated option: {opt}") + elif opt[0] in options_map: + raise ValueError(f"Repeated abbreviated option: {opt[0]}") + options_map[opt] = opt + options_map[opt[0]] = opt + optstrs.append(f"[{opt[0]}]{opt[1:]}") + msg += " (" + "/".join(optstrs) + "): " + while True: + answer = input(msg).lower() + if answer in options_map: + return options_map[answer] diff --git a/setup.cfg b/setup.cfg index 6239822ee..22b10b6eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -117,3 +117,5 @@ parentdir_prefix = [codespell] skip = dandi/_version.py,dandi/due.py,versioneer.py +# Don't warn about "[l]ist" in the abbrev_prompt() docstring: +ignore-regex = \[\w\]\w+