Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1aaad89
enhance per suggestions
CodyCBakerPhD Nov 18, 2025
0a25661
enhance per suggestions
CodyCBakerPhD Nov 18, 2025
d504162
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Nov 19, 2025
637d083
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Nov 20, 2025
8272663
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Nov 26, 2025
f338a98
add tests; required new fixture and new notification
CodyCBakerPhD Nov 26, 2025
7097c60
add tests; required new fixture and new notification
CodyCBakerPhD Nov 26, 2025
f1bea9c
add tests; required new fixture and new notification
CodyCBakerPhD Nov 26, 2025
6487279
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 1, 2025
7c4dde6
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 1, 2025
69f0c74
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 4, 2025
2edc49c
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 5, 2025
ee88e92
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 5, 2025
fb34d6a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 5, 2025
25a5230
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 6, 2025
82f4876
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 6, 2025
1f46a75
add more print header conditions
Dec 6, 2025
7c42b6b
add API test for new info message; fix new pytest fixture; fix notifi…
Dec 6, 2025
6d3b066
adjust datalad test to new expectation
Dec 6, 2025
e95399d
final review
Dec 7, 2025
1fec0f7
Merge branch 'main' into enhance_error_notifications
CodyCBakerPhD Dec 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 50 additions & 12 deletions src/nwb2bids/_command_line_interface/_main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import collections
import pathlib
import typing

import rich_click

from .._converters._run_config import RunConfig
from .._core._convert_nwb_dataset import convert_nwb_dataset
from .._inspection._inspection_result import Severity
from .._inspection._inspection_result import InspectionResult, Severity
from .._tools._pluralize import _pluralize
from ..testing import generate_ephys_tutorial

