Skip to content

Commit

Permalink
Add example and CLI to docs (#60)
Browse files Browse the repository at this point in the history
* Add example and CLI to docs.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix typo.

* Make some stylistic changes.

* Model CLI on preps.

* Some slight refactoring.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update cli.py

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update templates

* Use absolute instead of resolve.

* Don't print descriptions.

* Use rich.print in example.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
tsalo and pre-commit-ci[bot] committed Feb 5, 2024
1 parent 55c8ae6 commit 9fd90ee
Show file tree
Hide file tree
Showing 17 changed files with 794 additions and 71 deletions.
78 changes: 47 additions & 31 deletions bids/ext/reports/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,72 @@
from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import IO, Sequence

import rich
from bids.layout import BIDSLayout

from bids.ext.reports import BIDSReport

from ._version import __version__
from .logger import pybids_reports_logger
from bids.ext.reports._version import __version__
from bids.ext.reports.logger import pybids_reports_logger

# from bids.reports import BIDSReport
LOGGER = pybids_reports_logger()


def _path_exists(path, parser):
"""Ensure a given path exists."""
if path is None or not Path(path).exists():
raise parser.error(f"Path does not exist: <{path}>.")

return Path(path).absolute()


class MuhParser(argparse.ArgumentParser):
def _print_message(self, message: str, file: IO[str] | None = None) -> None:
rich.print(message, file=file)


def base_parser() -> MuhParser:
from functools import partial

parser = MuhParser(
prog="pybids_reports",
description="Report generator for BIDS datasets.",
epilog="""
For a more readable version of this help section,
see the online doc https://cohort-creator.readthedocs.io/en/latest/
""",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)

PathExists = partial(_path_exists, parser=parser)

parser.add_argument(
"bids_dir",
help="""
Path to BIDS dataset.
""",
nargs=1,
action="store",
type=PathExists,
help="Path to BIDS dataset.",
)
parser.add_argument(
"output_dir",
help="""
Output path.
""",
nargs=1,
action="store",
type=Path,
help="Output path.",
)
parser.add_argument(
"--participant_label",
help="""
The label(s) of the participant(s) that should be used for the report.
The label corresponds to sub-<participant_label> from the BIDS spec
(so it does not include "sub-").
help="""\
The label(s) of the participant(s) that should be used for the report.
The label corresponds to sub-<participant_label> from the BIDS spec
(so it does not include "sub-").
If this parameter is not provided, The first subject will be used.
Multiple participants can be specified with a space separated list.
If this parameter is not provided, The first subject will be used.
Multiple participants can be specified with a space separated list.
""",
nargs="+",
default=None,
)
parser.add_argument(
"-v",
Expand All @@ -67,21 +78,20 @@ def base_parser() -> MuhParser:
)
parser.add_argument(
"--verbosity",
help="""
Verbosity level.
""",
required=False,
choices=[0, 1, 2, 3],
default=2,
type=int,
nargs=1,
help="Verbosity level.",
)
return parser


def set_verbosity(verbosity: int | list[int]) -> None:
if isinstance(verbosity, list):
verbosity = verbosity[0]

if verbosity == 0:
LOGGER.setLevel("ERROR")
elif verbosity == 1:
Expand All @@ -92,24 +102,30 @@ def set_verbosity(verbosity: int | list[int]) -> None:
LOGGER.setLevel("DEBUG")


def cli(argv: Sequence[str] = sys.argv) -> None:
def cli(args: Sequence[str] = None, namespace=None) -> None:
"""Entry point."""
parser = base_parser()
opts = parser.parse_args(args, namespace)

args, unknowns = parser.parse_known_args(argv[1:])

bids_dir = Path(args.bids_dir[0]).resolve()
# output_dir = Path(args.output_dir[0])
participant_label = args.participant_label or None
bids_dir = opts.bids_dir.absolute()
output_dir = opts.output_dir.absolute()
participant_label = opts.participant_label or None

set_verbosity(args.verbosity)
set_verbosity(opts.verbosity)

LOGGER.debug(f"{bids_dir}")
LOGGER.debug(bids_dir)

layout = BIDSLayout(bids_dir)

report = BIDSReport(layout)
if participant_label:
report.generate(subject=participant_label)
counter = report.generate(subject=participant_label)
else:
counter = report.generate()

common_patterns = counter.most_common()
if not common_patterns:
LOGGER.warning("No common patterns found.")
else:
report.generate()
with open(output_dir / "report.txt", "w") as f:
f.write(str(counter.most_common()[0][0]))
8 changes: 4 additions & 4 deletions bids/ext/reports/parameters.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Functions for building strings for individual parameters."""

from __future__ import annotations

import math
Expand All @@ -12,11 +14,11 @@
from .logger import pybids_reports_logger
from .utils import list_to_str, num_to_str, remove_duplicates

"""Functions for building strings for individual parameters."""
LOGGER = pybids_reports_logger()


def nb_runs(run_list: list[str]) -> str:
"""Generate description of number of runs from list of files."""
nb_runs = len(run_list)
if nb_runs == 1:
return f"{num2words(nb_runs).title()} run"
Expand Down Expand Up @@ -65,6 +67,7 @@ def get_nb_vols(all_imgs: list[Nifti1Image | None]) -> list[int] | None:


def nb_vols(all_imgs: list[Nifti1Image]) -> str:
"""Generate description of number of volumes from files."""
nb_vols = get_nb_vols(all_imgs)
if nb_vols is None:
return "UNKNOWN"
Expand All @@ -73,7 +76,6 @@ def nb_vols(all_imgs: list[Nifti1Image]) -> str:

def duration(all_imgs: list[Nifti1Image], metadata: dict[str, Any]) -> str:
"""Generate general description of scan length from files."""

nb_vols = get_nb_vols(all_imgs)
if nb_vols is None:
return "UNKNOWN"
Expand Down Expand Up @@ -131,7 +133,6 @@ def multi_echo(files: list[BIDSFile]) -> str:
multi_echo : str
Whether the data are multi-echo or single-echo.
"""

echo_times = [f.get_metadata().get("EchoTime", None) for f in files]
echo_times = sorted(list(set(echo_times)))
if echo_times == [None]:
Expand Down Expand Up @@ -190,7 +191,6 @@ def bvals(bval_file: str | Path) -> str:

def intendedfor_targets(metadata: dict[str, Any], layout: BIDSLayout) -> str:
"""Generate description of intended for targets."""

if "IntendedFor" not in metadata:
return ""

Expand Down
8 changes: 7 additions & 1 deletion bids/ext/reports/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@


def common_mri_desc(
img: None | nib.Nifti1Image, metadata: dict[str, Any], config: dict[str, dict[str, str]]
img: None | nib.Nifti1Image,
metadata: dict[str, Any],
config: dict[str, dict[str, str]],
) -> dict[str, Any]:
"""Extract common MRI parameters from metadata."""
nb_slices = "UNKNOWN"
if "SliceTiming" in metadata:
nb_slices = str(len(metadata["SliceTiming"]))
Expand Down Expand Up @@ -253,6 +256,7 @@ def meg_info(files: list[BIDSFile]) -> str:


def device_info(metadata: dict[str, Any]) -> dict[str, Any]:
"""Extract device information from metadata."""
return {
"manufacturer": metadata.get("Manufacturer", "MANUFACTURER"),
"model_name": metadata.get("ManufacturersModelName", "MODEL"),
Expand Down Expand Up @@ -354,6 +358,7 @@ def parse_files(


def try_load_nii(file: BIDSFile) -> None | nib.Nifti1Image:
"""Try to load a nifti file, return None if it fails."""
try:
img = nib.load(file)
except (FileNotFoundError, ImageFileError):
Expand All @@ -362,6 +367,7 @@ def try_load_nii(file: BIDSFile) -> None | nib.Nifti1Image:


def files_not_found_warning(files: list[BIDSFile] | BIDSFile) -> None:
"""Warn user that files were not found or empty."""
if not isinstance(files, list):
files = [files]
files = [str(Path(file)) for file in files]
Expand Down
5 changes: 2 additions & 3 deletions bids/ext/reports/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class BIDSReport:
(str), a dictionary, or None. If None, loads and uses default
configuration information.
Keys in the dictionary include:
'dir': a dictionary for converting encoding direction strings
(e.g., j-) to descriptions (e.g., anterior to
posterior)
Expand Down Expand Up @@ -72,7 +73,7 @@ def generate_from_files(self, files: list[BIDSFile]) -> Counter[str]:
Parameters
----------
files : list of BIDSImageFile objects
files : list of :obj:`~bids.layout.BIDSImageFile` objects
List of files from which to generate methods description.
Returns
Expand Down Expand Up @@ -177,8 +178,6 @@ def generate(self, **kwargs: Any) -> Counter[str]:
for sub in subjects:
descriptions.append(self._report_subject(subject=sub, **kwargs))

print(descriptions)

counter = Counter(descriptions)
LOGGER.info(f"Number of patterns detected: {len(counter.keys())}")

Expand Down
9 changes: 9 additions & 0 deletions bids/ext/reports/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@


def render(template_name: str, data: dict[str, Any] | None = None) -> str:
"""Render a mustache template."""
template_file = Path(__file__).resolve().parent / "templates" / "templates" / template_name

with open(template_file) as template:
Expand All @@ -24,38 +25,46 @@ def render(template_name: str, data: dict[str, Any] | None = None) -> str:


def highlight_missing_tags(foo: str) -> str:
"""Highlight missing tags in a rendered template."""
foo = f"[blue]{foo}[/blue]"
foo = foo.replace("{{", "[/blue][red]{{")
foo = foo.replace("}}", "}}[/red][blue]")
return foo


def footer() -> str:
"""Add footer with PyBIDS information to the report."""
# Imported here to avoid a circular import
from . import __version__

return f"This section was (in part) generated automatically using pybids {__version__}."


def anat_info(desc_data: dict[str, Any]) -> str:
"""Generate anatomical report."""
return render(template_name="anat.mustache", data=desc_data)


def func_info(desc_data: dict[str, Any]) -> str:
"""Generate functional report."""
return render(template_name="func.mustache", data=desc_data)


def dwi_info(desc_data: dict[str, Any]) -> str:
"""Generate diffusion report."""
return render(template_name="dwi.mustache", data=desc_data)


def fmap_info(desc_data: dict[str, Any]) -> str:
"""Generate fieldmap report."""
return render(template_name="fmap.mustache", data=desc_data)


def pet_info(desc_data: dict[str, Any]) -> str:
"""Generate PET report."""
return render(template_name="pet.mustache", data=desc_data)


def meg_info(desc_data: dict[str, Any]) -> str:
"""Generate MEG report."""
return render(template_name="meeg.mustache", data=desc_data)
25 changes: 15 additions & 10 deletions bids/ext/reports/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@

import nibabel as nib
import pytest
from bids.layout import BIDSLayout
from bids.tests import get_test_data_path

from bids import BIDSLayout

@pytest.fixture(scope="module")
def testdataset():
"""Path to a BIDS dataset for testing."""
data_dir = join(get_test_data_path(), "synthetic")
return data_dir


@pytest.fixture
def testlayout():
@pytest.fixture(scope="module")
def testlayout(testdataset):
"""A BIDSLayout for testing."""
data_dir = join(get_test_data_path(), "synthetic")
return BIDSLayout(data_dir)
return BIDSLayout(testdataset)


@pytest.fixture
@pytest.fixture(scope="module")
def testimg(testlayout):
"""A Nifti1Image for testing."""
func_files = testlayout.get(
Expand All @@ -32,7 +37,7 @@ def testimg(testlayout):
return nib.load(func_files[0].path)


@pytest.fixture
@pytest.fixture(scope="module")
def testdiffimg(testlayout):
"""A Nifti1Image for testing."""
dwi_files = testlayout.get(
Expand All @@ -44,7 +49,7 @@ def testdiffimg(testlayout):
return nib.load(dwi_files[0].path)


@pytest.fixture
@pytest.fixture(scope="module")
def testconfig():
"""The standard config file for testing."""
config_file = abspath(join(get_test_data_path(), "../../reports/config/converters.json"))
Expand All @@ -53,7 +58,7 @@ def testconfig():
return config


@pytest.fixture
@pytest.fixture(scope="module")
def testmeta():
"""A small metadata dictionary for testing."""
return {
Expand All @@ -66,7 +71,7 @@ def testmeta():
}


@pytest.fixture
@pytest.fixture(scope="module")
def testmeta_light():
"""An even smaller metadata dictionary for testing."""
return {"RepetitionTime": 2.0}
Loading

0 comments on commit 9fd90ee

Please sign in to comment.