Coverage for pyTooling/Graph/GraphML.py: 97%

436 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +0000

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

2# _____ _ _ ____ _ # 

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

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

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

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

14# Copyright 2017-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 out GraphML XML files. 

33 

34.. seealso:: 

35 

36 `GraphML Primer <http://graphml.graphdrawing.org/primer/graphml-primer.html>`__ 

37 |rarr| The format's own introduction, describing the elements this module writes. 

38""" 

39from __future__ import annotations 

40 

41from enum import Enum, auto 

42from pathlib import Path 

43from typing import Any, ClassVar, Union, Optional as Nullable 

44 

45from pyTooling.Decorators import export, notimplemented, readonly 

46from pyTooling.MetaClasses import ExtendedType, abstractmethod 

47from pyTooling.Graph import Graph as pyToolingGraph, Subgraph as pyToolingSubgraph, Edge as pyToolingEdge, \ 

48 Link as pyToolingLink, GraphViz 

49from pyTooling.Tree import Node as pyToolingNode 

50 

51 

52_ATTRIBUTE_ESCAPES = str.maketrans({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}) #: Translation table escaping an XML attribute value. 

53 

54 

55def _escapeAttribute(value: Any) -> str: 

56 """ 

57 Escape a value for an XML attribute in double quotes: ``&``, ``<``, ``>`` and ``"``. 

58 

59 :param value: The value, converted by :func:`str`. 

60 :returns: The escaped text. 

61 """ 

62 return str(value).translate(_ATTRIBUTE_ESCAPES) 

63 

64@export 

65class AttributeContext(Enum): 

66 """ 

67 Enumeration of all attribute contexts. 

68 

69 An attribute context describes to what kind of GraphML node an attribute can be applied. 

70 """ 

71 GraphML = auto() 

72 Graph = auto() 

73 Node = auto() 

74 Edge = auto() 

75 Port = auto() 

76 

77 def __str__(self) -> str: 

78 """ 

79 Return the enumeration value's name as it is written in a GraphML document. 

80 

81 :returns: Name of the enumeration value in lower case. 

82 """ 

83 return f"{self.name.lower()}" 

84 

85 

86@export 

87class AttributeTypes(Enum): 

88 """ 

89 Enumeration of all attribute types. 

90 

91 An attribute type describes what datatype can be applied to an attribute. 

92 """ 

93 Boolean = auto() 

94 Int = auto() 

95 Long = auto() 

96 Float = auto() 

97 Double = auto() 

98 String = auto() 

99 

100 def __str__(self) -> str: 

101 """ 

102 Return the enumeration value's name as it is written in a GraphML document. 

103 

104 :returns: Name of the enumeration value in lower case. 

105 """ 

106 return f"{self.name.lower()}" 

107 

108 

109@export 

110class EdgeDefault(Enum): 

111 """An enumeration describing the default edge direction.""" 

112 Undirected = auto() 

113 Directed = auto() 

114 

115 def __str__(self) -> str: 

116 """ 

117 Return the enumeration value's name as it is written in a GraphML document. 

118 

119 :returns: Name of the enumeration value in lower case. 

120 """ 

121 return f"{self.name.lower()}" 

122 

123 

124@export 

125class ParsingOrder(Enum): 

126 """An enumeration describing the parsing order of the graph's representation.""" 

127 NodesFirst = auto() #: First, all nodes are given, then followed by all edges. 

128 AdjacencyList = auto() 

129 Free = auto() 

130 

131 def __str__(self) -> str: 

132 """ 

133 Return the enumeration value's name as it is written in a GraphML document. 

134 

135 :returns: Name of the enumeration value in lower case. 

136 """ 

137 return f"{self.name.lower()}" 

138 

139 

140@export 

141class IDStyle(Enum): 

142 """An enumeration describing the style of identifiers (IDs).""" 

143 Canonical = auto() 

144 Free = auto() 

145 

146 def __str__(self) -> str: 

147 """ 

148 Return the enumeration value's name as it is written in a GraphML document. 

149 

150 :returns: Name of the enumeration value in lower case. 

151 """ 

152 return f"{self.name.lower()}" 

153 

154 

155@export 

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

157 """ 

158 Base-class for all GraphML data model classes. 

159 """ 

160 @readonly 

161 def HasClosingTag(self) -> bool: 

162 """ 

163 Check if this XML element is written with a separate closing tag. 

164 

165 :returns: ``True``, if the element needs a closing tag. 

166 """ 

167 return True 

168 

169 @abstractmethod 

170 def Tag(self, indent: int = 0) -> str: 

171 """ 

172 Return this element as a self-closing XML tag. 

173 

174 An element always written with an opening and a closing tag overrides it with 

175 :deco:`~pyTooling.Decorators.notimplemented`. 

176 

177 :param indent: Optional, indentation level of the XML element. 

178 :returns: The XML tag, indented and terminated by a newline. 

179 """ 

180 

181 @abstractmethod 

182 def OpeningTag(self, indent: int = 0) -> str: 

183 """ 

184 Return the opening XML tag of this element. 

185 

186 An element always written as a single tag overrides it with :deco:`~pyTooling.Decorators.notimplemented`. 

187 

188 :param indent: Optional, indentation level of the XML element. 

189 :returns: The opening XML tag, indented and terminated by a newline. 

190 """ 

191 

192 @abstractmethod 

193 def ClosingTag(self, indent: int = 0) -> str: 

194 """ 

195 Return the closing XML tag of this element. 

196 

197 An element always written as a single tag overrides it with :deco:`~pyTooling.Decorators.notimplemented`. 

198 

199 :param indent: Optional, indentation level of the XML element. 

200 :returns: The closing XML tag, indented and terminated by a newline. 

201 """ 

