diff --git a/.gitignore b/.gitignore index a9d2561..7441c44 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,7 @@ ENV/ env.bak/ venv.bak/ graphvenv/ +graphenv/ # Spyder project settings .spyderproject diff --git a/README.md b/README.md index b0613c8..cb9008c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,421 @@ -# graphpy -python graph lib +# Graphpy -# WORK IN PROGRESS +Library for creating and manipulating graphs -WARNING!! This is a barebones graph lib. It has all critical foundations, but is still far from finished and as of now, probably riddled with bugs +## +++ WARNING: Work in Progress +++ + +This is a barebones graph lib. It has all critical foundations, but is still far from finished and as of now, probably riddled with small bugs + +# Structure + +The library models graph objects with nodes and edges, supporting node flags and edge weights, fully decked with logging, warnings and error handling + +Composed of a single **graph.py** file to be imported. It is defined by 6 main classes: Type, Node, Graph, Validator, Builder and Converter. + +## graph.py structure + +
+Click to expand! + +- const MERCILESS + > Toggles merciless mode (default True) + +- const VERBOSE + > Toggles verbose mode (default False) + +- class Type() + > Responsible for type checks + - def is_id(id) + - def is_data(data) + - def is_flag(flag) + - def is_node(node) + - def is_weight(weight) + - def is_nodelist(nodelist) + - def is_edgelist(edgelist) + - def is_adjmatrix(adj_mat) + - def is_adjlist(adj_list) + - def is_adjdict(adj_dict) + +- class Node(data, flag, edges) + > Models nodes + + > All inputs optional + - var data + - var flag + - var edges + +- class Graph(nodes) + > Models graphs + + > Input optional + - var nodes + - def add_edge(source_id, target_id, weight, symmetric) + > weight and symmetric optional + + > symmetric determines whether to add edge symmetrically + - def remove_edge(source_id, target_id, symmetric) + > symmetric optional + + > symmetric determines whether to remove edge symmetrically + - def add_node(data, flag, edges) + > All inputs optional + - def remove_node(id) + - def copy() + +- class Validator() + > Checks structure integrity and validity + - def is_graph(graph) + - def check_node(node, graph, _adding) + > _adding is an internal variable, shouldn't be touched + +- class Builder() + > Models graph constructors + - def adj_matrix(adj_mat, obj_list) + > obj_list optional. Determines node data + - def adj_list(adj_list, obj_list) + > obj_list optional. Determines node data + - def adj_dict(adj_dict, obj_list) + > obj_list optional. Determines node data + - def refactor(graph) + > refactors graph and removes unused ids + +- class Converter() + > Converts graphs to native data types + - def to_adjmatrix(graph, get_nodes) + > get_nodes optional. Determines whether to get data from nodes + - def to_adjlist(graph, get_nodes) + > get_nodes optional. Determines whether to get data from nodes + - def to_adjdict(graph, get_nodes) + > get_nodes optional. Determines whether to get data from nodes + +> Any method or variable not listed above is either supposed to be internal, or a work in progress + +
+ +## Default types +This library does not follow duck typing, it is designed with strong typing in mind. + +> More on it at *Duck typing and MERCILESS toggle* in the *Logs, warnings and error handling* section + +Types supported: + +
+Click to expand! + +```python +idtype = int +datatype = Any +flagtype = Union[int, float, str] +nodetype = Node +weighttype = Union[int, float] +nodelisttype = Dict[cls.idtype, cls.nodetype] +edgelisttype = Dict[cls.idtype, cls.weighttype] + +adjmatrixtype = Union[List[List[Union[cls.weighttype, None]]], + npt.NDArray[npt.NDArray[Union[cls.weighttype, None]]]] + +adjlisttype = Union[List[List[Tuple[cls.idtype, cls.weighttype]]], + npt.NDArray[npt.NDArray[Tuple[cls.idtype, cls.weighttype]]]] + +adjdicttype = Dict[cls.idtype, Union[cls.edgelisttype, None]] +``` + +
+ +# Logs, warnings and error handling + +## Logging + +This lib has a standard **always on** logging feature with negligible impact on performance. + +**Log type** + +
+Click to expand! + +It comes predefined as DEBUG, but may be changed to WARNING or ERROR in code at *line 48* in **graph.py**: + +```python +37 # Sets up log +38 def start_log(): +39 """ +40 Sets up log when import is made +41 """ +42 if not os.path.exists(log_dir): +43 os.mkdir(log_dir) +44 logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', +45 datefmt="%d-%m-%y %H:%M:%S", +46 filename=f"{log_dir}{log_name}", +47 # filename=f"{log_dir}testlog.log", +48 filemode='w', level=logging.DEBUG) +``` + +Example log: + +``` +24-07-22 16:19:55 - INFO - Graph #0 initialized with size 0 +24-07-22 16:19:55 - INFO - Node #0 added to graph #0 +24-07-22 16:19:55 - INFO - Node #1 added to graph #0 +24-07-22 16:19:55 - INFO - Edge (0->1 [5]) added to graph #0 +24-07-22 16:19:55 - INFO - Node #2 added to graph #0 +24-07-22 16:19:55 - INFO - Node #3 added to graph #0 +24-07-22 16:19:55 - INFO - Node #4 added to graph #0 +24-07-22 16:19:55 - INFO - Node #5 added to graph #0 +24-07-22 16:19:55 - INFO - Node #6 added to graph #0 +24-07-22 16:19:55 - INFO - Node #7 added to graph #0 +24-07-22 16:19:55 - INFO - Node #8 added to graph #0 +24-07-22 16:19:55 - INFO - Edge (7->3 [0]) added to graph #0 +24-07-22 16:19:55 - INFO - Node #2 removed from graph #0 +24-07-22 16:19:55 - INFO - Adjacency matrix is valid. Graph is being built +24-07-22 16:19:55 - INFO - Graph #1 initialized with size 3 +24-07-22 16:19:55 - INFO - Node #3 added to graph #1 +24-07-22 16:19:55 - INFO - Adjacency dictionary is valid. Graph is being built +24-07-22 16:19:55 - INFO - Graph #2 initialized with size 4 +24-07-22 16:19:55 - INFO - Edge (7->8 [5]) added to graph #0 +24-07-22 16:19:55 - INFO - Edge (8->7 [5]) added to graph #0 +24-07-22 16:19:55 - INFO - Edge (8->8 [0]) added to graph #0 + +``` + +
+ +**VERBOSE toggle** + +There is an optional VERBOSE toggle where the graphs present state is registered in the log after each operation. + +WARNING: VERBOSE parses the entire graph into an adjacency dictionary after each operation (O(n) in time and O(n²) in space). Not suitable for performance sensitive applications + +
+Click to expand! + +Example: +```python +import graph + +graph.VERBOSE = True +``` +Log output with VERBOSE: +``` +24-07-22 16:40:57 - INFO - Node #8 added to graph #0 +24-07-22 16:40:57 - INFO - {0: {1: 5}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {}, 7: {0: 1, 5: 0}, 8: {}} +24-07-22 16:40:57 - INFO - Edge (7->3 [0]) added to graph #0 +24-07-22 16:40:57 - INFO - {0: {1: 5}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {}, 7: {0: 1, 5: 0, 3: 0}, 8: {}} +24-07-22 16:40:57 - INFO - Node #2 removed from graph #0 +24-07-22 16:40:57 - INFO - {0: {1: 5}, 1: {}, 3: {}, 4: {}, 5: {}, 6: {}, 7: {0: 1, 5: 0, 3: 0}, 8: {}} +24-07-22 16:40:57 - INFO - Adjacency matrix is valid. Graph is being built +24-07-22 16:40:57 - INFO - Graph #1 initialized with size 3 +24-07-22 16:40:57 - INFO - {0: {0: 0, 1: 1, 2: 2}, 1: {1: 4, 2: 5}, 2: {0: 6, 2: 8}} +24-07-22 16:40:57 - INFO - Node #3 added to graph #1 +24-07-22 16:40:57 - INFO - {0: {0: 0, 1: 1, 2: 2}, 1: {1: 4, 2: 5}, 2: {0: 6, 2: 8}, 3: {}} +24-07-22 16:40:57 - INFO - Adjacency dictionary is valid. Graph is being built +``` +
+ + +**Log defaults** + +By default, whenever it is imported, the lib will automatically create a log file in a logs/ folder located in the main file directory. If no folder is found, it will create the folder first. + +
+Click to expand! + +Presets may be changed in **graph.py** at: + +```python +20 # Log configs +21 log_date = str(time.strftime("%d-%m-%y %H:%M:%S")) +22 log_dir = "logs/" +23 log_name = f"graphlog {log_date}.log" +24 print(f"Session log started at {log_dir}{log_name}") + ... +37 # Sets up log +38 def start_log(): +39 """ +40 Sets up log when import is made +41 """ +42 if not os.path.exists(log_dir): +43 os.mkdir(log_dir) +44 logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', +45 datefmt="%d-%m-%y %H:%M:%S", +46 filename=f"{log_dir}{log_name}", +47 # filename=f"{log_dir}testlog.log", +48 filemode='w', level=logging.DEBUG) +``` +
+ +## MERCILESS toggle + +By default, all mistakes are "punished" by raising an error. Even when not critical or relevant. This behaviour can be changed by toggling MERCILESS off. + +**WARNING:** By toggling MERCILESS off, all error handling is turned off, and as such, may present erratic and unexpected behaviour. + +```python +import graph + +graph.MERCILESS = False +``` + +## Duck typing and MERCILESS toggle +This lib is designed to work natively with predefined types, as a way of enforcing certain graph characteristics and ensuring proper behaviours, both from its internal methods and future external algorithms in future releases. It is also planned to be extended to C++ in the future, being designed with it in mind. + +Although not recommended, duck typing can be used by toggling MERCILESS off. Despite so, it has not been tested and will turn off all error handling, and as such, may present erratic and unexpected behaviour. + +Enforced types: +
+Click to expand! + +```python +idtype = int +datatype = Any +flagtype = Union[int, float, str] +nodetype = Node +weighttype = Union[int, float] +nodelisttype = Dict[cls.idtype, cls.nodetype] +edgelisttype = Dict[cls.idtype, cls.weighttype] + +adjmatrixtype = Union[List[List[Union[cls.weighttype, None]]], + npt.NDArray[npt.NDArray[Union[cls.weighttype, None]]]] + +adjlisttype = Union[List[List[Tuple[cls.idtype, cls.weighttype]]], + npt.NDArray[npt.NDArray[Tuple[cls.idtype, cls.weighttype]]]] + +adjdicttype = Dict[cls.idtype, Union[cls.edgelisttype, None]] +``` +
+ +# How to use + +1. Start by importing the library + > Modules can be imported directly from graph + +```python +import graph +``` +2. Instantiate a graph object + > Graph can be jumpstarted with a nodelist, a dictionary {id: Node} + +```python +import graph + +my_graph = graph.Graph() +``` + + * Similarly, you can start with a constructor + + +```python +import graph +from graph import Builder + +adjmat = [[0, 1, 2], + [None, 4, 5], + [6, None, 8]] + +my_graph = Builder.adj_matrix(adjmat) +``` +3. Now operate using its built in functions + +```python +my_graph.add_node(edges={0: 1, 5: 0}) +my_graph.add_node() +my_graph.add_edge(7, 3) +my_graph.remove_node(2) +``` + + +## Example code: + +
+Click to expand! + +```python +import graph as gr +from graph import Graph, Builder, Converter, Type +import numpy as np + +graph = Graph() +# gr.VERBOSE = True +# gr.MERCILESS = False +# node = gf.Node() +graph.add_node() +# graph.add_edge(0, 0, 5) +graph.add_node() +graph.add_edge(0, 1, 5) +for a in range(0, 5): + graph.add_node(data=a, flag='red') + +# node1 = graph.add_node(edges={1: 0}) +# graph.nodes[1] = 5 +graph.add_node(edges={0: 1, 5: 0}) +graph.add_node() + +graph.add_edge(7, 3) +# print("original", Converter.to_adjdict(graph)) +graph.remove_node(2) +print(Converter.to_adjlist(graph)) + +adjmat = [[0, 1, 2], + [None, 4, 5], + [6, None, 8]] + +graph2 = Builder.adj_matrix(adjmat) +graph2.add_node(data=5) +adj_list = Converter.to_adjlist(graph2, get_nodes=True)[0] +adj_list[0].append((9, 3)) + +adj_dict = Converter.to_adjdict(graph2, get_nodes=True)[0] +print(adj_dict) +graph3 = Builder.adj_dict(adj_dict) + +print(Type.is_adjlist(adj_list)) +graph.add_edge(7, 8, 5, symmetric=True) +# gr.MERCILESS = False +# graph.remove_edge(5, 7) +graph.add_edge(8, 8) +graph2.remove_node(2) + +for key, node in graph.nodes.items(): + print(key, node.data, node.edges) +print(graph.size, graph.last_id) +print("---") +# graph = Builder.refactor(graph) +graph.remove_edge(7, 8) # , symmetric=True) + +for key, node in graph.nodes.items(): + print(key, node.data, node.edges) + +print(graph.size, graph.last_id) + +``` + +Output: +``` +>ImportWarning: This library is a work in progress and may work unexpectedly + warnings.warn(warning, ImportWarning) +[[(1, 5)], [], None, [], [], [], [], [(0, 1), (5, 0), (3, 0)], []] +{0: {0: 0, 1: 1, 2: 2}, 1: {1: 4, 2: 5}, 2: {0: 6, 2: 8}, 3: {}} +True +0 None {1: 5} +1 None {} +3 1 {} +4 2 {} +5 3 {} +6 4 {} +7 None {0: 1, 5: 0, 3: 0, 8: 5} +8 None {7: 5, 8: 0} +8 8 +--- +0 None {1: 5} +1 None {} +3 1 {} +4 2 {} +5 3 {} +6 4 {} +7 None {0: 1, 5: 0, 3: 0} +8 None {7: 5, 8: 0} +8 8 +``` +
+ +## License +[GNU AGPLv3](https://choosealicense.com/licenses/agpl-3.0/) diff --git a/graph.py b/graph.py index b620140..abfd8c0 100644 --- a/graph.py +++ b/graph.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Dict, Any, Union, List +from typing import Dict, Any, Union, List, Tuple import logging import warnings import time @@ -11,6 +11,12 @@ import numpy as np import numpy.typing as npt +# General configs +# # Raise error whenever there is a mistake, even if not critical +MERCILESS = True +# # Writes graph on log whenever there is a change +VERBOSE = False + # Log configs log_date = str(time.strftime("%d-%m-%y %H:%M:%S")) log_dir = "logs/" @@ -21,13 +27,65 @@ warnings.simplefilter("always") # Import warning -warning = f" This library is a work in progress and not yet functional" +warning = f" This library is a work in progress and may work unexpectedly" + warnings.warn(warning, ImportWarning) # ============================================================================= +# Sets up log +def start_log(): + """ + Sets up log when import is made + """ + if not os.path.exists(log_dir): + os.mkdir(log_dir) + logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', + datefmt="%d-%m-%y %H:%M:%S", + filename=f"{log_dir}{log_name}", + # filename=f"{log_dir}testlog.log", + filemode='w', level=logging.DEBUG) + + +# Handles errors and warning messages +def error_handler(message, type): + """ + Handles errors and warning messages, logging everything + When MERCILESS == True, raises an error + Args: + message (str): Message to be thrown + type (str): Error type to be thrown + + Raises: + error: Error according to type + """ + if type == 'Runtime': + error = RuntimeError + warning = RuntimeWarning + + if type == 'Type': + error = TypeError + warning = RuntimeWarning + + if type == 'Index': + error = IndexError + warning = RuntimeWarning + + if type == 'Key': + error = KeyError + warning = RuntimeWarning + + warnings.warn(message, warning) + logging.warning(f" <'{type}Error'> {message}") + if MERCILESS: + logging.error(f" <'merciless == True'> Execution stopped") + raise error(message) + ... + # Sets up log when import is made + + def start_log(): if not os.path.exists(log_dir): os.mkdir(log_dir) @@ -36,8 +94,22 @@ def start_log(): filename=f"{log_dir}{log_name}", filemode='w', level=logging.DEBUG) +# Start classes + + +def start_classes(): + """ + Start essential auxiliary classes at import + """ + Type() + Validator() + Node() + Converter() + Builder() # Starts essential auxiliary classes + + def start_classes(): Type() Validator() @@ -45,10 +117,20 @@ def start_classes(): # ============================================================================= +# Type class. Determines lib specific data types. Used in type checks -# Type setting -class Type(): +class Type(): + """ + Type handler + Determines lib specific data types + Used in type checks + Each function checks a different type + Calls error handler whenever an inconsistency is found + + Returns: + Bool: Whether input is of predetermined type + """ @classmethod def __init__(cls): cls.idtype = int @@ -62,105 +144,139 @@ def __init__(cls): cls.adjmatrixtype = Union[List[List[Union[cls.weighttype, None]]], npt.NDArray[npt.NDArray[Union[cls.weighttype, None]]]] - @classmethod + cls.adjlisttype = Union[List[List[Tuple[cls.idtype, cls.weighttype]]], + npt.NDArray[npt.NDArray[Tuple[cls.idtype, cls.weighttype]]]] + + cls.adjdicttype = Dict[cls.idtype, Union[cls.edgelisttype, None]] + + @ classmethod def is_id(cls, id): try: check_type("id", id, cls.idtype) if not id >= 0: - raise + error_handler("Id out of bounds", "Key") + return True except: - logging.error(f" <'TypeError'> Id failed type check") - raise TypeError("Id failed type check") - return True + error_handler("Id failed type check", "Type") + return False - @classmethod + @ classmethod def is_data(cls, data): # equation = check_type("data", data, cls.datatype) return True - @classmethod + @ classmethod def is_flag(cls, flag): try: check_type("flag", flag, cls.flagtype) + return True except: - logging.error(f" <'TypeError'> Flag failed type check") - raise TypeError("Flag failed type check") - return True + error_handler("Flag failed type check", "Type") + return False - @classmethod + @ classmethod def is_node(cls, node): try: check_type("node", node, cls.nodetype) + return True except: - logging.error(f" <'TypeError'> Node failed type check") - raise TypeError("Node failed type check") - return True + error_handler("Node failed type check", "Type") + return False - @classmethod + @ classmethod def is_weight(cls, weight): try: check_type("weight", weight, cls.weighttype) + return True except: - logging.error(f" <'TypeError'> Weight failed type check") - raise TypeError("Weight failed type check") - return True + error_handler("Weight failed type check", "Type") + return False - @classmethod + @ classmethod def is_nodelist(cls, nodelist): try: check_type("nodelist", nodelist, cls.nodelisttype) + for key, val in nodelist.items(): + cls.is_id(key) + cls.is_node(val) + return True except: - logging.error(f" <'TypeError'> Nodelist failed type check") - raise TypeError("Nodelist failed type check") - for key, val in nodelist.items(): - cls.is_id(key) - cls.is_node(val) - - return True + error_handler("nodelist failed type check", "Type") + return False - @classmethod + @ classmethod def is_edgelist(cls, edgelist): try: check_type("edgelist", edgelist, cls.edgelisttype) + for key, weight in edgelist.items(): + cls.is_id(key) + if weight != None: + cls.is_weight(weight) + return True except: - logging.error(f" <'TypeError'> Edgelist failed type check") - raise TypeError("Edgelist failed type check") - for key, weight in edgelist.items(): - cls.is_id(key) - if weight != None: - cls.is_weight(weight) - return True + error_handler("edgelist failed type check", "Type") + return False - @classmethod + @ classmethod def is_adjmatrix(cls, adj_mat): try: check_type("adjmatrix", adj_mat, cls.adjmatrixtype) + mat_n = len(adj_mat) + for line in adj_mat: + if len(line) != mat_n: + error_handler("Adjmatrix not homogeneous", "Index") + return True except: - logging.error(f" <'TypeError'> Adjmatrix failed type check") - raise TypeError("Adjmatrix failed type check") - mat_n = len(adj_mat) - for i, line in enumerate(adj_mat): - if len(line) != mat_n: - logging.error(f" <'IndexError'> Adjmatrix not homogeneous") - raise IndexError("Adjmatrix not homogeneous") - for j, weight in enumerate(line): - if weight != None: - cls.is_weight(weight) - return True + error_handler("adjmatrix failed type check", "Type") + return False + + @ classmethod + def is_adjlist(cls, adj_list): + try: + check_type("adjlist", adj_list, cls.adjlisttype) + return True + except: + error_handler("adjlist failed type check", "Type") + return False + + @ classmethod + def is_adjdict(cls, adj_dict): + try: + check_type("adjdict", adj_dict, cls.adjdicttype) + return True + except: + error_handler("adjdict failed type check", "Type") + return False # ============================================================================= +# Node class. Creates node objects with data, flag and edges class Node(): + """ + Node class + Creates node objects with data, flag and edges + Creates empty edges as an empty dictionary when no edge is called + """ + def __init__(self, data: Type.datatype = None, flag: Type.flagtype = None, edges: Type.edgelisttype = None): + """ + Initializes a node + + Args: + data (Type.datatype, optional): Internal node data. Defaults to None. + flag (Type.flagtype, optional): Node flag. Defaults to None. + edges (Type.edgelisttype, optional): Node edges. Defaults to None. + """ # your object self.data = data # int, float or str. May be used for node markings self.flag = flag # Dict{id : weight} + # Check and attribution necessary due to dictionary particulars if edges == None: edges = {} self.edges = edges @@ -186,7 +302,15 @@ def set_edges(self): # ============================================================================= +# Graph class. Handles graphs and operations on them class Graph(): + """ + Graph class + Handles graphs and operations on them + Keeps object count + Keeps class specific toggles + """ + # Each graph has a specific class id for logging purposes graph_count = 0 @@ -194,217 +318,205 @@ class Graph(): # Checks new graph by default. Can be toggled for performance check_graph_at_initialization = True # Raise exception whenever a mistake is made by default, whether fatal or not - merciless = True - - def __init__(self, root: Type.idtype = None, - nodes: Type.nodelisttype = None, - weighted: bool = False, - directed: bool = False, - reflexive: bool = False, - symmetric: bool = False, - transitive: bool = False): + + def __init__(self, nodes: Type.nodelisttype = None): + """ + Initializes new graph objects and call validators + Calls class specific functions to deal with class attributes + + Args: + nodes (Type.nodelisttype, optional): Dict {id: Node} of nodes. Defaults to None. + """ + + # Dict{id : Node} + # Check and attribution necessary due to dictionary particulars if nodes == None: nodes = {} # Sets graph id self.graph_id = self.set_graph_id() - # Optional. Useful for trees - self.root = root - if root: - warnings.warn(f" To be scrapped for v1.0.0. Unnecessary added complexity", - PendingDeprecationWarning) - # Dict{id : node} + # Dict{id : Node} self.nodes = nodes # Registers last used id self.last_id = self.size - 1 - # Graph characteristics - self.weighted = weighted - self.directed = directed - - # Relation characteristics - self.reflexive = reflexive - # Equivalent to directed - self.symmetric = symmetric - self.transitive = transitive - # Check whether starter graph is valid if self.check_graph_at_initialization: Validator.is_graph(self) logging.info( f" Graph #{self.graph_id} initialized with size {self.size}") + if VERBOSE: + logging.info(str(Converter.to_adjdict(self))) def __len__(self): return len(self.nodes) - @property + @ property def size(self): return self.__len__() - @classmethod + @ classmethod def set_graph_id(cls): cls.graph_count += 1 return cls.graph_count - 1 - # Adds edge main_id -> dest_id with weight when applicable - def add_edge(self, main_id: Type.idtype, - dest_id: Type.idtype, + # Adds edge source_id -> target_id with weight when applicable + def add_edge(self, source_id: Type.idtype, + target_id: Type.idtype, weight: Type.weighttype = 0, symmetric: bool = False): + """ + Adds edge source_id -> target_id with weight when applicable + Adds edge symmetrically (if (a,b) in edges, then (b, a) also in edges)) when toggled + + Args: + source_id (Type.idtype): Edge origin node. Where edge is stored + target_id (Type.idtype): Edge target node. Key on edges in node[source_id] + weight (Type.weighttype, optional): Edge weight. Defaults to 0. + symmetric (bool, optional): Whether edge is to be added symmetrically. Defaults to False. + + Returns: + Bool: Whether edge was added or not + """ try: - Type.is_id(main_id) - Type.is_id(dest_id) + Type.is_id(source_id) + Type.is_id(target_id) Type.is_weight(weight) if not isinstance(symmetric, bool): - logging.error(f" <'TypeError'> Symmetric is not bool") - raise TypeError("Symmetric is not bool") + error_handler("Symmetric is not bool", "Type") - if not (main_id in self.nodes and dest_id in self.nodes): - raise KeyError("Edge id not found") + if not (source_id in self.nodes and target_id in self.nodes): + error_handler("Edge id not found", "Key") - self.nodes[main_id].edges[dest_id] = weight + self.nodes[source_id].edges[target_id] = weight logging.info( - f" Edge ({main_id}->{dest_id} [{weight}]) added to graph #{self.graph_id}") + f" Edge ({source_id}->{target_id} [{weight}]) added to graph #{self.graph_id}") + if VERBOSE: + logging.info(str(Converter.to_adjdict(self))) if symmetric: - self.add_edge(dest_id, main_id, weight) + self.add_edge(target_id, source_id, weight) return True except: - warnings.warn(f" <'KeyError'> Edge not added", RuntimeWarning) - logging.warning(f" <'KeyError'> Edge not added") - if self.merciless: - logging.error(f" <'merciless == True'> Execution stopped") - raise KeyError("Edge's id(s) not in nodes") + error_handler("Edge's id(s) not in nodes", "Key") + return False - def remove_edge(self, main_id: Type.idtype, dest_id: Type.idtype, symmetric: bool = False): + def remove_edge(self, source_id: Type.idtype, target_id: Type.idtype, symmetric: bool = False): + """ + Removes edge source_id -> target_id + Removes edge symmetrically (if (a,b) in edges, then (b, a) also in edges)) when toggled + + Args: + source_id (Type.idtype): Edge origin node. Where edge is stored + target_id (Type.idtype): Edge target node. Key on edges in node[source_id] + symmetric (bool, optional): Whether edge is to be removed symmetrically. Defaults to False. + + Returns: + Bool: Whether edge was removed or not + """ try: - Type.is_id(main_id) - Type.is_id(dest_id) + Type.is_id(source_id) + Type.is_id(target_id) if not isinstance(symmetric, bool): - logging.error(f" <'TypeError'> Symmetric is not bool") - raise TypeError("Symmetric is not bool") + error_handler("Symmetric is not bool", "Type") - if not (main_id in self.nodes and dest_id in self.nodes): - raise KeyError("Edge id not found") + if not (source_id in self.nodes and target_id in self.nodes): + error_handler("Edge id not found", "Key") - popped = False + if symmetric: + self.remove_edge(target_id, source_id) - node = self.nodes[main_id] + node = self.nodes[source_id] if node.edges: - if dest_id in node.edges: - node.edges.pop(dest_id) + if target_id in node.edges: + node.edges.pop(target_id) logging.info( - f" Edge ({main_id}->{dest_id}) removed from graph #{self.graph_id}") - popped = True - - if symmetric: - if self.remove_edge(dest_id, main_id): - popped = True - - if not popped: - raise KeyError("Edge not found") - return True + f" Edge ({source_id}->{target_id}) removed from graph #{self.graph_id}") + if VERBOSE: + logging.info(str(Converter.to_adjdict(self))) + return True + error_handler("Edge not found", "Key") except: - warnings.warn(f" <'KeyError'> Edge not found", RuntimeWarning) - logging.warning(f" <'KeyError'> Edge not found") - if self.merciless: - logging.error(f" <'merciless == True'> Execution stopped") - raise KeyError("Edge not found") + error_handler("Edge not found", "Key") + return False # Adds nodes with data, flag and edges when applicable def add_node(self, data: Type.datatype = None, flag: Type.flagtype = None, edges: Type.edgelist = None): + """ + Adds nodes with data, flag and edges when applicable + + Args: + data (Type.datatype, optional): Node data. Defaults to None. + flag (Type.flagtype, optional): Node flag. Defaults to None. + edges (Type.edgelist, optional): Node edges. Defaults to None. + + Returns: + Node: Returns added node object when valid + Bool: Returns False when failed adding node + """ if edges == None: edges = {} new_node = Node(data, flag, edges) new_id = self.last_id + 1 try: - Validator.check_node(new_node, self, adding=True) + Validator.check_node(new_node, self, _adding=True) self.nodes[new_id] = new_node self.last_id += 1 logging.info(f" Node #{new_id} added to graph #{self.graph_id}") + if VERBOSE: + logging.info(str(Converter.to_adjdict(self))) return new_node except: - warnings.warn( - f" <'KeyError'> Node not valid. Was not added", RuntimeWarning) - logging.warning(f" <'KeyError'> Node not valid. Was not added") - - if self.merciless: - logging.error(f" <'merciless == True'> Execution stopped") - raise KeyError("Node not valid") + error_handler("Node not valid. Was not added", "Key") return False # Removes nodes and all edges pointing to it def remove_node(self, id: Type.idtype): + """ + Removes nodes and all edges pointing to it + + Args: + id (Type.idtype): Id of the node to be removed + + Returns: + Node: Node removed when valid + Bool: False when failed to find node + """ try: Type.is_id(id) if id in self.nodes: popped = self.nodes.pop(id) if self.size > 0: - for key, node in self.nodes.items(): + for node in self.nodes.values(): if node.edges: if id in node.edges: node.edges.pop(id) logging.info( f" Node #{id} removed from graph #{self.graph_id}") + if VERBOSE: + logging.info( + str(Converter.to_adjdict(self))) return popped except: - warnings.warn(f" <'KeyError'> Node not found", RuntimeWarning) - logging.warning(f" <'KeyError'> Node not found") - - if self.merciless: - logging.error(f" <'merciless == True'> Execution stopped") - raise KeyError("Node not found") - return False - - # Sets relations - def set_relations(self, reflexive=False, symmetric=False, transitive=False): - warnings.warn(f" To be scrapped for v1.0.0. Unnecessary added complexity", - PendingDeprecationWarning) - - if not isinstance(reflexive, bool): - logging.error(f" <'TypeError'> Reflexive is not bool") - raise TypeError("Reflexive is not bool") - if not isinstance(symmetric, bool): - logging.error(f" <'TypeError'> Symmetric is not bool") - raise TypeError("Symmetric is not bool") - if not isinstance(transitive, bool): - logging.error(f" <'TypeError'> Transitive is not bool") - raise TypeError("Transitive is not bool") - - if ((self.reflexive != reflexive) or - (self.symmetric != symmetric) or - (self.transitive != transitive)): - - self.reflexive = reflexive - self.symmetric = symmetric - self.transitive = transitive - - info = (f" Relations' properties in graph #{self.graph_id} changed. New properties are:\n" - f" reflexive: {reflexive}\n" - f" symmetric: {symmetric}\n" - f" transitive: {transitive}") - logging.info(info) - return True - - else: - warnings.warn( - f" <'KeyError'> Relations already as defined", RuntimeWarning) - logging.warning(f" <'KeyError'> Relations already as defined") - if self.merciless: - logging.error(f" <'merciless == True'> Execution stopped") - raise KeyError("Relations already as defined") + error_handler("Node not found", "Key") return False def get_nodes(self): return self.nodes def copy(self): + """ + Returns deep copy (identical copy of object and its internal objects) + + Returns: + Graph: New graph object identical to original + """ return copy.deepcopy(self) # ============================================================================= @@ -412,112 +524,107 @@ def copy(self): # Validators class Validator(): + """ + Validator class. Checks graph, nodes and edges to ensure all properties are + valid + """ # Checks whether graph is valid - @staticmethod + @ staticmethod def is_graph(graph: Graph): + """ + Validates the entire graph - root = graph.root - nodes = graph.nodes - - last_id = graph.last_id - - weighted = graph.weighted - if not isinstance(weighted, bool): - logging.error(f" <'TypeError'> Weighted is not bool") - raise TypeError("Weighted is not bool") - directed = graph.directed - if not isinstance(directed, bool): - logging.error(f" <'TypeError'> Directed is not bool") - raise TypeError("Directed is not bool") - - reflexive = graph.reflexive - if not isinstance(reflexive, bool): - logging.error(f" <'TypeError'> Reflexive is not bool") - raise TypeError("Reflexive is not bool") - symmetric = graph.symmetric - if not isinstance(symmetric, bool): - logging.error(f" <'TypeError'> Symmetric is not bool") - raise TypeError("Symmetric is not bool") - transitive = graph.transitive - if not isinstance(transitive, bool): - logging.error(f" <'TypeError'> Transitive is not bool") - raise TypeError("Transitive is not bool") - - if not nodes: - if root != None: - warnings.warn(f" To be scrapped for v1.0.0. Unnecessary added complexity", - PendingDeprecationWarning) - # broken graph, has root, but no node - logging.error( - f" <'RuntimeError'> Broken graph, root without nodes") - raise RuntimeError("Broken graph, root without nodes") - # Empty graph is a valid graph - return True + Args: + graph (Graph): Graph to be checked - if root != None: - warnings.warn(f" To be scrapped for v1.0.0. Unnecessary added complexity", - PendingDeprecationWarning) - Type.is_id(root) - try: - if not root in nodes: - # Broken graph, root isn't one of its nodes - logging.error(f" <'KeyError'> Root not in nodes") - raise KeyError("Root not in nodes") - except: - # node typing wrong - logging.error(f" <'TypeError'> Nodelist failed type check") - raise TypeError("Nodelist failed type check") - Type.is_nodelist(nodes) - for key, node in nodes.items(): - id_range = key <= last_id - id_checks = id_range - if not id_checks: - logging.error(f" <'IndexError'> Id not in graph range") - raise IndexError("Id not in graph range") - Validator.check_node(node, graph) - return True + Returns: + Bool: Whether the graph is valid or not + """ + try: + nodes = graph.nodes + + last_id = graph.last_id + + if not nodes: + # Empty graph is a valid graph + return True - # Checks whether node is valid. Also used internally by Graph to check - # new nodes. Hence the adding parameter (shouldn't be used externally) - @staticmethod - def check_node(node: Type.nodetype, graph: Graph, adding=False): - Type.is_node(node) + Type.is_nodelist(nodes) + for key, node in nodes.items(): + id_range = key <= last_id + id_checks = id_range + if not id_checks: + error_handler("Id not in graph range", "Index") + Validator.check_node(node, graph) + return True + except: + error_handler("Graph failed type check", "Type") + return False + # Checks whether node is valid + @ staticmethod + def check_node(node: Type.nodetype, graph: Graph, _adding=False): + """ + Checks whether node is valid + Also used internally by Graph to check new nodes + Hence the adding parameter (shouldn't be used externally) + + Args: + node (Type.nodetype): Node to be checked + graph (Graph): Graph to which node belongs + _adding (bool, optional): Used internally when adding new node. Defaults to False. + + Returns: + Bool: Whether or not node is valid + """ try: + Type.is_node(node) check_type("graph", graph, Graph) + Type.is_nodelist(graph.nodes) + flag = node.flag + if flag: + Type.is_flag(flag) + if node.edges != {}: + Type.is_edgelist(node.edges) + for key, weight in node.edges.items(): + if key not in graph.nodes: + if not (_adding and key == graph.last_id + 1): + error_handler("Edge node not in nodes", "Key") + Type.is_weight(weight) + return True except: - logging.error(f" <'TypeError'> Graph failed type check") - raise TypeError("Graph failed type check") - - Type.is_nodelist(graph.nodes) - flag = node.flag - if flag: - Type.is_flag(flag) - if node.edges != None: - Type.is_edgelist(node.edges) - for key, weight in node.edges.items(): - if key not in graph.nodes: - if not (adding and key == graph.last_id + 1): - logging.error(f" <'KeyError'> Edge node not in nodes") - raise KeyError("Edge node not in nodes") - Type.is_weight(weight) - return True + error_handler("Node failed type check", "Type") + return False # ============================================================================= # Graph builders class Builder(): + """ + Graph building methods + """ # Advanced method to build graph from adjacency matrix - @staticmethod + @ staticmethod def adj_matrix(adj_mat: Type.adjmatrixtype, obj_list: List[Any] = None): + """ + Advanced method to build graph from adjacency matrix + + Args: + adj_mat (Type.adjmatrixtype): Source adjacency matrix + obj_list (List[Any], optional): Object list to go on 'node.data'. Defaults to None. + + Returns: + Graph: Built and checked graph + Bool: False if failed building graph + """ nodes = {} - Type.is_adjmatrix(adj_mat) try: + Type.is_adjmatrix(adj_mat) for i, line in enumerate(adj_mat): if obj_list: nodes[i] = Node(data=obj_list[i]) @@ -526,60 +633,175 @@ def adj_matrix(adj_mat: Type.adjmatrixtype, for j, weight in enumerate(line): if weight != None: nodes[i].edges[j] = weight - # print(nodes.edges) + + logging.info(f" Adjacency matrix is valid. Graph is being built") + return Graph(nodes=nodes) + except: - logging.error(f" <'RuntimeError'> Broken adjacency matrix") - raise RuntimeError("Broken adjacency matrix") + error_handler("Broken adjacency matrix", "Runtime") + return False - logging.info(f" Adjacency matrix is valid. Graph is being built") - return Graph(nodes=nodes) + # Advanced method to build graph from adjacency list + @ staticmethod + def adj_list(adj_list: Type.adjlisttype, + obj_list: List[Any] = None): + """ + Advanced method to build graph from adjacency list + + Args: + adj_list (Type.adjlisttype): Source adjacency list + obj_list (List[Any], optional): Object list to go on 'node.data'. Defaults to None. + + Returns: + Graph: Built and checked graph + Bool: False if failed building graph + """ + nodes = {} - # Shouldn't be needed. Maybe to delete unused id - @staticmethod - def refactor(): - raise NotImplementedError - ... + try: + Type.is_adjlist(adj_list) + for i, edgelist in enumerate(adj_list): + if obj_list: + nodes[i] = Node(data=obj_list[i]) + else: + nodes[i] = Node() + for j, weight in edgelist: + nodes[i].edges[j] = weight + + logging.info(f" Adjacency list is valid. Graph is being built") + return Graph(nodes=nodes) + except: + error_handler("Broken adjacency list", "Runtime") + return False + + # Advanced method to build graph from adjacency dictionary + @ staticmethod + def adj_dict(adj_dict: Type.adjdicttype, + obj_list: List[Any] = None): + """ + Advanced method to build graph from adjacency dictionary + + Args: + adj_dict (Type.adjdicttype): Source adjacency dictionary + obj_list (List[Any], optional): Object list to go on 'node.data'. Defaults to None. + + Returns: + Graph: Built and checked graph + Bool: False if failed building graph + """ + nodes = {} + + try: + Type.is_adjdict(adj_dict) + for i, edgelist in adj_dict.items(): + if obj_list: + nodes[i] = Node(data=obj_list[i]) + else: + nodes[i] = Node(edges=edgelist) + logging.info( + f" Adjacency dictionary is valid. Graph is being built") + return Graph(nodes=nodes) + except: + error_handler("Broken adjacency dictionary", "Runtime") + return False + + # Refactors graph to clean "waste" + @ staticmethod + def refactor(graph: Graph): + """ + Refactors graph to clean "waste" + Clears node flags + Removes unused node ids + + Args: + graph (Graph): Graph to be cleaned + + Returns: + Graph: Refactored graph + Bool: False when failed to refactor + """ + try: + Validator.is_graph(graph) + new_nodes = {} + for new_id, node in enumerate(graph.nodes.values()): + node.flag = new_id + new_nodes[new_id] = Node(data=node.data) + + for new_id, node in enumerate(graph.nodes.values()): + for eid, weight in node.edges.items(): + new_nodes[new_id].edges[graph.nodes[eid].flag] = weight + + refac = Graph(new_nodes) + return refac + except: + error_handler("Broken graph in refactor", "Runtime") + return False # ============================================================================= +# Converts graphs to native data types class Converter(): - + """ + Converts graphs to native data types for printing, exporting and all + """ # Returns an equivalent adjacency matrix and node data list - @staticmethod + @ staticmethod def to_adjmatrix(graph: Graph, get_nodes=False): + """ + Returns an equivalent adjacency matrix and node data list + + Args: + graph (Graph): Graph to be converted + get_nodes (bool, optional): Whether to get data list from 'node.data' . Defaults to False. + + Returns: + adjmatrixtype, list: Resulting adjacency matrix and data list + adjmatrixtype: Resulting adjacency matrix + Bool: False when failed to convert + """ try: Validator.is_graph(graph) if not isinstance(get_nodes, bool): - logging.error(f" <'TypeError'> get_nodes is not bool") - raise TypeError("get_nodes is not bool") - - adjmatrix = [[None for j in range(0, graph.size)] - for i in range(0, graph.size)] - nodes = [None for i in range(0, graph.size)] - for main_id, node in graph.nodes.items(): - nodes[main_id] = node.data - for dest_id, weight in node.edges.items(): - adjmatrix[main_id][dest_id] = weight + error_handler("get_nodes is not bool", "Type") + adjmatrix = [[None for j in range(0, graph.last_id + 1)] + for i in range(0, graph.last_id + 1)] + nodes = [None for i in range(0, graph.last_id + 1)] + for source_id, node in graph.nodes.items(): + nodes[source_id] = node.data + for target_id, weight in node.edges.items(): + adjmatrix[source_id][target_id] = weight if get_nodes: return adjmatrix, nodes return adjmatrix except: - logging.error(f" <'RuntimeError'> Wrong parameters in converter") - raise RuntimeError("Wrong parameters in converter") + error_handler("Wrong parameters in converter", "Runtime") + return False - @staticmethod + @ staticmethod def to_adjlist(graph: Graph, get_nodes=False): + """ + Returns an equivalent adjacency list and node data list + Edges returned as tuples due to duck typing + + Args: + graph (Graph): Graph to be converted + get_nodes (bool, optional): Whether to get data list from 'node.data' . Defaults to False. + + Returns: + adjlisttype, list: Resulting adjacency list and data list + adjlisttype: Resulting adjacency list + Bool: False when failed to convert + """ try: Validator.is_graph(graph) if not isinstance(get_nodes, bool): - logging.error(f" <'TypeError'> get_nodes is not bool") - raise TypeError("get_nodes is not bool") + error_handler("get_nodes is not bool", "Type") - adjlist = [None for i in range(0, graph.size)] - nodes = [None for i in range(0, graph.size)] + adjlist = [None for i in range(0, graph.last_id + 1)] + nodes = [None for i in range(0, graph.last_id + 1)] for id, node in graph.nodes.items(): nodes[id] = node.data adjlist[id] = list(node.edges.items()) @@ -588,11 +810,42 @@ def to_adjlist(graph: Graph, get_nodes=False): return adjlist except: - logging.error(f" <'RuntimeError'> Wrong parameters in converter") - raise RuntimeError("Wrong parameters in converter") + error_handler("Wrong parameters in converter", "Runtime") + return False + + @ staticmethod + def to_adjdict(graph: Graph, get_nodes=False): + """ + Returns an equivalent adjacency dict and node data list + Args: + graph (Graph): Graph to be converted + get_nodes (bool, optional): Whether to get data list from 'node.data' . Defaults to False. - # ============================================================================= - # Starters + Returns: + adjdicttype, list: Resulting adjacency dict and data list + adjdicttype: Resulting adjacency dict + Bool: False when failed to convert + """ + try: + Validator.is_graph(graph) + if not isinstance(get_nodes, bool): + error_handler("get_nodes is not bool", "Type") + adjdict = {} + nodes = [None for i in range(0, graph.last_id + 1)] + for id, node in graph.nodes.items(): + nodes[id] = node.data + adjdict[id] = node.edges + if get_nodes: + return adjdict, nodes + return adjdict + + except: + error_handler("Wrong parameters in converter", "Runtime") + return False + + +# ============================================================================= +# Starters start_log() start_classes()