Repository: niflows/niflow-manager

Issue:
Add BIDS App option to nfm init
Talking to @satra, we should create a template for creating a BIDS app. For now, we'll focus on Python, in keeping with prior work.

I think it should probably be versioned, and we'll consider the paper (doi:[https://doi.org/10.1371/journal.pcbi.1005209](10.1371/journal.pcbi.1005209)) version 1.0, so my thought for syntax would be:

```Shell
nfm init --bids-app 1.0 --language python
```

cc @shotgunosine

Discussion:

Patch:
diff --git a/niflow_manager/cli/init.py b/niflow_manager/cli/init.py
index 05fc627..35d748b 100755
--- a/niflow_manager/cli/init.py
+++ b/niflow_manager/cli/init.py
@@ -5,7 +5,7 @@ import subprocess as sp
 import click
 
 from ..util.git import git_variables
-from ..util.fsutil import copytree
+from ..util.fsutil import copytree, CopyPolicy
 
 
 def normalize_path(name):
@@ -43,7 +43,9 @@ def normalize_path(name):
 
 @click.argument('name', type=click.Path(), default='.')
 @click.option('--language', help='Language for new niflow')
-def init(name, language):
+@click.option('--bids', 'bids_version', type=click.Choice(['1.0']),
+              help='Niflow intended as a BIDS App')
+def init(name, language, bids_version):
     path, full_name, organization, workflow = normalize_path(name)
 
     click.echo(f'Initializing workflow: {path.name} in {path.parent}')
@@ -75,3 +77,13 @@ def init(name, language):
             copytree(language_dir, path, mapping=mapping)
         except FileNotFoundError:
             raise ValueError(f"Unknown language: {language}")
