diff --git a/CHANGELOG.md b/CHANGELOG.md index e59ced055..34665c932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,15 @@ # PyNWB Changelog -## PyNWB 2.8.0 (Upcoming) +## PyNWB 2.8.0 (May 28, 2024) ### Enhancements and minor changes - Set rate default value inside `mock_ElectricalSeries` to avoid having to set `rate=None` explicitly when passing timestamps. @h-mayorquin [#1894](https://github.com/NeurodataWithoutBorders/pynwb/pull/1894) - Integrate validation through the `TypeConfigurator`. @mavaylon1 [#1829](https://github.com/NeurodataWithoutBorders/pynwb/pull/1829) - Exposed `aws_region` to `NWBHDF5IO`. @rly [#1903](https://github.com/NeurodataWithoutBorders/pynwb/pull/1903) +### Bug fixes +- Revert changes in PyNWB 2.7.0 that allow datetimes without a timezone and without a time while issues with DANDI upload are resolved. @rly [#1908](https://github.com/NeurodataWithoutBorders/pynwb/pull/1908) + ## PyNWB 2.7.0 (May 2, 2024) ### Enhancements and minor changes diff --git a/docs/gallery/advanced_io/h5dataio.py b/docs/gallery/advanced_io/h5dataio.py index 5b5f73bc2..3b4391655 100644 --- a/docs/gallery/advanced_io/h5dataio.py +++ b/docs/gallery/advanced_io/h5dataio.py @@ -19,9 +19,12 @@ # from datetime import datetime + +from dateutil.tz import tzlocal + from pynwb import NWBFile -start_time = datetime(2017, 4, 3, hour=11, minute=0) +start_time = datetime(2017, 4, 3, 11, tzinfo=tzlocal()) nwbfile = NWBFile( session_description="demonstrate advanced HDF5 I/O features", @@ -29,6 +32,7 @@ session_start_time=start_time, ) + #################### # Normally if we create a :py:class:`~pynwb.base.TimeSeries` we would do diff --git a/docs/gallery/advanced_io/linking_data.py b/docs/gallery/advanced_io/linking_data.py index 93f93c825..2f79d1488 100644 --- a/docs/gallery/advanced_io/linking_data.py +++ b/docs/gallery/advanced_io/linking_data.py @@ -51,12 +51,15 @@ # sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_linking_data.png' from datetime import datetime +from uuid import uuid4 + import numpy as np +from dateutil.tz import tzlocal + from pynwb import NWBHDF5IO, NWBFile, TimeSeries -from uuid import uuid4 # Create the base data -start_time = datetime(2017, 4, 3, hour=11, minute=0) +start_time = datetime(2017, 4, 3, 11, tzinfo=tzlocal()) data = np.arange(1000).reshape((100, 10)) timestamps = np.arange(100) filename1 = "external1_example.nwb" diff --git a/docs/gallery/advanced_io/parallelio.py b/docs/gallery/advanced_io/parallelio.py index f04ef4a87..53abdf239 100644 --- a/docs/gallery/advanced_io/parallelio.py +++ b/docs/gallery/advanced_io/parallelio.py @@ -32,7 +32,7 @@ # from datetime import datetime # from hdmf.backends.hdf5.h5_utils import H5DataIO # -# start_time = datetime(2018, 4, 25, hour=2, minute=30, second=3) +# start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz("US/Pacific")) # fname = "test_parallel_pynwb.nwb" # rank = MPI.COMM_WORLD.rank # The process ID (integer 0-3 for 4-process run) # diff --git a/docs/gallery/advanced_io/plot_iterative_write.py b/docs/gallery/advanced_io/plot_iterative_write.py index 36b8bc0be..958981a0b 100644 --- a/docs/gallery/advanced_io/plot_iterative_write.py +++ b/docs/gallery/advanced_io/plot_iterative_write.py @@ -110,9 +110,13 @@ # sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_iterative_write.png' from datetime import datetime -from pynwb import NWBHDF5IO, NWBFile, TimeSeries from uuid import uuid4 +from dateutil.tz import tzlocal + +from pynwb import NWBHDF5IO, NWBFile, TimeSeries + + def write_test_file(filename, data, close_io=True): """ @@ -125,7 +129,7 @@ def write_test_file(filename, data, close_io=True): """ # Create a test NWBfile - start_time = datetime(2017, 4, 3, hour=11, minute=30) + start_time = datetime(2017, 4, 3, 11, tzinfo=tzlocal()) nwbfile = NWBFile( session_description="demonstrate iterative write", identifier=str(uuid4()), diff --git a/docs/gallery/domain/images.py b/docs/gallery/domain/images.py index b4511e3c5..d6eef24b3 100644 --- a/docs/gallery/domain/images.py +++ b/docs/gallery/domain/images.py @@ -19,18 +19,23 @@ The following examples will reference variables that may not be defined within the block they are used in. For clarity, we define them here: """ +# Define file paths used in the tutorial + +import os # sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_image_data.png' from datetime import datetime +from uuid import uuid4 + import numpy as np -import os +from dateutil import tz +from dateutil.tz import tzlocal from PIL import Image + from pynwb import NWBHDF5IO, NWBFile from pynwb.base import Images from pynwb.image import GrayscaleImage, ImageSeries, OpticalSeries, RGBAImage, RGBImage -from uuid import uuid4 -# Define file paths used in the tutorial nwbfile_path = os.path.abspath("images_tutorial.nwb") moviefiles_path = [ os.path.abspath("image/file_1.tiff"), @@ -45,12 +50,12 @@ # Create an :py:class:`~pynwb.file.NWBFile` object with the required fields # (``session_description``, ``identifier``, ``session_start_time``) and additional metadata. -session_start_time = datetime(2018, 4, 25, hour=2, minute=30) +session_start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz("US/Pacific")) nwbfile = NWBFile( session_description="my first synthetic recording", identifier=str(uuid4()), - session_start_time=session_start_time, + session_start_time=datetime.now(tzlocal()), experimenter=[ "Baggins, Bilbo", ], @@ -133,13 +138,13 @@ # ^^^^^^^^^^^^^^ # # External files (e.g. video files of the behaving animal) can be added to the :py:class:`~pynwb.file.NWBFile` -# by creating an :py:class:`~pynwb.image.ImageSeries` object using the +# by creating an :py:class:`~pynwb.image.ImageSeries` object using the # :py:attr:`~pynwb.image.ImageSeries.external_file` attribute that specifies # the path to the external file(s) on disk. # The file(s) path must be relative to the path of the NWB file. # Either ``external_file`` or ``data`` must be specified, but not both. # -# If the sampling rate is constant, use :py:attr:`~pynwb.base.TimeSeries.rate` and +# If the sampling rate is constant, use :py:attr:`~pynwb.base.TimeSeries.rate` and # :py:attr:`~pynwb.base.TimeSeries.starting_time` to specify time. # For irregularly sampled recordings, use :py:attr:`~pynwb.base.TimeSeries.timestamps` to specify time for each sample # image. @@ -147,7 +152,7 @@ # Each external image may contain one or more consecutive frames of the full :py:class:`~pynwb.image.ImageSeries`. # The :py:attr:`~pynwb.image.ImageSeries.starting_frame` attribute serves as an index to indicate which frame # each file contains. -# For example, if the ``external_file`` dataset has three paths to files and the first and the second file have 2 +# For example, if the ``external_file`` dataset has three paths to files and the first and the second file have 2 # frames, and the third file has 3 frames, then this attribute will have values `[0, 2, 4]`. external_file = [ diff --git a/docs/gallery/general/add_remove_containers.py b/docs/gallery/general/add_remove_containers.py index fcb74e72a..86aa373b2 100644 --- a/docs/gallery/general/add_remove_containers.py +++ b/docs/gallery/general/add_remove_containers.py @@ -33,7 +33,7 @@ nwbfile = NWBFile( session_description="demonstrate adding to an NWB file", identifier="NWB123", - session_start_time=datetime.datetime.now(), + session_start_time=datetime.datetime.now(datetime.timezone.utc), ) filename = "nwbfile.nwb" @@ -91,7 +91,7 @@ nwbfile = NWBFile( session_description="demonstrate export of an NWB file", identifier="NWB123", - session_start_time=datetime.datetime.now(), + session_start_time=datetime.datetime.now(datetime.timezone.utc), ) data1 = list(range(100, 200, 10)) timestamps1 = np.arange(10, dtype=float) diff --git a/docs/gallery/general/extensions.py b/docs/gallery/general/extensions.py index 7e232f168..ddf9159c7 100644 --- a/docs/gallery/general/extensions.py +++ b/docs/gallery/general/extensions.py @@ -164,15 +164,16 @@ def __init__(self, **kwargs): # To demonstrate this, first we will make some simulated data using our extensions. from datetime import datetime + +from dateutil.tz import tzlocal + from pynwb import NWBFile -from uuid import uuid4 -session_start_time = datetime(2017, 4, 3, hour=11, minute=0) +start_time = datetime(2017, 4, 3, 11, tzinfo=tzlocal()) +create_date = datetime(2017, 4, 15, 12, tzinfo=tzlocal()) nwbfile = NWBFile( - session_description="demonstrate caching", - identifier=str(uuid4()), - session_start_time=session_start_time, + "demonstrate caching", "NWB456", start_time, file_create_date=create_date ) device = nwbfile.create_device(name="trodes_rig123") @@ -332,6 +333,9 @@ class PotatoSack(MultiContainerInterface): # Then use the objects (again, this would often be done in a different file). from datetime import datetime + +from dateutil.tz import tzlocal + from pynwb import NWBHDF5IO, NWBFile # You can add potatoes to a potato sack in different ways @@ -339,11 +343,8 @@ class PotatoSack(MultiContainerInterface): potato_sack.add_potato(Potato("potato2", 3.0, 4.0)) potato_sack.create_potato("big_potato", 10.0, 20.0) -session_start_time = datetime(2017, 4, 3, hour=12, minute=0) nwbfile = NWBFile( - session_description="a file with metadata", - identifier=str(uuid4()), - session_start_time = session_start_time, + "a file with metadata", "NB123A", datetime(2018, 6, 1, tzinfo=tzlocal()) ) pmod = nwbfile.create_processing_module("module_name", "desc") diff --git a/docs/gallery/general/object_id.py b/docs/gallery/general/object_id.py index 25f125805..a4de45625 100644 --- a/docs/gallery/general/object_id.py +++ b/docs/gallery/general/object_id.py @@ -16,14 +16,16 @@ """ -# sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_objectid.png' - from datetime import datetime + import numpy as np +from dateutil.tz import tzlocal + +# sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_objectid.png' from pynwb import NWBFile, TimeSeries # set up the NWBFile -start_time = datetime(2019, 4, 3, hour=11, minute=0) +start_time = datetime(2019, 4, 3, 11, tzinfo=tzlocal()) nwbfile = NWBFile( session_description="demonstrate NWB object IDs", identifier="NWB456", diff --git a/docs/gallery/general/plot_file.py b/docs/gallery/general/plot_file.py index 5bfa837ee..5c59abf8d 100644 --- a/docs/gallery/general/plot_file.py +++ b/docs/gallery/general/plot_file.py @@ -165,7 +165,7 @@ # Use keyword arguments when constructing :py:class:`~pynwb.file.NWBFile` objects. # -session_start_time = datetime(2018, 4, 25, hour=2, minute=30, second=3, tzinfo=tz.gettz("US/Pacific")) +session_start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz("US/Pacific")) nwbfile = NWBFile( session_description="Mouse exploring an open field", # required diff --git a/docs/gallery/general/plot_timeintervals.py b/docs/gallery/general/plot_timeintervals.py index 3a4ad306a..4069fd4a4 100644 --- a/docs/gallery/general/plot_timeintervals.py +++ b/docs/gallery/general/plot_timeintervals.py @@ -36,15 +36,18 @@ # sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_timeintervals.png' from datetime import datetime +from uuid import uuid4 + import numpy as np +from dateutil.tz import tzlocal + from pynwb import NWBFile, TimeSeries -from uuid import uuid4 # create the NWBFile nwbfile = NWBFile( session_description="my first synthetic recording", # required identifier=str(uuid4()), # required - session_start_time=datetime(2017, 4, 3, hour=11), # required + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal()), # required experimenter="Baggins, Bilbo", # optional lab="Bag End Laboratory", # optional institution="University of Middle Earth at the Shire", # optional diff --git a/docs/gallery/general/scratch.py b/docs/gallery/general/scratch.py index 9083e4081..0e00c5e96 100644 --- a/docs/gallery/general/scratch.py +++ b/docs/gallery/general/scratch.py @@ -27,20 +27,24 @@ # To demonstrate linking and scratch space, lets assume we are starting with some acquired data. # -# sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_scratch.png' - from datetime import datetime + import numpy as np +from dateutil.tz import tzlocal + +# sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_scratch.png' from pynwb import NWBHDF5IO, NWBFile, TimeSeries # set up the NWBFile -start_time = datetime(2019, 4, 3, hour=11, minute=0) +start_time = datetime(2019, 4, 3, 11, tzinfo=tzlocal()) +create_date = datetime(2019, 4, 15, 12, tzinfo=tzlocal()) nwb = NWBFile( session_description="demonstrate NWBFile scratch", # required identifier="NWB456", # required session_start_time=start_time, # required -) + file_create_date=create_date, +) # optional # make some fake data timestamps = np.linspace(0, 100, 1024) diff --git a/src/pynwb/file.py b/src/pynwb/file.py index 06b7fbe07..0b294e873 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -1,4 +1,4 @@ -from datetime import datetime, date, timedelta +from datetime import datetime, timedelta from dateutil.tz import tzlocal from collections.abc import Iterable from warnings import warn @@ -104,8 +104,8 @@ class Subject(NWBContainer): 'doc': ('The weight of the subject, including units. Using kilograms is recommended. e.g., "0.02 kg". ' 'If a float is provided, then the weight will be stored as "[value] kg".'), 'default': None}, - {'name': 'date_of_birth', 'type': (datetime, date), 'default': None, - 'doc': 'The date of birth, which may include time and timezone. May be supplied instead of age.'}, + {'name': 'date_of_birth', 'type': datetime, 'default': None, + 'doc': 'The datetime of the date of birth. May be supplied instead of age.'}, {'name': 'strain', 'type': str, 'doc': 'The strain of the subject, e.g., "C57BL/6J"', 'default': None}, ) def __init__(self, **kwargs): @@ -141,6 +141,10 @@ def __init__(self, **kwargs): if isinstance(args_to_set["age"], timedelta): args_to_set["age"] = pd.Timedelta(args_to_set["age"]).isoformat() + date_of_birth = args_to_set['date_of_birth'] + if date_of_birth and date_of_birth.tzinfo is None: + args_to_set['date_of_birth'] = _add_missing_timezone(date_of_birth) + for key, val in args_to_set.items(): setattr(self, key, val) @@ -304,11 +308,10 @@ class NWBFile(MultiContainerInterface, HERDManager): @docval({'name': 'session_description', 'type': str, 'doc': 'a description of the session where this data was generated'}, {'name': 'identifier', 'type': str, 'doc': 'a unique text identifier for the file'}, - {'name': 'session_start_time', 'type': (datetime, date), - 'doc': 'the start date and time of the recording session'}, - {'name': 'file_create_date', 'type': ('array_data', datetime, date), + {'name': 'session_start_time', 'type': datetime, 'doc': 'the start date and time of the recording session'}, + {'name': 'file_create_date', 'type': ('array_data', datetime), 'doc': 'the date and time the file was created and subsequent modifications made', 'default': None}, - {'name': 'timestamps_reference_time', 'type': (datetime, date), + {'name': 'timestamps_reference_time', 'type': datetime, 'doc': 'date and time corresponding to time zero of all timestamps; defaults to value ' 'of session_start_time', 'default': None}, {'name': 'experimenter', 'type': (tuple, list, str), @@ -463,18 +466,26 @@ def __init__(self, **kwargs): kwargs['name'] = 'root' super().__init__(**kwargs) + # add timezone to session_start_time if missing + session_start_time = args_to_set['session_start_time'] + if session_start_time.tzinfo is None: + args_to_set['session_start_time'] = _add_missing_timezone(session_start_time) + # set timestamps_reference_time to session_start_time if not provided + # if provided, ensure that it has a timezone timestamps_reference_time = args_to_set['timestamps_reference_time'] 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.") # convert file_create_date to list and add timezone if missing file_create_date = args_to_set['file_create_date'] if file_create_date is None: file_create_date = datetime.now(tzlocal()) - if isinstance(file_create_date, (datetime, date)): + if isinstance(file_create_date, datetime): file_create_date = [file_create_date] - args_to_set['file_create_date'] = file_create_date + args_to_set['file_create_date'] = list(map(_add_missing_timezone, file_create_date)) # backwards-compatibility code for ic_electrodes / icephys_electrodes icephys_electrodes = args_to_set['icephys_electrodes'] @@ -1144,6 +1155,18 @@ def copy(self): return NWBFile(**kwargs) +def _add_missing_timezone(date): + """ + Add local timezone information on a datetime object if it is missing. + """ + if not isinstance(date, datetime): + raise ValueError("require datetime object") + if date.tzinfo is None: + warn("Date is missing timezone information. Updating to local timezone.", stacklevel=2) + return date.replace(tzinfo=tzlocal()) + return date + + def _tablefunc(table_name, description, columns): t = DynamicTable(name=table_name, description=description) for c in columns: diff --git a/src/pynwb/io/file.py b/src/pynwb/io/file.py index 15c2c1c06..1908c6b31 100644 --- a/src/pynwb/io/file.py +++ b/src/pynwb/io/file.py @@ -1,4 +1,4 @@ -import datetime +from dateutil.parser import parse as dateutil_parse from hdmf.build import ObjectMapper @@ -8,22 +8,6 @@ from .utils import get_nwb_version -def parse_datetime(datestr): - """Parse an ISO 8601 date string into a datetime object or a date object. - - If the date string does not contain a time component, then parse into a date object. - - :param datestr: str - :return: datetime.datetime or datetime.date - """ - if isinstance(datestr, bytes): - datestr = datestr.decode("utf-8") - dt = datetime.datetime.fromisoformat(datestr) - if "T" not in datestr: - dt = dt.date() - return dt - - @register_map(NWBFile) class NWBFileMap(ObjectMapper): @@ -173,19 +157,19 @@ def scratch(self, builder, manager): @ObjectMapper.constructor_arg('session_start_time') def dateconversion(self, builder, manager): datestr = builder.get('session_start_time').data - dt = parse_datetime(datestr) - return dt + date = dateutil_parse(datestr) + return date @ObjectMapper.constructor_arg('timestamps_reference_time') def dateconversion_trt(self, builder, manager): datestr = builder.get('timestamps_reference_time').data - dt = parse_datetime(datestr) - return dt + date = dateutil_parse(datestr) + return date @ObjectMapper.constructor_arg('file_create_date') def dateconversion_list(self, builder, manager): datestr = builder.get('file_create_date').data - dates = list(map(parse_datetime, datestr)) + dates = list(map(dateutil_parse, datestr)) return dates @ObjectMapper.constructor_arg('file_name') @@ -239,8 +223,8 @@ def dateconversion(self, builder, manager): return else: datestr = dob_builder.data - dt = parse_datetime(datestr) - return dt + date = dateutil_parse(datestr) + return date @ObjectMapper.constructor_arg("age__reference") def age_reference_none(self, builder, manager): diff --git a/src/pynwb/testing/mock/file.py b/src/pynwb/testing/mock/file.py index 50369e7e1..943f86dcb 100644 --- a/src/pynwb/testing/mock/file.py +++ b/src/pynwb/testing/mock/file.py @@ -1,6 +1,7 @@ from typing import Optional from uuid import uuid4 from datetime import datetime +from dateutil.tz import tzlocal from ...file import NWBFile, Subject from .utils import name_generator @@ -9,7 +10,7 @@ def mock_NWBFile( session_description: str = 'session_description', identifier: Optional[str] = None, - session_start_time: datetime = datetime(1970, 1, 1), + session_start_time: datetime = datetime(1970, 1, 1, tzinfo=tzlocal()), **kwargs ): return NWBFile( diff --git a/src/pynwb/testing/testh5io.py b/src/pynwb/testing/testh5io.py index 45ae8cebe..7234e79f5 100644 --- a/src/pynwb/testing/testh5io.py +++ b/src/pynwb/testing/testh5io.py @@ -1,4 +1,5 @@ from datetime import datetime +from dateutil.tz import tzlocal, tzutc import os from abc import ABCMeta, abstractmethod import warnings @@ -32,8 +33,8 @@ def getContainer(self, nwbfile): def setUp(self): self.container = self.setUpContainer() - self.start_time = datetime(1971, 1, 1, 12) - self.create_date = datetime(2018, 4, 15, 12) + self.start_time = datetime(1971, 1, 1, 12, tzinfo=tzutc()) + self.create_date = datetime(2018, 4, 15, 12, tzinfo=tzlocal()) self.container_type = self.container.__class__.__name__ self.filename = 'test_%s.nwb' % self.container_type self.export_filename = 'test_export_%s.nwb' % self.container_type @@ -225,7 +226,7 @@ def setUp(self): container_type = self.getContainerType().replace(" ", "_") session_description = 'A file to test writing and reading a %s' % container_type identifier = 'TEST_%s' % container_type - session_start_time = datetime(1971, 1, 1, 12) + session_start_time = datetime(1971, 1, 1, 12, tzinfo=tzutc()) self.nwbfile = NWBFile( session_description=session_description, identifier=identifier, diff --git a/tests/integration/hdf5/test_base.py b/tests/integration/hdf5/test_base.py index 75c346012..60f8510ff 100644 --- a/tests/integration/hdf5/test_base.py +++ b/tests/integration/hdf5/test_base.py @@ -1,5 +1,6 @@ import numpy as np from datetime import datetime +from dateutil.tz import tzlocal from pynwb import TimeSeries, NWBFile, NWBHDF5IO from pynwb.base import Images, Image, ImageReferences @@ -33,7 +34,7 @@ def test_timestamps_linking(self): tsa = TimeSeries(name='a', data=np.linspace(0, 1, 1000), timestamps=np.arange(1000.), unit='m') tsb = TimeSeries(name='b', data=np.linspace(0, 1, 1000), timestamps=tsa, unit='m') nwbfile = NWBFile(identifier='foo', - session_start_time=datetime(2017, 5, 1, 12, 0, 0), + session_start_time=datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()), session_description='bar') nwbfile.add_acquisition(tsa) nwbfile.add_acquisition(tsb) @@ -51,7 +52,7 @@ def test_data_linking(self): tsb = TimeSeries(name='b', data=tsa, timestamps=np.arange(1000.), unit='m') tsc = TimeSeries(name='c', data=tsb, timestamps=np.arange(1000.), unit='m') nwbfile = NWBFile(identifier='foo', - session_start_time=datetime(2017, 5, 1, 12, 0, 0), + session_start_time=datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()), session_description='bar') nwbfile.add_acquisition(tsa) nwbfile.add_acquisition(tsb) diff --git a/tests/integration/hdf5/test_io.py b/tests/integration/hdf5/test_io.py index da22a0651..d68334c89 100644 --- a/tests/integration/hdf5/test_io.py +++ b/tests/integration/hdf5/test_io.py @@ -245,7 +245,7 @@ class TestAppend(TestCase): def setUp(self): self.nwbfile = NWBFile(session_description='hi', identifier='hi', - session_start_time=datetime(1970, 1, 1, 12)) + session_start_time=datetime(1970, 1, 1, 12, tzinfo=tzutc())) self.path = "test_append.nwb" def tearDown(self): @@ -312,7 +312,7 @@ class TestH5DataIO(TestCase): def setUp(self): self.nwbfile = NWBFile(session_description='a', identifier='b', - session_start_time=datetime(1970, 1, 1, 12)) + session_start_time=datetime(1970, 1, 1, 12, tzinfo=tzutc())) self.path = "test_pynwb_io_hdf5_h5dataIO.h5" def tearDown(self): diff --git a/tests/integration/hdf5/test_modular_storage.py b/tests/integration/hdf5/test_modular_storage.py index 09c916bfb..fba5d02db 100644 --- a/tests/integration/hdf5/test_modular_storage.py +++ b/tests/integration/hdf5/test_modular_storage.py @@ -1,6 +1,7 @@ import os import gc from datetime import datetime +from dateutil.tz import tzutc import numpy as np from hdmf.backends.hdf5 import HDF5IO @@ -18,7 +19,7 @@ def setUp(self): self.link_filename = os.path.join(os.getcwd(), 'test_time_series_modular_link.nwb') # Make the data container file write - self.start_time = datetime(1971, 1, 1, 12) + self.start_time = datetime(1971, 1, 1, 12, tzinfo=tzutc()) self.data = np.arange(2000).reshape((1000, 2)) self.timestamps = np.linspace(0, 1, 1000) # The container before roundtrip diff --git a/tests/integration/hdf5/test_nwbfile.py b/tests/integration/hdf5/test_nwbfile.py index 641928598..e164ec649 100644 --- a/tests/integration/hdf5/test_nwbfile.py +++ b/tests/integration/hdf5/test_nwbfile.py @@ -1,4 +1,4 @@ -from datetime import datetime, date +from datetime import datetime from dateutil.tz import tzlocal, tzutc import pandas as pd import numpy as np @@ -21,12 +21,7 @@ def setUp(self): """ Set up an NWBFile object with an acquisition TimeSeries, analysis TimeSeries, and a processing module """ self.start_time = datetime(1970, 1, 1, 12, tzinfo=tzutc()) self.ref_time = datetime(1979, 1, 1, 0, tzinfo=tzutc()) - # try some dates with/without timezone and time - self.create_date = [ - datetime(2017, 5, 1, 12, tzinfo=tzlocal()), - datetime(2017, 5, 2, 13), - datetime(2017, 5, 2), - ] + self.create_date = datetime(2017, 4, 15, 12, tzinfo=tzlocal()) self.manager = get_manager() self.filename = 'test_nwbfileio.h5' self.nwbfile = NWBFile(session_description='a test NWB File', @@ -157,61 +152,16 @@ def getContainer(self, nwbfile): return nwbfile -class TestNWBFileNoTimezoneRoundTrip(TestNWBFileIO): - """ Test that an NWBFile with no timezone information can be written to and read from file """ - - def build_nwbfile(self): - description = 'test nwbfile no time' - identifier = 'TEST_no_time' - start_time = datetime(2024, 4, 10, 0, 21) - - self.container = NWBFile( - session_description=description, - identifier=identifier, - session_start_time=start_time - ) - - def roundtripContainer(self, cache_spec=False): - self.read_nwbfile = super().roundtripContainer(cache_spec=cache_spec) - self.assertEqual(self.read_nwbfile.session_start_time, self.container.session_start_time) - self.assertIsNone(self.read_nwbfile.session_start_time.tzinfo) - return self.read_nwbfile - - -class TestNWBFileNoTimeRoundTrip(TestNWBFileIO): - """ Test that an NWBFile with no time information can be written to and read from file """ - - def build_nwbfile(self): - description = 'test nwbfile no time' - identifier = 'TEST_no_time' - start_time = date(2024, 4, 9) - - self.container = NWBFile( - session_description=description, - identifier=identifier, - session_start_time=start_time - ) - - def roundtripContainer(self, cache_spec=False): - self.read_nwbfile = super().roundtripContainer(cache_spec=cache_spec) - self.assertEqual(self.read_nwbfile.session_start_time, self.container.session_start_time) - self.assertIsInstance(self.read_nwbfile.session_start_time, date) - self.assertNotIsInstance(self.read_nwbfile.session_start_time, datetime) - return self.read_nwbfile - - class TestExperimentersConstructorRoundtrip(TestNWBFileIO): """ Test that a list of multiple experimenters in a constructor is written to and read from file """ def build_nwbfile(self): description = 'test nwbfile experimenter' identifier = 'TEST_experimenter' - self.container = NWBFile( - session_description=description, - identifier=identifier, - session_start_time=self.start_time, - experimenter=('experimenter1', 'experimenter2') - ) + self.nwbfile = NWBFile(session_description=description, + identifier=identifier, + session_start_time=self.start_time, + experimenter=('experimenter1', 'experimenter2')) class TestExperimentersSetterRoundtrip(TestNWBFileIO): @@ -220,12 +170,10 @@ class TestExperimentersSetterRoundtrip(TestNWBFileIO): def build_nwbfile(self): description = 'test nwbfile experimenter' identifier = 'TEST_experimenter' - self.container = NWBFile( - session_description=description, - identifier=identifier, - session_start_time=self.start_time - ) - self.container.experimenter = ('experimenter1', 'experimenter2') + self.nwbfile = NWBFile(session_description=description, + identifier=identifier, + session_start_time=self.start_time) + self.nwbfile.experimenter = ('experimenter1', 'experimenter2') class TestPublicationsConstructorRoundtrip(TestNWBFileIO): @@ -234,12 +182,10 @@ class TestPublicationsConstructorRoundtrip(TestNWBFileIO): def build_nwbfile(self): description = 'test nwbfile publications' identifier = 'TEST_publications' - self.container = NWBFile( - session_description=description, - identifier=identifier, - session_start_time=self.start_time, - related_publications=('pub1', 'pub2') - ) + self.nwbfile = NWBFile(session_description=description, + identifier=identifier, + session_start_time=self.start_time, + related_publications=('pub1', 'pub2')) class TestPublicationsSetterRoundtrip(TestNWBFileIO): @@ -248,12 +194,10 @@ class TestPublicationsSetterRoundtrip(TestNWBFileIO): def build_nwbfile(self): description = 'test nwbfile publications' identifier = 'TEST_publications' - self.container = NWBFile( - session_description=description, - identifier=identifier, - session_start_time=self.start_time - ) - self.container.related_publications = ('pub1', 'pub2') + self.nwbfile = NWBFile(session_description=description, + identifier=identifier, + session_start_time=self.start_time) + self.nwbfile.related_publications = ('pub1', 'pub2') class TestSubjectIO(NWBH5IOMixin, TestCase): @@ -307,55 +251,6 @@ def getContainer(self, nwbfile): return nwbfile.subject -class TestSubjectDOBNoDateSetIO(NWBH5IOMixin, TestCase): - - def setUpContainer(self): - """ Return the test Subject """ - return Subject( - age="P90D", - description="An unfortunate rat", - genotype="WT", - sex="M", - species="Rattus norvegicus", - subject_id="RAT123", - weight="2 kg", - date_of_birth=date(2024, 4, 9), - strain="my_strain", - ) - - def addContainer(self, nwbfile): - """ Add the test Subject to the given NWBFile """ - nwbfile.subject = self.container - - def getContainer(self, nwbfile): - """ Return the test Subject from the given NWBFile """ - return nwbfile.subject - - -class TestSubjectMinimalSetIO(NWBH5IOMixin, TestCase): - - def setUpContainer(self): - """ Return the test Subject """ - return Subject( - age="P90D", - description="An unfortunate rat", - genotype="WT", - sex="M", - species="Rattus norvegicus", - subject_id="RAT123", - weight="2 kg", - strain="my_strain", - ) - - def addContainer(self, nwbfile): - """ Add the test Subject to the given NWBFile """ - nwbfile.subject = self.container - - def getContainer(self, nwbfile): - """ Return the test Subject from the given NWBFile """ - return nwbfile.subject - - class TestEmptySubjectIO(TestSubjectIO): def setUpContainer(self): diff --git a/tests/unit/test_epoch.py b/tests/unit/test_epoch.py index 4bf2637df..318ad3943 100644 --- a/tests/unit/test_epoch.py +++ b/tests/unit/test_epoch.py @@ -1,6 +1,7 @@ import numpy as np import pandas as pd from datetime import datetime +from dateutil import tz from pynwb.epoch import TimeIntervals from pynwb import TimeSeries, NWBFile @@ -66,7 +67,7 @@ def test_dataframe_roundtrip_drop_ts(self): self.assertEqual(obtained.loc[2, 'foo'], df.loc[2, 'foo']) def test_no_tags(self): - nwbfile = NWBFile("a file with header data", "NB123A", datetime(1970, 1, 1)) + nwbfile = NWBFile("a file with header data", "NB123A", datetime(1970, 1, 1, tzinfo=tz.tzutc())) df = self.get_dataframe() for i, row in df.iterrows(): nwbfile.add_epoch(start_time=row['start_time'], stop_time=row['stop_time']) diff --git a/tests/unit/test_extension.py b/tests/unit/test_extension.py index abbb6511a..7664bbf22 100644 --- a/tests/unit/test_extension.py +++ b/tests/unit/test_extension.py @@ -2,6 +2,7 @@ import random import string from datetime import datetime +from dateutil.tz import tzlocal from tempfile import gettempdir from hdmf.spec import RefSpec @@ -107,7 +108,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.test_attr = test_attr - nwbfile = NWBFile("a file with header data", "NB123A", datetime(2017, 5, 1, 12, 0, 0)) + nwbfile = NWBFile("a file with header data", "NB123A", datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())) nwbfile.add_lab_meta_data(MyTestMetaData(name='test_name', test_attr=5.)) @@ -127,7 +128,7 @@ def test_lab_meta_auto(self): MyTestMetaData = get_class('MyTestMetaData', self.prefix) - nwbfile = NWBFile("a file with header data", "NB123A", datetime(2017, 5, 1, 12, 0, 0)) + nwbfile = NWBFile("a file with header data", "NB123A", datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())) nwbfile.add_lab_meta_data(MyTestMetaData(name='test_name', test_attr=5.)) diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index fc44dfdd8..98446fa46 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -1,7 +1,7 @@ import numpy as np import pandas as pd -from datetime import datetime, date, timedelta +from datetime import datetime, timedelta from dateutil.tz import tzlocal, tzutc from hdmf.common import DynamicTable @@ -9,7 +9,7 @@ from hdmf.utils import docval, get_docval, popargs from pynwb import NWBFile, TimeSeries, NWBHDF5IO from pynwb.base import Image, Images -from pynwb.file import Subject, ElectrodeTable +from pynwb.file import Subject, ElectrodeTable, _add_missing_timezone from pynwb.epoch import TimeIntervals from pynwb.ecephys import ElectricalSeries from pynwb.testing import TestCase, remove_test_file @@ -19,10 +19,9 @@ class NWBFileTest(TestCase): def setUp(self): self.start = datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()) self.ref_time = datetime(1979, 1, 1, 0, tzinfo=tzutc()) - # try some dates with/without timezone and time self.create = [datetime(2017, 5, 1, 12, tzinfo=tzlocal()), - datetime(2017, 5, 2, 13), - datetime(2017, 5, 2)] + datetime(2017, 5, 2, 13, 0, 0, 1, tzinfo=tzutc()), + datetime(2017, 5, 2, 14, tzinfo=tzutc())] self.path = 'nwbfile_test.h5' self.nwbfile = NWBFile(session_description='a test session description for a test NWBFile', identifier='FILE123', @@ -503,22 +502,6 @@ def test_multi_publications(self): related_publications=('pub1', 'pub2')) self.assertTupleEqual(self.nwbfile.related_publications, ('pub1', 'pub2')) - def test_session_start_time_no_timezone(self): - self.nwbfile = NWBFile( - session_description='a test session description for a test NWBFile', - identifier='FILE123', - session_start_time=datetime(2024, 4, 10, 0, 21), - ) - self.assertIsNone(self.nwbfile.session_start_time.tzinfo) - - def test_session_start_time_no_time(self): - self.nwbfile = NWBFile( - session_description='a test session description for a test NWBFile', - identifier='FILE123', - session_start_time=date(2024, 4, 10), - ) - self.assertEqual(self.nwbfile.session_start_time, date(2024, 4, 10)) - class SubjectTest(TestCase): def setUp(self): @@ -534,7 +517,7 @@ def setUp(self): date_of_birth=datetime(2017, 5, 1, 12, tzinfo=tzlocal()), strain='my_strain', ) - self.start = datetime(2017, 5, 1, 12) + self.start = datetime(2017, 5, 1, 12, tzinfo=tzlocal()) self.path = 'nwbfile_test.h5' self.nwbfile = NWBFile( 'a test session description for a test NWBFile', @@ -603,35 +586,6 @@ def test_subject_age_duration(self): self.assertEqual(subject.age, "P1DT3H46M39S") - def test_dob_no_timezone(self): - self.subject = Subject( - age='P90D', - age__reference="birth", - description='An unfortunate rat', - genotype='WT', - sex='M', - species='Rattus norvegicus', - subject_id='RAT123', - weight='2 kg', - date_of_birth=datetime(2024, 4, 10, 0, 21), - strain='my_strain', - ) - - def test_dob_no_time(self): - self.subject = Subject( - age='P90D', - age__reference="birth", - description='An unfortunate rat', - genotype='WT', - sex='M', - species='Rattus norvegicus', - subject_id='RAT123', - weight='2 kg', - date_of_birth=date(2024, 4, 10), - strain='my_strain', - ) - - class TestCacheSpec(TestCase): """Test whether the file can be written and read when caching the spec.""" @@ -689,3 +643,22 @@ def test_reftime_default(self): # 'timestamps_reference_time' should default to 'session_start_time' self.assertEqual(self.nwbfile.timestamps_reference_time, self.start_time) + +class TestTimestampsRefAware(TestCase): + def setUp(self): + self.start_time = datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()) + 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) + + +class TestTimezone(TestCase): + def test_raise_warning__add_missing_timezone(self): + with self.assertWarnsWith(UserWarning, "Date is missing timezone information. Updating to local timezone."): + _add_missing_timezone(datetime(2017, 5, 1, 12)) diff --git a/tests/unit/test_icephys.py b/tests/unit/test_icephys.py index 8ff1a67ba..e0e8332f9 100644 --- a/tests/unit/test_icephys.py +++ b/tests/unit/test_icephys.py @@ -14,6 +14,7 @@ from pynwb.testing import TestCase from pynwb.file import NWBFile # Needed to test icephys functionality defined on NWBFile from datetime import datetime +from dateutil.tz import tzlocal def GetElectrode(): @@ -45,7 +46,7 @@ def test_sweep_table_depractation_warn(self): _ = NWBFile( session_description='NWBFile icephys test', identifier='NWB123', # required - session_start_time=datetime(2017, 4, 3, 11), + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal()), ic_electrodes=[self.icephys_electrode, ], sweep_table=SweepTable()) @@ -56,14 +57,14 @@ def test_ic_electrodes_parameter_deprecation(self): _ = NWBFile( session_description='NWBFile icephys test', identifier='NWB123', # required - session_start_time=datetime(2017, 4, 3, 11), + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal()), ic_electrodes=[self.icephys_electrode, ]) def test_icephys_electrodes_parameter(self): nwbfile = NWBFile( session_description='NWBFile icephys test', identifier='NWB123', # required - session_start_time=datetime(2017, 4, 3, 11), + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal()), icephys_electrodes=[self.icephys_electrode, ]) self.assertEqual(nwbfile.get_icephys_electrode('test_iS'), self.icephys_electrode) @@ -72,7 +73,7 @@ def test_add_ic_electrode_deprecation(self): nwbfile = NWBFile( session_description='NWBFile icephys test', identifier='NWB123', # required - session_start_time=datetime(2017, 4, 3, 11)) + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal())) msg = "NWBFile.add_ic_electrode has been replaced by NWBFile.add_icephys_electrode." with self.assertWarnsWith(DeprecationWarning, msg): @@ -82,7 +83,7 @@ def test_ic_electrodes_attribute_deprecation(self): nwbfile = NWBFile( session_description='NWBFile icephys test', identifier='NWB123', # required - session_start_time=datetime(2017, 4, 3, 11), + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal()), icephys_electrodes=[self.icephys_electrode, ]) # make sure NWBFile.ic_electrodes property warns @@ -99,7 +100,7 @@ def test_create_ic_electrode_deprecation(self): nwbfile = NWBFile( session_description='NWBFile icephys test', identifier='NWB123', # required - session_start_time=datetime(2017, 4, 3, 11)) + session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal())) device = Device(name='device_name') msg = "NWBFile.create_ic_electrode has been replaced by NWBFile.create_icephys_electrode." with self.assertWarnsWith(DeprecationWarning, msg): diff --git a/tests/unit/test_scratch.py b/tests/unit/test_scratch.py index c04a00e99..398ae2f78 100644 --- a/tests/unit/test_scratch.py +++ b/tests/unit/test_scratch.py @@ -1,4 +1,5 @@ from datetime import datetime +from dateutil.tz import tzlocal import numpy as np from numpy.testing import assert_array_equal import pandas as pd @@ -14,7 +15,7 @@ def setUp(self): self.nwbfile = NWBFile( session_description='a file to test writing and reading scratch data', identifier='TEST_scratch', - session_start_time=datetime(2017, 5, 1, 12, 0, 0) + session_start_time=datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()) ) def test_constructor_list(self):