Coverage for pyTooling/Graph/GraphViz.py: 99%

514 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|_ __ __ _ _ __ | |__ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _| '__/ _` | '_ \| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | | | (_| | |_) | | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|_| \__,_| .__/|_| |_| # 

7# |_| |___/ |___/ |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A data model to write Graphviz graphs in the DOT language. 

33 

34The model is the counterpart of :mod:`pyTooling.Graph.GraphML`: a :class:`Graph` holds nodes, edges and subgraphs, every 

35element carries attributes, and the graph is written as DOT text. Nothing is laid out or drawn here - that is what 

36Graphviz' :program:`dot` does, or :mod:`sphinx.ext.graphviz` in a documentation. 

37 

38.. rubric:: Attribute values 

39 

40An attribute value is written by its type: a :class:`str` as a quoted DOT string, an :class:`int` or :class:`float` as 

41a number, a :class:`bool` as ``true`` or ``false``, an :class:`HTMLLabel` in angle brackets, and a :class:`RecordLabel` 

42as the label of a ``record`` node. Quoting and escaping happen there and nowhere else, so a caller passes plain text. 

43 

44.. seealso:: 

45 

46 `The DOT Language <https://graphviz.org/doc/info/lang.html>`__ 

47 |rarr| The grammar this module writes. 

48 `Attributes <https://graphviz.org/doc/info/attrs.html>`__ 

49 |rarr| The attributes a graph, a node and an edge can carry. 

50 :mod:`pyTooling.Graph.GraphML` 

51 |rarr| Writing a graph as a GraphML document. 

52""" 

53from __future__ import annotations 

54 

55from enum import Enum 

56from html import escape as html_escape 

57from pathlib import Path 

58from typing import Mapping, Optional as Nullable, Sequence, Union 

59 

60from pyTooling.Common import getFullyQualifiedName 

61from pyTooling.Decorators import export, readonly 

62from pyTooling.MetaClasses import ExtendedType 

63from pyTooling.Graph import Graph as pyToolingGraph, Subgraph as pyToolingSubgraph, Vertex 

64from pyTooling.Graph import Edge as pyToolingEdge, Link as pyToolingLink 

65from pyTooling.Tree import Node as pyToolingNode 

66 

67 

68__all__ = ["AttributeValue", "RecordField"] 

69 

70 

71@export 

72def quote(text: str) -> str: 

73 """ 

74 Quote a text as a DOT string. 

75 

76 A backslash, a double quote and a line break are escaped, so the drawing shows the text as it was given - a 

77 backslash doesn't start one of Graphviz' escape sequences like ``\\l``. 

78 

79 :param text: The text to quote. 

80 :returns: The text in double quotes. 

81 :raises ValueError: If parameter 'text' is None. 

82 :raises TypeError: If parameter 'text' is not a string. 

83 """ 

84 if text is None: 

85 raise ValueError("Parameter 'text' is None.") 

86 elif not isinstance(text, str): 

87 ex = TypeError("Parameter 'text' is not of type 'str'.") 

88 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.") 

89 raise ex 

90 

91 return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"' 

92 

93 

94@export 

95class GraphKind(Enum): 

96 """Enumeration of the kinds of DOT graph, by the keyword declaring the graph and the operator writing an edge.""" 

97 Directed = ("digraph", "->") #: A directed graph: ``digraph``, an edge is written ``a -> b``. 

98 Undirected = ("graph", "--") #: An undirected graph: ``graph``, an edge is written ``a -- b``. 

99 

100 @readonly 

101 def Keyword(self) -> str: 

102 """ 

103 Read-only property to return the keyword declaring a graph of this kind. 

104 

105 :returns: ``digraph`` or ``graph``. 

106 """ 

107 return self.value[0] 

108 

109 @readonly 

110 def EdgeOperator(self) -> str: 

111 """ 

112 Read-only property to return the operator connecting the two nodes of an edge in a graph of this kind. 

113 

114 :returns: ``->`` or ``--``. 

115 """ 

116 return self.value[1] 

117 

118 

119@export 

120class HTMLLabel(metaclass=ExtendedType, slots=True): 

121 """ 

122 An HTML-like label, written in angle brackets instead of quotes. 

123 

124 The markup is written as given. Text placed into it has to be escaped with :meth:`Escape` first. 

125 """ 

126 _markup: str #: The label's markup, without the enclosing angle brackets. 

127 

128 def __init__(self, markup: str) -> None: 

129 """ 

130 Initialize an HTML-like label. 

131 

132 :param markup: The label's markup, without the enclosing angle brackets. 

133 :raises ValueError: If parameter 'markup' is None. 

134 :raises TypeError: If parameter 'markup' is not a string. 

135 """ 

136 if markup is None: 

137 raise ValueError("Parameter 'markup' is None.") 

138 elif not isinstance(markup, str): 

139 ex = TypeError("Parameter 'markup' is not of type 'str'.") 

140 ex.add_note(f"Got type '{getFullyQualifiedName(markup)}'.") 

141 raise ex 

142 

143 self._markup = markup 

144 

145 @readonly 

146 def Markup(self) -> str: 

147 """ 

148 Read-only property to access the label's markup (:attr:`_markup`). 

149 

150 :returns: The markup, without the enclosing angle brackets. 

151 """ 

152 return self._markup 

153 

154 @staticmethod 

155 def Escape(text: str) -> str: 

156 """ 

157 Escape the characters HTML gives a meaning to: ``&``, ``<`` and ``>``. 

158 

159 :param text: The text to escape. 

160 :returns: The text, safe to place into the label's markup. 

161 :raises ValueError: If parameter 'text' is None. 

162 :raises TypeError: If parameter 'text' is not a string. 

163 """ 

164 if text is None: 

165 raise ValueError("Parameter 'text' is None.") 

166 elif not isinstance(text, str): 

167 ex = TypeError("Parameter 'text' is not of type 'str'.") 

168 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.") 

169 raise ex 

170 

171 return html_escape(text, quote=False) 

172 

173 def __str__(self) -> str: 

174 """ 

175 Return the label as DOT writes it. 

176 

177 :returns: The markup in angle brackets. 

178 """ 

179 return f"<{self._markup}>" 

180 

181 

182RecordField = Union[str, Sequence[str], "RecordLabel"] 

183""" 

