Skip to content

Commit dfef174

Browse files
authored
Improves error messages for ClassLists (#80)
* Improves error messages for ClassLists * Addresses review comments
1 parent 1dbeedc commit dfef174

File tree

2 files changed

+150
-45
lines changed

2 files changed

+150
-45
lines changed

RATapi/classlist.py

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import collections
66
import contextlib
77
import warnings
8-
from collections.abc import Iterable, Sequence
8+
from collections.abc import Sequence
99
from typing import Any, Union
1010

1111
import numpy as np
@@ -96,8 +96,8 @@ def __setitem__(self, index: int, item: object) -> None:
9696

9797
def _setitem(self, index: int, item: object) -> None:
9898
"""Auxiliary routine of "__setitem__" used to enable wrapping."""
99-
self._check_classes(self + [item])
100-
self._check_unique_name_fields(self + [item])
99+
self._check_classes([item])
100+
self._check_unique_name_fields([item])
101101
self.data[index] = item
102102

103103
def __delitem__(self, index: int) -> None:
@@ -118,8 +118,8 @@ def _iadd(self, other: Sequence[object]) -> "ClassList":
118118
other = [other]
119119
if not hasattr(self, "_class_handle"):
120120
self._class_handle = self._determine_class_handle(self + other)
121-
self._check_classes(self + other)
122-
self._check_unique_name_fields(self + other)
121+
self._check_classes(other)
122+
self._check_unique_name_fields(other)
123123
super().__iadd__(other)
124124
return self
125125

@@ -168,8 +168,8 @@ def append(self, obj: object = None, **kwargs) -> None:
168168
if obj:
169169
if not hasattr(self, "_class_handle"):
170170
self._class_handle = type(obj)
171-
self._check_classes(self + [obj])
172-
self._check_unique_name_fields(self + [obj])
171+
self._check_classes([obj])
172+
self._check_unique_name_fields([obj])
173173
self.data.append(obj)
174174
else:
175175
if not hasattr(self, "_class_handle"):
@@ -215,8 +215,8 @@ def insert(self, index: int, obj: object = None, **kwargs) -> None:
215215
if obj:
216216
if not hasattr(self, "_class_handle"):
217217
self._class_handle = type(obj)
218-
self._check_classes(self + [obj])
219-
self._check_unique_name_fields(self + [obj])
218+
self._check_classes([obj])
219+
self._check_unique_name_fields([obj])
220220
self.data.insert(index, obj)
221221
else:
222222
if not hasattr(self, "_class_handle"):
@@ -252,8 +252,8 @@ def extend(self, other: Sequence[object]) -> None:
252252
other = [other]
253253
if not hasattr(self, "_class_handle"):
254254
self._class_handle = self._determine_class_handle(self + other)
255-
self._check_classes(self + other)
256-
self._check_unique_name_fields(self + other)
255+
self._check_classes(other)
256+
self._check_unique_name_fields(other)
257257
self.data.extend(other)
258258

259259
def set_fields(self, index: int, **kwargs) -> None:
@@ -312,13 +312,14 @@ def _validate_name_field(self, input_args: dict[str, Any]) -> None:
312312
"""
313313
names = [name.lower() for name in self.get_names()]
314314
with contextlib.suppress(KeyError):
315-
if input_args[self.name_field].lower() in names:
315+
name = input_args[self.name_field].lower()
316+
if name in names:
316317
raise ValueError(
317318
f"Input arguments contain the {self.name_field} '{input_args[self.name_field]}', "
318-
f"which is already specified in the ClassList",
319+
f"which is already specified at index {names.index(name)} of the ClassList",
319320
)
320321

321-
def _check_unique_name_fields(self, input_list: Iterable[object]) -> None:
322+
def _check_unique_name_fields(self, input_list: Sequence[object]) -> None:
322323
"""Raise a ValueError if any value of the name_field attribute is used more than once in a list of class
323324
objects.
324325
@@ -333,11 +334,49 @@ def _check_unique_name_fields(self, input_list: Iterable[object]) -> None:
333334
Raised if the input list defines more than one object with the same value of name_field.
334335
335336
"""
336-
names = [getattr(model, self.name_field).lower() for model in input_list if hasattr(model, self.name_field)]
337-
if len(set(names)) != len(names):
338-
raise ValueError(f"Input list contains objects with the same value of the {self.name_field} attribute")
337+
error_list = []
338+
try:
339+
existing_names = [name.lower() for name in self.get_names()]
340+
except AttributeError:
341+
existing_names = []
342+
343+
new_names = [getattr(model, self.name_field).lower() for model in input_list if hasattr(model, self.name_field)]
344+
full_names = existing_names + new_names
345+
346+
# There are duplicate names if this test fails
347+
if len(set(full_names)) != len(full_names):
348+
unique_names = [*dict.fromkeys(new_names)]
349+
350+
for name in unique_names:
351+
existing_indices = [i for i, other_name in enumerate(existing_names) if other_name == name]
352+
new_indices = [i for i, other_name in enumerate(new_names) if other_name == name]
353+
if (len(existing_indices) + len(new_indices)) > 1:
354+
existing_string = ""
355+
new_string = ""
356+
if existing_indices:
357+
existing_list = ", ".join(str(i) for i in existing_indices[:-1])
358+
existing_string = (
359+
f" item{f's {existing_list} and ' if existing_list else ' '}"
360+
f"{existing_indices[-1]} of the existing ClassList"
361+
)
362+
if new_indices:
363+
new_list = ", ".join(str(i) for i in new_indices[:-1])
364+
new_string = (
365+
f" item{f's {new_list} and ' if new_list else ' '}" f"{new_indices[-1]} of the input list"
366+
)
367+
error_list.append(
368+
f" '{name}' is shared between{existing_string}"
369+
f"{', and' if existing_string and new_string else ''}{new_string}"
370+
)
339371

340-
def _check_classes(self, input_list: Iterable[object]) -> None:
372+
if error_list:
373+
newline = "\n"
374+
raise ValueError(
375+
f"The value of the '{self.name_field}' attribute must be unique for each item in the ClassList:\n"
376+
f"{newline.join(error for error in error_list)}"
377+
)
378+
379+
def _check_classes(self, input_list: Sequence[object]) -> None:
341380
"""Raise a ValueError if any object in a list of objects is not of the type specified by self._class_handle.
342381
343382
Parameters
@@ -348,11 +387,19 @@ def _check_classes(self, input_list: Iterable[object]) -> None:
348387
Raises
349388
------
350389
ValueError
351-
Raised if the input list defines objects of different types.
390+
Raised if the input list contains objects of any type other than that given in self._class_handle.
352391
353392
"""
354-
if not (all(isinstance(element, self._class_handle) for element in input_list)):
355-
raise ValueError(f"Input list contains elements of type other than '{self._class_handle.__name__}'")
393+
error_list = []
394+
for i, element in enumerate(input_list):
395+
if not isinstance(element, self._class_handle):
396+
error_list.append(f" index {i} is of type {type(element).__name__}")
397+
if error_list:
398+
newline = "\n"
399+
raise ValueError(
400+
f"This ClassList only supports elements of type {self._class_handle.__name__}. "
401+
f"In the input list:\n{newline.join(error for error in error_list)}\n"
402+
)
356403

357404
def _get_item_from_name_field(self, value: Union[object, str]) -> Union[object, str]:
358405
"""Return the object with the given value of the name_field attribute in the ClassList.
@@ -379,7 +426,7 @@ def _get_item_from_name_field(self, value: Union[object, str]) -> Union[object,
379426
@staticmethod
380427
def _determine_class_handle(input_list: Sequence[object]):
381428
"""When inputting a sequence of object to a ClassList, the _class_handle should be set as the type of the
382-
element which satisfies "issubclass" for all of the other elements.
429+
element which satisfies "issubclass" for all the other elements.
383430
384431
Parameters
385432
----------

tests/test_classlist.py

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ def test_different_classes(self, input_list: Sequence[object]) -> None:
117117
"""If we initialise a ClassList with an input containing multiple classes, we should raise a ValueError."""
118118
with pytest.raises(
119119
ValueError,
120-
match=f"Input list contains elements of type other than '{type(input_list[0]).__name__}'",
120+
match=f"This ClassList only supports elements of type {type(input_list[0]).__name__}. In the input list:\n"
121+
f" index 1 is of type {type(input_list[1]).__name__}\n",
121122
):
122123
ClassList(input_list)
123124

@@ -134,7 +135,9 @@ def test_identical_name_fields(self, input_list: Sequence[object], name_field: s
134135
"""
135136
with pytest.raises(
136137
ValueError,
137-
match=f"Input list contains objects with the same value of the {name_field} attribute",
138+
match=f"The value of the '{name_field}' attribute must be unique for each item in the "
139+
f"ClassList:\n '{getattr(input_list[0], name_field).lower()}'"
140+
f" is shared between items 0 and 1 of the input list",
138141
):
139142
ClassList(input_list, name_field=name_field)
140143

@@ -194,7 +197,12 @@ def test_setitem(two_name_class_list: ClassList, new_item: InputAttributes, expe
194197
)
195198
def test_setitem_same_name_field(two_name_class_list: ClassList, new_item: InputAttributes) -> None:
196199
"""If we set the name_field of an object in the ClassList to one already defined, we should raise a ValueError."""
197-
with pytest.raises(ValueError, match="Input list contains objects with the same value of the name attribute"):
200+
with pytest.raises(
201+
ValueError,
202+
match=f"The value of the '{two_name_class_list.name_field}' attribute must be unique for each item in the "
203+
f"ClassList:\n '{new_item.name.lower()}' is shared between item 1 of the existing ClassList,"
204+
f" and item 0 of the input list",
205+
):
198206
two_name_class_list[0] = new_item
199207

200208

@@ -206,7 +214,11 @@ def test_setitem_same_name_field(two_name_class_list: ClassList, new_item: Input
206214
)
207215
def test_setitem_different_classes(two_name_class_list: ClassList, new_values: dict[str, Any]) -> None:
208216
"""If we set the name_field of an object in the ClassList to one already defined, we should raise a ValueError."""
209-
with pytest.raises(ValueError, match="Input list contains elements of type other than 'InputAttributes'"):
217+
with pytest.raises(
218+
ValueError,
219+
match=f"This ClassList only supports elements of type {two_name_class_list._class_handle.__name__}. "
220+
f"In the input list:\n index 0 is of type {type(new_values).__name__}\n",
221+
):
210222
two_name_class_list[0] = new_values
211223

212224

@@ -403,7 +415,9 @@ def test_append_object_same_name_field(two_name_class_list: ClassList, new_objec
403415
"""If we append an object with an already-specified name_field value to a ClassList we should raise a ValueError."""
404416
with pytest.raises(
405417
ValueError,
406-
match=f"Input list contains objects with the same value of the " f"{two_name_class_list.name_field} attribute",
418+
match=f"The value of the '{two_name_class_list.name_field}' attribute must be unique for each item in the "
419+
f"ClassList:\n '{new_object.name.lower()}' is shared between item 0 of the existing ClassList, and "
420+
f"item 0 of the input list",
407421
):
408422
two_name_class_list.append(new_object)
409423

@@ -420,7 +434,7 @@ def test_append_kwargs_same_name_field(two_name_class_list: ClassList, new_value
420434
ValueError,
421435
match=f"Input arguments contain the {two_name_class_list.name_field} "
422436
f"'{new_values[two_name_class_list.name_field]}', "
423-
f"which is already specified in the ClassList",
437+
f"which is already specified at index 0 of the ClassList",
424438
):
425439
two_name_class_list.append(**new_values)
426440

@@ -526,7 +540,9 @@ def test_insert_object_same_name(two_name_class_list: ClassList, new_object: obj
526540
"""If we insert an object with an already-specified name_field value to a ClassList we should raise a ValueError."""
527541
with pytest.raises(
528542
ValueError,
529-
match=f"Input list contains objects with the same value of the " f"{two_name_class_list.name_field} attribute",
543+
match=f"The value of the '{two_name_class_list.name_field}' attribute must be unique for each item in the "
544+
f"ClassList:\n '{new_object.name.lower()}' is shared between item 0 of the existing "
545+
f"ClassList, and item 0 of the input list",
530546
):
531547
two_name_class_list.insert(1, new_object)
532548

@@ -543,7 +559,7 @@ def test_insert_kwargs_same_name(two_name_class_list: ClassList, new_values: dic
543559
ValueError,
544560
match=f"Input arguments contain the {two_name_class_list.name_field} "
545561
f"'{new_values[two_name_class_list.name_field]}', "
546-
f"which is already specified in the ClassList",
562+
f"which is already specified at index 0 of the ClassList",
547563
):
548564
two_name_class_list.insert(1, **new_values)
549565

@@ -702,7 +718,7 @@ def test_set_fields_same_name_field(two_name_class_list: ClassList, new_values:
702718
ValueError,
703719
match=f"Input arguments contain the {two_name_class_list.name_field} "
704720
f"'{new_values[two_name_class_list.name_field]}', "
705-
f"which is already specified in the ClassList",
721+
f"which is already specified at index 1 of the ClassList",
706722
):
707723
two_name_class_list.set_fields(0, **new_values)
708724

@@ -767,7 +783,7 @@ def test__validate_name_field(two_name_class_list: ClassList, input_dict: dict[s
767783
"input_dict",
768784
[
769785
({"name": "Alice"}),
770-
({"name": "ALICE"}),
786+
({"name": "BOB"}),
771787
({"name": "alice"}),
772788
],
773789
)
@@ -777,18 +793,18 @@ def test__validate_name_field_not_unique(two_name_class_list: ClassList, input_d
777793
with pytest.raises(
778794
ValueError,
779795
match=f"Input arguments contain the {two_name_class_list.name_field} "
780-
f"'{input_dict[two_name_class_list.name_field]}', "
781-
f"which is already specified in the ClassList",
796+
f"'{input_dict[two_name_class_list.name_field]}', which is already specified at index "
797+
f"{two_name_class_list.index(input_dict['name'].lower())} of the ClassList",
782798
):
783799
two_name_class_list._validate_name_field(input_dict)
784800

785801

786802
@pytest.mark.parametrize(
787803
"input_list",
788804
[
789-
([InputAttributes(name="Alice"), InputAttributes(name="Bob")]),
790-
([InputAttributes(surname="Morgan"), InputAttributes(surname="Terwilliger")]),
791-
([InputAttributes(name="Alice", surname="Morgan"), InputAttributes(surname="Terwilliger")]),
805+
([InputAttributes(name="Eve"), InputAttributes(name="Gareth")]),
806+
([InputAttributes(surname="Polastri"), InputAttributes(surname="Mallory")]),
807+
([InputAttributes(name="Eve", surname="Polastri"), InputAttributes(surname="Mallory")]),
792808
([InputAttributes()]),
793809
([]),
794810
],
@@ -801,20 +817,59 @@ def test__check_unique_name_fields(two_name_class_list: ClassList, input_list: I
801817

802818

803819
@pytest.mark.parametrize(
804-
"input_list",
820+
["input_list", "error_message"],
805821
[
806-
([InputAttributes(name="Alice"), InputAttributes(name="Alice")]),
807-
([InputAttributes(name="Alice"), InputAttributes(name="ALICE")]),
808-
([InputAttributes(name="Alice"), InputAttributes(name="alice")]),
822+
(
823+
[InputAttributes(name="Alice"), InputAttributes(name="Bob")],
824+
(
825+
" 'alice' is shared between item 0 of the existing ClassList, and item 0 of the input list\n"
826+
" 'bob' is shared between item 1 of the existing ClassList, and item 1 of the input list"
827+
),
828+
),
829+
(
830+
[InputAttributes(name="Alice"), InputAttributes(name="Alice")],
831+
" 'alice' is shared between item 0 of the existing ClassList, and items 0 and 1 of the input list",
832+
),
833+
(
834+
[InputAttributes(name="Alice"), InputAttributes(name="ALICE")],
835+
" 'alice' is shared between item 0 of the existing ClassList, and items 0 and 1 of the input list",
836+
),
837+
(
838+
[InputAttributes(name="Alice"), InputAttributes(name="alice")],
839+
" 'alice' is shared between item 0 of the existing ClassList, and items 0 and 1 of the input list",
840+
),
841+
(
842+
[InputAttributes(name="Eve"), InputAttributes(name="Eve")],
843+
" 'eve' is shared between items 0 and 1 of the input list",
844+
),
845+
(
846+
[
847+
InputAttributes(name="Bob"),
848+
InputAttributes(name="Alice"),
849+
InputAttributes(name="Eve"),
850+
InputAttributes(name="Alice"),
851+
InputAttributes(name="Eve"),
852+
InputAttributes(name="Alice"),
853+
],
854+
(
855+
" 'bob' is shared between item 1 of the existing ClassList, and item 0 of the input list\n"
856+
" 'alice' is shared between item 0 of the existing ClassList,"
857+
" and items 1, 3 and 5 of the input list\n"
858+
" 'eve' is shared between items 2 and 4 of the input list"
859+
),
860+
),
809861
],
810862
)
811-
def test__check_unique_name_fields_not_unique(two_name_class_list: ClassList, input_list: Iterable) -> None:
863+
def test__check_unique_name_fields_not_unique(
864+
two_name_class_list: ClassList, input_list: Sequence, error_message: str
865+
) -> None:
812866
"""We should raise a ValueError if an input list contains multiple objects with (case-insensitive) matching
813867
name_field values defined.
814868
"""
815869
with pytest.raises(
816870
ValueError,
817-
match=f"Input list contains objects with the same value of the " f"{two_name_class_list.name_field} attribute",
871+
match=f"The value of the '{two_name_class_list.name_field}' attribute must be unique for each item in the "
872+
f"ClassList:\n{error_message}",
818873
):
819874
two_name_class_list._check_unique_name_fields(input_list)
820875

@@ -837,12 +892,15 @@ def test__check_classes(input_list: Iterable) -> None:
837892
([InputAttributes(name="Alice"), dict(name="Bob")]),
838893
],
839894
)
840-
def test__check_classes_different_classes(input_list: Iterable) -> None:
895+
def test__check_classes_different_classes(input_list: Sequence) -> None:
841896
"""We should raise a ValueError if an input list contains objects of different types."""
842897
class_list = ClassList([InputAttributes()])
843898
with pytest.raises(
844899
ValueError,
845-
match=(f"Input list contains elements of type other " f"than '{class_list._class_handle.__name__}'"),
900+
match=(
901+
f"This ClassList only supports elements of type {class_list._class_handle.__name__}. "
902+
f"In the input list:\n index 1 is of type {type(input_list[1]).__name__}"
903+
),
846904
):
847905
class_list._check_classes(input_list)
848906

0 commit comments

Comments
 (0)