Skip to content

Commit 223d9f7

Browse files
rlyajtritt
andauthored
Create and use testing module, remove builder tests, clean up test code (#1117)
* Update reqs, drop some Python 2 specific code * Auto-cast experimenter and related_publications to tuple * Use hdmf testing module, refactor, remove dead code * Remove unittest2 dependency * Remove test code * Add tests of backwards compatibility * Print validation errors as warnings in test read back compat * Rename TestReadOldFiles class * Refactor integration tests to use new testing module * Run backwards compatibility tests by default * Update hdmf and other reqs * Fix objectmapper reference * Update getting_started.rst * Specify file mode to avoid deprecation warning in h5py.File() * Fix new valueerror message * Fix new valueerror message * Update test_io.py fix mode flag Co-authored-by: Andrew Tritt <[email protected]>
1 parent ca02ffd commit 223d9f7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+1689
-2570
lines changed

.coveragerc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
[run]
22
branch = True
33
source = src/
4-
omit = src/pynwb/_version.py
4+
omit =
5+
src/pynwb/_version.py
6+
src/pynwb/testing/*

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ docs/source/modules/generated
2020
mylab.*.yaml
2121
*.npy
2222
*.nwb
23+
!/tests/back_compat/*.nwb
2324
manifest.json
2425

2526
# Auto-generated tutorials
@@ -65,4 +66,4 @@ tests/coverage/htmlcov
6566
.vscode/
6667

6768
#mypy
68-
.mypy_cache/
69+
.mypy_cache/

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ per-file-ignores =
2626
src/pynwb/io/__init__.py:F401
2727
src/pynwb/legacy/io/__init__.py:F401
2828
tests/integration/__init__.py:F401
29+
src/pynwb/testing/__init__.py:F401
2930

3031
[metadata]
3132
description-file = README.rst

src/pynwb/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,9 @@ def __init__(self, **kwargs):
155155
self.rate = rate
156156
if starting_time is not None:
157157
self.starting_time = starting_time
158-
self.starting_time_unit = self.__time_unit
159158
else:
160159
self.starting_time = 0.0
160+
self.starting_time_unit = self.__time_unit
161161
else:
162162
raise TypeError("either 'timestamps' or 'rate' must be specified")
163163

@@ -173,7 +173,7 @@ def unreadable_warning(attr):
173173
)
174174

175175
def no_len_warning(attr):
176-
return 'The {} attribute on this TimeSeries (named: {}) has no __len__, '.format(attr, self.name)
176+
return 'The {} attribute on this TimeSeries (named: {}) has no __len__'.format(attr, self.name)
177177

178178
if hasattr(self.data, '__len__'):
179179
try:

src/pynwb/file.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,6 @@ def __init__(self, **kwargs):
334334
'invalid_times',
335335
'units',
336336
'scratch',
337-
'experimenter',
338337
'experiment_description',
339338
'session_id',
340339
'lab',
@@ -343,7 +342,6 @@ def __init__(self, **kwargs):
343342
'notes',
344343
'pharmacology',
345344
'protocol',
346-
'related_publications',
347345
'slices',
348346
'source_script',
349347
'source_script_file_name',
@@ -354,6 +352,16 @@ def __init__(self, **kwargs):
354352
for attr in fieldnames:
355353
setattr(self, attr, kwargs.get(attr, None))
356354

355+
experimenter = kwargs.get('experimenter', None)
356+
if isinstance(experimenter, str):
357+
experimenter = (experimenter,)
358+
setattr(self, 'experimenter', experimenter)
359+
360+
related_pubs = kwargs.get('related_publications', None)
361+
if isinstance(related_pubs, str):
362+
related_pubs = (related_pubs,)
363+
setattr(self, 'related_publications', related_pubs)
364+
357365
if getargs('source_script', kwargs) is None and getargs('source_script_file_name', kwargs) is not None:
358366
raise ValueError("'source_script' cannot be None when 'source_script_file_name' is set")
359367

src/pynwb/testing/__init__.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
import os
1+
from hdmf.testing import TestCase, TestH5RoundTripMixin
2+
from .testh5io import TestNWBH5IOMixin, TestAcquisitionH5IOMixin
3+
from .utils import remove_test_file
24

3-
4-
def remove_test_file(path):
5-
"""A helper function for removing intermediate test files
6-
7-
This checks if the environment variable CLEAN_NWB has been set to False
8-
before removing the file. If CLEAN_NWB is set to False, it does not remove the file.
9-
"""
10-
clean_flag_set = os.getenv('CLEAN_NWB', True) not in ('False', 'false', 'FALSE', '0', 0, False)
11-
if os.path.exists(path) and clean_flag_set:
12-
os.remove(path)
5+
CORE_NAMESPACE = 'core'
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from pynwb import NWBFile, NWBHDF5IO, validate, __version__
2+
from datetime import datetime
3+
4+
# pynwb 1.0.2 should be installed with hdmf 1.0.3
5+
# pynwb 1.0.3 should be installed with hdmf 1.0.5
6+
# pynwb 1.1.0 should be installed with hdmf 1.2.0
7+
# pynwb 1.1.1+ should be installed with an appopriate version of hdmf
8+
9+
10+
def _write(test_name, nwbfile):
11+
filename = 'tests/back_compat/%s_%s.nwb' % (__version__, test_name)
12+
13+
with NWBHDF5IO(filename, 'w') as io:
14+
io.write(nwbfile)
15+
16+
with NWBHDF5IO(filename, 'r') as io:
17+
validate(io)
18+
nwbfile = io.read()
19+
20+
21+
def make_nwbfile():
22+
nwbfile = NWBFile(session_description='ADDME',
23+
identifier='ADDME',
24+
session_start_time=datetime.now().astimezone())
25+
test_name = 'nwbfile'
26+
_write(test_name, nwbfile)
27+
28+
29+
def make_nwbfile_str_experimenter():
30+
nwbfile = NWBFile(session_description='ADDME',
31+
identifier='ADDME',
32+
session_start_time=datetime.now().astimezone(),
33+
experimenter='one experimenter')
34+
test_name = 'str_experimenter'
35+
_write(test_name, nwbfile)
36+
37+
38+
def make_nwbfile_str_pub():
39+
nwbfile = NWBFile(session_description='ADDME',
40+
identifier='ADDME',
41+
session_start_time=datetime.now().astimezone(),
42+
related_publications='one publication')
43+
test_name = 'str_pub'
44+
_write(test_name, nwbfile)
45+
46+
47+
if __name__ == '__main__':
48+
make_nwbfile()
49+
make_nwbfile_str_experimenter()
50+
make_nwbfile_str_pub()

src/pynwb/testing/testh5io.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from datetime import datetime
2+
from dateutil.tz import tzlocal, tzutc
3+
import os
4+
from abc import ABCMeta, abstractmethod
5+
import warnings
6+
7+
from pynwb import NWBFile, NWBHDF5IO, validate as pynwb_validate
8+
from .utils import remove_test_file
9+
from hdmf.backends.warnings import BrokenLinkWarning
10+
from hdmf.build.warnings import MissingRequiredWarning, OrphanContainerWarning
11+
12+
13+
class TestNWBH5IOMixin(metaclass=ABCMeta):
14+
"""
15+
Mixin class for methods to run a roundtrip test writing an NWB file with an Container and reading the Container
16+
from the NWB file. The setUp, test_roundtrip, and tearDown methods will be run by unittest.
17+
18+
The abstract methods setUpContainer, addContainer, and getContainer needs to be implemented by classes that include
19+
this mixin.
20+
21+
Example:
22+
class TestMyContainerIO(TestNWBH5IOMixin, TestCase):
23+
def setUpContainer(self):
24+
# return a test Container to read/write
25+
def addContainer(self, nwbfile):
26+
# add the test Container to an NWB file
27+
def getContainer(self, nwbfile):
28+
# return the test Container from an NWB file
29+
30+
This code is adapted from hdmf.testing.TestH5RoundTripMixin.
31+
"""
32+
33+
def setUp(self):
34+
self.container = self.setUpContainer()
35+
self.start_time = datetime(1971, 1, 1, 12, tzinfo=tzutc())
36+
self.create_date = datetime(2018, 4, 15, 12, tzinfo=tzlocal())
37+
self.container_type = self.container.__class__.__name__
38+
self.filename = 'test_%s.nwb' % self.container_type
39+
self.writer = None
40+
self.reader = None
41+
42+
def tearDown(self):
43+
if self.writer is not None:
44+
self.writer.close()
45+
if self.reader is not None:
46+
self.reader.close()
47+
remove_test_file(self.filename)
48+
49+
@abstractmethod
50+
def setUpContainer(self):
51+
""" Should return the test Container to read/write """
52+
raise NotImplementedError('Cannot run test unless setUpContainer is implemented')
53+
54+
def test_roundtrip(self):
55+
"""
56+
Test whether the test Container read from file has the same contents as the original test Container and
57+
validate the file
58+
"""
59+
self.read_container = self.roundtripContainer()
60+
self.assertIsNotNone(str(self.container)) # added as a test to make sure printing works
61+
self.assertIsNotNone(str(self.read_container))
62+
# make sure we get a completely new object
63+
self.assertNotEqual(id(self.container), id(self.read_container))
64+
self.assertIs(self.read_nwbfile.objects[self.container.object_id], self.read_container)
65+
self.assertContainerEqual(self.read_container, self.container)
66+
67+
def roundtripContainer(self, cache_spec=False):
68+
"""
69+
Add the test Container to an NWBFile, write it to file, read the file, and return the test Container from the
70+
file
71+
"""
72+
description = 'a file to test writing and reading a %s' % self.container_type
73+
identifier = 'TEST_%s' % self.container_type
74+
nwbfile = NWBFile(description, identifier, self.start_time, file_create_date=self.create_date)
75+
self.addContainer(nwbfile)
76+
77+
with warnings.catch_warnings(record=True) as ws:
78+
self.writer = NWBHDF5IO(self.filename, mode='w')
79+
self.writer.write(nwbfile, cache_spec=cache_spec)
80+
self.writer.close()
81+
82+
self.validate()
83+
84+
self.reader = NWBHDF5IO(self.filename, mode='r')
85+
self.read_nwbfile = self.reader.read()
86+
87+
if ws:
88+
for w in ws:
89+
if issubclass(w.category, (MissingRequiredWarning,
90+
OrphanContainerWarning,
91+
BrokenLinkWarning)):
92+
raise Exception('%s: %s' % (w.category.__name__, w.message))
93+
else:
94+
warnings.warn(w.message, w.category)
95+
96+
try:
97+
return self.getContainer(self.read_nwbfile)
98+
except Exception as e:
99+
self.reader.close()
100+
self.reader = None
101+
raise e
102+
103+
@abstractmethod
104+
def addContainer(self, nwbfile):
105+
""" Should add the test Container to the given NWBFile """
106+
raise NotImplementedError('Cannot run test unless addContainer is implemented')
107+
108+
@abstractmethod
109+
def getContainer(self, nwbfile):
110+
""" Should return the test Container from the given NWBFile """
111+
raise NotImplementedError('Cannot run test unless getContainer is implemented')
112+
113+
def validate(self):
114+
""" Validate the created file """
115+
if os.path.exists(self.filename):
116+
with NWBHDF5IO(self.filename, mode='r') as io:
117+
errors = pynwb_validate(io)
118+
if errors:
119+
for err in errors:
120+
raise Exception(err)
121+
122+
123+
class TestAcquisitionH5IOMixin(TestNWBH5IOMixin):
124+
"""
125+
Mixin class for methods to run a roundtrip test writing an NWB file with an Container as an acquisition and reading
126+
the Container as an acquisition from the NWB file. The setUp, test_roundtrip, and tearDown methods will be run by
127+
unittest.
128+
129+
The abstract method setUpContainer needs to be implemented by classes that include this mixin.
130+
131+
Example:
132+
class TestMyContainerIO(TestNWBH5IOMixin, TestCase):
133+
def setUpContainer(self):
134+
# return a test Container to read/write
135+
136+
This code is adapted from hdmf.testing.TestH5RoundTripMixin.
137+
"""
138+
139+
def addContainer(self, nwbfile):
140+
''' Add an NWBDataInterface object to the file as an acquisition '''
141+
nwbfile.add_acquisition(self.container)
142+
143+
def getContainer(self, nwbfile):
144+
''' Get the NWBDataInterface object from the file '''
145+
return nwbfile.get_acquisition(self.container.name)

src/pynwb/testing/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import os
2+
3+
4+
def remove_test_file(path):
5+
"""A helper function for removing intermediate test files
6+
7+
This checks if the environment variable CLEAN_NWB has been set to False
8+
before removing the file. If CLEAN_NWB is set to False, it does not remove the file.
9+
"""
10+
clean_flag_set = os.getenv('CLEAN_NWB', True) not in ('False', 'false', 'FALSE', '0', 0, False)
11+
if os.path.exists(path) and clean_flag_set:
12+
os.remove(path)

test.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import unittest
1313
from tests.coloredtestrunner import ColoredTestRunner, ColoredTestResult
1414

15-
flags = {'pynwb': 2, 'integration': 3, 'example': 4}
15+
flags = {'pynwb': 2, 'integration': 3, 'example': 4, 'backwards': 5}
1616

1717
TOTAL = 0
1818
FAILURES = 0
@@ -111,7 +111,6 @@ def run_integration_tests(verbose=True):
111111
type_map = pynwb.get_type_map()
112112

113113
tested_containers = {}
114-
required_tests = {}
115114
for test_case in test_cases:
116115
if not hasattr(test_case, 'container'):
117116
continue
@@ -122,30 +121,13 @@ def run_integration_tests(verbose=True):
122121
else:
123122
tested_containers[container_class].append(test_case._testMethodName)
124123

125-
if container_class not in required_tests:
126-
required_tests[container_class] = list(test_case.required_tests)
127-
else:
128-
required_tests[container_class].extend(test_case.required_tests)
129-
130124
count_missing = 0
131125
for container_class in type_map.get_container_classes('core'):
132-
133126
if container_class not in tested_containers:
134127
count_missing += 1
135128
if verbose > 1:
136129
logging.info('%s missing test case; should define in %s' % (container_class,
137130
inspect.getfile(container_class)))
138-
continue
139-
140-
test_methods = tested_containers[container_class]
141-
required = required_tests[container_class]
142-
methods_missing = set(required) - set(test_methods)
143-
144-
if methods_missing != set([]):
145-
count_missing += 1
146-
if verbose > 1:
147-
logging.info('%s missing test method(s) \"%s\"; should define in %s' % (
148-
container_class, ', '.join(methods_missing), inspect.getfile(container_class)))
149131

150132
if count_missing > 0:
151133
logging.info('%d classes missing integration tests in ui_write' % count_missing)
@@ -165,6 +147,8 @@ def main():
165147
help='run integration tests')
166148
parser.add_argument('-e', '--example', action='append_const', const=flags['example'], dest='suites',
167149
help='run example tests')
150+
parser.add_argument('-b', '--backwards', action='append_const', const=flags['backwards'], dest='suites',
151+
help='run backwards compatibility tests')
168152
args = parser.parse_args()
169153
if not args.suites:
170154
args.suites = list(flags.values())
@@ -199,6 +183,9 @@ def main():
199183
if flags['integration'] in args.suites:
200184
run_integration_tests(verbose=args.verbosity)
201185

186+
if flags['backwards'] in args.suites:
187+
run_test_suite("tests/back_compat", "pynwb backwards compatibility tests", verbose=args.verbosity)
188+
202189
final_message = 'Ran %s tests' % TOTAL
203190
exitcode = 0
204191
if ERRORS > 0 or FAILURES > 0:

0 commit comments

Comments
 (0)