Skip to content

Commit 386713b

Browse files
authored
Fix multiple bugs in ObjectList (#1936)
* Fix multiple bugs in ObjectList (#1935) extend() and += now convert their input to a list, then check every object before changing anything. This rejects duplicates within the iterable (1), handles generators (2), and leaves the list and the objects' parent links unchanged if any object is rejected (5). Membership tests (in, index(), count(), remove()) now compare by identity, as the duplicate check in append() already does (3). reverse() no longer raises after reversing (4). __setitem__ now does the same type, duplicate and parent checks as append(), for both integer and slice keys (6). Container._set_object_list() now raises the TypeError it builds (7). SpikeTrainList's append(), extend(), __setitem__ and += now delegate to ObjectList after converting a multiplexed list to SpikeTrain objects, so they get the same fixes. Fixes #1935. See also #1927. * Check for duplicates in all SpikeTrainList paths that add spike trains SpikeTrainList's constructor, the in-place `stl += other_stl` path and the list branch of __radd__ add spike trains without the duplicate check, so the same SpikeTrain can be stored twice, after which count() and the list disagree. These paths now go through extend(), or in the case of the constructor perform the same check, which also stops the constructor from silently dropping the contents of a generator. See #1935. * Make ObjectList.extend() O(n + m) extend() checks each incoming object against the existing items with _contains(), which scans the whole list, so extending a list of n objects by m objects is O(n·m). Since the previous commit routes `stl += other_stl` through extend(), this makes combining two lists of 10,000 SpikeTrains take over a second, where it previously took under a millisecond. extend() now builds the set of existing ids once per call.
1 parent 9600fee commit 386713b

6 files changed

Lines changed: 279 additions & 37 deletions

File tree

‎neo/core/container.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def _set_object_list(self, name, value):
226226
elif isinstance(value, ObjectList): # from __iadd__
227227
setattr(self, name, value)
228228
else:
229-
TypeError("value must be a list or an ObjectList")
229+
raise TypeError("value must be a list or an ObjectList")
230230

231231
@property
232232
def _child_objects(self):

‎neo/core/objectlist.py‎

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ def __init__(self, allowed_contents, parent=None):
2727
self.parent = parent
2828

2929
def _handle_append(self, obj):
30+
self._check_allowed(obj)
31+
if self._contains(obj):
32+
raise ValueError("Cannot add this object because it is already contained within the list")
33+
self._set_parent(obj)
34+
35+
def _check_allowed(self, obj):
3036
if not (
3137
isinstance(obj, self.allowed_contents)
3238
or ( # also allow proxy objects of the correct type
@@ -35,9 +41,7 @@ def _handle_append(self, obj):
3541
):
3642
raise TypeError(f"Object is a {type(obj)}. It should be one of {self.allowed_contents}.")
3743

38-
if self._contains(obj):
39-
raise ValueError("Cannot add this object because it is already contained within the list")
40-
44+
def _set_parent(self, obj):
4145
# set the child-parent relationship
4246
if self.parent:
4347
relationship_name = self.parent.__class__.__name__.lower()
@@ -49,11 +53,8 @@ def _handle_append(self, obj):
4953
setattr(obj, relationship_name, self.parent)
5054

5155
def _contains(self, obj):
52-
if self._items is None:
53-
obj_ids = []
54-
else:
55-
obj_ids = [id(item) for item in self._items]
56-
return id(obj) in obj_ids
56+
# membership is by identity
57+
return self._items is not None and any(item is obj for item in self._items)
5758

5859
def __str__(self):
5960
return str(self._items)
@@ -75,12 +76,10 @@ def __radd__(self, objects):
7576
return objects + self._items
7677

7778
def __contains__(self, key):
78-
return key in self._items
79+
return self._contains(key)
7980

8081
def __iadd__(self, objects):
81-
for obj in objects:
82-
self._handle_append(obj)
83-
self._items.extend(objects)
82+
self.extend(objects)
8483
return self
8584

8685
def __iter__(self):
@@ -93,25 +92,55 @@ def __len__(self):
9392
return len(self._items)
9493

9594
def __setitem__(self, key, value):
96-
self._items[key] = value
95+
if isinstance(key, slice):
96+
values = list(value)
97+
for obj in values:
98+
self._check_allowed(obj)
99+
# assign to a copy first, so that nothing changes if the assignment or the checks fail
100+
new_items = self._items[:]
101+
new_items[key] = values
102+
if len({id(obj) for obj in new_items}) != len(new_items):
103+
raise ValueError("Cannot add this object because it is already contained within the list")
104+
for obj in values:
105+
self._set_parent(obj)
106+
self._items = new_items
107+
else:
108+
if value is self._items[key]:
109+
return
110+
self._handle_append(value)
111+
self._items[key] = value
97112

98113
def append(self, obj):
99114
self._handle_append(obj)
100115
self._items.append(obj)
101116

102117
def extend(self, objects):
118+
# objects may be an iterator that can only be consumed once (e.g., a generator), and we need to iterate twice
119+
objects = list(objects)
120+
# check everything before changing anything, so that a failure leaves the list unchanged.
121+
# Building the set of ids once, rather than calling _contains() for each object, keeps this O(n + m)
122+
seen = {id(item) for item in self._items or ()}
123+
for obj in objects:
124+
self._check_allowed(obj)
125+
if id(obj) in seen:
126+
raise ValueError("Cannot add this object because it is already contained within the list")
127+
seen.add(id(obj))
103128
for obj in objects:
104-
self._handle_append(obj)
129+
self._set_parent(obj)
105130
self._items.extend(objects)
106131

107132
def clear(self):
108133
self._items = []
109134

110135
def count(self, value):
111-
return self._items.count(value)
136+
return int(self._contains(value))
112137

113138
def index(self, value, start=0, stop=sys.maxsize):
114-
return self._items.index(value, start, stop)
139+
items = self._items or []
140+
for i in range(*slice(start, stop).indices(len(items))):
141+
if items[i] is value:
142+
return i
143+
raise ValueError("object is not in list")
115144

116145
def insert(self, index, obj):
117146
self._handle_append(obj)
@@ -121,10 +150,10 @@ def pop(self, index=-1):
121150
return self._items.pop(index)
122151

123152
def remove(self, value):
124-
return self._items.remove(value)
153+
del self._items[self.index(value)]
125154

126155
def reverse(self):
127-
raise self._items.reverse()
156+
self._items.reverse()
128157

129158
def sort(self, *args, key=None, reverse=False):
130159
self._items.sort(*args, key=key, reverse=reverse)

‎neo/core/spiketrainlist.py‎

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,13 @@ def __init__(self, items=None, parent=None):
105105
if items is None:
106106
self._items = items
107107
else:
108+
items = list(items)
108109
for item in items:
109110
if not is_spiketrain_or_proxy(item):
110111
raise ValueError("`items` can only contain SpikeTrain objects or proxy objects")
111-
self._items = list(items)
112+
if len({id(item) for item in items}) != len(items):
113+
raise ValueError("`items` contains the same object more than once")
114+
self._items = items
112115
self._spike_time_array = None
113116
self._channel_id_array = None
114117
self._all_channel_ids = None
@@ -144,7 +147,7 @@ def __getitem__(self, i):
144147
def __setitem__(self, i, value):
145148
if self._items is None:
146149
self._spiketrains_from_array()
147-
self._items[i] = value
150+
super().__setitem__(i, value)
148151

149152
def __str__(self):
150153
"""Return str(self)"""
@@ -183,7 +186,7 @@ def _add_spiketrainlists(self, other, in_place=False):
183186
if other._items is None:
184187
other._spiketrains_from_array()
185188
if in_place:
186-
self._items.extend(other._items)
189+
self.extend(other._items)
187190
return self
188191
else:
189192
return self.__class__(items=self._items[:] + other._items)
@@ -232,11 +235,7 @@ def __iadd__(self, other):
232235
if isinstance(other, self.__class__):
233236
return self._add_spiketrainlists(other, in_place=True)
234237
elif other and is_spiketrain_or_proxy(other[0]):
235-
for obj in other:
236-
self._handle_append(obj)
237-
if self._items is None:
238-
self._spiketrains_from_array()
239-
self._items.extend(other)
238+
self.extend(other)
240239
return self
241240
else:
242241
raise TypeError("Can only add a SpikeTrainList or a list of SpikeTrains in place")
@@ -246,11 +245,7 @@ def __radd__(self, other):
246245
if isinstance(other, self.__class__):
247246
return other._add_spiketrainlists(self)
248247
elif other and is_spiketrain_or_proxy(other[0]):
249-
for obj in other:
250-
obj.segment = self.segment
251-
if self._items is None:
252-
self._spiketrains_from_array()
253-
self._items.extend(other)
248+
self.extend(other)
254249
return self
255250
elif len(other) == 0:
256251
return self
@@ -281,8 +276,7 @@ def append(self, obj):
281276
raise ValueError("Can only append SpikeTrain objects")
282277
if self._items is None:
283278
self._spiketrains_from_array()
284-
self._handle_append(obj)
285-
self._items.append(obj)
279+
super().append(obj)
286280

287281
def extend(self, iterable):
288282
"""Extends the SpikeTrainList with additional SpikeTrain's from an iterable
@@ -306,9 +300,7 @@ def extend(self, iterable):
306300

307301
if self._items is None:
308302
self._spiketrains_from_array()
309-
for obj in iterable:
310-
self._handle_append(obj)
311-
self._items.extend(iterable)
303+
super().extend(iterable)
312304

313305
@classmethod
314306
def from_spike_time_array(

‎neo/test/coretest/test_container.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,21 @@ def test_merge__unmergable_unequal_AssertionError(self):
268268
)
269269

270270

271+
class Test_Container_set_object_list(unittest.TestCase):
272+
def test_set_list(self):
273+
seg = neo.core.Segment()
274+
sig = neo.core.AnalogSignal([1, 2, 3] * pq.mV, sampling_rate=1 * pq.kHz)
275+
seg.analogsignals = [sig]
276+
self.assertEqual(len(seg.analogsignals), 1)
277+
self.assertIs(sig.segment, seg)
278+
279+
def test_set_other_type_TypeError(self):
280+
seg = neo.core.Segment()
281+
sig = neo.core.AnalogSignal([1, 2, 3] * pq.mV, sampling_rate=1 * pq.kHz)
282+
with self.assertRaises(TypeError):
283+
seg.analogsignals = (sig,)
284+
285+
271286
@unittest.skipUnless(HAVE_IPYTHON, "requires IPython")
272287
class Test_pprint(unittest.TestCase):
273288
def test__pretty(self):
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
Tests of the neo.core.objectlist.ObjectList class
3+
"""
4+
5+
import unittest
6+
7+
import quantities as pq
8+
9+
from neo.core import AnalogSignal, Event, Group, Segment
10+
from neo.core.objectlist import ObjectList
11+
12+
13+
def make_signal(value=1.0):
14+
return AnalogSignal([[value], [value + 1], [value + 2]] * pq.mV, sampling_rate=1 * pq.kHz)
15+
16+
17+
class TestObjectList(unittest.TestCase):
18+
def setUp(self):
19+
self.segment = Segment()
20+
self.objlist = ObjectList(AnalogSignal, parent=self.segment)
21+
self.a, self.b, self.c = make_signal(1.0), make_signal(10.0), make_signal(100.0)
22+
23+
def test_append_sets_parent(self):
24+
self.objlist.append(self.a)
25+
self.assertEqual(len(self.objlist), 1)
26+
self.assertIs(self.a.segment, self.segment)
27+
28+
def test_append_duplicate(self):
29+
self.objlist.append(self.a)
30+
self.assertRaises(ValueError, self.objlist.append, self.a)
31+
self.assertEqual(len(self.objlist), 1)
32+
33+
def test_append_equal_but_distinct_object(self):
34+
# membership is by identity, so an object with the same data is a different object
35+
self.objlist.append(self.a)
36+
self.objlist.append(make_signal(1.0))
37+
self.assertEqual(len(self.objlist), 2)
38+
39+
def test_append_wrong_type(self):
40+
self.assertRaises(TypeError, self.objlist.append, Event())
41+
42+
def test_extend_from_generator(self):
43+
self.objlist.extend(sig for sig in (self.a, self.b))
44+
self.assertEqual(len(self.objlist), 2)
45+
self.assertIs(self.objlist[1], self.b)
46+
47+
def test_iadd_from_generator(self):
48+
self.objlist += (sig for sig in (self.a, self.b))
49+
self.assertEqual(len(self.objlist), 2)
50+
51+
def test_extend_duplicate_within_iterable(self):
52+
self.assertRaises(ValueError, self.objlist.extend, [self.a, self.a])
53+
self.assertEqual(len(self.objlist), 0)
54+
self.assertIsNone(self.a.segment)
55+
56+
def test_extend_duplicate_of_existing_item(self):
57+
self.objlist.append(self.a)
58+
self.assertRaises(ValueError, self.objlist.extend, [self.b, self.a])
59+
self.assertEqual(len(self.objlist), 1)
60+
self.assertIsNone(self.b.segment)
61+
62+
def test_extend_is_atomic(self):
63+
self.assertRaises(TypeError, self.objlist.extend, [self.a, Event()])
64+
self.assertEqual(len(self.objlist), 0)
65+
self.assertIsNone(self.a.segment)
66+
67+
def test_contains(self):
68+
self.objlist.extend([self.a, self.b])
69+
self.assertIn(self.a, self.objlist)
70+
self.assertIn(self.b, self.objlist)
71+
self.assertNotIn(self.c, self.objlist)
72+
self.assertNotIn(make_signal(10.0), self.objlist)
73+
74+
def test_index(self):
75+
self.objlist.extend([self.a, self.b, self.c])
76+
self.assertEqual(self.objlist.index(self.c), 2)
77+
self.assertEqual(self.objlist.index(self.c, 1), 2)
78+
self.assertEqual(self.objlist.index(self.c, -1), 2)
79+
self.assertRaises(ValueError, self.objlist.index, self.a, 1)
80+
self.assertRaises(ValueError, self.objlist.index, make_signal(10.0))
81+
82+
def test_count(self):
83+
self.objlist.extend([self.a, self.b])
84+
self.assertEqual(self.objlist.count(self.b), 1)
85+
self.assertEqual(self.objlist.count(make_signal(10.0)), 0)
86+
87+
def test_remove(self):
88+
self.objlist.extend([self.a, self.b, self.c])
89+
self.objlist.remove(self.b)
90+
self.assertEqual(list(self.objlist), [self.a, self.c])
91+
self.assertRaises(ValueError, self.objlist.remove, self.b)
92+
93+
def test_append_after_removal(self):
94+
self.objlist.extend([self.a, self.b])
95+
self.objlist.remove(self.a)
96+
self.objlist.append(self.a)
97+
self.assertIs(self.objlist.pop(), self.a)
98+
self.objlist.append(self.a)
99+
self.objlist.clear()
100+
self.objlist.append(self.a)
101+
self.assertEqual(len(self.objlist), 1)
102+
103+
def test_setitem(self):
104+
self.objlist.extend([self.a, self.b])
105+
self.objlist[1] = self.c
106+
self.assertIs(self.objlist[1], self.c)
107+
self.assertIs(self.c.segment, self.segment)
108+
109+
def test_setitem_same_object(self):
110+
self.objlist.extend([self.a, self.b])
111+
self.objlist[1] = self.b
112+
self.assertEqual(list(self.objlist), [self.a, self.b])
113+
114+
def test_setitem_duplicate(self):
115+
self.objlist.extend([self.a, self.b])
116+
with self.assertRaises(ValueError):
117+
self.objlist[1] = self.a
118+
self.assertEqual(list(self.objlist), [self.a, self.b])
119+
120+
def test_setitem_wrong_type(self):
121+
self.objlist.append(self.a)
122+
with self.assertRaises(TypeError):
123+
self.objlist[0] = Event()
124+
125+
def test_setitem_slice(self):
126+
self.objlist.extend([self.a, self.b])
127+
self.objlist[1:] = [self.b, self.c]
128+
self.assertEqual(list(self.objlist), [self.a, self.b, self.c])
129+
self.assertIs(self.c.segment, self.segment)
130+
131+
def test_setitem_slice_duplicate(self):
132+
self.objlist.extend([self.a, self.b])
133+
with self.assertRaises(ValueError):
134+
self.objlist[1:] = [self.c, self.a]
135+
with self.assertRaises(ValueError):
136+
self.objlist[1:] = [self.c, self.c]
137+
self.assertEqual(list(self.objlist), [self.a, self.b])
138+
self.assertIsNone(self.c.segment)
139+
140+
def test_setitem_slice_wrong_type(self):
141+
self.objlist.append(self.a)
142+
with self.assertRaises(TypeError):
143+
self.objlist[:] = [self.b, Event()]
144+
self.assertEqual(list(self.objlist), [self.a])
145+
146+
def test_reverse(self):
147+
self.objlist.extend([self.a, self.b, self.c])
148+
self.objlist.reverse()
149+
self.assertEqual(list(self.objlist), [self.c, self.b, self.a])
150+
151+
def test_without_parent(self):
152+
group = Group()
153+
objlist = ObjectList(AnalogSignal)
154+
objlist.extend([self.a, self.b])
155+
self.assertEqual(len(objlist), 2)
156+
self.assertIsNone(self.a.segment)
157+
group.analogsignals.append(self.c)
158+
self.assertIn(self.c, group.analogsignals)
159+
160+
161+
if __name__ == "__main__":
162+
unittest.main()

0 commit comments

Comments
 (0)