Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.9/dist-packages/networkx/readwrite/text.py: 17%

291 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-20 07:00 +0000

1""" 

2Text-based visual representations of graphs 

3""" 

4import sys 

5import warnings 

6from collections import defaultdict 

7 

8import networkx as nx 

9from networkx.utils import open_file 

10 

11__all__ = ["forest_str", "generate_network_text", "write_network_text"] 

12 

13 

14class BaseGlyphs: 

15 @classmethod 

16 def as_dict(cls): 

17 return { 

18 a: getattr(cls, a) 

19 for a in dir(cls) 

20 if not a.startswith("_") and a != "as_dict" 

21 } 

22 

23 

24class AsciiBaseGlyphs(BaseGlyphs): 

25 empty: str = "+" 

26 newtree_last: str = "+-- " 

27 newtree_mid: str = "+-- " 

28 endof_forest: str = " " 

29 within_forest: str = ": " 

30 within_tree: str = "| " 

31 

32 

33class AsciiDirectedGlyphs(AsciiBaseGlyphs): 

34 last: str = "L-> " 

35 mid: str = "|-> " 

36 backedge: str = "<-" 

37 vertical_edge: str = "!" 

38 

39 

40class AsciiUndirectedGlyphs(AsciiBaseGlyphs): 

41 last: str = "L-- " 

42 mid: str = "|-- " 

43 backedge: str = "-" 

44 vertical_edge: str = "|" 

45 

46 

47class UtfBaseGlyphs(BaseGlyphs): 

48 # Notes on available box and arrow characters 

49 # https://en.wikipedia.org/wiki/Box-drawing_character 

50 # https://stackoverflow.com/questions/2701192/triangle-arrow 

51 empty: str = "╙" 

52 newtree_last: str = "╙── " 

53 newtree_mid: str = "╟── " 

54 endof_forest: str = " " 

55 within_forest: str = "╎ " 

56 within_tree: str = "│ " 

57 

58 

59class UtfDirectedGlyphs(UtfBaseGlyphs): 

60 last: str = "└─╼ " 

61 mid: str = "├─╼ " 

62 backedge: str = "╾" 

63 vertical_edge: str = "╽" 

64 

65 

66class UtfUndirectedGlyphs(UtfBaseGlyphs): 

67 last: str = "└── " 

68 mid: str = "├── " 

69 backedge: str = "─" 

70 vertical_edge: str = "│" 

71 

72 

73def generate_network_text( 

74 graph, 

75 with_labels=True, 

76 sources=None, 

77 max_depth=None, 

78 ascii_only=False, 

79 vertical_chains=False, 

80): 

