divbase-cli 0.1.0a1__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.
- divbase_cli/__init__.py +1 -0
- divbase_cli/cli_commands/__init__.py +4 -0
- divbase_cli/cli_commands/auth_cli.py +95 -0
- divbase_cli/cli_commands/dimensions_cli.py +472 -0
- divbase_cli/cli_commands/file_cli.py +632 -0
- divbase_cli/cli_commands/query_cli.py +300 -0
- divbase_cli/cli_commands/shared_args_options.py +23 -0
- divbase_cli/cli_commands/task_history_cli.py +125 -0
- divbase_cli/cli_commands/user_config_cli.py +145 -0
- divbase_cli/cli_commands/version_cli.py +214 -0
- divbase_cli/cli_config.py +71 -0
- divbase_cli/cli_exceptions.py +166 -0
- divbase_cli/config_resolver.py +86 -0
- divbase_cli/display_task_history.py +197 -0
- divbase_cli/divbase_cli.py +76 -0
- divbase_cli/retries.py +34 -0
- divbase_cli/services/__init__.py +0 -0
- divbase_cli/services/announcements.py +46 -0
- divbase_cli/services/pre_signed_urls.py +460 -0
- divbase_cli/services/project_versions.py +103 -0
- divbase_cli/services/s3_files.py +444 -0
- divbase_cli/user_auth.py +380 -0
- divbase_cli/user_config.py +177 -0
- divbase_cli/utils.py +28 -0
- divbase_cli-0.1.0a1.dist-info/METADATA +44 -0
- divbase_cli-0.1.0a1.dist-info/RECORD +28 -0
- divbase_cli-0.1.0a1.dist-info/WHEEL +4 -0
- divbase_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
divbase_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a1"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI subcommand for managing user auth with DivBase server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
from rich import print
|
|
11
|
+
|
|
12
|
+
from divbase_cli.cli_config import cli_settings
|
|
13
|
+
from divbase_cli.cli_exceptions import DivBaseAPIConnectionError, DivBaseAPIError
|
|
14
|
+
from divbase_cli.config_resolver import resolve_url_for_non_project_specific_commands
|
|
15
|
+
from divbase_cli.services.announcements import get_and_display_announcements
|
|
16
|
+
from divbase_cli.user_auth import (
|
|
17
|
+
check_existing_session,
|
|
18
|
+
login_to_divbase,
|
|
19
|
+
logout_of_divbase,
|
|
20
|
+
make_authenticated_request,
|
|
21
|
+
)
|
|
22
|
+
from divbase_cli.user_config import load_user_config
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
divbase_home_url = cli_settings.DIVBASE_API_URL.replace("/api", "")
|
|
27
|
+
|
|
28
|
+
auth_app = typer.Typer(
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
help=f"Login/logout of DivBase server. To register, visit {divbase_home_url}.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@auth_app.command("login")
|
|
35
|
+
def login(
|
|
36
|
+
email: str,
|
|
37
|
+
divbase_url: str = typer.Option(cli_settings.DIVBASE_API_URL, help="DivBase server URL to connect to."),
|
|
38
|
+
force: bool = typer.Option(False, "--force", "-f", help="Force login again even if already logged in"),
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Log in to the DivBase server.
|
|
42
|
+
|
|
43
|
+
You'll be prompted for your password after running the command.
|
|
44
|
+
"""
|
|
45
|
+
password: str = typer.prompt("please enter your password", hide_input=True, confirmation_prompt=False)
|
|
46
|
+
secret_password = SecretStr(password)
|
|
47
|
+
del password # avoid user passwords showing up in error messages etc...
|
|
48
|
+
|
|
49
|
+
config = load_user_config()
|
|
50
|
+
|
|
51
|
+
if not force:
|
|
52
|
+
session_expires_at = check_existing_session(divbase_url=divbase_url, config=config)
|
|
53
|
+
if session_expires_at:
|
|
54
|
+
print(f"Already logged in to {divbase_url} with email: {config.logged_in_email}.")
|
|
55
|
+
print(f"Session expires: {datetime.fromtimestamp(session_expires_at)}")
|
|
56
|
+
|
|
57
|
+
if not typer.confirm("Do you want to login again? This will replace your current session."):
|
|
58
|
+
print("Login cancelled.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
login_to_divbase(email=email, password=secret_password, divbase_url=divbase_url)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
get_and_display_announcements(divbase_base_url=divbase_url)
|
|
65
|
+
except (DivBaseAPIError, DivBaseAPIConnectionError):
|
|
66
|
+
# lets not fail the login process if announcements are not working.
|
|
67
|
+
logger.info("Failed to get announcements after login. Error was: ", exc_info=True)
|
|
68
|
+
|
|
69
|
+
print(f"Logged in successfully as: {email}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@auth_app.command("logout")
|
|
73
|
+
def logout():
|
|
74
|
+
"""
|
|
75
|
+
Log out of the DivBase server.
|
|
76
|
+
"""
|
|
77
|
+
logout_of_divbase()
|
|
78
|
+
print("Logged out successfully.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@auth_app.command("whoami")
|
|
82
|
+
def whoami():
|
|
83
|
+
"""
|
|
84
|
+
Return information about the currently logged-in user.
|
|
85
|
+
"""
|
|
86
|
+
divbase_url = resolve_url_for_non_project_specific_commands()
|
|
87
|
+
|
|
88
|
+
request = make_authenticated_request(
|
|
89
|
+
method="GET",
|
|
90
|
+
divbase_base_url=divbase_url,
|
|
91
|
+
api_route="v1/auth/whoami",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
current_user = request.json()
|
|
95
|
+
print(f"Currently logged in as: {current_user['email']} (Name: {current_user['name']})")
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import yaml
|
|
7
|
+
from rich import print
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from divbase_cli.cli_commands.shared_args_options import PROJECT_NAME_OPTION
|
|
11
|
+
from divbase_cli.config_resolver import ensure_logged_in, resolve_project
|
|
12
|
+
from divbase_cli.user_auth import make_authenticated_request
|
|
13
|
+
from divbase_cli.utils import print_rich_table_as_tsv
|
|
14
|
+
from divbase_lib.api_schemas.vcf_dimensions import (
|
|
15
|
+
DimensionsSamplesResult,
|
|
16
|
+
DimensionsScaffoldsResult,
|
|
17
|
+
DimensionsShowResult,
|
|
18
|
+
DimensionsVCFFilesResult,
|
|
19
|
+
)
|
|
20
|
+
from divbase_lib.metadata_validator import SharedMetadataValidator
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
dimensions_app = typer.Typer(
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
help="Create and inspect dimensions (number of samples, number of variants, scaffold names) of the VCF files in a project",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dimensions_app.command("update")
|
|
32
|
+
def update_dimensions_index(
|
|
33
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Calculate and add the dimensions of a VCF file to the dimensions index file in the project."""
|
|
36
|
+
|
|
37
|
+
project_config = resolve_project(project_name=project)
|
|
38
|
+
|
|
39
|
+
response = make_authenticated_request(
|
|
40
|
+
method="PUT",
|
|
41
|
+
divbase_base_url=project_config.divbase_url,
|
|
42
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
task_id = response.json()
|
|
46
|
+
print(
|
|
47
|
+
f"Job submitted successfully with task id: {task_id}. To check the status of your job, use the command: divbase-cli task-history id {task_id}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dimensions_app.command("show")
|
|
52
|
+
def show_dimensions_index(
|
|
53
|
+
filename: str = typer.Option(
|
|
54
|
+
None,
|
|
55
|
+
"--filename",
|
|
56
|
+
help="If set, will show only the entry for this VCF filename.",
|
|
57
|
+
),
|
|
58
|
+
unique_scaffolds: bool = (
|
|
59
|
+
typer.Option(
|
|
60
|
+
False,
|
|
61
|
+
"--unique-scaffolds",
|
|
62
|
+
help="If set, will show all unique scaffold names found across all the VCF files in the project's VCF dimensions cache.",
|
|
63
|
+
)
|
|
64
|
+
),
|
|
65
|
+
unique_samples: bool = (
|
|
66
|
+
typer.Option(
|
|
67
|
+
False,
|
|
68
|
+
"--unique-samples",
|
|
69
|
+
help="If set, will show all unique sample names found across all the VCF files in the project's VCF dimensions cache.",
|
|
70
|
+
)
|
|
71
|
+
),
|
|
72
|
+
unique_vcf_files: bool = (
|
|
73
|
+
typer.Option(
|
|
74
|
+
False,
|
|
75
|
+
"--cached-vcf-files",
|
|
76
|
+
help="If set, will show all VCF file entries (filename + s3 version ID) found in the project's VCF dimensions cache.",
|
|
77
|
+
)
|
|
78
|
+
),
|
|
79
|
+
sample_names_limit: int = typer.Option(
|
|
80
|
+
20,
|
|
81
|
+
"--sample-names-limit",
|
|
82
|
+
min=1,
|
|
83
|
+
help="Maximum number of sample names to display per list in terminal output.",
|
|
84
|
+
),
|
|
85
|
+
sample_names_output: str | None = typer.Option(
|
|
86
|
+
None,
|
|
87
|
+
"--sample-names-output",
|
|
88
|
+
help="Write full sample names to file instead of truncating in terminal output. "
|
|
89
|
+
"Mutually exclusive with --sample-names-stdout.",
|
|
90
|
+
),
|
|
91
|
+
sample_names_stdout: bool = typer.Option(
|
|
92
|
+
False,
|
|
93
|
+
"--sample-names-stdout",
|
|
94
|
+
help="Print full sample names to stdout (useful for piping). Mutually exclusive with --sample-names-output.",
|
|
95
|
+
),
|
|
96
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Show the dimensions index file for a project.
|
|
100
|
+
When running --unique-scaffolds, the sorting separates between numeric and non-numeric scaffold names.
|
|
101
|
+
"""
|
|
102
|
+
project_config = resolve_project(project_name=project)
|
|
103
|
+
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
104
|
+
|
|
105
|
+
# These two options are mutually exclusive. But due to how typer handles options, this error will only be raised if --sample-names-output has an input value (i.e. path).
|
|
106
|
+
# If the path is missing, it will raise an error about the missing path argument instead...
|
|
107
|
+
if sample_names_output and sample_names_stdout:
|
|
108
|
+
typer.echo("Use only one of --sample-names-output or --sample-names-stdout.", err=True)
|
|
109
|
+
raise typer.Exit(code=1)
|
|
110
|
+
|
|
111
|
+
if unique_samples:
|
|
112
|
+
response = make_authenticated_request(
|
|
113
|
+
method="GET",
|
|
114
|
+
divbase_base_url=logged_in_url,
|
|
115
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}/samples",
|
|
116
|
+
)
|
|
117
|
+
unique_sample_names_sorted = DimensionsSamplesResult(**response.json()).unique_samples
|
|
118
|
+
sample_count = len(unique_sample_names_sorted)
|
|
119
|
+
|
|
120
|
+
if sample_names_output:
|
|
121
|
+
output_path = Path(sample_names_output)
|
|
122
|
+
output_path.write_text("\n".join(unique_sample_names_sorted) + "\n")
|
|
123
|
+
print(f"Wrote {sample_count} unique sample names to: {output_path}")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if sample_names_stdout:
|
|
127
|
+
print("\n".join(unique_sample_names_sorted))
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if sample_count > sample_names_limit:
|
|
131
|
+
preview = unique_sample_names_sorted[:sample_names_limit]
|
|
132
|
+
print(
|
|
133
|
+
f"Unique sample names found across all the VCF files in the project (count: {sample_count}, showing first {sample_names_limit}):\n{preview}\n"
|
|
134
|
+
f"To view all, use --sample-names-output <FILE> or --sample-names-stdout."
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
print(
|
|
139
|
+
f"Unique sample names found across all the VCF files in the project (count: {sample_count}):\n{unique_sample_names_sorted}"
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if unique_scaffolds:
|
|
144
|
+
response = make_authenticated_request(
|
|
145
|
+
method="GET",
|
|
146
|
+
divbase_base_url=project_config.divbase_url,
|
|
147
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}/scaffolds",
|
|
148
|
+
)
|
|
149
|
+
unique_scaffold_names_sorted = DimensionsScaffoldsResult(**response.json()).unique_scaffolds
|
|
150
|
+
scaffold_count = len(unique_scaffold_names_sorted)
|
|
151
|
+
print(
|
|
152
|
+
f"Unique scaffold names found across all the VCF files in the project (count: {scaffold_count}):\n{unique_scaffold_names_sorted}"
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if unique_vcf_files:
|
|
157
|
+
response = make_authenticated_request(
|
|
158
|
+
method="GET",
|
|
159
|
+
divbase_base_url=project_config.divbase_url,
|
|
160
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}/vcf-files",
|
|
161
|
+
)
|
|
162
|
+
unique_vcf_files_with_versions = DimensionsVCFFilesResult(**response.json()).unique_vcf_files
|
|
163
|
+
unique_vcf_files_with_versions = sorted(
|
|
164
|
+
unique_vcf_files_with_versions,
|
|
165
|
+
key=lambda entry: entry.vcf_file_s3_key,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
table = Table()
|
|
169
|
+
table.add_column("Filename")
|
|
170
|
+
table.add_column("S3 version ID")
|
|
171
|
+
|
|
172
|
+
for entry in unique_vcf_files_with_versions:
|
|
173
|
+
table.add_row(entry.vcf_file_s3_key, entry.s3_version_id)
|
|
174
|
+
|
|
175
|
+
print_rich_table_as_tsv(table=table)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
response = make_authenticated_request(
|
|
179
|
+
method="GET",
|
|
180
|
+
divbase_base_url=project_config.divbase_url,
|
|
181
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}",
|
|
182
|
+
)
|
|
183
|
+
vcf_dimensions_data = DimensionsShowResult(**response.json())
|
|
184
|
+
|
|
185
|
+
dimensions_info = _format_api_response_for_display_in_terminal(vcf_dimensions_data)
|
|
186
|
+
|
|
187
|
+
if filename:
|
|
188
|
+
record = None
|
|
189
|
+
for entry in dimensions_info.get("indexed_files", []):
|
|
190
|
+
if entry.get("filename") == filename:
|
|
191
|
+
record = entry
|
|
192
|
+
break
|
|
193
|
+
if record:
|
|
194
|
+
if sample_names_output or sample_names_stdout:
|
|
195
|
+
_write_or_print_sample_names(
|
|
196
|
+
indexed_files=[record],
|
|
197
|
+
sample_names_output=sample_names_output,
|
|
198
|
+
sample_names_stdout=sample_names_stdout,
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
_truncate_sample_names_in_entry(record, sample_names_limit)
|
|
203
|
+
print(yaml.safe_dump(record, sort_keys=False))
|
|
204
|
+
else:
|
|
205
|
+
print(
|
|
206
|
+
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)."
|
|
207
|
+
"\nHint: use 'divbase-cli files ls' to view all files in the project."
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if sample_names_output or sample_names_stdout:
|
|
212
|
+
_write_or_print_sample_names(
|
|
213
|
+
indexed_files=dimensions_info.get("indexed_files", []),
|
|
214
|
+
sample_names_output=sample_names_output,
|
|
215
|
+
sample_names_stdout=sample_names_stdout,
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
for entry in dimensions_info.get("indexed_files", []):
|
|
220
|
+
_truncate_sample_names_in_entry(entry, sample_names_limit)
|
|
221
|
+
|
|
222
|
+
print(yaml.safe_dump(dimensions_info, sort_keys=False))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _format_api_response_for_display_in_terminal(api_response: DimensionsShowResult) -> dict:
|
|
226
|
+
"""
|
|
227
|
+
Convert the API response to a YAML-like format for display in the user's terminal.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def sort_scaffolds(scaffolds: list[str]) -> list[str]:
|
|
231
|
+
numeric = sorted([int(s) for s in scaffolds if s.isdigit()])
|
|
232
|
+
non_numeric = sorted([s for s in scaffolds if not s.isdigit()])
|
|
233
|
+
return [str(n) for n in numeric] + non_numeric
|
|
234
|
+
|
|
235
|
+
dimensions_list = []
|
|
236
|
+
for entry in api_response.vcf_files:
|
|
237
|
+
scaffolds = entry.get("scaffolds", [])
|
|
238
|
+
sorted_scaffolds = sort_scaffolds(scaffolds)
|
|
239
|
+
dimensions_entry = {
|
|
240
|
+
"filename": entry["vcf_file_s3_key"],
|
|
241
|
+
"file_version_ID_in_bucket": entry["s3_version_id"],
|
|
242
|
+
"last_updated": entry.get("updated_at"),
|
|
243
|
+
"dimensions": {
|
|
244
|
+
"scaffold_count": len(sorted_scaffolds),
|
|
245
|
+
"scaffolds": sorted_scaffolds,
|
|
246
|
+
"sample_count": entry.get("sample_count", 0),
|
|
247
|
+
"sample_names": entry.get("samples", []),
|
|
248
|
+
"variants": entry.get("variant_count", 0),
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
dimensions_list.append(dimensions_entry)
|
|
252
|
+
|
|
253
|
+
skipped_list = []
|
|
254
|
+
for entry in api_response.skipped_files:
|
|
255
|
+
skipped_entry = {
|
|
256
|
+
"filename": entry["vcf_file_s3_key"],
|
|
257
|
+
"file_version_ID_in_bucket": entry["s3_version_id"],
|
|
258
|
+
"skip_reason": entry.get("skip_reason", "unknown"),
|
|
259
|
+
}
|
|
260
|
+
skipped_list.append(skipped_entry)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"indexed_files": dimensions_list,
|
|
264
|
+
"skipped_files": skipped_list,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dimensions_app.command("create-metadata-template")
|
|
269
|
+
def create_metadata_template_with_project_samples_names(
|
|
270
|
+
output_path: Path | None = typer.Option(
|
|
271
|
+
None,
|
|
272
|
+
"--output",
|
|
273
|
+
"-o",
|
|
274
|
+
file_okay=True,
|
|
275
|
+
dir_okay=False,
|
|
276
|
+
resolve_path=True,
|
|
277
|
+
help="Path to the output TSV file to create. Defaults to sample_metadata_<project_name>.tsv in the current directory. If a file already exists at the given path, you will be prompted to confirm if you want to overwrite it.",
|
|
278
|
+
),
|
|
279
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""
|
|
282
|
+
Create a template sample metadata file (TSV format) pre-filled with the sample names from the project's VCF files based on the information stored in the project's VCF dimensions cache. Tip: run 'divbase-cli dimensions update' first to ensure that the VCF dimensions areup-to-date.
|
|
283
|
+
"""
|
|
284
|
+
project_config = resolve_project(project_name=project)
|
|
285
|
+
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
286
|
+
|
|
287
|
+
response = make_authenticated_request(
|
|
288
|
+
method="GET",
|
|
289
|
+
divbase_base_url=logged_in_url,
|
|
290
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}/samples",
|
|
291
|
+
)
|
|
292
|
+
unique_sample_names_sorted = DimensionsSamplesResult(**response.json()).unique_samples
|
|
293
|
+
|
|
294
|
+
sample_count = len(unique_sample_names_sorted)
|
|
295
|
+
print(
|
|
296
|
+
f"There were {sample_count} unique samples found in the dimensions file for the {project_config.name} project."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if sample_count == 0:
|
|
300
|
+
# Fallback in case there are no samples in the dimensions index. If no dimensions entry for the project
|
|
301
|
+
# VCFDimensionsEntryMissingError will be returned. But for some reason, there are no samples in the VCF, this will catch that.
|
|
302
|
+
print("No samples found for this project. No file written.")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
if output_path is None:
|
|
306
|
+
output_path = Path.cwd() / f"sample_metadata_{project_config.name}.tsv"
|
|
307
|
+
|
|
308
|
+
if output_path.exists():
|
|
309
|
+
overwrite = typer.confirm(f"File '{output_path}' already exists. Do you want to overwrite it?")
|
|
310
|
+
if not overwrite:
|
|
311
|
+
print("File not written. Exiting.")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
with open(output_path, mode="w", newline="") as tsvfile:
|
|
315
|
+
writer = csv.writer(tsvfile, delimiter="\t")
|
|
316
|
+
writer.writerow(["#Sample_ID"])
|
|
317
|
+
for sample in unique_sample_names_sorted:
|
|
318
|
+
writer.writerow([sample])
|
|
319
|
+
|
|
320
|
+
print(f"A sample metadata template with these sample names was written to: {output_path}")
|
|
321
|
+
|
|
322
|
+
# TODO perhaps add a message on how to fill in additional columns and how to upload the metadata file to DivBase?
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dimensions_app.command("validate-metadata-file")
|
|
326
|
+
def validate_metadata_template_versus_dimensions_and_formatting_constraints(
|
|
327
|
+
input_path: Path = typer.Argument(
|
|
328
|
+
...,
|
|
329
|
+
exists=True,
|
|
330
|
+
file_okay=True,
|
|
331
|
+
dir_okay=False,
|
|
332
|
+
resolve_path=True,
|
|
333
|
+
help="Path to the input TSV file to validate.",
|
|
334
|
+
),
|
|
335
|
+
untruncated: bool = typer.Option(
|
|
336
|
+
False,
|
|
337
|
+
"--untruncated",
|
|
338
|
+
help="Show full (untruncated) validator lists (including sample mismatches and grouped warning row/value previews).",
|
|
339
|
+
),
|
|
340
|
+
project: str | None = PROJECT_NAME_OPTION,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""
|
|
343
|
+
Validate a sample metadata TSV file (before you upload it to the project's data store) to check that it will work with DivBase queries.
|
|
344
|
+
"""
|
|
345
|
+
# Client-side validation of a sidecar metadata TSV file, intended to be run before upload to DivBase.
|
|
346
|
+
# Uses the SharedMetadataValidator (that is also used on the server-side) which checks for formatting errors and also validates that the sample names
|
|
347
|
+
# in the TSV file match the sample names in the dimensions index for the project
|
|
348
|
+
|
|
349
|
+
project_config = resolve_project(project_name=project)
|
|
350
|
+
logged_in_url = ensure_logged_in(desired_url=project_config.divbase_url)
|
|
351
|
+
|
|
352
|
+
print(f"Validating local metadata file: {input_path}")
|
|
353
|
+
print(f"Project: {project_config.name}\n")
|
|
354
|
+
|
|
355
|
+
response = make_authenticated_request(
|
|
356
|
+
method="GET",
|
|
357
|
+
divbase_base_url=logged_in_url,
|
|
358
|
+
api_route=f"v1/vcf-dimensions/projects/{project_config.name}/samples",
|
|
359
|
+
)
|
|
360
|
+
unique_sample_names = DimensionsSamplesResult(**response.json()).unique_samples
|
|
361
|
+
|
|
362
|
+
dimensions_sample_preview_limit = None if untruncated else 20
|
|
363
|
+
|
|
364
|
+
shared_validator = SharedMetadataValidator(
|
|
365
|
+
file_path=input_path,
|
|
366
|
+
project_samples=set(unique_sample_names),
|
|
367
|
+
skip_dimensions_check=False,
|
|
368
|
+
dimensions_sample_preview_limit=dimensions_sample_preview_limit,
|
|
369
|
+
)
|
|
370
|
+
result = shared_validator.load_and_validate()
|
|
371
|
+
|
|
372
|
+
errors = [error_entry.message for error_entry in result.errors]
|
|
373
|
+
warnings = [warning_entry.message for warning_entry in result.warnings]
|
|
374
|
+
stats = result.stats
|
|
375
|
+
numeric_cols = result.numeric_columns
|
|
376
|
+
string_cols = result.string_columns
|
|
377
|
+
mixed_cols = result.mixed_type_columns
|
|
378
|
+
|
|
379
|
+
print("[bold cyan]VALIDATION SUMMARY:[/bold cyan]")
|
|
380
|
+
print(
|
|
381
|
+
f" Total columns: {getattr(stats, 'total_columns', 0)} ({getattr(stats, 'user_defined_columns', 0)} user-defined + 1 Sample_ID column)"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
samples_in_tsv = getattr(stats, "samples_in_tsv", 0)
|
|
385
|
+
samples_matching = getattr(stats, "samples_matching_project", 0)
|
|
386
|
+
total_project = getattr(stats, "total_project_samples", 0)
|
|
387
|
+
|
|
388
|
+
print(
|
|
389
|
+
f" Samples matching project VCF dimensions: {samples_matching}/{samples_in_tsv} (project has {total_project} total)"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
print(f" Numeric columns ({len(numeric_cols)}): {', '.join(numeric_cols) if numeric_cols else 'None'}")
|
|
393
|
+
print(f" String columns ({len(string_cols)}): {', '.join(string_cols) if string_cols else 'None'}")
|
|
394
|
+
print(
|
|
395
|
+
f" Mixed-type columns treated as string ({len(mixed_cols)}): {', '.join(mixed_cols) if mixed_cols else 'None'}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if getattr(stats, "has_multi_values", False):
|
|
399
|
+
print(" Multi-value cells: Yes (Python list notation detected)")
|
|
400
|
+
else:
|
|
401
|
+
print(" Multi-value cells: No")
|
|
402
|
+
|
|
403
|
+
empty_cells = getattr(stats, "empty_cells_per_column", {})
|
|
404
|
+
if empty_cells:
|
|
405
|
+
print(
|
|
406
|
+
f" User-defined columns with empty cells ({len(empty_cells)}): {', '.join(f'{col} ({count})' for col, count in empty_cells.items())}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
print()
|
|
410
|
+
|
|
411
|
+
if errors:
|
|
412
|
+
print("[red bold]ERRORS (must be fixed):[/red bold]")
|
|
413
|
+
for error in errors:
|
|
414
|
+
print(f" - {error}")
|
|
415
|
+
print()
|
|
416
|
+
|
|
417
|
+
if warnings:
|
|
418
|
+
print("[yellow bold]WARNINGS (should be reviewed):[/yellow bold]")
|
|
419
|
+
for warning in warnings:
|
|
420
|
+
print(f" - {warning}")
|
|
421
|
+
print()
|
|
422
|
+
|
|
423
|
+
if not errors and not warnings:
|
|
424
|
+
print(
|
|
425
|
+
"[green bold]Validation passed![/green bold] The metadata file meets all DivBase requirements. "
|
|
426
|
+
f"The file is ready to be uploaded to your DivBase project with the command:\n 'divbase-cli files upload {input_path}'"
|
|
427
|
+
)
|
|
428
|
+
elif errors:
|
|
429
|
+
print("[red bold]Validation failed![/red bold] Please fix the errors above before uploading.")
|
|
430
|
+
raise typer.Exit(code=1)
|
|
431
|
+
else:
|
|
432
|
+
print("[yellow bold]Validation passed with warnings![/yellow bold] Review the warnings above.")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _truncate_sample_names_in_entry(entry: dict, sample_names_limit: int) -> None:
|
|
436
|
+
"""
|
|
437
|
+
Truncate sample names output in terminal, while preserving full counts.
|
|
438
|
+
"""
|
|
439
|
+
dimensions = entry.get("dimensions", {})
|
|
440
|
+
sample_names = dimensions.get("sample_names", [])
|
|
441
|
+
if len(sample_names) > sample_names_limit:
|
|
442
|
+
dimensions["sample_names"] = sample_names[:sample_names_limit]
|
|
443
|
+
dimensions["sample_names_note"] = (
|
|
444
|
+
f"Showing first {sample_names_limit} of {len(sample_names)} samples. "
|
|
445
|
+
"Use --sample-names-output <FILE> or --sample-names-stdout to view all."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _write_or_print_sample_names(
|
|
450
|
+
indexed_files: list[dict],
|
|
451
|
+
sample_names_output: str | None,
|
|
452
|
+
sample_names_stdout: bool,
|
|
453
|
+
) -> None:
|
|
454
|
+
"""
|
|
455
|
+
Export full sample names data from divbase-cli dimensions show
|
|
456
|
+
either to a file or to stdout. If used without --unique-samples, it will also include the filename that each sample occurs in.
|
|
457
|
+
"""
|
|
458
|
+
lines: list[str] = []
|
|
459
|
+
for entry in indexed_files:
|
|
460
|
+
file_name = entry.get("filename", "")
|
|
461
|
+
sample_names = entry.get("dimensions", {}).get("sample_names", [])
|
|
462
|
+
for sample_name in sample_names:
|
|
463
|
+
lines.append(f"{file_name}\t{sample_name}")
|
|
464
|
+
|
|
465
|
+
if sample_names_output:
|
|
466
|
+
output_path = Path(sample_names_output)
|
|
467
|
+
output_path.write_text("\n".join(lines) + ("\n" if lines else ""))
|
|
468
|
+
print(f"Wrote {len(lines)} sample-name rows to: {output_path}")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
if sample_names_stdout:
|
|
472
|
+
print("\n".join(lines))
|