184A field of a :class:`RecordLabel`: a text, a sequence of rows written left-aligned in one field, or a nested record 

185label. 

186""" 

187 

188 

189@export 

190class RecordLabel(metaclass=ExtendedType, slots=True): 

191 """ 

192 The label of a ``record`` node: fields side by side, or - flipped - one below the other. 

193 

194 Graphviz lays a record's fields out horizontally if the graph flows top to bottom, and vertically if it flows left 

195 to right (``rankdir=LR``). :attr:`Flipped` turns the outermost direction by 90 degrees, and a nested record label 

196 is always turned against the fields around it. 

197 

198 A field's text is escaped, and a field of rows writes every row left-aligned. An empty sequence of rows is written 

199 as a single space, because an empty field would collapse. 

200 """ 

201 _fields: list[RecordField] #: The fields, in the order they are drawn. 

202 _flipped: bool #: If ``True``, the outermost fields are laid out against the graph's direction. 

203 

204 def __init__(self, fields: Sequence[RecordField], flipped: bool = False) -> None: 

205 """ 

206 Initialize a record label. 

207 

208 :param fields: The fields, in the order they are drawn. 

209 :param flipped: Optional, if ``True``, the outermost fields are laid out against the graph's direction. 

210 Default: ``False``. 

211 :raises ValueError: If parameter 'fields' is None or empty. 

212 :raises TypeError: If parameter 'fields' is not a sequence, or is a string. 

213 :raises ValueError: If a field is None. 

214 :raises TypeError: If a field is not a string, a sequence of strings or a :class:`RecordLabel`. |br| 

215 The note lists the supported types. 

216 :raises ValueError: If parameter 'flipped' is None. 

217 :raises TypeError: If parameter 'flipped' is not a boolean. 

218 """ 

219 if fields is None: 

220 raise ValueError("Parameter 'fields' is None.") 

221 elif isinstance(fields, str) or not isinstance(fields, Sequence): 

222 ex = TypeError("Parameter 'fields' is not a sequence ('list', 'tuple', ...).") 

223 ex.add_note(f"Got type '{getFullyQualifiedName(fields)}'.") 

224 raise ex 

225 elif len(fields) == 0: 

226 raise ValueError("Parameter 'fields' is empty.") 

227 

228 for field in fields: 

229 if field is None: 

230 raise ValueError("Parameter 'fields' contains None.") 

231 elif isinstance(field, (str, RecordLabel)): 

232 continue 

233 elif isinstance(field, Sequence) and all(isinstance(row, str) for row in field): 

234 continue 

235 

236 ex = TypeError("Parameter 'fields' contains a field of an unsupported type.") 

237 ex.add_note(f"Got type '{getFullyQualifiedName(field)}'.") 

238 ex.add_note("Supported types: str, a sequence of str, RecordLabel") 

239 raise ex 

240 

241 if flipped is None: 

242 raise ValueError("Parameter 'flipped' is None.") 

243 elif not isinstance(flipped, bool): 

244 ex = TypeError("Parameter 'flipped' is not of type 'bool'.") 

245 ex.add_note(f"Got type '{getFullyQualifiedName(flipped)}'.") 

246 raise ex 

247 

248 self._fields = list(fields) 

249 self._flipped = flipped 

250 

251 @readonly 

252 def Fields(self) -> list[RecordField]: 

253 """ 

254 Read-only property to access the label's fields (:attr:`_fields`). 

255 

256 :returns: The fields, in the order they are drawn. 

257 """ 

258 return self._fields 

259 

260 @readonly 

261 def Flipped(self) -> bool: 

262 """ 

263 Read-only property to access whether the outermost fields are laid out against the graph's direction 

264 (:attr:`_flipped`). 

265 

266 :returns: ``True``, if the outermost fields are turned. 

267 """ 

268 return self._flipped 

269 

270 @staticmethod 

271 def Escape(text: str) -> str: 

272 """ 

273 Escape the characters a record label gives a meaning to: ``\\``, ``{``, ``}``, ``|``, ``<``, ``>`` and ``"``. 

274 

275 :param text: The text to escape. 

276 :returns: The text, safe to place into a field. 

277 :raises ValueError: If parameter 'text' is None. 

278 :raises TypeError: If parameter 'text' is not a string. 

279 """ 

280 if text is None: 

281 raise ValueError("Parameter 'text' is None.") 

282 elif not isinstance(text, str): 

283 ex = TypeError("Parameter 'text' is not of type 'str'.") 

284 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.") 

285 raise ex 

286 

287 for character in ("\\", "{", "}", "|", "<", ">", '"'): 

288 text = text.replace(character, f"\\{character}") 

289 

290 return text 

291 

292 def _Content(self) -> str: 

293 """ 

294 Return the fields separated by ``|``, each field escaped and a nested record label in braces. 

295 

296 :returns: The fields as a record label writes them, without the outermost braces. 

297 """ 

298 fields = [] 

299 for field in self._fields: 

300 if isinstance(field, RecordLabel): 

301 fields.append(f"{{{field._Content()}}}") 

302 elif isinstance(field, str): 

303 fields.append(self.Escape(field)) 

304 elif len(field) == 0: 

305 fields.append(" ") 

306 else: 

307 fields.append("".join(f"{self.Escape(row)}\\l" for row in field)) 

308 

309 return "|".join(fields) 

310 

311 def __str__(self) -> str: 

312 """ 

313 Return the label as DOT writes it. 

314 

315 :returns: The fields in double quotes, in braces if the label is flipped. 

316 """ 

317 content = self._Content() 

318 if self._flipped: 

319 content = f"{{{content}}}" 

320 

321 return f'"{content}"' 

322 

323 

324AttributeValue = Union[str, int, float, bool, HTMLLabel, RecordLabel] 

325"""The types an attribute's value can have.""" 

326 

327 

328@export 

329class Base(metaclass=ExtendedType, slots=True): 

330 """ 

331 Base-class of every element of a DOT graph: something carrying attributes. 

332 

333 Attributes are read and written with dictionary syntax, ``node["shape"] = "box"``, and a value is checked when it is 

334 assigned, so writing the graph can't fail on one. 

335 """ 

336 _attributes: dict[str, AttributeValue] #: Attributes of the element, by name. 

337 

338 def __init__(self, attributes: Nullable[Mapping[str, AttributeValue]] = None) -> None: 

