From 80e948d4935e5a2d1e424b06d71ecdb8bff84157 Mon Sep 17 00:00:00 2001 From: Andrey Fedorov Date: Thu, 2 Apr 2026 17:07:46 -0400 Subject: [PATCH 1/4] feat: auto-discover binaries from upstream release archive Introduce binaries.txt as the single source of truth for the list of dcmqi executables. CMakeLists.txt now reads it via file(STRINGS), __init__.py discovers installed binaries dynamically at import time, and tests parametrize from the file. The update-dcmqi workflow extracts binary names from the Linux archive and updates binaries.txt and pyproject.toml [project.scripts] automatically when a new release is detected. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/update-dcmqi.yml | 37 ++++++++++++++++- CMakeLists.txt | 16 ++------ binaries.txt | 6 +++ src/dcmqi/__init__.py | 66 +++++++++++++----------------- tests/test_executable.py | 24 +++-------- 5 files changed, 79 insertions(+), 70 deletions(-) create mode 100644 binaries.txt diff --git a/.github/workflows/update-dcmqi.yml b/.github/workflows/update-dcmqi.yml index f53bd80..cac0795 100644 --- a/.github/workflows/update-dcmqi.yml +++ b/.github/workflows/update-dcmqi.yml @@ -50,15 +50,32 @@ jobs: echo "Assets: $assets" # Download assets and compute checksums + # Also extract binary names from one archive (Linux) declare -A checksums + binaries="" for asset in $assets; do echo "Downloading $asset..." url="https://github.com/QIICR/dcmqi/releases/download/$latest_tag/$asset" - sha256=$(curl -sL "$url" | sha256sum | awk '{print $1}') + tmpfile=$(mktemp) + curl -sL "$url" -o "$tmpfile" + sha256=$(sha256sum "$tmpfile" | awk '{print $1}') checksums["$asset"]="$sha256" echo " SHA256: $sha256" + + # Discover binaries from the Linux archive + if [[ "$asset" == *-linux.tar.gz ]] && [ -z "$binaries" ]; then + echo "Discovering binaries from $asset..." + binaries=$(tar -tzf "$tmpfile" | grep '^[^/]*/bin/[^/]*$' | grep -v '/$' | sed 's|.*/bin/||' | sort) + echo " Discovered binaries: $(echo $binaries | tr '\n' ' ')" + fi + rm -f "$tmpfile" done + if [ -z "$binaries" ]; then + echo "ERROR: Could not discover binaries from Linux archive" + exit 1 + fi + # Determine macOS asset pattern has_mac_split=false for asset in $assets; do @@ -140,6 +157,22 @@ jobs: echo 'set(dcmqi_archive_url "https://github.com/QIICR/dcmqi/releases/download/v${version}/${dcmqi_archive_filename}")' } > dcmqiUrls.cmake + # Update binaries.txt with discovered binary list + echo "$binaries" > binaries.txt + + # Update pyproject.toml [project.scripts] to match binaries.txt + python3 - < Path: executable_path = f"dcmqi/bin/{name}" @@ -43,31 +30,34 @@ def _program(name: str, args: list[str]) -> int: return subprocess.call([_lookup(name), *args], close_fds=False) -def itkimage2segimage() -> NoReturn: - """Run the itkimage2segimage executable with arguments passed to a Python script.""" - raise SystemExit(_program("itkimage2segimage", sys.argv[1:])) - - -def segimage2itkimage() -> NoReturn: - """Run the segimage2itkimage executable with arguments passed to a Python script.""" - raise SystemExit(_program("segimage2itkimage", sys.argv[1:])) - +def _make_wrapper(name: str): + def _wrapper() -> NoReturn: + raise SystemExit(_program(name, sys.argv[1:])) -def tid1500writer() -> NoReturn: - """Run the tid1500writer executable with arguments passed to a Python script.""" - raise SystemExit(_program("tid1500writer", sys.argv[1:])) + _wrapper.__name__ = name + _wrapper.__qualname__ = name + _wrapper.__doc__ = f"Run the {name} executable with arguments passed to a Python script." + return _wrapper -def tid1500reader() -> NoReturn: - """Run the tid1500reader executable with arguments passed to a Python script.""" - raise SystemExit(_program("tid1500reader", sys.argv[1:])) - - -def itkimage2paramap() -> NoReturn: - """Run the itkimage2paramap executable with arguments passed to a Python script.""" - raise SystemExit(_program("itkimage2paramap", sys.argv[1:])) - - -def paramap2itkimage() -> NoReturn: - """Run the paramap2itkimage executable with arguments passed to a Python script.""" - raise SystemExit(_program("paramap2itkimage", sys.argv[1:])) +def _discover_binaries() -> list[str]: + """Return names of all executables installed in dcmqi/bin/.""" + files = distribution("dcmqi").files + if files is None: + return [] + binaries = [] + for _file in files: + parts = Path(str(_file)).parts + if len(parts) == 3 and parts[0] == "dcmqi" and parts[1] == "bin": + # Strip platform suffix (.exe on Windows) + name = Path(parts[2]).stem + binaries.append(name) + return sorted(binaries) + + +# Dynamically create wrapper functions for each installed binary +_binaries = _discover_binaries() +for _name in _binaries: + globals()[_name] = _make_wrapper(_name) + +__all__ = ["__version__", *_binaries] diff --git a/tests/test_executable.py b/tests/test_executable.py index 24ecf21..4a8157a 100644 --- a/tests/test_executable.py +++ b/tests/test_executable.py @@ -11,28 +11,16 @@ from . import push_argv -all_tools = pytest.mark.parametrize( - "tool", - [ - "itkimage2segimage", - "segimage2itkimage", - "tid1500writer", - "tid1500reader", - "itkimage2paramap", - "paramap2itkimage", - ], +_BINARIES_FILE = Path(__file__).parent.parent / "binaries.txt" +_EXPECTED_TOOLS = sorted( + line.strip() for line in _BINARIES_FILE.read_text().splitlines() if line.strip() ) +all_tools = pytest.mark.parametrize("tool", _EXPECTED_TOOLS) + all_tools_version = pytest.mark.parametrize( ("tool", "expected_version"), - [ - ("itkimage2segimage", "1.0"), - ("segimage2itkimage", "1.0"), - ("tid1500writer", "1.0"), - ("tid1500reader", "1.0"), - ("itkimage2paramap", "1.0"), - ("paramap2itkimage", "1.0"), - ], + [(t, "1.0") for t in _EXPECTED_TOOLS], ) From 9776eb0878e3f2e0b502f95657e58e528b9f3ea7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:08:25 +0000 Subject: [PATCH 2/4] style: pre-commit fixes --- src/dcmqi/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dcmqi/__init__.py b/src/dcmqi/__init__.py index 747729e..718a9dd 100644 --- a/src/dcmqi/__init__.py +++ b/src/dcmqi/__init__.py @@ -36,7 +36,9 @@ def _wrapper() -> NoReturn: _wrapper.__name__ = name _wrapper.__qualname__ = name - _wrapper.__doc__ = f"Run the {name} executable with arguments passed to a Python script." + _wrapper.__doc__ = ( + f"Run the {name} executable with arguments passed to a Python script." + ) return _wrapper From 2dae5d62f4d2ca2c7437d63dcbaee15543cd9093 Mon Sep 17 00:00:00 2001 From: Andrey Fedorov Date: Thu, 2 Apr 2026 17:14:14 -0400 Subject: [PATCH 3/4] style: fix pre-commit issues - Fix YAML-invalid heredoc in update-dcmqi.yml by collapsing Python script to a single-line python3 -c invocation - Add noqa: PLE0604 for dynamic __all__ in __init__.py - Add return type annotation to _make_wrapper - Apply ruff-format fix Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/update-dcmqi.yml | 12 +----------- src/dcmqi/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/update-dcmqi.yml b/.github/workflows/update-dcmqi.yml index cac0795..cc20520 100644 --- a/.github/workflows/update-dcmqi.yml +++ b/.github/workflows/update-dcmqi.yml @@ -161,17 +161,7 @@ jobs: echo "$binaries" > binaries.txt # Update pyproject.toml [project.scripts] to match binaries.txt - python3 - < int: return subprocess.call([_lookup(name), *args], close_fds=False) -def _make_wrapper(name: str): +def _make_wrapper(name: str) -> Callable[[], NoReturn]: def _wrapper() -> NoReturn: raise SystemExit(_program(name, sys.argv[1:])) @@ -62,4 +62,4 @@ def _discover_binaries() -> list[str]: for _name in _binaries: globals()[_name] = _make_wrapper(_name) -__all__ = ["__version__", *_binaries] +__all__ = ["__version__", *_binaries] # noqa: PLE0604 From 88f0b0043f635f49059bbd8d3449fc4ec6be5272 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:14:42 +0000 Subject: [PATCH 4/4] style: pre-commit fixes --- src/dcmqi/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dcmqi/__init__.py b/src/dcmqi/__init__.py index 9f90a82..5135cf3 100644 --- a/src/dcmqi/__init__.py +++ b/src/dcmqi/__init__.py @@ -8,9 +8,10 @@ import subprocess import sys +from collections.abc import Callable from importlib.metadata import distribution from pathlib import Path -from typing import Callable, NoReturn +from typing import NoReturn from ._version import version as __version__