Skip to content

Commit 0b3565f

Browse files
authored
Adds "ClassList" for use as table-like collection of objects (#4)
* Adds "classList.py" for listing pydantic models * Updates "classlist.py" to follow pep8 * Adds tests "test_classlist.py" * Added to docstrings in "classlist.py" * Added fixtures and updates to "test_classlist.py" * Adds code to print a Classlist as a table. * Adds __repr__ method to ClassList, alongside tests * Moved test helper classes to "tests/utils.py" * Moved "runTests.yml" into workflows directory * Rewrites ClassList to allow the user to specify a name_field, which must have unique values. * Rewrites test_classlist.py to use the InputAttributes class * Adds code to ClassList to enable _class_handle to be set by other routines * Updates "test_classlist.py" to account for flexible name_field and empty ClassLists * Updates ClassList __repr__() to properly represent an empty table and list
1 parent 0407dd1 commit 0b3565f

File tree

6 files changed

+828
-0
lines changed

6 files changed

+828
-0
lines changed

RAT/classlist.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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)

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
pydantic >= 2.0.3
2+
pytest >= 7.4.0
3+
pytest-cov >= 4.1.0
4+
tabulate >= 0.9.0

tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)