339 """ 

340 Initialize the element's attributes. 

341 

342 :param attributes: Optional, attributes of the element, by name. 

343 :raises TypeError: If parameter 'attributes' is not a mapping. 

344 :raises ValueError: If an attribute's name is None or empty, or its value is None. 

345 :raises TypeError: If an attribute's name is not a string, or its value is not of type :data:`AttributeValue`. 

346 """ 

347 self._attributes = {} 

348 if attributes is not None: 

349 if not isinstance(attributes, Mapping): 

350 ex = TypeError("Parameter 'attributes' is not a mapping ('dict', ...).") 

351 ex.add_note(f"Got type '{getFullyQualifiedName(attributes)}'.") 

352 raise ex 

353 

354 for name, value in attributes.items(): 

355 try: 

356 self[name] = value 

357 except (TypeError, ValueError) as ex: 

358 ex.add_note(f"Raised for attribute '{name}' of parameter 'attributes'.") 

359 raise 

360 

361 @readonly 

362 def Attributes(self) -> dict[str, AttributeValue]: 

363 """ 

364 Read-only property to access the element's attributes (:attr:`_attributes`). 

365 

366 :returns: The attributes, by name. 

367 """ 

368 return self._attributes 

369 

370 def __getitem__(self, name: str) -> AttributeValue: 

371 """ 

372 Return the value of an attribute. 

373 

374 :param name: Name of the attribute. 

375 :returns: The attribute's value. 

376 :raises KeyError: If the element has no such attribute. 

377 """ 

378 return self._attributes[name] 

379 

380 def __setitem__(self, name: str, value: AttributeValue) -> None: 

381 """ 

382 Set the value of an attribute. 

383 

384 :param name: Name of the attribute. 

385 :param value: The attribute's value. 

386 :raises ValueError: If parameter 'name' is None or empty. 

387 :raises TypeError: If parameter 'name' is not a string. 

388 :raises ValueError: If parameter 'value' is None. 

389 :raises TypeError: If parameter 'value' is not of type :data:`AttributeValue`. |br| 

390 The note lists the supported types. 

391 """ 

392 if name is None: 

393 raise ValueError("Parameter 'name' is None.") 

394 elif not isinstance(name, str): 

395 ex = TypeError("Parameter 'name' is not of type 'str'.") 

396 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

397 raise ex 

398 elif name == "": 

399 raise ValueError("Parameter 'name' is empty.") 

400 

401 if value is None: 

402 raise ValueError("Parameter 'value' is None.") 

403 elif not isinstance(value, (str, int, float, bool, HTMLLabel, RecordLabel)): 

404 ex = TypeError("Parameter 'value' is not of a supported attribute type.") 

405 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

406 ex.add_note("Supported types: str, int, float, bool, HTMLLabel, RecordLabel") 

407 raise ex 

408 

409 self._attributes[name] = value 

410 

411 def __delitem__(self, name: str) -> None: 

412 """ 

413 Remove an attribute. 

414 

415 :param name: Name of the attribute. 

416 :raises KeyError: If the element has no such attribute. 

417 """ 

418 del self._attributes[name] 

419 

420 def __contains__(self, name: str) -> bool: 

421 """ 

422 Check if the element has an attribute. 

423 

424 :param name: Name of the attribute. 

425 :returns: ``True``, if the element has an attribute of that name. 

426 """ 

427 return name in self._attributes 

428 

429 def __len__(self) -> int: 

430 """ 

431 Return the number of attributes. 

432 

433 :returns: Number of attributes of the element. 

434 """ 

435 return len(self._attributes) 

436 

437 @staticmethod 

438 def _FormatValue(value: AttributeValue) -> str: 

439 """ 

440 Return an attribute's value as DOT writes it. 

441 

442 :param value: The value to write. 

443 :returns: The value, quoted, as a number, as ``true``/``false``, or as the label it is. 

444 """ 

445 if isinstance(value, bool): 

446 return "true" if value else "false" 

447 elif isinstance(value, (int, float)): 

448 return str(value) 

449 elif isinstance(value, str): 

450 return quote(value) 

451 else: 

452 return str(value) 

453 

454 def _AttributeList(self) -> str: 

455 """ 

456 Return the attributes as a DOT attribute list. 

457 

458 :returns: The attributes in brackets, preceded by a space, or an empty string if there are none. 

459 """ 

460 if len(self._attributes) == 0: 

461 return "" 

462 

463 return " [" + ", ".join(f"{name}={self._FormatValue(value)}" for name, value in self._attributes.items()) + "]" 

464 

465 

466@export 

467class DefaultAttributes(Base): 

468 """ 

469 The attributes every node or every edge of a graph starts with: a ``node [...]`` or ``edge [...]`` statement. 

470 """ 

471 _keyword: str #: ``node`` or ``edge``, the keyword of the statement. 

472 

473 def __init__(self, keyword: str) -> None: 

474 """ 

475 Initialize empty default attributes. 

476 

477 :param keyword: ``node`` or ``edge``, the keyword of the statement. 

478 :raises ValueError: If parameter 'keyword' is None. 

479 :raises TypeError: If parameter 'keyword' is not a string. 

480 :raises ValueError: If parameter 'keyword' is neither ``node`` nor ``edge``. 

481 """ 

482 super().__init__() 

483 

484 if keyword is None: 

485 raise ValueError("Parameter 'keyword' is None.") 

486 elif not isinstance(keyword, str): 

487 ex = TypeError("Parameter 'keyword' is not of type 'str'.") 

488 ex.add_note(f"Got type '{getFullyQualifiedName(keyword)}'.") 

489 raise ex 

490 elif keyword not in ("node", "edge"): 

491 raise ValueError("Parameter 'keyword' is neither 'node' nor 'edge'.") 

492 

493 self._keyword = keyword 

494 

495 def ToStringLines(self, indent: int = 1) -> list[str]: 

496 """ 

497 Render the defaults as DOT lines. 

498 

499 :param indent: Optional, indentation level of the statement. 

500 :returns: The statement as a line, or no line if there are no default attributes. 

501 """ 

502 if len(self._attributes) == 0: 

503 return [] 

504 

505 return [f"{' ' * indent}{self._keyword}{self._AttributeList()};\n"] 

506 

507 

508@export 