81 """Generate lines in the "network text" format 

82 

83 This works via a depth-first traversal of the graph and writing a line for 

84 each unique node encountered. Non-tree edges are written to the right of 

85 each node, and connection to a non-tree edge is indicated with an ellipsis. 

86 This representation works best when the input graph is a forest, but any 

87 graph can be represented. 

88 

89 This notation is original to networkx, although it is simple enough that it 

90 may be known in existing literature. See #5602 for details. The procedure 

91 is summarized as follows: 

92 

93 1. Given a set of source nodes (which can be specified, or automatically 

94 discovered via finding the (strongly) connected components and choosing one 

95 node with minimum degree from each), we traverse the graph in depth first 

96 order. 

97 

98 2. Each reachable node will be printed exactly once on it's own line. 

99 

100 3. Edges are indicated in one of four ways: 

101 

102 a. a parent "L-style" connection on the upper left. This corresponds to 

103 a traversal in the directed DFS tree. 

104 

105 b. a backref "<-style" connection shown directly on the right. For 

106 directed graphs, these are drawn for any incoming edges to a node that 

107 is not a parent edge. For undirected graphs, these are drawn for only 

108 the non-parent edges that have already been represented (The edges that 

109 have not been represented will be handled in the recursive case). 

110 

111 c. a child "L-style" connection on the lower right. Drawing of the 

112 children are handled recursively. 

113 

114 d. if ``vertical_chains`` is true, and a parent node only has one child 

115 a "vertical-style" edge is drawn between them. 

116 

117 4. The children of each node (wrt the directed DFS tree) are drawn 

118 underneath and to the right of it. In the case that a child node has already 

119 been drawn the connection is replaced with an ellipsis ("...") to indicate 

120 that there is one or more connections represented elsewhere. 

121 

122 5. If a maximum depth is specified, an edge to nodes past this maximum 

123 depth will be represented by an ellipsis. 

124 

125 6. If a a node has a truthy "collapse" value, then we do not traverse past 

126 that node. 

127 

128 Parameters 

129 ---------- 

130 graph : nx.DiGraph | nx.Graph 

131 Graph to represent 

132 

133 with_labels : bool | str 

134 If True will use the "label" attribute of a node to display if it 

135 exists otherwise it will use the node value itself. If given as a 

136 string, then that attribute name will be used instead of "label". 

137 Defaults to True. 

138 

139 sources : List 

140 Specifies which nodes to start traversal from. Note: nodes that are not 

141 reachable from one of these sources may not be shown. If unspecified, 

142 the minimal set of nodes needed to reach all others will be used. 

143 

144 max_depth : int | None 

145 The maximum depth to traverse before stopping. Defaults to None. 

146 

147 ascii_only : Boolean 

148 If True only ASCII characters are used to construct the visualization 

149 

150 vertical_chains : Boolean 

151 If True, chains of nodes will be drawn vertically when possible. 

152 

153 Yields 

154 ------ 

155 str : a line of generated text 

156 

157 Examples 

158 -------- 

159 >>> graph = nx.path_graph(10) 

160 >>> graph.add_node('A') 

161 >>> graph.add_node('B') 

162 >>> graph.add_node('C') 

163 >>> graph.add_node('D') 

164 >>> graph.add_edge(9, 'A') 

165 >>> graph.add_edge(9, 'B') 

166 >>> graph.add_edge(9, 'C') 

167 >>> graph.add_edge('C', 'D') 

168 >>> graph.add_edge('C', 'E') 

169 >>> graph.add_edge('C', 'F') 

170 >>> nx.write_network_text(graph) 

171 ╙── 0 

172 └── 1 

173 └── 2 

174 └── 3 

175 └── 4 

176 └── 5 

177 └── 6 

178 └── 7 

179 └── 8 

180 └── 9 

181 ├── A 

182 ├── B 

183 └── C 

184 ├── D 

185 ├── E 

186 └── F 

187 >>> nx.write_network_text(graph, vertical_chains=True) 

188 ╙── 0 

189 

190 1 

191 

192 2 

193 

194 3 

195 

196 4 

197 

198 5 

199 

200 6 

201 

202 7 

203 

204 8 

205 

206 9 

207 ├── A 

208 ├── B 

209 └── C 

210 ├── D 

211 ├── E 

212 └── F 

213 """ 

214 from typing import Any, NamedTuple 

215 

216 class StackFrame(NamedTuple): 

217 parent: Any 

218 node: Any 

219 indents: list 

220 this_islast: bool 

221 this_vertical: bool 

222 

223 collapse_attr = "collapse" 

224 

225 is_directed = graph.is_directed() 

226 

227 if is_directed: 

228 glyphs = AsciiDirectedGlyphs if ascii_only else UtfDirectedGlyphs 

229 succ = graph.succ 

230 pred = graph.pred 

231 else: 

232 glyphs = AsciiUndirectedGlyphs if ascii_only else UtfUndirectedGlyphs 

233 succ = graph.adj 

234 pred = graph.adj 

235 

236 if isinstance(with_labels, str): 

237 label_attr = with_labels 

238 elif with_labels: 

239 label_attr = "label" 

240 else: 

241 label_attr = None 

242 

243 if max_depth == 0: 

244 yield glyphs.empty + " ..." 

245 elif len(graph.nodes) == 0: 

246 yield glyphs.empty 

247 else: 

248 # If the nodes to traverse are unspecified, find the minimal set of 

249 # nodes that will reach the entire graph 

