Skip to content

Commit dbc6393

Browse files
committed
Improve dimensions show UX command for samples
The 5000 sample test case clearly showed that this needed improvement. This adds options to control the output by count, by saving to file, and by printing to stdout. --sample-names-limit (int; default: 20) --sample-names-output <path/to/output> --sample-names-stdout
1 parent c50135a commit dbc6393

2 files changed

Lines changed: 215 additions & 0 deletions

File tree

packages/divbase-cli/src/divbase_cli/cli_commands/dimensions_cli.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from pathlib import Path
23

34
import typer
45
import yaml
@@ -60,6 +61,23 @@ def show_dimensions_index(
6061
help="If set, will show all unique sample names found across all the VCF files in the project.",
6162
)
6263
),
64+
sample_names_limit: int = typer.Option(
65+
20,
66+
"--sample-names-limit",
67+
min=1,
68+
help="Maximum number of sample names to display per list in terminal output.",
69+
),
70+
sample_names_output: str | None = typer.Option(
71+
None,
72+
"--sample-names-output",
73+
help="Write full sample names to file instead of truncating in terminal output. "
74+
"Mutually exclusive with --sample-names-stdout.",
75+
),
76+
sample_names_stdout: bool = typer.Option(
77+
False,
78+
"--sample-names-stdout",
79+
help="Print full sample names to stdout (useful for piping). Mutually exclusive with --sample-names-output.",
80+
),
6381
project: str | None = PROJECT_NAME_OPTION,
6482
) -> None:
6583
"""
@@ -69,6 +87,10 @@ def show_dimensions_index(
6987

7088
project_config = resolve_project(project_name=project)
7189

90+
# These two options are mutually exclusive. But due to how typer handles options, this error will only be raise if --sample-names-output has an input value (i.e. path). If the path is missing, it will raise an error about the missing path argument instead...
91+
if sample_names_output and sample_names_stdout:
92+
raise typer.BadParameter("Use only one of --sample-names-output or --sample-names-stdout.")
93+
7294
if unique_samples:
7395
response = make_authenticated_request(
7496
method="GET",
@@ -77,6 +99,25 @@ def show_dimensions_index(
7799
)
78100
unique_sample_names_sorted = DimensionsSamplesResult(**response.json()).unique_samples
79101
sample_count = len(unique_sample_names_sorted)
102+
103+
if sample_names_output:
104+
output_path = Path(sample_names_output)
105+
output_path.write_text("\n".join(unique_sample_names_sorted) + "\n")
106+
print(f"Wrote {sample_count} unique sample names to: {output_path}")
107+
return
108+
109+
if sample_names_stdout:
110+
print("\n".join(unique_sample_names_sorted))
111+
return
112+
113+
if sample_count > sample_names_limit:
114+
preview = unique_sample_names_sorted[:sample_names_limit]
115+
print(
116+
f"Unique sample names found across all the VCF files in the project (count: {sample_count}, showing first {sample_names_limit}):\n{preview}\n"
117+
f"To view all, use --sample-names-output <FILE> or --sample-names-stdout."
118+
)
119+
return
120+
80121
print(
81122
f"Unique sample names found across all the VCF files in the project (count: {sample_count}):\n{unique_sample_names_sorted}"
82123
)
@@ -111,6 +152,15 @@ def show_dimensions_index(
111152
record = entry
112153
break
113154
if record:
155+
if sample_names_output or sample_names_stdout:
156+
_write_or_print_sample_names(
157+
indexed_files=[record],
158+
sample_names_output=sample_names_output,
159+
sample_names_stdout=sample_names_stdout,
160+
)
161+
return
162+
163+
_truncate_sample_names_in_entry(record, sample_names_limit)
114164
print(yaml.safe_dump(record, sort_keys=False))
115165
else:
116166
print(
@@ -119,6 +169,17 @@ def show_dimensions_index(
119169
)
120170
return
121171

172+
if sample_names_output or sample_names_stdout:
173+
_write_or_print_sample_names(
174+
indexed_files=dimensions_info.get("indexed_files", []),
175+
sample_names_output=sample_names_output,
176+
sample_names_stdout=sample_names_stdout,
177+
)
178+
return
179+
180+
for entry in dimensions_info.get("indexed_files", []):
181+
_truncate_sample_names_in_entry(entry, sample_names_limit)
182+
122183
print(yaml.safe_dump(dimensions_info, sort_keys=False))
123184

124185

@@ -163,3 +224,43 @@ def sort_scaffolds(scaffolds: list[str]) -> list[str]:
163224
"indexed_files": dimensions_list,
164225
"skipped_files": skipped_list,
165226
}
227+
228+
229+
def _truncate_sample_names_in_entry(entry: dict, sample_names_limit: int) -> None:
230+
"""
231+
Truncate sample names output in terminal, while preserving full counts.
232+
"""
233+
dimensions = entry.get("dimensions", {})
234+
sample_names = dimensions.get("sample_names", [])
235+
if len(sample_names) > sample_names_limit:
236+
dimensions["sample_names"] = sample_names[:sample_names_limit]
237+
dimensions["sample_names_note"] = (
238+
f"Showing first {sample_names_limit} of {len(sample_names)} samples. "
239+
"Use --sample-names-output <FILE> or --sample-names-stdout to view all."
240+
)
241+
242+
243+
def _write_or_print_sample_names(
244+
indexed_files: list[dict],
245+
sample_names_output: str | None,
246+
sample_names_stdout: bool,
247+
) -> None:
248+
"""
249+
Export full sample names data from divbase-cli dimensions show
250+
either to a file or to stdout. If used without --unique-samples, it will also include the filename that each sample occurs in.
251+
"""
252+
lines: list[str] = []
253+
for entry in indexed_files:
254+
file_name = entry.get("filename", "")
255+
sample_names = entry.get("dimensions", {}).get("sample_names", [])
256+
for sample_name in sample_names:
257+
lines.append(f"{file_name}\t{sample_name}")
258+
259+
if sample_names_output:
260+
output_path = Path(sample_names_output)
261+
output_path.write_text("\n".join(lines) + ("\n" if lines else ""))
262+
print(f"Wrote {len(lines)} sample-name rows to: {output_path}")
263+
return
264+
265+
if sample_names_stdout:
266+
print("\n".join(lines))

tests/e2e_integration/cli_commands/test_dimensions_cli.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,117 @@ def test_show_unique_scaffolds_dedicated_endpoint(
365365
# Verify numeric scaffolds come first, sorted numerically
366366
numeric_scaffolds = [s for s in scaffold_names if s.isdigit()]
367367
assert numeric_scaffolds == sorted(numeric_scaffolds, key=int), "Numeric scaffolds should be sorted numerically"
368+
369+
370+
def test_show_dimensions_sample_names_output_writes_file(
371+
CONSTANTS,
372+
run_update_dimensions,
373+
project_map,
374+
logged_in_edit_user_with_existing_config,
375+
tmp_path,
376+
):
377+
"""
378+
Test that --sample-names-output writes full sample-name rows to file.
379+
"""
380+
project_name = CONSTANTS["SPLIT_SCAFFOLD_PROJECT"]
381+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
382+
project_id = project_map[project_name]
383+
user_id = 1
384+
385+
run_update_dimensions(bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id)
386+
387+
output_path = tmp_path / "sample_names.tsv"
388+
command = f"dimensions show --project {project_name} --sample-names-output {output_path}"
389+
cli_result = runner.invoke(app, command)
390+
assert cli_result.exit_code == 0, f"Command failed with: {cli_result.stdout}"
391+
assert output_path.exists(), f"Expected output file {output_path} to exist"
392+
393+
rows = output_path.read_text().strip().splitlines()
394+
assert len(rows) > 0, "Expected at least one sample row in output file"
395+
assert all("\t" in row for row in rows), "Expected tab-delimited rows in format: filename<TAB>sample_name"
396+
assert any("HOM_20ind_17SNPs" in row for row in rows), "Expected known VCF filename in output rows"
397+
assert "Wrote" in cli_result.stdout and "sample-name rows" in cli_result.stdout
398+
399+
400+
def test_show_dimensions_sample_names_stdout_streams_rows(
401+
CONSTANTS,
402+
run_update_dimensions,
403+
project_map,
404+
logged_in_edit_user_with_existing_config,
405+
):
406+
"""
407+
Test that --sample-names-stdout prints filename/sample rows to stdout.
408+
"""
409+
project_name = CONSTANTS["SPLIT_SCAFFOLD_PROJECT"]
410+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
411+
project_id = project_map[project_name]
412+
user_id = 1
413+
414+
run_update_dimensions(bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id)
415+
416+
command = f"dimensions show --project {project_name} --sample-names-stdout"
417+
cli_result = runner.invoke(app, command)
418+
assert cli_result.exit_code == 0, f"Command failed with: {cli_result.stdout}"
419+
420+
lines = [line for line in cli_result.stdout.splitlines() if line.strip()]
421+
assert len(lines) > 0, "Expected streamed rows in stdout"
422+
assert all("\t" in line for line in lines), "Expected tab-delimited rows in format: filename<TAB>sample_name"
423+
424+
425+
def test_show_dimensions_truncates_sample_names_in_terminal(
426+
CONSTANTS,
427+
run_update_dimensions,
428+
project_map,
429+
logged_in_edit_user_with_existing_config,
430+
):
431+
"""
432+
Test that --sample-names-limit truncates shown sample names and adds a note.
433+
"""
434+
project_name = CONSTANTS["SPLIT_SCAFFOLD_PROJECT"]
435+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
436+
project_id = project_map[project_name]
437+
user_id = 1
438+
439+
run_update_dimensions(bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id)
440+
441+
command = f"dimensions show --project {project_name} --sample-names-limit 2"
442+
cli_result = runner.invoke(app, command)
443+
assert cli_result.exit_code == 0, f"Command failed with: {cli_result.stdout}"
444+
445+
dimensions_info = yaml.safe_load(cli_result.stdout)
446+
indexed_files = dimensions_info.get("indexed_files", [])
447+
assert len(indexed_files) > 0, "Expected indexed files in output"
448+
449+
first_entry_dimensions = indexed_files[0].get("dimensions", {})
450+
shown_sample_names = first_entry_dimensions.get("sample_names", [])
451+
assert len(shown_sample_names) <= 2, f"Expected sample_names to be truncated to <=2, got {shown_sample_names}"
452+
assert "sample_names_note" in first_entry_dimensions, "Expected truncation note in output"
453+
454+
455+
def test_show_dimensions_rejects_output_and_stdout_together(
456+
CONSTANTS,
457+
run_update_dimensions,
458+
project_map,
459+
logged_in_edit_user_with_existing_config,
460+
tmp_path,
461+
):
462+
"""
463+
Test that using --sample-names-output and --sample-names-stdout together fails.
464+
"""
465+
project_name = CONSTANTS["SPLIT_SCAFFOLD_PROJECT"]
466+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
467+
project_id = project_map[project_name]
468+
user_id = 1
469+
470+
run_update_dimensions(bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id)
471+
472+
output_path = tmp_path / "sample_names.tsv"
473+
command = f"dimensions show --project {project_name} --sample-names-output {output_path} --sample-names-stdout"
474+
cli_result = runner.invoke(app, command)
475+
assert cli_result.exit_code != 0, "Expected command to fail when both output modes are provided"
476+
combined_output = (
477+
(cli_result.stdout or "")
478+
+ (getattr(cli_result, "stderr", "") or "")
479+
+ (str(cli_result.exception) if cli_result.exception else "")
480+
)
481+
assert "Use only one of --sample-names-output or --sample-names-stdout." in combined_output

0 commit comments

Comments
 (0)