202 

203 @abstractmethod 

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

205 """ 

206 Render this element as a list of XML lines. 

207 

208 :param indent: Optional, indentation level of the XML element. 

209 :returns: List of XML lines describing this element. 

210 """ 

211 

212 

213@export 

214class BaseWithID(Base): 

215 """Base-class for all GraphML elements carrying a document-wide unique ID.""" 

216 _id: Nullable[str] #: Unique identifier of this GraphML element. ``None`` if it has none, e.g. an edge. 

217 

218 def __init__(self, identifier: Nullable[str]) -> None: 

219 """ 

220 Initialize a GraphML element with its unique ID. 

221 

222 :param identifier: Unique ID of the element within the GraphML document, or ``None``. 

223 """ 

224 super().__init__() 

225 self._id = identifier 

226 

227 @readonly 

228 def ID(self) -> Nullable[str]: 

229 """ 

230 Read-only property to access the element's unique ID (:attr:`_id`). 

231 

232 :returns: Unique ID of the element, or ``None`` if it has none. 

233 """ 

234 return self._id 

235 

236 

237@export 

238class BaseWithData(BaseWithID): 

239 """Base-class for all GraphML elements that can carry attached data items (key-value-pairs).""" 

240 _data: list[Data] #: Data items (key-value-pairs) attached to this GraphML element. 

241 

242 def __init__(self, identifier: Nullable[str]) -> None: 

243 """ 

244 Initialize a GraphML element with its unique ID and an empty list of data items. 

245 

246 :param identifier: Unique ID of the element within the GraphML document, or ``None``. 

247 """ 

248 super().__init__(identifier) 

249 

250 self._data = [] 

251 

252 @readonly 

253 def Data(self) -> list[Data]: 

254 """ 

255 Read-only property to access the data elements attached to this element (:attr:`_data`). 

256 

257 :returns: List of data elements. 

258 """ 

259 return self._data 

260 

261 def AddData(self, data: Data) -> Data: 

262 """ 

263 Attach a data item (key-value-pair) to this element. 

264 

265 :param data: The data item to attach. 

266 :returns: The attached data item, so it can be used in the calling expression. 

267 """ 

268 self._data.append(data) 

269 return data 

270 

271 

272@export 

273class Key(BaseWithID): 

274 """ 

275 Declares an attribute that data items can refer to. 

276 

277 A GraphML document declares its attributes once - name, data type, and the element kind they apply to - and every 

278 :class:`Data` item then references such a key by ID. 

279 """ 

280 _context: AttributeContext #: GraphML element kind this key can be used on. 

281 _attributeName: str #: Name of the attribute described by this key. 

282 _attributeType: AttributeTypes #: Data type of the attribute described by this key. 

283 

284 def __init__(self, identifier: str, context: AttributeContext, name: str, type: AttributeTypes) -> None: 

285 """ 

286 Initialize a key declaring an attribute. 

287 

288 :param identifier: Unique ID of the key within the GraphML document. 

289 :param context: GraphML element kind this key can be used on. 

290 :param name: Name of the declared attribute. 

291 :param type: Data type of the declared attribute. 

292 """ 

293 super().__init__(identifier) 

294 

295 self._context = context 

296 self._attributeName = name 

297 self._attributeType = type 

298 

299 @readonly 

300 def Context(self) -> AttributeContext: 

301 """ 

302 Read-only property to access the context this key applies to (:attr:`_context`). 

303 

304 :returns: The attribute's context (graph, node, edge, ...). 

305 """ 

306 return self._context 

307 

308 @readonly 

309 def AttributeName(self) -> str: 

310 """ 

311 Read-only property to access the name of the described attribute (:attr:`_attributeName`). 

312 

313 :returns: Name of the attribute. 

314 """ 

315 return self._attributeName 

316 

317 @readonly 

318 def AttributeType(self) -> AttributeTypes: 

319 """ 

320 Read-only property to access the type of the described attribute (:attr:`_attributeType`). 

321 

322 :returns: Type of the attribute. 

323 """ 

324 return self._attributeType 

325 

326 @readonly 

327 def HasClosingTag(self) -> bool: 

328 """ 

329 Check if this XML element is written with a separate closing tag. 

330 

331 A key is always written as a self-closing tag. 

332 

333 :returns: ``False``, because a key never has a closing tag. 

334 """ 

335 return False 

336 

337 def Tag(self, indent: int = 2) -> str: 

338 """ 

339 Return this key as a self-closing XML tag. 

340 

341 :param indent: Optional, indentation level of the XML element. 

342 :returns: The XML tag, indented and terminated by a newline. 

343 """ 

344 identifier = _escapeAttribute(self._id) 

345 attributeName = _escapeAttribute(self._attributeName) 

346 return f"""{' '*indent}<key id="{identifier}" for="{self._context}" attr.name="{attributeName}" attr.type="{self._attributeType}" />\n""" 

347 

348 @notimplemented("A key is always written as a self-closing tag.") 

349 def OpeningTag(self, indent: int = 2) -> str: 

350 """ 

351 A key has no content, so it is never written with an opening and a closing tag. 

352 

353 :param indent: Optional, indentation level of the XML element. 

354 """ 

355 

356 @notimplemented("A key is always written as a self-closing tag.") 

357 def ClosingTag(self, indent: int = 2) -> str: 

358 """ 

359 A key has no content, so it is never written with an opening and a closing tag. 

360 

361 :param indent: Optional, indentation level of the XML element. 

362 """ 

363 

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

365 """ 

366 Render this key as a list of XML lines. 

367 

368 :param indent: Optional, indentation level of the XML element. 

369 :returns: List of XML lines describing this key and everything attached to it. 

370 """ 