250 if sources is None: 

251 sources = _find_sources(graph) 

252 

253 # Populate the stack with each: 

254 # 1. parent node in the DFS tree (or None for root nodes), 

255 # 2. the current node in the DFS tree 

256 # 2. a list of indentations indicating depth 

257 # 3. a flag indicating if the node is the final one to be written. 

258 # Reverse the stack so sources are popped in the correct order. 

259 last_idx = len(sources) - 1 

260 stack = [ 

261 StackFrame(None, node, [], (idx == last_idx), False) 

262 for idx, node in enumerate(sources) 

263 ][::-1] 

264 

265 num_skipped_children = defaultdict(lambda: 0) 

266 seen_nodes = set() 

267 while stack: 

268 parent, node, indents, this_islast, this_vertical = stack.pop() 

269 

270 if node is not Ellipsis: 

271 skip = node in seen_nodes 

272 if skip: 

273 # Mark that we skipped a parent's child 

274 num_skipped_children[parent] += 1 

275 

276 if this_islast: 

277 # If we reached the last child of a parent, and we skipped 

278 # any of that parents children, then we should emit an 

279 # ellipsis at the end after this. 

280 if num_skipped_children[parent] and parent is not None: 

281 # Append the ellipsis to be emitted last 

282 next_islast = True 

283 try_frame = StackFrame( 

284 node, Ellipsis, indents, next_islast, False 

285 ) 

286 stack.append(try_frame) 

287 

288 # Redo this frame, but not as a last object 

289 next_islast = False 

290 try_frame = StackFrame( 

291 parent, node, indents, next_islast, this_vertical 

292 ) 

293 stack.append(try_frame) 

294 continue 

295 

296 if skip: 

297 continue 

298 seen_nodes.add(node) 

299 

300 if not indents: 

301 # Top level items (i.e. trees in the forest) get different 

302 # glyphs to indicate they are not actually connected 

303 if this_islast: 

304 this_vertical = False 

305 this_prefix = indents + [glyphs.newtree_last] 

306 next_prefix = indents + [glyphs.endof_forest] 

307 else: 

308 this_prefix = indents + [glyphs.newtree_mid] 

309 next_prefix = indents + [glyphs.within_forest] 

310 

311 else: 

312 # Non-top-level items 

313 if this_vertical: 

314 this_prefix = indents 

315 next_prefix = indents 

316 else: 

317 if this_islast: 

318 this_prefix = indents + [glyphs.last] 

319 next_prefix = indents + [glyphs.endof_forest] 

320 else: 

321 this_prefix = indents + [glyphs.mid] 

322 next_prefix = indents + [glyphs.within_tree] 

323 

324 if node is Ellipsis: 

325 label = " ..." 

326 suffix = "" 

327 children = [] 

328 else: 

329 if label_attr is not None: 

330 label = str(graph.nodes[node].get(label_attr, node)) 

331 else: 

332 label = str(node) 

333 

334 # Determine if we want to show the children of this node. 

335 if collapse_attr is not None: 

336 collapse = graph.nodes[node].get(collapse_attr, False) 

337 else: 

338 collapse = False 

339 

340 # Determine: 

341 # (1) children to traverse into after showing this node. 

342 # (2) parents to immediately show to the right of this node. 

343 if is_directed: 

344 # In the directed case we must show every successor node 

345 # note: it may be skipped later, but we don't have that 

346 # information here. 

347 children = list(succ[node]) 

348 # In the directed case we must show every predecessor 

349 # except for parent we directly traversed from. 

350 handled_parents = {parent} 

351 else: 

352 # Showing only the unseen children results in a more 

353 # concise representation for the undirected case. 

354 children = [ 

355 child for child in succ[node] if child not in seen_nodes 

356 ] 

357 

358 # In the undirected case, parents are also children, so we 

359 # only need to immediately show the ones we can no longer 

360 # traverse 

361 handled_parents = {*children, parent} 

362 

363 if max_depth is not None and len(indents) == max_depth - 1: 

364 # Use ellipsis to indicate we have reached maximum depth 

365 if children: 