+
+    if bids_version is not None:
+        if language is None:
+            raise ValueError("BIDS App templates are language-specific; please specify --language")
+        bids_app_dir = Path(pkgr_fn('niflow_manager',
+                                    f'data/templates/{language}-bidsapp-{bids_version}'))
+        try:
+            copytree(bids_app_dir, path, policy=CopyPolicy.OVERWRITE, mapping=mapping)
+        except FileNotFoundError:
+            raise ValueError(f"No BIDS App template for language: {language}")
diff --git a/niflow_manager/data/templates/python-bidsapp-1.0/package/setup.cfg b/niflow_manager/data/templates/python-bidsapp-1.0/package/setup.cfg
new file mode 100644
index 0000000..2d0f26e
--- /dev/null
+++ b/niflow_manager/data/templates/python-bidsapp-1.0/package/setup.cfg
@@ -0,0 +1,33 @@
+[metadata]
+name = {FULLNAME}
+version = attr: niflow.{ORGANIZATION}.{WORKFLOW}.__version__
+url = https://github.com/niflows/{FULLNAME}
+author = {USERNAME}
+author_email = {USEREMAIL}
+description = {FULLNAME} workflow package
+license = Apache License, 2.0
+classifiers =
+    Development Status :: 2 - Pre-Alpha
+    Environment :: Console
+    Intended Audience :: Developers
+    License :: OSI Approved :: Apache Software License
+    Operating System :: OS Independent
+    Programming Language :: Python :: 3
+    Topic :: Scientific/Engineering
+
+[options]
+packages = find:
+# Place any Python dependencies here
+install_requires = 
+    pybids
+# Uncomment the following line if your package should distribute any non-Python files
+# include_package_data = True
+
+[options.entry_points]
+console_scripts =
+    {FULLNAME} = niflow.{ORGANIZATION}.{WORKFLOW}.cli:main
+
+## If your workflow requires data to run, uncomment and update the following section:
+# [options.package_data]
+# {FULLNAME} =
+#     niflow/{ORGANIZATION}/{WORKFLOW}/data/*
diff --git a/niflow_manager/data/templates/python-bidsapp-1.0/package/{ORGANIZATION}/{WORKFLOW}/cli.py b/niflow_manager/data/templates/python-bidsapp-1.0/package/{ORGANIZATION}/{WORKFLOW}/cli.py
new file mode 100644
index 0000000..fb4f1ca
--- /dev/null
+++ b/niflow_manager/data/templates/python-bidsapp-1.0/package/{ORGANIZATION}/{WORKFLOW}/cli.py
@@ -0,0 +1,48 @@
+# This file provides a user-facing command-line interface (CLI) to your BIDS app
+from argparse import ArgumentParser
+from argparse import RawTextHelpFormatter
+import os.path as op
+from . import __version__
+from .workflow import init_{WORKFLOW}_wf
+
+
+# This parser includes the options that bids apps are required to accept
+# as well as a few additional parameters that are nice
+def get_parser():
+    """Build parser object"""
+    verstr = 'fitlins v{{}}'.format(__version__)
+
+    parser = ArgumentParser(description='Describe your BIDS app here',
+                            formatter_class=RawTextHelpFormatter)
+
+    # Arguments as specified by BIDS-Apps
+    # required, positional arguments
+    # IMPORTANT: they must go directly with the parser object
+    parser.add_argument('bids_dir', action='store', type=op.abspath,
+                        help='the root folder of a BIDS valid dataset (sub-XXXXX folders should '
+                             'be found at the top level in this folder).')
+    parser.add_argument('output_dir', action='store', type=op.abspath,
+                        help='the output path for the outcomes of preprocessing and visual '
+                             'reports')
+    parser.add_argument('analysis_level', choices=['run', 'session', 'participant', 'dataset'],
+                        help='processing stage to be runa (see BIDS-Apps specification).')
+
+    # optional arguments
+    parser.add_argument('-v', '--version', action='version', version=verstr)
+
+    g_bids = parser.add_argument_group('Options for filtering BIDS queries')
+    g_bids.add_argument('--participant-label', action='store', nargs='+', default=None,
+                        help='one or more participant identifiers (the sub- prefix can be '
+                             'removed)')
+    g_bids.add_argument('--derivative-label', action='store', type=str,
+                        help='execution label to append to derivative directory name')
+
+
+# The main function is what will be run when niflow-{ORGANIZATION}-{WORKFLOW} is called
+# Command-line arguments are available via the sys.argv list, though you should probably
+# use the parser object defined above as it already includes the required BIDS app
+# paramters. 
+def main():
+    opts = get_parser().parse_args()
+    wf = init_{WORKFLOW}_wf(opts)
+    wf.run()
diff --git a/niflow_manager/data/templates/python-bidsapp-1.0/package/{ORGANIZATION}/{WORKFLOW}/workflow.py b/niflow_manager/data/templates/python-bidsapp-1.0/package/{ORGANIZATION}/{WORKFLOW}/workflow.py
new file mode 100644
index 0000000..406035e
--- /dev/null
+++ b/niflow_manager/data/templates/python-bidsapp-1.0/package/{ORGANIZATION}/{WORKFLOW}/workflow.py
@@ -0,0 +1,30 @@
+# This file demonstrates a workflow-generating function, a particular convention for generating
+# nipype workflows. Others are possible.
+
+# Every workflow need pe.Workflow [0] and pe.Node [1], and most will need basic utility
+# interfaces [2].
+# [0] https://nipype.rtfd.io/en/latest/api/generated/nipype.pipeline.engine.workflows.html
+# [1] https://nipype.rtfd.io/en/latest/api/generated/nipype.pipeline.engine.nodes.html
+# [2] https://nipype.rtfd.io/en/latest/interfaces/generated/nipype.interfaces.utility/base.html
+from nipype.pipeline import engine as pe
+from nipype.interfaces import utility as niu
+
+def init_{WORKFLOW}_wf(opts, name='{WORKFLOW}_wf'):
+    wf = pe.Workflow(name=name)
+
+    # inputnode/outputnode can be thought of as the parameters and return values of a function
+    inputnode = pe.Node(niu.IdentityInterface(['in_file']), name='inputnode')
+    outputnode = pe.Node(niu.IdentityInterface(['out_file']), name='outputnode')
+
+    #
+    # The rest of the workflow should be defined here.
+    #
+
+    wf.connect([
+        ## Connect fields from one interface to another, e.g.,
+        # (inputnode, some_node_1, [('in_file', 'in_file')]),
+        # ...
+        # (some_node_n, outputnode, [('out_file', 'out_file')]),
+        ])
+
+    return wf