371 return [self.Tag(indent)] 

372 

373 

374@export 

375class Data(Base): 

376 """A single attached attribute: a value and the :class:`Key` describing it.""" 

377 _key: Key #: Key describing name and type of this data item. 

378 _data: Any #: Value of this data item. 

379 

380 def __init__(self, key: Key, data: Any) -> None: 

381 """ 

382 Initialize a data item with the key describing it and its value. 

383 

384 :param key: Key declaring name and type of this attribute. 

385 :param data: Value of this attribute. 

386 """ 

387 super().__init__() 

388 

389 self._key = key 

390 self._data = data 

391 

392 @readonly 

393 def Key(self) -> Key: 

394 """ 

395 Read-only property to access the key describing this data element (:attr:`_key`). 

396 

397 :returns: The key this data element refers to. 

398 """ 

399 return self._key 

400 

401 @readonly 

402 def Data(self) -> Any: 

403 """ 

404 Read-only property to access the data element's value (:attr:`_data`). 

405 

406 :returns: Value of the data element. 

407 """ 

408 return self._data 

409 

410 @readonly 

411 def HasClosingTag(self) -> bool: 

412 """ 

413 Check if this XML element is written with a separate closing tag. 

414 

415 :returns: ``False``, because a data element is written inline. 

416 """ 

417 return False 

418 

419 def Tag(self, indent: int = 2) -> str: 

420 """ 

421 Return this data item as a self-closing XML tag. 

422 

423 :param indent: Optional, indentation level of the XML element. 

424 :returns: The XML tag, indented and terminated by a newline. 

425 """ 

426 data = str(self._data) 

427 data = data.replace("&", "&amp;") 

428 data = data.replace("<", "&lt;") 

429 data = data.replace(">", "&gt;") 

430 data = data.replace("\n", "\\n") 

431 return f"""{' '*indent}<data key="{_escapeAttribute(self._key._id)}">{data}</data>\n""" 

432 

433 @notimplemented("A data item is always written in one line by Tag().") 

434 def OpeningTag(self, indent: int = 2) -> str: 

435 """ 

436 A data item holds a value only, so :meth:`Tag` writes it in one line. 

437 

438 :param indent: Optional, indentation level of the XML element. 

439 """ 

440 

441 @notimplemented("A data item is always written in one line by Tag().") 

442 def ClosingTag(self, indent: int = 2) -> str: 

443 """ 

444 A data item holds a value only, so :meth:`Tag` writes it in one line. 

445 

446 :param indent: Optional, indentation level of the XML element. 

447 """ 

448 

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

450 """ 

451 Render this data item as a list of XML lines. 

452 

453 :param indent: Optional, indentation level of the XML element. 

454 :returns: List of XML lines describing this data item and everything attached to it. 

455 """ 

456 return [self.Tag(indent)] 

457 

458 

459@export 

460class Node(BaseWithData): 

461 """A node (vertex) of a GraphML graph.""" 

462 

463 def __init__(self, identifier: str) -> None: 

464 """ 

465 Initialize a node. 

466 

467 :param identifier: Unique ID of the node within the GraphML document. 

468 """ 

469 super().__init__(identifier) 

470 

471 @readonly 

472 def HasClosingTag(self) -> bool: 

473 """ 

474 Check if this XML element is written with a separate closing tag. 

475 

476 :returns: ``True``, if the node carries data elements, otherwise ``False``. 

477 """ 

478 return len(self._data) > 0 

479 

480 def Tag(self, indent: int = 2) -> str: 

481 """ 

482 Return this node as a self-closing XML tag. 

483 

484 :param indent: Optional, indentation level of the XML element. 

485 :returns: The XML tag, indented and terminated by a newline. 

486 """ 

487 return f"""{' '*indent}<node id="{_escapeAttribute(self._id)}" />\n""" 

488 

489 def OpeningTag(self, indent: int = 2) -> str: 

490 """ 

491 Return the opening XML tag of this node. 

492 

493 :param indent: Optional, indentation level of the XML element. 

494 :returns: The opening XML tag, indented and terminated by a newline. 

495 """ 

496 return f"""{' '*indent}<node id="{_escapeAttribute(self._id)}">\n""" 

497 

498 def ClosingTag(self, indent: int = 2) -> str: 

499 """ 

500 Return the closing XML tag of this node. 

501 

502 :param indent: Optional, indentation level of the XML element. 

503 :returns: The closing XML tag, indented and terminated by a newline. 

504 """ 

505 return f"""{' ' * indent}</node>\n""" 

506 

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

508 """ 

509 Render this node as a list of XML lines. 

510 

511 :param indent: Optional, indentation level of the XML element. 

512 :returns: List of XML lines describing this node and everything attached to it. 

513 """ 

514 if not self.HasClosingTag: 

515 return [self.Tag(indent)] 

516 

517 lines = [self.OpeningTag(indent)] 

518 for data in self._data: 

519 lines.extend(data.ToStringLines(indent + 1)) 

520 lines.append(self.ClosingTag(indent)) 

521 

522 return lines 

523 

524 

525@export 

526class Edge(BaseWithData): 

527 """An edge of a GraphML graph, connecting a source node to a target node.""" 

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

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

530 

531 def __init__(self, identifier: Nullable[str], source: Node, target: Node) -> None: 

532 """ 

533 Initialize an edge between two nodes. 

534 

535 :param identifier: Unique ID of the edge within the GraphML document. 

536 :param source: Node the edge starts at. 

537 :param target: Node the edge ends at. 

538 """ 

539 super().__init__(identifier) 

540 

541 self._source = source 

542 self._target = target 

