Skip to content

Commit 6b1a55f

Browse files
bendichterrly
andauthored
allow a datetime.date object to be used instead of datetime.datetime for isodatetime data (#874)
Co-authored-by: Ryan Ly <[email protected]>
1 parent 6808826 commit 6b1a55f

File tree

10 files changed

+152
-41
lines changed

10 files changed

+152
-41
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
### New features and minor improvements
66
- 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)
7-
- 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)
7+
- 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)
88
- 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)
9+
- Allow for `datetime.date` to be used instead of `datetime.datetime`. @bendichter [#874](https://github.com/hdmf-dev/hdmf/pull/874)
910
- Updated `HDMFIO` and `HDF5IO` to support `ExternalResources`. @mavaylon1 [#895](https://github.com/hdmf-dev/hdmf/pull/895)
1011
- Dropped Python 3.7 support. @rly [#897](https://github.com/hdmf-dev/hdmf/pull/897)
1112

src/hdmf/build/builders.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import posixpath as _posixpath
44
from abc import ABCMeta
55
from collections.abc import Iterable
6-
from datetime import datetime
6+
from datetime import datetime, date
77

88
import numpy as np
99
from h5py import RegionReference
@@ -318,7 +318,7 @@ class DatasetBuilder(BaseBuilder):
318318

319319
@docval({'name': 'name', 'type': str, 'doc': 'The name of the dataset.'},
320320
{'name': 'data',
321-
'type': ('array_data', 'scalar_data', 'data', 'DatasetBuilder', 'RegionBuilder', Iterable, datetime),
321+
'type': ('array_data', 'scalar_data', 'data', 'DatasetBuilder', 'RegionBuilder', Iterable, datetime, date),
322322
'doc': 'The data in this dataset.', 'default': None},
323323
{'name': 'dtype', 'type': (type, np.dtype, str, list),
324324
'doc': 'The datatype of this dataset.', 'default': None},

src/hdmf/build/classgenerator.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from copy import deepcopy
2-
from datetime import datetime
2+
from datetime import datetime, date
33

44
import numpy as np
55

@@ -126,8 +126,8 @@ def __new__(cls, *args, **kwargs): # pragma: no cover
126126
'ascii': bytes,
127127
'bytes': bytes,
128128
'bool': (bool, np.bool_),
129-
'isodatetime': datetime,
130-
'datetime': datetime
129+
'isodatetime': (datetime, date),
130+
'datetime': (datetime, date)
131131
}
132132

133133
@classmethod

src/hdmf/build/objectmapper.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import warnings
44
from collections import OrderedDict
55
from copy import copy
6-
from datetime import datetime
76

87
import numpy as np
98

@@ -611,7 +610,8 @@ def __convert_string(self, value, spec):
611610
elif 'ascii' in spec.dtype:
612611
string_type = bytes
613612
elif 'isodatetime' in spec.dtype:
614-
string_type = datetime.isoformat
613+
def string_type(x):
614+
return x.isoformat() # method works for both date and datetime
615615
if string_type is not None:
616616
if spec.shape is not None or spec.dims is not None:
617617
ret = list(map(string_type, value))

src/hdmf/spec/spec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class DtypeHelper:
4141
'object': ['object'],
4242
'region': ['region'],
4343
'numeric': ['numeric'],
44-
'isodatetime': ["isodatetime", "datetime"]
44+
'isodatetime': ["isodatetime", "datetime", "date"]
4545
}
4646

4747
# List of recommended primary dtype strings. These are the keys of primary_dtype_string_synonyms

src/hdmf/utils.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,7 @@ def dec(func):
599599
'expected {})'.format(a['name'], [type(x) for x in a['enum']], a['type']))
600600
raise Exception(msg)
601601
if a.get('allow_none', False) and 'default' not in a:
602-
msg = ('docval for {}: allow_none=True can only be set if a default value is provided.').format(
603-
a['name'])
602+
msg = 'docval for {}: allow_none=True can only be set if a default value is provided.'.format(a['name'])
604603
raise Exception(msg)
605604
if 'default' in a:
606605
kw.append(a)

src/hdmf/validate/validator.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ def check_type(expected, received):
7979

8080

8181
def get_iso8601_regex():
82-
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]):'
83-
r'([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$')
82+
isodate_re = (
83+
r'^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])' # date
84+
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
85+
)
8486
return re.compile(isodate_re)
8587

