Contributing to VoxelOps
First off, thank you for considering contributing to VoxelOps! We welcome contributions of all kinds, from bug fixes to new features. This guide focuses on how to add a new “runner” to the project.
What is a Runner?
In VoxelOps, a “runner” is a Python function that wraps a command-line tool, typically a Docker-based neuroimaging tool. The goal is to provide a simple, consistent Python interface for these tools.
A runner is composed of three main parts:
The Runner Function: This is the main function that users will call. It takes an
inputsobject and an optionalconfigobject, builds and executes a command, and returns a dictionary of results. (e.g.,src/voxelops/runners/qsiprep.py)The Schemas: These are dataclasses that define the inputs, default parameters, and expected outputs for the runner. They provide type hinting and validation. (e.g.,
src/voxelops/schemas/qsiprep.py)Tests: Each runner should have corresponding tests to ensure it works correctly. (e.g.,
tests/test_runners_qsiprep.py)
Step-by-Step Guide to Adding a New Runner
Let’s say we want to add a new runner for a tool called mytool.
### 1. Create the Schema File
First, create a new file in src/voxelops/schemas/ named mytool.py. In
this file, you’ll define three dataclasses:
MyToolInputs: Required inputs for your tool, likebids_dirorparticipant.MyToolDefaults: Default parameters for the tool, like the Docker image name or the number of processors.MyToolOutputs: The expected outputs of the tool, like file paths.
Here’s an example for src/voxelops/schemas/mytool.py:
from dataclasses import dataclass
from pathlib import Path
@dataclass
class MyToolInputs:
bids_dir: Path
participant: str
output_dir: Path | None = None
work_dir: Path | None = None
@dataclass
class MyToolDefaults:
docker_image: str = "myorg/mytool:latest"
nprocs: int = 2
@dataclass
class MyToolOutputs:
output_file: Path
@classmethod
def from_inputs(cls, inputs: MyToolInputs, output_dir: Path) -> "MyToolOutputs":
return cls(
output_file=output_dir / f"sub-{inputs.participant}" / "output.txt"
)
### 2. Create the Runner Function File
Next, create the main runner file in src/voxelops/runners/, also named
mytool.py. This file will contain the run_mytool function.
This function should:
Accept
inputs(MyToolInputs) and optionalconfig(MyToolDefaults).Use helpers from
voxelops.runners._baseto validate inputs.Construct the full
docker runcommand as a list of strings.Call
run_dockerfrom the base module to execute the command.Return the resulting execution dictionary, augmented with inputs, config, and expected outputs.
Here is an example for src/voxelops/runners/mytool.py:
import os
from pathlib import Path
from typing import Dict, Optional, Any
from voxelops.runners._base import (
run_docker,
validate_input_dir,
validate_participant,
)
from voxelops.schemas.mytool import (
MyToolInputs,
MyToolOutputs,
MyToolDefaults,
)
def run_mytool(
inputs: MyToolInputs, config: Optional[MyToolDefaults] = None, **overrides
) -> Dict[str, Any]:
config = config or MyToolDefaults()
for key, value in overrides.items():
if hasattr(config, key):
setattr(config, key, value)
validate_input_dir(inputs.bids_dir, "BIDS")
validate_participant(inputs.bids_dir, inputs.participant)
output_dir = inputs.output_dir or (inputs.bids_dir.parent / "derivatives")
work_dir = inputs.work_dir or (output_dir.parent / "work" / "mytool")
output_dir.mkdir(parents=True, exist_ok=True)
work_dir.mkdir(parents=True, exist_ok=True)
expected_outputs = MyToolOutputs.from_inputs(inputs, output_dir)
uid = os.getuid()
gid = os.getgid()
cmd = [
"docker", "run", "-ti", "--rm",
"--user", f"{uid}:{gid}",
"-v", f"{inputs.bids_dir}:/data:ro",
"-v", f"{output_dir}:/out",
"-v", f"{work_dir}:/work",
config.docker_image,
"/data", "/out", "participant",
"--participant-label", inputs.participant,
"--nprocs", str(config.nprocs),
]
log_dir = output_dir.parent / "logs"
result = run_docker(
cmd=cmd,
tool_name="mytool",
participant=inputs.participant,
log_dir=log_dir,
)
result["inputs"] = inputs
result["config"] = config
result["expected_outputs"] = expected_outputs
return result
### 3. Add the Runner to the __init__.py
Make your new runner easily importable by adding it to
src/voxelops/runners/__init__.py:
# src/voxelops/runners/__init__.py
...
from .mytool import run_mytool
...
And also to the main __init__.py in src/voxelops/__init__.py:
# src/voxelops/__init__.py
...
from .runners import (
...
run_mytool,
)
...
__all__ = [
...
"run_mytool",
]
### 4. Write Tests
Finally, add tests for your new runner. Create a new file
tests/test_runners_mytool.py. You should at least test:
That the runner function runs without errors (you can mock the
subprocess.runcall).That the Docker command is built correctly.
That input validation works as expected.
Refer to existing tests like tests/test_runners_qsiprep.py for examples.
### 5. Update Documentation
If you’ve added a new runner, add it to the list of available procedures in
docs/index.rst and create a new .rst file for your runner in the
docs/source/ folder.
Final Words
Once you’ve followed these steps, open a pull request on GitHub. We’ll review your contribution and work with you to get it merged.
Thank you for helping us make VoxelOps better!