text.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851
  1. """
  2. Text-based visual representations of graphs
  3. """
  4. import sys
  5. from collections import defaultdict
  6. import networkx as nx
  7. from networkx.utils import open_file
  8. __all__ = ["generate_network_text", "write_network_text"]
  9. class BaseGlyphs:
  10. @classmethod
  11. def as_dict(cls):
  12. return {
  13. a: getattr(cls, a)
  14. for a in dir(cls)
  15. if not a.startswith("_") and a != "as_dict"
  16. }
  17. class AsciiBaseGlyphs(BaseGlyphs):
  18. empty: str = "+"
  19. newtree_last: str = "+-- "
  20. newtree_mid: str = "+-- "
  21. endof_forest: str = " "
  22. within_forest: str = ": "
  23. within_tree: str = "| "
  24. class AsciiDirectedGlyphs(AsciiBaseGlyphs):
  25. last: str = "L-> "
  26. mid: str = "|-> "
  27. backedge: str = "<-"
  28. vertical_edge: str = "!"
  29. class AsciiUndirectedGlyphs(AsciiBaseGlyphs):
  30. last: str = "L-- "
  31. mid: str = "|-- "
  32. backedge: str = "-"
  33. vertical_edge: str = "|"
  34. class UtfBaseGlyphs(BaseGlyphs):
  35. # Notes on available box and arrow characters
  36. # https://en.wikipedia.org/wiki/Box-drawing_character
  37. # https://stackoverflow.com/questions/2701192/triangle-arrow
  38. empty: str = "╙"
  39. newtree_last: str = "╙── "
  40. newtree_mid: str = "╟── "
  41. endof_forest: str = " "
  42. within_forest: str = "╎ "
  43. within_tree: str = "│ "
  44. class UtfDirectedGlyphs(UtfBaseGlyphs):
  45. last: str = "└─╼ "
  46. mid: str = "├─╼ "
  47. backedge: str = "╾"
  48. vertical_edge: str = "╽"
  49. class UtfUndirectedGlyphs(UtfBaseGlyphs):
  50. last: str = "└── "
  51. mid: str = "├── "
  52. backedge: str = "─"
  53. vertical_edge: str = "│"
  54. def generate_network_text(
  55. graph,
  56. with_labels=True,
  57. sources=None,
  58. max_depth=None,
  59. ascii_only=False,
  60. vertical_chains=False,
  61. ):
  62. """Generate lines in the "network text" format
  63. This works via a depth-first traversal of the graph and writing a line for
  64. each unique node encountered. Non-tree edges are written to the right of
  65. each node, and connection to a non-tree edge is indicated with an ellipsis.
  66. This representation works best when the input graph is a forest, but any
  67. graph can be represented.
  68. This notation is original to networkx, although it is simple enough that it
  69. may be known in existing literature. See #5602 for details. The procedure
  70. is summarized as follows:
  71. 1. Given a set of source nodes (which can be specified, or automatically
  72. discovered via finding the (strongly) connected components and choosing one
  73. node with minimum degree from each), we traverse the graph in depth first
  74. order.
  75. 2. Each reachable node will be printed exactly once on it's own line.
  76. 3. Edges are indicated in one of four ways:
  77. a. a parent "L-style" connection on the upper left. This corresponds to
  78. a traversal in the directed DFS tree.
  79. b. a backref "<-style" connection shown directly on the right. For
  80. directed graphs, these are drawn for any incoming edges to a node that
  81. is not a parent edge. For undirected graphs, these are drawn for only
  82. the non-parent edges that have already been represented (The edges that
  83. have not been represented will be handled in the recursive case).
  84. c. a child "L-style" connection on the lower right. Drawing of the
  85. children are handled recursively.
  86. d. if ``vertical_chains`` is true, and a parent node only has one child
  87. a "vertical-style" edge is drawn between them.
  88. 4. The children of each node (wrt the directed DFS tree) are drawn
  89. underneath and to the right of it. In the case that a child node has already
  90. been drawn the connection is replaced with an ellipsis ("...") to indicate
  91. that there is one or more connections represented elsewhere.
  92. 5. If a maximum depth is specified, an edge to nodes past this maximum
  93. depth will be represented by an ellipsis.
  94. 6. If a node has a truthy "collapse" value, then we do not traverse past
  95. that node.
  96. Parameters
  97. ----------
  98. graph : nx.DiGraph | nx.Graph
  99. Graph to represent
  100. with_labels : bool | str
  101. If True will use the "label" attribute of a node to display if it
  102. exists otherwise it will use the node value itself. If given as a
  103. string, then that attribute name will be used instead of "label".
  104. Defaults to True.
  105. sources : List
  106. Specifies which nodes to start traversal from. Note: nodes that are not
  107. reachable from one of these sources may not be shown. If unspecified,
  108. the minimal set of nodes needed to reach all others will be used.
  109. max_depth : int | None
  110. The maximum depth to traverse before stopping. Defaults to None.
  111. ascii_only : Boolean
  112. If True only ASCII characters are used to construct the visualization
  113. vertical_chains : Boolean
  114. If True, chains of nodes will be drawn vertically when possible.
  115. Yields
  116. ------
  117. str : a line of generated text
  118. Examples
  119. --------
  120. >>> graph = nx.path_graph(10)
  121. >>> graph.add_node("A")
  122. >>> graph.add_node("B")
  123. >>> graph.add_node("C")
  124. >>> graph.add_node("D")
  125. >>> graph.add_edge(9, "A")
  126. >>> graph.add_edge(9, "B")
  127. >>> graph.add_edge(9, "C")
  128. >>> graph.add_edge("C", "D")
  129. >>> graph.add_edge("C", "E")
  130. >>> graph.add_edge("C", "F")
  131. >>> nx.write_network_text(graph)
  132. ╙── 0
  133. └── 1
  134. └── 2
  135. └── 3
  136. └── 4
  137. └── 5
  138. └── 6
  139. └── 7
  140. └── 8
  141. └── 9
  142. ├── A
  143. ├── B
  144. └── C
  145. ├── D
  146. ├── E
  147. └── F
  148. >>> nx.write_network_text(graph, vertical_chains=True)
  149. ╙── 0
  150. 1
  151. 2
  152. 3
  153. 4
  154. 5
  155. 6
  156. 7
  157. 8
  158. 9
  159. ├── A
  160. ├── B
  161. └── C
  162. ├── D
  163. ├── E
  164. └── F
  165. """
  166. from typing import Any, NamedTuple
  167. class StackFrame(NamedTuple):
  168. parent: Any
  169. node: Any
  170. indents: list
  171. this_islast: bool
  172. this_vertical: bool
  173. collapse_attr = "collapse"
  174. is_directed = graph.is_directed()
  175. if is_directed:
  176. glyphs = AsciiDirectedGlyphs if ascii_only else UtfDirectedGlyphs
  177. succ = graph.succ
  178. pred = graph.pred
  179. else:
  180. glyphs = AsciiUndirectedGlyphs if ascii_only else UtfUndirectedGlyphs
  181. succ = graph.adj
  182. pred = graph.adj
  183. if isinstance(with_labels, str):
  184. label_attr = with_labels
  185. elif with_labels:
  186. label_attr = "label"
  187. else:
  188. label_attr = None
  189. if max_depth == 0:
  190. yield glyphs.empty + " ..."
  191. elif len(graph.nodes) == 0:
  192. yield glyphs.empty
  193. else:
  194. # If the nodes to traverse are unspecified, find the minimal set of
  195. # nodes that will reach the entire graph
  196. if sources is None:
  197. sources = _find_sources(graph)
  198. # Populate the stack with each:
  199. # 1. parent node in the DFS tree (or None for root nodes),
  200. # 2. the current node in the DFS tree
  201. # 2. a list of indentations indicating depth
  202. # 3. a flag indicating if the node is the final one to be written.
  203. # Reverse the stack so sources are popped in the correct order.
  204. last_idx = len(sources) - 1
  205. stack = [
  206. StackFrame(None, node, [], (idx == last_idx), False)
  207. for idx, node in enumerate(sources)
  208. ][::-1]
  209. num_skipped_children = defaultdict(lambda: 0)
  210. seen_nodes = set()
  211. while stack:
  212. parent, node, indents, this_islast, this_vertical = stack.pop()
  213. if node is not Ellipsis:
  214. skip = node in seen_nodes
  215. if skip:
  216. # Mark that we skipped a parent's child
  217. num_skipped_children[parent] += 1
  218. if this_islast:
  219. # If we reached the last child of a parent, and we skipped
  220. # any of that parents children, then we should emit an
  221. # ellipsis at the end after this.
  222. if num_skipped_children[parent] and parent is not None:
  223. # Append the ellipsis to be emitted last
  224. next_islast = True
  225. try_frame = StackFrame(
  226. node, Ellipsis, indents, next_islast, False
  227. )
  228. stack.append(try_frame)
  229. # Redo this frame, but not as a last object
  230. next_islast = False
  231. try_frame = StackFrame(
  232. parent, node, indents, next_islast, this_vertical
  233. )
  234. stack.append(try_frame)
  235. continue
  236. if skip:
  237. continue
  238. seen_nodes.add(node)
  239. if not indents:
  240. # Top level items (i.e. trees in the forest) get different
  241. # glyphs to indicate they are not actually connected
  242. if this_islast:
  243. this_vertical = False
  244. this_prefix = indents + [glyphs.newtree_last]
  245. next_prefix = indents + [glyphs.endof_forest]
  246. else:
  247. this_prefix = indents + [glyphs.newtree_mid]
  248. next_prefix = indents + [glyphs.within_forest]
  249. else:
  250. # Non-top-level items
  251. if this_vertical:
  252. this_prefix = indents
  253. next_prefix = indents
  254. else:
  255. if this_islast:
  256. this_prefix = indents + [glyphs.last]
  257. next_prefix = indents + [glyphs.endof_forest]
  258. else:
  259. this_prefix = indents + [glyphs.mid]
  260. next_prefix = indents + [glyphs.within_tree]
  261. if node is Ellipsis:
  262. label = " ..."
  263. suffix = ""
  264. children = []
  265. else:
  266. if label_attr is not None:
  267. label = str(graph.nodes[node].get(label_attr, node))
  268. else:
  269. label = str(node)
  270. # Determine if we want to show the children of this node.
  271. if collapse_attr is not None:
  272. collapse = graph.nodes[node].get(collapse_attr, False)
  273. else:
  274. collapse = False
  275. # Determine:
  276. # (1) children to traverse into after showing this node.
  277. # (2) parents to immediately show to the right of this node.
  278. if is_directed:
  279. # In the directed case we must show every successor node
  280. # note: it may be skipped later, but we don't have that
  281. # information here.
  282. children = list(succ[node])
  283. # In the directed case we must show every predecessor
  284. # except for parent we directly traversed from.
  285. handled_parents = {parent}
  286. else:
  287. # Showing only the unseen children results in a more
  288. # concise representation for the undirected case.
  289. children = [
  290. child for child in succ[node] if child not in seen_nodes
  291. ]
  292. # In the undirected case, parents are also children, so we
  293. # only need to immediately show the ones we can no longer
  294. # traverse
  295. handled_parents = {*children, parent}
  296. if max_depth is not None and len(indents) == max_depth - 1:
  297. # Use ellipsis to indicate we have reached maximum depth
  298. if children:
  299. children = [Ellipsis]
  300. handled_parents = {parent}
  301. if collapse:
  302. # Collapsing a node is the same as reaching maximum depth
  303. if children:
  304. children = [Ellipsis]
  305. handled_parents = {parent}
  306. # The other parents are other predecessors of this node that
  307. # are not handled elsewhere.
  308. other_parents = [p for p in pred[node] if p not in handled_parents]
  309. if other_parents:
  310. if label_attr is not None:
  311. other_parents_labels = ", ".join(
  312. [
  313. str(graph.nodes[p].get(label_attr, p))
  314. for p in other_parents
  315. ]
  316. )
  317. else:
  318. other_parents_labels = ", ".join(
  319. [str(p) for p in other_parents]
  320. )
  321. suffix = " ".join(["", glyphs.backedge, other_parents_labels])
  322. else:
  323. suffix = ""
  324. # Emit the line for this node, this will be called for each node
  325. # exactly once.
  326. if this_vertical:
  327. yield "".join(this_prefix + [glyphs.vertical_edge])
  328. yield "".join(this_prefix + [label, suffix])
  329. if vertical_chains:
  330. if is_directed:
  331. num_children = len(set(children))
  332. else:
  333. num_children = len(set(children) - {parent})
  334. # The next node can be drawn vertically if it is the only
  335. # remaining child of this node.
  336. next_is_vertical = num_children == 1
  337. else:
  338. next_is_vertical = False
  339. # Push children on the stack in reverse order so they are popped in
  340. # the original order.
  341. for idx, child in enumerate(children[::-1]):
  342. next_islast = idx == 0
  343. try_frame = StackFrame(
  344. node, child, next_prefix, next_islast, next_is_vertical
  345. )
  346. stack.append(try_frame)
  347. @open_file(1, "w")
  348. def write_network_text(
  349. graph,
  350. path=None,
  351. with_labels=True,
  352. sources=None,
  353. max_depth=None,
  354. ascii_only=False,
  355. end="\n",
  356. vertical_chains=False,
  357. ):
  358. """Creates a nice text representation of a graph
  359. This works via a depth-first traversal of the graph and writing a line for
  360. each unique node encountered. Non-tree edges are written to the right of
  361. each node, and connection to a non-tree edge is indicated with an ellipsis.
  362. This representation works best when the input graph is a forest, but any
  363. graph can be represented.
  364. Parameters
  365. ----------
  366. graph : nx.DiGraph | nx.Graph
  367. Graph to represent
  368. path : string or file or callable or None
  369. Filename or file handle for data output.
  370. if a function, then it will be called for each generated line.
  371. if None, this will default to "sys.stdout.write"
  372. with_labels : bool | str
  373. If True will use the "label" attribute of a node to display if it
  374. exists otherwise it will use the node value itself. If given as a
  375. string, then that attribute name will be used instead of "label".
  376. Defaults to True.
  377. sources : List
  378. Specifies which nodes to start traversal from. Note: nodes that are not
  379. reachable from one of these sources may not be shown. If unspecified,
  380. the minimal set of nodes needed to reach all others will be used.
  381. max_depth : int | None
  382. The maximum depth to traverse before stopping. Defaults to None.
  383. ascii_only : Boolean
  384. If True only ASCII characters are used to construct the visualization
  385. end : string
  386. The line ending character
  387. vertical_chains : Boolean
  388. If True, chains of nodes will be drawn vertically when possible.
  389. Examples
  390. --------
  391. >>> graph = nx.balanced_tree(r=2, h=2, create_using=nx.DiGraph)
  392. >>> nx.write_network_text(graph)
  393. ╙── 0
  394. ├─╼ 1
  395. │ ├─╼ 3
  396. │ └─╼ 4
  397. └─╼ 2
  398. ├─╼ 5
  399. └─╼ 6
  400. >>> # A near tree with one non-tree edge
  401. >>> graph.add_edge(5, 1)
  402. >>> nx.write_network_text(graph)
  403. ╙── 0
  404. ├─╼ 1 ╾ 5
  405. │ ├─╼ 3
  406. │ └─╼ 4
  407. └─╼ 2
  408. ├─╼ 5
  409. │ └─╼ ...
  410. └─╼ 6
  411. >>> graph = nx.cycle_graph(5)
  412. >>> nx.write_network_text(graph)
  413. ╙── 0
  414. ├── 1
  415. │ └── 2
  416. │ └── 3
  417. │ └── 4 ─ 0
  418. └── ...
  419. >>> graph = nx.cycle_graph(5, nx.DiGraph)
  420. >>> nx.write_network_text(graph, vertical_chains=True)
  421. ╙── 0 ╾ 4
  422. 1
  423. 2
  424. 3
  425. 4
  426. └─╼ ...
  427. >>> nx.write_network_text(graph, vertical_chains=True, ascii_only=True)
  428. +-- 0 <- 4
  429. !
  430. 1
  431. !
  432. 2
  433. !
  434. 3
  435. !
  436. 4
  437. L-> ...
  438. >>> graph = nx.generators.barbell_graph(4, 2)
  439. >>> nx.write_network_text(graph, vertical_chains=False)
  440. ╙── 4
  441. ├── 5
  442. │ └── 6
  443. │ ├── 7
  444. │ │ ├── 8 ─ 6
  445. │ │ │ └── 9 ─ 6, 7
  446. │ │ └── ...
  447. │ └── ...
  448. └── 3
  449. ├── 0
  450. │ ├── 1 ─ 3
  451. │ │ └── 2 ─ 0, 3
  452. │ └── ...
  453. └── ...
  454. >>> nx.write_network_text(graph, vertical_chains=True)
  455. ╙── 4
  456. ├── 5
  457. │ │
  458. │ 6
  459. │ ├── 7
  460. │ │ ├── 8 ─ 6
  461. │ │ │ │
  462. │ │ │ 9 ─ 6, 7
  463. │ │ └── ...
  464. │ └── ...
  465. └── 3
  466. ├── 0
  467. │ ├── 1 ─ 3
  468. │ │ │
  469. │ │ 2 ─ 0, 3
  470. │ └── ...
  471. └── ...
  472. >>> graph = nx.complete_graph(5, create_using=nx.Graph)
  473. >>> nx.write_network_text(graph)
  474. ╙── 0
  475. ├── 1
  476. │ ├── 2 ─ 0
  477. │ │ ├── 3 ─ 0, 1
  478. │ │ │ └── 4 ─ 0, 1, 2
  479. │ │ └── ...
  480. │ └── ...
  481. └── ...
  482. >>> graph = nx.complete_graph(3, create_using=nx.DiGraph)
  483. >>> nx.write_network_text(graph)
  484. ╙── 0 ╾ 1, 2
  485. ├─╼ 1 ╾ 2
  486. │ ├─╼ 2 ╾ 0
  487. │ │ └─╼ ...
  488. │ └─╼ ...
  489. └─╼ ...
  490. """
  491. if path is None:
  492. # The path is unspecified, write to stdout
  493. _write = sys.stdout.write
  494. elif hasattr(path, "write"):
  495. # The path is already an open file
  496. _write = path.write
  497. elif callable(path):
  498. # The path is a custom callable
  499. _write = path
  500. else:
  501. raise TypeError(type(path))
  502. for line in generate_network_text(
  503. graph,
  504. with_labels=with_labels,
  505. sources=sources,
  506. max_depth=max_depth,
  507. ascii_only=ascii_only,
  508. vertical_chains=vertical_chains,
  509. ):
  510. _write(line + end)
  511. def _find_sources(graph):
  512. """
  513. Determine a minimal set of nodes such that the entire graph is reachable
  514. """
  515. # For each connected part of the graph, choose at least
  516. # one node as a starting point, preferably without a parent
  517. if graph.is_directed():
  518. # Choose one node from each SCC with minimum in_degree
  519. sccs = list(nx.strongly_connected_components(graph))
  520. # condensing the SCCs forms a dag, the nodes in this graph with
  521. # 0 in-degree correspond to the SCCs from which the minimum set
  522. # of nodes from which all other nodes can be reached.
  523. scc_graph = nx.condensation(graph, sccs)
  524. supernode_to_nodes = {sn: [] for sn in scc_graph.nodes()}
  525. # Note: the order of mapping differs between pypy and cpython
  526. # so we have to loop over graph nodes for consistency
  527. mapping = scc_graph.graph["mapping"]
  528. for n in graph.nodes:
  529. sn = mapping[n]
  530. supernode_to_nodes[sn].append(n)
  531. sources = []
  532. for sn in scc_graph.nodes():
  533. if scc_graph.in_degree[sn] == 0:
  534. scc = supernode_to_nodes[sn]
  535. node = min(scc, key=lambda n: graph.in_degree[n])
  536. sources.append(node)
  537. else:
  538. # For undirected graph, the entire graph will be reachable as
  539. # long as we consider one node from every connected component
  540. sources = [
  541. min(cc, key=lambda n: graph.degree[n])
  542. for cc in nx.connected_components(graph)
  543. ]
  544. sources = sorted(sources, key=lambda n: graph.degree[n])
  545. return sources
  546. def _parse_network_text(lines):
  547. """Reconstructs a graph from a network text representation.
  548. This is mainly used for testing. Network text is for display, not
  549. serialization, as such this cannot parse all network text representations
  550. because node labels can be ambiguous with the glyphs and indentation used
  551. to represent edge structure. Additionally, there is no way to determine if
  552. disconnected graphs were originally directed or undirected.
  553. Parameters
  554. ----------
  555. lines : list or iterator of strings
  556. Input data in network text format
  557. Returns
  558. -------
  559. G: NetworkX graph
  560. The graph corresponding to the lines in network text format.
  561. """
  562. from itertools import chain
  563. from typing import Any, NamedTuple
  564. class ParseStackFrame(NamedTuple):
  565. node: Any
  566. indent: int
  567. has_vertical_child: int | None
  568. initial_line_iter = iter(lines)
  569. is_ascii = None
  570. is_directed = None
  571. ##############
  572. # Initial Pass
  573. ##############
  574. # Do an initial pass over the lines to determine what type of graph it is.
  575. # Remember what these lines were, so we can reiterate over them in the
  576. # parsing pass.
  577. initial_lines = []
  578. try:
  579. first_line = next(initial_line_iter)
  580. except StopIteration:
  581. ...
  582. else:
  583. initial_lines.append(first_line)
  584. # The first character indicates if it is an ASCII or UTF graph
  585. first_char = first_line[0]
  586. if first_char in {
  587. UtfBaseGlyphs.empty,
  588. UtfBaseGlyphs.newtree_mid[0],
  589. UtfBaseGlyphs.newtree_last[0],
  590. }:
  591. is_ascii = False
  592. elif first_char in {
  593. AsciiBaseGlyphs.empty,
  594. AsciiBaseGlyphs.newtree_mid[0],
  595. AsciiBaseGlyphs.newtree_last[0],
  596. }:
  597. is_ascii = True
  598. else:
  599. raise AssertionError(f"Unexpected first character: {first_char}")
  600. if is_ascii:
  601. directed_glyphs = AsciiDirectedGlyphs.as_dict()
  602. undirected_glyphs = AsciiUndirectedGlyphs.as_dict()
  603. else:
  604. directed_glyphs = UtfDirectedGlyphs.as_dict()
  605. undirected_glyphs = UtfUndirectedGlyphs.as_dict()
  606. # For both directed / undirected glyphs, determine which glyphs never
  607. # appear as substrings in the other undirected / directed glyphs. Glyphs
  608. # with this property unambiguously indicates if a graph is directed /
  609. # undirected.
  610. directed_items = set(directed_glyphs.values())
  611. undirected_items = set(undirected_glyphs.values())
  612. unambiguous_directed_items = []
  613. for item in directed_items:
  614. other_items = undirected_items
  615. other_supersets = [other for other in other_items if item in other]
  616. if not other_supersets:
  617. unambiguous_directed_items.append(item)
  618. unambiguous_undirected_items = []
  619. for item in undirected_items:
  620. other_items = directed_items
  621. other_supersets = [other for other in other_items if item in other]
  622. if not other_supersets:
  623. unambiguous_undirected_items.append(item)
  624. for line in initial_line_iter:
  625. initial_lines.append(line)
  626. if any(item in line for item in unambiguous_undirected_items):
  627. is_directed = False
  628. break
  629. elif any(item in line for item in unambiguous_directed_items):
  630. is_directed = True
  631. break
  632. if is_directed is None:
  633. # Not enough information to determine, choose undirected by default
  634. is_directed = False
  635. glyphs = directed_glyphs if is_directed else undirected_glyphs
  636. # the backedge symbol by itself can be ambiguous, but with spaces around it
  637. # becomes unambiguous.
  638. backedge_symbol = " " + glyphs["backedge"] + " "
  639. # Reconstruct an iterator over all of the lines.
  640. parsing_line_iter = chain(initial_lines, initial_line_iter)
  641. ##############
  642. # Parsing Pass
  643. ##############
  644. edges = []
  645. nodes = []
  646. is_empty = None
  647. noparent = object() # sentinel value
  648. # keep a stack of previous nodes that could be parents of subsequent nodes
  649. stack = [ParseStackFrame(noparent, -1, None)]
  650. for line in parsing_line_iter:
  651. if line == glyphs["empty"]:
  652. # If the line is the empty glyph, we are done.
  653. # There shouldn't be anything else after this.
  654. is_empty = True
  655. continue
  656. if backedge_symbol in line:
  657. # This line has one or more backedges, separate those out
  658. node_part, backedge_part = line.split(backedge_symbol)
  659. backedge_nodes = [u.strip() for u in backedge_part.split(", ")]
  660. # Now the node can be parsed
  661. node_part = node_part.rstrip()
  662. prefix, node = node_part.rsplit(" ", 1)
  663. node = node.strip()
  664. # Add the backedges to the edge list
  665. edges.extend([(u, node) for u in backedge_nodes])
  666. else:
  667. # No backedge, the tail of this line is the node
  668. prefix, node = line.rsplit(" ", 1)
  669. node = node.strip()
  670. prev = stack.pop()
  671. if node in glyphs["vertical_edge"]:
  672. # Previous node is still the previous node, but we know it will
  673. # have exactly one child, which will need to have its nesting level
  674. # adjusted.
  675. modified_prev = ParseStackFrame(
  676. prev.node,
  677. prev.indent,
  678. True,
  679. )
  680. stack.append(modified_prev)
  681. continue
  682. # The length of the string before the node characters give us a hint
  683. # about our nesting level. The only case where this doesn't work is
  684. # when there are vertical chains, which is handled explicitly.
  685. indent = len(prefix)
  686. curr = ParseStackFrame(node, indent, None)
  687. if prev.has_vertical_child:
  688. # In this case we know prev must be the parent of our current line,
  689. # so we don't have to search the stack. (which is good because the
  690. # indentation check wouldn't work in this case).
  691. ...
  692. else:
  693. # If the previous node nesting-level is greater than the current
  694. # nodes nesting-level than the previous node was the end of a path,
  695. # and is not our parent. We can safely pop nodes off the stack
  696. # until we find one with a comparable nesting-level, which is our
  697. # parent.
  698. while curr.indent <= prev.indent:
  699. prev = stack.pop()
  700. if node == "...":
  701. # The current previous node is no longer a valid parent,
  702. # keep it popped from the stack.
  703. stack.append(prev)
  704. else:
  705. # The previous and current nodes may still be parents, so add them
  706. # back onto the stack.
  707. stack.append(prev)
  708. stack.append(curr)
  709. # Add the node and the edge to its parent to the node / edge lists.
  710. nodes.append(curr.node)
  711. if prev.node is not noparent:
  712. edges.append((prev.node, curr.node))
  713. if is_empty:
  714. # Sanity check
  715. assert len(nodes) == 0
  716. # Reconstruct the graph
  717. cls = nx.DiGraph if is_directed else nx.Graph
  718. new = cls()
  719. new.add_nodes_from(nodes)
  720. new.add_edges_from(edges)
  721. return new