# Copyright 2020 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Utility classes (and functions, in the future) for graph operations."""
import functools
from typing import Dict, Generic, List, Optional, Tuple, TypeVar
def sorted_nodes_by_name(nodes):
"""Sorts a list of Nodes by their name."""
return sorted(nodes, key=lambda node: node.name)
def sorted_edges_by_name(edges):
"""Sorts a list of edges (tuples) by their names.
Prioritizes sorting by the first node in an edge."""
return sorted(edges, key=lambda edge: (edge[0].name, edge[1].name))
@functools.total_ordering
class Node:
"""A node/vertex in a directed graph."""
def __init__(self, unique_key: str):
"""Initializes a new node with the given key.
Args:
unique_key: A key uniquely identifying the node.
"""
self._unique_key = unique_key
self._outbound = set()
self._inbound = set()
def __eq__(self, other: 'Node'):
return self._unique_key == other._unique_key
def __lt__(self, other: 'Node'):
return self._unique_key < other._unique_key
def __hash__(self):
return hash(self._unique_key)
def __str__(self) -> str:
return self.name
@property
def name(self):
"""A unique string representation of the node."""
return self._unique_key
@property
def inbound(self):
"""A set of Nodes that have a directed edge into this Node."""
return self._inbound
@property
def outbound(self):
"""A set of Nodes that this Node has a directed edge into."""
return self._outbound
def add_outbound(self, node: 'Node'):
"""Creates an edge from the current node to the provided node."""
self._outbound.add(node)
def add_inbound(self, node: 'Node'):
"""Creates an edge from the provided node to the current node."""
self._inbound.add(node)
def get_node_metadata(self) -> Optional[Dict]:
"""Generates JSON metadata for the current node.
If the returned dict is None, the metadata field will be excluded."""
return None
T = TypeVar('T', bound=Node)
class Graph(Generic[T]):
"""A directed graph data structure.
Maintains an internal Dict[str, T] _key_to_node mapping the unique key of
nodes to their Node objects. Allows subclasses to specify their own Node
subclasses via Generic typing.
"""
def __init__(self):
self._key_to_node = {}
self._edges = []
@property
def num_nodes(self) -> int:
"""The number of nodes in the graph."""
return len(self.nodes)
@property
def num_edges(self) -> int:
"""The number of edges in the graph."""
return len(self.edges)
@property
def nodes(self) -> List[T]:
"""A list of Nodes in the graph."""
return list(self._key_to_node.values())
@property
def edges(self) -> List[Tuple[T, T]]:
"""A list of tuples (begin, end) representing directed edges."""
return self._edges
def get_node_by_key(self, key: str) -> Optional[T]:
"""Returns a node by that key or None if no such node exists."""
return self._key_to_node.get(key)
def create_node_from_key(self, key: str) -> T:
"""Given a unique key, creates and returns a Node object.
Should be overridden by child classes.
"""
return Node(key) # type: ignore
def add_node_if_new(self, key: str) -> T:
"""Adds a Node to the graph.
A new Node object is constructed from the given key and added.
If the key already exists in the graph, no change is made to the graph.
Args:
key: A unique key to create the new Node from.
Returns:
The Node with the given key in the graph.
"""
try:
return self._key_to_node[key]
except KeyError:
node = self.create_node_from_key(key)
self._key_to_node[key] = node
return node
def add_edge_if_new(self, src: str, dest: str) -> bool:
"""Adds a directed edge to the graph.
The source and destination nodes are created and added if they
do not already exist. If the edge already exists in the graph,
this is a no-op.
Args:
src: A unique key representing the source node.
dest: A unique key representing the destination node.
Returns:
True if the edge was added (did not already exist), else False
"""
src_node = self.add_node_if_new(src)
dest_node = self.add_node_if_new(dest)
# The following check is much faster than `if (src, dest) not in _edges`
if dest_node not in src_node.outbound:
src_node.add_outbound(dest_node)
dest_node.add_inbound(src_node)
self._edges.append((src_node, dest_node))
return True
return False
def get_edge_metadata(self, begin_node, end_node) -> Optional[Dict]:
"""Generates JSON metadata for the current edge.
If the returned dict is None, the metadata field will be excluded."""
return None