543 

544 @readonly 

545 def Source(self) -> Node: 

546 """ 

547 Read-only property to access the edge's source node (:attr:`_source`). 

548 

549 :returns: Source node of the edge. 

550 """ 

551 return self._source 

552 

553 @readonly 

554 def Target(self) -> Node: 

555 """ 

556 Read-only property to access the edge's target node (:attr:`_target`). 

557 

558 :returns: Target node of the edge. 

559 """ 

560 return self._target 

561 

562 def _IDAttribute(self) -> str: 

563 """ 

564 Return the ``id`` attribute of this edge's tag, or nothing for an edge without an ID - it is optional in GraphML. 

565 

566 :returns: The attribute with a leading space, or an empty string. 

567 """ 

568 if self._id is None: 

569 return "" 

570 else: 

571 return f' id="{_escapeAttribute(self._id)}"' 

572 

573 @readonly 

574 def HasClosingTag(self) -> bool: 

575 """ 

576 Check if this XML element is written with a separate closing tag. 

577 

578 :returns: ``True``, if the edge carries data elements, otherwise ``False``. 

579 """ 

580 return len(self._data) > 0 

581 

582 def Tag(self, indent: int = 2) -> str: 

583 """ 

584 Return this edge as a self-closing XML tag. 

585 

586 :param indent: Optional, indentation level of the XML element. 

587 :returns: The XML tag, indented and terminated by a newline. 

588 """ 

589 source = _escapeAttribute(self._source._id) 

590 target = _escapeAttribute(self._target._id) 

591 return f"""{' ' * indent}<edge{self._IDAttribute()} source="{source}" target="{target}" />\n""" 

592 

593 def OpeningTag(self, indent: int = 2) -> str: 

594 """ 

595 Return the opening XML tag of this edge. 

596 

597 :param indent: Optional, indentation level of the XML element. 

598 :returns: The opening XML tag, indented and terminated by a newline. 

599 """ 

600 source = _escapeAttribute(self._source._id) 

601 target = _escapeAttribute(self._target._id) 

602 return f"""{' '*indent}<edge{self._IDAttribute()} source="{source}" target="{target}">\n""" 

603 

604 def ClosingTag(self, indent: int = 2) -> str: 

605 """ 

606 Return the closing XML tag of this edge. 

607 

608 :param indent: Optional, indentation level of the XML element. 

609 :returns: The closing XML tag, indented and terminated by a newline. 

610 """ 

611 return f"""{' ' * indent}</edge>\n""" 

612 

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

614 """ 

615 Render this edge as a list of XML lines. 

616 

617 :param indent: Optional, indentation level of the XML element. 

618 :returns: List of XML lines describing this edge and everything attached to it. 

619 """ 

620 if not self.HasClosingTag: 

621 return [self.Tag(indent)] 

622 

623 lines = [self.OpeningTag(indent)] 

624 for data in self._data: 

625 lines.extend(data.ToStringLines(indent + 1)) 

626 lines.append(self.ClosingTag(indent)) 

627 

628 return lines 

629 

630 

631@export 

632class BaseGraph(BaseWithData, mixin=True): 

633 """ 

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

635 

636 Beside the elements themselves, it carries the document-level settings applied while writing them: the default edge 

637 direction, the parsing order, and the ID styles for nodes and edges. 

638 """ 

639 _subgraphs: dict[str, Subgraph] #: Subgraphs of this graph, by ID. 

640 _nodes: dict[str, Node] #: Nodes of this graph, by ID. 

641 _edges: dict[str, Edge] #: Edges of this graph with an ID, by ID. 

642 _edgesWithoutID: list[Edge] #: Edges of this graph without an ID. 

643 _edgeDefault: EdgeDefault #: Direction applied to edges that don't specify one. 

644 _parseOrder: ParsingOrder #: Order in which nodes and edges may appear in the XML document. 

645 _nodeIDStyle: IDStyle #: Whether node IDs are free-form or canonical. 

646 _edgeIDStyle: IDStyle #: Whether edge IDs are free-form or canonical. 

647 

648 def __init__(self, identifier: Nullable[str] = None) -> None: 

649 """ 

650 Initialize an empty graph with the default document settings. 

651 

652 Edges are directed, nodes are written before edges, and both ID styles are free-form until they are changed. 

653 

654 :param identifier: Optional, unique ID of the graph within the GraphML document. 

655 """ 

656 super().__init__(identifier) 

657 

658 self._subgraphs = {} 

659 self._nodes = {} 

660 self._edges = {} 

661 self._edgesWithoutID = [] 

662 self._edgeDefault = EdgeDefault.Directed 

663 self._parseOrder = ParsingOrder.NodesFirst 

664 self._nodeIDStyle = IDStyle.Free 

665 self._edgeIDStyle = IDStyle.Free 

666 

667 @readonly 

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

669 """ 

670 Read-only property to access the graph's subgraphs (:attr:`_subgraphs`). 

671 

672 :returns: Dictionary of subgraph IDs and subgraphs. 

673 """ 

674 return self._subgraphs 

675 

676 @readonly 

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

678 """ 

679 Read-only property to access the graph's nodes (:attr:`_nodes`). 

680 

681 :returns: Dictionary of node IDs and nodes. 

682 """ 

683 return self._nodes 

684 

685 @readonly 

686 def Edges(self) -> dict[str, Edge]: 

687 """ 

688 Read-only property to access the graph's edges with an ID (:attr:`_edges`). 

689 

690 An edge without an ID is in :attr:`EdgesWithoutID`. 

691 

692 :returns: Dictionary of edge IDs and edges. 

693 """ 

694 return self._edges 

695 

696 @readonly 