509class Node(Base): 

510 """A node of a DOT graph.""" 

511 _identifier: str #: Identifier of the node, which an edge names it by. 

512 

513 def __init__( 

514 self, 

515 identifier: str, 

516 label: Nullable[Union[str, HTMLLabel, RecordLabel]] = None, 

517 attributes: Nullable[Mapping[str, AttributeValue]] = None 

518 ) -> None: 

519 """ 

520 Initialize a node. 

521 

522 :param identifier: Identifier of the node, which an edge names it by. 

523 :param label: Optional, the node's label. Graphviz shows the identifier, if there is none. 

524 :param attributes: Optional, further attributes of the node, by name. 

525 :raises ValueError: If parameter 'identifier' is None or empty. 

526 :raises TypeError: If parameter 'identifier' is not a string. 

527 :raises TypeError: If parameter 'label' is not a string, :class:`HTMLLabel` or :class:`RecordLabel`. 

528 :raises ValueError: If parameters 'label' and 'attributes' both set a label. 

529 """ 

530 super().__init__(attributes) 

531 

532 if identifier is None: 

533 raise ValueError("Parameter 'identifier' is None.") 

534 elif not isinstance(identifier, str): 

535 ex = TypeError("Parameter 'identifier' is not of type 'str'.") 

536 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

537 raise ex 

538 elif identifier == "": 

539 raise ValueError("Parameter 'identifier' is empty.") 

540 

541 if label is not None: 

542 if not isinstance(label, (str, HTMLLabel, RecordLabel)): 

543 ex = TypeError("Parameter 'label' is not of type 'str', 'HTMLLabel' or 'RecordLabel'.") 

544 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.") 

545 raise ex 

546 elif "label" in self._attributes: 

547 raise ValueError("Parameters 'label' and 'attributes' both set a label.") 

548 

549 self._attributes["label"] = label 

550 

551 self._identifier = identifier 

552 

553 @readonly 

554 def Identifier(self) -> str: 

555 """ 

556 Read-only property to access the node's identifier (:attr:`_identifier`). 

557 

558 :returns: The identifier, which an edge names the node by. 

559 """ 

560 return self._identifier 

561 

562 def ToStringLines(self, indent: int = 1) -> list[str]: 

563 """ 

564 Render the node as DOT lines. 

565 

566 :param indent: Optional, indentation level of the statement. 

567 :returns: The node statement as a line. 

568 """ 

569 return [f"{' ' * indent}{quote(self._identifier)}{self._AttributeList()};\n"] 

570 

571 

572@export 

573class Edge(Base): 

574 """An edge of a DOT graph, connecting a source node to a target node.""" 

575 _source: Node #: Node the edge starts at. 

576 _target: Node #: Node the edge ends at. 

577 

578 def __init__(self, source: Node, target: Node, attributes: Nullable[Mapping[str, AttributeValue]] = None) -> None: 

579 """ 

580 Initialize an edge. 

581 

582 :param source: Node the edge starts at. 

583 :param target: Node the edge ends at. 

584 :param attributes: Optional, further attributes of the edge, by name. 

585 :raises ValueError: If parameter 'source' is None. 

586 :raises TypeError: If parameter 'source' is not a :class:`Node`. 

587 :raises ValueError: If parameter 'target' is None. 

588 :raises TypeError: If parameter 'target' is not a :class:`Node`. 

589 """ 

590 super().__init__(attributes) 

591 

592 for parameter, node in (("source", source), ("target", target)): 

593 if node is None: 

594 raise ValueError(f"Parameter '{parameter}' is None.") 

595 elif not isinstance(node, Node): 

596 ex = TypeError(f"Parameter '{parameter}' is not of type 'Node'.") 

597 ex.add_note(f"Got type '{getFullyQualifiedName(node)}'.") 

598 raise ex 

599 

600 self._source = source 

601 self._target = target 

602 

603 @readonly 

604 def Source(self) -> Node: 

605 """ 

606 Read-only property to access the node the edge starts at (:attr:`_source`). 

607 

608 :returns: The source node. 

609 """ 

610 return self._source 

611 

612 @readonly 

613 def Target(self) -> Node: 

614 """ 

615 Read-only property to access the node the edge ends at (:attr:`_target`). 

616 

617 :returns: The target node. 

618 """ 

619 return self._target 

620 

621 def ToStringLines(self, kind: GraphKind = GraphKind.Directed, indent: int = 1) -> list[str]: 

622 """ 

623 Render the edge as DOT lines. 

624 

625 :param kind: Optional, kind of the graph, which decides the edge operator. Default: :attr:`GraphKind.Directed`. 

626 :param indent: Optional, indentation level of the statement. 

627 :returns: The edge statement as a line. 

628 """ 

629 source = quote(self._source._identifier) 

630 target = quote(self._target._identifier) 

631 

632 return [f"{' ' * indent}{source} {kind.EdgeOperator} {target}{self._AttributeList()};\n"] 

633 

634 

635@export 

636class BaseGraph(Base): 

637 """ 

638 Base-class for everything that contains nodes, edges and subgraphs - a graph as well as a subgraph. 

639 

640 Its own attributes are the graph's attributes, written as ``name=value;`` statements, and its default attributes 

641 are what every node and every edge starts with. The statements are written in this order: attributes, defaults, 

642 subgraphs, nodes, edges. 

643 """ 

644 _nodeDefaults: DefaultAttributes #: Attributes every node starts with. 

645 _edgeDefaults: DefaultAttributes #: Attributes every edge starts with. 

646 _subgraphs: dict[str, Subgraph] #: Subgraphs, by identifier. 

647 _nodes: dict[str, Node] #: Nodes, by identifier. 

648 _edges: list[Edge] #: Edges, in the order they were added. 

649 

650 def __init__(self, attributes: Nullable[Mapping[str, AttributeValue]] = None) -> None: 

651 """ 

652 Initialize an empty graph. 

653 

654 :param attributes: Optional, attributes of the graph, by name. 

655 """ 

656 super().__init__(attributes) 

657 

658 self._nodeDefaults = DefaultAttributes("node") 

659 self._edgeDefaults = DefaultAttributes("edge") 

660 self._subgraphs = {} 

661 self._nodes = {} 

662 self._edges = [] 

663 

664 @readonly 

665 def NodeDefaults(self) -> DefaultAttributes: 

