Repository: mne-tools/mne-bids

Issue:
Fix report (tempita was removed upstream)
I'm working on this right now.


Discussion:

Resolving patch:
diff --git a/mne_bids/report/__init__.py b/mne_bids/report/__init__.py
new file mode 100644
--- /dev/null
+++ b/mne_bids/report/__init__.py
@@ -0,0 +1,3 @@
+"""Create a summary report of the BIDS dataset."""
+
+from ._report import make_report
diff --git a/mne_bids/report.py b/mne_bids/report/_report.py
similarity index 81%
rename from mne_bids/report.py
rename to mne_bids/report/_report.py
--- a/mne_bids/report.py
+++ b/mne_bids/report/_report.py
@@ -8,7 +8,7 @@
 from pathlib import Path
 
 import numpy as np
-from mne.externals.tempita import Template
+import jinja2
 from mne.utils import warn, logger, verbose
 
 from mne_bids.config import DOI, ALLOWED_DATATYPES
@@ -17,8 +17,15 @@
                            get_entity_vals, BIDSPath,
                            _parse_ext, _find_matching_sidecar)
 
-# functions to be used inside Template strings
-FUNCTION_TEMPLATE = """{{py:
+
+jinja_env = jinja2.Environment(
+    loader=jinja2.PackageLoader(
+        package_name='mne_bids.report',
+        package_path='templates'
+    )
+)
+
+
 def _pretty_str(listed):
     # make strings a sequence of ',' and 'and'
     if not isinstance(listed, list):
@@ -28,6 +35,7 @@ def _pretty_str(listed):
         return ','.join(listed)
     return '{}, and {}'.format(', '.join(listed[:-1]), listed[-1])
 
+
 def _range_str(minval, maxval, meanval, stdval, n_unknown, type):
     if minval == 'n/a':
         return 'ages all unknown'
@@ -36,14 +44,18 @@ def _range_str(minval, maxval, meanval, stdval, n_unknown, type):
         unknown_str = f'; {n_unknown} with unknown {type}'
     else:
         unknown_str = ''
-    return f'ages ranged from {round(minval, 2)} to {round(maxval, 2)} '\
-           f'(mean = {round(meanval, 2)}, std = {round(stdval, 2)}{unknown_str})'
+    return (
+        f'ages ranged from {round(minval, 2)} to {round(maxval, 2)} '
+        f'(mean = {round(meanval, 2)}, std = {round(stdval, 2)}{unknown_str})'
+    )
+
 
 def _summarize_participant_hand(hands):
     n_unknown = len([hand for hand in hands if hand == 'n/a'])
 
     if n_unknown == len(hands):
-        return f'handedness were all unknown'
+        return 'handedness were all unknown'
+
     n_rhand = len([hand for hand in hands if hand.upper() == 'R'])
     n_lhand = len([hand for hand in hands if hand.upper() == 'L'])
     n_ambidex = len([hand for hand in hands if hand.upper() == 'A'])
@@ -51,16 +63,19 @@ def _summarize_participant_hand(hands):
     return f'comprised of {n_rhand} right hand, {n_lhand} left hand ' \
            f'and {n_ambidex} ambidextrous'
 
+
 def _summarize_participant_sex(sexs):
     n_unknown = len([sex for sex in sexs if sex == 'n/a'])
 
     if n_unknown == len(sexs):
-        return f'sex were all unknown'
+        return 'sex were all unknown'
+
     n_males = len([sex for sex in sexs if sex.upper() == 'M'])
     n_females = len([sex for sex in sexs if sex.upper() == 'F'])
 
     return f'comprised of {n_males} male and {n_females} female participants'
 
+
 def _length_recording_str(length_recordings):
     import numpy as np
     if length_recordings is None:
@@ -72,10 +87,14 @@ def _length_recording_str(length_recordings):
     std_record_length = round(np.std(length_recordings), 2)
     total_record_length = round(sum(length_recordings), 2)
 
-    return f' Recording durations ranged from {min_record_length} to {max_record_length} seconds '\
-           f'(mean = {mean_record_length}, std = {std_record_length}), '\
-           f'for a total of {total_record_length} seconds of data recorded '\
-           f'over all scans.'
+    return (
+        f'Recording durations ranged from {min_record_length} to '
+        f'{max_record_length} seconds '
+        f'(mean = {mean_record_length}, std = {std_record_length}), '
+        f'for a total of {total_record_length} seconds of data recorded '
+        f'over all scans.'
+    )
+
 
 def _summarize_software_filters(software_filters):
     if software_filters in [{}, 'n/a']:
@@ -95,39 +114,6 @@ def _summarize_software_filters(software_filters):
                 msg += ', '.join(parameters)
     return msg
 
-}}"""  # noqa
-
-BIDS_DATASET_TEMPLATE = \
-    """{{if name == 'n/a'}}This{{else}}The {{name}}{{endif}}
-dataset was created by {{_pretty_str(authors)}} and conforms to BIDS version
-{{bids_version}}. This report was generated with
-MNE-BIDS ({{mne_bids_doi}}). """
-BIDS_DATASET_TEMPLATE += \
-    """The dataset consists of
-{{n_subjects}} participants ({{PARTICIPANTS_TEMPLATE}}) {{if n_sessions}}and {{n_sessions}} recording sessions: {{(_pretty_str(sessions))}}.{{else}}.{{endif}} """  # noqa
-
-PARTICIPANTS_TEMPLATE = \
-    """{{_summarize_participant_sex(sexs)}};
-{{_summarize_participant_hand(hands)}}; {{_range_str(min_age, max_age, mean_age, std_age, n_age_unknown, 'age')}}"""  # noqa
-
-DATATYPE_AGNOSTIC_TEMPLATE = \
-    """Data was recorded using a {{_pretty_str(system)}} system
-{{if manufacturer}}({{_pretty_str(manufacturer)}} manufacturer){{endif}}
-sampled at {{_pretty_str(sfreq)}} Hz
-with line noise at {{_pretty_str(powerlinefreq)}} Hz.
-{{if _summarize_software_filters(software_filters)}}
-The following software filters were applied during recording: 
-{{_summarize_software_filters(software_filters)}}.
-{{endif}}
-{{if n_scans > 1}}
-There were {{n_scans}} scans in total.
-{{else}}There was {{n_scans}} scan in total.
-{{endif}}
-{{_length_recording_str(length_recordings)}}
-For each dataset, there were on average {{mean_chs}} (std = {{std_chs}}) recording channels per scan,
-out of which {{mean_good_chs}} (std = {{std_good_chs}}) were used in analysis
-({{mean_bad_chs}} +/- {{std_bad_chs}} were removed from analysis). """  # noqa
-
 
 def _pretty_dict(template_dict):
     """Remove problematic blank spaces."""