697 def EdgesWithoutID(self) -> list[Edge]: 

698 """ 

699 Read-only property to access the graph's edges without an ID (:attr:`_edgesWithoutID`). 

700 

701 An edge with an ID is in :attr:`Edges`. 

702 

703 :returns: List of edges without an ID, in the order they were added. 

704 """ 

705 return self._edgesWithoutID 

706 

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

708 """ 

709 Add a subgraph to this graph, which is a node of this graph as well. 

710 

711 :param subgraph: The subgraph to add. 

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

713 """ 

714 self._subgraphs[subgraph._subgraphID] = subgraph 

715 self._nodes[subgraph._id] = subgraph 

716 return subgraph 

717 

718 def GetSubgraph(self, subgraphName: str) -> Subgraph: 

719 """ 

720 Return the subgraph with the given ID. 

721 

722 :param subgraphName: ID of the subgraph. 

723 :returns: The subgraph with that ID. 

724 :raises KeyError: If no subgraph has that ID. 

725 """ 

726 return self._subgraphs[subgraphName] 

727 

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

729 """ 

730 Add a node to this graph. 

731 

732 :param node: The node to add. 

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

734 """ 

735 self._nodes[node._id] = node 

736 return node 

737 

738 def GetNode(self, nodeName: str) -> Node: 

739 """ 

740 Return the node with the given ID. 

741 

742 :param nodeName: ID of the node. 

743 :returns: The node with that ID. 

744 :raises KeyError: If no node has that ID. 

745 """ 

746 return self._nodes[nodeName] 

747 

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

749 """ 

750 Add an edge to this graph. 

751 

752 :param edge: The edge to add. 

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

754 """ 

755 if edge._id is None: 

756 self._edgesWithoutID.append(edge) 

757 else: 

758 self._edges[edge._id] = edge 

759 

760 return edge 

761 

762 def GetEdge(self, edgeName: str) -> Edge: 

763 """ 

764 Return the edge with the given ID. 

765 

766 :param edgeName: ID of the edge. 

767 :returns: The edge with that ID. 

768 :raises KeyError: If no edge has that ID. 

769 """ 

770 return self._edges[edgeName] 

771 

772 @notimplemented("A graph is always written with an opening and a closing tag.") 

773 def Tag(self, indent: int = 1) -> str: 

774 """ 

775 A graph is written with its nodes and edges inside, so it is never written as a self-closing tag. 

776 

777 :param indent: Optional, indentation level of the XML element. 

778 """ 

779 

780 def OpeningTag(self, indent: int = 1) -> str: 

781 """ 

782 Return the opening XML tag of this graph. 

783 

784 Beside the graph's ID, the tag carries the parsing hints a reader needs: the default edge direction, the 

785 number of nodes and edges, the parsing order and both ID styles. 

786 

787 :param indent: Optional, indentation level of the XML element. 

788 :returns: The opening XML tag, indented and terminated by a newline. 

789 """ 

790 return f"""\ 

791{' '*indent}<graph id="{_escapeAttribute(self._id)}" 

792{' '*indent} edgedefault="{self._edgeDefault!s}" 

793{' '*indent} parse.nodes="{len(self._nodes)}" 

794{' '*indent} parse.edges="{len(self._edgesWithoutID) + len(self._edges)}" 

795{' '*indent} parse.order="{self._parseOrder!s}" 

796{' '*indent} parse.nodeids="{self._nodeIDStyle!s}" 

797{' '*indent} parse.edgeids="{self._edgeIDStyle!s}"> 

798""" 

799 

800 def ClosingTag(self, indent: int = 1) -> str: 

801 """ 

802 Return the closing XML tag of this graph. 

803 

804 :param indent: Optional, indentation level of the XML element. 

805 :returns: The closing XML tag, indented and terminated by a newline. 

806 """ 

807 return f"{' '*indent}</graph>\n" 

808 

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

810 """ 

811 Render this graph as a list of XML lines. 

812 

813 :param indent: Optional, indentation level of the XML element. 

814 :returns: List of XML lines describing this graph and everything it contains. 

815 """ 

816 lines = [self.OpeningTag(indent)] 

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

818 lines.extend(node.ToStringLines(indent + 1)) 

819 

820 for edge in (*self._edgesWithoutID, *self._edges.values()): 

821 lines.extend(edge.ToStringLines(indent + 1)) 

822 # for data in self._data: 

823 # lines.extend(data.ToStringLines(indent + 1)) 

824 lines.append(self.ClosingTag(indent)) 

825 

826 return lines 

827 

828 

829@export 

830class Graph(BaseGraph): 

831 """ 

832 The root graph of a GraphML document. 

833 

834 It owns the ID space: every node, edge and subgraph registers itself here, so an ID is used only once per document. 

835 """ 

836 _document: GraphMLDocument #: The GraphML document this graph belongs to. 

837 _ids: dict[str, Union[Node, Edge, Subgraph]] #: Every element of this graph by ID, used to keep IDs unique. 

838 

839 def __init__(self, document: GraphMLDocument, identifier: str) -> None: 

840 """ 

841 Initialize the root graph of a GraphML document. 

842 

843 :param document: The GraphML document this graph belongs to. 

844 :param identifier: Unique ID of the graph within the GraphML document. 

845 """ 

846 super().__init__(identifier) 

847 self._document = document 

848 self._ids = {} 

849 

850 def GetByID(self, identifier: str) -> Union[Node, Edge, Subgraph]: 

851 """ 

852 Return the element with the given ID, whichever kind it is. 

853 

854 :param identifier: ID of the node, edge or subgraph. 

855 :returns: The element registered under that ID. 

856 :raises KeyError: If no element has that ID. 

857 """ 