366 children = [Ellipsis] 

367 handled_parents = {parent} 

368 

369 if collapse: 

370 # Collapsing a node is the same as reaching maximum depth 

371 if children: 

372 children = [Ellipsis] 

373 handled_parents = {parent} 

374 

375 # The other parents are other predecessors of this node that 

376 # are not handled elsewhere. 

377 other_parents = [p for p in pred[node] if p not in handled_parents] 

378 if other_parents: 

379 if label_attr is not None: 

380 other_parents_labels = ", ".join( 

381 [ 

382 str(graph.nodes[p].get(label_attr, p)) 

383 for p in other_parents 

384 ] 

385 ) 

386 else: 

387 other_parents_labels = ", ".join( 

388 [str(p) for p in other_parents] 

389 ) 

390 suffix = " ".join(["", glyphs.backedge, other_parents_labels]) 

391 else: 

392 suffix = "" 

393 

394 # Emit the line for this node, this will be called for each node 

395 # exactly once. 

396 if this_vertical: 

397 yield "".join(this_prefix + [glyphs.vertical_edge]) 

398 

399 yield "".join(this_prefix + [label, suffix]) 

400 

401 if vertical_chains: 

402 if is_directed: 

403 num_children = len(set(children)) 

404 else: 

405 num_children = len(set(children) - {parent}) 

406 # The next node can be drawn vertically if it is the only 

407 # remaining child of this node. 

408 next_is_vertical = num_children == 1 

409 else: 

410 next_is_vertical = False 

411 

412 # Push children on the stack in reverse order so they are popped in 

413 # the original order. 

414 for idx, child in enumerate(children[::-1]): 

415 next_islast = idx == 0 

416 try_frame = StackFrame( 

417 node, child, next_prefix, next_islast, next_is_vertical 

418 ) 

419 stack.append(try_frame) 

420 

421 

422@open_file(1, "w") 

423def write_network_text( 

424 graph, 

425 path=None, 

426 with_labels=True, 

427 sources=None, 

428 max_depth=None, 

429 ascii_only=False, 

430 end="\n", 

431 vertical_chains=False, 

432): 

