Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions dandi/cli/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from enum import Enum
from functools import wraps
import os

Expand Down Expand Up @@ -29,6 +30,24 @@ def get_metavar(self, param, ctx=None):
return "N[:M]"


class EnumChoice(click.Choice):
"""A ``click.Choice`` over a ``str``-valued ``Enum``, matched on member values.
The available choices presented on the command line are the enum member
values (e.g. ``error``, ``skip``), and ``convert`` returns the corresponding
enum member. A string default is converted to its member as well.
"""

def __init__(self, enum_cls: type[Enum], case_sensitive: bool = True) -> None:
self.enum_cls = enum_cls
super().__init__([e.value for e in enum_cls], case_sensitive=case_sensitive)

def convert(self, value, param, ctx):
if value is None or isinstance(value, self.enum_cls):
return value
return self.enum_cls(super().convert(value, param, ctx))


class ChoiceList(click.ParamType):
name = "choice-list"

Expand Down
16 changes: 11 additions & 5 deletions dandi/cli/cmd_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

import click

from .base import ChoiceList, IntColonInt, instance_option, map_to_click_exceptions
from .base import (
ChoiceList,
EnumChoice,
IntColonInt,
instance_option,
map_to_click_exceptions,
)
from ..consts import SyncMode
from ..dandiarchive import _dandi_url_parser, parse_dandi_url
from ..dandiset import Dandiset
Expand Down Expand Up @@ -63,7 +69,7 @@
@click.option(
"-e",
"--existing",
type=click.Choice(list(DownloadExisting)),
type=EnumChoice(DownloadExisting),
# TODO: verify-reupload (to become default)
help="How to handle paths that already exist locally. "
"For 'error', if the local file exists, display an error and skip downloading that asset. "
Expand All @@ -80,12 +86,12 @@
"-f",
"--format",
help="Choose the format/frontend for output. TODO: support all of the ls",
type=click.Choice(list(DownloadFormat)),
type=EnumChoice(DownloadFormat),
default="pyout",
)
@click.option(
"--path-type",
type=click.Choice(list(PathType)),
type=EnumChoice(PathType),
default="exact",
help="Whether to interpret asset paths in URLs as exact matches or glob patterns",
show_default=True,
Expand Down Expand Up @@ -121,7 +127,7 @@
is_flag=False,
flag_value="ask",
default=None,
type=click.Choice(list(SyncMode)),
type=EnumChoice(SyncMode),
help="Delete local assets that do not exist on the server. "
"With 'ask' (the default when --sync is passed without a value), prompt before "
"deleting. With 'do', delete without prompting.",
Expand Down
11 changes: 8 additions & 3 deletions dandi/cli/cmd_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import click

from .base import devel_debug_option, instance_option, map_to_click_exceptions
from .base import (
EnumChoice,
devel_debug_option,
instance_option,
map_to_click_exceptions,
)
from ..move import MoveExisting, MoveWorkOn


Expand All @@ -16,7 +21,7 @@
@click.option(
"-e",
"--existing",
type=click.Choice(list(MoveExisting)),
type=EnumChoice(MoveExisting),
default="error",
help="How to handle assets that would be moved to a destination that already exists",
show_default=True,
Expand All @@ -30,7 +35,7 @@
@click.option(
"-w",
"--work-on",
type=click.Choice(list(MoveWorkOn)),
type=EnumChoice(MoveWorkOn),
default="auto",
help=(
"Whether to operate on the local Dandiset, remote Dandiset, or both;"
Expand Down
13 changes: 9 additions & 4 deletions dandi/cli/cmd_organize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import click

from .base import dandiset_path_option, devel_debug_option, map_to_click_exceptions
from .base import (
EnumChoice,
dandiset_path_option,
devel_debug_option,
map_to_click_exceptions,
)
from ..consts import dandi_layout_fields
from ..organize import CopyMode, FileOperationMode, OrganizeInvalid

Expand All @@ -17,7 +22,7 @@
@click.option(
"--invalid",
help="What to do if files without sufficient metadata are encountered.",
type=click.Choice(list(OrganizeInvalid)),
type=EnumChoice(OrganizeInvalid),
default="fail",
show_default=True,
)
Expand All @@ -30,7 +35,7 @@
"If 'auto' - whichever of symlink, hardlink, copy is allowed by system. "
"The other modes (copy, move, symlink, hardlink) define how data files "
"should be made available.",
type=click.Choice(list(FileOperationMode)),
type=EnumChoice(FileOperationMode),
default="auto",
show_default=True,
)
Expand All @@ -45,7 +50,7 @@
)
@click.option(
"--media-files-mode",
type=click.Choice(list(CopyMode)),
type=EnumChoice(CopyMode),
default=None,
help="How to relocate video files referenced by NWB files",
)
Expand Down
7 changes: 4 additions & 3 deletions dandi/cli/cmd_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import click

from .base import (
EnumChoice,
IntColonInt,
devel_debug_option,
devel_option,
Expand All @@ -17,7 +18,7 @@
@click.option(
"-e",
"--existing",
type=click.Choice(list(UploadExisting)),
type=EnumChoice(UploadExisting),
help="What to do if a file found existing on the server. 'skip' would skip"
"the file, 'force' - force reupload, 'overwrite' - force upload if "
"either size or modification time differs; 'refresh' - upload only if "
Expand All @@ -40,7 +41,7 @@
is_flag=False,
flag_value="ask",
default=None,
type=click.Choice(list(SyncMode)),
type=EnumChoice(SyncMode),
help="Delete assets on the server that do not exist locally. "
"With 'ask' (the default when --sync is passed without a value), prompt before "
"deleting. With 'do', delete without prompting.",
Expand All @@ -52,7 +53,7 @@
"'require' - data must pass validation before upload; "
"'skip' - no validation is performed on data before upload; "
"'ignore' - data is validated but upload proceeds regardless of validation results.",
type=click.Choice(list(UploadValidation)),
type=EnumChoice(UploadValidation),
default="require",
show_default=True,
)
Expand Down
74 changes: 74 additions & 0 deletions dandi/cli/tests/test_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from enum import Enum

import click
from click.testing import CliRunner
import pytest

from ..base import EnumChoice


class _Existing(str, Enum):
ERROR = "error"
SKIP = "skip"
OVERWRITE = "overwrite-different"

def __str__(self) -> str:
return self.value


def _make_command(**option_kwargs):
@click.command()
@click.option("--existing", type=EnumChoice(_Existing), **option_kwargs)
def cmd(existing):
click.echo(f"{type(existing).__name__}:{existing!r}")

return cmd


@pytest.mark.ai_generated
def test_enum_choice_accepts_member_value():
captured = {}

@click.command()
@click.option("--existing", type=EnumChoice(_Existing), default="error")
def cmd(existing):
captured["existing"] = existing

r = CliRunner().invoke(cmd, ["--existing", "overwrite-different"])
assert r.exit_code == 0, r.output
assert captured["existing"] is _Existing.OVERWRITE


@pytest.mark.ai_generated
def test_enum_choice_rejects_member_name():
r = CliRunner().invoke(_make_command(default="error"), ["--existing", "SKIP"])
assert r.exit_code != 0
assert "'SKIP' is not one of" in r.output


@pytest.mark.ai_generated
def test_enum_choice_none_default_passes_through():
captured = {}

@click.command()
@click.option("--existing", type=EnumChoice(_Existing), default=None)
def cmd(existing):
captured["existing"] = existing

r = CliRunner().invoke(cmd, [])
assert r.exit_code == 0, r.output
assert captured["existing"] is None


@pytest.mark.ai_generated
def test_enum_choice_string_default_converted_to_member():
captured = {}

@click.command()
@click.option("--existing", type=EnumChoice(_Existing), default="skip")
def cmd(existing):
captured["existing"] = existing

r = CliRunner().invoke(cmd, [])
assert r.exit_code == 0, r.output
assert captured["existing"] is _Existing.SKIP
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ classifiers = [
dependencies = [
"bidsschematools ~= 1.0",
"bids-validator-deno >= 2.0.5",
# >=8.2.0: https://github.com/pallets/click/issues/2911
"click >= 7.1, <8.2.0",
"click >= 7.1",
"click-didyoumean",
"dandischema ~= 0.12.0",
"etelemetry >= 0.2.2",
Expand Down
Loading