666 """ 

667 Read-only property to access the attributes every node starts with (:attr:`_nodeDefaults`). 

668 

669 :returns: The node defaults. 

670 """ 

671 return self._nodeDefaults 

672 

673 @readonly 

674 def EdgeDefaults(self) -> DefaultAttributes: 

675 """ 

676 Read-only property to access the attributes every edge starts with (:attr:`_edgeDefaults`). 

677 

678 :returns: The edge defaults. 

679 """ 

680 return self._edgeDefaults 

681 

682 @readonly 

683 def Subgraphs(self) -> dict[str, Subgraph]: 

684 """ 

685 Read-only property to access the subgraphs (:attr:`_subgraphs`). 

686 

687 :returns: Dictionary of subgraph identifiers and subgraphs. 

688 """ 

689 return self._subgraphs 

690 

691 @readonly 

692 def Nodes(self) -> dict[str, Node]: 

693 """ 

694 Read-only property to access the nodes (:attr:`_nodes`). 

695 

696 :returns: Dictionary of node identifiers and nodes. 

697 """ 

698 return self._nodes 

699 

700 @readonly 

701 def Edges(self) -> list[Edge]: 

702 """ 

703 Read-only property to access the edges (:attr:`_edges`). 

704 

705 :returns: The edges, in the order they were added. 

706 """ 

707 return self._edges 

708 

709 def AddSubgraph(self, subgraph: Subgraph) -> Subgraph: 

710 """ 

711 Add a subgraph. 

712 

713 :param subgraph: The subgraph to add. 

714 :returns: The added subgraph, so it can be used in the calling expression. 

715 :raises ValueError: If parameter 'subgraph' is None. 

716 :raises TypeError: If parameter 'subgraph' is not a :class:`Subgraph`. 

717 :raises ValueError: If a subgraph with the same identifier was added before. 

718 """ 

719 if subgraph is None: 

720 raise ValueError("Parameter 'subgraph' is None.") 

721 elif not isinstance(subgraph, Subgraph): 

722 ex = TypeError("Parameter 'subgraph' is not of type 'Subgraph'.") 

723 ex.add_note(f"Got type '{getFullyQualifiedName(subgraph)}'.") 

724 raise ex 

725 elif subgraph._identifier in self._subgraphs: 

726 raise ValueError(f"A subgraph '{subgraph._identifier}' was added before.") 

727 

728 self._subgraphs[subgraph._identifier] = subgraph 

729 return subgraph 

730 

731 def AddNode(self, node: Node) -> Node: 

732 """ 

733 Add a node. 

734 

735 :param node: The node to add. 

736 :returns: The added node, so it can be used in the calling expression. 

737 :raises ValueError: If parameter 'node' is None. 

738 :raises TypeError: If parameter 'node' is not a :class:`Node`. 

739 :raises ValueError: If a node with the same identifier was added before. 

740 """ 

741 if node is None: 

742 raise ValueError("Parameter 'node' is None.") 

743 elif not isinstance(node, Node): 

744 ex = TypeError("Parameter 'node' is not of type 'Node'.") 

745 ex.add_note(f"Got type '{getFullyQualifiedName(node)}'.") 

746 raise ex 

747 elif node._identifier in self._nodes: 

748 raise ValueError(f"A node '{node._identifier}' was added before.") 

749 

750 self._nodes[node._identifier] = node 

751 return node 

752 

753 def AddEdge(self, edge: Edge) -> Edge: 

754 """ 

755 Add an edge. 

756 

757 An edge may connect nodes of different subgraphs. Graphviz places the edge with the subgraph it is written in, so 

758 an edge between clusters belongs to the graph containing both. 

759 

760 :param edge: The edge to add. 

761 :returns: The added edge, so it can be used in the calling expression. 

762 :raises ValueError: If parameter 'edge' is None. 

763 :raises TypeError: If parameter 'edge' is not an :class:`Edge`. 

764 """ 

765 if edge is None: 

766 raise ValueError("Parameter 'edge' is None.") 

767 elif not isinstance(edge, Edge): 

768 ex = TypeError("Parameter 'edge' is not of type 'Edge'.") 

769 ex.add_note(f"Got type '{getFullyQualifiedName(edge)}'.") 

770 raise ex 

771 

772 self._edges.append(edge) 

773 return edge 

774 

775 def GetNode(self, identifier: str) -> Node: 

776 """ 

777 Return the node with the given identifier. 

778 

779 :param identifier: Identifier of the node. 

780 :returns: The node with that identifier. 

781 :raises ValueError: If parameter 'identifier' is None. 

782 :raises TypeError: If parameter 'identifier' is not a string. 

783 :raises KeyError: If no node has that identifier. 

784 """ 

785 if identifier is None: 

786 raise ValueError("Parameter 'identifier' is None.") 

787 elif not isinstance(identifier, str): 

788 ex = TypeError("Parameter 'identifier' is not of type 'str'.") 

789 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

790 raise ex 

791 

792 return self._nodes[identifier] 

793 

794 def HasNode(self, identifier: str) -> bool: 

795 """ 

796 Check if a node with the given identifier was added. 

797 

798 :param identifier: Identifier of the node. 

799 :returns: ``True``, if such a node exists. 

800 :raises ValueError: If parameter 'identifier' is None. 

801 :raises TypeError: If parameter 'identifier' is not a string. 

802 """ 

803 if identifier is None: 

804 raise ValueError("Parameter 'identifier' is None.") 

805 elif not isinstance(identifier, str): 

806 ex = TypeError("Parameter 'identifier' is not of type 'str'.") 

807 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

808 raise ex 

809 

810 return identifier in self._nodes 

811 

812 def _StatementLines(self, kind: GraphKind, indent: int) -> list[str]: 

813 """ 

814 Render the graph's statements: attributes, defaults, subgraphs, nodes and edges. 

815 

816 :param kind: Kind of the graph, which decides the edge operator. 

817 :param indent: Indentation level of the statements. 

818 :returns: The statements as lines. 

819 """ 

820 lines = [f"{' ' * indent}{name}={self._FormatValue(value)};\n" for name, value in self._attributes.items()] 

821 lines.extend(self._nodeDefaults.ToStringLines(indent)) 

822 lines.extend(self._edgeDefaults.ToStringLines(indent)) 

