diff --git a/.github/workflows/pydra.yml b/.github/workflows/pydra.yml new file mode 100644 index 0000000000..8d76d7f107 --- /dev/null +++ b/.github/workflows/pydra.yml @@ -0,0 +1,85 @@ +name: Pydra code generation + +on: + pull_request: + types: [opened, synchronize] + branches: [ master, dev] + merge_group: + types: [checks_requested] + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + generate-pydra: + runs-on: ubuntu-latest + + env: + QT_SELECT: qt6 + SCCACHE_GHA_ENABLED: "true" + SCCACHE_CACHE_SIZE: "2G" + + steps: + - uses: actions/checkout@v1 + with: + submodules: true + + - name: Get latest version + run: echo "MRTRIX3_VERSION=$(git fetch --tags && git describe --tags --abbrev=0)" >> $GITHUB_ENV + + - name: install dependencies + run: | + sudo apt-get update + sudo apt-get install clang qt6-base-dev libglvnd-dev zlib1g-dev libfftw3-dev ninja-build python3-numpy libpng-dev + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.9 + + - name: Get CMake + uses: lukka/get-cmake@latest + with: + cmakeVersion: '3.16.3' + + - name: Print CMake version + run: cmake --version + + - name: Make installation dir + run: | + sudo mkdir -p /opt/mrtrix3 + sudo chown $USER /opt/mrtrix3 + + - name: configure + run: > + cmake + -B build + -G Ninja + -D CMAKE_BUILD_TYPE=Release + -D MRTRIX_BUILD_TESTS=ON + -D MRTRIX_STL_DEBUGGING=ON + -D MRTRIX_WARNINGS_AS_ERRORS=ON + -D CMAKE_C_COMPILER=clang + -D CMAKE_CXX_COMPILER=clang++ + -D CMAKE_INSTALL_PREFIX=/opt/mrtrix3 + + - name: Build MRtrix3 + run: cmake --build build + + - name: Install MRtrix3 + run: cmake --install build + + - name: Clone pydra-tasks-mrtrix3 + run: git clone https://github.com/nipype/pydra-tasks-mrtrix3 + + - name: Install pydra-tasks-mrtrix3 and fileformats packages + run: pip install -e ./pydra-tasks-mrtrix3[test,dev] -e ./pydra-tasks-mrtrix3/related-packages/fileformats -e ./pydra-tasks-mrtrix3/related-packages/fileformats-extras + + - name: Generate Pydra task packages + run: python3 pydra-tasks-mrtrix3/generate.py /opt/mrtrix3/bin pydra-tasks-mrtrix3 3.1.0 + + - name: Test with pytest + run: pytest -sv pydra-tasks-mrtrix3/pydra/tasks/mrtrix3 --cov pydra.tasks.mrtrix3 --cov-report xml + + + diff --git a/.gitignore b/.gitignore index 38528667db..d9cf6aa55c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,11 +12,13 @@ *.hdr *.rste /python/lib/mrtrix3/_version.py +*.venv /scripts/mrtrix_bash_completion /dev/ /compiled_docs/ /.vscode/ .cproject +.DS_store .idea .project .settings diff --git a/cpp/cmd/amp2response.cpp b/cpp/cmd/amp2response.cpp index e0a7075882..6ef8bfc919 100644 --- a/cpp/cmd/amp2response.cpp +++ b/cpp/cmd/amp2response.cpp @@ -57,7 +57,7 @@ void usage() { ARGUMENTS + Argument ("amps", "the amplitudes image").type_image_in() + Argument ("mask", "the mask containing the voxels from which to estimate the response function").type_image_in() - + Argument ("directions", "a 4D image containing the estimated fibre directions").type_image_in() + + Argument ("fibre_directions", "a 4D image containing the estimated fibre directions").type_image_in() + Argument ("response", "the output zonal spherical harmonic coefficients").type_file_out(); OPTIONS diff --git a/cpp/cmd/peaks2amp.cpp b/cpp/cmd/peaks2amp.cpp index e673453737..e009296efb 100644 --- a/cpp/cmd/peaks2amp.cpp +++ b/cpp/cmd/peaks2amp.cpp @@ -30,7 +30,7 @@ void usage() { SYNOPSIS = "Extract amplitudes from a peak directions image"; ARGUMENTS - + Argument ("directions", "the input directions image." + + Argument ("fibre_directions", "the input directions image." " Each volume corresponds to the x, y & z" " component of each direction vector in turn.").type_image_in() diff --git a/cpp/cmd/peaks2fixel.cpp b/cpp/cmd/peaks2fixel.cpp index 1b80c83fa1..a788920d53 100644 --- a/cpp/cmd/peaks2fixel.cpp +++ b/cpp/cmd/peaks2fixel.cpp @@ -34,7 +34,7 @@ void usage() { + Fixel::format_description; ARGUMENTS - + Argument ("directions", "the input directions image;" + + Argument ("fibre_directions", "the input directions image;" " each volume corresponds to the x, y & z" " component of each direction vector in turn.").type_image_in() diff --git a/cpp/cmd/sh2amp.cpp b/cpp/cmd/sh2amp.cpp index c50729d05b..31c7bcd2d7 100644 --- a/cpp/cmd/sh2amp.cpp +++ b/cpp/cmd/sh2amp.cpp @@ -67,7 +67,7 @@ void usage() { ARGUMENTS + Argument ("input", "the input spherical harmonic (SH) coefficients image").type_image_in() - + Argument ("directions", "the set of directions along which the SH functions will be sampled").type_file_in() + + Argument ("fibre_directions", "the set of directions along which the SH functions will be sampled").type_file_in() + Argument ("output", "the output amplitudes image").type_image_out(); OPTIONS diff --git a/cpp/cmd/sh2response.cpp b/cpp/cmd/sh2response.cpp index 6a37182423..87e69b3668 100644 --- a/cpp/cmd/sh2response.cpp +++ b/cpp/cmd/sh2response.cpp @@ -42,7 +42,7 @@ void usage() { ARGUMENTS + Argument ("SH", "the spherical harmonic decomposition of the diffusion-weighted images").type_image_in() + Argument ("mask", "the mask containing the voxels from which to estimate the response function").type_image_in() - + Argument ("directions", "a 4D image containing the direction vectors along which to estimate the response function").type_image_in() + + Argument ("fibre_directions", "a 4D image containing the direction vectors along which to estimate the response function").type_image_in() + Argument ("response", "the output axially-symmetric spherical harmonic coefficients").type_file_out(); OPTIONS diff --git a/cpp/core/app.cpp b/cpp/core/app.cpp index 03fc1eff4d..9f849f7b76 100644 --- a/cpp/core/app.cpp +++ b/cpp/core/app.cpp @@ -782,6 +782,403 @@ std::string restructured_text_usage() { return s; } +std::string pydra_code() { + + constexpr std::array CMD_PREFIXES = { + "Fivett", "Afd", "Amp", "Connectome", "Dcm", "Dir", "Dwi", "Fixel", "Fod", "Label", "Mask", "Mesh", + "Mr", "Mt", "Peaks", "Sh", "Tck", "Tensor", "Transform", "Tsf", "Voxel", "Vector", "Warp"}; + + auto convert_to_pascal_case = [&](const std::string_view input) { + std::string result; + bool capitalizeNext = true; + for (char c : input) { + if (!result.size() && c == '5') { + result += "Five"; // handle 5tt prefixes so they don't create invalid Python identifiers + capitalizeNext = false; + } else if (std::isalpha(c) && capitalizeNext) { + result += std::toupper(c); + capitalizeNext = false; + } else { + result += c; + if (c == '2') + capitalizeNext = true; + for (const std::string_view prefix : CMD_PREFIXES) { + if (result == prefix) { + capitalizeNext = true; + break; + } + } + } + } + return result; + }; + + std::string name_string = convert_to_pascal_case(NAME); + // Check whether name starts with 5tt and escape the name if so + + if (name_string.length() > 3) { + std::string prefix = name_string.substr(0, 3); + if (!prefix.compare("5tt")) { + name_string = "Fivett" + name_string.substr(3, name_string.length()); + } + } + + std::string base_indent(" "); + std::string indent = base_indent + " "; + + constexpr std::array PYTHON_KEYWORDS = { + "and", "as", "assert", "break", "class", "continue", "def", "del", "elif", + "else", "except", "False", "finally", "for", "from", "global", "if", "import", + "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", + "return", "True", "try", "while", "with", "yield", "container", "image", "container_xargs"}; + + // Add import lines + std::string s = std::string("# Auto-generated from MRtrix C++ command with '__print_pydra_code__' secret option\n\n"); + s += "from typing import Any \n"; + s += "from pathlib import Path # noqa: F401\n"; + s += "from fileformats.generic import File, Directory # noqa: F401\n"; + s += "from fileformats.vendor.mrtrix3.medimage import ImageIn, ImageOut, Tracks # noqa: F401\n"; + s += "from pydra.compose import shell\n"; + s += "from pydra.utils.typing import MultiInputObj\n"; + + auto escape_id = [&](const std::string_view id) { + std::string escaped(id); + // Replace any spaces and periods with underscores + std::replace(escaped.begin(), escaped.end(), ' ', '_'); + std::replace(escaped.begin(), escaped.end(), '.', '_'); + // Append any Python keywords with an underscore + bool is_keyword = std::any_of(std::begin(PYTHON_KEYWORDS), + std::end(PYTHON_KEYWORDS), + [&id](const std::string_view kword) { return kword == id; }); + if (is_keyword) + escaped += "_"; + + return escaped; + }; + + auto format_type = [&](const ArgTypeFlags &types) { + std::string type; + if (types[ArgTypeFlags::Text]) + type += " | str"; + if (types[ArgTypeFlags::Boolean]) + type += " | bool"; + if (types[ArgTypeFlags::Integer]) + type += " | int"; + if (types[ArgTypeFlags::Float]) + type += " | float"; + if (types[ArgTypeFlags::FileIn]) + type += " | File"; + if (types[ArgTypeFlags::FileOut]) + type += " | File"; + if (types[ArgTypeFlags::DirectoryIn]) + type += " | Directory"; + if (types[ArgTypeFlags::DirectoryOut]) + type += " | Directory"; + if (types[ArgTypeFlags::Choice]) + type += " | str"; + if (types[ArgTypeFlags::ImageIn]) + type += " | ImageIn"; + if (types[ArgTypeFlags::ImageOut]) + type += " | ImageOut"; + if (types[ArgTypeFlags::IntSeq]) + type += " | list[int]"; + if (types[ArgTypeFlags::FloatSeq]) + type += " | list[float]"; + if (types[ArgTypeFlags::TracksIn]) + type += " | Tracks"; + if (types[ArgTypeFlags::TracksOut]) + type += " | Tracks"; + if (type.empty()) + type = "str"; + else + type = type.substr(3); // drop the preceding " | " + return type; + }; + + auto format_option_type = [&](const Option &opt) { + std::string f; + bool is_multi = (opt.flags.allow_multiple()) && (!opt.size() || !opt[0].types[ArgTypeFlags::FileOut]); + if (is_multi) { + f += "MultiInputObj["; + } + if (opt.empty()) { + f += "bool"; + } else if (opt.size() == 1) { + f += format_type(opt[0].types); + } else { + f += "tuple["; + for (size_t a = 0; a < opt.size(); ++a) { + f += format_type(opt[0].types); + if (a != opt.size() - 1) { + f += ", "; + } + } + f += "]"; + } + if (is_multi) { + f += "]"; + } + return f; + }; + + auto format_choices = [&](const Argument &arg) { + std::string f = indent + "allowed_values=["; + f += "\"" + arg.choices[0] + "\""; + for (int i = 1; i < arg.choices.size(); ++i) { + f += ", \"" + arg.choices[i] + "\""; + } + f += "],\n"; + return f; + }; + + auto format_output_template = [&](const std::string_view id, const ArgTypeFlags &types) { + std::string tmpl(id); + if (types[ArgTypeFlags::ImageOut]) { + tmpl += ".mif"; + } else if (types[ArgTypeFlags::TracksOut]) { + tmpl += ".tck"; + } else if (types[ArgTypeFlags::FileOut]) { + tmpl += ".txt"; + } + // TODO: Add special cases for file-out based on the 'id' where the extension + // is something else. + return tmpl; + }; + + auto format_output_templates = [&](const std::string_view id, const Option &opt) { + if (opt.size() == 1) + return "\"" + format_output_template(id, opt[0].types) + "\""; + std::string tmpl = "("; + for (size_t i = 0; i < opt.size(); ++i) + tmpl += "\"" + format_output_template(std::string(id) + MR::str(i), opt[i].types) + "\","; + tmpl += ")"; + return tmpl; + }; + + auto format_arg_name = [&](const Argument &arg) { + std::string id = arg.id; + std::string arg_name; + if (id == "input" && (arg.types[ArgTypeFlags::ImageIn] || arg.types[ArgTypeFlags::FileIn])) + arg_name = "in_file"; + else if (id == "input" && arg.types[ArgTypeFlags::DirectoryIn]) + arg_name = "in_dir"; + else if (id == "output" && (arg.types[ArgTypeFlags::ImageOut] || arg.types[ArgTypeFlags::FileOut])) + arg_name = "out_file"; + else if (id == "output" && arg.types[ArgTypeFlags::DirectoryOut]) + arg_name = "out_dir"; + else + arg_name = escape_id(arg.id); + return arg_name; + }; + + auto format_argument = [&](const Argument &arg, int position, bool is_output = false) { + bool is_multi = (arg.flags.allow_multiple()) && (!arg.types[ArgTypeFlags::FileOut]); + // Print name of field + std::string f = ""; + std::string arg_name = format_arg_name(arg); + f += base_indent + arg_name + ": "; + // Print type + std::string type = ""; + if (is_multi) { + if (is_output) + type += "list["; + else + type += "MultiInputObj["; + } + type += format_type(arg.types); + if (is_multi) { + type += "]"; + } + if (arg.flags.optional()) { + type += " | None"; + } + f += type; + if (is_output) + f += " = shell.outarg(\n"; + else + f += " = shell.arg(\n"; + // Print metadata fields + f += indent + "argstr=\"\",\n"; + f += indent + "position=" + std::to_string(position) + ",\n"; + if (arg.flags.optional()) { + if (arg.types[ArgTypeFlags::Boolean]) + f += indent + "default=False,\n"; + else + f += indent + "default=None,\n"; + } + if (is_output) { + f += indent + "path_template=\"" + format_output_template(arg_name, arg.types) + "\",\n"; + } + + f += indent + "help=\"\"\"" + arg.desc + "\"\"\",\n"; + + if (arg.types[ArgTypeFlags::Choice]) { + f += format_choices(arg); + } + f += base_indent + ")\n"; + return f; + }; + + auto format_option = [&](const Option &opt, bool is_output = false) { + // Print name of field + std::string f = base_indent + escape_id(opt.id) + ": "; + std::string type_string = format_option_type(opt); + bool is_multi = type_string.length() > 19 && type_string.substr(0, 19) == "MultiInputObj"; + if (is_output && !is_multi) { + type_string += "| bool | None"; + } else if (opt.flags.optional() && type_string != "bool" && type_string != "Any") { + type_string += " | None"; + } + // Print type + f += type_string; + if (is_output) + f += " = shell.outarg(\n"; + else + f += " = shell.arg(\n"; + if (opt.flags.optional()) { + if (type_string == "bool") + f += indent + "default=False,\n"; + else + f += indent + "default=None,\n"; + } + // Print metadata fields + f += indent + "argstr= \"-" + opt.id + "\",\n"; + if (is_output) { + f += indent + "path_template=" + format_output_templates(escape_id(opt.id), opt) + ",\n"; + } + f += indent + "help=\"\"\"" + opt.desc + "\"\"\",\n"; + if (opt.size() == 1 && (opt[0].types[ArgTypeFlags::IntSeq] || opt[0].types[ArgTypeFlags::FloatSeq])) { + f += indent + "sep=\",\",\n"; + } else if (opt.size() > 1) { + f += indent + "sep=\" \",\n"; + } + if (opt.size() == 1 && opt[0].types[ArgTypeFlags::Choice]) { + f += format_choices(opt[0]); + } + f += base_indent + ")\n"; + return f; + }; + + auto option_is_output = [&](const Option &opt) { + for (size_t i = 0; i < opt.size(); ++i) { + if (opt[i].types[ArgTypeFlags::ImageOut] || opt[i].types[ArgTypeFlags::FileOut] || + opt[i].types[ArgTypeFlags::DirectoryOut]) + return true; + } + return false; + }; + + auto argument_is_output = [&](const Argument &arg) { + return arg.types[ArgTypeFlags::ImageOut] || arg.types[ArgTypeFlags::FileOut] || + arg.types[ArgTypeFlags::DirectoryOut]; + }; + + // Create actual class + s += "\n\n@shell.define\nclass " + name_string + "(shell.Task[\"" + name_string + ".Outputs\"]):\n"; + s += " \"\"\""; + // Add description + if (!DESCRIPTION.empty()) { + for (size_t i = 0; i < DESCRIPTION.size(); ++i) + s += base_indent + std::string(DESCRIPTION[i]) + "\n\n"; + } + + if (!EXAMPLES.empty()) { + s += "\n" + base_indent + "Example usages\n" + base_indent + "--------------\n\n"; + for (size_t i = 0; i < EXAMPLES.size(); ++i) { + s += "\n" + base_indent + EXAMPLES[i].title + ":\n\n"; + s += base_indent + "`$ " + EXAMPLES[i].code + "`\n\n"; + if (EXAMPLES[i].description.size()) + s += base_indent + EXAMPLES[i].description + "\n"; + s += "\n"; + } + } + + s += "\n" + base_indent + "References\n" + base_indent + "----------\n\n"; + for (size_t i = 0; i < REFERENCES.size(); ++i) + s += indent + REFERENCES[i] + "\n\n"; + s += indent + core_reference + "\n\n"; + + s += "\n" + base_indent + "MRtrix\n" + base_indent + "------" + "\n\n" + base_indent + "Version:" + mrtrix_version + + ", built " + build_date + "\n\n" + base_indent + "Author: " + AUTHOR + "\n\n" + base_indent + + "Copyright: " + COPYRIGHT; + s += " \"\"\"\n"; + s += " executable = \"" + NAME + "\"\n"; + + s += "\n" + base_indent + "# Arguments\n"; + + // Print out input spec + for (size_t i = 0; i < ARGUMENTS.size(); ++i) { + if (!argument_is_output(ARGUMENTS[i])) + s += format_argument(ARGUMENTS[i], i + 1); + } + + s += "\n" + base_indent + "# Options\n"; + + std::vector group_names; + for (size_t i = 0; i < OPTIONS.size(); ++i) { + if (std::find(group_names.begin(), group_names.end(), OPTIONS[i].name) == group_names.end()) + group_names.push_back(OPTIONS[i].name); + } + for (size_t i = 0; i < group_names.size(); ++i) { + size_t n = i; + while (OPTIONS[n].name != group_names[i]) + ++n; + if (OPTIONS[n].name != std::string("OPTIONS")) + s += std::string("\n") + base_indent + "# " + OPTIONS[n].name + ":\n"; + while (n < OPTIONS.size()) { + if (OPTIONS[n].name == group_names[i]) { + for (size_t o = 0; o < OPTIONS[n].size(); ++o) { + if (!option_is_output(OPTIONS[n][o])) { + s += format_option(OPTIONS[n][o]); + } + } + } + ++n; + } + } + + s += "\n" + base_indent + "# Standard options\n"; + for (size_t i = 0; i < __standard_options.size(); ++i) + if (__standard_options[i].id != str("help") && __standard_options[i].id != str("version")) + s += format_option(__standard_options[i]); + + s += "\n" + base_indent + "class Outputs(shell.Outputs):\n"; + + // Add an additional indent + base_indent += " "; + indent += " "; + + int n_outputs = 0; + for (size_t i = 0; i < ARGUMENTS.size(); ++i) { + if (argument_is_output(ARGUMENTS[i])) { + s += format_argument(ARGUMENTS[i], i + 1, true); + n_outputs++; + } + } + + for (size_t i = 0; i < group_names.size(); ++i) { + size_t n = i; + while (OPTIONS[n].name != group_names[i]) + ++n; + while (n < OPTIONS.size()) { + if (OPTIONS[n].name == group_names[i]) { + for (size_t o = 0; o < OPTIONS[n].size(); ++o) { + if (option_is_output(OPTIONS[n][o])) { + s += format_option(OPTIONS[n][o], true); + n_outputs++; + } + } + } + ++n; + } + } + + if (n_outputs == 0) + s += base_indent + "pass\n"; + + return s; +} + const Option *match_option(std::string_view arg) { auto no_dash_arg = without_leading_dash(arg); if (arg.size() == no_dash_arg.size() || no_dash_arg.empty() || isdigit(no_dash_arg.front()) != 0 || @@ -881,6 +1278,10 @@ void parse_special_options() { print(restructured_text_usage()); throw 0; } + if (raw_arguments_list.front() == "__print_pydra_code__") { + print(pydra_code()); + throw 0; + } if (raw_arguments_list.front() == "__print_synopsis__") { print(SYNOPSIS); throw 0; @@ -925,11 +1326,11 @@ void parse() { } if (num_optional_arguments && num_args_required > argument.size()) - throw Exception("Expected at least " + str(num_args_required) + " arguments (" + str(argument.size()) + + throw Exception("Expected at least " + str(num_args_required) + " arguments (" + str(!argument.empty()) + " supplied)"); if (num_optional_arguments == 0 && num_args_required != argument.size()) { - Exception e("Expected exactly " + str(num_args_required) + " arguments (" + str(argument.size()) + " supplied)"); + Exception e("Expected exactly " + str(num_args_required) + " arguments (" + str(!argument.empty()) + " supplied)"); std::string s = "Usage: " + NAME; for (const auto &a : ARGUMENTS) s += " " + std::string(a.id); diff --git a/docs/reference/commands/amp2response.rst b/docs/reference/commands/amp2response.rst index 67548381f0..168bed3de8 100644 --- a/docs/reference/commands/amp2response.rst +++ b/docs/reference/commands/amp2response.rst @@ -13,11 +13,11 @@ Usage :: - amp2response [ options ] amps mask directions response + amp2response [ options ] amps mask fibre_directions response - *amps*: the amplitudes image - *mask*: the mask containing the voxels from which to estimate the response function -- *directions*: a 4D image containing the estimated fibre directions +- *fibre_directions*: a 4D image containing the estimated fibre directions - *response*: the output zonal spherical harmonic coefficients Description diff --git a/docs/reference/commands/peaks2amp.rst b/docs/reference/commands/peaks2amp.rst index 9dd02a0465..48f687945a 100644 --- a/docs/reference/commands/peaks2amp.rst +++ b/docs/reference/commands/peaks2amp.rst @@ -13,9 +13,9 @@ Usage :: - peaks2amp [ options ] directions amplitudes + peaks2amp [ options ] fibre_directions amplitudes -- *directions*: the input directions image. Each volume corresponds to the x, y & z component of each direction vector in turn. +- *fibre_directions*: the input directions image. Each volume corresponds to the x, y & z component of each direction vector in turn. - *amplitudes*: the output amplitudes image. Options diff --git a/docs/reference/commands/peaks2fixel.rst b/docs/reference/commands/peaks2fixel.rst index 867df8ac7b..6630b5a8cc 100644 --- a/docs/reference/commands/peaks2fixel.rst +++ b/docs/reference/commands/peaks2fixel.rst @@ -13,9 +13,9 @@ Usage :: - peaks2fixel [ options ] directions fixels + peaks2fixel [ options ] fibre_directions fixels -- *directions*: the input directions image; each volume corresponds to the x, y & z component of each direction vector in turn. +- *fibre_directions*: the input directions image; each volume corresponds to the x, y & z component of each direction vector in turn. - *fixels*: the output fixel directory. Description diff --git a/docs/reference/commands/sh2amp.rst b/docs/reference/commands/sh2amp.rst index 356cdb455c..b4f7466935 100644 --- a/docs/reference/commands/sh2amp.rst +++ b/docs/reference/commands/sh2amp.rst @@ -13,10 +13,10 @@ Usage :: - sh2amp [ options ] input directions output + sh2amp [ options ] input fibre_directions output - *input*: the input spherical harmonic (SH) coefficients image -- *directions*: the set of directions along which the SH functions will be sampled +- *fibre_directions*: the set of directions along which the SH functions will be sampled - *output*: the output amplitudes image Description diff --git a/docs/reference/commands/sh2response.rst b/docs/reference/commands/sh2response.rst index a477ae6e46..304d9a7cd5 100644 --- a/docs/reference/commands/sh2response.rst +++ b/docs/reference/commands/sh2response.rst @@ -13,11 +13,11 @@ Usage :: - sh2response [ options ] SH mask directions response + sh2response [ options ] SH mask fibre_directions response - *SH*: the spherical harmonic decomposition of the diffusion-weighted images - *mask*: the mask containing the voxels from which to estimate the response function -- *directions*: a 4D image containing the direction vectors along which to estimate the response function +- *fibre_directions*: a 4D image containing the direction vectors along which to estimate the response function - *response*: the output axially-symmetric spherical harmonic coefficients Description diff --git a/python/mrtrix3/app.py b/python/mrtrix3/app.py index 9b3e54af50..88b7b9d41c 100644 --- a/python/mrtrix3/app.py +++ b/python/mrtrix3/app.py @@ -13,9 +13,16 @@ # # For more details, see http://www.mrtrix.org/. -import argparse, importlib, inspect, math, os, pathlib, random, shlex, shutil, signal, string, subprocess, sys, textwrap, time +import argparse, importlib, inspect, math, os, pathlib, random, shlex, shutil, signal, string, subprocess, sys, textwrap, time, re +from keyword import kwlist as PYTHON_KEYWORDS from mrtrix3 import ANSI, CONFIG, MRtrixError, setup_ansi from mrtrix3 import utils, version +try: + import black.parsing +except ImportError: + HAVE_BLACK = False +else: + HAVE_BLACK = True @@ -147,6 +154,9 @@ def _execute(usage_function, execute_function): #pylint: disable=unused-variable elif sys.argv[-1] == '__print_usage_rst__': CMDLINE.print_usage_rst() sys.exit(0) + elif sys.argv[-1] == '__print_pydra_code__': + CMDLINE.print_pydra_code() + sys.exit(0) # Do the main command-line input parsing ARGS = CMDLINE.parse_args() @@ -576,6 +586,7 @@ def _get_message(self): # MRtrix3 binaries, and defining functions for exporting the help page for the purpose of # automated self-documentation. +# pylint: disable=too-many-public-methods class Parser(argparse.ArgumentParser): # Function that will create a new class, @@ -738,6 +749,20 @@ def _legacytypestring(): def _metavar(): return 'values' + + # Would you mind if this is defined here instead of in commands.population_template.usage + # makes it much easiery to import and include in instance checks for pydra auto-gen?? + class SequenceDirectoryOut(CustomTypeBase): + def __call__(self, input_value): + return [Parser.make_userpath_object(Parser._UserDirOutPathExtras, item) # pylint: disable=protected-access \ + for item in input_value.split(',')] + @staticmethod + def _legacytypestring(): + return 'SEQDIROUT' + @staticmethod + def _metavar(): + return 'directory_list' + class DirectoryIn(CustomTypeBase): def __call__(self, input_value): abspath = Parser.make_userpath_object(Parser._UserPathExtras, input_value) @@ -1470,6 +1495,242 @@ def print_group_options(group): alg, '__print_usage_rst__']) + def print_pydra_code(self): + + if self._subparsers: + + if len(sys.argv) == 3: + for alg in self._subparsers._group_actions[0].choices: + if alg == sys.argv[-2]: + self._subparsers._group_actions[0].choices[alg].print_pydra_code() + return + self.error('Invalid subparser nominated: ' + sys.argv[-2]) + assert len(sys.argv) == 2 + sys.stdout.write(",".join(self._subparsers._group_actions[0].choices)) + sys.stdout.flush() + return + + def get_arg_metadata(arg): + kwds = { + "help": arg.help, + } + if arg.choices: + kwds["allowed_values"] = list(arg.choices) + return kwds + + def parse_type(type_, optional: bool = False): + if type_ is str or type_ is None: + type_str = "str" + elif isinstance(type_, Parser.Bool): + type_str = "bool" + elif type(type_).__name__ == "IntBounded": + type_str = "int" + elif type(type_).__name__ == "FloatBounded": + type_str = "float" + elif isinstance(type_, Parser.FileIn): + type_str = "File" + elif isinstance(type_, Parser.FileOut): + type_str = "File" + elif isinstance(type_, Parser.DirectoryIn): + type_str = "Directory" + elif isinstance(type_, Parser.DirectoryOut): + type_str = "Directory" + elif isinstance(type_, Parser.SequenceDirectoryOut): + type_str = "list[Directory]" + elif isinstance(type_, Parser.ImageIn): + type_str = "ImageIn" + elif isinstance(type_, Parser.ImageOut): + type_str = "ImageOut" + elif isinstance(type_, Parser.SequenceInt): + type_str = "list[int]" + elif isinstance(type_, Parser.SequenceFloat): + type_str = "list[float]" + elif isinstance(type_, Parser.TracksIn): + type_str = "Tracks" + elif isinstance(type_, Parser.TracksOut): + type_str = "Tracks" + else: + warn("Unrecognized type: " + str(type_) + " defaulting to Any") + type_str = "Any" + if optional: + type_str += " | None" + return type_str + + def escape_id(id_: str) -> str: + if id_ == "input": + escaped = "in_file" + elif id_ == "output": + escaped = "out_file" + elif id_ in list(PYTHON_KEYWORDS) + ["container", "image", "container_xargs"]: + escaped = id_ + "_" + else: + escaped = id_ + escaped = escaped.replace(".", '_') + return escaped + + def is_output(arg_option) -> bool: + typ = arg_option.type + if isinstance(typ, (Parser.FileOut, Parser.DirectoryOut, Parser.ImageOut, Parser.SequenceDirectoryOut)): + return True + return hasattr(typ, "_legacytypestring") and typ._legacytypestring().endswith("OUT") + + inputs = [] + outputs = [] + input_names = [a.dest for a in self._positionals._group_actions] + for pos, arg in enumerate(self._positionals._group_actions, start=1): + kwds = { + "position": pos, + "argstr": "", + "help": arg.help, + } + if arg.choices: + kwds["allowed_values"] = list(arg.choices) + arg_id = escape_id(arg.dest) + type_ = parse_type(arg.type) + if is_output(arg): + if isinstance(arg.type, Parser.ImageOut): + ext = ".mif" + elif isinstance(arg.type, Parser.FileOut): + ext = ".txt" + else: + ext = "" + kwds["path_template"] = arg_id + ext + (outputs if is_output(arg) else inputs).append( + ( + arg_id, + type_, + kwds, + ) + ) + for group in reversed(self._action_groups): + for option in group._group_actions: + if option.dest in input_names: + continue + if isinstance(option, argparse._StoreTrueAction): + assert option.type is None + type_ = "bool" + else: + type_ = parse_type(option.type, optional=True) + if isinstance(option, argparse._AppendAction): + if is_output(option): + type_ = "list" + else: + type_ = "MultiInputObj" + type_ += f"[{type_}]" + kwds = get_arg_metadata(option) + kwds["argstr"] = "-" + option.dest + if type_ == "bool": + kwds["default"] = False + else: + kwds["default"] = None + if is_output(option): + if isinstance(option.type, Parser.ImageOut): + ext = ".mif" + elif isinstance(option.type, Parser.FileOut): + ext = ".txt" + else: + ext = "" + kwds["path_template"] = escape_id(option.dest) + ext + (outputs if is_output(option) else inputs).append( + ( + escape_id(option.dest), + type_, + kwds, + ) + ) + # Replace # escapes + inputs_str = "" + outputs_str = "" + indent = " " + md_indent = indent + " " + for inpt_name, type_, kwds in inputs: + inputs_str += f"{indent}{inpt_name}: {type_} = shell.arg(\n{indent} " + inputs_str += f"\n{md_indent}".join(f"{k}={v!r}," for k, v in kwds.items()) + inputs_str += f"\n{indent})\n" + for outpt_name, type_, kwds in outputs: + outputs_str += f"{indent} {outpt_name}: {type_} = shell.outarg(\n{indent} " + outputs_str += f"\n{md_indent} ".join(f"{k}={v!r}," for k, v in kwds.items()) + outputs_str += f"\n{indent} )\n" + + def cmd_to_task_name(cmd_name: str) -> str: + """Get Task class name from cmd name""" + if cmd_name == "population_template": + return "PopulationTemplate" + task_name = cmd_name.replace(" ", "_") + if task_name[0] == "5": + task_name = "five" + task_name[1:] + cmd_prefixes = [ + "fivett", "afd", "amp", "connectome", "dcm", "dir", "dwi", + "fixel", "fod", "label", "mask", "mesh", "mr", "mt", + "peaks", "response", "sh", "tck", "transform", "tsf", "voxel", "vector" + ] + # convert to PascalCase + task_name = "".join( + g.capitalize() + for g in re.match(rf"({'|'.join(cmd_prefixes)})(2?)([^_]+)(_?)(.*)", task_name).groups() + ) + return task_name + + task_name = cmd_to_task_name(self.prog) + + if self._mutually_exclusive_option_groups: + xor = [] + for group, required in self._mutually_exclusive_option_groups: + xor_set = list(group) + if not required: + xor_set.append(None) + xor.append(xor_set) + xor_str = f"(xor={xor!r})" + else: + xor_str = "" + + text = ( + "# Auto-generated by mrtrix3/app.py:print_pydra_code()\n\n" + "from typing import Any\n" + "from pathlib import Path # noqa: F401\n" + "from fileformats.generic import FsObject, File, Directory # noqa: F401\n" + "from fileformats.vendor.mrtrix3.medimage import Tracks, ImageIn, ImageOut # noqa: F401\n" + "from pydra.utils.typing import MultiInputObj\n" + "from pydra.compose import shell\n" + ) + + text += f"\n\n@shell.define{xor_str}\nclass {task_name}(shell.Task[\"{task_name}.Outputs\"]):\n" + indent = " " + text += indent + "\"\"\"\n" + text += indent + (self.description if self.description else "").replace("\n", "\n ") + "\n" + text += indent + "References\n" + text += indent + "----------\n\n" + for ref in self._citation_list: + ref_text = indent + "* " + if ref[0]: + ref_text += ref[0] + ': ' + ref_text += ref[1] + text += ref_text + '\n\n' + text += indent + _MRTRIX3_CORE_REFERENCE.replace("\n", "\n ") + '\n\n' + text += indent + '--------------\n\n\n\n' + text += indent + '**Author:** ' + self._author + '\n\n' + text += indent + '**Copyright:** ' + self._copyright.replace("\n", "\n ") + '\n\n' + text += indent + "\"\"\"\n" + if " " in self.prog: + executable = tuple(self.prog.split(" ")) + else: + executable = self.prog + text += f" executable={executable!r}\n\n" + text += inputs_str + if outputs_str: + text += f"\n\n{indent}class Outputs(shell.Outputs):\n" + text += outputs_str + + if HAVE_BLACK: + try: + text = black.format_file_contents( + text, fast=False, mode=black.FileMode() + ) + except black.parsing.InvalidInput: + pass + sys.stdout.write(text) + sys.stdout.flush() + def print_version(self): text = f'== {self.prog} {self._git_version if self._is_project else version.VERSION} ==\n' if self._is_project: diff --git a/python/mrtrix3/commands/population_template/usage.py b/python/mrtrix3/commands/population_template/usage.py index e76741f86a..2908ac468d 100644 --- a/python/mrtrix3/commands/population_template/usage.py +++ b/python/mrtrix3/commands/population_template/usage.py @@ -33,16 +33,7 @@ REGISTRATION_MODES -class SequenceDirectoryOut(app.Parser.CustomTypeBase): - def __call__(self, input_value): - return [app.Parser.make_userpath_object(app.Parser._UserDirOutPathExtras, item) # pylint: disable=protected-access \ - for item in input_value.split(',')] - @staticmethod - def _legacytypestring(): - return 'SEQDIROUT' - @staticmethod - def _metavar(): - return 'directory_list' + class DirectoryInOrImageOut(app.Parser.CustomTypeBase): @@ -247,7 +238,7 @@ def usage(cmdline): #pylint: disable=unused-variable help='Output a directory containing warps from each input to the template.' ' If the folder does not exist it will be created') options.add_argument('-transformed_dir', - type=SequenceDirectoryOut(), + type=app.Parser.SequenceDirectoryOut(), help='Output a directory containing the input images transformed to the template.' ' If the folder does not exist it will be created.' ' For multi-contrast registration,'