Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
113 changes: 112 additions & 1 deletion trx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
38 changes: 37 additions & 1 deletion trx/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down