433 """Creates a nice text representation of a graph 

434 

435 This works via a depth-first traversal of the graph and writing a line for 

436 each unique node encountered. Non-tree edges are written to the right of 

437 each node, and connection to a non-tree edge is indicated with an ellipsis. 

438 This representation works best when the input graph is a forest, but any 

439 graph can be represented. 

440 

441 Parameters 

442 ---------- 

443 graph : nx.DiGraph | nx.Graph 

444 Graph to represent 

445 

446 path : string or file or callable or None 

447 Filename or file handle for data output. 

448 if a function, then it will be called for each generated line. 

449 if None, this will default to "sys.stdout.write" 

450 

451 with_labels : bool | str 

452 If True will use the "label" attribute of a node to display if it 

453 exists otherwise it will use the node value itself. If given as a 

454 string, then that attribute name will be used instead of "label". 

455 Defaults to True. 

456 

457 sources : List 

458 Specifies which nodes to start traversal from. Note: nodes that are not 

459 reachable from one of these sources may not be shown. If unspecified, 

460 the minimal set of nodes needed to reach all others will be used. 

461 

462 max_depth : int | None 

463 The maximum depth to traverse before stopping. Defaults to None. 

464 

465 ascii_only : Boolean 

466 If True only ASCII characters are used to construct the visualization 

467 

468 end : string 

469 The line ending character 

470 

471 vertical_chains : Boolean 

472 If True, chains of nodes will be drawn vertically when possible. 

473 

474 Examples 

475 -------- 

476 >>> graph = nx.balanced_tree(r=2, h=2, create_using=nx.DiGraph) 

477 >>> nx.write_network_text(graph) 

478 ╙── 0 

479 ├─╼ 1 

480 │ ├─╼ 3 

481 │ └─╼ 4 

482 └─╼ 2 

483 ├─╼ 5 

484 └─╼ 6 

485 

486 >>> # A near tree with one non-tree edge 

487 >>> graph.add_edge(5, 1) 

488 >>> nx.write_network_text(graph) 

489 ╙── 0 

490 ├─╼ 1 ╾ 5 

491 │ ├─╼ 3 

492 │ └─╼ 4 

493 └─╼ 2 

494 ├─╼ 5 

495 │ └─╼ ... 

496 └─╼ 6 

497 

498 >>> graph = nx.cycle_graph(5) 

499 >>> nx.write_network_text(graph) 

500 ╙── 0 

501 ├── 1 

502 │ └── 2 

503 │ └── 3 

504 │ └── 4 ─ 0 

505 └── ... 

506 

507 >>> graph = nx.cycle_graph(5, nx.DiGraph) 

508 >>> nx.write_network_text(graph, vertical_chains=True) 

509 ╙── 0 ╾ 4 

510 

511 1 

512 

513 2 

514 

515 3 

516 

517 4 

518 └─╼ ... 

519 

520 >>> nx.write_network_text(graph, vertical_chains=True, ascii_only=True) 

521 +-- 0 <- 4 

522 ! 

523 1 

524 ! 

525 2 

526 ! 

527 3 

528 ! 

529 4 

530 L-> ... 

531 

532 >>> graph = nx.generators.barbell_graph(4, 2) 

533 >>> nx.write_network_text(graph, vertical_chains=False) 

534 ╙── 4 

535 ├── 5 

536 │ └── 6 

537 │ ├── 7 

538 │ │ ├── 8 ─ 6 

539 │ │ │ └── 9 ─ 6, 7 

540 │ │ └── ... 

541 │ └── ... 

542 └── 3 

543 ├── 0 

544 │ ├── 1 ─ 3 

545 │ │ └── 2 ─ 0, 3 

546 │ └── ... 

547 └── ... 

548 >>> nx.write_network_text(graph, vertical_chains=True) 

549 ╙── 4 

550 ├── 5 

551 │ │ 

552 │ 6 

553 │ ├── 7 

554 │ │ ├── 8 ─ 6 

555 │ │ │ │ 

556 │ │ │ 9 ─ 6, 7 

557 │ │ └── ... 

558 │ └── ... 

559 └── 3 

560 ├── 0 

561 │ ├── 1 ─ 3 

562 │ │ │ 

563 │ │ 2 ─ 0, 3 

564 │ └── ... 

565 └── ... 

566 

567 >>> graph = nx.complete_graph(5, create_using=nx.Graph) 

568 >>> nx.write_network_text(graph) 

569 ╙── 0 

570 ├── 1 

571 │ ├── 2 ─ 0 

572 │ │ ├── 3 ─ 0, 1 

573 │ │ │ └── 4 ─ 0, 1, 2 

574 │ │ └── ... 

575 │ └── ... 

576 └── ... 

577 

578 >>> graph = nx.complete_graph(3, create_using=nx.DiGraph) 

579 >>> nx.write_network_text(graph) 

580 ╙── 0 ╾ 1, 2 

581 ├─╼ 1 ╾ 2 

582 │ ├─╼ 2 ╾ 0 

583 │ │ └─╼ ... 

584 │ └─╼ ... 

585 └─╼ ... 

586 """ 

587 if path is None: 

588 # The path is unspecified, write to stdout 

589 _write = sys.stdout.write 

590 elif hasattr(path, "write"): 

591 # The path is already an open file 

592 _write = path.write 

593 elif callable(path): 

594 # The path is a custom callable 

595 _write = path 

596 else: 

597 raise TypeError(type(path)) 

598 

599 for line in generate_network_text( 

600 graph, 

601 with_labels=with_labels, 

602 sources=sources, 

603 max_depth=max_depth, 

604 ascii_only=ascii_only, 

605 vertical_chains=vertical_chains, 

606 ): 

607 _write(line + end) 

608 

609 

610def _find_sources(graph): 

611 """ 

612 Determine a minimal set of nodes such that the entire graph is reachable 

613 """ 

614 # For each connected part of the graph, choose at least 

615 # one node as a starting point, preferably without a parent 

