# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
#
# Copyright 2023 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/
#
# STATEMENT OF CHANGES: This file was ported carrying over full git history from niworkflows,
# another NiPreps project licensed under the Apache-2.0 terms, and has been changed since.
"""The reporting visualization unit or *reportlet*."""
import re
from pathlib import Path
from uuid import uuid4
from nipype.utils.filemanip import copyfile
from nireports.assembler import data
from nireports.assembler.misc import dict2html, read_crashfile
SVG_SNIPPET = [
"""\
<div class="reportlet">
<object class="svg-reportlet" type="image/svg+xml" data="./{name}" style="{style}">
Problem loading figure {name}. If the link below works, please try \
reloading the report in your browser.</object>
</div>
<small>Get figure file: <a href="./{name}" target="_blank">{name}</a></small>
""",
"""\
<div class="reportlet">
<img class="svg-reportlet" src="./{name}" style="{style}" />
</div>
<small>Get figure file: <a href="./{name}" target="_blank">{name}</a></small>
""",
]
METADATA_ACCORDION_BLOCK = """\
<div class="accordion accordion-flush" id="{metadata_id}">
"""
# aria-expanded="{metadata_folded}"
METADATA_ACCORDION_ITEM = """
<div class="accordion-item">
<h2 class="accordion-header" id="{metadata_id}-{metadata_index}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" \
data-bs-target="#{metadata_id}-{metadata_index}-collapse" \
aria-controls="{metadata_id}-{metadata_index}-collapse">
{metadata_item_key}
</button>
</h2>
<div id="{metadata_id}-{metadata_index}-collapse" class="accordion-collapse collapse" \
aria-labelledby="{metadata_id}-{metadata_index}-heading" \
data-bs-parent="#{metadata_id}-{metadata_index}">
<div class="accordion-body metadata-table">
{metadata_html}
</div>
</div>
</div>
"""
ERROR_TEMPLATE = """
<details>
<summary>Node Name: {node}</summary><br>
<div class="small">
File: <code>{file}</code><br />
Working Directory: <code>{node_dir}</code><br />
Inputs: <br />
<ul>
{inputs}
</ul>
<pre>{traceback}</pre>
</div>
</details>
"""
BOILERPLATE_NAV_TEMPLATE = """
<li class="nav-item" role="presentation">
<button class="nav-link {active}" id="{anchor}-tab" data-bs-toggle="tab" \
data-bs-target="#{anchor}-tab-pane" type="button" role="tab" aria-controls="{anchor}-tab-pane"\
aria-selected="{selected}">{tab_title}</button>
</li>
"""
BOILERPLATE_TXT_TEMPLATE = """
<div class="tab-pane fade {active}" id="{anchor}-tab-pane" role="tabpanel" \
aria-labelledby="{anchor}-tab" tabindex="0">
<div class="boiler-{anchor} p-3 m-4 bg-primary" \
style="--bs-bg-opacity: .04;{style}">{body}</div>
</div>
"""
HTML_BOILER_STYLE = " font-family: 'Bitstream Charter', 'Georgia', Times;"
[docs]
class Reportlet:
"""
A visual report atom (*reportlet*).
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, add_config_paths
>>> from nireports.assembler import data
>>> test_data_path = data.load('tests', 'work')
>>> testdir = Path(tmpdir)
>>> data_dir = copytree(test_data_path, str(testdir / 'work'))
>>> out_figs = testdir / 'out' / 'fmriprep'
>>> try:
... add_config_paths(figures=data.load("nipreps.json"))
... except ValueError as e:
... if "Configuration 'figures' already exists" != str(e):
... raise
>>> bl = BIDSLayout(str(testdir / 'work' / 'reportlets'),
... config='figures', validate=False)
Examples
--------
>>> bl.get(subject='01', desc='reconall')[0]._path.as_posix() # doctest: +ELLIPSIS
'.../nireports/sub-01/figures/sub-01_desc-reconall_T1w.svg'
>>> len(bl.get(subject='01', space='.*', regex_search=True))
2
>>> r = Reportlet(bl, out_dir=out_figs, config={
... 'title': 'Some Title', 'bids': {'datatype': 'figures', 'desc': 'reconall'},
... 'description': 'Some description'})
>>> r.name
'datatype-figures_desc-reconall'
>>> '<img ' in r.components[0][0]
True
>>> r = Reportlet(bl, out_dir=out_figs, config={
... 'title': 'Some Title', 'bids': {'datatype': 'figures', 'desc': 'reconall'},
... 'description': 'Some description', 'static': False})
>>> r.name
'datatype-figures_desc-reconall'
>>> '<object ' in r.components[0][0]
True
>>> r = Reportlet(bl, out_dir=out_figs, config={
... 'title': 'Some Title', 'bids': {'datatype': 'figures', 'desc': 'summary'},
... 'description': 'Some description'})
>>> '<h3 ' in r.components[0][0]
True
>>> r.components[0][1] is None
True
>>> r = Reportlet(bl, out_dir=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_dir=out_figs, config={
... 'title': 'Some Title',
... 'bids': {'datatype': 'fmap', 'space': '.*', 'regex_search': True},
... 'caption': 'Some description {space}'})
>>> r.is_empty()
True
"""
__slots__ = {
"components": "A list of visual elements for composite reportlets.",
"description": "This reportlet's longer description.",
"name": "A unique name for the reportlet (used to create HTML anchors).",
"subtitle": "This reportlet's subtitle.",
"title": "This reportlet's title.",
}
def __init__(self, layout, config=None, out_dir=None, bids_filters=None, metadata=None):
if not config:
raise RuntimeError("Reportlet must have a config object")
if out_dir is None:
raise RuntimeError("Reportlet must have an output directory")
out_dir = Path(out_dir)
self.title = config.get("title")
self.subtitle = config.get("subtitle")
self.description = config.get("description")
self.components = []
# Determine whether this is a "BIDS-type" reportlet (typically, an SVG file)
if bidsquery := config.get("bids", {}):
_bidsquery = (bids_filters or {}).copy()
_bidsquery.update(bidsquery)
bidsquery = _bidsquery
self.name = config.get(
"name",
"_".join("%s-%s" % i for i in sorted(bidsquery.items())),
)
# Query the BIDS layout of reportlets
files = layout.get(**bidsquery)
for bidsfile in files:
src = dst = Path(bidsfile.path)
ext = "".join(src.suffixes)
desc_text = config.get("caption")
is_static = config.get("static", True)
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))
dst = out_dir / html_anchor
dst.parent.mkdir(parents=True, exist_ok=True)
copyfile(src, dst, copy=True, use_hardlink=True)
# 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:
# # set preserveAspectRatio
# Remove height and width attributes from initial <svg> tag
svglines = dst.read_text().splitlines()
expr = re.compile(r' (height|width)=["\'][0-9]+(\.[0-9]*)?[a-z]*["\']')
for ll, line in enumerate(svglines[:6]):
if line.strip().startswith("<svg"):
# It is critical that viewBox is correctly spelled out
fixedline = expr.sub("", line.replace("viewbox", "viewBox"))
dst.write_text("\n".join([fixedline] + svglines[ll + 1 :]))
break
style = {"width": "100%"} if is_static else {}
style.update(config.get("style", {}))
contents = SVG_SNIPPET[is_static].format(
name=html_anchor,
style="; ".join(f"{k}: {v}" for k, v in style.items()),
)
if contents:
self.components.append((contents, desc_text))
elif meta_reportlet := config.get("metadata", False):
meta_settings = config.get("settings", {})
meta_id = meta_settings.get("id", f"meta-{uuid4()}")
self.name = f"meta-{meta_id}"
if metadata is not None and not isinstance(meta_reportlet, dict):
meta_reportlet = metadata.get(meta_id)
if not meta_reportlet:
self.components.append(
(
'<p class="alert alert-success" role="alert">'
f'Could not find metadata for reportlet "{meta_id}"'
"</p>",
"",
)
)
return
# meta_folded = meta_settings.get("folded", None)
contents = [METADATA_ACCORDION_BLOCK.format(metadata_id=meta_id)]
for ii, (group_name, values) in enumerate(meta_reportlet.items()):
contents.append(
METADATA_ACCORDION_ITEM.format(
metadata_id=meta_id,
metadata_index=ii,
metadata_item_key=group_name,
metadata_html=dict2html(values, f"{meta_id}-table-{ii}"),
)
)
contents.append("</div>")
self.components.append(("\n".join(contents), config.get("caption")))
elif (custom := config.get("custom", None)) in ("boilerplate", "errors"):
desc_text = config.get("caption")
path = config.get("path", None)
if custom == "errors":
self.name = "errors"
# Interpolate error log directory
error_dir = Path(path)
# Read in all crash files
errors = [
read_crashfile(str(f), root=layout.root, root_replace="<workdir>")
for f in error_dir.glob("crash*.*")
]
if not errors:
self.components.append(
(
'<p class="alert alert-success" role="alert">No errors to report!</p>',
desc_text,
)
)
else:
contents = [
'<p class="alert alert-danger" role="alert">'
f"One or more execution steps failed ({len(errors)}). "
"Error details are attached below.</p>",
]
for error in errors:
contents.append(
ERROR_TEMPLATE.format(
inputs="\n".join(
[
f"<li>{err_in[0]}: <code>{err_in[-1]}</code></li>"
for err_in in error.pop("inputs", {})
]
),
**error,
)
)
self.components.append(("\n".join(contents), desc_text))
elif custom == "boilerplate":
self.name = "boilerplate"
logs_path = Path(path)
bibfile = config.get("bibfile", ["nireports", "data/bibliography.bib"])
boiler_tabs = ['<ul class="nav nav-tabs" id="myTab" role="tablist">']
boiler_body = ['<div class="tab-content" id="myTabContent">']
boiler_idx = 0
for boiler_type in ("html", "md", "tex"):
if not (logs_path / f"CITATION.{boiler_type}").exists():
continue
text = ""
tab_title = ""
if boiler_type == "html":
text = (
re.compile("<body>(.*?)</body>", re.DOTALL | re.IGNORECASE)
.findall((logs_path / "CITATION.html").read_text())[0]
.strip()
)
tab_title = "HTML"
elif boiler_type == "md":
text = (logs_path / "CITATION.md").read_text()
text = f"<pre>{text}</pre>"
tab_title = "Markdown"
else:
text = (
re.compile(
r"\\begin{document}(.*?)\\end{document}",
re.DOTALL | re.IGNORECASE,
)
.findall((logs_path / "CITATION.tex").read_text())[0]
.strip()
)
text = f"""<pre>{text}</pre>
<h3>Bibliography</h3>
<pre>{data.Loader(bibfile[0]).readable(bibfile[1]).read_text()}</pre>
"""
tab_title = "LaTeX"
boiler_tabs.append(
BOILERPLATE_NAV_TEMPLATE.format(
active="active" if boiler_idx == 0 else "",
boiler_idx=boiler_idx,
selected="true" if boiler_idx == 0 else "false",
tab_title=tab_title,
anchor=boiler_type,
)
)
boiler_body.append(
BOILERPLATE_TXT_TEMPLATE.format(
active="show active" if boiler_idx == 0 else "",
boiler_idx=boiler_idx,
body=text,
anchor=boiler_type,
style="" if boiler_type != "html" else HTML_BOILER_STYLE,
)
)
boiler_idx += 1
if boiler_idx == 0:
desc_text = None
self.components.append(
(
'<p class="alert alert-danger" role="alert">'
"Failed to generate the boilerplate</p>",
desc_text,
)
)
else:
boiler_tabs.append("</ul>")
self.components.append(("\n".join(boiler_tabs + boiler_body), desc_text))
[docs]
def is_empty(self):
"""Determine whether the reportlet has no components."""
return len(self.components) == 0