@@ -174,7 +160,7 @@ def _summarize_dataset(root):
         'name': name,
         'bids_version': bids_version,
         'mne_bids_doi': DOI,
-        'authors': authors,
+        'authors': _pretty_str(authors),
     }
     _pretty_dict(template_dict)
     return template_dict
@@ -240,13 +226,12 @@ def _summarize_participants_tsv(root):
                 mean_age, std_age = np.mean(age_list), np.std(age_list)
 
     template_dict = {
-        'sexs': sexs,
-        'hands': hands,
-        'n_age_unknown': n_age_unknown,
-        'mean_age': mean_age,
-        'std_age': std_age,
-        'min_age': min_age,
-        'max_age': max_age,
+        'sexs': _summarize_participant_sex(sexs),
+        'hands': _summarize_participant_hand(hands),
+        'ages': _range_str(
+            min_age, max_age, mean_age, std_age, n_age_unknown,
+            'age'
+        )
     }
     return template_dict
 
@@ -369,11 +354,11 @@ def _summarize_sidecar_json(root, scans_fpaths):
 
     template_dict = {
         'n_scans': n_scans,
-        'manufacturer': list(manufacturers),
-        'sfreq': sfreqs,
-        'powerlinefreq': powerlinefreqs,
-        'software_filters': software_filters,
-        'length_recordings': length_recordings,
+        'manufacturer': _pretty_str(manufacturers),
+        'sfreq': _pretty_str(sfreqs),
+        'powerlinefreq': _pretty_str(powerlinefreqs),
+        'software_filters': _summarize_software_filters(software_filters),
+        'length_recordings': _length_recording_str(length_recordings),
     }
     return template_dict
 
@@ -502,44 +487,51 @@ def make_report(root, session=None, verbose=None):
     # RECOMMENDED: scans summary
     scans_summary = _summarize_scans(root, session=session)
 
+    dataset_agnostic_summary = scans_summary.copy()
+    dataset_agnostic_summary['system'] = _pretty_str(modalities)
+
     # turn off 'recommended' report summary
     # if files are not available to summarize
     if not participant_summary:
-        participant_template = ''
+        participants_info = ''
     else:
-        content = f'{FUNCTION_TEMPLATE}{PARTICIPANTS_TEMPLATE}'
-        participant_template = Template(content=content)
-        participant_template = participant_template.substitute(
-            **participant_summary)
-        logger.info(f'The participant template found: {participant_template}')
-
-    dataset_summary['PARTICIPANTS_TEMPLATE'] = str(participant_template)
+        particpants_info_template = jinja_env.get_template(
+            'participants.jinja'
+        )
+        participants_info = particpants_info_template.render(
+            **participant_summary
+        )
+        logger.info(f'The participant template found: {participants_info}')
 
     if not scans_summary:
-        datatype_agnostic_template = ''
+        datatype_agnostic_info = ''
     else:
-        datatype_agnostic_template = DATATYPE_AGNOSTIC_TEMPLATE
+        datatype_agnostic_template = jinja_env.get_template(
+            'datatype_agnostic.jinja'
+        )
+        datatype_agnostic_info = datatype_agnostic_template.render(
+            **dataset_agnostic_summary
+        )
 
     dataset_summary.update({
-        'system': modalities,
         'n_subjects': len(subjects),
+        'participants_info': participants_info,
         'n_sessions': len(sessions),
-        'sessions': sessions,
+        'sessions': _pretty_str(sessions),
     })
 
     # XXX: add channel summary for modalities (ieeg, meg, eeg)
     # create the content and mne Template
     # lower-case templates are "Recommended",
     # while upper-case templates are "Required".
-    content = f'{FUNCTION_TEMPLATE}{BIDS_DATASET_TEMPLATE}' \
-              f'{datatype_agnostic_template}'
 
-    paragraph = Template(content=content)
-    paragraph = paragraph.substitute(**dataset_summary,
-                                     **scans_summary)
+    dataset_summary_template = jinja_env.get_template('dataset_summary.jinja')
+    dataset_summary_info = dataset_summary_template.render(**dataset_summary)
 
-    # Clean paragraph
+    # Concatenate info and clean the paragraph
+    paragraph = f'{dataset_summary_info}\n{datatype_agnostic_info}'
     paragraph = paragraph.replace('\n', ' ')
-    paragraph = paragraph.replace('  ', ' ')
+    while '  ' in paragraph:
+        paragraph = paragraph.replace('  ', ' ')
 
     return '\n'.join(textwrap.wrap(paragraph, width=80))


Test patch:
diff --git a/mne_bids/tests/test_report.py b/mne_bids/tests/test_report.py
--- a/mne_bids/tests/test_report.py
+++ b/mne_bids/tests/test_report.py
@@ -3,6 +3,7 @@
 #
 # License: BSD-3-Clause
 import os.path as op
+import textwrap
 
 import mne
 import pytest
@@ -51,7 +52,7 @@ def test_report(tmp_path):
 This report was generated with MNE-BIDS (https://doi.org/10.21105/joss.01896).
 The dataset consists of 1 participants (sex were all unknown; handedness were
 all unknown; ages all unknown) and 1 recording sessions: 01. Data was recorded
-using a MEG system (Elekta manufacturer) sampled at 300.31 Hz with line noise at
+using an MEG system (Elekta) sampled at 300.31 Hz with line noise at
 60.0 Hz. The following software filters were applied during recording:
 SpatialCompensation. There was 1 scan in total. Recording durations ranged from
 20.0 to 20.0 seconds (mean = 20.0, std = 0.0), for a total of 20.0 seconds of
@@ -59,6 +60,7 @@ def test_report(tmp_path):
 = 0.0) recording channels per scan, out of which 374.0 (std = 0.0) were used in
 analysis (2.0 +/- 0.0 were removed from analysis)."""  # noqa
 
+    expected_report = '\n'.join(textwrap.wrap(expected_report, width=80))
     assert report == expected_report
 
 
@@ -84,7 +86,7 @@ def test_report_no_participant_information(tmp_path):
 This report was generated with MNE-BIDS (https://doi.org/10.21105/joss.01896).
 The dataset consists of 1 participants (sex were all unknown; handedness were
 all unknown; ages all unknown) and 1 recording sessions: 01. Data was recorded
-using a MEG system (Elekta manufacturer) sampled at 300.31 Hz with line noise at
+using an MEG system (Elekta) sampled at 300.31 Hz with line noise at
 60.0 Hz. The following software filters were applied during recording:
 SpatialCompensation. There was 1 scan in total. Recording durations ranged from
 20.0 to 20.0 seconds (mean = 20.0, std = 0.0), for a total of 20.0 seconds of
@@ -92,4 +94,5 @@ def test_report_no_participant_information(tmp_path):
 = 0.0) recording channels per scan, out of which 374.0 (std = 0.0) were used in
 analysis (2.0 +/- 0.0 were removed from analysis)."""  # noqa
 
+    expected_report = '\n'.join(textwrap.wrap(expected_report, width=80))
     assert report == expected_report
diff --git a/test_requirements.txt b/test_requirements.txt
--- a/test_requirements.txt
+++ b/test_requirements.txt
@@ -14,3 +14,4 @@ check-manifest
 pydocstyle
 flake8
 pooch
+jinja2