Skip to content

Commit

Permalink
allow a datetime.date object to be used instead of `datetime.dateti…
Browse files Browse the repository at this point in the history
…me` for isodatetime data (#874)

Co-authored-by: Ryan Ly <rly@lbl.gov>
  • Loading branch information
bendichter and rly committed Jul 10, 2023
1 parent 6808826 commit 6b1a55f
Show file tree
Hide file tree
Showing 10 changed files with 152 additions and 41 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

### New features and minor improvements
- Updated `ExternalResources` to have EntityKeyTable with updated tests/documentation and minor bug fix to ObjectKeyTable. @mavaylon1 [#872](https://github.com/hdmf-dev/hdmf/pull/872)
- Added warning for DynamicTableRegion links that are not added to the same parent as the original container object. @mavaylon1 [#891](https://github.com/hdmf-dev/hdmf/pull/891)
- Added warning for `DynamicTableRegion` links that are not added to the same parent as the original container object. @mavaylon1 [#891](https://github.com/hdmf-dev/hdmf/pull/891)
- Added the `TermSet` class along with integrated validation methods for any child of `AbstractContainer`, e.g., `VectorData`, `Data`, `DynamicTable`. @mavaylon1 [#880](https://github.com/hdmf-dev/hdmf/pull/880)
- Allow for `datetime.date` to be used instead of `datetime.datetime`. @bendichter [#874](https://github.com/hdmf-dev/hdmf/pull/874)
- Updated `HDMFIO` and `HDF5IO` to support `ExternalResources`. @mavaylon1 [#895](https://github.com/hdmf-dev/hdmf/pull/895)
- Dropped Python 3.7 support. @rly [#897](https://github.com/hdmf-dev/hdmf/pull/897)

Expand Down
4 changes: 2 additions & 2 deletions src/hdmf/build/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import posixpath as _posixpath
from abc import ABCMeta
from collections.abc import Iterable
from datetime import datetime
from datetime import datetime, date

import numpy as np
from h5py import RegionReference
Expand Down Expand Up @@ -318,7 +318,7 @@ class DatasetBuilder(BaseBuilder):

@docval({'name': 'name', 'type': str, 'doc': 'The name of the dataset.'},
{'name': 'data',
'type': ('array_data', 'scalar_data', 'data', 'DatasetBuilder', 'RegionBuilder', Iterable, datetime),
'type': ('array_data', 'scalar_data', 'data', 'DatasetBuilder', 'RegionBuilder', Iterable, datetime, date),
'doc': 'The data in this dataset.', 'default': None},
{'name': 'dtype', 'type': (type, np.dtype, str, list),
'doc': 'The datatype of this dataset.', 'default': None},
Expand Down
6 changes: 3 additions & 3 deletions src/hdmf/build/classgenerator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from copy import deepcopy
from datetime import datetime
from datetime import datetime, date

import numpy as np

Expand Down Expand Up @@ -126,8 +126,8 @@ def __new__(cls, *args, **kwargs): # pragma: no cover
'ascii': bytes,
'bytes': bytes,
'bool': (bool, np.bool_),
'isodatetime': datetime,
'datetime': datetime
'isodatetime': (datetime, date),
'datetime': (datetime, date)
}

@classmethod
Expand Down
4 changes: 2 additions & 2 deletions src/hdmf/build/objectmapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import warnings
from collections import OrderedDict
from copy import copy
from datetime import datetime

import numpy as np

Expand Down Expand Up @@ -611,7 +610,8 @@ def __convert_string(self, value, spec):
elif 'ascii' in spec.dtype:
string_type = bytes
elif 'isodatetime' in spec.dtype:
string_type = datetime.isoformat
def string_type(x):
return x.isoformat() # method works for both date and datetime
if string_type is not None:
if spec.shape is not None or spec.dims is not None:
ret = list(map(string_type, value))
Expand Down
2 changes: 1 addition & 1 deletion src/hdmf/spec/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class DtypeHelper:
'object': ['object'],
'region': ['region'],
'numeric': ['numeric'],
'isodatetime': ["isodatetime", "datetime"]
'isodatetime': ["isodatetime", "datetime", "date"]
}

# List of recommended primary dtype strings. These are the keys of primary_dtype_string_synonyms
Expand Down
3 changes: 1 addition & 2 deletions src/hdmf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,7 @@ def dec(func):
'expected {})'.format(a['name'], [type(x) for x in a['enum']], a['type']))
raise Exception(msg)
if a.get('allow_none', False) and 'default' not in a:
msg = ('docval for {}: allow_none=True can only be set if a default value is provided.').format(
a['name'])
msg = 'docval for {}: allow_none=True can only be set if a default value is provided.'.format(a['name'])
raise Exception(msg)
if 'default' in a:
kw.append(a)
Expand Down
6 changes: 4 additions & 2 deletions src/hdmf/validate/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ def check_type(expected, received):


def get_iso8601_regex():
isodate_re = (r'^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):'
r'([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$')
isodate_re = (
r'^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])' # date
r'(T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?)?$' # time
)
return re.compile(isodate_re)


Expand Down
85 changes: 85 additions & 0 deletions tests/unit/build_tests/mapper_tests/test_build_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from hdmf.utils import docval, getargs
from hdmf import Container
from hdmf.spec import GroupSpec, DatasetSpec
from hdmf.testing import TestCase
from datetime import datetime, date

from tests.unit.helpers.utils import create_test_type_map


class Bar(Container):

@docval({'name': 'name', 'type': str, 'doc': 'the name of this Bar'},
{'name': 'data', 'type': ('data', 'array_data', datetime, date), 'doc': 'some data'})
def __init__(self, **kwargs):
name, data = getargs('name', 'data', kwargs)
super().__init__(name=name)
self.__data = data

@property
def data_type(self):
return 'Bar'

@property
def data(self):
return self.__data


class TestBuildDatasetDateTime(TestCase):
"""Test that building a dataset with dtype isodatetime works with datetime and date objects."""

def test_datetime_scalar(self):
bar_spec = GroupSpec(
doc='A test group specification with a data type',
data_type_def='Bar',
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime')],
)
type_map = create_test_type_map([bar_spec], {'Bar': Bar})

bar_inst = Bar(name='my_bar', data=datetime(2023, 7, 9))
builder = type_map.build(bar_inst)
ret = builder.get('data')
assert ret.data == b'2023-07-09T00:00:00'
assert ret.dtype == 'ascii'

def test_date_scalar(self):
bar_spec = GroupSpec(
doc='A test group specification with a data type',
data_type_def='Bar',
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime')],
)
type_map = create_test_type_map([bar_spec], {'Bar': Bar})

bar_inst = Bar(name='my_bar', data=date(2023, 7, 9))
builder = type_map.build(bar_inst)
ret = builder.get('data')
assert ret.data == b'2023-07-09'
assert ret.dtype == 'ascii'

def test_datetime_array(self):
bar_spec = GroupSpec(
doc='A test group specification with a data type',
data_type_def='Bar',
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime', dims=(None,))],
)
type_map = create_test_type_map([bar_spec], {'Bar': Bar})

bar_inst = Bar(name='my_bar', data=[datetime(2023, 7, 9), datetime(2023, 7, 10)])
builder = type_map.build(bar_inst)
ret = builder.get('data')
assert ret.data == [b'2023-07-09T00:00:00', b'2023-07-10T00:00:00']
assert ret.dtype == 'ascii'

def test_date_array(self):
bar_spec = GroupSpec(
doc='A test group specification with a data type',
data_type_def='Bar',
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime', dims=(None,))],
)
type_map = create_test_type_map([bar_spec], {'Bar': Bar})

bar_inst = Bar(name='my_bar', data=[date(2023, 7, 9), date(2023, 7, 10)])
builder = type_map.build(bar_inst)
ret = builder.get('data')
assert ret.data == [b'2023-07-09', b'2023-07-10']
assert ret.dtype == 'ascii'
16 changes: 14 additions & 2 deletions tests/unit/build_tests/test_convert_dtype.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, date

import numpy as np
from hdmf.backends.hdf5 import H5DataIO
Expand Down Expand Up @@ -534,8 +534,20 @@ def test_isodatetime_spec(self):

# NOTE: datetime.isoformat is called on all values with a datetime spec before conversion
# see ObjectMapper.get_attr_value
value = datetime.isoformat(datetime(2020, 11, 10))
value = datetime(2020, 11, 10).isoformat()
ret, ret_dtype = ObjectMapper.convert_dtype(spec, value)
self.assertEqual(ret, b'2020-11-10T00:00:00')
self.assertIs(type(ret), bytes)
self.assertEqual(ret_dtype, 'ascii')

def test_isodate_spec(self):
spec_type = 'isodatetime'
spec = DatasetSpec('an example dataset', spec_type, name='data')

# NOTE: datetime.isoformat is called on all values with a datetime spec before conversion
# see ObjectMapper.get_attr_value
value = date(2020, 11, 10).isoformat()
ret, ret_dtype = ObjectMapper.convert_dtype(spec, value)
self.assertEqual(ret, b'2020-11-10')
self.assertIs(type(ret), bytes)
self.assertEqual(ret_dtype, 'ascii')
64 changes: 38 additions & 26 deletions tests/unit/validator_tests/test_validate.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import ABCMeta, abstractmethod
from datetime import datetime
from datetime import datetime, date
from unittest import mock, skip

import numpy as np
Expand Down Expand Up @@ -104,46 +104,58 @@ def test_valid(self):
class TestDateTimeInSpec(ValidatorTestBase):

def getSpecs(self):
ret = GroupSpec('A test group specification with a data type',
data_type_def='Bar',
datasets=[DatasetSpec('an example dataset', 'int', name='data',
attributes=[AttributeSpec(
'attr2', 'an example integer attribute', 'int')]),
DatasetSpec('an example time dataset', 'isodatetime', name='time'),
DatasetSpec('an array of times', 'isodatetime', name='time_array',
dims=('num_times',), shape=(None,))],
attributes=[AttributeSpec('attr1', 'an example string attribute', 'text')])
return (ret,)
ret = GroupSpec(
'A test group specification with a data type',
data_type_def='Bar',
datasets=[
DatasetSpec(
'an example dataset',
'int',
name='data',
attributes=[AttributeSpec('attr2', 'an example integer attribute', 'int')]
),
DatasetSpec('an example time dataset', 'isodatetime', name='datetime'),
DatasetSpec('an example time dataset', 'isodatetime', name='date', quantity='?'),
DatasetSpec('an array of times', 'isodatetime', name='time_array', dims=('num_times',), shape=(None,))
],
attributes=[AttributeSpec('attr1', 'an example string attribute', 'text')])
return ret,

def test_valid_isodatetime(self):
builder = GroupBuilder('my_bar',
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
datasets=[DatasetBuilder('data', 100, attributes={'attr2': 10}),
DatasetBuilder('time',
datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())),
DatasetBuilder('time_array',
[datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])])
builder = GroupBuilder(
'my_bar',
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
datasets=[
DatasetBuilder('data', 100, attributes={'attr2': 10}),
DatasetBuilder('datetime', datetime(2017, 5, 1, 12, 0, 0)),
DatasetBuilder('date', date(2017, 5, 1)),
DatasetBuilder('time_array', [datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])
]
)
validator = self.vmap.get_validator('Bar')
result = validator.validate(builder)
self.assertEqual(len(result), 0)

def test_invalid_isodatetime(self):
builder = GroupBuilder('my_bar',
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
datasets=[DatasetBuilder('data', 100, attributes={'attr2': 10}),
DatasetBuilder('time', 100),
DatasetBuilder('time_array',
[datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])])
builder = GroupBuilder(
'my_bar',
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
datasets=[
DatasetBuilder('data', 100, attributes={'attr2': 10}),
DatasetBuilder('datetime', 100),
DatasetBuilder('time_array', [datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])
]
)
validator = self.vmap.get_validator('Bar')
result = validator.validate(builder)
self.assertEqual(len(result), 1)
self.assertValidationError(result[0], DtypeError, name='Bar/time')
self.assertValidationError(result[0], DtypeError, name='Bar/datetime')

def test_invalid_isodatetime_array(self):
builder = GroupBuilder('my_bar',
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
datasets=[DatasetBuilder('data', 100, attributes={'attr2': 10}),
DatasetBuilder('time',
DatasetBuilder('datetime',
datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())),
DatasetBuilder('time_array',
datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()))])
Expand Down

0 comments on commit 6b1a55f

Please sign in to comment.