Expand Down Expand Up @@ -112,23 +113,60 @@ def _run_convert_nwb_dataset(

converter = convert_nwb_dataset(nwb_paths=handled_nwb_paths, run_config=run_config)

if silent:
return

notifications = converter.messages
notifications_by_severity: dict[Severity, list[InspectionResult]] = collections.defaultdict(list)
for notification in notifications:
notifications_by_severity[notification.severity].append(notification)
errors = notifications_by_severity[Severity.ERROR]
criticals = notifications_by_severity[Severity.CRITICAL]

console_notification = ""
if notifications:
notification_text = (
f'\n{(n:=len(notifications))} {_pluralize(n=n, word="suggestion")} for improvement '
f'{_pluralize(n=n, word="was", plural="were")} found during conversion.'
)
console_notification += rich_click.style(text=notification_text, fg="yellow")
if errors:
number_of_errors = len(errors)

top_three = errors[:3]
number_to_print = len(top_three)

if console_notification != "" and not silent:
text = (
"\nBIDS dataset was not successfully created!\n"
f'{_pluralize(n=number_to_print, phrase="An error was", plural="Some errors were")} '
"encountered during conversion.\n"
)
if number_to_print > 1 and number_of_errors > 3:
error_text = "".join(f"\n\t- {error.reason}" for error in top_three)
text += (
f"The first {number_to_print} of {number_of_errors} are shown below:\n\n"
f"{error_text}\n\n"
# TODO: "The full log file can be found at {run_config.log_file_path}\n"
)
console_notification = rich_click.style(text=text, fg="red")
rich_click.echo(message=console_notification)
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you return here, what if the len(criticals) > 0?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors take priority over criticals. Both will show up in the full logs (next PR)


not_any_failures = not any(notification.severity == Severity.ERROR for notification in notifications)
if not_any_failures and not silent:
text = "\nBIDS dataset was successfully created!\n\n"
console_notification = rich_click.style(text=text, fg="green")
if criticals:
text = (
"\nBIDS dataset was successfully created, but may not be valid!\n"
# TODO: "Please review the full notifications report at {run_config.log_file_path}\n\n"
)
console_notification = rich_click.style(text=text, fg="yellow")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The color coding seems a bit unconventional here. The why coding a message related to critical issues in yellow while coding a message related to error issues in red in line 145.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'yellow' akin to 'warning'

'red' akin to breaking 'error'

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to think critical as more severe than error.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the naming convention needs a revamp for clarity

rich_click.echo(message=console_notification)
return
Comment on lines 126 to 161
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two if statements seem to assume that errors and criticals can not be both nonempty. I think that's a false assumption.

If no such assumption is made, the intent of the block of code is to ignore any elements in criticals if errors is nonempty. Such arrangement binds the Severity.ERROR to "BIDS dataset was not successfully created" and Severity.CRITICAL to "BIDS dataset was successfully created, but may not be valid" when there is no issue with the severity of Severity.ERROR. This kind of binding that I suggested to avoid in #176 (comment).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an order of precedence

If any errors, those and only those take priority as information back to the user

If not any errors but some critical, those are then displayed in a less scary manner

Overall trying to limit the amount of output the CLI can flood in these events

All notifications will be included in the linked log dump, so if user checks that they will know all


text = "\nBIDS dataset was successfully created!"
if notifications:
number_of_notifications = len(notifications)

text += (
f'\n{number_of_notifications} {_pluralize(n=number_of_notifications, phrase="suggestion")} for improvement '
f'{_pluralize(n=number_of_notifications, phrase="was", plural="were")} found during conversion.'
# TODO: " Please review the full notifications report at {run_config.log_file_path}\n"
)
text += "\n\n"
console_notification += rich_click.style(text=text, fg="green")
rich_click.echo(message=console_notification)


# nwb2bids tutorial
Expand Down
6 changes: 3 additions & 3 deletions src/nwb2bids/_tools/_pluralize.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
def _pluralize(n: int, word: str, plural: str | None = None) -> str:
def _pluralize(n: int, phrase: str, plural: str | None = None) -> str:
if n == 1:
return word
return phrase
elif plural is None:
return word + "s"
return phrase + "s"
else:
return plural
10 changes: 5 additions & 5 deletions src/nwb2bids/bids_models/_bids_session_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ class BidsSessionMetadata(BaseMetadataContainerModel):
@property
def messages(self) -> list[InspectionResult]:
messages = self.participant.messages.copy()
messages += self._internal_messages
messages += self._internal_messages.copy()
if self.events is not None:
messages += self.events.messages
messages += self.events.messages.copy()
if self.probe_table is not None:
messages += self.probe_table.messages
messages += self.probe_table.messages.copy()
if self.channel_table is not None:
messages += self.channel_table.messages
messages += self.channel_table.messages.copy()
if self.electrode_table is not None:
messages += self.electrode_table.messages
messages += self.electrode_table.messages.copy()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need any of these .copy method call at all?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid any potential mutation of upstream attributes

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see - many of these (but not all) are computed fields so meaningless to copy (though I guess doesn't harm anything either way)

Will do a holistic review in an upcoming standardization of notification handling

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copy method calls are not needed at all in any field, computed or not since the += does not mutate its right operand as demo in the following script.

l1 = [1, 2, 3]
l2 = l1
l3 = [4, 5, 6]

l1 += l3

print(f"l1: {l1}")
print(f"l2: {l2}")
print(f"l3: {l3}")

for idx in range(len(l1)):
    l1[idx] += 10

print("=== After modification ===")
print(f"l1: {l1}")
print(f"l2: {l2}")  # This shows that l1 has been mutated in place, affecting l2
print(f"l3: {l3}")  # l3 remains unchanged, showing right operand of `+=` was not mutated

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean mutation of deeper elements (the inspection results themselves are mutable)

Def not an issue with computed fields since no underlying attribute to mutatate (fresh every time)

Haven't verified that a simple list copy also copies the models it contains however, that was simply an assumption on my part

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean mutation of deeper elements (the inspection results themselves are mutable)

Def not an issue with computed fields since no underlying attribute to mutatate (fresh every time)

Haven't verified that a simple list copy also copies the models it contains however, that was simply an assumption on my part

If you want copies of the elements with in the lists as well, the copy method calls are not going to do it since the method only does shallow copy. You can look into https://docs.python.org/3/library/copy.html#copy.deepcopy though. Why are you concerned about mutation of InspectionResult objects, aren't they supposed to be "read-only" once they are created? I don't think any code should attempt to modify one once its is created.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think the sanitization does mutate the severity of certain things from higher to lower after sanitization is performed

Might be better ways of doing that in conjunction with frozen models tho

messages.sort(key=lambda message: (-message.category.value, -message.severity.value, message.title))
return messages

Expand Down
23 changes: 22 additions & 1 deletion src/nwb2bids/bids_models/_probes.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import json
import pathlib
from typing import Any

import pandas
import pydantic
import pynwb
import typing_extensions

from .._inspection._inspection_result import InspectionResult
from .._inspection._inspection_result import Category, DataStandard, InspectionResult, Severity
from ..bids_models._base_metadata_model import BaseMetadataContainerModel, BaseMetadataModel


Expand All @@ -20,6 +21,26 @@ class Probe(BaseMetadataModel):
class ProbeTable(BaseMetadataContainerModel):
probes: list[Probe]

def _check_fields(self) -> None:
# Check if values are specified
probes_missing_description = [probe for probe in self.probes if probe.description is None]
for probe_missing_description in probes_missing_description:
self.messages.append(
InspectionResult(
title="Missing description",
reason="A basic description of this field is recommended to improve contextual understanding.",
solution="Add a description to the field.",
field=f"nwbfile.devices.{probe_missing_description.probe_id}",
source_file_paths=[], # TODO: figure out better way of handling
data_standards=[DataStandard.BIDS, DataStandard.NWB],
category=Category.STYLE_SUGGESTION,
severity=Severity.INFO,
)
)

def model_post_init(self, context: Any, /) -> None:
self._check_fields()

@pydantic.computed_field
@property
def messages(self) -> list[InspectionResult]:
Expand Down
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pathlib
import shutil
import sys
import uuid

import py.path
import pynwb
Expand Down Expand Up @@ -317,6 +318,29 @@ def problematic_nwbfile_path_3(testing_files_directory: pathlib.Path) -> pathlib
return nwbfile_path


@pytest.fixture(scope="session")
def problematic_nwbfile_path_4(testing_files_directory: pathlib.Path) -> pathlib.Path:
"""
A fourth NWB file with less problematic metadata corresponding only to low-level 'info' events.
"""
nwbfile = pynwb.NWBFile(
identifier=str(uuid.uuid4()),
session_id="problematic4",
session_description="",
session_start_time=datetime.datetime.now().astimezone(),
)
device = pynwb.device.Device(name="DeviceWithoutDescription")
nwbfile.add_device(device)

problematic_subdirectory = testing_files_directory / "problematic"
problematic_subdirectory.mkdir(exist_ok=True)
nwbfile_path = problematic_subdirectory / "problematic4.nwb"
with pynwb.NWBHDF5IO(path=nwbfile_path, mode="w") as file_stream:
file_stream.write(nwbfile)

return nwbfile_path


@pytest.fixture(scope="session")
def problematic_nwbfile_path_missing_session_id(testing_files_directory: pathlib.Path) -> pathlib.Path:
nwbfile = pynwb.testing.mock.file.mock_NWBFile(session_id=None)
Expand Down
60 changes: 58 additions & 2 deletions tests/convert_nwb_dataset/test_cli_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,68 @@
import subprocess


def test_problematic_cli_messages(problematic_nwbfile_path_1: pathlib.Path, temporary_bids_directory: pathlib.Path):
def test_problematic_cli_error_messages(
problematic_nwbfile_path_1: pathlib.Path, temporary_bids_directory: pathlib.Path
):
command = f"nwb2bids convert {problematic_nwbfile_path_1} -o {temporary_bids_directory}"

result = subprocess.run(args=command, shell=True, capture_output=True)
assert result.returncode == 0
assert b"4 suggestions for improvement were found during conversion." in result.stdout

expected_message = [
"",
"BIDS dataset was not successfully created!",
"Some errors were encountered during conversion.",
"The first 3 of 4 are shown below:",
"",
"",
"\t- Participant species is not a proper Latin binomial or NCBI Taxonomy id.",
"\t- The participant ID contains invalid characters. BIDS allows only the "
"plus sign to be used as a separator in the subject entity label. "
"Underscores, dashes, spaces, slashes, and other special characters "
"(including #) are expressly forbidden.",
"\t- Participant sex is not one of the allowed patterns by BIDS.",
"",
"",
]
assert expected_message == result.stdout.decode(encoding="utf-8").splitlines()
assert result.stderr == b""


def test_problematic_cli_critical_messages(
problematic_nwbfile_path_3: pathlib.Path, temporary_bids_directory: pathlib.Path
):
command = f"nwb2bids convert {problematic_nwbfile_path_3} -o {temporary_bids_directory}"

result = subprocess.run(args=command, shell=True, capture_output=True)
assert result.returncode == 0

expected_message = ["", "BIDS dataset was successfully created, but may not be valid!", ""]
assert expected_message == result.stdout.decode(encoding="utf-8").splitlines()
assert result.stderr == b""


def test_problematic_cli_info_messages(
problematic_nwbfile_path_4: pathlib.Path, temporary_bids_directory: pathlib.Path
):
command = f"nwb2bids convert {problematic_nwbfile_path_4} -o {temporary_bids_directory}"

result = subprocess.run(args=command, shell=True, capture_output=True)
assert result.returncode == 0

expected_message = ["", "BIDS dataset was successfully created, but may not be valid!", ""]
assert expected_message == result.stdout.decode(encoding="utf-8").splitlines()
assert result.stderr == b""


def test_problematic_cli_success(minimal_nwbfile_path: pathlib.Path, temporary_bids_directory: pathlib.Path):
command = f"nwb2bids convert {minimal_nwbfile_path} -o {temporary_bids_directory}"

result = subprocess.run(args=command, shell=True, capture_output=True)
assert result.returncode == 0

expected_message = ["", "BIDS dataset was successfully created!", "", ""]
assert expected_message == result.stdout.decode(encoding="utf-8").splitlines()
assert result.stderr == b""


Expand Down