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 example and CLI to docs #60

Merged
merged 16 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}>.")

Check warning on line 23 in bids/ext/reports/cli.py

View check run for this annotation

Codecov / codecov/patch

bids/ext/reports/cli.py#L23

Added line #L23 was not covered by tests

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 @@
)
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 @@
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)

Check warning on line 122 in bids/ext/reports/cli.py

View check run for this annotation

Codecov / codecov/patch

bids/ext/reports/cli.py#L122

Added line #L122 was not covered by tests
else:
counter = report.generate()

common_patterns = counter.most_common()
if not common_patterns:
LOGGER.warning("No common patterns found.")

Check warning on line 128 in bids/ext/reports/cli.py

View check run for this annotation

Codecov / codecov/patch

bids/ext/reports/cli.py#L128

Added line #L128 was not covered by tests
else:
report.generate()
with open(output_dir / "report.txt", "w") as f:
f.write(str(counter.most_common()[0][0]))
Comment on lines -113 to +131
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will write out the most common report to a text file, but only if there's a description to write out.

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
Loading