diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f45ea8b..50bbea44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ # v0.7.0 (Feb 23, 2026) +### New Features +* Added specifiable output format for reports based on file extension. Reports saved with `.md` extension use Markdown format, `.html`/`.htm` use HTML format with styled output, and all others default to RST format. [#153](https://github.com/NeurodataWithoutBorders/nwbinspector/issues/153) + ### New Checks * Added `check_file_extension` for NWB file extension best practice recommendations (`.nwb`, `.nwb.h5`, or `.nwb.zarr`) [#625](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/625) * Added `check_time_series_duration` to detect unusually long TimeSeries durations (default threshold: 1 year). [#627](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/627) diff --git a/docs/user_guide/using_the_command_line_interface.rst b/docs/user_guide/using_the_command_line_interface.rst index aa2a4b8c..b49714fa 100644 --- a/docs/user_guide/using_the_command_line_interface.rst +++ b/docs/user_guide/using_the_command_line_interface.rst @@ -127,6 +127,25 @@ There are many common options you can specify with flags, such as saving the rep nwbinspector path/to/my/data.nwb --report-file-path path/to/my/nwbinspector_report.txt +The report format is automatically determined based on the file extension: + +- ``.md`` - Markdown format with ``#``/``##``/``###`` section headings +- ``.html`` or ``.htm`` - HTML format with professional styling, color-coded importance levels, and a styled summary section +- All other extensions (including ``.txt``, ``.rst``) - RST format with ``=``/``-``/``~`` section headings (default) + +For example, to save a Markdown-formatted report: + +:: + + nwbinspector path/to/my/data.nwb --report-file-path path/to/my/nwbinspector_report.md + +Or an HTML report with styled output: + +:: + + nwbinspector path/to/my/data.nwb --report-file-path path/to/my/nwbinspector_report.html + + If a report file from a previous run of the inspector is already present at the location, it can be overwritten with the ``-o`` or ``--overwrite`` flag... diff --git a/docs/user_guide/using_the_library.rst b/docs/user_guide/using_the_library.rst index 05056eb0..b7ca313a 100644 --- a/docs/user_guide/using_the_library.rst +++ b/docs/user_guide/using_the_library.rst @@ -147,6 +147,69 @@ See the section on :ref:`advanced_streaming_api` for more customized usage of th +Formatting Inspection Results +----------------------------- + +The NWBInspector provides several formatter classes for rendering inspection results in different formats. Each formatter +inherits from :py:class:`~nwbinspector._formatting.MessageFormatter` and provides format-specific rendering of section +headers and report summaries. + +**Available Formatters:** + +- :py:class:`~nwbinspector.RstFormatter` - RST format with ``=``/``-``/``~`` section headings (default) +- :py:class:`~nwbinspector.MarkdownFormatter` - Markdown format with ``#``/``##``/``###`` section headings +- :py:class:`~nwbinspector.HtmlFormatter` - HTML format with professional styling and color-coded importance levels + +To format inspection results, use the :py:func:`~nwbinspector.format_messages` function with your chosen formatter: + +.. code-block:: python + + from nwbinspector import inspect_nwbfile, format_messages, RstFormatter + + messages = list(inspect_nwbfile(nwbfile_path="path_to_single_nwbfile")) + formatted_report = format_messages(messages=messages, formatter=RstFormatter()) + print(formatted_report) + +For Markdown output: + +.. code-block:: python + + from nwbinspector import inspect_nwbfile, format_messages, MarkdownFormatter + + messages = list(inspect_nwbfile(nwbfile_path="path_to_single_nwbfile")) + formatted_report = format_messages(messages=messages, formatter=MarkdownFormatter()) + + with open("report.md", "w") as f: + f.write(formatted_report) + +For HTML output with professional styling: + +.. code-block:: python + + from nwbinspector import inspect_nwbfile, format_messages, HtmlFormatter + + messages = list(inspect_nwbfile(nwbfile_path="path_to_single_nwbfile")) + formatted_report = format_messages(messages=messages, formatter=HtmlFormatter()) + + with open("report.html", "w") as f: + f.write(formatted_report) + +The ``format_messages`` function also accepts optional parameters for customizing the report organization: + +.. code-block:: python + + from nwbinspector import inspect_nwbfile, format_messages, MarkdownFormatter + + messages = list(inspect_nwbfile(nwbfile_path="path_to_single_nwbfile")) + formatted_report = format_messages( + messages=messages, + formatter=MarkdownFormatter(), + levels=["importance", "file_path"], # Custom organization levels + reverse=[True, False], # Reverse order for each level + ) + + + Examining the Default Check Registry ------------------------------------ diff --git a/src/nwbinspector/__init__.py b/src/nwbinspector/__init__.py index 0f21d7c2..d3ca400b 100644 --- a/src/nwbinspector/__init__.py +++ b/src/nwbinspector/__init__.py @@ -14,7 +14,9 @@ print_to_console, save_report, MessageFormatter, - FormatterOptions, + RstFormatter, + MarkdownFormatter, + HtmlFormatter, InspectorOutputJSONEncoder, ) from ._organization import organize_messages @@ -52,7 +54,9 @@ "print_to_console", "save_report", "MessageFormatter", - "FormatterOptions", + "RstFormatter", + "MarkdownFormatter", + "HtmlFormatter", "organize_messages", "__version__", # Public submodules diff --git a/src/nwbinspector/_formatting.py b/src/nwbinspector/_formatting.py index 4a83b268..0610ff3a 100644 --- a/src/nwbinspector/_formatting.py +++ b/src/nwbinspector/_formatting.py @@ -3,6 +3,7 @@ import json import os import sys +from abc import ABC, abstractmethod from collections import defaultdict from datetime import datetime from enum import Enum @@ -41,38 +42,18 @@ def _get_report_header() -> dict[str, str]: ) -class FormatterOptions: - """Class structure for defining all free attributes for the design of a report format.""" +class MessageFormatter(ABC): + """ + Abstract base class for message formatters. - def __init__( - self, indent_size: int = 2, indent: Optional[str] = None, section_headers: tuple[str, ...] = ("=", "-", "~") - ) -> None: - """ - Class that defines all the format parameters used by the generic MessageFormatter. - - Parameters - ---------- - indent_size : int, optional - Defines the spacing between numerical sectioning and section name or message. - Defaults to 2 spaces. - indent : str, optional - Defines the specific indentation to inject between numerical sectioning and section name or message. - Overrides indent_size. - Defaults to " " * indent_size. - section_headers : tuple of strings - List of characters that will be injected under the display of each new section of the report. - If levels is longer than this list, the last item will be repeated over the remaining levels. - If levels is shorter than this list, only the first len(levels) of items will be used. - Defaults to the .rst style for three subsections: ["=", "-", "~"] - """ - # TODO - # Future custom options could include section break sizes, section-specific indents, etc. - self.indent = indent if indent is not None else " " * indent_size - self.section_headers = section_headers + For full customization of all format parameters, subclass this class and implement + the abstract methods. + """ - -class MessageFormatter: - """For full customization of all format parameters, use this class instead of the 'format_messages' function.""" + # Section header characters to use for each level of nesting + section_headers: tuple[str, ...] = ("=", "-", "~") + # Indentation between numerical sectioning and section name or message + indent: str = " " def __init__( self, @@ -80,7 +61,6 @@ def __init__( levels: list[str], reverse: Optional[list[bool]] = None, detailed: bool = False, - formatter_options: Optional[FormatterOptions] = None, nfiles_detected: Optional[int] = None, ) -> None: self.nmessages = len(messages) @@ -96,16 +76,10 @@ def __init__( ) self.collection_levels = set([x for x in InspectorMessage.__annotations__]) - set(levels) - set(["severity"]) self.reverse = reverse - if formatter_options is None: - self.formatter_options = FormatterOptions() - else: - assert isinstance( - formatter_options, FormatterOptions - ), "'formatter_options' is not an instance of FormatterOptions!" - self.formatter_options = formatter_options - self.formatter_options.section_headers = self.formatter_options.section_headers + ( - self.formatter_options.section_headers[-1], - ) * (self.nlevels - len(self.formatter_options.section_headers)) + # Extend section_headers to cover all levels + self._extended_section_headers = self.section_headers + (self.section_headers[-1],) * ( + self.nlevels - len(self.section_headers) + ) self.message_counter = 0 self.formatted_messages: list = [] @@ -142,9 +116,60 @@ def _get_message_header(self, message: InspectorMessage) -> str: return message_header def _get_message_increment(self, level_counter: list[int]) -> str: - return ( - f"{'.'.join(np.array(level_counter, dtype=str))}.{self.message_counter}" f"{self.formatter_options.indent}" - ) + return f"{'.'.join(np.array(level_counter, dtype=str))}.{self.message_counter}{self.indent}" + + @abstractmethod + def _format_section_header(self, section_name: str, level: int) -> list[str]: + """ + Format a section header for the specific output format. + + Parameters + ---------- + section_name : str + The name/title of the section including the numerical prefix. + level : int + The nesting level of the section (0-indexed). + + Returns + ------- + list of str + The formatted section header lines to append to the output. + """ + pass + + def _get_report_prefix(self) -> list[str]: + """Return lines to add at the very start of the report. Override for formats like HTML.""" + return [] + + def _get_report_suffix(self) -> list[str]: + """Return lines to add at the very end of the report. Override for formats like HTML.""" + return [] + + def _format_report_summary(self, report_header: dict[str, str]) -> list[str]: + """Format the report summary section. Override for format-specific styling.""" + lines = [ + "*" * 50, + "NWBInspector Report Summary", + "", + f"Timestamp: {report_header['Timestamp']}", + f"Platform: {report_header['Platform']}", + f"NWBInspector version: {report_header['NWBInspector_version']}", + "", + ] + + if self.nfiles_detected is not None: + lines.append(f"Scanned {self.nfiles_detected} file(s).") + if self.nmessages == 0: + lines.append("No issues found!") + else: + lines.append(f"Found {self.nmessages} issues across {self.nfiles_with_issues} file(s):") + + for importance_level, number_of_results in self.message_count_by_importance.items(): + increment = " " * (8 - len(str(number_of_results))) + lines.append(f"{increment}{number_of_results} - {importance_level}") + + lines.extend(["*" * 50, "", ""]) + return lines def _add_subsection( self, @@ -158,12 +183,10 @@ def _add_subsection( this_level_counter.append(0) for i, (key, val) in enumerate(organized_messages.items()): # Add section header and recurse this_level_counter[-1] = i - increment = f"{'.'.join(np.array(this_level_counter, dtype=str))}{self.formatter_options.indent}" + increment = f"{'.'.join(np.array(this_level_counter, dtype=str))}{self.indent}" section_name = f"{increment}{self._get_name(obj=key)}" - self.formatted_messages.append(section_name) - self.formatted_messages.extend( - [f"{self.formatter_options.section_headers[len(this_level_counter) - 1]}" * len(section_name), ""] - ) + level = len(this_level_counter) - 1 + self.formatted_messages.extend(self._format_section_header(section_name=section_name, level=level)) self._add_subsection(organized_messages=val, levels=levels[1:], level_counter=this_level_counter) else: # Final section, display message information if levels[0] == "file_path" and not self.detailed: @@ -198,32 +221,129 @@ def _add_subsection( def format_messages(self) -> list[str]: """Deploy recursive addition of sections, terminating with message display.""" + # Add format-specific prefix (e.g., HTML doctype and opening tags) + self.formatted_messages.extend(self._get_report_prefix()) + + # Add format-specific report summary report_header = _get_report_header() - self.formatted_messages.extend( - [ - "*" * 50, - "NWBInspector Report Summary", - "", - f"Timestamp: {report_header['Timestamp']}", - f"Platform: {report_header['Platform']}", - f"NWBInspector version: {report_header['NWBInspector_version']}", - "", - ] - ) + self.formatted_messages.extend(self._format_report_summary(report_header)) + + # Add the organized messages + self._add_subsection(organized_messages=self.initial_organized_messages, levels=self.levels, level_counter=[]) + + # Add format-specific suffix (e.g., HTML closing tags) + self.formatted_messages.extend(self._get_report_suffix()) + + return self.formatted_messages + + +class RstFormatter(MessageFormatter): + """Formatter that outputs in reStructuredText (RST) format with underline-style section headers.""" + + section_headers: tuple[str, ...] = ("=", "-", "~") + + def _format_section_header(self, section_name: str, level: int) -> list[str]: + """Format section header using RST underline style.""" + header_char = self._extended_section_headers[level] + return [section_name, header_char * len(section_name), ""] + + +class MarkdownFormatter(MessageFormatter): + """Formatter that outputs in Markdown format with prefix-style section headers (#, ##, ###).""" + + section_headers: tuple[str, ...] = ("#", "##", "###") + + def _format_section_header(self, section_name: str, level: int) -> list[str]: + """Format section header using Markdown prefix style.""" + header_prefix = self._extended_section_headers[level] + return [f"{header_prefix} {section_name}", ""] + + +class HtmlFormatter(MessageFormatter): + """Formatter that outputs in HTML format with proper document structure.""" + + section_headers: tuple[str, ...] = ("h2", "h3", "h4", "h5", "h6") + + def _get_report_prefix(self) -> list[str]: + """Return HTML document opening structure.""" + return [ + "", + "", + "", + " ", + " NWBInspector Report", + " ", + "", + "", + ] + + def _get_report_suffix(self) -> list[str]: + """Return HTML document closing structure.""" + return [ + "", + "", + ] + + def _format_report_summary(self, report_header: dict[str, str]) -> list[str]: + """Format the report summary section with HTML styling.""" + lines = [ + "
", + "