616 if graph.is_directed(): 

617 # Choose one node from each SCC with minimum in_degree 

618 sccs = list(nx.strongly_connected_components(graph)) 

619 # condensing the SCCs forms a dag, the nodes in this graph with 

620 # 0 in-degree correspond to the SCCs from which the minimum set 

621 # of nodes from which all other nodes can be reached. 

622 scc_graph = nx.condensation(graph, sccs) 

623 supernode_to_nodes = {sn: [] for sn in scc_graph.nodes()} 

624 # Note: the order of mapping differs between pypy and cpython 

625 # so we have to loop over graph nodes for consistency 

626 mapping = scc_graph.graph["mapping"] 

627 for n in graph.nodes: 

628 sn = mapping[n] 

629 supernode_to_nodes[sn].append(n) 

630 sources = [] 

631 for sn in scc_graph.nodes(): 

632 if scc_graph.in_degree[sn] == 0: 

633 scc = supernode_to_nodes[sn] 

634 node = min(scc, key=lambda n: graph.in_degree[n]) 

635 sources.append(node) 

636 else: 

637 # For undirected graph, the entire graph will be reachable as 

638 # long as we consider one node from every connected component 

639 sources = [ 

640 min(cc, key=lambda n: graph.degree[n]) 

641 for cc in nx.connected_components(graph) 

642 ] 

643 sources = sorted(sources, key=lambda n: graph.degree[n]) 

644 return sources 

645 

646 

647def forest_str(graph, with_labels=True, sources=None, write=None, ascii_only=False): 

648 """Creates a nice utf8 representation of a forest 

649 

650 This function has been superseded by 

651 :func:`nx.readwrite.text.generate_network_text`, which should be used 

652 instead. 

653 

654 Parameters 

655 ---------- 

656 graph : nx.DiGraph | nx.Graph 

657 Graph to represent (must be a tree, forest, or the empty graph) 

658 

659 with_labels : bool 

660 If True will use the "label" attribute of a node to display if it 

661 exists otherwise it will use the node value itself. Defaults to True. 

662 

663 sources : List 

664 Mainly relevant for undirected forests, specifies which nodes to list 

665 first. If unspecified the root nodes of each tree will be used for 

666 directed forests; for undirected forests this defaults to the nodes 

667 with the smallest degree. 

668 

669 write : callable 

670 Function to use to write to, if None new lines are appended to 

671 a list and returned. If set to the `print` function, lines will 

672 be written to stdout as they are generated. If specified, 

673 this function will return None. Defaults to None. 

674 

675 ascii_only : Boolean 

676 If True only ASCII characters are used to construct the visualization 

677 

678 Returns 

679 ------- 

680 str | None : 

681 utf8 representation of the tree / forest 

682 

683 Examples 

684 -------- 

685 >>> graph = nx.balanced_tree(r=2, h=3, create_using=nx.DiGraph) 

686 >>> print(nx.forest_str(graph)) 

687 ╙── 0 

688 ├─╼ 1 

689 │ ├─╼ 3 

690 │ │ ├─╼ 7 

691 │ │ └─╼ 8 

692 │ └─╼ 4 

693 │ ├─╼ 9 

694 │ └─╼ 10 

695 └─╼ 2 

696 ├─╼ 5 

697 │ ├─╼ 11 

698 │ └─╼ 12 

699 └─╼ 6 

700 ├─╼ 13 

701 └─╼ 14 

702 

703 

704 >>> graph = nx.balanced_tree(r=1, h=2, create_using=nx.Graph) 

705 >>> print(nx.forest_str(graph)) 

706 ╙── 0 

707 └── 1 

708 └── 2 

709 

710 >>> print(nx.forest_str(graph, ascii_only=True)) 

711 +-- 0 

712 L-- 1 

713 L-- 2 

714 """ 

715 msg = ( 

716 "\nforest_str is deprecated as of version 3.1 and will be removed " 

717 "in version 3.3. Use generate_network_text or write_network_text " 

718 "instead.\n" 

719 ) 

