Coverage for tests/test_sequences.py: 100%
277 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 23:02 +0000
1"""Tests for `bunches.sequences`."""
3from __future__ import annotations
5import pytest
7from bunches import DictList, Listing, settings
9from .conftest import Another, Something
12class TestListing:
13 """Tests for `Listing`."""
15 def test_init(self) -> None:
16 """Tests defaults."""
17 listing = Listing()
18 assert listing.contents == []
19 assert len(listing) == 0
21 def test_add(self) -> None:
22 """Tests `add` extends with sequences and appends everything else."""
23 listing = Listing(['a'])
24 listing.add('bc')
25 listing.add(['d', 'e'])
26 listing.add(('f', 'g'))
27 listing.add(b'h')
28 listing.add(7)
29 listing.add({'set'})
30 assert listing.contents == [
31 'a', 'bc', 'd', 'e', 'f', 'g', b'h', 7, {'set'}]
33 def test_delete(self) -> None:
34 """Tests `delete` and `__delitem__`."""
35 listing = Listing(['a', 'b', 'c', 'd'])
36 listing.delete(0)
37 del listing[0]
38 assert listing.contents == ['c', 'd']
39 listing.delete(slice(0, 2))
40 assert listing.contents == []
41 with pytest.raises(IndexError):
42 listing.delete(0)
44 def test_insert_and_prepend(self) -> None:
45 """Tests `insert` and `prepend`."""
46 listing = Listing(['c'])
47 listing.insert(0, 'b')
48 listing.prepend('a')
49 assert listing.contents == ['a', 'b', 'c']
50 listing.prepend(['x', 'y'])
51 assert listing.contents == ['x', 'y', 'a', 'b', 'c']
52 listing.prepend([['nested']])
53 assert listing.contents[0] == ['nested']
54 listing.prepend([])
55 assert len(listing) == 6
57 def test_getitem_setitem(self) -> None:
58 """Tests `__getitem__` and `__setitem__`."""
59 listing = Listing(['a', 'b', 'c'])
60 assert listing[1] == 'b'
61 assert listing[-1] == 'c'
62 assert listing[0:2] == ['a', 'b']
63 listing[1] = 'z'
64 assert listing.contents == ['a', 'z', 'c']
65 listing[0:2] = ['x']
66 assert listing.contents == ['x', 'c']
67 with pytest.raises(IndexError):
68 listing[5]
70 def test_mutable_sequence_methods(self) -> None:
71 """Tests methods inherited from `MutableSequence`."""
72 listing = Listing(['a', 'b', 'c'])
73 listing.append('d')
74 listing.extend(['e', 'f'])
75 assert listing.contents == ['a', 'b', 'c', 'd', 'e', 'f']
76 listing.remove('b')
77 assert listing.pop() == 'f'
78 assert listing.index('c') == 1
79 assert listing.count('a') == 1
80 listing.reverse()
81 assert listing.contents == ['e', 'd', 'c', 'a']
82 assert 'a' in listing
83 assert list(reversed(listing)) == ['a', 'c', 'd', 'e']
84 listing.clear()
85 assert listing.contents == []
87 def test_subset(self) -> None:
88 """Tests `subset`."""
89 listing = Listing(['a', 'b', 'c', 'd'])
90 subset = listing.subset(include = ['a', 'b', 'c'], exclude = 'b')
91 assert isinstance(subset, Listing)
92 assert subset.contents == ['a', 'c']
93 assert listing.subset(include = 'a').contents == ['a']
94 assert listing.subset(exclude = ['a', 'b']).contents == ['c', 'd']
95 assert listing.contents == ['a', 'b', 'c', 'd']
96 with pytest.raises(ValueError, match = 'must not be None'):
97 listing.subset()
99 def test_subset_preserves_order_of_contents(self) -> None:
100 """Tests `subset` follows the order of `contents`, not `include`."""
101 listing = Listing(['a', 'b', 'c'])
102 assert listing.subset(include = ['c', 'a']).contents == ['a', 'c']
104 def test_subset_returns(self) -> None:
105 """Tests the `returns` argument of `subset`."""
106 listing = Listing(['a', 'b'])
107 assert listing.subset(include = 'a', returns = 'simple') == ['a']
108 copied = listing.subset(include = 'a', returns = 'copy')
109 assert isinstance(copied, Listing)
110 with pytest.raises(ValueError, match = 'returns argument'):
111 listing.subset(
112 include = 'a', returns = 'other') # type: ignore[arg-type]
113 settings.set_subset_return('simple')
114 assert listing.subset(include = 'a') == ['a']
116 def test_add_operators(self) -> None:
117 """Tests `+` and `+=`."""
118 listing = Listing(['a'])
119 combined = listing + ['b', 'c']
120 assert combined.contents == ['a', 'b', 'c']
121 assert listing.contents == ['a']
122 assert (listing + 'z').contents == ['a', 'z']
123 listing += ['b']
124 listing += 'c'
125 assert listing.contents == ['a', 'b', 'c']
127 def test_equality(self) -> None:
128 """Tests equality."""
129 assert Listing([1, 2]) == Listing([1, 2])
130 assert Listing([1, 2]) != Listing([2, 1])
132 def test_add_operator_is_deep_copy(self) -> None:
133 """Tests that `+` does not share mutable items with the original."""
134 listing = Listing([[1]])
135 combined = listing + [2]
136 combined[0].append(3)
137 assert listing.contents == [[1]]
139 def test_add_listing(self) -> None:
140 """Tests that adding a `Listing` extends with its items."""
141 listing = Listing([1])
142 listing += Listing([2, 3])
143 assert listing.contents == [1, 2, 3]
144 listing.add(range(4, 6))
145 assert listing.contents == [1, 2, 3, 4, 5]
147 def test_prepend_strings_and_tuples(self) -> None:
148 """Tests that `prepend` keeps `str` and `bytes` items whole."""
149 listing = Listing(['c'])
150 listing.prepend('ab')
151 listing.prepend(b'x')
152 listing.prepend(('y', 'z'))
153 assert listing.contents == ['y', 'z', b'x', 'ab', 'c']
155 def test_subset_unhashable_items(self) -> None:
156 """Tests `subset` with unhashable items."""
157 listing = Listing([[1], [2], [3]])
158 assert listing.subset(include = [[1], [3]]).contents == [[1], [3]]
159 assert listing.subset(exclude = [[2]]).contents == [[1], [3]]
161 def test_subset_include_is_deep_copy(self) -> None:
162 """Tests that including items does not share mutable items."""
163 listing = Listing([[1], [2]])
164 subset = listing.subset(include = [[1]])
165 subset[0].append(5)
166 assert listing.contents == [[1], [2]]
169class TestDictList:
170 """Tests for `DictList`."""
172 @pytest.fixture
173 def dictlist(self) -> DictList:
174 """Returns a `DictList` of strings.
176 Returns:
177 `DictList` of strings.
179 """
180 return DictList(['a', 'b', 'c'])
182 def test_init(self) -> None:
183 """Tests defaults."""
184 dictlist = DictList()
185 assert dictlist.contents == []
186 assert dictlist.default_factory is None
188 def test_list_interface(self, dictlist: DictList) -> None:
189 """Tests list behavior inherited from `Listing`.
191 Args:
192 dictlist: `DictList` of strings.
194 """
195 dictlist.add('d')
196 assert dictlist[3] == 'd'
197 dictlist.insert(2, 'zebra')
198 assert dictlist[2] == 'zebra'
199 dictlist.append('e')
200 dictlist.extend(['f', 'g'])
201 assert dictlist.contents == [
202 'a', 'b', 'zebra', 'c', 'd', 'e', 'f', 'g']
203 dictlist.prepend('first')
204 assert dictlist[0] == 'first'
205 assert dictlist[1:3] == ['a', 'b']
206 dictlist[0] = 'start'
207 dictlist[1:3] = ['x', 'y']
208 assert dictlist.contents[:4] == ['start', 'x', 'y', 'zebra']
210 def test_getitem_by_name(self) -> None:
211 """Tests `__getitem__` with names, attributes and multiple matches."""
212 something = Something()
213 dictlist = DictList([something, Another(), 'plain', Something('x')])
214 assert dictlist['something'] is something
215 assert dictlist['another'] == Another()
216 assert dictlist['plain'] == 'plain'
217 assert dictlist['x'] == Something('x')
218 with pytest.raises(KeyError, match = 'missing'):
219 dictlist['missing']
220 with pytest.raises(IndexError):
221 dictlist[10]
223 def test_getitem_multiple_matches(self) -> None:
224 """Tests that duplicate names return a `DictList` of matches."""
225 dictlist = DictList(['a', 'b', 'a'], default_factory = 'nope')
226 matches = dictlist['a']
227 assert isinstance(matches, DictList)
228 assert matches.contents == ['a', 'a']
229 assert matches.default_factory == 'nope'
230 assert dictlist['b'] == 'b'
232 def test_setitem(self, dictlist: DictList) -> None:
233 """Tests `__setitem__` with an index and with a name.
235 Args:
236 dictlist: `DictList` of strings.
238 """
239 dictlist[0] = 'z'
240 assert dictlist.contents == ['z', 'b', 'c']
241 dictlist['ignored'] = 'd'
242 assert dictlist.contents == ['z', 'b', 'c', 'd']
243 dictlist['ignored'] = ['e', 'f']
244 assert dictlist.contents == ['z', 'b', 'c', 'd', 'e', 'f']
246 def test_get(self, dictlist: DictList) -> None:
247 """Tests `get` with and without defaults.
249 Args:
250 dictlist: `DictList` of strings.
252 """
253 assert dictlist.get('a') == 'a'
254 assert dictlist.get(1) == 'b'
255 assert dictlist.get('tree', 'No') == 'No'
256 with pytest.raises(KeyError, match = 'tree'):
257 dictlist.get('tree')
258 with pytest.raises(KeyError):
259 dictlist.get(10)
260 dictlist.setdefault(value = 'No')
261 assert dictlist.default_factory == 'No'
262 assert dictlist.get('tree') == 'No'
263 assert dictlist.get(10) == 'No'
264 assert dictlist.get('tree', 'Other') == 'Other'
265 dictlist.default_factory = list
266 assert dictlist.get('tree') == []
268 def test_keys_values_items(self) -> None:
269 """Tests `keys`, `values` and `items`."""
270 something = Something()
271 dictlist = DictList(['a', something, 'a'])
272 assert dictlist.keys() == ('a', 'something', 'a')
273 assert dictlist.values() == ('a', something, 'a')
274 assert dictlist.items() == (
275 ('a', 'a'), ('something', something), ('a', 'a'))
277 def test_update(self, dictlist: DictList) -> None:
278 """Tests `update`, which discards the keys.
280 Args:
281 dictlist: `DictList` of strings.
283 """
284 dictlist.update({'ignored': 'd', 'also_ignored': 'e'})
285 assert dictlist.contents == ['a', 'b', 'c', 'd', 'e']
287 def test_delete(self) -> None:
288 """Tests `delete` by index, by slice, and by name."""
289 dictlist = DictList(['a', 'b', 'a', 'c', 'd', 'e'])
290 dictlist.delete(0)
291 assert dictlist.contents == ['b', 'a', 'c', 'd', 'e']
292 dictlist.delete('a')
293 assert dictlist.contents == ['b', 'c', 'd', 'e']
294 del dictlist['b']
295 assert dictlist.contents == ['c', 'd', 'e']
296 dictlist.delete(slice(0, 2))
297 assert dictlist.contents == ['e']
298 with pytest.raises(KeyError, match = 'missing'):
299 dictlist.delete('missing')
300 with pytest.raises(IndexError):
301 dictlist.delete(5)
303 def test_delete_preserves_container(self) -> None:
304 """Tests that deleting by name modifies `contents` in place."""
305 contents = ['a', 'b', 'a']
306 dictlist = DictList(contents)
307 dictlist.delete('a')
308 assert dictlist.contents is contents
309 assert contents == ['b']
311 def test_remove(self) -> None:
312 """Tests `remove` inherited from `MutableSequence`."""
313 dictlist = DictList(['a', 'b', 'c'])
314 dictlist.remove('b')
315 assert dictlist.contents == ['a', 'c']
317 def test_subset(self) -> None:
318 """Tests `subset`."""
319 dictlist = DictList(['a', 'b', 'c', 'd', 'zebra'])
320 subset = dictlist.subset(
321 include = ['a', 'b', 'c', 'zebra'], exclude = 'c')
322 assert isinstance(subset, DictList)
323 assert subset.contents == ['a', 'b', 'zebra']
324 assert subset['zebra'] == 'zebra'
325 copied = dictlist.subset(exclude = 'a', returns = 'copy')
326 assert copied.contents == ['b', 'c', 'd', 'zebra']
327 assert dictlist.contents == ['a', 'b', 'c', 'd', 'zebra']
329 def test_hybrid_use(self) -> None:
330 """Tests a mix of dict and list access on the same instance."""
331 dictlist = DictList(['a', 'b', 'c'])
332 dictlist.setdefault(value = 'No')
333 assert dictlist.get('tree') == 'No'
334 assert dictlist[1] == 'b'
335 dictlist.append('b')
336 assert dictlist.values() == ('a', 'b', 'c', 'b')
337 assert dictlist.keys() == ('a', 'b', 'c', 'b')
338 dictlist.clear()
339 item = Something()
340 dictlist.add(item)
341 assert dictlist.keys() == ('something',)
342 assert dictlist.values() == (item,)
344 def test_getitem_slice_and_negative_index(
345 self, dictlist: DictList) -> None:
346 """Tests that slices return lists and negative indexes work.
348 Args:
349 dictlist: `DictList` of strings.
351 """
352 assert dictlist[-1] == 'c'
353 assert dictlist[::2] == ['a', 'c']
354 assert dictlist.get(slice(0, 1)) == ['a']
356 def test_custom_key_namer(self) -> None:
357 """Tests that `DictList` uses the global key namer."""
358 settings.set_key_namer(lambda item: getattr(item, 'name', 'unnamed'))
359 dictlist = DictList([Something(), 'plain'])
360 assert dictlist.keys() == ('something', 'unnamed')
361 assert dictlist['unnamed'] == 'plain'
362 dictlist.delete('unnamed')
363 assert dictlist.keys() == ('something',)
365 def test_add_operators(self, dictlist: DictList) -> None:
366 """Tests `+` and `+=`.
368 Args:
369 dictlist: `DictList` of strings.
371 """
372 combined = dictlist + Something()
373 assert isinstance(combined, DictList)
374 assert combined['something'] == Something()
375 assert 'something' not in dictlist.keys()
376 dictlist += ['d']
377 assert dictlist.keys() == ('a', 'b', 'c', 'd')
379 def test_items_with_duplicate_names(self) -> None:
380 """Tests that `items` keeps every item with a duplicate name."""
381 first = Something('same')
382 second = Another('same')
383 dictlist = DictList([first, second])
384 assert dictlist.items() == (('same', first), ('same', second))
385 assert dictlist['same'].contents == [first, second]
387 def test_subset_by_name(self) -> None:
388 """Tests that `subset` selects items by name."""
389 first = Something()
390 second = Another()
391 third = Something('third')
392 dictlist = DictList([first, second, third])
393 subset = dictlist.subset(include = ['something', 'third'])
394 assert subset.keys() == ('something', 'third')
395 assert subset.values() == (first, third)
396 assert subset['third'] is not third
397 subset = dictlist.subset(exclude = 'another')
398 assert subset.keys() == ('something', 'third')
399 assert dictlist.subset(include = first).contents == []