| """ |
| Text-based visual representations of graphs |
| """ |
|
|
| import sys |
| from collections import defaultdict |
|
|
| import networkx as nx |
| from networkx.utils import open_file |
|
|
| __all__ = ["generate_network_text", "write_network_text"] |
|
|
|
|
| class BaseGlyphs: |
| @classmethod |
| def as_dict(cls): |
| return { |
| a: getattr(cls, a) |
| for a in dir(cls) |
| if not a.startswith("_") and a != "as_dict" |
| } |
|
|
|
|
| class AsciiBaseGlyphs(BaseGlyphs): |
| empty: str = "+" |
| newtree_last: str = "+-- " |
| newtree_mid: str = "+-- " |
| endof_forest: str = " " |
| within_forest: str = ": " |
| within_tree: str = "| " |
|
|
|
|
| class AsciiDirectedGlyphs(AsciiBaseGlyphs): |
| last: str = "L-> " |
| mid: str = "|-> " |
| backedge: str = "<-" |
| vertical_edge: str = "!" |
|
|
|
|
| class AsciiUndirectedGlyphs(AsciiBaseGlyphs): |
| last: str = "L-- " |
| mid: str = "|-- " |
| backedge: str = "-" |
| vertical_edge: str = "|" |
|
|
|
|
| class UtfBaseGlyphs(BaseGlyphs): |
| |
| |
| |
| empty: str = "╙" |
| newtree_last: str = "╙── " |
| newtree_mid: str = "╟── " |
| endof_forest: str = " " |
| within_forest: str = "╎ " |
| within_tree: str = "│ " |
|
|
|
|
| class UtfDirectedGlyphs(UtfBaseGlyphs): |
| last: str = "└─╼ " |
| mid: str = "├─╼ " |
| backedge: str = "╾" |
| vertical_edge: str = "╽" |
|
|
|
|
| class UtfUndirectedGlyphs(UtfBaseGlyphs): |
| last: str = "└── " |
| mid: str = "├── " |
| backedge: str = "─" |
| vertical_edge: str = "│" |
|
|
|
|
| def generate_network_text( |
| graph, |
| with_labels=True, |
| sources=None, |
| max_depth=None, |
| ascii_only=False, |
| vertical_chains=False, |
| ): |
| """Generate lines in the "network text" format |
| |
| This works via a depth-first traversal of the graph and writing a line for |
| each unique node encountered. Non-tree edges are written to the right of |
| each node, and connection to a non-tree edge is indicated with an ellipsis. |
| This representation works best when the input graph is a forest, but any |
| graph can be represented. |
| |
| This notation is original to networkx, although it is simple enough that it |
| may be known in existing literature. See #5602 for details. The procedure |
| is summarized as follows: |
| |
| 1. Given a set of source nodes (which can be specified, or automatically |
| discovered via finding the (strongly) connected components and choosing one |
| node with minimum degree from each), we traverse the graph in depth first |
| order. |
| |
| 2. Each reachable node will be printed exactly once on it's own line. |
| |
| 3. Edges are indicated in one of four ways: |
| |
| a. a parent "L-style" connection on the upper left. This corresponds to |
| a traversal in the directed DFS tree. |
| |
| b. a backref "<-style" connection shown directly on the right. For |
| directed graphs, these are drawn for any incoming edges to a node that |
| is not a parent edge. For undirected graphs, these are drawn for only |
| the non-parent edges that have already been represented (The edges that |
| have not been represented will be handled in the recursive case). |
| |
| c. a child "L-style" connection on the lower right. Drawing of the |
| children are handled recursively. |
| |
| d. if ``vertical_chains`` is true, and a parent node only has one child |
| a "vertical-style" edge is drawn between them. |
| |
| 4. The children of each node (wrt the directed DFS tree) are drawn |
| underneath and to the right of it. In the case that a child node has already |
| been drawn the connection is replaced with an ellipsis ("...") to indicate |
| that there is one or more connections represented elsewhere. |
| |
| 5. If a maximum depth is specified, an edge to nodes past this maximum |
| depth will be represented by an ellipsis. |
| |
| 6. If a node has a truthy "collapse" value, then we do not traverse past |
| that node. |
| |
| Parameters |
| ---------- |
| graph : nx.DiGraph | nx.Graph |
| Graph to represent |
| |
| with_labels : bool | str |
| If True will use the "label" attribute of a node to display if it |
| exists otherwise it will use the node value itself. If given as a |
| string, then that attribute name will be used instead of "label". |
| Defaults to True. |
| |
| sources : List |
| Specifies which nodes to start traversal from. Note: nodes that are not |
| reachable from one of these sources may not be shown. If unspecified, |
| the minimal set of nodes needed to reach all others will be used. |
| |
| max_depth : int | None |
| The maximum depth to traverse before stopping. Defaults to None. |
| |
| ascii_only : Boolean |
| If True only ASCII characters are used to construct the visualization |
| |
| vertical_chains : Boolean |
| If True, chains of nodes will be drawn vertically when possible. |
| |
| Yields |
| ------ |
| str : a line of generated text |
| |
| Examples |
| -------- |
| >>> graph = nx.path_graph(10) |
| >>> graph.add_node("A") |
| >>> graph.add_node("B") |
| >>> graph.add_node("C") |
| >>> graph.add_node("D") |
| >>> graph.add_edge(9, "A") |
| >>> graph.add_edge(9, "B") |
| >>> graph.add_edge(9, "C") |
| >>> graph.add_edge("C", "D") |
| >>> graph.add_edge("C", "E") |
| >>> graph.add_edge("C", "F") |
| >>> nx.write_network_text(graph) |
| ╙── 0 |
| └── 1 |
| └── 2 |
| └── 3 |
| └── 4 |
| └── 5 |
| └── 6 |
| └── 7 |
| └── 8 |
| └── 9 |
| ├── A |
| ├── B |
| └── C |
| ├── D |
| ├── E |
| └── F |
| >>> nx.write_network_text(graph, vertical_chains=True) |
| ╙── 0 |
| │ |
| 1 |
| │ |
| 2 |
| │ |
| 3 |
| │ |
| 4 |
| │ |
| 5 |
| │ |
| 6 |
| │ |
| 7 |
| │ |
| 8 |
| │ |
| 9 |
| ├── A |
| ├── B |
| └── C |
| ├── D |
| ├── E |
| └── F |
| """ |
| from typing import Any, NamedTuple |
|
|
| class StackFrame(NamedTuple): |
| parent: Any |
| node: Any |
| indents: list |
| this_islast: bool |
| this_vertical: bool |
|
|
| collapse_attr = "collapse" |
|
|
| is_directed = graph.is_directed() |
|
|
| if is_directed: |
| glyphs = AsciiDirectedGlyphs if ascii_only else UtfDirectedGlyphs |
| succ = graph.succ |
| pred = graph.pred |
| else: |
| glyphs = AsciiUndirectedGlyphs if ascii_only else UtfUndirectedGlyphs |
| succ = graph.adj |
| pred = graph.adj |
|
|
| if isinstance(with_labels, str): |
| label_attr = with_labels |
| elif with_labels: |
| label_attr = "label" |
| else: |
| label_attr = None |
|
|
| if max_depth == 0: |
| yield glyphs.empty + " ..." |
| elif len(graph.nodes) == 0: |
| yield glyphs.empty |
| else: |
| |
| |
| if sources is None: |
| sources = _find_sources(graph) |
|
|
| |
| |
| |
| |
| |
| |
| last_idx = len(sources) - 1 |
| stack = [ |
| StackFrame(None, node, [], (idx == last_idx), False) |
| for idx, node in enumerate(sources) |
| ][::-1] |
|
|
| num_skipped_children = defaultdict(lambda: 0) |
| seen_nodes = set() |
| while stack: |
| parent, node, indents, this_islast, this_vertical = stack.pop() |
|
|
| if node is not Ellipsis: |
| skip = node in seen_nodes |
| if skip: |
| |
| num_skipped_children[parent] += 1 |
|
|
| if this_islast: |
| |
| |
| |
| if num_skipped_children[parent] and parent is not None: |
| |
| next_islast = True |
| try_frame = StackFrame( |
| node, Ellipsis, indents, next_islast, False |
| ) |
| stack.append(try_frame) |
|
|
| |
| next_islast = False |
| try_frame = StackFrame( |
| parent, node, indents, next_islast, this_vertical |
| ) |
| stack.append(try_frame) |
| continue |
|
|
| if skip: |
| continue |
| seen_nodes.add(node) |
|
|
| if not indents: |
| |
| |
| if this_islast: |
| this_vertical = False |
| this_prefix = indents + [glyphs.newtree_last] |
| next_prefix = indents + [glyphs.endof_forest] |
| else: |
| this_prefix = indents + [glyphs.newtree_mid] |
| next_prefix = indents + [glyphs.within_forest] |
|
|
| else: |
| |
| if this_vertical: |
| this_prefix = indents |
| next_prefix = indents |
| else: |
| if this_islast: |
| this_prefix = indents + [glyphs.last] |
| next_prefix = indents + [glyphs.endof_forest] |
| else: |
| this_prefix = indents + [glyphs.mid] |
| next_prefix = indents + [glyphs.within_tree] |
|
|
| if node is Ellipsis: |
| label = " ..." |
| suffix = "" |
| children = [] |
| else: |
| if label_attr is not None: |
| label = str(graph.nodes[node].get(label_attr, node)) |
| else: |
| label = str(node) |
|
|
| |
| if collapse_attr is not None: |
| collapse = graph.nodes[node].get(collapse_attr, False) |
| else: |
| collapse = False |
|
|
| |
| |
| |
| if is_directed: |
| |
| |
| |
| children = list(succ[node]) |
| |
| |
| handled_parents = {parent} |
| else: |
| |
| |
| children = [ |
| child for child in succ[node] if child not in seen_nodes |
| ] |
|
|
| |
| |
| |
| handled_parents = {*children, parent} |
|
|
| if max_depth is not None and len(indents) == max_depth - 1: |
| |
| if children: |
| children = [Ellipsis] |
| handled_parents = {parent} |
|
|
| if collapse: |
| |
| if children: |
| children = [Ellipsis] |
| handled_parents = {parent} |
|
|
| |
| |
| other_parents = [p for p in pred[node] if p not in handled_parents] |
| if other_parents: |
| if label_attr is not None: |
| other_parents_labels = ", ".join( |
| [ |
| str(graph.nodes[p].get(label_attr, p)) |
| for p in other_parents |
| ] |
| ) |
| else: |
| other_parents_labels = ", ".join( |
| [str(p) for p in other_parents] |
| ) |
| suffix = " ".join(["", glyphs.backedge, other_parents_labels]) |
| else: |
| suffix = "" |
|
|
| |
| |
| if this_vertical: |
| yield "".join(this_prefix + [glyphs.vertical_edge]) |
|
|
| yield "".join(this_prefix + [label, suffix]) |
|
|
| if vertical_chains: |
| if is_directed: |
| num_children = len(set(children)) |
| else: |
| num_children = len(set(children) - {parent}) |
| |
| |
| next_is_vertical = num_children == 1 |
| else: |
| next_is_vertical = False |
|
|
| |
| |
| for idx, child in enumerate(children[::-1]): |
| next_islast = idx == 0 |
| try_frame = StackFrame( |
| node, child, next_prefix, next_islast, next_is_vertical |
| ) |
| stack.append(try_frame) |
|
|
|
|
| @open_file(1, "w") |
| def write_network_text( |
| graph, |
| path=None, |
| with_labels=True, |
| sources=None, |
| max_depth=None, |
| ascii_only=False, |
| end="\n", |
| vertical_chains=False, |
| ): |
| """Creates a nice text representation of a graph |
| |
| This works via a depth-first traversal of the graph and writing a line for |
| each unique node encountered. Non-tree edges are written to the right of |
| each node, and connection to a non-tree edge is indicated with an ellipsis. |
| This representation works best when the input graph is a forest, but any |
| graph can be represented. |
| |
| Parameters |
| ---------- |
| graph : nx.DiGraph | nx.Graph |
| Graph to represent |
| |
| path : string or file or callable or None |
| Filename or file handle for data output. |
| if a function, then it will be called for each generated line. |
| if None, this will default to "sys.stdout.write" |
| |
| with_labels : bool | str |
| If True will use the "label" attribute of a node to display if it |
| exists otherwise it will use the node value itself. If given as a |
| string, then that attribute name will be used instead of "label". |
| Defaults to True. |
| |
| sources : List |
| Specifies which nodes to start traversal from. Note: nodes that are not |
| reachable from one of these sources may not be shown. If unspecified, |
| the minimal set of nodes needed to reach all others will be used. |
| |
| max_depth : int | None |
| The maximum depth to traverse before stopping. Defaults to None. |
| |
| ascii_only : Boolean |
| If True only ASCII characters are used to construct the visualization |
| |
| end : string |
| The line ending character |
| |
| vertical_chains : Boolean |
| If True, chains of nodes will be drawn vertically when possible. |
| |
| Examples |
| -------- |
| >>> graph = nx.balanced_tree(r=2, h=2, create_using=nx.DiGraph) |
| >>> nx.write_network_text(graph) |
| ╙── 0 |
| ├─╼ 1 |
| │ ├─╼ 3 |
| │ └─╼ 4 |
| └─╼ 2 |
| ├─╼ 5 |
| └─╼ 6 |
| |
| >>> # A near tree with one non-tree edge |
| >>> graph.add_edge(5, 1) |
| >>> nx.write_network_text(graph) |
| ╙── 0 |
| ├─╼ 1 ╾ 5 |
| │ ├─╼ 3 |
| │ └─╼ 4 |
| └─╼ 2 |
| ├─╼ 5 |
| │ └─╼ ... |
| └─╼ 6 |
| |
| >>> graph = nx.cycle_graph(5) |
| >>> nx.write_network_text(graph) |
| ╙── 0 |
| ├── 1 |
| │ └── 2 |
| │ └── 3 |
| │ └── 4 ─ 0 |
| └── ... |
| |
| >>> graph = nx.cycle_graph(5, nx.DiGraph) |
| >>> nx.write_network_text(graph, vertical_chains=True) |
| ╙── 0 ╾ 4 |
| ╽ |
| 1 |
| ╽ |
| 2 |
| ╽ |
| 3 |
| ╽ |
| 4 |
| └─╼ ... |
| |
| >>> nx.write_network_text(graph, vertical_chains=True, ascii_only=True) |
| +-- 0 <- 4 |
| ! |
| 1 |
| ! |
| 2 |
| ! |
| 3 |
| ! |
| 4 |
| L-> ... |
| |
| >>> graph = nx.generators.barbell_graph(4, 2) |
| >>> nx.write_network_text(graph, vertical_chains=False) |
| ╙── 4 |
| ├── 5 |
| │ └── 6 |
| │ ├── 7 |
| │ │ ├── 8 ─ 6 |
| │ │ │ └── 9 ─ 6, 7 |
| │ │ └── ... |
| │ └── ... |
| └── 3 |
| ├── 0 |
| │ ├── 1 ─ 3 |
| │ │ └── 2 ─ 0, 3 |
| │ └── ... |
| └── ... |
| >>> nx.write_network_text(graph, vertical_chains=True) |
| ╙── 4 |
| ├── 5 |
| │ │ |
| │ 6 |
| │ ├── 7 |
| │ │ ├── 8 ─ 6 |
| │ │ │ │ |
| │ │ │ 9 ─ 6, 7 |
| │ │ └── ... |
| │ └── ... |
| └── 3 |
| ├── 0 |
| │ ├── 1 ─ 3 |
| │ │ │ |
| │ │ 2 ─ 0, 3 |
| │ └── ... |
| └── ... |
| |
| >>> graph = nx.complete_graph(5, create_using=nx.Graph) |
| >>> nx.write_network_text(graph) |
| ╙── 0 |
| ├── 1 |
| │ ├── 2 ─ 0 |
| │ │ ├── 3 ─ 0, 1 |
| │ │ │ └── 4 ─ 0, 1, 2 |
| │ │ └── ... |
| │ └── ... |
| └── ... |
| |
| >>> graph = nx.complete_graph(3, create_using=nx.DiGraph) |
| >>> nx.write_network_text(graph) |
| ╙── 0 ╾ 1, 2 |
| ├─╼ 1 ╾ 2 |
| │ ├─╼ 2 ╾ 0 |
| │ │ └─╼ ... |
| │ └─╼ ... |
| └─╼ ... |
| """ |
| if path is None: |
| |
| _write = sys.stdout.write |
| elif hasattr(path, "write"): |
| |
| _write = path.write |
| elif callable(path): |
| |
| _write = path |
| else: |
| raise TypeError(type(path)) |
|
|
| for line in generate_network_text( |
| graph, |
| with_labels=with_labels, |
| sources=sources, |
| max_depth=max_depth, |
| ascii_only=ascii_only, |
| vertical_chains=vertical_chains, |
| ): |
| _write(line + end) |
|
|
|
|
| def _find_sources(graph): |
| """ |
| Determine a minimal set of nodes such that the entire graph is reachable |
| """ |
| |
| |
| if graph.is_directed(): |
| |
| sccs = list(nx.strongly_connected_components(graph)) |
| |
| |
| |
| scc_graph = nx.condensation(graph, sccs) |
| supernode_to_nodes = {sn: [] for sn in scc_graph.nodes()} |
| |
| |
| mapping = scc_graph.graph["mapping"] |
| for n in graph.nodes: |
| sn = mapping[n] |
| supernode_to_nodes[sn].append(n) |
| sources = [] |
| for sn in scc_graph.nodes(): |
| if scc_graph.in_degree[sn] == 0: |
| scc = supernode_to_nodes[sn] |
| node = min(scc, key=lambda n: graph.in_degree[n]) |
| sources.append(node) |
| else: |
| |
| |
| sources = [ |
| min(cc, key=lambda n: graph.degree[n]) |
| for cc in nx.connected_components(graph) |
| ] |
| sources = sorted(sources, key=lambda n: graph.degree[n]) |
| return sources |
|
|
|
|
| def _parse_network_text(lines): |
| """Reconstructs a graph from a network text representation. |
| |
| This is mainly used for testing. Network text is for display, not |
| serialization, as such this cannot parse all network text representations |
| because node labels can be ambiguous with the glyphs and indentation used |
| to represent edge structure. Additionally, there is no way to determine if |
| disconnected graphs were originally directed or undirected. |
| |
| Parameters |
| ---------- |
| lines : list or iterator of strings |
| Input data in network text format |
| |
| Returns |
| ------- |
| G: NetworkX graph |
| The graph corresponding to the lines in network text format. |
| """ |
| from itertools import chain |
| from typing import Any, NamedTuple |
|
|
| class ParseStackFrame(NamedTuple): |
| node: Any |
| indent: int |
| has_vertical_child: int | None |
|
|
| initial_line_iter = iter(lines) |
|
|
| is_ascii = None |
| is_directed = None |
|
|
| |
| |
| |
|
|
| |
| |
| |
| initial_lines = [] |
| try: |
| first_line = next(initial_line_iter) |
| except StopIteration: |
| ... |
| else: |
| initial_lines.append(first_line) |
| |
| first_char = first_line[0] |
| if first_char in { |
| UtfBaseGlyphs.empty, |
| UtfBaseGlyphs.newtree_mid[0], |
| UtfBaseGlyphs.newtree_last[0], |
| }: |
| is_ascii = False |
| elif first_char in { |
| AsciiBaseGlyphs.empty, |
| AsciiBaseGlyphs.newtree_mid[0], |
| AsciiBaseGlyphs.newtree_last[0], |
| }: |
| is_ascii = True |
| else: |
| raise AssertionError(f"Unexpected first character: {first_char}") |
|
|
| if is_ascii: |
| directed_glyphs = AsciiDirectedGlyphs.as_dict() |
| undirected_glyphs = AsciiUndirectedGlyphs.as_dict() |
| else: |
| directed_glyphs = UtfDirectedGlyphs.as_dict() |
| undirected_glyphs = UtfUndirectedGlyphs.as_dict() |
|
|
| |
| |
| |
| |
| directed_items = set(directed_glyphs.values()) |
| undirected_items = set(undirected_glyphs.values()) |
| unambiguous_directed_items = [] |
| for item in directed_items: |
| other_items = undirected_items |
| other_supersets = [other for other in other_items if item in other] |
| if not other_supersets: |
| unambiguous_directed_items.append(item) |
| unambiguous_undirected_items = [] |
| for item in undirected_items: |
| other_items = directed_items |
| other_supersets = [other for other in other_items if item in other] |
| if not other_supersets: |
| unambiguous_undirected_items.append(item) |
|
|
| for line in initial_line_iter: |
| initial_lines.append(line) |
| if any(item in line for item in unambiguous_undirected_items): |
| is_directed = False |
| break |
| elif any(item in line for item in unambiguous_directed_items): |
| is_directed = True |
| break |
|
|
| if is_directed is None: |
| |
| is_directed = False |
|
|
| glyphs = directed_glyphs if is_directed else undirected_glyphs |
|
|
| |
| |
| backedge_symbol = " " + glyphs["backedge"] + " " |
|
|
| |
| parsing_line_iter = chain(initial_lines, initial_line_iter) |
|
|
| |
| |
| |
|
|
| edges = [] |
| nodes = [] |
| is_empty = None |
|
|
| noparent = object() |
|
|
| |
| stack = [ParseStackFrame(noparent, -1, None)] |
|
|
| for line in parsing_line_iter: |
| if line == glyphs["empty"]: |
| |
| |
| is_empty = True |
| continue |
|
|
| if backedge_symbol in line: |
| |
| node_part, backedge_part = line.split(backedge_symbol) |
| backedge_nodes = [u.strip() for u in backedge_part.split(", ")] |
| |
| node_part = node_part.rstrip() |
| prefix, node = node_part.rsplit(" ", 1) |
| node = node.strip() |
| |
| edges.extend([(u, node) for u in backedge_nodes]) |
| else: |
| |
| prefix, node = line.rsplit(" ", 1) |
| node = node.strip() |
|
|
| prev = stack.pop() |
|
|
| if node in glyphs["vertical_edge"]: |
| |
| |
| |
| modified_prev = ParseStackFrame( |
| prev.node, |
| prev.indent, |
| True, |
| ) |
| stack.append(modified_prev) |
| continue |
|
|
| |
| |
| |
| indent = len(prefix) |
| curr = ParseStackFrame(node, indent, None) |
|
|
| if prev.has_vertical_child: |
| |
| |
| |
| ... |
| else: |
| |
| |
| |
| |
| |
| while curr.indent <= prev.indent: |
| prev = stack.pop() |
|
|
| if node == "...": |
| |
| |
| stack.append(prev) |
| else: |
| |
| |
| stack.append(prev) |
| stack.append(curr) |
|
|
| |
| nodes.append(curr.node) |
| if prev.node is not noparent: |
| edges.append((prev.node, curr.node)) |
|
|
| if is_empty: |
| |
| assert len(nodes) == 0 |
|
|
| |
| cls = nx.DiGraph if is_directed else nx.Graph |
| new = cls() |
| new.add_nodes_from(nodes) |
| new.add_edges_from(edges) |
| return new |
|
|