858 return self._ids[identifier] 

859 

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

861 """ 

862 Add a subgraph to the root graph and register its ID. 

863 

864 :param subgraph: The subgraph to add. 

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

866 """ 

867 result = super().AddSubgraph(subgraph) 

868 self._ids[subgraph._subgraphID] = subgraph 

869 subgraph._root = self 

870 return result 

871 

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

873 """ 

874 Add a node to the root graph and register its ID. 

875 

876 :param node: The node to add. 

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

878 """ 

879 result = super().AddNode(node) 

880 self._ids[node._id] = node 

881 return result 

882 

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

884 """ 

885 Add an edge to the root graph and register its ID, if it has one. 

886 

887 :param edge: The edge to add. 

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

889 """ 

890 result = super().AddEdge(edge) 

891 if edge._id is not None: 

892 self._ids[edge._id] = edge 

893 

894 return result 

895 

896 

897@export 

898class Subgraph(Node, BaseGraph): 

899 """ 

900 A nested graph, which is a node of its parent graph and a graph of its own. 

901 

902 It therefore carries two identifiers: the node's ID it is referenced by, and :attr:`_subgraphID` for the graph it 

903 contains. 

904 """ 

905 _subgraphID: str #: ID of the subgraph, which is distinct from the node's own ID. 

906 _root: Nullable[Graph] #: The graph this subgraph is nested in. 

907 

908 def __init__(self, nodeIdentifier: str, graphIdentifier: str) -> None: 

909 """ 

910 Initialize a subgraph, which is a node in its parent graph and a graph of its own. 

911 

912 :param nodeIdentifier: Unique ID of the node representing the subgraph. 

913 :param graphIdentifier: Unique ID of the graph contained in that node. 

914 """ 

915 super().__init__(nodeIdentifier) 

916 BaseGraph.__init__(self, nodeIdentifier) 

917 

918 self._subgraphID = graphIdentifier 

919 self._root = None 

920 

921 @readonly 

922 def RootGraph(self) -> Graph: 

923 """ 

924 Read-only property to access the graph this subgraph is embedded in (:attr:`_root`). 

925 

926 :returns: The root graph. 

927 """ 

928 return self._root 

929 

930 @readonly 

931 def SubgraphID(self) -> str: 

932 """ 

933 Read-only property to access the subgraph's ID (:attr:`_subgraphID`). 

934 

935 :returns: ID of the subgraph. 

936 """ 

937 return self._subgraphID 

938 

939 @readonly 

940 def HasClosingTag(self) -> bool: 

941 """ 

942 Check if this XML element is written with a separate closing tag. 

943 

944 :returns: ``True``, because a subgraph always has a closing tag. 

945 """ 

946 return True 

947 

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

949 """ 

950 Add a node to this subgraph and register its ID at the root graph. 

951 

952 :param node: The node to add. 

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

954 """ 

955 result = super().AddNode(node) 

956 self._root._ids[node._id] = node 

957 return result 

958 

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

960 """ 

961 Add an edge to this subgraph and register its ID at the root graph, if it has one. 

962 

963 :param edge: The edge to add. 

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

965 """ 

966 result = super().AddEdge(edge) 

967 if edge._id is not None: 

968 self._root._ids[edge._id] = edge 

969 

970 return result 

971 

972 @notimplemented("A subgraph is always written with an opening and a closing tag.") 

973 def Tag(self, indent: int = 2) -> str: 

974 """ 

975 A subgraph always contains a graph, so it is never written as a self-closing tag. 

976 

977 :param indent: Optional, indentation level of the XML element. 

978 """ 

979 

980 def OpeningTag(self, indent: int = 1) -> str: 

981 """ 

982 Return the opening XML tag of this subgraph. 

983 

984 :param indent: Optional, indentation level of the XML element. 

985 :returns: The opening XML tag, indented and terminated by a newline. 

986 """ 

987 return f"""\ 

988{' ' * indent}<graph id="{_escapeAttribute(self._subgraphID)}" 

989{' ' * indent} edgedefault="{self._edgeDefault!s}" 

990{' ' * indent} parse.nodes="{len(self._nodes)}" 

991{' ' * indent} parse.edges="{len(self._edgesWithoutID) + len(self._edges)}" 

992{' ' * indent} parse.order="{self._parseOrder!s}" 

993{' ' * indent} parse.nodeids="{self._nodeIDStyle!s}" 

994{' ' * indent} parse.edgeids="{self._edgeIDStyle!s}"> 

995""" 

996 

997 def ClosingTag(self, indent: int = 2) -> str: 

998 """ 

999 Return the closing XML tag of this subgraph. 

1000 

1001 :param indent: Optional, indentation level of the XML element. 

1002 :returns: The closing XML tag, indented and terminated by a newline. 

1003 """ 

1004 return BaseGraph.ClosingTag(self, indent) 

1005 

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

1007 """ 

1008 Render this subgraph as a list of XML lines. 

1009 

1010 :param indent: Optional, indentation level of the XML element. 

1011 :returns: List of XML lines describing this subgraph and everything it contains. 

1012 """ 

1013 lines = [super().OpeningTag(indent)] 

1014 for data in self._data: 1014 ↛ 1015line 1014 didn't jump to line 1015 because the loop on line 1014 never started

1015 lines.extend(data.ToStringLines(indent + 1)) 

1016 # lines.extend(Graph.ToStringLines(self, indent + 1)) 

1017 lines.append(self.OpeningTag(indent + 1)) 

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

1019 lines.extend(node.ToStringLines(indent + 2)) 

1020 

1021 for edge in (*self._edgesWithoutID, *self._edges.values()): 

