text.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. """
  2. Text-based visual representations of graphs
  3. """
  4. import sys
  5. import warnings
  6. from collections import defaultdict
  7. import networkx as nx
  8. from networkx.utils import open_file
  9. __all__ = ["forest_str", "generate_network_text", "write_network_text"]
  10. class _AsciiBaseGlyphs:
  11. empty = "+"
  12. newtree_last = "+-- "
  13. newtree_mid = "+-- "
  14. endof_forest = " "
  15. within_forest = ": "
  16. within_tree = "| "
  17. class AsciiDirectedGlyphs(_AsciiBaseGlyphs):
  18. last = "L-> "
  19. mid = "|-> "
  20. backedge = "<-"
  21. class AsciiUndirectedGlyphs(_AsciiBaseGlyphs):
  22. last = "L-- "
  23. mid = "|-- "
  24. backedge = "-"
  25. class _UtfBaseGlyphs:
  26. # Notes on available box and arrow characters
  27. # https://en.wikipedia.org/wiki/Box-drawing_character
  28. # https://stackoverflow.com/questions/2701192/triangle-arrow
  29. empty = "╙"
  30. newtree_last = "╙── "
  31. newtree_mid = "╟── "
  32. endof_forest = " "
  33. within_forest = "╎ "
  34. within_tree = "│ "
  35. class UtfDirectedGlyphs(_UtfBaseGlyphs):
  36. last = "└─╼ "
  37. mid = "├─╼ "
  38. backedge = "╾"
  39. class UtfUndirectedGlyphs(_UtfBaseGlyphs):
  40. last = "└── "
  41. mid = "├── "
  42. backedge = "─"
  43. def generate_network_text(
  44. graph, with_labels=True, sources=None, max_depth=None, ascii_only=False
  45. ):
  46. """Generate lines in the "network text" format
  47. This works via a depth-first traversal of the graph and writing a line for
  48. each unique node encountered. Non-tree edges are written to the right of
  49. each node, and connection to a non-tree edge is indicated with an ellipsis.
  50. This representation works best when the input graph is a forest, but any
  51. graph can be represented.
  52. This notation is original to networkx, although it is simple enough that it
  53. may be known in existing literature. See #5602 for details. The procedure
  54. is summarized as follows:
  55. 1. Given a set of source nodes (which can be specified, or automatically
  56. discovered via finding the (strongly) connected components and choosing one
  57. node with minimum degree from each), we traverse the graph in depth first
  58. order.
  59. 2. Each reachable node will be printed exactly once on it's own line.
  60. 3. Edges are indicated in one of three ways:
  61. a. a parent "L-style" connection on the upper left. This corresponds to
  62. a traversal in the directed DFS tree.
  63. b. a backref "<-style" connection shown directly on the right. For
  64. directed graphs, these are drawn for any incoming edges to a node that
  65. is not a parent edge. For undirected graphs, these are drawn for only
  66. the non-parent edges that have already been represented (The edges that
  67. have not been represented will be handled in the recursive case).
  68. c. a child "L-style" connection on the lower right. Drawing of the
  69. children are handled recursively.
  70. 4. The children of each node (wrt the directed DFS tree) are drawn
  71. underneath and to the right of it. In the case that a child node has already
  72. been drawn the connection is replaced with an ellipsis ("...") to indicate
  73. that there is one or more connections represented elsewhere.
  74. 5. If a maximum depth is specified, an edge to nodes past this maximum
  75. depth will be represented by an ellipsis.
  76. Parameters
  77. ----------
  78. graph : nx.DiGraph | nx.Graph
  79. Graph to represent
  80. with_labels : bool | str
  81. If True will use the "label" attribute of a node to display if it
  82. exists otherwise it will use the node value itself. If given as a
  83. string, then that attribute name will be used instead of "label".
  84. Defaults to True.
  85. sources : List
  86. Specifies which nodes to start traversal from. Note: nodes that are not
  87. reachable from one of these sources may not be shown. If unspecified,
  88. the minimal set of nodes needed to reach all others will be used.
  89. max_depth : int | None
  90. The maximum depth to traverse before stopping. Defaults to None.
  91. ascii_only : Boolean
  92. If True only ASCII characters are used to construct the visualization
  93. Yields
  94. ------
  95. str : a line of generated text
  96. """
  97. is_directed = graph.is_directed()
  98. if is_directed:
  99. glyphs = AsciiDirectedGlyphs if ascii_only else UtfDirectedGlyphs
  100. succ = graph.succ
  101. pred = graph.pred
  102. else:
  103. glyphs = AsciiUndirectedGlyphs if ascii_only else UtfUndirectedGlyphs
  104. succ = graph.adj
  105. pred = graph.adj
  106. if isinstance(with_labels, str):
  107. label_attr = with_labels
  108. elif with_labels:
  109. label_attr = "label"
  110. else:
  111. label_attr = None
  112. if max_depth == 0:
  113. yield glyphs.empty + " ..."
  114. elif len(graph.nodes) == 0:
  115. yield glyphs.empty
  116. else:
  117. # If the nodes to traverse are unspecified, find the minimal set of
  118. # nodes that will reach the entire graph
  119. if sources is None:
  120. sources = _find_sources(graph)
  121. # Populate the stack with each:
  122. # 1. parent node in the DFS tree (or None for root nodes),
  123. # 2. the current node in the DFS tree
  124. # 2. a list of indentations indicating depth
  125. # 3. a flag indicating if the node is the final one to be written.
  126. # Reverse the stack so sources are popped in the correct order.
  127. last_idx = len(sources) - 1
  128. stack = [
  129. (None, node, [], (idx == last_idx)) for idx, node in enumerate(sources)
  130. ][::-1]
  131. num_skipped_children = defaultdict(lambda: 0)
  132. seen_nodes = set()
  133. while stack:
  134. parent, node, indents, this_islast = stack.pop()
  135. if node is not Ellipsis:
  136. skip = node in seen_nodes
  137. if skip:
  138. # Mark that we skipped a parent's child
  139. num_skipped_children[parent] += 1
  140. if this_islast:
  141. # If we reached the last child of a parent, and we skipped
  142. # any of that parents children, then we should emit an
  143. # ellipsis at the end after this.
  144. if num_skipped_children[parent] and parent is not None:
  145. # Append the ellipsis to be emitted last
  146. next_islast = True
  147. try_frame = (node, Ellipsis, indents, next_islast)
  148. stack.append(try_frame)
  149. # Redo this frame, but not as a last object
  150. next_islast = False
  151. try_frame = (parent, node, indents, next_islast)
  152. stack.append(try_frame)
  153. continue
  154. if skip:
  155. continue
  156. seen_nodes.add(node)
  157. if not indents:
  158. # Top level items (i.e. trees in the forest) get different
  159. # glyphs to indicate they are not actually connected
  160. if this_islast:
  161. this_prefix = indents + [glyphs.newtree_last]
  162. next_prefix = indents + [glyphs.endof_forest]
  163. else:
  164. this_prefix = indents + [glyphs.newtree_mid]
  165. next_prefix = indents + [glyphs.within_forest]
  166. else:
  167. # For individual tree edges distinguish between directed and
  168. # undirected cases
  169. if this_islast:
  170. this_prefix = indents + [glyphs.last]
  171. next_prefix = indents + [glyphs.endof_forest]
  172. else:
  173. this_prefix = indents + [glyphs.mid]
  174. next_prefix = indents + [glyphs.within_tree]
  175. if node is Ellipsis:
  176. label = " ..."
  177. suffix = ""
  178. children = []
  179. else:
  180. if label_attr is not None:
  181. label = str(graph.nodes[node].get(label_attr, node))
  182. else:
  183. label = str(node)
  184. # Determine:
  185. # (1) children to traverse into after showing this node.
  186. # (2) parents to immediately show to the right of this node.
  187. if is_directed:
  188. # In the directed case we must show every successor node
  189. # note: it may be skipped later, but we don't have that
  190. # information here.
  191. children = list(succ[node])
  192. # In the directed case we must show every predecessor
  193. # except for parent we directly traversed from.
  194. handled_parents = {parent}
  195. else:
  196. # Showing only the unseen children results in a more
  197. # concise representation for the undirected case.
  198. children = [
  199. child for child in succ[node] if child not in seen_nodes
  200. ]
  201. # In the undirected case, parents are also children, so we
  202. # only need to immediately show the ones we can no longer
  203. # traverse
  204. handled_parents = {*children, parent}
  205. if max_depth is not None and len(indents) == max_depth - 1:
  206. # Use ellipsis to indicate we have reached maximum depth
  207. if children:
  208. children = [Ellipsis]
  209. handled_parents = {parent}
  210. # The other parents are other predecessors of this node that
  211. # are not handled elsewhere.
  212. other_parents = [p for p in pred[node] if p not in handled_parents]
  213. if other_parents:
  214. if label_attr is not None:
  215. other_parents_labels = ", ".join(
  216. [
  217. str(graph.nodes[p].get(label_attr, p))
  218. for p in other_parents
  219. ]
  220. )
  221. else:
  222. other_parents_labels = ", ".join(
  223. [str(p) for p in other_parents]
  224. )
  225. suffix = " ".join(["", glyphs.backedge, other_parents_labels])
  226. else:
  227. suffix = ""
  228. # Emit the line for this node, this will be called for each node
  229. # exactly once.
  230. yield "".join(this_prefix + [label, suffix])
  231. # Push children on the stack in reverse order so they are popped in
  232. # the original order.
  233. for idx, child in enumerate(children[::-1]):
  234. next_islast = idx == 0
  235. try_frame = (node, child, next_prefix, next_islast)
  236. stack.append(try_frame)
  237. @open_file(1, "w")
  238. def write_network_text(
  239. graph,
  240. path=None,
  241. with_labels=True,
  242. sources=None,
  243. max_depth=None,
  244. ascii_only=False,
  245. end="\n",
  246. ):
  247. """Creates a nice text representation of a graph
  248. This works via a depth-first traversal of the graph and writing a line for
  249. each unique node encountered. Non-tree edges are written to the right of
  250. each node, and connection to a non-tree edge is indicated with an ellipsis.
  251. This representation works best when the input graph is a forest, but any
  252. graph can be represented.
  253. Parameters
  254. ----------
  255. graph : nx.DiGraph | nx.Graph
  256. Graph to represent
  257. path : string or file or callable or None
  258. Filename or file handle for data output.
  259. if a function, then it will be called for each generated line.
  260. if None, this will default to "sys.stdout.write"
  261. with_labels : bool | str
  262. If True will use the "label" attribute of a node to display if it
  263. exists otherwise it will use the node value itself. If given as a
  264. string, then that attribute name will be used instead of "label".
  265. Defaults to True.
  266. sources : List
  267. Specifies which nodes to start traversal from. Note: nodes that are not
  268. reachable from one of these sources may not be shown. If unspecified,
  269. the minimal set of nodes needed to reach all others will be used.
  270. max_depth : int | None
  271. The maximum depth to traverse before stopping. Defaults to None.
  272. ascii_only : Boolean
  273. If True only ASCII characters are used to construct the visualization
  274. end : string
  275. The line ending character
  276. Examples
  277. --------
  278. >>> graph = nx.balanced_tree(r=2, h=2, create_using=nx.DiGraph)
  279. >>> nx.write_network_text(graph)
  280. ╙── 0
  281. ├─╼ 1
  282. │ ├─╼ 3
  283. │ └─╼ 4
  284. └─╼ 2
  285. ├─╼ 5
  286. └─╼ 6
  287. >>> # A near tree with one non-tree edge
  288. >>> graph.add_edge(5, 1)
  289. >>> nx.write_network_text(graph)
  290. ╙── 0
  291. ├─╼ 1 ╾ 5
  292. │ ├─╼ 3
  293. │ └─╼ 4
  294. └─╼ 2
  295. ├─╼ 5
  296. │ └─╼ ...
  297. └─╼ 6
  298. >>> graph = nx.cycle_graph(5)
  299. >>> nx.write_network_text(graph)
  300. ╙── 0
  301. ├── 1
  302. │ └── 2
  303. │ └── 3
  304. │ └── 4 ─ 0
  305. └── ...
  306. >>> graph = nx.generators.barbell_graph(4, 2)
  307. >>> nx.write_network_text(graph)
  308. ╙── 4
  309. ├── 5
  310. │ └── 6
  311. │ ├── 7
  312. │ │ ├── 8 ─ 6
  313. │ │ │ └── 9 ─ 6, 7
  314. │ │ └── ...
  315. │ └── ...
  316. └── 3
  317. ├── 0
  318. │ ├── 1 ─ 3
  319. │ │ └── 2 ─ 0, 3
  320. │ └── ...
  321. └── ...
  322. >>> graph = nx.complete_graph(5, create_using=nx.Graph)
  323. >>> nx.write_network_text(graph)
  324. ╙── 0
  325. ├── 1
  326. │ ├── 2 ─ 0
  327. │ │ ├── 3 ─ 0, 1
  328. │ │ │ └── 4 ─ 0, 1, 2
  329. │ │ └── ...
  330. │ └── ...
  331. └── ...
  332. >>> graph = nx.complete_graph(3, create_using=nx.DiGraph)
  333. >>> nx.write_network_text(graph)
  334. ╙── 0 ╾ 1, 2
  335. ├─╼ 1 ╾ 2
  336. │ ├─╼ 2 ╾ 0
  337. │ │ └─╼ ...
  338. │ └─╼ ...
  339. └─╼ ...
  340. """
  341. if path is None:
  342. # The path is unspecified, write to stdout
  343. _write = sys.stdout.write
  344. elif hasattr(path, "write"):
  345. # The path is already an open file
  346. _write = path.write
  347. elif callable(path):
  348. # The path is a custom callable
  349. _write = path
  350. else:
  351. raise TypeError(type(path))
  352. for line in generate_network_text(
  353. graph,
  354. with_labels=with_labels,
  355. sources=sources,
  356. max_depth=max_depth,
  357. ascii_only=ascii_only,
  358. ):
  359. _write(line + end)
  360. def _find_sources(graph):
  361. """
  362. Determine a minimal set of nodes such that the entire graph is reachable
  363. """
  364. # For each connected part of the graph, choose at least
  365. # one node as a starting point, preferably without a parent
  366. if graph.is_directed():
  367. # Choose one node from each SCC with minimum in_degree
  368. sccs = list(nx.strongly_connected_components(graph))
  369. # condensing the SCCs forms a dag, the nodes in this graph with
  370. # 0 in-degree correspond to the SCCs from which the minimum set
  371. # of nodes from which all other nodes can be reached.
  372. scc_graph = nx.condensation(graph, sccs)
  373. supernode_to_nodes = {sn: [] for sn in scc_graph.nodes()}
  374. # Note: the order of mapping differs between pypy and cpython
  375. # so we have to loop over graph nodes for consistency
  376. mapping = scc_graph.graph["mapping"]
  377. for n in graph.nodes:
  378. sn = mapping[n]
  379. supernode_to_nodes[sn].append(n)
  380. sources = []
  381. for sn in scc_graph.nodes():
  382. if scc_graph.in_degree[sn] == 0:
  383. scc = supernode_to_nodes[sn]
  384. node = min(scc, key=lambda n: graph.in_degree[n])
  385. sources.append(node)
  386. else:
  387. # For undirected graph, the entire graph will be reachable as
  388. # long as we consider one node from every connected component
  389. sources = [
  390. min(cc, key=lambda n: graph.degree[n])
  391. for cc in nx.connected_components(graph)
  392. ]
  393. sources = sorted(sources, key=lambda n: graph.degree[n])
  394. return sources
  395. def forest_str(graph, with_labels=True, sources=None, write=None, ascii_only=False):
  396. """Creates a nice utf8 representation of a forest
  397. This function has been superseded by
  398. :func:`nx.readwrite.text.generate_network_text`, which should be used
  399. instead.
  400. Parameters
  401. ----------
  402. graph : nx.DiGraph | nx.Graph
  403. Graph to represent (must be a tree, forest, or the empty graph)
  404. with_labels : bool
  405. If True will use the "label" attribute of a node to display if it
  406. exists otherwise it will use the node value itself. Defaults to True.
  407. sources : List
  408. Mainly relevant for undirected forests, specifies which nodes to list
  409. first. If unspecified the root nodes of each tree will be used for
  410. directed forests; for undirected forests this defaults to the nodes
  411. with the smallest degree.
  412. write : callable
  413. Function to use to write to, if None new lines are appended to
  414. a list and returned. If set to the `print` function, lines will
  415. be written to stdout as they are generated. If specified,
  416. this function will return None. Defaults to None.
  417. ascii_only : Boolean
  418. If True only ASCII characters are used to construct the visualization
  419. Returns
  420. -------
  421. str | None :
  422. utf8 representation of the tree / forest
  423. Examples
  424. --------
  425. >>> graph = nx.balanced_tree(r=2, h=3, create_using=nx.DiGraph)
  426. >>> print(nx.forest_str(graph))
  427. ╙── 0
  428. ├─╼ 1
  429. │ ├─╼ 3
  430. │ │ ├─╼ 7
  431. │ │ └─╼ 8
  432. │ └─╼ 4
  433. │ ├─╼ 9
  434. │ └─╼ 10
  435. └─╼ 2
  436. ├─╼ 5
  437. │ ├─╼ 11
  438. │ └─╼ 12
  439. └─╼ 6
  440. ├─╼ 13
  441. └─╼ 14
  442. >>> graph = nx.balanced_tree(r=1, h=2, create_using=nx.Graph)
  443. >>> print(nx.forest_str(graph))
  444. ╙── 0
  445. └── 1
  446. └── 2
  447. >>> print(nx.forest_str(graph, ascii_only=True))
  448. +-- 0
  449. L-- 1
  450. L-- 2
  451. """
  452. msg = (
  453. "\nforest_str is deprecated as of version 3.1 and will be removed "
  454. "in version 3.3. Use generate_network_text or write_network_text "
  455. "instead.\n"
  456. )
  457. warnings.warn(msg, DeprecationWarning)
  458. if len(graph.nodes) > 0:
  459. if not nx.is_forest(graph):
  460. raise nx.NetworkXNotImplemented("input must be a forest or the empty graph")
  461. printbuf = []
  462. if write is None:
  463. _write = printbuf.append
  464. else:
  465. _write = write
  466. write_network_text(
  467. graph,
  468. _write,
  469. with_labels=with_labels,
  470. sources=sources,
  471. ascii_only=ascii_only,
  472. end="",
  473. )
  474. if write is None:
  475. # Only return a string if the custom write function was not specified
  476. return "\n".join(printbuf)