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 [ + "", + "", + "
", + " ", + "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("Scanned 1 file(s).
+Found 1 issues across 1 file(s):
+