Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BidsValidator Plugin #291

Merged
merged 13 commits into from
Jun 23, 2023
71 changes: 54 additions & 17 deletions docs/bids_app/plugins.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,69 @@
# Plugins

Plugins are a Snakebids feature that allow you to add arbitrary behaviour to your Snakebids app after CLI arguments are parsed but before Snakemake is invoked. For example, you might add BIDS validation of an input dataset to your app via a plugin, so your app is only run if the input dataset is valid.
Plugins are a feature in Snakebids that enables you to add custom functionality to your Snakebids application after parsing CLI arguments but before invoking Snakemake. For example, you can use a plugin to perform BIDS validation of your Snakebids app's input, which ensures your app is only executed if the input dataset is valid. You can either use those that are distributed with Snakebids (see [Using plugins](#using-plugins)) or create your own plugins (see [Creating plugins](#creating-plugins)).

A plugin is simply a function that takes a {class}`SnakeBidsApp <snakebids.app.SnakeBidsApp>` as input and returns either a modified {class}`SnakeBidsApp <snakebids.app.SnakeBidsApp>` or `None`. To add one or more plugins to your {class}`SnakeBidsApp <snakebids.app.SnakeBidsApp>`, pass them to the {class}`~snakebids.app.SnakeBidsApp` constructor via the {attr}`~snakebids.app.SnakeBidsApp.plugins` parameter. Your plugin will have access to CLI parameters (after they've been parsed) via their names in {attr}`SnakeBidsApp.config <snakebids.app.SnakeBidsApp.config>`. Any modifications to that config dictionary will be carried forward into the workflow.
## Using plugins
To add one or more plugins to your {class}`SnakeBidsApp <snakebids.app.SnakeBidsApp>`, pass them to the {class}`~snakebids.app.SnakeBidsApp` constructor via the {attr}`~snakebids.app.SnakeBidsApp.plugins` parameter. Your plugin will have access to CLI parameters (after they've been parsed) via their names in {attr}`SnakeBidsApp.config <snakebids.app.SnakeBidsApp.config>`. Any modifications to that config dictionary made by the plugin will be carried forward into the workflow.

As an example, a plugin could run the [BIDS Validator](https://github.com/bids-standard/bids-validator) on the input directory like so:
As an example, the distributed {class}`BidsValidator` plugin can be used to run the [BIDS Validator](https://github.com/bids-standard/bids-validator) on the input directory like so:

```py
import subprocess

from snakebids.app import SnakeBidsApp

def bids_validate(app: SnakeBidsApp) -> None:
if app.config["skip_bids_validation"]:
return

try:
subprocess.run(["bids-validator", app.config["bids_dir"]], check=True)
except subprocess.CalledProcessError as err:
raise InvalidBidsError from err

class InvalidBidsError(Exception):
"""Error raised if an input BIDS dataset is invalid."""
from snakebids.plugins.validator import BidsValidator

SnakeBidsApp(
"path/to/snakebids/app",
plugins=[bids_validate]
plugins=[BidsValidator]
).run_snakemake()
```

You would also want to add some logic to check if the BIDS Validator is installed and pass along the error message, but the point is that a plugin can do anything that can be handled by a Python function.
## Creating plugins
A plugin is a function or callable class that accepts a {class}`SnakeBidsApp <snakebids.app.SnakeBidsApp>` as input and returns a modified {class}`SnakeBidsApp` or `None`.

As an example, a simplified version of the bids-validator plugin that runs the [BIDS Validator](https://github.com/bids-standard/bids-validator) could be defined as follows:

```py
import subprocess

from snakebids.app import SnakeBidsApp
from snakebids.exceptions import SnakeBidsPluginError

class BidsValidator:
"""Perform BIDS validation of dataset

Parameters
-----------
app
Snakebids application to be run
"""

def __call__(self, app: SnakeBidsApp) -> None:
# Skip bids validation
if app.config["plugins.validator.skip"]:
return

try:
subprocess.run(["bids-validator", app.config["bids_dir"]], check=True)
except subprocess.CalledProcessError as err:
raise InvalidBidsError from err


class InvalidBidsError(SnakebidsPluginError):
"""Error raised if input BIDS dataset is invalid,
inheriting from SnakebidsPluginError.
"""
```

```{note}
When adding plugin-specific parameters to the config dictionary, it is recommended to use namespaced keys (e.g. ``plugins.validator.skip``). This will help ensure plugin-specific parameters do not conflict with other parameters already defined in the dictionary or by other plugins.
```


A plugin can be used to implement any logic that can be handled by a Python function. In the above example, you may also want to add some logic to check if the BIDS Validator is installed and pass along a custom error message if it is not. Created plugins can then be used within a Snakebids workflow, similar to the example provided in [Using plugins](#using-plugins) section. Prospective plugin developers can take a look at the source of the `snakebids.plugins` module for examples.


```{note}
When creating a custom error for your Snakebids plugin, it is recommended to inherit from {class}`SnakebidsPluginError <snakebids.exceptions.SnakebidsPluginError>` such that errors will be recognized as a plugin error.
```
2 changes: 1 addition & 1 deletion docs/running_snakebids/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Indexing of large datasets can be a time-consuming process. Leveraging the funct
1. `--pybidsdb-dir {dir}`: specify the path to the database directory
1. `--pybidsdb-reset`: indicate that an existing database should be updated

It's important to note that this indexing feature is **disabled by default**, meaning Snakebids does not create or expect to find a database unless it has been explictly set using the associated CLI arguments.
The boilerplate app starts with the validator plugin enabled - without it, validation is not performed. By default, this feature uses the command-line (node.js) version of the [validator](https://www.npmjs.com/package/bids-validator). If this is not found to be installed on the system, the `pybids` version of validation will be performed instead. To opt-out of validation, invoke the `--skip-bids-validation` flag. Details related to using and creating plugins can be found on the [plugins](/bids_app/plugins) page.

Workflow mode
=============
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,4 @@ reportImportCycles = false

[tool.ruff]
select = ["E", "F", "PL", "RUF"]
ignore = ["PLR0913"]
2 changes: 1 addition & 1 deletion snakebids/core/construct_bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Optional


def bids( # noqa: PLR0913
def bids(
root: Optional[str | Path] = None,
datatype: Optional[str] = None,
prefix: Optional[str] = None,
Expand Down
21 changes: 17 additions & 4 deletions snakebids/core/input_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@


@overload
def generate_inputs( # noqa: PLR0913
def generate_inputs(
bids_dir: Path | str,
pybids_inputs: InputsConfig,
pybidsdb_dir: Path | str | None = ...,
Expand All @@ -42,14 +42,15 @@ def generate_inputs( # noqa: PLR0913
participant_label: Iterable[str] | str | None = ...,
exclude_participant_label: Iterable[str] | str | None = ...,
use_bids_inputs: Literal[True] | None = ...,
validate: bool = ...,
pybids_database_dir: Path | str | None = ...,
pybids_reset_database: bool = ...,
) -> BidsDataset:
...


@overload
def generate_inputs( # noqa: PLR0913
def generate_inputs(
bids_dir: Path | str,
pybids_inputs: InputsConfig,
pybidsdb_dir: Path | str | None = ...,
Expand All @@ -60,13 +61,14 @@ def generate_inputs( # noqa: PLR0913
participant_label: Iterable[str] | str | None = ...,
exclude_participant_label: Iterable[str] | str | None = ...,
use_bids_inputs: Literal[False] = ...,
validate: bool = ...,
pybids_database_dir: Path | str | None = ...,
pybids_reset_database: bool = ...,
) -> BidsDatasetDict:
...


def generate_inputs( # noqa: PLR0913
def generate_inputs(
bids_dir: Path | str,
pybids_inputs: InputsConfig,
pybidsdb_dir: Path | str | None = None,
Expand All @@ -77,6 +79,7 @@ def generate_inputs( # noqa: PLR0913
participant_label: Iterable[str] | str | None = None,
exclude_participant_label: Iterable[str] | str | None = None,
use_bids_inputs: bool | None = None,
validate: bool = False,
pybids_database_dir: Path | str | None = None,
pybids_reset_database: bool = False,
) -> BidsDataset | BidsDatasetDict:
Expand Down Expand Up @@ -146,6 +149,10 @@ def generate_inputs( # noqa: PLR0913
:class`BidsDataset`. Setting to True is deprecated as of v0.8, as this is now
the default behaviour

validate
If True performs validation of BIDS directory using pybids, otherwise
skips validation.

Returns
-------
BidsDataset | BidsDatasetDict
Expand Down Expand Up @@ -267,6 +274,7 @@ def generate_inputs( # noqa: PLR0913
pybids_config=pybids_config,
pybidsdb_dir=pybidsdb_dir or pybids_database_dir,
pybidsdb_reset=pybidsdb_reset or pybids_reset_database,
validate=validate,
)
if not _all_custom_paths(pybids_inputs)
else None
Expand Down Expand Up @@ -308,11 +316,13 @@ def _all_custom_paths(config: InputsConfig):


def _gen_bids_layout(
*,
bids_dir: Path | str,
derivatives: Path | str | bool,
pybidsdb_dir: Path | str | None,
pybidsdb_reset: bool,
pybids_config: Path | str | None = None,
validate: bool = False,
) -> BIDSLayout:
"""Create (or reindex) the BIDSLayout if one doesn't exist,
which is only saved if a database directory path is provided
Expand All @@ -335,6 +345,9 @@ def _gen_bids_layout(
A boolean that determines whether to reset / overwrite
existing database.

validate
A boolean that determines whether to validate the bids dataset

Returns
-------
layout : BIDSLayout
Expand All @@ -353,7 +366,7 @@ def _gen_bids_layout(
return BIDSLayout(
str(bids_dir),
derivatives=derivatives,
validate=False,
validate=validate,
config=pybids_config,
database_path=pybidsdb_dir,
reset_database=pybidsdb_reset,
Expand Down
4 changes: 4 additions & 0 deletions snakebids/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ def __init__(self, misspecified_filter: str):
f"{misspecified_filter}. Filters must be of the form "
"{entity}={filter} or {entity}:{REQUIRED|OPTIONAL|NONE} (case-insensitive)."
)


class SnakebidsPluginError(Exception):
"""Exception raised when a Snakebids plugin encounters a problem"""
75 changes: 75 additions & 0 deletions snakebids/plugins/validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import json
import logging
import subprocess
import tempfile

import attr

from snakebids.app import SnakeBidsApp
from snakebids.exceptions import SnakebidsPluginError

_logger = logging.getLogger(__name__)


class InvalidBidsError(SnakebidsPluginError):
"""Error raised if an input BIDS dataset is invalid."""


@attr.define
class BidsValidator:
kaitj marked this conversation as resolved.
Show resolved Hide resolved
"""Snakebids plugin to perform validation of a BIDS dataset using the
bids-validator. If the dataset is not valid according to the BIDS
specifications, an InvalidBidsError is raised.

Parameters
kaitj marked this conversation as resolved.
Show resolved Hide resolved
----------
raise_invalid_bids : bool
Flag to indicate whether InvalidBidsError should be raised if BIDS
validation fails. Default to True.

"""

raise_invalid_bids: bool = attr.field(default=True)

def __call__(self, app: SnakeBidsApp) -> None:
"""Perform BIDS validation of dataset.

Parameters
----------
app
Snakebids application to be run

Raises
------
InvalidBidsError
Raised when the input BIDS directory does not pass validation with
the bids-validator
"""
# Skip bids validation
if app.config["plugins.validator.skip"]:
return

validator_config_dict = {"ignoredFiles": ["/participants.tsv"]}

with tempfile.NamedTemporaryFile(mode="w+", suffix=".json") as temp:
temp.write(json.dumps(validator_config_dict))
temp.flush()
try:
subprocess.check_call(
["bids-validator", app.config["bids_dirs"], "-c", temp.name]
)

# If successfully bids-validation performed
app.config["plugins.validator.success"] = True
except FileNotFoundError:
# If the bids-validator call can't be made
app.config["plugins.validator.success"] = False
kaitj marked this conversation as resolved.
Show resolved Hide resolved
_logger.warning(
"Missing bids-validator installation - falling back to pybids "
"validation."
)
# Any other bids-validator error
except subprocess.CalledProcessError as err:
app.config["plugins.validator.success"] = False
if self.raise_invalid_bids:
raise InvalidBidsError from err
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,20 @@ parse_args:
default: False
nargs: '+'

# custom command-line parameters can then be added, these will get added to the config
# below is an example to override config['bet_frac']

# custom command-line parameters can then be added, these will get added to the config and also accessible to plugins
# below are examples for plugin and custom parameters (e.g. config['smoothing_fwhm'])
--skip_bids_validation:
help: 'Skip validation of BIDS dataset. BIDS validation is performed by
default using the bids-validator plugin (if installed/enabled) or with the pybids
validator implementation (if bids-validator is not installed/enabled).'
dest: "plugins.validator.skip"
action: "store_true"
default: False
kaitj marked this conversation as resolved.
Show resolved Hide resolved

--smoothing_fwhm:
nargs: '+'
required: True
nargs: '+'
required: True


#--- workflow specific configuration -- below is just an example:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ inputs = generate_inputs(
derivatives=config.get("derivatives", None),
participant_label=config.get("participant_label", None),
exclude_participant_label=config.get("exclude_participant_label", None),
validate=not config.get("plugins.validator.skip", False)
)



#this adds constraints to the bids naming
wildcard_constraints: **get_wildcard_constraints(config['pybids_inputs'])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path

from snakebids.app import SnakeBidsApp
from snakebids.plugins.validator import BidsValidator


def get_parser():
Expand All @@ -11,7 +12,10 @@ def get_parser():


def main():
app = SnakeBidsApp(Path(__file__).resolve().parent.parent) # to get repository root
app = SnakeBidsApp(
Path(__file__).resolve().parent.parent, # to get repository root
plugins=[BidsValidator()],
)
app.run_snakemake()


Expand Down
9 changes: 9 additions & 0 deletions snakebids/tests/mock/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@
"type": "Path",
"nargs": "+",
},
"--skip_bids_validation": {
"help": "Skip validation of BIDS dataset. BIDS validation is performed by "
"default using the bids-validator plugin (if installed/enabled) or with the pybids "
"validator implementation (if bids-validator is not installed/enabled). "
"(default: %(default)s)",
"dest": "plugins.validator.skip",
"action": "store_true",
"default": False,
},
"--arg-using-dash-syntax": {
"help": "A fake argument for testing purposes",
"nargs": "+",
Expand Down
8 changes: 8 additions & 0 deletions snakebids/tests/mock/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ parse_args:
type: Path
nargs: '+'

--skip_bids_validation:
help: 'Skip validation of BIDS dataset. BIDS validation is performed by
default using the bids-validator plugin (if installed/enabled) or with the pybids
validator implementation (if bids-validator is not installed/enabled).'
dest: "plugins.validator.skip"
action: "store_true"
default: False

--arg-using-dash-syntax:
help: A fake argument for testing purposes
nargs: '+'
Loading