8688

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from hdmf.utils import docval, getargs
2+
from hdmf import Container
3+
from hdmf.spec import GroupSpec, DatasetSpec
4+
from hdmf.testing import TestCase
5+
from datetime import datetime, date
6+
7+
from tests.unit.helpers.utils import create_test_type_map
8+
9+
10+
class Bar(Container):
11+
12+
@docval({'name': 'name', 'type': str, 'doc': 'the name of this Bar'},
13+
{'name': 'data', 'type': ('data', 'array_data', datetime, date), 'doc': 'some data'})
14+
def __init__(self, **kwargs):
15+
name, data = getargs('name', 'data', kwargs)
16+
super().__init__(name=name)
17+
self.__data = data
18+
19+
@property
20+
def data_type(self):
21+
return 'Bar'
22+
23+
@property
24+
def data(self):
25+
return self.__data
26+
27+
28+
class TestBuildDatasetDateTime(TestCase):
29+
"""Test that building a dataset with dtype isodatetime works with datetime and date objects."""
30+
31+
def test_datetime_scalar(self):
32+
bar_spec = GroupSpec(
33+
doc='A test group specification with a data type',
34+
data_type_def='Bar',
35+
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime')],
36+
)
37+
type_map = create_test_type_map([bar_spec], {'Bar': Bar})
38+
39+
bar_inst = Bar(name='my_bar', data=datetime(2023, 7, 9))
40+
builder = type_map.build(bar_inst)
41+
ret = builder.get('data')
42+
assert ret.data == b'2023-07-09T00:00:00'
43+
assert ret.dtype == 'ascii'
44+
45+
def test_date_scalar(self):
46+
bar_spec = GroupSpec(
47+
doc='A test group specification with a data type',
48+
data_type_def='Bar',
49+
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime')],
50+
)
51+
type_map = create_test_type_map([bar_spec], {'Bar': Bar})
52+
53+
bar_inst = Bar(name='my_bar', data=date(2023, 7, 9))
54+
builder = type_map.build(bar_inst)
55+
ret = builder.get('data')
56+
assert ret.data == b'2023-07-09'
57+
assert ret.dtype == 'ascii'
58+
59+
def test_datetime_array(self):
60+
bar_spec = GroupSpec(
61+
doc='A test group specification with a data type',
62+
data_type_def='Bar',
63+
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime', dims=(None,))],
64+
)
65+
type_map = create_test_type_map([bar_spec], {'Bar': Bar})
66+
67+
bar_inst = Bar(name='my_bar', data=[datetime(2023, 7, 9), datetime(2023, 7, 10)])
68+
builder = type_map.build(bar_inst)
69+
ret = builder.get('data')
70+
assert ret.data == [b'2023-07-09T00:00:00', b'2023-07-10T00:00:00']
71+
assert ret.dtype == 'ascii'
72+
73+
def test_date_array(self):
74+
bar_spec = GroupSpec(
75+
doc='A test group specification with a data type',
76+
data_type_def='Bar',
77+
datasets=[DatasetSpec(doc='an example dataset', name='data', dtype='isodatetime', dims=(None,))],
78+
)
79+
type_map = create_test_type_map([bar_spec], {'Bar': Bar})
80+
81+
bar_inst = Bar(name='my_bar', data=[date(2023, 7, 9), date(2023, 7, 10)])
82+
builder = type_map.build(bar_inst)
83+
ret = builder.get('data')
84+
assert ret.data == [b'2023-07-09', b'2023-07-10']
85+
assert ret.dtype == 'ascii'

tests/unit/build_tests/test_convert_dtype.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, date
22

33
import numpy as np
44
from hdmf.backends.hdf5 import H5DataIO
@@ -534,8 +534,20 @@ def test_isodatetime_spec(self):
534534

535535
# NOTE: datetime.isoformat is called on all values with a datetime spec before conversion
536536
# see ObjectMapper.get_attr_value
537-
value = datetime.isoformat(datetime(2020, 11, 10))
537+
value = datetime(2020, 11, 10).isoformat()
538538
ret, ret_dtype = ObjectMapper.convert_dtype(spec, value)
539539
self.assertEqual(ret, b'2020-11-10T00:00:00')
540540
self.assertIs(type(ret), bytes)
541541
self.assertEqual(ret_dtype, 'ascii')
542+
543+
def test_isodate_spec(self):
544+
spec_type = 'isodatetime'
545+
spec = DatasetSpec('an example dataset', spec_type, name='data')
546+
547+
# NOTE: datetime.isoformat is called on all values with a datetime spec before conversion
548+
# see ObjectMapper.get_attr_value
549+
value = date(2020, 11, 10).isoformat()
550+
ret, ret_dtype = ObjectMapper.convert_dtype(spec, value)
551+
self.assertEqual(ret, b'2020-11-10')
552+
self.assertIs(type(ret), bytes)
553+
self.assertEqual(ret_dtype, 'ascii')