823 for subgraph in self._subgraphs.values(): 

824 lines.extend(subgraph.ToStringLines(kind, indent)) 

825 

826 for node in self._nodes.values(): 

827 lines.extend(node.ToStringLines(indent)) 

828 

829 for edge in self._edges: 

830 lines.extend(edge.ToStringLines(kind, indent)) 

831 

832 return lines 

833 

834 

835@export 

836class Subgraph(BaseGraph): 

837 """ 

838 A subgraph of a DOT graph. 

839 

840 A subgraph whose identifier starts with ``cluster`` is a **cluster**: Graphviz draws its nodes together, inside a 

841 box, and its attributes like ``label`` and ``style`` apply to that box. 

842 """ 

843 _identifier: str #: Identifier of the subgraph. 

844 

845 def __init__(self, identifier: str, attributes: Nullable[Mapping[str, AttributeValue]] = None) -> None: 

846 """ 

847 Initialize an empty subgraph. 

848 

849 :param identifier: Identifier of the subgraph. It starts with ``cluster`` for a cluster. 

850 :param attributes: Optional, further attributes of the subgraph, by name. 

851 :raises ValueError: If parameter 'identifier' is None or empty. 

852 :raises TypeError: If parameter 'identifier' is not a string. 

853 """ 

854 super().__init__(attributes) 

855 

856 if identifier is None: 

857 raise ValueError("Parameter 'identifier' is None.") 

858 elif not isinstance(identifier, str): 

859 ex = TypeError("Parameter 'identifier' is not of type 'str'.") 

860 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

861 raise ex 

862 elif identifier == "": 

863 raise ValueError("Parameter 'identifier' is empty.") 

864 

865 self._identifier = identifier 

866 

867 @readonly 

868 def Identifier(self) -> str: 

869 """ 

870 Read-only property to access the subgraph's identifier (:attr:`_identifier`). 

871 

872 :returns: The identifier of the subgraph. 

873 """ 

874 return self._identifier 

875 

876 @readonly 

877 def IsCluster(self) -> bool: 

878 """ 

879 Read-only property to return whether Graphviz draws the subgraph as a cluster. 

880 

881 :returns: ``True``, if the identifier starts with ``cluster``. 

882 """ 

883 return self._identifier.startswith("cluster") 

884 

885 def ToStringLines(self, kind: GraphKind = GraphKind.Directed, indent: int = 1) -> list[str]: 

886 """ 

887 Render the subgraph as DOT lines. 

888 

889 :param kind: Optional, kind of the graph, which decides the edge operator. Default: :attr:`GraphKind.Directed`. 

890 :param indent: Optional, indentation level of the subgraph statement. 

891 :returns: The subgraph statement and its statements as lines. 

892 """ 

893 lines = [f"{' ' * indent}subgraph {quote(self._identifier)} {{\n"] 

894 lines.extend(self._StatementLines(kind, indent + 1)) 

895 lines.append(f"{' ' * indent}}}\n") 

896 

897 return lines 

898 

899 

900@export 

901class Graph(BaseGraph): 

902 """ 

903 A DOT graph - the document Graphviz reads. 

904 

905 Its kind decides whether it is a ``digraph`` or a ``graph``, and a **strict** graph merges multiple edges between 

906 the same two nodes into one. 

907 """ 

908 _identifier: Nullable[str] #: Identifier of the graph, which Graphviz uses as the drawing's name. 

909 _kind: GraphKind #: Directed or undirected. 

910 _strict: bool #: If ``True``, multiple edges between the same two nodes are merged. 

911 

912 def __init__( 

913 self, 

914 identifier: Nullable[str] = None, 

915 kind: GraphKind = GraphKind.Directed, 

916 strict: bool = False, 

917 attributes: Nullable[Mapping[str, AttributeValue]] = None 

918 ) -> None: 

919 """ 

920 Initialize an empty graph. 

921 

922 :param identifier: Optional, identifier of the graph, which Graphviz uses as the drawing's name. 

923 :param kind: Optional, kind of the graph. Default: :attr:`GraphKind.Directed`. 

924 :param strict: Optional, if ``True``, multiple edges between the same two nodes are merged. Default: 

925 ``False``. 

926 :param attributes: Optional, further attributes of the graph, by name, e.g. ``{"rankdir": "LR"}``. 

927 :raises TypeError: If parameter 'identifier' is not a string. 

928 :raises ValueError: If parameter 'kind' is None. 

929 :raises TypeError: If parameter 'kind' is not a :class:`GraphKind`. 

930 :raises ValueError: If parameter 'strict' is None. 

931 :raises TypeError: If parameter 'strict' is not a boolean. 

932 """ 

933 super().__init__(attributes) 

934 

935 if identifier is not None and not isinstance(identifier, str): 

936 ex = TypeError("Parameter 'identifier' is not of type 'str'.") 

937 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

938 raise ex 

939 

940 if kind is None: 

941 raise ValueError("Parameter 'kind' is None.") 

942 elif not isinstance(kind, GraphKind): 

943 ex = TypeError("Parameter 'kind' is not of type 'GraphKind'.") 

944 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.") 

945 raise ex 

946 

947 if strict is None: 

948 raise ValueError("Parameter 'strict' is None.") 

949 elif not isinstance(strict, bool): 

950 ex = TypeError("Parameter 'strict' is not of type 'bool'.") 

951 ex.add_note(f"Got type '{getFullyQualifiedName(strict)}'.") 

952 raise ex 

953 

954 self._identifier = identifier 

955 self._kind = kind 

956 self._strict = strict 

957 

958 @readonly 

959 def Identifier(self) -> Nullable[str]: 

960 """ 

961 Read-only property to access the graph's identifier (:attr:`_identifier`). 

962 

963 :returns: The identifier, or ``None`` if the graph is anonymous. 

964 """ 

965 return self._identifier 

966 

967 @readonly 

968 def Kind(self) -> GraphKind: 

969 """ 

970 Read-only property to access the graph's kind (:attr:`_kind`). 

971 

972 :returns: Directed or undirected. 

973 """ 

974 return self._kind 

975 

976 @readonly 

977 def Strict(self) -> bool: 

978 """ 

979 Read-only property to access whether multiple edges between the same two nodes are merged (:attr:`_strict`). 

980 

981 :returns: ``True``, if the graph is strict. 

982 """ 

983 return self._strict 

984 

985 @staticmethod 

986 def _Identifiers(elements: Sequence[Union[Vertex, pyToolingNode]]) -> dict[int, str]: 

987 """ 

988 Return the identifiers of vertices or tree nodes: an element's ID as text, or - without an ID - a generated 

989 ``vertex<number>``, which no element's ID is. 

990 

991 Generated numbers first fill the gaps between the numbers taken by IDs like ``vertex7``, then follow the highest of 

992 them. The elements without an ID are numbered in the order they are given. 

993 

994 :param elements: The vertices or tree nodes, in the order they are converted. 

995 :returns: The identifiers, by the elements' :func:`id`. 

996 """ 

997 identifiers = {id(element): str(element._id) for element in elements if element._id is not None} 

998 taken = sorted({ 

999 int(identifier[6:]) for identifier in identifiers.values() 

1000 if identifier.startswith("vertex") and identifier[6:].isdecimal() 

1001 }) 

1002 

1003 if len(taken) > 0 and taken[-1] == len(taken): 

1004 number = len(taken) + 1 

1005 index = len(taken) 

1006 else: 

1007 number = 1 

1008 index = 0 

1009 

1010 for element in elements: 

1011 if element._id is not None: 

1012 continue 

1013 

1014 while index < len(taken) and taken[index] <= number: 

1015 if taken[index] == number: 1015 ↛ 1018line 1015 didn't jump to line 1018 because the condition on line 1015 was always true

1016 number += 1 

1017 

1018 index += 1 

1019 

1020 identifiers[id(element)] = f"vertex{number}" 

1021 number += 1 

1022 

1023 return identifiers 

1024 

1025 def _ConvertVertex(self, vertex: Vertex, identifier: str) -> Node: 

1026 """ 

1027 Return the node a vertex of a :class:`pyTooling.Graph.Graph` becomes. 

1028 

1029 The node is labelled with the vertex' value. A vertex without a value shows its ID, and one without an ID either 

1030 gets an empty label rather than its generated identifier. A derived class overrides this method to add labels or 

1031 attributes. 

1032 

1033 :param vertex: The vertex to convert. 

1034 :param identifier: Identifier of the node: the vertex' ID, or a generated one. 

1035 :returns: The node. 

1036 """ 

1037 if vertex._value is not None: 

1038 return Node(identifier, str(vertex._value)) 

1039 elif vertex._id is None: 

1040 return Node(identifier, "") 

1041 

1042 return Node(identifier) 

1043 

1044 def _ConvertEdge(self, edge: pyToolingEdge, source: Node, target: Node) -> Edge: 

1045 """ 

1046 Return the edge an edge of a :class:`pyTooling.Graph.Graph` becomes, labelled with the edge's value. 

1047 

1048 A derived class overrides this method to add labels or attributes. 

1049 

1050 :param edge: The edge to convert. 

1051 :param source: Node the converted edge's source vertex became. 

1052 :param target: Node the converted edge's destination vertex became. 

1053 :returns: The edge. 

1054 """ 

1055 if edge._value is not None: 

1056 return Edge(source, target, {"label": str(edge._value)}) 

1057 

1058 return Edge(source, target) 

1059 

1060 def _ConvertLink(self, link: pyToolingLink, source: Node, target: Node) -> Edge: 

1061 """ 

1062 Return the edge a link between two subgraphs of a :class:`pyTooling.Graph.Graph` becomes: dashed, and labelled 

1063 with the link's value. 

1064 

1065 A derived class overrides this method to add labels or attributes. 

1066 

1067 :param link: The link to convert. 

1068 :param source: Node the link's source vertex became. 

1069 :param target: Node the link's destination vertex became. 

1070 :returns: The edge. 

1071 """ 

1072 if link._value is not None: 1072 ↛ 1075line 1072 didn't jump to line 1075 because the condition on line 1072 was always true

1073 return Edge(source, target, {"style": "dashed", "label": str(link._value)}) 

1074 

1075 return Edge(source, target, {"style": "dashed"}) 

1076 

1077 def _ConvertSubgraph(self, subgraph: pyToolingSubgraph, identifier: str) -> Subgraph: 

1078 """ 

1079 Return the cluster a subgraph of a :class:`pyTooling.Graph.Graph` becomes, labelled with the subgraph's name. 

1080 

1081 A derived class overrides this method to add labels or attributes. 

1082 

1083 :param subgraph: The subgraph to convert. 

1084 :param identifier: Identifier of the cluster. 

1085 :returns: The cluster, still empty. 

1086 """ 

1087 if subgraph._name is not None: 1087 ↛ 1090line 1087 didn't jump to line 1090 because the condition on line 1087 was always true

1088 return Subgraph(identifier, {"label": subgraph._name}) 

1089 

1090 return Subgraph(identifier) 

1091 

1092 def _ConvertTreeNode(self, node: pyToolingNode, identifier: str) -> Node: 

1093 """ 

1094 Return the node a node of a :class:`pyTooling.Tree.Node` tree becomes. 

1095 

1096 The node is labelled like a vertex in :meth:`_ConvertVertex`. A derived class overrides this method to add labels 

1097 or attributes. 

1098 

1099 :param node: The tree node to convert. 

1100 :param identifier: Identifier of the node: the tree node's ID, or a generated one. 

1101 :returns: The node. 

1102 """ 

1103 if node._value is not None: 

1104 return Node(identifier, str(node._value)) 

1105 elif node._id is None: 

1106 return Node(identifier, "") 

1107 

1108 return Node(identifier) 

1109 

1110 def FromGraph(self, graph: pyToolingGraph) -> None: 

1111 """ 

1112 Fill this graph from a :class:`pyTooling.Graph.Graph`. 

1113 

1114 Every subgraph becomes a cluster of its vertices, every vertex a node and every edge an edge, in the graph or in 

1115 the cluster they belong to. A link between two subgraphs becomes an edge of this graph. A vertex without an ID 

1116 gets a generated identifier, which no vertex with an ID has. Subgraphs are converted in the order of their names, 

1117 so the same graph is always written the same way. 

1118 

1119 What an element becomes is decided by :meth:`_ConvertVertex`, :meth:`_ConvertEdge`, :meth:`_ConvertLink` and 

1120 :meth:`_ConvertSubgraph`, which a derived class overrides. Without an identifier of its own, this graph takes the 

1121 graph's name. 

1122 

1123 :param graph: The graph to convert. 

1124 :raises ValueError: If parameter 'graph' is None. 

1125 :raises TypeError: If parameter 'graph' is not a :class:`pyTooling.Graph.Graph`. 

1126 """ 

