|
| 1 | +"""The classlist module. Contains the ClassList class, which defines a list containing instances of a particular class. |
| 2 | +""" |
| 3 | + |
| 4 | +import collections |
| 5 | +from collections.abc import Iterable, Sequence |
| 6 | +import contextlib |
| 7 | +import tabulate |
| 8 | +from typing import Any, Union |
| 9 | +import warnings |
| 10 | + |
| 11 | + |
| 12 | +class ClassList(collections.UserList): |
| 13 | + """List of instances of a particular class. |
| 14 | +
|
| 15 | + This class subclasses collections.UserList to construct a list intended to store ONLY instances of a particular |
| 16 | + class, given on initialisation. Any attempt to introduce an object of a different type will raise a ValueError. |
| 17 | + The class must be able to accept attribute values using keyword arguments. In addition, if the class has the |
| 18 | + attribute given in the ClassList's "name_field" attribute (the default is "name"), the ClassList will ensure that |
| 19 | + all objects within the ClassList have unique values for that attribute. It is then possible to use this attribute |
| 20 | + of an object in the .remove(), .count(), and .index() routines in place of the full object. Due to the requirement |
| 21 | + of unique values of the name_field attribute, the multiplication operators __mul__, __rmul__, and __imul__ have |
| 22 | + been disabled, since they cannot allow for unique attribute values by definition. |
| 23 | +
|
| 24 | + We extend the UserList class to enable objects to be added and modified using just the keyword arguments, enable |
| 25 | + the object name_field attribute to be used in place of the full object, and ensure all elements are of the |
| 26 | + specified type, with unique name_field attributes defined. |
| 27 | +
|
| 28 | + Parameters |
| 29 | + ---------- |
| 30 | + init_list : Sequence [object] or object, optional |
| 31 | + An instance, or list of instance(s), of the class to be used in this ClassList. |
| 32 | + name_field : str, optional |
| 33 | + The field used to define unique objects in the ClassList (default is "name"). |
| 34 | + """ |
| 35 | + def __init__(self, init_list: Union[Sequence[object], object] = None, name_field: str = "name") -> None: |
| 36 | + self.name_field = name_field |
| 37 | + |
| 38 | + # Set input as list if necessary |
| 39 | + if init_list and not (isinstance(init_list, Sequence) and not isinstance(init_list, str)): |
| 40 | + init_list = [init_list] |
| 41 | + |
| 42 | + # Set class to be used for this instance of the ClassList, checking that all elements of the input list are of |
| 43 | + # the same type and have unique values of the specified name_field |
| 44 | + if init_list: |
| 45 | + self._class_handle = type(init_list[0]) |
| 46 | + self._check_classes(init_list) |
| 47 | + self._check_unique_name_fields(init_list) |
| 48 | + |
| 49 | + super().__init__(init_list) |
| 50 | + |
| 51 | + def __repr__(self): |
| 52 | + try: |
| 53 | + [model.__dict__ for model in self.data] |
| 54 | + except AttributeError: |
| 55 | + output = repr(self.data) |
| 56 | + else: |
| 57 | + if any(model.__dict__ for model in self.data): |
| 58 | + table = [model.__dict__ for model in self.data] |
| 59 | + output = tabulate.tabulate(table, headers='keys', showindex=True) |
| 60 | + else: |
| 61 | + output = repr(self.data) |
| 62 | + return output |
| 63 | + |
| 64 | + def __setitem__(self, index: int, set_dict: dict[str, Any]) -> None: |
| 65 | + """Assign the values of an existing object's attributes using a dictionary.""" |
| 66 | + self._validate_name_field(set_dict) |
| 67 | + for key, value in set_dict.items(): |
| 68 | + setattr(self.data[index], key, value) |
| 69 | + |
| 70 | + def __iadd__(self, other: Sequence[object]) -> 'ClassList': |
| 71 | + """Define in-place addition using the "+=" operator.""" |
| 72 | + if not hasattr(self, '_class_handle'): |
| 73 | + self._class_handle = type(other[0]) |
| 74 | + self._check_classes(self + other) |
| 75 | + self._check_unique_name_fields(self + other) |
| 76 | + super().__iadd__(other) |
| 77 | + return self |
| 78 | + |
| 79 | + def __mul__(self, n: int) -> None: |
| 80 | + """Define multiplication using the "*" operator.""" |
| 81 | + raise TypeError(f"unsupported operand type(s) for *: '{self.__class__.__name__}' and '{n.__class__.__name__}'") |
| 82 | + |
| 83 | + def __rmul__(self, n: int) -> None: |
| 84 | + """Define multiplication using the "*" operator.""" |
| 85 | + raise TypeError(f"unsupported operand type(s) for *: '{n.__class__.__name__}' and '{self.__class__.__name__}'") |
| 86 | + |
| 87 | + def __imul__(self, n: int) -> None: |
| 88 | + """Define in-place multiplication using the "*=" operator.""" |
| 89 | + raise TypeError(f"unsupported operand type(s) for *=: '{self.__class__.__name__}' and '{n.__class__.__name__}'") |
| 90 | + |
| 91 | + def append(self, obj: object = None, **kwargs) -> None: |
| 92 | + """Append a new object to the ClassList using either the object itself, or keyword arguments to set attribute |
| 93 | + values. |
| 94 | +
|
| 95 | + Parameters |
| 96 | + ---------- |
| 97 | + obj : object, optional |
| 98 | + An instance of the class specified by self._class_handle. |
| 99 | + **kwargs : dict[str, Any], optional |
| 100 | + The input keyword arguments for a new object in the ClassList. |
| 101 | +
|
| 102 | + Raises |
| 103 | + ------ |
| 104 | + ValueError |
| 105 | + Raised if the input arguments contain a name_field value already defined in the ClassList. |
| 106 | +
|
| 107 | + Warnings |
| 108 | + -------- |
| 109 | + SyntaxWarning |
| 110 | + Raised if the input arguments contain BOTH an object and keyword arguments. In this situation the object is |
| 111 | + appended to the ClassList and the keyword arguments are discarded. |
| 112 | + """ |
| 113 | + if obj and kwargs: |
| 114 | + warnings.warn('ClassList.append() called with both an object and keyword arguments. ' |
| 115 | + 'The keyword arguments will be ignored.', SyntaxWarning) |
| 116 | + if obj: |
| 117 | + if not hasattr(self, '_class_handle'): |
| 118 | + self._class_handle = type(obj) |
| 119 | + self._check_classes(self + [obj]) |
| 120 | + self._check_unique_name_fields(self + [obj]) |
| 121 | + self.data.append(obj) |
| 122 | + else: |
| 123 | + if not hasattr(self, '_class_handle'): |
| 124 | + raise TypeError('ClassList.append() called with keyword arguments for a ClassList without a class ' |
| 125 | + 'defined. Call ClassList.append() with an object to define the class.') |
| 126 | + self._validate_name_field(kwargs) |
| 127 | + self.data.append(self._class_handle(**kwargs)) |
| 128 | + |
| 129 | + def insert(self, index: int, obj: object = None, **kwargs) -> None: |
| 130 | + """Insert a new object into the ClassList at a given index using either the object itself, or keyword arguments |
| 131 | + to set attribute values. |
| 132 | +
|
| 133 | + Parameters |
| 134 | + ---------- |
| 135 | + index: int |
| 136 | + The index at which to insert a new object in the ClassList. |
| 137 | + obj : object, optional |
| 138 | + An instance of the class specified by self._class_handle. |
| 139 | + **kwargs : dict[str, Any], optional |
| 140 | + The input keyword arguments for a new object in the ClassList. |
| 141 | +
|
| 142 | + Raises |
| 143 | + ------ |
| 144 | + ValueError |
| 145 | + Raised if the input arguments contain a name_field value already defined in the ClassList. |
| 146 | +
|
| 147 | + Warnings |
| 148 | + -------- |
| 149 | + SyntaxWarning |
| 150 | + Raised if the input arguments contain both an object and keyword arguments. In this situation the object is |
| 151 | + inserted into the ClassList and the keyword arguments are discarded. |
| 152 | + """ |
| 153 | + if obj and kwargs: |
| 154 | + warnings.warn('ClassList.insert() called with both object and keyword arguments. ' |
| 155 | + 'The keyword arguments will be ignored.', SyntaxWarning) |
| 156 | + if obj: |
| 157 | + if not hasattr(self, '_class_handle'): |
| 158 | + self._class_handle = type(obj) |
| 159 | + self._check_classes(self + [obj]) |
| 160 | + self._check_unique_name_fields(self + [obj]) |
| 161 | + self.data.insert(index, obj) |
| 162 | + else: |
| 163 | + if not hasattr(self, '_class_handle'): |
| 164 | + raise TypeError('ClassList.insert() called with keyword arguments for a ClassList without a class ' |
| 165 | + 'defined. Call ClassList.insert() with an object to define the class.') |
| 166 | + self._validate_name_field(kwargs) |
| 167 | + self.data.insert(index, self._class_handle(**kwargs)) |
| 168 | + |
| 169 | + def remove(self, item: Union[object, str]) -> None: |
| 170 | + """Remove an object from the ClassList using either the object itself or its name_field value.""" |
| 171 | + item = self._get_item_from_name_field(item) |
| 172 | + self.data.remove(item) |
| 173 | + |
| 174 | + def count(self, item: Union[object, str]) -> int: |
| 175 | + """Return the number of times an object appears in the ClassList using either the object itself or its |
| 176 | + name_field value.""" |
| 177 | + item = self._get_item_from_name_field(item) |
| 178 | + return self.data.count(item) |
| 179 | + |
| 180 | + def index(self, item: Union[object, str], *args) -> int: |
| 181 | + """Return the index of a particular object in the ClassList using either the object itself or its |
| 182 | + name_field value.""" |
| 183 | + item = self._get_item_from_name_field(item) |
| 184 | + return self.data.index(item, *args) |
| 185 | + |
| 186 | + def extend(self, other: Sequence[object]) -> None: |
| 187 | + """Extend the ClassList by adding another sequence.""" |
| 188 | + if not hasattr(self, '_class_handle'): |
| 189 | + self._class_handle = type(other[0]) |
| 190 | + self._check_classes(self + other) |
| 191 | + self._check_unique_name_fields(self + other) |
| 192 | + self.data.extend(other) |
| 193 | + |
| 194 | + def get_names(self) -> list[str]: |
| 195 | + """Return a list of the values of the name_field attribute of each class object in the list. |
| 196 | +
|
| 197 | + Returns |
| 198 | + ------- |
| 199 | + names : list [str] |
| 200 | + The value of the name_field attribute of each object in the ClassList. |
| 201 | + """ |
| 202 | + return [getattr(model, self.name_field) for model in self.data if hasattr(model, self.name_field)] |
| 203 | + |
| 204 | + def _validate_name_field(self, input_args: dict[str, Any]) -> None: |
| 205 | + """Raise a ValueError if the name_field attribute is passed as an object parameter, and its value is already |
| 206 | + used within the ClassList. |
| 207 | +
|
| 208 | + Parameters |
| 209 | + ---------- |
| 210 | + input_args : dict [str, Any] |
| 211 | + The input keyword arguments for a new object in the ClassList. |
| 212 | +
|
| 213 | + Raises |
| 214 | + ------ |
| 215 | + ValueError |
| 216 | + Raised if the input arguments contain a name_field value already defined in the ClassList. |
| 217 | + """ |
| 218 | + names = self.get_names() |
| 219 | + with contextlib.suppress(KeyError): |
| 220 | + if input_args[self.name_field] in names: |
| 221 | + raise ValueError(f"Input arguments contain the {self.name_field} '{input_args[self.name_field]}', " |
| 222 | + f"which is already specified in the ClassList") |
| 223 | + |
| 224 | + def _check_unique_name_fields(self, input_list: Iterable[object]) -> None: |
| 225 | + """Raise a ValueError if any value of the name_field attribute is used more than once in a list of class |
| 226 | + objects. |
| 227 | +
|
| 228 | + Parameters |
| 229 | + ---------- |
| 230 | + input_list : iterable |
| 231 | + An iterable of instances of the class given in self._class_handle. |
| 232 | +
|
| 233 | + Raises |
| 234 | + ------ |
| 235 | + ValueError |
| 236 | + Raised if the input list defines more than one object with the same value of name_field. |
| 237 | + """ |
| 238 | + names = [getattr(model, self.name_field) for model in input_list if hasattr(model, self.name_field)] |
| 239 | + if len(set(names)) != len(names): |
| 240 | + raise ValueError(f"Input list contains objects with the same value of the {self.name_field} attribute") |
| 241 | + |
| 242 | + def _check_classes(self, input_list: Iterable[object]) -> None: |
| 243 | + """Raise a ValueError if any object in a list of objects is not of the type specified by self._class_handle. |
| 244 | +
|
| 245 | + Parameters |
| 246 | + ---------- |
| 247 | + input_list : iterable |
| 248 | + A list of instances of the class given in self._class_handle. |
| 249 | +
|
| 250 | + Raises |
| 251 | + ------ |
| 252 | + ValueError |
| 253 | + Raised if the input list defines objects of different types. |
| 254 | + """ |
| 255 | + if not (all(isinstance(element, self._class_handle) for element in input_list)): |
| 256 | + raise ValueError(f"Input list contains elements of type other than '{self._class_handle}'") |
| 257 | + |
| 258 | + def _get_item_from_name_field(self, value: Union[object, str]) -> Union[object, str]: |
| 259 | + """Return the object with the given value of the name_field attribute in the ClassList. |
| 260 | +
|
| 261 | + Parameters |
| 262 | + ---------- |
| 263 | + value : object or str |
| 264 | + Either an object in the ClassList, or the value of the name_field attribute of an object in the ClassList. |
| 265 | +
|
| 266 | + Returns |
| 267 | + ------- |
| 268 | + instance : object or str |
| 269 | + Either the object with the value of the name_field attribute given by value, or the input value if an |
| 270 | + object with that value of the name_field attribute cannot be found. |
| 271 | + """ |
| 272 | + return next((model for model in self.data if getattr(model, self.name_field) == value), value) |
0 commit comments