720 warnings.warn(msg, DeprecationWarning) 

721 

722 if len(graph.nodes) > 0: 

723 if not nx.is_forest(graph): 

724 raise nx.NetworkXNotImplemented("input must be a forest or the empty graph") 

725 

726 printbuf = [] 

727 if write is None: 

728 _write = printbuf.append 

729 else: 

730 _write = write 

731 

732 write_network_text( 

733 graph, 

734 _write, 

735 with_labels=with_labels, 

736 sources=sources, 

737 ascii_only=ascii_only, 

738 end="", 

739 ) 

740 

741 if write is None: 

742 # Only return a string if the custom write function was not specified 

743 return "\n".join(printbuf) 

744 

745 

746def _parse_network_text(lines): 

747 """Reconstructs a graph from a network text representation. 

748 

749 This is mainly used for testing. Network text is for display, not 

750 serialization, as such this cannot parse all network text representations 

751 because node labels can be ambiguous with the glyphs and indentation used 

752 to represent edge structure. Additionally, there is no way to determine if 

753 disconnected graphs were originally directed or undirected. 

754 

755 Parameters 

756 ---------- 

757 lines : list or iterator of strings 

758 Input data in network text format 

759 

760 Returns 

761 ------- 

762 G: NetworkX graph 

763 The graph corresponding to the lines in network text format. 

764 """ 

765 from itertools import chain 

766 from typing import Any, NamedTuple, Union 

767 

768 class ParseStackFrame(NamedTuple): 

769 node: Any 

770 indent: int 

771 has_vertical_child: Union[int, None] 

772 

773 initial_line_iter = iter(lines) 

774 

775 is_ascii = None 

776 is_directed = None 

777 

778 ############## 

779 # Initial Pass 

780 ############## 

781 

782 # Do an initial pass over the lines to determine what type of graph it is. 

783 # Remember what these lines were, so we can reiterate over them in the 

784 # parsing pass. 

785 initial_lines = [] 

786 try: 

787 first_line = next(initial_line_iter) 

788 except StopIteration: 

789 ... 

790 else: 

791 initial_lines.append(first_line) 

792 # The first character indicates if it is an ASCII or UTF graph 

793 first_char = first_line[0] 

794 if first_char in { 

795 UtfBaseGlyphs.empty, 

796 UtfBaseGlyphs.newtree_mid[0], 

797 UtfBaseGlyphs.newtree_last[0], 

798 }: 

799 is_ascii = False 

800 elif first_char in { 

801 AsciiBaseGlyphs.empty, 

802 AsciiBaseGlyphs.newtree_mid[0], 

803 AsciiBaseGlyphs.newtree_last[0], 

804 }: 

805 is_ascii = True 

806 else: 

807 raise AssertionError(f"Unexpected first character: {first_char}") 

808 

809 if is_ascii: 

810 directed_glyphs = AsciiDirectedGlyphs.as_dict() 

811 undirected_glyphs = AsciiUndirectedGlyphs.as_dict() 

812 else: 

813 directed_glyphs = UtfDirectedGlyphs.as_dict() 

814 undirected_glyphs = UtfUndirectedGlyphs.as_dict() 

815 

816 # For both directed / undirected glyphs, determine which glyphs never 

817 # appear as substrings in the other undirected / directed glyphs. Glyphs 

818 # with this property unambiguously indicates if a graph is directed / 

819 # undirected. 

820 directed_items = set(directed_glyphs.values()) 

821 undirected_items = set(undirected_glyphs.values()) 

822 unambiguous_directed_items = [] 

823 for item in directed_items: 

824 other_items = undirected_items 

825 other_supersets = [other for other in other_items if item in other] 

826 if not other_supersets: 

827 unambiguous_directed_items.append(item) 

828 unambiguous_undirected_items = [] 

829 for item in undirected_items: 

830 other_items = directed_items 

831 other_supersets = [other for other in other_items if item in other] 

832 if not other_supersets: 

833 unambiguous_undirected_items.append(item) 

834 

835 for line in initial_line_iter: 

836 initial_lines.append(line) 

