about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/networkx/readwrite/text.py
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/networkx/readwrite/text.py')
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/readwrite/text.py852
1 files changed, 852 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/networkx/readwrite/text.py b/.venv/lib/python3.12/site-packages/networkx/readwrite/text.py
new file mode 100644
index 00000000..c54901d1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/readwrite/text.py
@@ -0,0 +1,852 @@
+"""
+Text-based visual representations of graphs
+"""
+
+import sys
+import warnings
+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):
+    # Notes on available box and arrow characters
+    # https://en.wikipedia.org/wiki/Box-drawing_character
+    # https://stackoverflow.com/questions/2701192/triangle-arrow
+    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 the nodes to traverse are unspecified, find the minimal set of
+        # nodes that will reach the entire graph
+        if sources is None:
+            sources = _find_sources(graph)
+
+        # Populate the stack with each:
+        # 1. parent node in the DFS tree (or None for root nodes),
+        # 2. the current node in the DFS tree
+        # 2. a list of indentations indicating depth
+        # 3. a flag indicating if the node is the final one to be written.
+        # Reverse the stack so sources are popped in the correct order.
+        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:
+                    # Mark that we skipped a parent's child
+                    num_skipped_children[parent] += 1
+
+                if this_islast:
+                    # If we reached the last child of a parent, and we skipped
+                    # any of that parents children, then we should emit an
+                    # ellipsis at the end after this.
+                    if num_skipped_children[parent] and parent is not None:
+                        # Append the ellipsis to be emitted last
+                        next_islast = True
+                        try_frame = StackFrame(
+                            node, Ellipsis, indents, next_islast, False
+                        )
+                        stack.append(try_frame)
+
+                        # Redo this frame, but not as a last object
+                        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:
+                # Top level items (i.e. trees in the forest) get different
+                # glyphs to indicate they are not actually connected
+                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:
+                # Non-top-level items
+                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)
+
+                # Determine if we want to show the children of this node.
+                if collapse_attr is not None:
+                    collapse = graph.nodes[node].get(collapse_attr, False)
+                else:
+                    collapse = False
+
+                # Determine:
+                # (1) children to traverse into after showing this node.
+                # (2) parents to immediately show to the right of this node.
+                if is_directed:
+                    # In the directed case we must show every successor node
+                    # note: it may be skipped later, but we don't have that
+                    # information here.
+                    children = list(succ[node])
+                    # In the directed case we must show every predecessor
+                    # except for parent we directly traversed from.
+                    handled_parents = {parent}
+                else:
+                    # Showing only the unseen children results in a more
+                    # concise representation for the undirected case.
+                    children = [
+                        child for child in succ[node] if child not in seen_nodes
+                    ]
+
+                    # In the undirected case, parents are also children, so we
+                    # only need to immediately show the ones we can no longer
+                    # traverse
+                    handled_parents = {*children, parent}
+
+                if max_depth is not None and len(indents) == max_depth - 1:
+                    # Use ellipsis to indicate we have reached maximum depth
+                    if children:
+                        children = [Ellipsis]
+                    handled_parents = {parent}
+
+                if collapse:
+                    # Collapsing a node is the same as reaching maximum depth
+                    if children:
+                        children = [Ellipsis]
+                    handled_parents = {parent}
+
+                # The other parents are other predecessors of this node that
+                # are not handled elsewhere.
+                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 = ""
+
+            # Emit the line for this node, this will be called for each node
+            # exactly once.
+            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})
+                # The next node can be drawn vertically if it is the only
+                # remaining child of this node.
+                next_is_vertical = num_children == 1
+            else:
+                next_is_vertical = False
+
+            # Push children on the stack in reverse order so they are popped in
+            # the original order.
+            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:
+        # The path is unspecified, write to stdout
+        _write = sys.stdout.write
+    elif hasattr(path, "write"):
+        # The path is already an open file
+        _write = path.write
+    elif callable(path):
+        # The path is a custom callable
+        _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
+    """
+    # For each connected part of the graph, choose at least
+    # one node as a starting point, preferably without a parent
+    if graph.is_directed():
+        # Choose one node from each SCC with minimum in_degree
+        sccs = list(nx.strongly_connected_components(graph))
+        # condensing the SCCs forms a dag, the nodes in this graph with
+        # 0 in-degree correspond to the SCCs from which the minimum set
+        # of nodes from which all other nodes can be reached.
+        scc_graph = nx.condensation(graph, sccs)
+        supernode_to_nodes = {sn: [] for sn in scc_graph.nodes()}
+        # Note: the order of mapping differs between pypy and cpython
+        # so we have to loop over graph nodes for consistency
+        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:
+        # For undirected graph, the entire graph will be reachable as
+        # long as we consider one node from every connected component
+        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, Union
+
+    class ParseStackFrame(NamedTuple):
+        node: Any
+        indent: int
+        has_vertical_child: int | None
+
+    initial_line_iter = iter(lines)
+
+    is_ascii = None
+    is_directed = None
+
+    ##############
+    # Initial Pass
+    ##############
+
+    # Do an initial pass over the lines to determine what type of graph it is.
+    # Remember what these lines were, so we can reiterate over them in the
+    # parsing pass.
+    initial_lines = []
+    try:
+        first_line = next(initial_line_iter)
+    except StopIteration:
+        ...
+    else:
+        initial_lines.append(first_line)
+        # The first character indicates if it is an ASCII or UTF graph
+        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()
+
+    # For both directed / undirected glyphs, determine which glyphs never
+    # appear as substrings in the other undirected / directed glyphs.  Glyphs
+    # with this property unambiguously indicates if a graph is directed /
+    # undirected.
+    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:
+        # Not enough information to determine, choose undirected by default
+        is_directed = False
+
+    glyphs = directed_glyphs if is_directed else undirected_glyphs
+
+    # the backedge symbol by itself can be ambiguous, but with spaces around it
+    # becomes unambiguous.
+    backedge_symbol = " " + glyphs["backedge"] + " "
+
+    # Reconstruct an iterator over all of the lines.
+    parsing_line_iter = chain(initial_lines, initial_line_iter)
+
+    ##############
+    # Parsing Pass
+    ##############
+
+    edges = []
+    nodes = []
+    is_empty = None
+
+    noparent = object()  # sentinel value
+
+    # keep a stack of previous nodes that could be parents of subsequent nodes
+    stack = [ParseStackFrame(noparent, -1, None)]
+
+    for line in parsing_line_iter:
+        if line == glyphs["empty"]:
+            # If the line is the empty glyph, we are done.
+            # There shouldn't be anything else after this.
+            is_empty = True
+            continue
+
+        if backedge_symbol in line:
+            # This line has one or more backedges, separate those out
+            node_part, backedge_part = line.split(backedge_symbol)
+            backedge_nodes = [u.strip() for u in backedge_part.split(", ")]
+            # Now the node can be parsed
+            node_part = node_part.rstrip()
+            prefix, node = node_part.rsplit(" ", 1)
+            node = node.strip()
+            # Add the backedges to the edge list
+            edges.extend([(u, node) for u in backedge_nodes])
+        else:
+            # No backedge, the tail of this line is the node
+            prefix, node = line.rsplit(" ", 1)
+            node = node.strip()
+
+        prev = stack.pop()
+
+        if node in glyphs["vertical_edge"]:
+            # Previous node is still the previous node, but we know it will
+            # have exactly one child, which will need to have its nesting level
+            # adjusted.
+            modified_prev = ParseStackFrame(
+                prev.node,
+                prev.indent,
+                True,
+            )
+            stack.append(modified_prev)
+            continue
+
+        # The length of the string before the node characters give us a hint
+        # about our nesting level. The only case where this doesn't work is
+        # when there are vertical chains, which is handled explicitly.
+        indent = len(prefix)
+        curr = ParseStackFrame(node, indent, None)
+
+        if prev.has_vertical_child:
+            # In this case we know prev must be the parent of our current line,
+            # so we don't have to search the stack. (which is good because the
+            # indentation check wouldn't work in this case).
+            ...
+        else:
+            # If the previous node nesting-level is greater than the current
+            # nodes nesting-level than the previous node was the end of a path,
+            # and is not our parent. We can safely pop nodes off the stack
+            # until we find one with a comparable nesting-level, which is our
+            # parent.
+            while curr.indent <= prev.indent:
+                prev = stack.pop()
+
+        if node == "...":
+            # The current previous node is no longer a valid parent,
+            # keep it popped from the stack.
+            stack.append(prev)
+        else:
+            # The previous and current nodes may still be parents, so add them
+            # back onto the stack.
+            stack.append(prev)
+            stack.append(curr)
+
+            # Add the node and the edge to its parent to the node / edge lists.
+            nodes.append(curr.node)
+            if prev.node is not noparent:
+                edges.append((prev.node, curr.node))
+
+    if is_empty:
+        # Sanity check
+        assert len(nodes) == 0
+
+    # Reconstruct the graph
+    cls = nx.DiGraph if is_directed else nx.Graph
+    new = cls()
+    new.add_nodes_from(nodes)
+    new.add_edges_from(edges)
+    return new