diff --git a/README.md b/README.md index 335011e..61fcef6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ TRX-Python provides a unified CLI (`trx`) for common operations: # Show all available commands trx --help +# Display TRX file information (header, groups, data keys, archive contents) +trx info data.trx + # Convert between formats trx convert input.trk output.trx @@ -60,6 +63,7 @@ trx validate data.trx Individual commands are also available for backward compatibility: ```bash +trx_info data.trx trx_convert_tractogram input.trk output.trx trx_concatenate_tractograms tract1.trx tract2.trx merged.trx trx_validate data.trx diff --git a/pyproject.toml b/pyproject.toml index 2f6f606..360cd70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ trx_simple_compare = "trx.cli:compare_cmd" trx_validate = "trx.cli:validate_cmd" trx_verify_header_compatibility = "trx.cli:verify_header_cmd" trx_visualize_overlap = "trx.cli:visualize_cmd" +trx_info = "trx.cli:info_cmd" [tool.setuptools] packages = ["trx"] diff --git a/trx/cli.py b/trx/cli.py index 92202a6..afb2c15 100644 --- a/trx/cli.py +++ b/trx/cli.py @@ -13,7 +13,7 @@ from typing_extensions import Annotated from trx.io import load, save -from trx.trx_file_memmap import TrxFile, concatenate +from trx.trx_file_memmap import TrxFile, concatenate, load as load_trx from trx.workflows import ( convert_dsi_studio, convert_tractogram, @@ -874,6 +874,111 @@ def visualize( ) +def _format_size(size_bytes: int) -> str: + """Format byte size to human readable string. + + Parameters + ---------- + size_bytes : int + Size in bytes. + + Returns + ------- + str + Human readable size string (e.g., "1.5 MB"). + """ + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024: + return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} TB" + + +@app.command("info") +def info( + in_tractogram: Annotated[ + Path, + typer.Argument(help="Input TRX file."), + ], +) -> None: + """Display detailed information about a TRX file. + + Shows file size, compression status, header metadata (affine, dimensions, + voxel sizes), streamline/vertex counts, data keys (dpv, dps, dpg), groups, + and archive contents listing similar to ``unzip -l``. + + Parameters + ---------- + in_tractogram : Path + Input TRX file (.trx extension required). + + Returns + ------- + None + Prints TRX file information to stdout. + + Examples + -------- + $ trx info tractogram.trx + $ trx_info tractogram.trx + """ + import zipfile + + if not in_tractogram.exists(): + typer.echo( + typer.style(f"Error: {in_tractogram} does not exist.", fg=typer.colors.RED), + err=True, + ) + raise typer.Exit(code=1) + + if in_tractogram.suffix.lower() != ".trx": + typer.echo( + typer.style( + f"Error: {in_tractogram.name} is not a TRX file. " + "Only .trx files are supported.", + fg=typer.colors.RED, + ), + err=True, + ) + raise typer.Exit(code=1) + + # Show archive info + file_size = in_tractogram.stat().st_size + typer.echo(f"File: {in_tractogram}") + typer.echo(f"Size: {_format_size(file_size)}") + + with zipfile.ZipFile(str(in_tractogram), "r") as zf: + total_uncompressed = sum(info.file_size for info in zf.infolist()) + is_compressed = any(info.compress_type != 0 for info in zf.infolist()) + typer.echo(f"Entries: {len(zf.infolist())}") + typer.echo(f"Compressed: {'Yes' if is_compressed else 'No'}") + typer.echo(f"Uncompressed size: {_format_size(total_uncompressed)}") + + typer.echo("") + + # Show TRX content info + trx = load_trx(str(in_tractogram)) + typer.echo(trx) + + # Show file listing (unzip -l style) + typer.echo("\nArchive contents:") + typer.echo(" Length Date Time Name") + typer.echo("--------- ---------- ----- ----") + with zipfile.ZipFile(str(in_tractogram), "r") as zf: + for zinfo in zf.infolist(): + dt = zinfo.date_time + date_str = f"{dt[1]:02d}-{dt[2]:02d}-{dt[0]}" + time_str = f"{dt[3]:02d}:{dt[4]:02d}" + typer.echo( + f"{zinfo.file_size:>9} {date_str} {time_str} {zinfo.filename}" + ) + num_files = len(zf.infolist()) + typer.echo("--------- -------") + typer.echo(f"{total_uncompressed:>9} {num_files} files") + + trx.close() + + def main(): """Entry point for the TRX CLI.""" app() @@ -964,6 +1069,12 @@ def _create_standalone_app(command_func, name: str, help_text: str): "Display tractogram and density map with bounding box.", ) +info_cmd = _create_standalone_app( + info, + "trx_info", + "Display information about a TRX file.", +) + if __name__ == "__main__": main() diff --git a/trx/tests/test_cli.py b/trx/tests/test_cli.py index ddb70bd..35b78d3 100644 --- a/trx/tests/test_cli.py +++ b/trx/tests/test_cli.py @@ -27,7 +27,10 @@ ) # If they already exist, this only takes 5 seconds (check md5sum) -fetch_data(get_testing_files_dict(), keys=["DSI.zip", "trx_from_scratch.zip"]) +fetch_data( + get_testing_files_dict(), + keys=["DSI.zip", "trx_from_scratch.zip", "gold_standard.zip"], +) def _normalize_dtype_dict(dtype_dict): @@ -91,6 +94,10 @@ def test_help_option_visualize(self, script_runner): ret = script_runner.run(["trx_visualize_overlap", "--help"]) assert ret.success + def test_help_option_info(self, script_runner): + ret = script_runner.run(["trx_info", "--help"]) + assert ret.success + # Tests for unified trx CLI class TestUnifiedCLI: @@ -136,6 +143,35 @@ def test_trx_visualize_help(self, script_runner): ret = script_runner.run(["trx", "visualize", "--help"]) assert ret.success + def test_trx_info_help(self, script_runner): + ret = script_runner.run(["trx", "info", "--help"]) + assert ret.success + + def test_trx_info_execution(self, script_runner): + """Test trx info command execution on a real TRX file.""" + trx_path = os.path.join(get_home(), "gold_standard", "gs.trx") + ret = script_runner.run(["trx", "info", trx_path]) + assert ret.success + # Check key output elements + assert "VOXEL_TO_RASMM" in ret.stdout + assert "DIMENSIONS" in ret.stdout + assert "streamline_count" in ret.stdout + assert "vertex_count" in ret.stdout + assert "Archive contents:" in ret.stdout + + def test_trx_info_wrong_extension(self, script_runner): + """Test trx info rejects non-TRX files.""" + tck_path = os.path.join(get_home(), "gold_standard", "gs.tck") + ret = script_runner.run(["trx", "info", tck_path]) + assert not ret.success + assert "not a TRX file" in ret.stderr + + def test_trx_info_file_not_found(self, script_runner): + """Test trx info handles missing files.""" + ret = script_runner.run(["trx", "info", "nonexistent.trx"]) + assert not ret.success + assert "does not exist" in ret.stderr + # Tests for workflow functions class TestWorkflowFunctions: