Source code for niworkflows.reports.core

# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
#
# Copyright 2021 The NiPreps Developers <nipreps@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# We support and encourage derived works from this project, please read
# about our expectations at
#
#     https://www.nipreps.org/community/licensing/
#
"""
Reports builder for BIDS-Apps.

Generalizes report generation across BIDS-Apps

"""
from pathlib import Path
import re
from itertools import compress
from collections import defaultdict
from bids.layout import BIDSLayout, add_config_paths
import jinja2
from nipype.utils.filemanip import copyfile

from .. import data, load_resource

# Add a new figures spec
try:
    add_config_paths(figures=data.load('nipreps.json'))
except ValueError as e:
    if "Configuration 'figures' already exists" != str(e):
        raise

PLURAL_SUFFIX = defaultdict(str("s").format, [("echo", "es")])
SVG_SNIPPET = [
    """\
<object class="svg-reportlet" type="image/svg+xml" data="./{0}">
Problem loading figure {0}. If the link below works, please try \
reloading the report in your browser.</object>
</div>
<div class="elem-filename">
    Get figure file: <a href="./{0}" target="_blank">{0}</a>
</div>
""",
    """\
<img class="svg-reportlet" src="./{0}" style="width: 100%" />
</div>
<div class="elem-filename">
    Get figure file: <a href="./{0}" target="_blank">{0}</a>
</div>
""",
]


[docs] class Smallest: """An object that always evaluates smaller than anything else, for sorting >>> Smallest() < 1 True >>> Smallest() < "epsilon" True >>> sorted([1, None, 2], key=lambda x: x if x is not None else Smallest()) [None, 1, 2] """ def __lt__(self, other): return not isinstance(other, Smallest) def __eq__(self, other): return isinstance(other, Smallest) def __gt__(self, other): return False
[docs] class Element: """Just a basic component of a report""" def __init__(self, name, title=None): self.name = name self.title = title
[docs] class Reportlet(Element): """ A reportlet has title, description and a list of components with either an HTML fragment or a path to an SVG file, and possibly a caption. This is a factory class to generate Reportlets reusing the layout from a ``Report`` object. .. testsetup:: >>> from shutil import copytree >>> from bids.layout import BIDSLayout >>> source = find_resource_or_skip('data/tests/work') >>> testdir = Path(tmpdir) >>> _ = copytree(source, testdir / 'work') >>> out_figs = testdir / 'out' / 'fmriprep' >>> bl = BIDSLayout(testdir / 'work' / 'reportlets', ... config='figures', validate=False) >>> bl.get(subject='01', desc='reconall') # doctest: +ELLIPSIS [<BIDSFile filename='.../fmriprep/sub-01/figures/sub-01_desc-reconall_T1w.svg'>] >>> len(bl.get(subject='01', space='.*', regex_search=True)) 2 >>> r = Reportlet(bl, out_figs, config={ ... 'title': 'Some Title', 'bids': {'datatype': 'figures', 'desc': 'reconall'}, ... 'description': 'Some description'}) >>> r.name 'datatype-figures_desc-reconall' >>> r.components[0][0].startswith('<img') True >>> r = Reportlet(bl, out_figs, config={ ... 'title': 'Some Title', 'bids': {'datatype': 'figures', 'desc': 'reconall'}, ... 'description': 'Some description', 'static': False}) >>> r.name 'datatype-figures_desc-reconall' >>> r.components[0][0].startswith('<object') True >>> r = Reportlet(bl, out_figs, config={ ... 'title': 'Some Title', 'bids': {'datatype': 'figures', 'desc': 'summary'}, ... 'description': 'Some description'}) >>> r.components[0][0].startswith('<h3') True >>> r.components[0][1] is None True >>> r = Reportlet(bl, out_figs, config={ ... 'title': 'Some Title', ... 'bids': {'datatype': 'figures', 'space': '.*', 'regex_search': True}, ... 'caption': 'Some description {space}'}) >>> sorted(r.components)[0][1] 'Some description MNI152NLin2009cAsym' >>> sorted(r.components)[1][1] 'Some description MNI152NLin6Asym' >>> r = Reportlet(bl, out_figs, config={ ... 'title': 'Some Title', ... 'bids': {'datatype': 'fmap', 'space': '.*', 'regex_search': True}, ... 'caption': 'Some description {space}'}) >>> r.is_empty() True """ def __init__(self, layout, out_dir, config=None): if not config: raise RuntimeError("Reportlet must have a config object") self.name = config.get( "name", "_".join("%s-%s" % i for i in sorted(config["bids"].items())) ) self.title = config.get("title") self.subtitle = config.get("subtitle") self.description = config.get("description") # Query the BIDS layout of reportlets files = layout.get(**config["bids"]) self.components = [] for bidsfile in files: src = Path(bidsfile.path) ext = "".join(src.suffixes) desc_text = config.get("caption") contents = None if ext == ".html": contents = src.read_text().strip() elif ext == ".svg": entities = dict(bidsfile.entities) if desc_text: desc_text = desc_text.format(**entities) try: html_anchor = src.relative_to(out_dir) except ValueError: html_anchor = src.relative_to(Path(layout.root).parent) dst = out_dir / html_anchor dst.parent.mkdir(parents=True, exist_ok=True) copyfile(src, dst, copy=True, use_hardlink=True) contents = SVG_SNIPPET[config.get("static", True)].format(html_anchor) # Our current implementations of dynamic reportlets do this themselves, # however I'll leave the code here since this is potentially something we # will want to transfer from every figure generator to this location. # The following code misses setting preserveAspecRatio="xMidYMid meet" # if not is_static: # # Remove height and width attributes from initial <svg> tag # svglines = out_file.read_text().splitlines() # expr = re.compile(r' (height|width)=["\'][0-9]+(\.[0-9]*)?[a-z]*["\']') # for l, line in enumerate(svglines[:6]): # if line.strip().startswith('<svg'): # newline = expr.sub('', line) # svglines[l] = newline # out_file.write_text('\n'.join(svglines)) # break if contents: self.components.append((contents, desc_text))
[docs] def is_empty(self): return len(self.components) == 0
[docs] class SubReport(Element): """SubReports are sections within a Report.""" def __init__(self, name, isnested=False, reportlets=None, title=""): self.name = name self.title = title self.reportlets = reportlets or [] self.isnested = isnested
[docs] class Report: """ The full report object. This object maintains a BIDSLayout to index all reportlets. .. testsetup:: >>> from shutil import copytree >>> from bids.layout import BIDSLayout >>> source = find_resource_or_skip('data/tests/work') >>> testdir = Path(tmpdir) >>> _ = copytree(source, str(testdir / 'work')) >>> out_figs = testdir / 'out' / 'fmriprep' >>> robj = Report(testdir / 'out', 'madeoutuuid', subject_id='01', packagename='fmriprep', ... reportlets_dir=testdir / 'work' / 'reportlets') >>> robj.layout.get(subject='01', desc='reconall') # doctest: +ELLIPSIS [<BIDSFile filename='.../figures/sub-01_desc-reconall_T1w.svg'>] >>> robj.generate_report() 0 >>> len((testdir / 'out' / 'fmriprep' / 'sub-01.html').read_text()) 36713 """ def __init__( self, out_dir, run_uuid, config=None, out_filename="report.html", packagename=None, reportlets_dir=None, subject_id=None, ): self.root = Path(reportlets_dir or out_dir) # Initialize structuring elements self.sections = [] self.errors = [] self.out_dir = Path(out_dir) self.out_filename = out_filename self.run_uuid = run_uuid self.packagename = packagename self.subject_id = subject_id if subject_id is not None: self.subject_id = ( subject_id[4:] if subject_id.startswith("sub-") else subject_id ) self.out_filename = f"sub-{self.subject_id}.html" # Default template from niworkflows self.template_path = load_resource('reports') / 'report.tpl' self._load_config(Path(config or load_resource('reports') / 'default.yml')) assert self.template_path.exists() def _load_config(self, config): from yaml import safe_load as load settings = load(config.read_text()) self.packagename = self.packagename or settings.get("package", None) if self.packagename is not None: self.root = self.root / self.packagename self.out_dir = self.out_dir / self.packagename if self.subject_id is not None: self.root = self.root / "sub-{}".format(self.subject_id) if "template_path" in settings: self.template_path = config.parent / settings["template_path"] self.index(settings["sections"])
[docs] def init_layout(self): self.layout = BIDSLayout(self.root, config="figures", validate=False)
[docs] def index(self, config): """ Traverse the reports config definition and instantiate reportlets. This method also places figures in their final location. """ # Initialize a BIDS layout self.init_layout() for subrep_cfg in config: # First determine whether we need to split by some ordering # (ie. sessions / tasks / runs), which are separated by commas. orderings = [ s for s in subrep_cfg.get("ordering", "").strip().split(",") if s ] entities, list_combos = self._process_orderings(orderings, self.layout) if not list_combos: # E.g. this is an anatomical reportlet reportlets = [ Reportlet(self.layout, self.out_dir, config=cfg) for cfg in subrep_cfg["reportlets"] ] else: # Do not use dictionary for queries, as we need to preserve ordering # of ordering columns. reportlets = [] for c in list_combos: # do not display entities with the value None. c_filt = list(filter(None, c)) ent_filt = list(compress(entities, c)) # Set a common title for this particular combination c title = "Reports for: %s." % ", ".join( [ '%s <span class="bids-entity">%s</span>' % (ent_filt[i], c_filt[i]) for i in range(len(c_filt)) ] ) for cfg in subrep_cfg["reportlets"]: cfg["bids"].update({entities[i]: c[i] for i in range(len(c))}) rlet = Reportlet(self.layout, self.out_dir, config=cfg) if not rlet.is_empty(): rlet.title = title title = None reportlets.append(rlet) # Filter out empty reportlets reportlets = [r for r in reportlets if not r.is_empty()] if reportlets: sub_report = SubReport( subrep_cfg["name"], isnested=bool(list_combos), reportlets=reportlets, title=subrep_cfg.get("title"), ) self.sections.append(sub_report) # Populate errors section error_dir = ( self.out_dir / "sub-{}".format(self.subject_id) / "log" / self.run_uuid ) if error_dir.is_dir(): from ..utils.misc import read_crashfile self.errors = [read_crashfile(str(f)) for f in error_dir.glob("crash*.*")]
[docs] def generate_report(self): """Once the Report has been indexed, the final HTML can be generated""" logs_path = self.out_dir / "logs" boilerplate = [] boiler_idx = 0 if (logs_path / "CITATION.html").exists(): text = ( re.compile("<body>(.*?)</body>", re.DOTALL | re.IGNORECASE) .findall((logs_path / "CITATION.html").read_text())[0] .strip() ) boilerplate.append( (boiler_idx, "HTML", f'<div class="boiler-html">{text}</div>') ) boiler_idx += 1 if (logs_path / "CITATION.md").exists(): text = (logs_path / "CITATION.md").read_text() boilerplate.append((boiler_idx, "Markdown", f"<pre>{text}</pre>\n")) boiler_idx += 1 if (logs_path / "CITATION.tex").exists(): text = ( re.compile( r"\\begin{document}(.*?)\\end{document}", re.DOTALL | re.IGNORECASE ) .findall((logs_path / "CITATION.tex").read_text())[0] .strip() ) bib = data.Loader(self.packagename).readable("data/boilerplate.bib") boilerplate.append( ( boiler_idx, "LaTeX", f"<pre>{text}</pre>\n<h3>Bibliography</h3>\n<pre>{bib.read_text()}</pre>\n", ) ) boiler_idx += 1 env = jinja2.Environment( loader=jinja2.FileSystemLoader(searchpath=str(self.template_path.parent)), trim_blocks=True, lstrip_blocks=True, autoescape=False, ) report_tpl = env.get_template(self.template_path.name) report_render = report_tpl.render( sections=self.sections, errors=self.errors, boilerplate=boilerplate ) # Write out report self.out_dir.mkdir(parents=True, exist_ok=True) (self.out_dir / self.out_filename).write_text(report_render, encoding="UTF-8") return len(self.errors)
@staticmethod def _process_orderings(orderings, layout): """ Generate relevant combinations of orderings with observed values. Arguments --------- orderings : :obj:`list` of :obj:`list` of :obj:`str` Sections prescribing an ordering to select across sessions, acquisitions, runs, etc. layout : :obj:`bids.layout.BIDSLayout` The BIDS layout Returns ------- entities: :obj:`list` of :obj:`str` The relevant orderings that had unique values value_combos: :obj:`list` of :obj:`tuple` Unique value combinations for the entities """ # get a set of all unique entity combinations all_value_combos = { tuple(bids_file.get_entities().get(k, None) for k in orderings) for bids_file in layout.get() } # remove the all None member if it exists none_member = tuple([None for k in orderings]) if none_member in all_value_combos: all_value_combos.remove(tuple([None for k in orderings])) # see what values exist for each entity unique_values = [ {value[idx] for value in all_value_combos} for idx in range(len(orderings)) ] # if all values are None for an entity, we do not want to keep that entity keep_idx = [ False if (len(val_set) == 1 and None in val_set) or not val_set else True for val_set in unique_values ] # the "kept" entities entities = list(compress(orderings, keep_idx)) # the "kept" value combinations value_combos = [ tuple(compress(value_combo, keep_idx)) for value_combo in all_value_combos ] # sort the value combinations alphabetically from the first entity to the last entity value_combos.sort( key=lambda entry: tuple( value if value is not None else Smallest() for value in entry ) ) return entities, value_combos
[docs] def run_reports( out_dir, subject_label, run_uuid, config=None, reportlets_dir=None, packagename=None, ): """ Run the reports. .. testsetup:: >>> from shutil import copytree >>> source = find_resource_or_skip('data/tests/work') >>> testdir = Path(tmpdir) >>> _ = copytree(source, testdir / 'work') >>> (testdir / 'fmriprep').mkdir(parents=True, exist_ok=True) >>> run_reports(testdir / 'out', '01', 'madeoutuuid', packagename='fmriprep', ... reportlets_dir=testdir / 'work' / 'reportlets') 0 """ return Report( out_dir, run_uuid, config=config, subject_id=subject_label, packagename=packagename, reportlets_dir=reportlets_dir, ).generate_report()
[docs] def generate_reports( subject_list, output_dir, run_uuid, config=None, work_dir=None, packagename=None ): """Execute run_reports on a list of subjects.""" reportlets_dir = None if work_dir is not None: reportlets_dir = Path(work_dir) / "reportlets" report_errors = [ run_reports( output_dir, subject_label, run_uuid, config=config, packagename=packagename, reportlets_dir=reportlets_dir, ) for subject_label in subject_list ] errno = sum(report_errors) if errno: import logging logger = logging.getLogger("cli") error_list = ", ".join( "%s (%d)" % (subid, err) for subid, err in zip(subject_list, report_errors) if err ) logger.error( "Preprocessing did not finish successfully. Errors occurred while processing " "data from participants: %s. Check the HTML reports for details.", error_list, ) return errno