diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0ce7cb3..2230cbd49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## PyNWB 3.0.0 (Upcoming) ### Enhancements and minor changes +- Added `pynwb.read_nwb` convenience method to simplify reading an NWBFile written with any backend @h-mayorquin [#1994](https://github.com/NeurodataWithoutBorders/pynwb/pull/1994) - Added support for NWB schema 2.8.0. @rly [#2001](https://github.com/NeurodataWithoutBorders/pynwb/pull/2001) - Removed `SpatialSeries.bounds` field that was not functional. This will be fixed in a future release. @rly [#1907](https://github.com/NeurodataWithoutBorders/pynwb/pull/1907), [#1996](https://github.com/NeurodataWithoutBorders/pynwb/pull/1996) - Added support for `NWBFile.was_generated_by` field. @stephprince [#1924](https://github.com/NeurodataWithoutBorders/pynwb/pull/1924) diff --git a/docs/gallery/general/plot_read_basics.py b/docs/gallery/general/plot_read_basics.py index 212c5434e..df138e29e 100644 --- a/docs/gallery/general/plot_read_basics.py +++ b/docs/gallery/general/plot_read_basics.py @@ -30,7 +30,6 @@ import matplotlib.pyplot as plt import numpy as np -from pynwb import NWBHDF5IO #################### # We will access NWB data on the `DANDI Archive `_, @@ -103,14 +102,17 @@ # read the data into a :py:class:`~pynwb.file.NWBFile` object. filepath = "sub-P11HMH_ses-20061101_ecephys+image.nwb" -# Open the file in read mode "r", -io = NWBHDF5IO(filepath, mode="r") -nwbfile = io.read() +from pynwb import read_nwb + +nwbfile = read_nwb(filepath) nwbfile ####################################### -# :py:class:`~pynwb.NWBHDF5IO` can also be used as a context manager: +# For more advanced use cases, the :py:class:~pynwb.NWBHDF5IO class provides additional functionality. +# Below, we demonstrate how :py:class:~pynwb.NWBHDF5IO can be used as a context manager +# to read data from an NWB file in a more controlled manner: +from pynwb import NWBHDF5IO with NWBHDF5IO(filepath, mode="r") as io2: nwbfile2 = io2.read() @@ -291,4 +293,4 @@ # ----------------------- # It is good practice, especially on Windows, to close any files that you have opened. -io.close() +nwbfile.get_read_io().close() diff --git a/requirements-opt.txt b/requirements-opt.txt index fa6a18806..8934de4f0 100644 --- a/requirements-opt.txt +++ b/requirements-opt.txt @@ -6,3 +6,6 @@ oaklib==0.5.32 fsspec==2024.10.0 requests==2.32.3 aiohttp==3.10.11 + +# For read_nwb tests +hdmf-zarr diff --git a/src/pynwb/__init__.py b/src/pynwb/__init__.py index 32f9ed4d9..7931322a8 100644 --- a/src/pynwb/__init__.py +++ b/src/pynwb/__init__.py @@ -531,6 +531,71 @@ def read_nwb(**kwargs): return nwbfile +@docval({'name': 'path', 'type': (str, Path), + 'doc': 'Path to the NWB file. Can be either a local filesystem path to ' + 'an HDF5 (.nwb) or Zarr (.zarr) file.'}, + is_method=False) +def read_nwb(**kwargs): + """Read an NWB file from a local path. + + High-level interface for reading NWB files. Automatically handles both HDF5 + and Zarr formats. For advanced use cases (parallel I/O, custom namespaces), + use NWBHDF5IO or NWBZarrIO. + + See also + * :py:class:`~pynwb.NWBHDF5IO`: Core I/O class for HDF5 files with advanced options. + * :py:class:`~hdmf_zarr.nwb.NWBZarrIO`: Core I/O class for Zarr files with advanced options. + + Notes + This function uses the following defaults: + * Always opens in read-only mode + * Automatically loads namespaces + * Reads any backend (e.g. HDF5 or Zarr) if there is an IO class available. + + Advanced features requiring direct use of IO classes (e.g. NWBHDF5IO NWBZarrIO) include: + * Streaming data from s3 + * Custom namespace extensions + * Parallel I/O with MPI + * Custom build managers + * Write or append modes + * Pre-opened HDF5 file objects or Zarr stores + * Remote file access configuration + + Example usage reading a local NWB file: + + .. code-block:: python + + from pynwb import read_nwb + nwbfile = read_nwb("path/to/file.nwb") + + :Returns: pynwb.NWBFile The loaded NWB file object. + """ + + path = popargs('path', kwargs) + # HDF5 is always available so we try that first + backend_is_hdf5 = NWBHDF5IO.can_read(path=path) + if backend_is_hdf5: + return NWBHDF5IO.read_nwb(path=path) + else: + # If hdmf5 zarr is available we try that next + try: + from hdmf_zarr import NWBZarrIO + backend_is_zarr = NWBZarrIO.can_read(path=path) + if backend_is_zarr: + return NWBZarrIO.read_nwb(path=path) + else: + raise ValueError( + f"Unable to read file: '{path}'. The file is not recognized as " + "either a valid HDF5 or Zarr NWB file. Please ensure the file exists and contains valid NWB data." + ) + except ImportError: + raise ValueError( + f"Unable to read file: '{path}'. The file is not recognized as an HDF5 NWB file. " + "If you are trying to read a Zarr file, please install hdmf-zarr using: pip install hdmf-zarr" + ) + + + from . import io as __io # noqa: F401,E402 from .core import NWBContainer, NWBData # noqa: F401,E402 from .base import TimeSeries, ProcessingModule # noqa: F401,E402 diff --git a/test.py b/test.py index f64fcd75d..570bd4748 100644 --- a/test.py +++ b/test.py @@ -264,6 +264,7 @@ def run_integration_tests(verbose=True): logging.info('all classes have integration tests') run_test_suite("tests/integration/utils", "integration utils tests", verbose=verbose) + run_test_suite("tests/integration/io", "integration io tests", verbose=verbose) def clean_up_tests(): diff --git a/tests/integration/hdf5/test_io.py b/tests/integration/hdf5/test_io.py index 1e6ed0593..f17a7217a 100644 --- a/tests/integration/hdf5/test_io.py +++ b/tests/integration/hdf5/test_io.py @@ -613,7 +613,7 @@ def test_read_nwb_method_file(self): io.write(self.nwbfile) import h5py - + file = h5py.File(self.path, 'r') read_nwbfile = NWBHDF5IO.read_nwb(file=file) diff --git a/tests/integration/io/__init__.py b/tests/integration/io/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/io/test_read.py b/tests/integration/io/test_read.py new file mode 100644 index 000000000..145e689d0 --- /dev/null +++ b/tests/integration/io/test_read.py @@ -0,0 +1,77 @@ +from pathlib import Path +import tempfile + +from pynwb import read_nwb +from pynwb.testing.mock.file import mock_NWBFile +from pynwb.testing import TestCase + +import unittest +try: + from hdmf_zarr import NWBZarrIO # noqa f401 + HAVE_NWBZarrIO = True +except ImportError: + HAVE_NWBZarrIO = False + + +class TestReadNWBMethod(TestCase): + """Test suite for the read_nwb function.""" + + def setUp(self): + self.nwbfile = mock_NWBFile() + + def test_read_nwb_hdf5(self): + """Test reading a valid HDF5 NWB file.""" + from pynwb import NWBHDF5IO + + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "test.nwb" + with NWBHDF5IO(path, 'w') as io: + io.write(self.nwbfile) + + read_nwbfile = read_nwb(path=path) + self.assertContainerEqual(read_nwbfile, self.nwbfile) + read_nwbfile.get_read_io().close() + + @unittest.skipIf(not HAVE_NWBZarrIO, "NWBZarrIO library not available") + def test_read_zarr(self): + """Test reading a valid Zarr NWB file.""" + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "test.zarr" + with NWBZarrIO(path, 'w') as io: + io.write(self.nwbfile) + + read_nwbfile = read_nwb(path=path) + self.assertContainerEqual(read_nwbfile, self.nwbfile) + read_nwbfile.get_read_io().close() + + def test_read_zarr_without_hdmf_zarr(self): + """Test attempting to read a Zarr file without hdmf_zarr installed.""" + if HAVE_NWBZarrIO: + self.skipTest("hdmf_zarr is installed") + + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "test.zarr" + path.mkdir() # Create empty directory to simulate Zarr store + + expected_message = ( + f"Unable to read file: '{path}'. The file is not recognized as an HDF5 NWB file. " + "If you are trying to read a Zarr file, please install hdmf-zarr using: pip install hdmf-zarr" + ) + + with self.assertRaisesWith(ValueError, expected_message): + read_nwb(path=path) + + @unittest.skipIf(not HAVE_NWBZarrIO, "NWBZarrIO library not available. Need for correct error message.") + def test_read_invalid_file(self): + """Test attempting to read a file that exists but is neither HDF5 nor Zarr.""" + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "test.txt" + path.write_text("Not an NWB file") + + expected_message = ( + f"Unable to read file: '{path}'. The file is not recognized as either a valid HDF5 or Zarr NWB file. " + "Please ensure the file exists and contains valid NWB data." + ) + + with self.assertRaisesWith(ValueError, expected_message): + read_nwb(path=path) \ No newline at end of file