Repository: NeurodataWithoutBorders/pynwb (2025-03-20 ~ 2025-04-03)

problem_statement: [Feature]: Support len(processing_module) to get number of items
### What would you like to see added to PyNWB?

A ProcessingModule is a special MultiContainerInterface that contains a labeled dictionary of `NWBDataInterface` objects called `data_interfaces`. It also has a description but nothing else. It's a bit unwieldy to call `len(nwbfile.processing["behavior"].data_interfaces)`. It would be nicer to treat `nwbfile.processing["behavior"]` as a dictionary and be able to call `len` and other functions on it: `len(nwbfile.processing["behavior"])`.

For convenience, getitem works, e.g., `nwbfile.processing["behavior"]["child1"]`, and I believe iterating works.

It might also be nice to move the other dictionary functions up to that level, e.g., `keys`, `values`, `items`.

### Is your feature request related to a problem?

_No response_

### What solution would you like?

See above.

### Do you have any interest in helping implement the feature?

Yes.

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/.github/CODE_OF_CONDUCT.rst)
- [X] Have you checked the [Contributing](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/docs/CONTRIBUTING.rst) document?
- [X] Have you ensured this change was not already [requested](https://github.com/NeurodataWithoutBorders/pynwb/issues)?
date: 2025-03-20
patch: diff --git a/src/pynwb/base.py b/src/pynwb/base.py
index a7154450..cd982e23 100644
--- a/src/pynwb/base.py
+++ b/src/pynwb/base.py
@@ -84,6 +84,46 @@ class ProcessingModule(MultiContainerInterface):
         warn('get_data_interface is deprecated and will be removed in PyNWB 4.0. Use get instead.', DeprecationWarning)
         return self.get(kwargs['data_interface_name'])
 
+    def __len__(self):
+        """Get the number of data interfaces in this ProcessingModule.
+
+        Returns
+        -------
+        int
+            Number of data interfaces
+        """
+        return len(self.data_interfaces)
+
+    def keys(self):
+        """Get the names of data interfaces in this ProcessingModule.
+
+        Returns
+        -------
+        KeysView
+            View of interface names
+        """
+        return self.data_interfaces.keys()
+
+    def values(self):
+        """Get the data interfaces in this ProcessingModule.
+
+        Returns
+        -------
+        ValuesView
+            View of interfaces
+        """
+        return self.data_interfaces.values()
+
+    def items(self):
+        """Get the (name, interface) pairs in this ProcessingModule.
+
+        Returns
+        -------
+        ItemsView
+            View of (name, interface) pairs
+        """
+        return self.data_interfaces.items()
+
 
 @register_class('TimeSeries', CORE_NAMESPACE)
 class TimeSeries(NWBDataInterface):

test_patch: diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py
index c75ba1e2..c1818957 100644
--- a/tests/unit/test_base.py
+++ b/tests/unit/test_base.py
@@ -98,6 +98,35 @@ class TestProcessingModule(TestCase):
         tmp = self.pm["test_ts"]
         self.assertIs(tmp, ts)
 
+    def test_len(self):
+        """Test that len() returns number of data interfaces."""
+        self.assertEqual(len(self.pm), 0)
+        ts = self._create_time_series()
+        self.pm.add(ts)
+        self.assertEqual(len(self.pm), 1)
+        ts2 = TimeSeries(name="test_ts2", data=[1, 2, 3], unit="unit", rate=1.0)
+        self.pm.add(ts2)
+        self.assertEqual(len(self.pm), 2)
+
+    def test_dict_methods(self):
+        """Test dictionary-like methods (keys, values, items)."""
+        ts = self._create_time_series()
+        ts2 = TimeSeries(name="test_ts2", data=[1, 2, 3], unit="unit", rate=1.0)
+        self.pm.add(ts)
+        self.pm.add(ts2)
+
+        # Test keys()
+        keys = self.pm.keys()
+        self.assertEqual(set(keys), {"test_ts", "test_ts2"})
+
+        # Test values()
+        values = self.pm.values()
+        self.assertEqual(set(values), {ts, ts2})
+
+        # Test items()
+        items = self.pm.items()
+        self.assertEqual(set(items), {("test_ts", ts), ("test_ts2", ts2)})
+
 
 class TestTimeSeries(TestCase):
     def test_init_no_parent(self):


----

problem_statement: [Bug]: Fail to read NWB file where TimeZone is missing from session_start_time
### What happened?

Not sure if this is a bug, but it seems strict to enforce TimeZone on read.

### Steps to Reproduce

In MATLAB:
```matlab
nwb = NwbFile( ...
    'session_description', 'Simple demo file', ...
    'identifier', 'test_file', ...
    'session_start_time', datetime("now"), ...
    'timestamps_reference_time', datetime("now"));
nwbExport(nwb, 'temp.nwb')
```

In python:
```python
from pynwb import NWBHDF5IO

io = NWBHDF5IO('temp.nwb', mode="r")
nwbfile = io.read()
```

### Traceback

```python
Error using objectmapper>construct
Python Error: ConstructError: (root GroupBuilder {'attributes': {'namespace': 'core', 'neurodata_type': 'NWBFile', 'nwb_version': '2.8.0',
'object_id': 'ad86c34b-4de3-4352-a698-d112fe26f032'}, 'groups': {'acquisition': root/acquisition GroupBuilder {'attributes': {}, 'groups': {},
'datasets': {}, 'links': {}}, 'analysis': root/analysis GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}, 'general':
root/general GroupBuilder {'attributes': {}, 'groups': {'devices': root/general/devices GroupBuilder {'attributes': {}, 'groups': {},
'datasets': {}, 'links': {}}, 'extracellular_ephys': root/general/extracellular_ephys GroupBuilder {'attributes': {}, 'groups': {},
'datasets': {}, 'links': {}}, 'intracellular_ephys': root/general/intracellular_ephys GroupBuilder {'attributes': {}, 'groups': {},
'datasets': {}, 'links': {}}, 'optogenetics': root/general/optogenetics GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links':
{}}, 'optophysiology': root/general/optophysiology GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}}, 'datasets':
{}, 'links': {}}, 'intervals': root/intervals GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}, 'processing':
root/processing GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}, 'scratch': root/scratch GroupBuilder
{'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}, 'stimulus': root/stimulus GroupBuilder {'attributes': {}, 'groups':
{'presentation': root/stimulus/presentation GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}, 'templates':
root/stimulus/templates GroupBuilder {'attributes': {}, 'groups': {}, 'datasets': {}, 'links': {}}}, 'datasets': {}, 'links': {}}},
'datasets': {'file_create_date': root/file_create_date DatasetBuilder {'attributes': {}, 'data': <StrDataset for HDF5 dataset
"file_create_date": shape (1,), type "|O">}, 'identifier': root/identifier DatasetBuilder {'attributes': {}, 'data': 'test_file'},
'session_description': root/session_description DatasetBuilder {'attributes': {}, 'data': 'Simple demo file'}, 'session_start_time':
root/session_start_time DatasetBuilder {'attributes': {}, 'data': '2025-03-05T15:08:07.380042'}, 'timestamps_reference_time':
root/timestamps_reference_time DatasetBuilder {'attributes': {}, 'data': '2025-03-05T15:08:07.380880'}}, 'links': {}}, "Could not construct
NWBFile object due to: 'timestamps_reference_time' must be a timezone-aware datetime object.")
```

### Operating System

macOS

### Python Executable

Python

### Python Version

3.9

### Package Versions

_No response_

### Code of Conduct

- [x] I agree to follow this project's [Code of Conduct](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/.github/CODE_OF_CONDUCT.rst)
- [x] Have you checked the [Contributing](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/docs/CONTRIBUTING.rst) document?
- [x] Have you ensured this bug was not already [reported](https://github.com/NeurodataWithoutBorders/pynwb/issues)?
date: 2025-04-03
patch: diff --git a/src/pynwb/file.py b/src/pynwb/file.py
index d0a0e617..33369904 100644
--- a/src/pynwb/file.py
+++ b/src/pynwb/file.py
@@ -491,7 +491,7 @@ class NWBFile(MultiContainerInterface, HERDManager):
         if timestamps_reference_time is None:
             args_to_set['timestamps_reference_time'] = args_to_set['session_start_time']
         elif timestamps_reference_time.tzinfo is None:
-            raise ValueError("'timestamps_reference_time' must be a timezone-aware datetime object.")
+            args_to_set['timestamps_reference_time'] = _add_missing_timezone(timestamps_reference_time)
 
         # convert file_create_date to list and add timezone if missing
         file_create_date = args_to_set['file_create_date']

test_patch: diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py
index c76abd7f..7d66926c 100644
--- a/tests/unit/test_file.py
+++ b/tests/unit/test_file.py
@@ -698,13 +698,14 @@ class TestTimestampsRefAware(TestCase):
         self.ref_time_notz = datetime(1979, 1, 1, 0, 0, 0)
 
     def test_reftime_tzaware(self):
-        with self.assertRaises(ValueError):
-            # 'timestamps_reference_time' must be a timezone-aware datetime
-            NWBFile('test session description',
-                    'TEST124',
-                    self.start_time,
-                    timestamps_reference_time=self.ref_time_notz)
+        with self.assertWarnsWith(UserWarning, "Date is missing timezone information. Updating to local timezone."):
+            nwbfile = NWBFile('test session description',
+                              'TEST124',
+                              self.start_time,
+                              timestamps_reference_time=self.ref_time_notz)
 
+        # test time zone is automatically added to timestamps_reference_time
+        self.assertEqual(nwbfile.timestamps_reference_time, self.ref_time_notz.replace(tzinfo=tzlocal()))
 
 class TestTimezone(TestCase):
     def test_raise_warning__add_missing_timezone(self):


----

problem_statement: [Bug]: External file format error on ImageSeries
### What happened?

When creating a new `ImageSeries` with an external file, pynwb now throws an error if `format` is not specified as "external", even if no format value was provided.

Would it be better for this to remain a warning (or removed entirely), since it is just informing the user that the value for format was automatically set to "external" because no format parameter was provided? Or should a user always have to add the `format='external'` argument when providing external data.

There is a separate check in the ImageSeries initialization if a different value for format is provided (e.g. if `format='png'` when `external_file` is present).


### Steps to Reproduce

```python
from pynwb.image import ImageSeries
image_series = ImageSeries(
            name="TestImageSeries",
            rate=1.0,
            external_file=["test.png"],
        )
```

### Traceback

```python
ImageSeries 'TestImageSeries': The value for 'format' has been changed to 'external'. If an external file is detected, setting a value for 'format' other than 'external' is deprecated.
```

### Operating System

macOS

### Python Executable

Conda

### Python Version

3.13

### Package Versions

_No response_

### Code of Conduct

- [x] I agree to follow this project's [Code of Conduct](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/.github/CODE_OF_CONDUCT.rst)
- [x] Have you checked the [Contributing](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/docs/CONTRIBUTING.rst) document?
- [x] Have you ensured this bug was not already [reported](https://github.com/NeurodataWithoutBorders/pynwb/issues)?
date: 2025-04-03
patch: diff --git a/src/pynwb/image.py b/src/pynwb/image.py
index acd9bdb9..fdea3fc1 100644
--- a/src/pynwb/image.py
+++ b/src/pynwb/image.py
@@ -128,11 +128,7 @@ class ImageSeries(TimeSeries):
         for key, val in args_to_set.items():
             setattr(self, key, val)
 
-        if self._change_external_file_format():
-            self._error_on_new_warn_on_construct(error_msg=f"{self.__class__.__name__} '{self.name}': "
-                                                "The value for 'format' has been changed to 'external'. "
-                                                "If an external file is detected, setting a value for "
-                                                "'format' other than 'external' is deprecated.")
+        self._change_external_file_format()
 
         error_msg = self._check_image_series_dimension()
         if error_msg:
@@ -160,9 +156,6 @@ class ImageSeries(TimeSeries):
             and self.format is None
         ):
             self.format = "external"
-            return True
-
-        return False
 
     def _check_time_series_dimension(self):
         """Override _check_time_series_dimension to do nothing.

test_patch: diff --git a/tests/unit/test_image.py b/tests/unit/test_image.py
index 7d4f5f2f..52c59f19 100644
--- a/tests/unit/test_image.py
+++ b/tests/unit/test_image.py
@@ -249,25 +249,14 @@ class ImageSeriesConstructor(TestCase):
 
     def test_external_file_default_format(self):
         """Test that format is set to 'external' if not provided, when external_file is provided."""
-        msg = (
-            "ImageSeries 'test_iS': The value for 'format' has been changed to 'external'. "
-            "If an external file is detected, setting a value for "
-            "'format' other than 'external' is deprecated."
-        )
+
         kwargs = dict(name="test_iS",
                 external_file=["external_file", "external_file2"],
                 unit="n.a.",
                 starting_frame=[0, 10],
                 rate=0.2,)
         
-        # create object with deprecated argument
-        with self.assertRaisesWith(ValueError, msg):
-            ImageSeries(**kwargs)
-
-        # create object in construct mode, modeling the behavior of the ObjectMapper on read
-        iS = ImageSeries.__new__(ImageSeries, in_construct_mode=True)
-        with self.assertWarnsWith(warn_type=UserWarning, exc_msg=msg):
-            iS.__init__(**kwargs)
+        iS = ImageSeries(**kwargs)
         self.assertEqual(iS.format, "external")
 
     def test_external_file_with_correct_format(self):