837 if any(item in line for item in unambiguous_undirected_items): 

838 is_directed = False 

839 break 

840 elif any(item in line for item in unambiguous_directed_items): 

841 is_directed = True 

842 break 

843 

844 if is_directed is None: 

845 # Not enough information to determine, choose undirected by default 

846 is_directed = False 

847 

848 glyphs = directed_glyphs if is_directed else undirected_glyphs 

849 

850 # the backedge symbol by itself can be ambiguous, but with spaces around it 

851 # becomes unambiguous. 

852 backedge_symbol = " " + glyphs["backedge"] + " " 

853 

854 # Reconstruct an iterator over all of the lines. 

855 parsing_line_iter = chain(initial_lines, initial_line_iter) 

856 

857 ############## 

858 # Parsing Pass 

859 ############## 

860 

861 edges = [] 

862 nodes = [] 

863 is_empty = None 

864 

865 noparent = object() # sentinel value 

866 

867 # keep a stack of previous nodes that could be parents of subsequent nodes 

868 stack = [ParseStackFrame(noparent, -1, None)] 

869 

870 for line in parsing_line_iter: 

871 if line == glyphs["empty"]: 

872 # If the line is the empty glyph, we are done. 

873 # There shouldn't be anything else after this. 

874 is_empty = True 

875 continue 

876 

877 if backedge_symbol in line: 

878 # This line has one or more backedges, separate those out 

879 node_part, backedge_part = line.split(backedge_symbol) 

880 backedge_nodes = [u.strip() for u in backedge_part.split(", ")] 

881 # Now the node can be parsed 

882 node_part = node_part.rstrip() 

883 prefix, node = node_part.rsplit(" ", 1) 

884 node = node.strip() 

885 # Add the backedges to the edge list 

886 edges.extend([(u, node) for u in backedge_nodes]) 

887 else: 

888 # No backedge, the tail of this line is the node 

889 prefix, node = line.rsplit(" ", 1) 

890 node = node.strip() 

891 

892 prev = stack.pop() 

893 

894 if node in glyphs["vertical_edge"]: 

895 # Previous node is still the previous node, but we know it will 

896 # have exactly one child, which will need to have its nesting level 

897 # adjusted. 

898 modified_prev = ParseStackFrame( 

899 prev.node, 

900 prev.indent, 

901 True, 

902 ) 

903 stack.append(modified_prev) 

904 continue 

905 

906 # The length of the string before the node characters give us a hint 

907 # about our nesting level. The only case where this doesn't work is 

908 # when there are vertical chains, which is handled explicitly. 

909 indent = len(prefix) 

910 curr = ParseStackFrame(node, indent, None) 

911 

912 if prev.has_vertical_child: 

913 # In this case we know prev must be the parent of our current line, 

914 # so we don't have to search the stack. (which is good because the 

915 # indentation check wouldn't work in this case). 

916 ... 

917 else: 

918 # If the previous node nesting-level is greater than the current 

919 # nodes nesting-level than the previous node was the end of a path, 

920 # and is not our parent. We can safely pop nodes off the stack 

921 # until we find one with a comparable nesting-level, which is our 

922 # parent. 

923 while curr.indent <= prev.indent: 

924 prev = stack.pop() 

925 

926 if node == "...": 

927 # The current previous node is no longer a valid parent, 

928 # keep it popped from the stack. 

929 stack.append(prev) 

930 else: 

931 # The previous and current nodes may still be parents, so add them 

932 # back onto the stack. 

933 stack.append(prev) 

934 stack.append(curr) 

935 

936 # Add the node and the edge to its parent to the node / edge lists. 

937 nodes.append(curr.node) 

938 if prev.node is not noparent: 

939 edges.append((prev.node, curr.node)) 

940 

941 if is_empty: 

942 # Sanity check 

943 assert len(nodes) == 0 

944 

945 # Reconstruct the graph 

946 cls = nx.DiGraph if is_directed else nx.Graph 

947 new = cls() 

948 new.add_nodes_from(nodes) 

949 new.add_edges_from(edges) 

950 return new