1127 if graph is None: 

1128 raise ValueError("Parameter 'graph' is None.") 

1129 elif not isinstance(graph, pyToolingGraph): 

1130 ex = TypeError("Parameter 'graph' is not of type 'pyTooling.Graph.Graph'.") 

1131 ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.") 

1132 raise ex 

1133 

1134 if self._identifier is None: 

1135 self._identifier = graph._name 

1136 

1137 subgraphs = sorted(graph.Subgraphs, key=lambda subgraph: "" if subgraph._name is None else subgraph._name) 

1138 vertices: list[Vertex] = [] 

1139 for subgraph in subgraphs: 

1140 vertices += subgraph.IterateVertices() 

1141 

1142 vertices += graph.IterateVertices() 

1143 

1144 identifiers = self._Identifiers(vertices) 

1145 nodes: dict[int, Node] = {} 

1146 

1147 clusters = [] 

1148 for index, subgraph in enumerate(subgraphs, start=1): 

1149 cluster = self.AddSubgraph(self._ConvertSubgraph(subgraph, f"cluster{index}")) 

1150 clusters.append(cluster) 

1151 for vertex in subgraph.IterateVertices(): 

1152 nodes[id(vertex)] = cluster.AddNode(self._ConvertVertex(vertex, identifiers[id(vertex)])) 

1153 

1154 for vertex in graph.IterateVertices(): 

1155 nodes[id(vertex)] = self.AddNode(self._ConvertVertex(vertex, identifiers[id(vertex)])) 

1156 

1157 for cluster, subgraph in zip(clusters, subgraphs): 

1158 for edge in subgraph.IterateEdges(): 

1159 cluster.AddEdge(self._ConvertEdge(edge, nodes[id(edge._source)], nodes[id(edge._destination)])) 

1160 

1161 for edge in graph.IterateEdges(): 

1162 self.AddEdge(self._ConvertEdge(edge, nodes[id(edge._source)], nodes[id(edge._destination)])) 

1163 

1164 # a link is known to both subgraphs it connects 

1165 converted: set[int] = set() 

1166 for subgraph in subgraphs: 

1167 for link in subgraph.IterateLinks(): 

1168 if id(link) not in converted: 

1169 converted.add(id(link)) 

1170 self.AddEdge(self._ConvertLink(link, nodes[id(link._source)], nodes[id(link._destination)])) 

1171 

1172 def FromTree(self, tree: pyToolingNode) -> None: 

1173 """ 

1174 Fill this graph from a tree of :class:`pyTooling.Tree.Node`. 

1175 

1176 Every tree node becomes a node, connected to its parent by an edge from parent to child. A tree node without an 

1177 ID gets a generated identifier, which no tree node with an ID has. 

1178 

1179 What a tree node becomes is decided by :meth:`_ConvertTreeNode`, which a derived class overrides. Without an 

1180 identifier of its own, this graph takes the root's ID. 

1181 

1182 :param tree: The root of the tree to convert. 

1183 :raises ValueError: If parameter 'tree' is None. 

1184 :raises TypeError: If parameter 'tree' is not a :class:`pyTooling.Tree.Node`. 

1185 """ 

1186 if tree is None: 

1187 raise ValueError("Parameter 'tree' is None.") 

1188 elif not isinstance(tree, pyToolingNode): 

1189 ex = TypeError("Parameter 'tree' is not of type 'pyTooling.Tree.Node'.") 

1190 ex.add_note(f"Got type '{getFullyQualifiedName(tree)}'.") 

1191 raise ex 

1192 

1193 if self._identifier is None and tree._id is not None: 1193 ↛ 1196line 1193 didn't jump to line 1196 because the condition on line 1193 was always true

1194 self._identifier = str(tree._id) 

1195 

1196 treeNodes = [tree] 

1197 treeNodes += tree.GetDescendants() 

1198 

1199 identifiers = self._Identifiers(treeNodes) 

1200 nodes: dict[int, Node] = {} 

1201 

1202 for treeNode in treeNodes: 

1203 node = self.AddNode(self._ConvertTreeNode(treeNode, identifiers[id(treeNode)])) 

1204 nodes[id(treeNode)] = node 

1205 if treeNode is not tree: 

1206 self.AddEdge(Edge(nodes[id(treeNode._parent)], node)) 

1207 

1208 def ToStringLines(self, indent: int = 0) -> list[str]: 

1209 """ 

1210 Render the graph as DOT lines. 

1211 

1212 :param indent: Optional, indentation level of the graph statement. 

1213 :returns: The graph and its statements as lines. 

1214 """ 

1215 head = f"{'strict ' if self._strict else ''}{self._kind.Keyword}" 

1216 if self._identifier is not None: 

1217 head += f" {quote(self._identifier)}" 

1218 

1219 lines = [f"{' ' * indent}{head} {{\n"] 

1220 lines.extend(self._StatementLines(self._kind, indent + 1)) 

1221 lines.append(f"{' ' * indent}}}\n") 

1222 

1223 return lines 

1224 

1225 def WriteToFile(self, file: Path) -> None: 

1226 """ 

1227 Write the graph as a DOT file. 

1228 

1229 :param file: Path of the file to write. 

1230 :raises ValueError: If parameter 'file' is None. 

1231 :raises TypeError: If parameter 'file' is not a :class:`~pathlib.Path`. 

1232 """ 

1233 if file is None: 

1234 raise ValueError("Parameter 'file' is None.") 

1235 elif not isinstance(file, Path): 

1236 ex = TypeError("Parameter 'file' is not of type 'Path'.") 

1237 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.") 

1238 raise ex 

1239 

1240 with file.open("w", encoding="utf-8") as f: 

1241 f.writelines(self.ToStringLines()) 

1242 

1243 def __str__(self) -> str: 

1244 """ 

1245 Return the graph as DOT text. 

1246 

1247 :returns: The graph in the DOT language. 

1248 """ 

1249 return "".join(self.ToStringLines())