Skip to content

Commit

Permalink
Add option for user to list to-be-deleted assets when doing a sync do…
Browse files Browse the repository at this point in the history
…wnload
  • Loading branch information
jwodder committed May 10, 2021
1 parent b86d5f8 commit f698a67
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 10 deletions.
24 changes: 18 additions & 6 deletions dandi/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import sys
import time

import click
import humanize
import requests

Expand All @@ -18,6 +17,7 @@
from . import get_logger
from .support.pyout import naturalsize
from .utils import (
abbrev_prompt,
ensure_datetime,
find_files,
flattened,
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 27 additions & 4 deletions dandi/tests/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
29 changes: 29 additions & 0 deletions dandi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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+

0 comments on commit f698a67

Please sign in to comment.