From 58019c3ea2952ba483cbe08bff68ea6e227c429d Mon Sep 17 00:00:00 2001 From: Serge Koudoro Date: Fri, 6 Feb 2026 13:13:38 -0500 Subject: [PATCH 1/3] NF: add trx info CLI --- README.md | 4 ++ pyproject.toml | 1 + trx/cli.py | 113 +++++++++++++++++++++++++++++++++++++++++- trx/tests/test_cli.py | 38 +++++++++++++- 4 files changed, 154 insertions(+), 2 deletions(-) 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: From e83903687f3b905d8534fa2304e83e9355a5c225 Mon Sep 17 00:00:00 2001 From: Serge Koudoro Date: Mon, 9 Feb 2026 14:34:21 -0500 Subject: [PATCH 2/3] RF: more informative messaging for `trx info` --- trx/trx_file_memmap.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/trx/trx_file_memmap.py b/trx/trx_file_memmap.py index 2bfd0bc..6554e5f 100644 --- a/trx/trx_file_memmap.py +++ b/trx/trx_file_memmap.py @@ -1010,12 +1010,24 @@ def __str__(self) -> str: text += "\nstreamline_count: {}".format(strs_len) text += "\nvertex_count: {}".format(pts_len) - text += "\ndata_per_vertex keys: {}".format(list(self.data_per_vertex.keys())) - text += "\ndata_per_streamline keys: {}".format( - list(self.data_per_streamline.keys()) - ) - text += "\ngroups keys: {}".format(list(self.groups.keys())) + dpv_keys = list(self.data_per_vertex.keys()) + if dpv_keys: + text += "\ndata_per_vertex keys: {}".format(dpv_keys) + else: + text += "\nNo data per vertex (dpv) keys" + + dps_keys = list(self.data_per_streamline.keys()) + if dps_keys: + text += "\ndata_per_streamline keys: {}".format(dps_keys) + else: + text += "\nNo data per streamline (dps) keys" + + group_keys = list(self.groups.keys()) + if group_keys: + text += "\ngroups keys: {}".format(group_keys) + else: + text += "\nNo group keys" for group_key in self.groups.keys(): if group_key in self.data_per_group: text += "\ndata_per_groups ({}) keys: {}".format( From 5f6125572c92a1865ccfa43789322ee61673c6ef Mon Sep 17 00:00:00 2001 From: Serge Koudoro Date: Mon, 9 Feb 2026 14:59:07 -0500 Subject: [PATCH 3/3] NF: check if valid interphinx url --- docs/source/conf.py | 51 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 27eaff0..9fae80c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -132,14 +132,57 @@ autoapi_dirs = ['../../trx'] autoapi_ignore = ['*test*', '*version*'] + +def _validate_reference_urls(urls, timeout=5): + """Validate reference URLs and return only reachable ones. + + Checks if the objects.inv file (used by sphinx for intersphinx) is + accessible at each URL. + + Parameters + ---------- + urls : dict + Dictionary of package names to documentation URLs. + timeout : int + Connection timeout in seconds. + + Returns + ------- + dict + Dictionary containing only URLs that are reachable. + """ + import urllib.request + import urllib.error + + valid_urls = {} + for name, url in urls.items(): + objects_inv_url = url.rstrip('/') + '/objects.inv' + try: + req = urllib.request.Request( + objects_inv_url, + headers={'User-Agent': 'Sphinx-doc-builder'} + ) + urllib.request.urlopen(req, timeout=timeout) + valid_urls[name] = url + except urllib.error.URLError as e: + reason = getattr(e, 'reason', str(e)) + print(f"WARNING: Skipping '{name}' reference URL ({url}): {reason}") + except Exception as e: + print(f"WARNING: Skipping '{name}' reference URL ({url}): {e}") + return valid_urls + + +# Reference URLs for sphinx-gallery hyperlinks +_reference_urls = { + 'numpy': 'https://numpy.org/doc/stable/', + 'nibabel': 'https://nipy.org/nibabel/', +} + # Sphinx gallery configuration sphinx_gallery_conf = { 'examples_dirs': '../../examples', 'gallery_dirs': 'auto_examples', 'within_subsection_order': 'NumberOfCodeLinesSortKey', - 'reference_url': { - 'numpy': 'https://numpy.org/doc/stable/', - 'nibabel': 'https://nipy.org/nibabel/', - }, + 'reference_url': _validate_reference_urls(_reference_urls), 'default_thumb_file': os.path.join(os.path.dirname(__file__), '..', '_static', 'trx_logo.png'), }