1022 lines.extend(edge.ToStringLines(indent + 2)) 

1023 # for data in self._data: 

1024 # lines.extend(data.ToStringLines(indent + 1)) 

1025 lines.append(self.ClosingTag(indent + 1)) 

1026 lines.append(super().ClosingTag(indent)) 

1027 

1028 return lines 

1029 

1030 

1031@export 

1032class GraphMLDocument(Base): 

1033 """ 

1034 A GraphML document: the root graph, the keys it declares, and the XML boilerplate to write it out. 

1035 """ 

1036 

1037 _XML_NS: ClassVar[dict[Nullable[str], str]] = { 

1038 None: "http://graphml.graphdrawing.org/xmlns", 

1039 "xsi": "http://www.w3.org/2001/XMLSchema-instance" 

1040 } #: XML namespaces of a GraphML document. 

1041 _XSI: ClassVar[dict[str, str]] = { 

1042 "schemaLocation": "http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd" 

1043 } #: XML schema instance attributes of a GraphML document. 

1044 

1045 _graph: Graph #: The document's root graph. 

1046 _keys: dict[str, Key] #: Keys declared by this document, by ID. 

1047 

1048 def __init__(self, identifier: str = "G") -> None: 

1049 """ 

1050 Initialize a GraphML document with an empty root graph. 

1051 

1052 :param identifier: Optional, unique ID of the root graph. 

1053 """ 

1054 super().__init__() 

1055 

1056 self._graph = Graph(self, identifier) 

1057 self._keys = {} 

1058 

1059 @readonly 

1060 def Graph(self) -> BaseGraph: 

1061 """ 

1062 Read-only property to access the document's graph (:attr:`_graph`). 

1063 

1064 :returns: The graph described by this document. 

1065 """ 

1066 return self._graph 

1067 

1068 @readonly 

1069 def Keys(self) -> dict[str, Key]: 

1070 """ 

1071 Read-only property to access the attribute keys declared in this document (:attr:`_keys`). 

1072 

1073 :returns: Dictionary of key IDs and keys. 

1074 """ 

1075 return self._keys 

1076 

1077 def AddKey(self, key: Key) -> Key: 

1078 """ 

1079 Declare an attribute, so data items can refer to it. 

1080 

1081 :param key: The key to declare. 

1082 :returns: The declared key, so it can be used in the calling expression. 

1083 """ 

1084 self._keys[key._id] = key 

1085 return key 

1086 

1087 def GetKey(self, keyName: str) -> Key: 

1088 """ 

1089 Return the declared key with the given ID. 

1090 

1091 :param keyName: ID of the key. 

1092 :returns: The key with that ID. 

1093 :raises KeyError: If no key has that ID. 

1094 """ 

1095 return self._keys[keyName] 

1096 

1097 def ContainsKey(self, keyName: str) -> bool: 

1098 """ 

1099 Check if a key with the given ID was declared. 

1100 

1101 :param keyName: ID of the key. 

1102 :returns: ``True``, if such a key exists. 

1103 """ 

1104 return keyName in self._keys 

1105 

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

1107 """ 

1108 Fill this document from a :class:`pyTooling.Graph.Graph`. 

1109 

1110 Vertices become nodes, edges become edges, and the vertex and edge values are attached as data items, 

1111 declared by two keys this method adds. A subgraph becomes a GraphML subgraph. A link connects vertices of two 

1112 graphs; it becomes an edge of the root graph. 

1113 

1114 A node's ID is its vertex' ID. A vertex without an ID gets a generated ID ``vertex<number>``, which no vertex' ID 

1115 is, because GraphML requires one. Subgraphs are translated ordered by name, so the generated IDs don't change from 

1116 one run to the next. A graph without a name keeps the document's graph ID. 

1117 

1118 :param graph: The graph to translate into this document. 

1119 """ 

1120 document = self 

1121 if graph._name is not None: 

1122 self._graph._id = graph._name 

1123 

1124 nodeValue = self.AddKey(Key("nodeValue", AttributeContext.Node, "value", AttributeTypes.String)) 

1125 edgeValue = self.AddKey(Key("edgeValue", AttributeContext.Edge, "value", AttributeTypes.String)) 

1126 

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

1128 vertices = [vertex for subgraph in subgraphs for vertex in subgraph.IterateVertices()] 

1129 vertices += graph.IterateVertices() 

1130 identifiers = GraphViz.Graph._Identifiers(vertices) 

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

1132 

1133 def getKey(keyID: str, context: AttributeContext, name: str) -> Key: 

1134 """ 

1135 Nested function returning the key with the given ID, which is declared on first use. 

1136 

1137 :param keyID: ID of the key. 

1138 :param context: Kind of element the key's data items belong to. 

1139 :param name: Attribute name of the key. 

1140 :returns: The key. 

1141 """ 

1142 if document.ContainsKey(keyID): 

1143 return document.GetKey(keyID) 

1144 

1145 return document.AddKey(Key(keyID, context, name, AttributeTypes.String)) 

1146 

1147 def translateEdge(edge: Union[pyToolingEdge, pyToolingLink], prefix: str) -> Edge: 

1148 """ 

1149 Nested function translating an edge or a link, with its value and key-value pairs, into a GraphML edge. 

1150 

1151 :param edge: The edge or link to translate. 

1152 :param prefix: Prefix of the key IDs for its key-value pairs: ``edge`` or ``link``. 

1153 :returns: The GraphML edge between the nodes of its source and destination vertex. 

1154 """ 

1155 newEdge = Edge(edge._id, nodes[id(edge._source)], nodes[id(edge._destination)]) 

1156 if edge._value is not None: 

1157 newEdge.AddData(Data(edgeValue, edge._value)) 

