divbase-cli 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,4 @@
1
+ # File: /divbase/divbase/packages/divbase-cli/src/divbase_cli/commands/__init__.py
2
+ """
3
+ This file initializes the commands subpackage for the divbase-cli package.
4
+ """
@@ -0,0 +1,92 @@
1
+ """
2
+ CLI subcommand for managing user auth with DivBase server.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from pydantic import SecretStr
10
+ from typing_extensions import Annotated
11
+
12
+ from divbase_cli.cli_commands.user_config_cli import CONFIG_FILE_OPTION
13
+ from divbase_cli.cli_config import cli_settings
14
+ from divbase_cli.cli_exceptions import AuthenticationError
15
+ from divbase_cli.user_auth import (
16
+ check_existing_session,
17
+ login_to_divbase,
18
+ logout_of_divbase,
19
+ make_authenticated_request,
20
+ )
21
+ from divbase_cli.user_config import load_user_config
22
+
23
+ auth_app = typer.Typer(
24
+ no_args_is_help=True, help="Login/logout of DivBase server. To register, visit https://divbase.scilifelab.se/."
25
+ )
26
+
27
+
28
+ @auth_app.command("login")
29
+ def login(
30
+ email: str,
31
+ password: Annotated[str, typer.Option(prompt=True, hide_input=True)],
32
+ divbase_url: str = typer.Option(cli_settings.DIVBASE_API_URL, help="DivBase server URL to connect to."),
33
+ config_file: Path = CONFIG_FILE_OPTION,
34
+ force: bool = typer.Option(False, "--force", "-f", help="Force login again even if already logged in"),
35
+ ):
36
+ """
37
+ Log in to the DivBase server.
38
+
39
+ TODO - think abit more about already logged in validation and UX.
40
+ One thing to consider would be use case of very close to refresh token expiry, that could be bad UX.
41
+ (But that is dependent on whether we will allow renewal of refresh tokens...)
42
+ """
43
+ secret_password = SecretStr(password)
44
+ del password # avoid user passwords showing up in error messages etc...
45
+
46
+ config = load_user_config(config_file)
47
+
48
+ if not force:
49
+ session_expires_at = check_existing_session(divbase_url=divbase_url, config=config)
50
+ if session_expires_at:
51
+ print(f"Already logged in to {divbase_url}")
52
+ print(f"Session expires: {datetime.fromtimestamp(session_expires_at)}")
53
+
54
+ if not typer.confirm("Do you want to login again? This will replace your current session."):
55
+ print("Login cancelled.")
56
+ return
57
+
58
+ login_to_divbase(email=email, password=secret_password, divbase_url=divbase_url, config_path=config_file)
59
+ print(f"Logged in successfully as: {email}")
60
+
61
+
62
+ @auth_app.command("logout")
63
+ def logout():
64
+ """
65
+ Log out of the DivBase server.
66
+ """
67
+ logout_of_divbase()
68
+ print("Logged out successfully.")
69
+
70
+
71
+ @auth_app.command("whoami")
72
+ def whoami(
73
+ config_file: Path = CONFIG_FILE_OPTION,
74
+ ):
75
+ """
76
+ Return information about the currently logged-in user.
77
+ """
78
+ config = load_user_config(config_file)
79
+ logged_in_url = config.logged_in_url
80
+
81
+ # TODO - move logged in check to the make_authenticated_request function?
82
+ if not logged_in_url:
83
+ raise AuthenticationError("You are not logged in. Please log in with 'divbase-cli auth login [EMAIL]'.")
84
+
85
+ request = make_authenticated_request(
86
+ method="GET",
87
+ divbase_base_url=logged_in_url,
88
+ api_route="v1/auth/whoami",
89
+ )
90
+
91
+ current_user = request.json()
92
+ print(f"Currently logged in as: {current_user['email']} (Name: {current_user['name']})")
@@ -0,0 +1,143 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ import yaml
6
+
7
+ from divbase_cli.cli_commands.user_config_cli import CONFIG_FILE_OPTION
8
+ from divbase_cli.cli_commands.version_cli import PROJECT_NAME_OPTION
9
+ from divbase_cli.config_resolver import resolve_project
10
+ from divbase_cli.user_auth import make_authenticated_request
11
+ from divbase_lib.api_schemas.vcf_dimensions import DimensionsShowResult
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ dimensions_app = typer.Typer(
17
+ no_args_is_help=True,
18
+ help="Create and inspect dimensions (number of samples, number of variants, scaffold names) of the VCF files in a project",
19
+ )
20
+
21
+
22
+ @dimensions_app.command("update")
23
+ def update_dimensions_index(
24
+ project: str | None = PROJECT_NAME_OPTION,
25
+ config_file: Path = CONFIG_FILE_OPTION,
26
+ ) -> None:
27
+ """Calculate and add the dimensions of a VCF file to the dimensions index file in the project."""
28
+
29
+ project_config = resolve_project(project_name=project, config_path=config_file)
30
+
31
+ response = make_authenticated_request(
32
+ method="PUT",
33
+ divbase_base_url=project_config.divbase_url,
34
+ api_route=f"v1/vcf-dimensions/projects/{project_config.name}",
35
+ )
36
+
37
+ task_id = response.json()
38
+ print(f"Job submitted successfully with task id: {task_id}")
39
+
40
+
41
+ @dimensions_app.command("show")
42
+ def show_dimensions_index(
43
+ filename: str = typer.Option(
44
+ None,
45
+ "--filename",
46
+ help="If set, will show only the entry for this VCF filename.",
47
+ ),
48
+ unique_scaffolds: bool = (
49
+ typer.Option(
50
+ False,
51
+ "--unique-scaffolds",
52
+ help="If set, will show all unique scaffold names found across all the VCF files in the project.",
53
+ )
54
+ ),
55
+ project: str | None = PROJECT_NAME_OPTION,
56
+ config_file: Path = CONFIG_FILE_OPTION,
57
+ ) -> None:
58
+ """
59
+ Show the dimensions index file for a project.
60
+ When running --unique-scaffolds, the sorting separates between numeric and non-numeric scaffold names.
61
+ """
62
+
63
+ project_config = resolve_project(project_name=project, config_path=config_file)
64
+
65
+ response = make_authenticated_request(
66
+ method="GET",
67
+ divbase_base_url=project_config.divbase_url,
68
+ api_route=f"v1/vcf-dimensions/projects/{project_config.name}",
69
+ )
70
+ vcf_dimensions_data = DimensionsShowResult(**response.json())
71
+
72
+ dimensions_info = _format_api_response_for_display_in_terminal(vcf_dimensions_data)
73
+
74
+ if filename:
75
+ record = None
76
+ for entry in dimensions_info.get("indexed_files", []):
77
+ if entry.get("filename") == filename:
78
+ record = entry
79
+ break
80
+ if record:
81
+ print(yaml.safe_dump(record, sort_keys=False))
82
+ else:
83
+ print(
84
+ f"No entry found for filename: {filename}. Please check that the filename is correct and that it is a VCF file (extension: .vcf or .vcf.gz)."
85
+ "\nHint: use 'divbase-cli files list' to view all files in the project."
86
+ )
87
+ return
88
+
89
+ if unique_scaffolds:
90
+ unique_scaffold_names = set()
91
+ for entry in dimensions_info.get("indexed_files", []):
92
+ unique_scaffold_names.update(entry.get("dimensions", {}).get("scaffolds", []))
93
+
94
+ numeric_scaffold_names = []
95
+ non_numeric_scaffold_names = []
96
+ for scaffold in unique_scaffold_names:
97
+ if scaffold.isdigit():
98
+ numeric_scaffold_names.append(int(scaffold))
99
+ else:
100
+ non_numeric_scaffold_names.append(scaffold)
101
+
102
+ unique_scaffold_names_sorted = [str(n) for n in sorted(numeric_scaffold_names)] + sorted(
103
+ non_numeric_scaffold_names
104
+ )
105
+
106
+ print(f"Unique scaffold names found across all the VCF files in the project:\n{unique_scaffold_names_sorted}")
107
+ return
108
+
109
+ print(yaml.safe_dump(dimensions_info, sort_keys=False))
110
+
111
+
112
+ def _format_api_response_for_display_in_terminal(api_response: DimensionsShowResult) -> dict:
113
+ """
114
+ Convert the API response to a YAML-like format for display in the user's terminal.
115
+ """
116
+ dimensions_list = []
117
+ for entry in api_response.vcf_files:
118
+ dimensions_entry = {
119
+ "filename": entry["vcf_file_s3_key"],
120
+ "file_version_ID_in_bucket": entry["s3_version_id"],
121
+ "last_updated": entry.get("updated_at"),
122
+ "dimensions": {
123
+ "scaffolds": entry.get("scaffolds", []),
124
+ "sample_count": entry.get("sample_count", 0),
125
+ "sample_names": entry.get("samples", []),
126
+ "variants": entry.get("variant_count", 0),
127
+ },
128
+ }
129
+ dimensions_list.append(dimensions_entry)
130
+
131
+ skipped_list = []
132
+ for entry in api_response.skipped_files:
133
+ skipped_entry = {
134
+ "filename": entry["vcf_file_s3_key"],
135
+ "file_version_ID_in_bucket": entry["s3_version_id"],
136
+ "skip_reason": entry.get("skip_reason", "unknown"),
137
+ }
138
+ skipped_list.append(skipped_entry)
139
+
140
+ return {
141
+ "indexed_files": dimensions_list,
142
+ "skipped_files": skipped_list,
143
+ }
@@ -0,0 +1,245 @@
1
+ """
2
+ Command line interface for managing files in a DivBase project's store on DivBase.
3
+
4
+ TODO - support for specifying versions of files when downloading files?
5
+ TODO - Download all files option.
6
+ TODO - skip checked option (aka skip files that already exist in same local dir with correct checksum).
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich import print
13
+ from typing_extensions import Annotated
14
+
15
+ from divbase_cli.cli_commands.user_config_cli import CONFIG_FILE_OPTION
16
+ from divbase_cli.cli_commands.version_cli import PROJECT_NAME_OPTION
17
+ from divbase_cli.config_resolver import ensure_logged_in, resolve_download_dir, resolve_project
18
+ from divbase_cli.services import (
19
+ download_files_command,
20
+ list_files_command,
21
+ soft_delete_objects_command,
22
+ upload_files_command,
23
+ )
24
+
25
+ file_app = typer.Typer(no_args_is_help=True, help="Download/upload/list files to/from the project's store on DivBase.")
26
+
27
+
28
+ @file_app.command("list")
29
+ def list_files(
30
+ project: str | None = PROJECT_NAME_OPTION,
31
+ config_file: Path = CONFIG_FILE_OPTION,
32
+ ):
33
+ """
34
+ list all files in the project's DivBase store.
35
+
36
+ To see files at a user specified project version (controlled by the 'divbase-cli version' subcommand),
37
+ you can instead use the 'divbase-cli version info [VERSION_NAME]' command.
38
+ """
39
+ project_config = resolve_project(project_name=project, config_path=config_file)
40
+ logged_in_url = ensure_logged_in(config_path=config_file, desired_url=project_config.divbase_url)
41
+
42
+ files = list_files_command(divbase_base_url=logged_in_url, project_name=project_config.name)
43
+ if not files:
44
+ print("No files found in the project's store on DivBase.")
45
+ else:
46
+ print(f"Files in the project '{project_config.name}':")
47
+ for file in files:
48
+ print(f"- '{file}'")
49
+
50
+
51
+ @file_app.command("download")
52
+ def download_files(
53
+ files: list[str] = typer.Argument(
54
+ None, help="Space separated list of files/objects to download from the project's store on DivBase."
55
+ ),
56
+ file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to upload."),
57
+ download_dir: str = typer.Option(
58
+ None,
59
+ help="""Directory to download the files to.
60
+ If not provided, defaults to what you specified in your user config.
61
+ If also not specified in your user config, downloads to the current directory.
62
+ You can also specify "." to download to the current directory.""",
63
+ ),
64
+ disable_verify_checksums: Annotated[
65
+ bool,
66
+ typer.Option(
67
+ "--disable-verify-checksums",
68
+ help="Turn off checksum verification which is on by default. "
69
+ "Checksum verification means all downloaded files are verified against their MD5 checksums."
70
+ "It is recommended to leave checksum verification enabled unless you have a specific reason to disable it.",
71
+ ),
72
+ ] = False,
73
+ project_version: str = typer.Option(
74
+ default=None,
75
+ help="User defined version of the project's at which to download the files. If not provided, downloads the latest version of all selected files.",
76
+ ),
77
+ project: str | None = PROJECT_NAME_OPTION,
78
+ config_file: Path = CONFIG_FILE_OPTION,
79
+ ):
80
+ """
81
+ Download files from the project's store on DivBase. This can be done by either:
82
+ 1. providing a list of files paths directly in the command line
83
+ 2. providing a directory to download the files to.
84
+ """
85
+ project_config = resolve_project(project_name=project, config_path=config_file)
86
+ logged_in_url = ensure_logged_in(config_path=config_file, desired_url=project_config.divbase_url)
87
+ download_dir_path = resolve_download_dir(download_dir=download_dir, config_path=config_file)
88
+
89
+ if bool(files) + bool(file_list) > 1:
90
+ print("Please specify only one of --files or --file-list.")
91
+ raise typer.Exit(1)
92
+
93
+ all_files: set[str] = set()
94
+ if files:
95
+ all_files.update(files)
96
+ if file_list:
97
+ with open(file_list) as f:
98
+ for object_name in f:
99
+ all_files.add(object_name.strip())
100
+
101
+ if not all_files:
102
+ print("No files specified for download.")
103
+ raise typer.Exit(1)
104
+
105
+ download_results = download_files_command(
106
+ divbase_base_url=logged_in_url,
107
+ project_name=project_config.name,
108
+ all_files=list(all_files),
109
+ download_dir=download_dir_path,
110
+ verify_checksums=not disable_verify_checksums,
111
+ project_version=project_version,
112
+ )
113
+
114
+ if download_results.successful:
115
+ print("[green bold]Successfully downloaded the following files:[/green bold]")
116
+ for success in download_results.successful:
117
+ print(f"- '{success.object_name}' downloaded to: '{success.file_path.resolve()}'")
118
+ if download_results.failed:
119
+ print("[red bold]ERROR: Failed to download the following files:[/red bold]")
120
+ for failed in download_results.failed:
121
+ print(f"[red]- '{failed.object_name}': Exception: '{failed.exception}'[/red]")
122
+
123
+ raise typer.Exit(1)
124
+
125
+
126
+ @file_app.command("upload")
127
+ def upload_files(
128
+ files: list[Path] | None = typer.Argument(None, help="Space seperated list of files to upload."),
129
+ upload_dir: Path | None = typer.Option(None, "--upload-dir", help="Directory to upload all files from."),
130
+ file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to upload."),
131
+ disable_safe_mode: Annotated[
132
+ bool,
133
+ typer.Option(
134
+ "--disable-safe-mode",
135
+ help="Turn off safe mode which is on by default. Safe mode adds 2 extra bits of security by first calculating the MD5 checksum of each file that you're about to upload:"
136
+ "(1) Checks if any of the files you're about to upload already exist (by comparing name and checksum) and if so stops the upload process."
137
+ "(2) Sends the file's checksum when the file is uploaded so the server can verify the upload was successful (by calculating and comparing the checksums)."
138
+ "It is recommended to leave safe mode enabled unless you have a specific reason to disable it.",
139
+ ),
140
+ ] = False,
141
+ project: str | None = PROJECT_NAME_OPTION,
142
+ config_file: Path = CONFIG_FILE_OPTION,
143
+ ):
144
+ """
145
+ Upload files to your project's store on DivBase by either:
146
+ 1. providing a list of files paths directly in the command line
147
+ 2. providing a directory to upload
148
+ 3. providing a text file with or a file list.
149
+ """
150
+ project_config = resolve_project(project_name=project, config_path=config_file)
151
+ logged_in_url = ensure_logged_in(config_path=config_file, desired_url=project_config.divbase_url)
152
+
153
+ if bool(files) + bool(upload_dir) + bool(file_list) > 1:
154
+ print("Please specify only one of --files, --upload_dir, or --file-list.")
155
+ raise typer.Exit(1)
156
+
157
+ all_files = set()
158
+ if files:
159
+ all_files.update(files)
160
+ if upload_dir:
161
+ all_files.update([p for p in upload_dir.iterdir() if p.is_file()])
162
+ if file_list:
163
+ with open(file_list) as f:
164
+ for line in f:
165
+ path = Path(line.strip())
166
+ if path.is_file():
167
+ all_files.add(path)
168
+
169
+ if not all_files:
170
+ print("No files specified for upload.")
171
+ raise typer.Exit(1)
172
+
173
+ uploaded_results = upload_files_command(
174
+ project_name=project_config.name,
175
+ divbase_base_url=logged_in_url,
176
+ all_files=list(all_files),
177
+ safe_mode=not disable_safe_mode,
178
+ )
179
+
180
+ if uploaded_results.successful:
181
+ print("[green bold] The following files were successfully uploaded: [/green bold]")
182
+ for object in uploaded_results.successful:
183
+ print(f"- '{object.object_name}' created from file at: '{object.file_path.resolve()}'")
184
+
185
+ if uploaded_results.failed:
186
+ print("[red bold]ERROR: Failed to upload the following files:[/red bold]")
187
+ for failed in uploaded_results.failed:
188
+ print(f"[red]- '{failed.object_name}': Exception: '{failed.exception}'[/red]")
189
+
190
+ raise typer.Exit(1)
191
+
192
+
193
+ @file_app.command("remove")
194
+ def remove_files(
195
+ files: list[str] | None = typer.Argument(
196
+ None, help="Space seperated list of files/objects in the project's store on DivBase to delete."
197
+ ),
198
+ file_list: Path | None = typer.Option(None, "--file-list", help="Text file with list of files to upload."),
199
+ dry_run: bool = typer.Option(
200
+ False, "--dry-run", help="If set, will not actually delete the files, just print what would be deleted."
201
+ ),
202
+ project: str | None = PROJECT_NAME_OPTION,
203
+ config_file: Path = CONFIG_FILE_OPTION,
204
+ ):
205
+ """
206
+ Remove files from the project's store on DivBase by either:
207
+ 1. providing a list of files paths directly in the command line
208
+ 2. providing a text file with or a file list.
209
+
210
+ 'dry_run' mode will not actually delete the files, just print what would be deleted.
211
+ """
212
+ project_config = resolve_project(project_name=project, config_path=config_file)
213
+ logged_in_url = ensure_logged_in(config_path=config_file, desired_url=project_config.divbase_url)
214
+
215
+ if bool(files) + bool(file_list) > 1:
216
+ print("Please specify only one of --files or --file-list.")
217
+ raise typer.Exit(1)
218
+
219
+ all_files = set()
220
+
221
+ if files:
222
+ all_files.update(files)
223
+ if file_list:
224
+ with open(file_list) as f:
225
+ for line in f:
226
+ all_files.add(line.strip())
227
+
228
+ if dry_run:
229
+ print("Dry run mode enabled. The following files would have been deleted:")
230
+ for file in all_files:
231
+ print(f"- '{file}'")
232
+ return
233
+
234
+ deleted_files = soft_delete_objects_command(
235
+ divbase_base_url=logged_in_url,
236
+ project_name=project_config.name,
237
+ all_files=list(all_files),
238
+ )
239
+
240
+ if deleted_files:
241
+ print("Deleted files:")
242
+ for file in deleted_files:
243
+ print(f"- '{file}'")
244
+ else:
245
+ print("No files were deleted.")
@@ -0,0 +1,144 @@
1
+ """
2
+ Query subcommand for the DivBase CLI.
3
+
4
+ Submits queries (sample metadata and/or bcftools) to the DivBase API.
5
+
6
+ If sample metadata query:
7
+ results are printed to the console.
8
+
9
+ If bcftools query:
10
+ a task id is returned which can be used to check the status of the job.
11
+ After task completed, a merged VCF file will be added to the project's storage bucket which can be downloaded by the user.
12
+
13
+
14
+ TODO:
15
+ - Ability to download results file given task id with the file cli?
16
+ -
17
+ """
18
+
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ import typer
23
+ from rich import print
24
+
25
+ from divbase_cli.cli_commands.user_config_cli import CONFIG_FILE_OPTION
26
+ from divbase_cli.cli_commands.version_cli import PROJECT_NAME_OPTION
27
+ from divbase_cli.cli_config import cli_settings
28
+ from divbase_cli.config_resolver import resolve_project
29
+ from divbase_cli.user_auth import make_authenticated_request
30
+ from divbase_lib.api_schemas.queries import (
31
+ BcftoolsQueryRequest,
32
+ SampleMetadataQueryRequest,
33
+ SampleMetadataQueryTaskResult,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ METADATA_TSV_ARGUMENT = typer.Option(
40
+ cli_settings.METADATA_TSV_NAME, help="Name of the sample metadata TSV file in the project's data store on DivBase."
41
+ )
42
+
43
+ BCFTOOLS_ARGUMENT = typer.Option(
44
+ ...,
45
+ help="""
46
+ String consisting of the bcftools command to run on the files returned by the tsv query.
47
+ """,
48
+ )
49
+
50
+ # In 1 command this is required, other optional, hence only defining the text up here.
51
+ TSV_FILTER_HELP_TEXT = """String consisting of keys:values in the tsv file to filter on.
52
+ The syntax is 'Key1:Value1,Value2;Key2:Value3,Value4', where the key
53
+ are the column header names in the tsv, and values are the column values.
54
+ Multiple values for a key are separated by commas, and multiple keys are
55
+ separated by semicolons. When multple keys are provided, an intersect query
56
+ will be performed. E.g. 'Area:West of Ireland,Northern Portugal;Sex:F'.
57
+ """
58
+
59
+
60
+ query_app = typer.Typer(
61
+ help="Run queries on the VCF files stored in the project's data store on DivBase. Queries are run on the DivBase API",
62
+ no_args_is_help=True,
63
+ )
64
+
65
+
66
+ @query_app.command("tsv")
67
+ def sample_metadata_query(
68
+ filter: str = typer.Argument(
69
+ ...,
70
+ help=TSV_FILTER_HELP_TEXT,
71
+ ),
72
+ show_sample_results: bool = typer.Option(
73
+ default=False,
74
+ help="Print sample_ID and Filename results from the query.",
75
+ ),
76
+ metadata_tsv_name: str = METADATA_TSV_ARGUMENT,
77
+ project: str | None = PROJECT_NAME_OPTION,
78
+ config_file: Path = CONFIG_FILE_OPTION,
79
+ ) -> None:
80
+ """
81
+ Query the tsv sidecar metadata file for the VCF files in the project's data store on DivBase.
82
+ Returns the sample IDs and filenames that match the query.
83
+
84
+ TODO: it perhaps be useful to set the default download_dir in the config so that we can
85
+ look for files there? For now this code just uses file.parent as the download directory.
86
+ TODO: handle when the name of the sample column is something other than Sample_ID
87
+ """
88
+
89
+ project_config = resolve_project(project_name=project, config_path=config_file)
90
+
91
+ request_data = SampleMetadataQueryRequest(tsv_filter=filter, metadata_tsv_name=metadata_tsv_name)
92
+
93
+ response = make_authenticated_request(
94
+ method="POST",
95
+ divbase_base_url=project_config.divbase_url,
96
+ api_route=f"v1/query/sample-metadata/projects/{project_config.name}",
97
+ json=request_data.model_dump(),
98
+ timeout=20, # This is longer than default (5), as api call response is query result, not a task-id.
99
+ )
100
+
101
+ results = SampleMetadataQueryTaskResult(**response.json())
102
+
103
+ if show_sample_results:
104
+ print("[bright_blue]Name and file for each sample in query results:[/bright_blue]")
105
+ for sample in results.sample_and_filename_subset:
106
+ print(f"Sample ID: '{sample['Sample_ID']}', Filename: '{sample['Filename']}'")
107
+
108
+ print(f"The results for the query ([bright_blue]{results.query_message}[/bright_blue]):")
109
+ print(f"Unique Sample IDs: {results.unique_sample_ids}")
110
+ print(f"Unique filenames: {results.unique_filenames}\n")
111
+
112
+
113
+ @query_app.command("bcftools-pipe")
114
+ def pipe_query(
115
+ tsv_filter: str = typer.Option(None, help=TSV_FILTER_HELP_TEXT),
116
+ command: str = BCFTOOLS_ARGUMENT,
117
+ metadata_tsv_name: str = METADATA_TSV_ARGUMENT,
118
+ project: str | None = PROJECT_NAME_OPTION,
119
+ config_file: Path = CONFIG_FILE_OPTION,
120
+ ) -> None:
121
+ """
122
+ Submit a query to run on the DivBase API. A single, merged VCF file will be added to the project on success.
123
+
124
+ TODO Error handling for subprocess calls.
125
+ TODO: handle case empty results are returned from tsv_query()
126
+ TODO what if the user just want to run bcftools on existing files in the bucket, without a tsv file query first?
127
+ TODO what if a job fails and the user wants to re-run it? do we store temp files?
128
+ TODO be consistent about input argument and options. when are they optional, how is that indicated in docstring? etc.
129
+ TODO consider handling the bcftools command whitelist checks also on the CLI level since the error messages are nicer looking?
130
+ TODO consider moving downloading of missing files elsewhere, since this is now done before the celery task
131
+ """
132
+ project_config = resolve_project(project_name=project, config_path=config_file)
133
+
134
+ request_data = BcftoolsQueryRequest(tsv_filter=tsv_filter, command=command, metadata_tsv_name=metadata_tsv_name)
135
+
136
+ response = make_authenticated_request(
137
+ method="POST",
138
+ divbase_base_url=project_config.divbase_url,
139
+ api_route=f"v1/query/bcftools-pipe/projects/{project_config.name}",
140
+ json=request_data.model_dump(),
141
+ )
142
+
143
+ task_id = response.json()
144
+ print(f"Job submitted successfully with task id: {task_id}")