from typing import Dict, Iterable, Optional, Tuple, Union
from . import Axis, AxisPoint, Category, Coordinate, Point
class AxesDescriptor:
"""A descriptor for the axes attribute on the RiskMatrix.
Raises:
KeyError: When an axis name is not found in the axes.
AttributeError: Overriding or deleting the axes attribute is not allowed.
"""
def __init__(self):
self._axes = []
def __get__(self, instance, owner) -> Tuple[Axis, ...]:
return tuple(self._axes)
def __getitem__(self, val: Union[str, int]) -> Axis:
if isinstance(val, str):
for axis in self._axes:
if axis.name == val:
return axis
raise KeyError(f"No axis named {val}.")
if isinstance(val, int):
return self._axes[val]
raise TypeError(f"Axes indices must be integers, or strings, not {val}")
def __iter__(self):
self.__i = 0
return self
def __next__(self):
try:
val = self[self.__i]
except IndexError:
raise StopIteration
self.__i += 1
return val
def __set__(self, instance, val):
raise AttributeError("Can't override axes attribute. Add axes individually.")
def __delete__(self, instance):
raise AttributeError("Can't delete axes attribute.")
def add(self, axis: Axis):
self._axes.append(axis)
[docs]class RiskMatrix:
"""The main class to build a risk matrix."""
def __init__(self, name: str) -> None:
self.name = name
self.axes: AxesDescriptor = AxesDescriptor()
self._categories: Dict[int, Category] = {}
self._coordinates: Dict[Coordinate, Category] = {}
# This boolean determines whether it's ok to force Coordinate order if they have a similar value.
# Setting it to False can make ordering Coordinates with equivalent values ambiguous.
self.strict_coordinate_comparison = True
def __repr__(self):
return f"RiskMatrix({self.name}) " + str(self.axes)
def __str__(self):
return self.name
@property
def categories(self) -> Tuple[Category, ...]:
"""Return a tuple of all Categories in the Riskmatrix.
Sorted by value from low to high.
Returns:
Tuple[Category, ...]: A tuple of Categories.
"""
return tuple(sorted(self._categories.values(), key=lambda x: x.value))
@property
def coordinates(self) -> Tuple[Coordinate, ...]:
"""Return a tuple of all Coordinates in the RiskMatrix.
Returns:
Tuple[Coordinate, ...]: Tuple of Coordinates sorted alphabetically.
"""
return tuple(sorted(self._coordinates, key=lambda c: str(c)))
[docs] def add_axis(
self,
axis_name: str,
*,
points: Iterable[Point] = None,
size: int = None,
use_letters: bool = False,
) -> Axis:
"""Add an axis to the risk matrix using a list of axis points.
Alternatively, you can also give a size number to quickly set up an axis.
This is nice if you don't care about the information in the axis points.
Args:
axis_name (str): The name for the axis. E.g. Severity or Probability.
points (Iterable[Tuple], optional): A list of points that make up the axis. Defaults to None.
size (int, optional): A quick way to set up an axis by defining how many points you want. Defaults to None.
use_letters (bool, optional): Option to use letters instead of numbers when specifying size. Defaults to False.
Raises:
ValueError: You have to provide either a list of points or a size. You can't do both.
Returns:
Axis
"""
if points and size:
raise ValueError(
"You should choose between giving a list of points or defining a size."
)
axis = Axis(axis_name, self)
if points:
for point in points:
p = Point(point.code, point.name, point.description)
axis.add_point(p)
elif size:
for code in range(1, size + 1):
if use_letters:
code = self._convert_number_to_letter(code)
axis_point = Point(str(code), "")
axis.add_point(axis_point)
self.axes.add(axis)
return axis
def _convert_number_to_letter(self, number: int):
"""Given a number, return the appropriate letter combination.
e.g.:
1 returns A
26 returns Z
27 returns AA
28 returns AB
etc..
The max return value is ZZ.
Args:
number (int): Number to convert into a letter.
Returns:
str: A letter equivalent to the number.
"""
abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
result = ""
major, minor = divmod(number, 26)
if major == 1 and minor == 0:
return abc[number - 1]
elif major:
result += abc[major - 1]
if minor:
result += abc[minor - 1]
return result
[docs] def add_category(
self, code: str, name: str, color: str, text_color: str, description: str = ""
) -> Category:
"""Add a category to the Riskmatrix.
Categories should be added from low to high.
Args:
code (str): A short code for the category. E.g. 'HIG'
name (str): A full name for the category. E.g. 'High risk'
color (str): A hexadecimal background color code.
text_color (str): A hexadecimal text color code.
description (str, optional): A longer description about what this category means. Defaults to "".
Returns:
Category
"""
category = Category(code, name, color, text_color, description)
category.value = len(self.categories)
self._categories[category.value] = category
return category
[docs] def map_coordinate(
self, category: Category, points: Iterable[AxisPoint]
) -> Coordinate:
"""Map a Category to a Coordinate
Args:
category (Category): An instance of Category.
points (Iterable[AxisPoint]): A collection of AxisPoint that make up a Coordinate.
Returns:
Coordinate
"""
c = Coordinate(points)
if c.matrix is not self:
raise ValueError(
f"This Coordinate {c} does not belong to RiskMatrix {self.name}"
)
self._coordinates[c] = self.categories[category.value]
return c
[docs] def map_coordinates(
self, category: Category, coordinates: Iterable[Iterable[AxisPoint]]
) -> None:
"""Given a Category and a list of AxisPoint collections (each making up a Coordinate), map the Category to
each Coordinate.
Args:
category (Category): A single Category.
coordinates (Iterable[Iterable[AxisPoint]]): A list of AxisPoint iterables that represent Coordinates.
Returns:
None
"""
for coordinate_points in coordinates:
self.map_coordinate(category, coordinate_points)
[docs] def get_category(self, coordinate: Coordinate) -> Category:
"""Give a Coordinate to get a Category if there is a mapping between them.
Args:
coordinate (Coordinate): An instance of Coordinate.
Returns:
Category: An instance of Category.
Exceptions:
KeyError: If the Coordinate couldn't be found, a KeyError is raised.
"""
try:
return self._coordinates[coordinate]
except KeyError as e:
raise KeyError(
f"{coordinate} couldn't be found. Are you sure you mapped it?"
) from e
[docs] def get_coordinate(self, coordinate: str) -> Coordinate:
"""Get the Coordinate for a string code like 'A2'.
Args:
coordinate (str): A string which is the code of the Coordinate. E.g. 'A2'
Returns:
Optional[Coordinate]: A Coordinate if it can be found, or None.
Exceptions:
KeyError: If the Coordinate couldn't be found, a KeyError is raised.
"""
for c in self._coordinates:
if str(c) == coordinate:
return c
raise KeyError(
f"{coordinate} couldn't be found. Here are the coordinates: {self.coordinates}"
)