Skip to content

Commit 76010ca

Browse files
authored
Improve CLI upload / download experience (#82)
* Repeat dataset selection upon failure * Flag to include hidden files in upload * Select files to upload from list or glob * Let the user try again if no files were selected * Strip the data/ prefix when downloading files
1 parent 8863606 commit 76010ca

File tree

6 files changed

+222
-91
lines changed

6 files changed

+222
-91
lines changed

cirro/cli/controller.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ def run_ingest(input_params: UploadArguments, interactive=False):
5656
return
5757

5858
if interactive:
59-
input_params = gather_upload_arguments(input_params, projects, processes)
60-
files = input_params['files']
59+
input_params, files = gather_upload_arguments(input_params, projects, processes)
60+
directory = input_params['data_directory']
6161
else:
62-
files = get_files_in_directory(input_params['data_directory'])
63-
if len(files) == 0:
64-
raise RuntimeWarning("No files to upload, exiting")
65-
directory = input_params['data_directory']
62+
directory = input_params['data_directory']
63+
files = get_files_in_directory(directory)
64+
65+
if len(files) == 0:
66+
raise RuntimeWarning("No files to upload, exiting")
67+
6668
process = get_item_from_name_or_id(processes, input_params['process'])
6769
cirro.process.check_dataset_files(files, process.id, directory)
6870

cirro/cli/interactive/download_args.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from cirro.api.models.file import File
77
from cirro.api.models.project import Project
88
from cirro.cli.interactive.common_args import ask_project
9-
from cirro.cli.interactive.utils import prompt_wrapper, InputError
9+
from cirro.cli.interactive.utils import ask, prompt_wrapper, InputError
1010
from cirro.cli.models import DownloadArguments
1111
from cirro.utils import format_date
1212

@@ -32,7 +32,16 @@ def ask_dataset(datasets: List[Dataset], input_value: str) -> str:
3232
for dataset in datasets:
3333
if f'{dataset.name} - {dataset.id}' == choice:
3434
return dataset.id
35-
raise InputError("User must select a dataset to download")
35+
36+
# The user has made a selection which does not match
37+
# any of the options available.
38+
# This is most likely because there was a typo
39+
if ask(
40+
'confirm',
41+
'The selection does match an option available - try again?'
42+
):
43+
return ask_dataset(datasets, input_value)
44+
raise InputError("Exiting - no dataset selected")
3645

3746

3847
def ask_dataset_files(files: List[File]) -> List[File]:
@@ -61,47 +70,61 @@ def ask_dataset_files(files: List[File]) -> List[File]:
6170
return ask_dataset_files_glob(files)
6271

6372

73+
def strip_prefix(fp: str, prefix: str):
74+
assert fp.startswith(prefix), f"Expected {fp} to start with {prefix}"
75+
return fp[len(prefix):]
76+
77+
6478
def ask_dataset_files_list(files: List[File]) -> List[File]:
6579
answers = prompt_wrapper({
6680
'type': 'checkbox',
6781
'name': 'files',
6882
'message': 'Select the files to download',
6983
'choices': [
70-
file.relative_path
84+
strip_prefix(file.relative_path, "data/")
7185
for file in files
7286
]
7387
})
7488

75-
return [
89+
selected_files = [
7690
file
7791
for file in files
78-
if file.relative_path in set(answers['files'])
92+
if strip_prefix(file.relative_path, "data/") in set(answers['files'])
7993
]
8094

95+
if len(selected_files) == 0:
96+
if ask(
97+
"confirm",
98+
"No files were selected - try again?"
99+
):
100+
return ask_dataset_files_list(files)
101+
else:
102+
raise RuntimeWarning("No files selected")
103+
else:
104+
return selected_files
105+
81106

82107
def ask_dataset_files_glob(files: List[File]) -> List[File]:
83108

84-
selected_files = ask_dataset_files_glob_single(files)
85-
answers = prompt_wrapper({
86-
'type': 'confirm',
87-
'name': 'confirm',
88-
'message': f'Number of files selected: {len(selected_files):} / {len(files):,}'
89-
})
90-
while not answers['confirm']:
109+
confirmed = False
110+
while not confirmed:
91111
selected_files = ask_dataset_files_glob_single(files)
92-
answers = prompt_wrapper({
93-
'type': 'confirm',
94-
'name': 'confirm',
95-
'message': f'Number of files selected: {len(selected_files):} / {len(files):,}'
96-
})
112+
confirmed = ask(
113+
"confirm",
114+
f'Number of files selected: {len(selected_files):} / {len(files):,}'
115+
)
116+
117+
if len(selected_files) == 0:
118+
raise RuntimeWarning("No files selected")
119+
97120
return selected_files
98121

99122

100123
def ask_dataset_files_glob_single(files: List[File]) -> List[File]:
101124

102125
print("All Files:")
103126
for file in files:
104-
print(f" - {file.relative_path}")
127+
print(f" - {strip_prefix(file.relative_path, 'data/')}")
105128

106129
answers = prompt_wrapper({
107130
'type': 'text',
@@ -113,12 +136,12 @@ def ask_dataset_files_glob_single(files: List[File]) -> List[File]:
113136
selected_files = [
114137
file
115138
for file in files
116-
if fnmatch(file.relative_path, answers['glob'])
139+
if fnmatch(strip_prefix(file.relative_path, "data/"), answers['glob'])
117140
]
118141

119142
print("Selected Files:")
120143
for file in selected_files:
121-
print(f" - {file.relative_path}")
144+
print(f" - {strip_prefix(file.relative_path, 'data/')}")
122145

123146
return selected_files
124147

cirro/cli/interactive/upload_args.py

Lines changed: 150 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,53 @@
55

66
from prompt_toolkit.shortcuts import CompleteStyle
77
from prompt_toolkit.validation import Validator, ValidationError
8-
from questionary import Choice
98

109
from cirro.api.models.process import Process
1110
from cirro.api.models.project import Project
1211
from cirro.cli.interactive.common_args import ask_project
13-
from cirro.cli.interactive.utils import ask
12+
from cirro.cli.interactive.utils import ask, prompt_wrapper
1413
from cirro.cli.models import UploadArguments
15-
from cirro.file_utils import get_directory_stats, get_files_in_directory
14+
from cirro.file_utils import get_files_in_directory, get_files_stats
15+
16+
17+
def ask_data_directory(input_value: str) -> str:
18+
directory_prompt = {
19+
'type': 'path',
20+
'name': 'data_directory',
21+
'message': 'Enter the full path of the data directory',
22+
'validate': DataDirectoryValidator,
23+
'default': input_value or '',
24+
'complete_style': CompleteStyle.READLINE_LIKE,
25+
'only_directories': True
26+
}
27+
28+
answers = prompt_wrapper(directory_prompt)
29+
return answers['data_directory']
30+
31+
32+
def ask_name(input_value: str) -> str:
33+
name_prompt = {
34+
'type': 'input',
35+
'name': 'name',
36+
'message': 'What is the name of this dataset?',
37+
'validate': lambda val: len(val.strip()) > 0 or 'Please enter a name',
38+
'default': input_value or ''
39+
}
40+
41+
answers = prompt_wrapper(name_prompt)
42+
return answers['name']
43+
44+
45+
def ask_description(input_value: str) -> str:
46+
description_prompt = {
47+
'type': 'input',
48+
'name': 'description',
49+
'message': 'Enter a description of the dataset (optional)',
50+
'default': input_value or ''
51+
}
52+
53+
answers = prompt_wrapper(description_prompt)
54+
return answers['description']
1655

1756

1857
class DataDirectoryValidator(Validator):
@@ -25,20 +64,22 @@ def validate(self, document):
2564
)
2665

2766

28-
def confirm_data_directory(directory: str, files: List[str]):
29-
stats = get_directory_stats(directory, files)
30-
is_accepted = ask(
31-
'confirm',
32-
f'Please confirm that you wish to upload {stats["numberOfFiles"]} files ({stats["sizeFriendly"]})',
33-
default=True
34-
)
67+
def confirm_data_files(data_directory: str, files: List[str]):
68+
stats = get_files_stats([
69+
Path(data_directory) / file
70+
for file in files
71+
])
3572

36-
if not is_accepted:
73+
if not ask(
74+
"confirm",
75+
f'Please confirm that you wish to upload {stats["numberOfFiles"]} files ({stats["sizeFriendly"]})'
76+
):
3777
sys.exit(1)
3878

3979

4080
def ask_process(processes: List[Process], input_value: str) -> str:
4181
process_names = [process.name for process in processes]
82+
process_names.sort()
4283
return ask(
4384
'select',
4485
'What type of files?',
@@ -47,61 +88,114 @@ def ask_process(processes: List[Process], input_value: str) -> str:
4788
)
4889

4990

50-
def gather_upload_arguments(input_params: UploadArguments, projects: List[Project], processes: List[Process]):
51-
input_params['project'] = ask_project(projects, input_params.get('project'))
91+
def ask_include_hidden() -> bool:
92+
return prompt_wrapper({
93+
'type': 'confirm',
94+
'message': "Include hidden files (expert: e.g. Zarr)",
95+
'name': 'include_hidden',
96+
'default': False
97+
})['include_hidden']
98+
99+
100+
def ask_files_in_directory(data_directory) -> List[str]:
101+
102+
# Ask whether hidden files should be included
103+
include_hidden = ask_include_hidden()
52104

53-
input_params['data_directory'] = ask(
54-
'path',
55-
'Enter the full path of the data directory',
56-
required=True,
57-
validate=DataDirectoryValidator,
58-
default=input_params.get('data_directory') or '',
59-
complete_style=CompleteStyle.READLINE_LIKE,
60-
only_directories=True
105+
# Get the list of all files in the directory
106+
# (relative to the data_directory)
107+
files = get_files_in_directory(
108+
data_directory,
109+
include_hidden=include_hidden
61110
)
62111

63-
upload_method = ask(
112+
choices = [
113+
"Upload all files",
114+
"Select files from a list",
115+
"Select files with a naming pattern (glob)"
116+
]
117+
118+
choice = ask(
64119
'select',
65-
'What files would you like to upload?',
66-
choices=[
67-
Choice('Upload all files in directory', 'all'),
68-
Choice('Choose files from a list', 'select'),
69-
Choice('Specify a glob pattern', 'glob'),
70-
]
120+
'Which files would you like to upload from this dataset?',
121+
choices=choices
71122
)
72-
input_params['files'] = get_files_in_directory(input_params['data_directory'])
73-
if upload_method == 'select':
74-
input_params['files'] = ask(
75-
'checkbox',
76-
'Select the files you wish to upload',
77-
choices=input_params['files']
123+
124+
if choice == choices[0]:
125+
return files
126+
elif choice == choices[1]:
127+
return ask_dataset_files_list(files)
128+
else:
129+
return ask_dataset_files_glob(files)
130+
131+
132+
def ask_dataset_files_list(files: List[str]) -> List[str]:
133+
return prompt_wrapper({
134+
'type': 'checkbox',
135+
'name': 'files',
136+
'message': 'Select the files to upload',
137+
'choices': files
138+
})['files']
139+
140+
141+
def ask_dataset_files_glob(files: List[str]) -> List[str]:
142+
143+
selected_files = ask_dataset_files_glob_single(files)
144+
confirmed = ask(
145+
"confirm",
146+
f'Number of files selected: {len(selected_files):} / {len(files):,}'
147+
)
148+
while not confirmed:
149+
selected_files = ask_dataset_files_glob_single(files)
150+
confirmed = ask(
151+
"confirm",
152+
f'Number of files selected: {len(selected_files):} / {len(files):,}'
78153
)
79-
elif upload_method == 'glob':
80-
matching_files = None
81-
while not matching_files:
82-
glob_pattern = ask('text', 'Glob pattern:')
83-
matching_files = [f for f in input_params['files'] if fnmatch(f, glob_pattern)]
84-
if len(matching_files) == 0:
85-
print('Glob pattern does not match any files, please specify another')
86154

87-
input_params['files'] = matching_files
155+
if len(selected_files) == 0:
156+
raise RuntimeWarning("No files selected")
157+
158+
return selected_files
159+
160+
161+
def ask_dataset_files_glob_single(files: List[str]) -> List[str]:
162+
163+
print("All Files:")
164+
for file in files:
165+
print(f" - {file}")
166+
167+
answers = prompt_wrapper({
168+
'type': 'text',
169+
'name': 'glob',
170+
'message': 'Select files by naming pattern (using the * wildcard)',
171+
'default': '*'
172+
})
173+
174+
selected_files = [
175+
file
176+
for file in files
177+
if fnmatch(file, answers['glob'])
178+
]
179+
180+
print("Selected Files:")
181+
for file in selected_files:
182+
print(f" - {file}")
88183

89-
confirm_data_directory(input_params['data_directory'], input_params['files'])
184+
return selected_files
185+
186+
187+
def gather_upload_arguments(input_params: UploadArguments, projects: List[Project], processes: List[Process]):
188+
input_params['project'] = ask_project(projects, input_params.get('project'))
189+
190+
input_params['data_directory'] = ask_data_directory(input_params.get('data_directory'))
191+
files = ask_files_in_directory(input_params['data_directory'])
192+
193+
confirm_data_files(input_params['data_directory'], files)
90194

91195
input_params['process'] = ask_process(processes, input_params.get('process'))
92196

93197
data_directory_name = Path(input_params['data_directory']).name
94198
default_name = input_params.get('name') or data_directory_name
95-
input_params['name'] = ask(
96-
'text',
97-
'What is the name of this dataset?',
98-
default=default_name,
99-
validate=lambda val: len(val.strip()) > 0 or 'Please enter a name'
100-
)
101-
input_params['description'] = ask(
102-
'text',
103-
'Enter a description of the dataset (optional)',
104-
default=input_params.get('description') or ''
105-
)
106-
107-
return input_params
199+
input_params['name'] = ask_name(default_name)
200+
input_params['description'] = ask_description(input_params.get('description'))
201+
return input_params, files

0 commit comments

Comments
 (0)