-
Notifications
You must be signed in to change notification settings - Fork 4
Adds "ClassList" for use as table-like collection of objects #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
4830cc0
Adds "classList.py" for listing pydantic models
DrPaulSharp e5cceec
Updates "classlist.py" to follow pep8
DrPaulSharp 85372a1
Adds tests "test_classlist.py"
DrPaulSharp 81614ef
Added to docstrings in "classlist.py"
DrPaulSharp c73315e
Added fixtures and updates to "test_classlist.py"
DrPaulSharp 2f334a6
Adds code to print a Classlist as a table.
DrPaulSharp f93b501
Adds __repr__ method to ClassList, alongside tests
DrPaulSharp 92a731e
Moved test helper classes to "tests/utils.py"
DrPaulSharp f389ee4
Moved "runTests.yml" into workflows directory
DrPaulSharp f0ac42f
Rewrites ClassList to allow the user to specify a name_field, which m…
DrPaulSharp f5d28fd
Rewrites test_classlist.py to use the InputAttributes class
DrPaulSharp 80ee3fc
Adds code to ClassList to enable _class_handle to be set by other rou…
DrPaulSharp 9c099b2
Updates "test_classlist.py" to account for flexible name_field and em…
DrPaulSharp 53ef1cc
Updates ClassList __repr__() to properly represent an empty table and…
DrPaulSharp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| """The classlist module. Contains the ClassList class, which defines a list containing instances of a particular class. | ||
| """ | ||
|
|
||
| import collections | ||
| from collections.abc import Iterable, Sequence | ||
| import contextlib | ||
| import tabulate | ||
| from typing import Any, Union | ||
| import warnings | ||
|
|
||
|
|
||
| class ClassList(collections.UserList): | ||
| """List of instances of a particular class. | ||
|
|
||
| This class subclasses collections.UserList to construct a list intended to store ONLY instances of a particular | ||
| class, given on initialisation. Any attempt to introduce an object of a different type will raise a ValueError. | ||
| The class must be able to accept attribute values using keyword arguments. In addition, if the class has the | ||
| attribute given in the ClassList's "name_field" attribute (the default is "name"), the ClassList will ensure that | ||
| all objects within the ClassList have unique values for that attribute. It is then possible to use this attribute | ||
| of an object in the .remove(), .count(), and .index() routines in place of the full object. Due to the requirement | ||
| of unique values of the name_field attribute, the multiplication operators __mul__, __rmul__, and __imul__ have | ||
| been disabled, since they cannot allow for unique attribute values by definition. | ||
|
|
||
| We extend the UserList class to enable objects to be added and modified using just the keyword arguments, enable | ||
| the object name_field attribute to be used in place of the full object, and ensure all elements are of the | ||
| specified type, with unique name_field attributes defined. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| init_list : Sequence [object] or object, optional | ||
| An instance, or list of instance(s), of the class to be used in this ClassList. | ||
| name_field : str, optional | ||
| The field used to define unique objects in the ClassList (default is "name"). | ||
| """ | ||
| def __init__(self, init_list: Union[Sequence[object], object] = None, name_field: str = "name") -> None: | ||
| self.name_field = name_field | ||
|
|
||
| # Set input as list if necessary | ||
| if init_list and not (isinstance(init_list, Sequence) and not isinstance(init_list, str)): | ||
| init_list = [init_list] | ||
|
|
||
| # Set class to be used for this instance of the ClassList, checking that all elements of the input list are of | ||
| # the same type and have unique values of the specified name_field | ||
| if init_list: | ||
| self._class_handle = type(init_list[0]) | ||
| self._check_classes(init_list) | ||
| self._check_unique_name_fields(init_list) | ||
|
|
||
| super().__init__(init_list) | ||
|
|
||
| def __repr__(self): | ||
| try: | ||
| [model.__dict__ for model in self.data] | ||
| except AttributeError: | ||
| output = repr(self.data) | ||
| else: | ||
| if any(model.__dict__ for model in self.data): | ||
| table = [model.__dict__ for model in self.data] | ||
| output = tabulate.tabulate(table, headers='keys', showindex=True) | ||
| else: | ||
| output = repr(self.data) | ||
| return output | ||
|
|
||
| def __setitem__(self, index: int, set_dict: dict[str, Any]) -> None: | ||
| """Assign the values of an existing object's attributes using a dictionary.""" | ||
| self._validate_name_field(set_dict) | ||
| for key, value in set_dict.items(): | ||
| setattr(self.data[index], key, value) | ||
|
|
||
| def __iadd__(self, other: Sequence[object]) -> 'ClassList': | ||
| """Define in-place addition using the "+=" operator.""" | ||
| if not hasattr(self, '_class_handle'): | ||
| self._class_handle = type(other[0]) | ||
| self._check_classes(self + other) | ||
| self._check_unique_name_fields(self + other) | ||
| super().__iadd__(other) | ||
| return self | ||
|
|
||
| def __mul__(self, n: int) -> None: | ||
| """Define multiplication using the "*" operator.""" | ||
| raise TypeError(f"unsupported operand type(s) for *: '{self.__class__.__name__}' and '{n.__class__.__name__}'") | ||
|
|
||
| def __rmul__(self, n: int) -> None: | ||
| """Define multiplication using the "*" operator.""" | ||
| raise TypeError(f"unsupported operand type(s) for *: '{n.__class__.__name__}' and '{self.__class__.__name__}'") | ||
DrPaulSharp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def __imul__(self, n: int) -> None: | ||
| """Define in-place multiplication using the "*=" operator.""" | ||
| raise TypeError(f"unsupported operand type(s) for *=: '{self.__class__.__name__}' and '{n.__class__.__name__}'") | ||
|
|
||
| def append(self, obj: object = None, **kwargs) -> None: | ||
| """Append a new object to the ClassList using either the object itself, or keyword arguments to set attribute | ||
| values. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| obj : object, optional | ||
| An instance of the class specified by self._class_handle. | ||
| **kwargs : dict[str, Any], optional | ||
| The input keyword arguments for a new object in the ClassList. | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| Raised if the input arguments contain a name_field value already defined in the ClassList. | ||
|
|
||
| Warnings | ||
| -------- | ||
| SyntaxWarning | ||
| Raised if the input arguments contain BOTH an object and keyword arguments. In this situation the object is | ||
| appended to the ClassList and the keyword arguments are discarded. | ||
| """ | ||
| if obj and kwargs: | ||
| warnings.warn('ClassList.append() called with both an object and keyword arguments. ' | ||
| 'The keyword arguments will be ignored.', SyntaxWarning) | ||
| if obj: | ||
| if not hasattr(self, '_class_handle'): | ||
| self._class_handle = type(obj) | ||
| self._check_classes(self + [obj]) | ||
| self._check_unique_name_fields(self + [obj]) | ||
| self.data.append(obj) | ||
| else: | ||
| if not hasattr(self, '_class_handle'): | ||
| raise TypeError('ClassList.append() called with keyword arguments for a ClassList without a class ' | ||
| 'defined. Call ClassList.append() with an object to define the class.') | ||
| self._validate_name_field(kwargs) | ||
| self.data.append(self._class_handle(**kwargs)) | ||
|
|
||
| def insert(self, index: int, obj: object = None, **kwargs) -> None: | ||
| """Insert a new object into the ClassList at a given index using either the object itself, or keyword arguments | ||
| to set attribute values. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| index: int | ||
| The index at which to insert a new object in the ClassList. | ||
| obj : object, optional | ||
| An instance of the class specified by self._class_handle. | ||
| **kwargs : dict[str, Any], optional | ||
| The input keyword arguments for a new object in the ClassList. | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| Raised if the input arguments contain a name_field value already defined in the ClassList. | ||
|
|
||
| Warnings | ||
| -------- | ||
| SyntaxWarning | ||
| Raised if the input arguments contain both an object and keyword arguments. In this situation the object is | ||
| inserted into the ClassList and the keyword arguments are discarded. | ||
| """ | ||
| if obj and kwargs: | ||
| warnings.warn('ClassList.insert() called with both object and keyword arguments. ' | ||
| 'The keyword arguments will be ignored.', SyntaxWarning) | ||
| if obj: | ||
| if not hasattr(self, '_class_handle'): | ||
| self._class_handle = type(obj) | ||
| self._check_classes(self + [obj]) | ||
| self._check_unique_name_fields(self + [obj]) | ||
| self.data.insert(index, obj) | ||
| else: | ||
| if not hasattr(self, '_class_handle'): | ||
| raise TypeError('ClassList.insert() called with keyword arguments for a ClassList without a class ' | ||
| 'defined. Call ClassList.insert() with an object to define the class.') | ||
| self._validate_name_field(kwargs) | ||
| self.data.insert(index, self._class_handle(**kwargs)) | ||
|
|
||
| def remove(self, item: Union[object, str]) -> None: | ||
| """Remove an object from the ClassList using either the object itself or its name_field value.""" | ||
| item = self._get_item_from_name_field(item) | ||
| self.data.remove(item) | ||
|
|
||
| def count(self, item: Union[object, str]) -> int: | ||
| """Return the number of times an object appears in the ClassList using either the object itself or its | ||
| name_field value.""" | ||
| item = self._get_item_from_name_field(item) | ||
| return self.data.count(item) | ||
|
|
||
| def index(self, item: Union[object, str], *args) -> int: | ||
| """Return the index of a particular object in the ClassList using either the object itself or its | ||
| name_field value.""" | ||
| item = self._get_item_from_name_field(item) | ||
| return self.data.index(item, *args) | ||
|
|
||
| def extend(self, other: Sequence[object]) -> None: | ||
| """Extend the ClassList by adding another sequence.""" | ||
| if not hasattr(self, '_class_handle'): | ||
| self._class_handle = type(other[0]) | ||
| self._check_classes(self + other) | ||
| self._check_unique_name_fields(self + other) | ||
| self.data.extend(other) | ||
|
|
||
| def get_names(self) -> list[str]: | ||
DrPaulSharp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """Return a list of the values of the name_field attribute of each class object in the list. | ||
|
|
||
| Returns | ||
| ------- | ||
| names : list [str] | ||
| The value of the name_field attribute of each object in the ClassList. | ||
| """ | ||
| return [getattr(model, self.name_field) for model in self.data if hasattr(model, self.name_field)] | ||
|
|
||
| def _validate_name_field(self, input_args: dict[str, Any]) -> None: | ||
| """Raise a ValueError if the name_field attribute is passed as an object parameter, and its value is already | ||
| used within the ClassList. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| input_args : dict [str, Any] | ||
| The input keyword arguments for a new object in the ClassList. | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| Raised if the input arguments contain a name_field value already defined in the ClassList. | ||
| """ | ||
| names = self.get_names() | ||
| with contextlib.suppress(KeyError): | ||
| if input_args[self.name_field] in names: | ||
| raise ValueError(f"Input arguments contain the {self.name_field} '{input_args[self.name_field]}', " | ||
| f"which is already specified in the ClassList") | ||
|
|
||
| def _check_unique_name_fields(self, input_list: Iterable[object]) -> None: | ||
| """Raise a ValueError if any value of the name_field attribute is used more than once in a list of class | ||
| objects. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| input_list : iterable | ||
| An iterable of instances of the class given in self._class_handle. | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| Raised if the input list defines more than one object with the same value of name_field. | ||
| """ | ||
| names = [getattr(model, self.name_field) for model in input_list if hasattr(model, self.name_field)] | ||
| if len(set(names)) != len(names): | ||
| raise ValueError(f"Input list contains objects with the same value of the {self.name_field} attribute") | ||
|
|
||
| def _check_classes(self, input_list: Iterable[object]) -> None: | ||
| """Raise a ValueError if any object in a list of objects is not of the type specified by self._class_handle. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| input_list : iterable | ||
| A list of instances of the class given in self._class_handle. | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| Raised if the input list defines objects of different types. | ||
| """ | ||
| if not (all(isinstance(element, self._class_handle) for element in input_list)): | ||
| raise ValueError(f"Input list contains elements of type other than '{self._class_handle}'") | ||
|
|
||
| def _get_item_from_name_field(self, value: Union[object, str]) -> Union[object, str]: | ||
| """Return the object with the given value of the name_field attribute in the ClassList. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| value : object or str | ||
| Either an object in the ClassList, or the value of the name_field attribute of an object in the ClassList. | ||
|
|
||
| Returns | ||
| ------- | ||
| instance : object or str | ||
| Either the object with the value of the name_field attribute given by value, or the input value if an | ||
| object with that value of the name_field attribute cannot be found. | ||
| """ | ||
| return next((model for model in self.data if getattr(model, self.name_field) == value), value) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| pydantic >= 2.0.3 | ||
| pytest >= 7.4.0 | ||
| pytest-cov >= 4.1.0 | ||
| tabulate >= 0.9.0 |
Empty file.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.