NWBInspector Report

", + "
", + f"

Timestamp: {report_header['Timestamp']}

", + f"

Platform: {report_header['Platform']}

", + f"

NWBInspector version: {report_header['NWBInspector_version']}

", + "
", + "
", + ] if self.nfiles_detected is not None: - self.formatted_messages.append(f"Scanned {self.nfiles_detected} file(s).") + lines.append(f"

Scanned {self.nfiles_detected} file(s).

") + if self.nmessages == 0: - self.formatted_messages.append("No issues found!") + lines.append("

✓ No issues found!

") else: - self.formatted_messages.append(f"Found {self.nmessages} issues across {self.nfiles_with_issues} file(s):") + lines.append( + f"

Found {self.nmessages} issues across {self.nfiles_with_issues} file(s):

" + ) + lines.append(" ") - for importance_level, number_of_results in self.message_count_by_importance.items(): - increment = " " * (8 - len(str(number_of_results))) - self.formatted_messages.append(f"{increment}{number_of_results} - {importance_level}") - self.formatted_messages.extend(["*" * 50, "", ""]) - self._add_subsection(organized_messages=self.initial_organized_messages, levels=self.levels, level_counter=[]) - return self.formatted_messages + lines.extend( + [ + "
", + "
", + "", + ] + ) + return lines + + def _format_section_header(self, section_name: str, level: int) -> list[str]: + """Format section header using HTML heading tags.""" + tag = self._extended_section_headers[min(level, len(self._extended_section_headers) - 1)] + return [f"<{tag}>{section_name}", ""] def format_messages( @@ -232,12 +352,48 @@ def format_messages( reverse: Optional[list[bool]] = None, detailed: bool = False, nfiles_detected: Optional[int] = None, + output_format: str = "rst", ) -> list[str]: - """Print InspectorMessages in order specified by the organization structure.""" + """Print InspectorMessages in order specified by the organization structure. + + Parameters + ---------- + messages : list of InspectorMessage + The messages to format. + levels : list of str, optional + The levels to organize by. Defaults to ["file_path", "importance"]. + reverse : list of bool, optional + Whether to reverse each level. Defaults to False for all levels. + detailed : bool, optional + Whether to show detailed output. Defaults to False. + nfiles_detected : int, optional + Number of files detected during inspection. + output_format : str, optional + The output format for the report. Can be "rst" or "markdown". + Defaults to "rst". + + Returns + ------- + list of str + The formatted message lines. + """ levels = levels or ["file_path", "importance"] - message_formatter = MessageFormatter( - messages=messages, levels=levels, reverse=reverse, detailed=detailed, nfiles_detected=nfiles_detected + # Select the appropriate formatter class based on output format + formatter_class: type[MessageFormatter] + if output_format == "markdown": + formatter_class = MarkdownFormatter + elif output_format == "html": + formatter_class = HtmlFormatter + else: + formatter_class = RstFormatter + + message_formatter = formatter_class( + messages=messages, + levels=levels, + reverse=reverse, + detailed=detailed, + nfiles_detected=nfiles_detected, ) formatted_messages = message_formatter.format_messages() diff --git a/src/nwbinspector/_nwbinspector_cli.py b/src/nwbinspector/_nwbinspector_cli.py index 307564ed..df7338cb 100644 --- a/src/nwbinspector/_nwbinspector_cli.py +++ b/src/nwbinspector/_nwbinspector_cli.py @@ -214,12 +214,22 @@ def _nwbinspector_cli( json.dump(obj=json_report, fp=fp, cls=InspectorOutputJSONEncoder) print(f"{os.linesep*2}Report saved to {str(Path(json_file_path).absolute())}!{os.linesep}") + # Determine output format based on file extension + output_format = "rst" + if report_file_path is not None: + report_path = Path(report_file_path) + if report_path.suffix.lower() == ".md": + output_format = "markdown" + elif report_path.suffix.lower() in (".html", ".htm"): + output_format = "html" + formatted_messages = format_messages( messages=messages, levels=handled_levels, reverse=handled_reverse, detailed=detailed, nfiles_detected=nfiles_detected, + output_format=output_format, ) print_to_console(formatted_messages=formatted_messages) if report_file_path is not None: diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 6cd252fc..249cd97b 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,21 +1,28 @@ """Tests for the _formatting module.""" from unittest import TestCase +from unittest.mock import patch from nwbinspector import Importance, InspectorMessage -from nwbinspector._formatting import MessageFormatter +from nwbinspector._formatting import HtmlFormatter, MarkdownFormatter, RstFormatter class TestMessageFormatterSummary(TestCase): """Test the summary message generation in MessageFormatter.format_messages().""" - def test_format_messages_no_issues(self): + @patch("nwbinspector._formatting._get_report_header") + def test_format_messages_no_issues(self, mock_header): """Test that the correct summary is generated when no issues are found.""" + mock_header.return_value = { + "Timestamp": "2024-01-01 00:00:00", + "Platform": "TestPlatform", + "NWBInspector_version": "0.0.0", + } messages = [] levels = ["file_path", "importance"] nfiles_detected = 5 - formatter = MessageFormatter( + formatter = RstFormatter( messages=messages, levels=levels, nfiles_detected=nfiles_detected, @@ -24,8 +31,14 @@ def test_format_messages_no_issues(self): self.assertIn("Scanned 5 file(s).", formatted_messages) self.assertIn("No issues found!", formatted_messages) - def test_format_messages_with_issues(self): + @patch("nwbinspector._formatting._get_report_header") + def test_format_messages_with_issues(self, mock_header): """Test that the correct summary is generated when issues are found.""" + mock_header.return_value = { + "Timestamp": "2024-01-01 00:00:00", + "Platform": "TestPlatform", + "NWBInspector_version": "0.0.0", + } messages = [ InspectorMessage( message="Test issue 1", @@ -58,7 +71,7 @@ def test_format_messages_with_issues(self): levels = ["file_path", "importance"] nfiles_detected = 4 - formatter = MessageFormatter( + formatter = RstFormatter( messages=messages, levels=levels, nfiles_detected=nfiles_detected, @@ -67,3 +80,170 @@ def test_format_messages_with_issues(self): self.assertIn("Scanned 4 file(s).", formatted_messages) self.assertIn("Found 3 issues across 2 file(s):", formatted_messages) + + +class TestFormatterCompleteOutput(TestCase): + """Test the complete output string for each formatter.""" + + def _create_test_message(self): + """Create a single test message for consistent testing.""" + return InspectorMessage( + message="Test message", + importance=Importance.CRITICAL, + check_function_name="test_check", + object_type="TestType", + object_name="test_object", + location="/test/location", + file_path="/path/to/file.nwb", + ) + + @patch("nwbinspector._formatting._get_report_header") + def test_rst_formatter_complete_output(self, mock_header): + """Test that RstFormatter produces the exact expected RST output.""" + mock_header.return_value = { + "Timestamp": "2024-01-01 00:00:00", + "Platform": "TestPlatform", + "NWBInspector_version": "0.0.0", + } + messages = [self._create_test_message()] + levels = ["importance", "file_path"] + nfiles_detected = 1 + + formatter = RstFormatter( + messages=messages, + levels=levels, + nfiles_detected=nfiles_detected, + ) + formatted_messages = formatter.format_messages() + output = "\n".join(formatted_messages) + + expected_output = """************************************************** +NWBInspector Report Summary + +Timestamp: 2024-01-01 00:00:00 +Platform: TestPlatform +NWBInspector version: 0.0.0 + +Scanned 1 file(s). +Found 1 issues across 1 file(s): + 1 - CRITICAL +************************************************** + + +0 CRITICAL +=========== + +0.0 /path/to/file.nwb: test_check - 'TestType' object at location '/test/location' + Message: Test message +""" + self.assertEqual(output, expected_output) + + @patch("nwbinspector._formatting._get_report_header") + def test_markdown_formatter_complete_output(self, mock_header): + """Test that MarkdownFormatter produces the exact expected Markdown output.""" + mock_header.return_value = { + "Timestamp": "2024-01-01 00:00:00", + "Platform": "TestPlatform", + "NWBInspector_version": "0.0.0", + } + messages = [self._create_test_message()] + levels = ["importance", "file_path"] + nfiles_detected = 1 + + formatter = MarkdownFormatter( + messages=messages, + levels=levels, + nfiles_detected=nfiles_detected, + ) + formatted_messages = formatter.format_messages() + output = "\n".join(formatted_messages) + + expected_output = """************************************************** +NWBInspector Report Summary + +Timestamp: 2024-01-01 00:00:00 +Platform: TestPlatform +NWBInspector version: 0.0.0 + +Scanned 1 file(s). +Found 1 issues across 1 file(s): + 1 - CRITICAL +************************************************** + + +# 0 CRITICAL + +0.0 /path/to/file.nwb: test_check - 'TestType' object at location '/test/location' + Message: Test message +""" + self.assertEqual(output, expected_output) + + @patch("nwbinspector._formatting._get_report_header") + def test_html_formatter_complete_output(self, mock_header): + """Test that HtmlFormatter produces the exact expected HTML output.""" + mock_header.return_value = { + "Timestamp": "2024-01-01 00:00:00", + "Platform": "TestPlatform", + "NWBInspector_version": "0.0.0", + } + messages = [self._create_test_message()] + levels = ["importance", "file_path"] + nfiles_detected = 1 + + formatter = HtmlFormatter( + messages=messages, + levels=levels, + nfiles_detected=nfiles_detected, + ) + formatted_messages = formatter.format_messages() + output = "\n".join(formatted_messages) + + expected_output = """ + + + + NWBInspector Report + + + +
+

NWBInspector Report

+
+

Timestamp: 2024-01-01 00:00:00

+

Platform: TestPlatform

+

NWBInspector version: 0.0.0

+
+
+

Scanned 1 file(s).

+

Found 1 issues across 1 file(s):

+ +
+
+ +

0 CRITICAL

+ +0.0 /path/to/file.nwb: test_check - 'TestType' object at location '/test/location' + Message: Test message + + +""" + self.assertEqual(output, expected_output)