tests/unit/validator_tests/test_validate.py

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import ABCMeta, abstractmethod
2-
from datetime import datetime
2+
from datetime import datetime, date
33
from unittest import mock, skip
44

55
import numpy as np
@@ -104,46 +104,58 @@ def test_valid(self):
104104
class TestDateTimeInSpec(ValidatorTestBase):
105105

106106
def getSpecs(self):
107-
ret = GroupSpec('A test group specification with a data type',
108-
data_type_def='Bar',
109-
datasets=[DatasetSpec('an example dataset', 'int', name='data',
110-
attributes=[AttributeSpec(
111-
'attr2', 'an example integer attribute', 'int')]),
112-
DatasetSpec('an example time dataset', 'isodatetime', name='time'),
113-
DatasetSpec('an array of times', 'isodatetime', name='time_array',
114-
dims=('num_times',), shape=(None,))],
115-
attributes=[AttributeSpec('attr1', 'an example string attribute', 'text')])
116-
return (ret,)
107+
ret = GroupSpec(
108+
'A test group specification with a data type',
109+
data_type_def='Bar',
110+
datasets=[
111+
DatasetSpec(
112+
'an example dataset',
113+
'int',
114+
name='data',
115+
attributes=[AttributeSpec('attr2', 'an example integer attribute', 'int')]
116+
),
117+
DatasetSpec('an example time dataset', 'isodatetime', name='datetime'),
118+
DatasetSpec('an example time dataset', 'isodatetime', name='date', quantity='?'),
119+
DatasetSpec('an array of times', 'isodatetime', name='time_array', dims=('num_times',), shape=(None,))
120+
],
121+
attributes=[AttributeSpec('attr1', 'an example string attribute', 'text')])
122+
return ret,
117123

118124
def test_valid_isodatetime(self):
119-
builder = GroupBuilder('my_bar',
120-
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
121-
datasets=[DatasetBuilder('data', 100, attributes={'attr2': 10}),
122-
DatasetBuilder('time',
123-
datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())),
124-
DatasetBuilder('time_array',
125-
[datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])])
125+
builder = GroupBuilder(
126+
'my_bar',
127+
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
128+
datasets=[
129+
DatasetBuilder('data', 100, attributes={'attr2': 10}),
130+
DatasetBuilder('datetime', datetime(2017, 5, 1, 12, 0, 0)),
131+
DatasetBuilder('date', date(2017, 5, 1)),
132+
DatasetBuilder('time_array', [datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])
133+
]
134+
)
126135
validator = self.vmap.get_validator('Bar')
127136
result = validator.validate(builder)
128137
self.assertEqual(len(result), 0)
129138

130139
def test_invalid_isodatetime(self):
131-
builder = GroupBuilder('my_bar',
132-
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
133-
datasets=[DatasetBuilder('data', 100, attributes={'attr2': 10}),
134-
DatasetBuilder('time', 100),
135-
DatasetBuilder('time_array',
136-
[datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])])
140+
builder = GroupBuilder(
141+
'my_bar',
142+
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
143+
datasets=[
144+
DatasetBuilder('data', 100, attributes={'attr2': 10}),
145+
DatasetBuilder('datetime', 100),
146+
DatasetBuilder('time_array', [datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())])
147+
]
148+
)
137149
validator = self.vmap.get_validator('Bar')
138150
result = validator.validate(builder)
139151
self.assertEqual(len(result), 1)
140-
self.assertValidationError(result[0], DtypeError, name='Bar/time')
152+
self.assertValidationError(result[0], DtypeError, name='Bar/datetime')
141153

142154
def test_invalid_isodatetime_array(self):
143155
builder = GroupBuilder('my_bar',
144156
attributes={'data_type': 'Bar', 'attr1': 'a string attribute'},
145157
datasets=[DatasetBuilder('data', 100, attributes={'attr2': 10}),
146-
DatasetBuilder('time',
158+
DatasetBuilder('datetime',
147159
datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal())),
148160
DatasetBuilder('time_array',
149161
datetime(2017, 5, 1, 12, 0, 0, tzinfo=tzlocal()))])

0 commit comments

Comments
 (0)