1158 

1159 for key, value in edge._dict.items(): 

1160 newEdge.AddData(Data(getKey(f"{prefix}{key!s}", AttributeContext.Edge, str(key)), value)) 

1161 

1162 return newEdge 

1163 

1164 def translateGraph(graphMLGraph: BaseGraph, pyTGraph: Union[pyToolingGraph, pyToolingSubgraph]) -> None: 

1165 """ 

1166 Nested function translating the vertices and edges of a graph or subgraph into GraphML nodes and edges. 

1167 

1168 :param graphMLGraph: The GraphML graph or subgraph the elements are added to. 

1169 :param pyTGraph: The pyTooling graph or subgraph to translate. 

1170 """ 

1171 for vertex in pyTGraph.IterateVertices(): 

1172 newNode = graphMLGraph.AddNode(Node(identifiers[id(vertex)])) 

1173 nodes[id(vertex)] = newNode 

1174 if vertex._value is not None: 

1175 newNode.AddData(Data(nodeValue, vertex._value)) 

1176 

1177 for key, value in vertex._dict.items(): 

1178 newNode.AddData(Data(getKey(f"node{key!s}", AttributeContext.Node, str(key)), value)) 

1179 

1180 for edge in pyTGraph.IterateEdges(): 

1181 graphMLGraph.AddEdge(translateEdge(edge, "edge")) 

1182 

1183 for subgraph in subgraphs: 

1184 nodeGraph = Subgraph(subgraph.Name, "sg" + subgraph.Name) 

1185 self._graph.AddSubgraph(nodeGraph) 

1186 translateGraph(nodeGraph, subgraph) 

1187 

1188 translateGraph(self._graph, graph) 

1189 

1190 # A link is known to both graphs it connects: collect it from all graphs, and write it once into the root graph. 

1191 translatedLinks: set[int] = set() 

1192 for pyTGraph in (*subgraphs, graph): 

1193 for link in pyTGraph.IterateLinks(): 

1194 if id(link) not in translatedLinks: 

1195 translatedLinks.add(id(link)) 

1196 self._graph.AddEdge(translateEdge(link, "link")) 

1197 

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

1199 """ 

1200 Fill this document from a :class:`pyTooling.Tree.Node`. 

1201 

1202 Every node of the tree becomes a GraphML node, and every parent-child relation becomes an edge. 

1203 

1204 A GraphML node's ID is the tree node's ID. A tree node without an ID gets a generated ID ``vertex<number>``, which 

1205 no tree node's ID is, because GraphML requires one. A root without an ID keeps the document's graph ID. 

1206 

1207 :param tree: The root node of the tree to translate into this document. 

1208 """ 

1209 if tree._id is not None: 

1210 self._graph._id = tree._id 

1211 

1212 nodeValue = self.AddKey(Key("nodeValue", AttributeContext.Node, "value", AttributeTypes.String)) 

1213 

1214 treeNodes = [tree] 

1215 treeNodes += tree.GetDescendants() 

1216 identifiers = GraphViz.Graph._Identifiers(treeNodes) 

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

1218 

1219 for i, treeNode in enumerate(treeNodes): 

1220 newNode = self._graph.AddNode(Node(identifiers[id(treeNode)])) 

1221 nodes[id(treeNode)] = newNode 

1222 if treeNode._value is not None: 

1223 newNode.AddData(Data(nodeValue, treeNode._value)) 

1224 

1225 if treeNode is not tree: 

1226 self._graph.AddEdge(Edge(f"e{i - 1}", newNode, nodes[id(treeNode._parent)])) 

1227 

1228 @notimplemented("A GraphML document is always written with an opening and a closing tag.") 

1229 def Tag(self, indent: int = 0) -> str: 

1230 """ 

1231 A GraphML document always contains keys and a graph, so it is never written as a self-closing tag. 

1232 

1233 :param indent: Optional, indentation level of the XML element. 

1234 """ 

1235 

1236 def OpeningTag(self, indent: int = 0) -> str: 

1237 """ 

1238 Return the opening XML tag of this document. 

1239 

1240 :param indent: Optional, indentation level of the XML element. 

1241 :returns: The opening XML tag, indented and terminated by a newline. 

1242 """ 

1243 return f"""\ 

1244{' '*indent}<graphml xmlns="{self._XML_NS[None]}" 

1245{' '*indent} xmlns:xsi="{self._XML_NS["xsi"]}" 

1246{' '*indent} xsi:schemaLocation="{self._XSI["schemaLocation"]}"> 

1247""" 

1248 

1249 def ClosingTag(self, indent: int = 0) -> str: 

1250 """ 

1251 Return the closing XML tag of this document. 

1252 

1253 :param indent: Optional, indentation level of the XML element. 

1254 :returns: The closing XML tag, indented and terminated by a newline. 

1255 """ 

1256 return f"{' '*indent}</graphml>\n" 

1257 

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

1259 """ 

1260 Render this document as a list of XML lines. 

1261 

1262 :param indent: Optional, indentation level of the XML element. 

1263 :returns: List of XML lines describing this document and everything it contains. 

1264 """ 

1265 lines = [self.OpeningTag(indent)] 

1266 for key in self._keys.values(): 

1267 lines.extend(key.ToStringLines(indent + 1)) 

1268 lines.extend(self._graph.ToStringLines(indent + 1)) 

1269 lines.append(self.ClosingTag(indent)) 

1270 

1271 return lines 

1272 

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

1274 """ 

1275 Write this document as a GraphML file. 

1276 

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

1278 """ 

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

1280 f.write("""<?xml version="1.0" encoding="utf-8"?>""") 

1281 f.writelines(self.ToStringLines())