Test patch:
diff --git a/niflow_manager/cli/tests/test_init.py b/niflow_manager/cli/tests/test_init.py
index a19c5ab..b3ba532 100644
--- a/niflow_manager/cli/tests/test_init.py
+++ b/niflow_manager/cli/tests/test_init.py
@@ -4,6 +4,8 @@ import pytest
 from click.testing import CliRunner
 from .. import main
 
+GIT_AUTHOR = 'Test Author'
+GIT_EMAIL = 'unreal3214@fake2182.tld'
 
 @pytest.mark.parametrize(
     "directory, organization, workflow, override, target, abort",
@@ -48,11 +50,17 @@ def test_init_path_selection(directory, organization, workflow, override, target
             assert (targ_path / '.git').exists()
 
 
-@pytest.mark.parametrize(
-    "author, email, gitconfig",
-    [('Test Author', 'unreal3214@fake2182.tld', os.devnull),
-     ('Test Author', 'unreal3214@fake2182.tld', 'tmp_gitconfig')])
-def test_init_python(author, email, gitconfig):
+def test_init_unknown_language():
+    runner = CliRunner()
+    args = ['init', 'niflow-org-wf', '--language', 'unsupportedlanguage']
+    with runner.isolated_filesystem():
+        result = runner.invoke(main, args, None)
+        assert result.exit_code != 0
+        assert "Unknown language" in result.exception.args[0]
+
+
+@pytest.mark.parametrize("gitconfig", [os.devnull, 'tmp_gitconfig'])
+def test_init_python(gitconfig):
     runner = CliRunner()
 
     args = ['init', 'niflow-org-wf', '--language', 'python']
@@ -61,10 +69,10 @@ def test_init_python(author, email, gitconfig):
         inputs = None
         if gitconfig != os.devnull:
             config_path = Path(gitconfig)
-            config_path.write_text(f'[user]\n\temail = {email}\n\tname = {author}')
+            config_path.write_text(f'[user]\n\temail = {GIT_EMAIL}\n\tname = {GIT_AUTHOR}')
             gitconfig = str(config_path.absolute())
         else:
-            inputs = '\n'.join([author, email])
+            inputs = '\n'.join([GIT_AUTHOR, GIT_EMAIL])
 
         result = runner.invoke(main, args, inputs, env={'GIT_CONFIG': gitconfig})
 
@@ -76,3 +84,42 @@ def test_init_python(author, email, gitconfig):
     else:
         assert 'Enter package author name: ' not in result.stdout
         assert 'Enter package author email: ' not in result.stdout
+
+
+def test_init_python_bidsapp():
+    runner = CliRunner()
+
+    args = ['init', 'niflow-org-bidsapp', '--language', 'python', '--bids', '1.0']
+
+    with runner.isolated_filesystem():
+        config_path = Path('tmp_gitconfig')
+        config_path.write_text(f'[user]\n\temail = {GIT_EMAIL}\n\tname = {GIT_AUTHOR}')
+        gitconfig = str(config_path.absolute())
+
+        result = runner.invoke(main, args, None, env={'GIT_CONFIG': gitconfig})
+
+        assert 'pybids' in Path('niflow-org-bidsapp/package/setup.cfg').read_text()
+
+    assert result.exit_code == 0
+
+
+@pytest.mark.parametrize("language", [None, "base"])
+def test_init_bidsapp(language):
+    runner = CliRunner()
+
+    args = ['init', 'niflow-org-bidsapp', '--bids', '1.0']
+    if language is not None:
+        args.extend(['--language', language])
+
+    with runner.isolated_filesystem():
+        config_path = Path('tmp_gitconfig')
+        config_path.write_text(f'[user]\n\temail = {GIT_EMAIL}\n\tname = {GIT_AUTHOR}')
+        gitconfig = str(config_path.absolute())
+
+        result = runner.invoke(main, args, None, env={'GIT_CONFIG': gitconfig})
+
+    assert result.exit_code != 0
+    if language is None:
+        assert 'language-specific' in result.exception.args[0]
+    else:
+        assert 'No BIDS App template' in result.exception.args[0]