Skip to content

Commit d8f433d

Browse files
committed
Redesign --sync as optional-value option (ask/do) for download and upload
Instead of a separate --yes flag, make --sync itself accept an optional value: 'ask' (default when --sync is passed bare, preserving backward compatibility) prompts before deleting, while 'do' deletes without prompting. This applies to both `dandi download --sync` and `dandi upload --sync`, keeping the control co-located with the feature. Usage: dandi download --sync ... # prompts (same as before) dandi download --sync ask ... # prompts (explicit) dandi download --sync do ... # no prompt, headless-friendly The Python API still accepts bool for backward compatibility (True is normalized to SyncMode.ASK). Closes #1833 https://claude.ai/code/session_01297X1VGdztFArkpVFB1zEk
1 parent 0a89353 commit d8f433d

File tree

7 files changed

+60
-25
lines changed

7 files changed

+60
-25
lines changed

dandi/cli/cmd_download.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import click
77

88
from .base import ChoiceList, IntColonInt, instance_option, map_to_click_exceptions
9+
from ..consts import SyncMode
910
from ..dandiarchive import _dandi_url_parser, parse_dandi_url
1011
from ..dandiset import Dandiset
1112
from ..download import DownloadExisting, DownloadFormat, PathType
@@ -116,13 +117,14 @@
116117
),
117118
)
118119
@click.option(
119-
"--sync", is_flag=True, help="Delete local assets that do not exist on the server"
120-
)
121-
@click.option(
122-
"-y",
123-
"--yes",
124-
is_flag=True,
125-
help="Automatically confirm yes to any prompts (e.g., deletion with --sync)",
120+
"--sync",
121+
is_flag=False,
122+
flag_value="ask",
123+
default=None,
124+
type=click.Choice(list(SyncMode)),
125+
help="Delete local assets that do not exist on the server. "
126+
"With 'ask' (the default when --sync is passed without a value), prompt before "
127+
"deleting. With 'do', delete without prompting.",
126128
)
127129
@instance_option(
128130
default=None,
@@ -156,8 +158,7 @@ def download(
156158
jobs: tuple[int, int],
157159
format: DownloadFormat,
158160
download_types: set[str],
159-
sync: bool,
160-
yes: bool,
161+
sync: str | None,
161162
dandi_instance: str,
162163
path_type: PathType,
163164
preserve_tree: bool,
@@ -197,8 +198,7 @@ def download(
197198
get_metadata="dandiset.yaml" in download_types or preserve_tree,
198199
get_assets="assets" in download_types or preserve_tree,
199200
preserve_tree=preserve_tree,
200-
sync=sync,
201-
yes=yes,
201+
sync=SyncMode(sync) if sync is not None else None,
202202
path_type=path_type,
203203
# develop_debug=develop_debug
204204
)

dandi/cli/cmd_upload.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
instance_option,
1010
map_to_click_exceptions,
1111
)
12+
from ..consts import SyncMode
1213
from ..upload import UploadExisting, UploadValidation
1314

1415

@@ -35,7 +36,14 @@
3536
),
3637
)
3738
@click.option(
38-
"--sync", is_flag=True, help="Delete assets on the server that do not exist locally"
39+
"--sync",
40+
is_flag=False,
41+
flag_value="ask",
42+
default=None,
43+
type=click.Choice(list(SyncMode)),
44+
help="Delete assets on the server that do not exist locally. "
45+
"With 'ask' (the default when --sync is passed without a value), prompt before "
46+
"deleting. With 'do', delete without prompting.",
3947
)
4048
@click.option(
4149
"--validation",
@@ -71,7 +79,7 @@
7179
def upload(
7280
paths: tuple[str, ...],
7381
jobs_pair: tuple[int, int] | None,
74-
sync: bool,
82+
sync: str | None,
7583
dandi_instance: str,
7684
existing: UploadExisting,
7785
validation: UploadValidation,
@@ -120,6 +128,6 @@ def upload(
120128
devel_debug=devel_debug,
121129
jobs=jobs,
122130
jobs_per_file=jobs_per_file,
123-
sync=sync,
131+
sync=SyncMode(sync) if sync is not None else None,
124132
validation_log_path=companion,
125133
)

dandi/consts.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ class EmbargoStatus(Enum):
101101
EMBARGOED = "EMBARGOED"
102102

103103

104+
class SyncMode(str, Enum):
105+
ASK = "ask"
106+
DO = "do"
107+
108+
def __str__(self) -> str:
109+
return self.value
110+
111+
104112
dandiset_metadata_file = "dandiset.yaml"
105113
dandiset_identifier_regex = f"^{DANDISET_ID_REGEX}$"
106114

dandi/download.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
import requests
3939

4040
from . import get_logger
41-
from .consts import DOWNLOAD_SUFFIX, RETRY_STATUSES, dandiset_metadata_file
41+
from .consts import DOWNLOAD_SUFFIX, RETRY_STATUSES, SyncMode, dandiset_metadata_file
4242
from .dandiapi import AssetType, BaseRemoteZarrAsset, RemoteDandiset
4343
from .dandiarchive import (
4444
AssetItemURL,
@@ -107,8 +107,7 @@ def download(
107107
get_metadata: bool = True,
108108
get_assets: bool = True,
109109
preserve_tree: bool = False,
110-
sync: bool = False,
111-
yes: bool = False,
110+
sync: bool | SyncMode | None = False,
112111
path_type: PathType = PathType.EXACT,
113112
) -> None:
114113
# TODO: unduplicate with upload. For now stole from that one
@@ -203,10 +202,13 @@ def p4e(out):
203202
raise AssertionError(f"Unhandled DownloadFormat member: {format!r}")
204203

205204
if sync:
205+
# Normalize legacy bool True to SyncMode.ASK
206+
if sync is True:
207+
sync = SyncMode.ASK
206208
to_delete = [p for dl in downloaders for p in dl.delete_for_sync()]
207209
if to_delete:
208210
do_delete = False
209-
if yes:
211+
if sync is SyncMode.DO:
210212
do_delete = True
211213
else:
212214
while True:

dandi/tests/test_download.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from .fixtures import SampleDandiset, SampleDandisetFactory
2929
from .skip import mark
3030
from .test_helpers import assert_dirtrees_eq
31-
from ..consts import DRAFT, dandiset_metadata_file
31+
from ..consts import DRAFT, SyncMode, dandiset_metadata_file
3232
from ..dandiarchive import DandisetURL
3333
from ..download import (
3434
DownloadDirectory,
@@ -320,7 +320,7 @@ def test_download_sync(
320320

321321

322322
@pytest.mark.ai_generated
323-
def test_download_sync_yes(
323+
def test_download_sync_do(
324324
mocker: MockerFixture, text_dandiset: SampleDandiset, tmp_path: Path
325325
) -> None:
326326
text_dandiset.dandiset.get_asset_by_path("file.txt").delete()
@@ -331,8 +331,7 @@ def test_download_sync_yes(
331331
f"dandi://{text_dandiset.api.instance_id}/{text_dandiset.dandiset_id}",
332332
tmp_path,
333333
existing=DownloadExisting.OVERWRITE,
334-
sync=True,
335-
yes=True,
334+
sync=SyncMode.DO,
336335
)
337336
confirm_mock.assert_not_called()
338337
assert not (dspath / "file.txt").exists()

dandi/tests/test_upload.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DOWNLOAD_SUFFIX,
2525
ZARR_MIME_TYPE,
2626
EmbargoStatus,
27+
SyncMode,
2728
dandiset_metadata_file,
2829
)
2930
from ..dandiapi import AssetType, RemoteBlobAsset, RemoteZarrAsset, RESTFullAPIClient
@@ -192,6 +193,16 @@ def test_upload_sync_folder(
192193
text_dandiset.dandiset.get_asset_by_path("subdir2/banana.txt")
193194

194195

196+
@pytest.mark.ai_generated
197+
def test_upload_sync_do(mocker: MockerFixture, text_dandiset: SampleDandiset) -> None:
198+
(text_dandiset.dspath / "file.txt").unlink()
199+
confirm_mock = mocker.patch("click.confirm")
200+
text_dandiset.upload(sync=SyncMode.DO)
201+
confirm_mock.assert_not_called()
202+
with pytest.raises(NotFoundError):
203+
text_dandiset.dandiset.get_asset_by_path("file.txt")
204+
205+
195206
def test_upload_bids_invalid(
196207
mocker: MockerFixture, bids_dandiset_invalid: SampleDandiset
197208
) -> None:

dandi/upload.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
DOWNLOAD_SUFFIX,
3333
DRAFT,
3434
DandiInstance,
35+
SyncMode,
3536
dandiset_identifier_regex,
3637
dandiset_metadata_file,
3738
)
@@ -112,7 +113,7 @@ def upload(
112113
devel_debug: bool = False,
113114
jobs: int | None = None,
114115
jobs_per_file: int | None = None,
115-
sync: bool = False,
116+
sync: bool | SyncMode | None = False,
116117
validation_log_path: str | Path | None = None,
117118
) -> None:
118119
if paths:
@@ -505,6 +506,9 @@ def upload_agg(*ignored: Any) -> str:
505506
raise upload_err
506507

507508
if sync:
509+
# Normalize legacy bool True to SyncMode.ASK
510+
if sync is True:
511+
sync = SyncMode.ASK
508512
relpaths: list[str] = []
509513
for p in paths:
510514
rp = os.path.relpath(p, dandiset.path)
@@ -516,8 +520,11 @@ def upload_agg(*ignored: Any) -> str:
516520
p == "" or path_is_subpath(asset.path, p) for p in relpaths
517521
) and not os.path.lexists(Path(dandiset.path, asset.path)):
518522
to_delete.append(asset)
519-
if to_delete and click.confirm(
520-
f"Delete {pluralize(len(to_delete), 'asset')} on server?"
523+
if to_delete and (
524+
sync is SyncMode.DO
525+
or click.confirm(
526+
f"Delete {pluralize(len(to_delete), 'asset')} on server?"
527+
)
521528
):
522529
for asset in to_delete:
523530
asset.delete()

0 commit comments

Comments
 (0)