GraphML.py
Summary
- Lines:
430 of 443 covered (97.1%)
- Branches:
59 of 60 taken (98.3%), 1 lines partially
- Coverage:
97.2%
- Directory:
- Missed lines:
307, 692, 735, 779, 867, 941, 950, 959, 1028, 1088, 1292-1294
Source
pyTooling/Graph/GraphML.pycoveredpartialuncoveredexcludednot executable
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({"&": "&", "<": "<", ">": ">", '"': """})
53"""Translation table escaping an XML attribute value."""
54
55
56def _escapeAttribute(value: Any) -> str:
57 """
58 Escape a value for an XML attribute in double quotes: ``&``, ``<``, ``>`` and ``"``.
59
60 :param value: The value, converted by :func:`str`.
61 :returns: The escaped text.
62 """
63 return str(value).translate(_ATTRIBUTE_ESCAPES)
64
65@export
66class AttributeContext(Enum):
67 """
68 Enumeration of all attribute contexts.
69
70 An attribute context describes to what kind of GraphML node an attribute can be applied.
71 """
72 GraphML = auto()
73 Graph = auto()
74 Node = auto()
75 Edge = auto()
76 Port = auto()
77
78 def __str__(self) -> str:
79 """
80 Return the enumeration value's name as it is written in a GraphML document.
81
82 :returns: Name of the enumeration value in lower case.
83 """
84 return f"{self.name.lower()}"
85
86
87@export
88class AttributeTypes(Enum):
89 """
90 Enumeration of all attribute types.
91
92 An attribute type describes what datatype can be applied to an attribute.
93 """
94 Boolean = auto()
95 Int = auto()
96 Long = auto()
97 Float = auto()
98 Double = auto()
99 String = auto()
100
101 def __str__(self) -> str:
102 """
103 Return the enumeration value's name as it is written in a GraphML document.
104
105 :returns: Name of the enumeration value in lower case.
106 """
107 return f"{self.name.lower()}"
108
109
110@export
111class EdgeDefault(Enum):
112 """An enumeration describing the default edge direction."""
113 Undirected = auto()
114 Directed = auto()
115
116 def __str__(self) -> str:
117 """
118 Return the enumeration value's name as it is written in a GraphML document.
119
120 :returns: Name of the enumeration value in lower case.
121 """
122 return f"{self.name.lower()}"
123
124
125@export
126class ParsingOrder(Enum):
127 """An enumeration describing the parsing order of the graph's representation."""
128 NodesFirst = auto() #: First, all nodes are given, then followed by all edges.
129 AdjacencyList = auto()
130 Free = auto()
131
132 def __str__(self) -> str:
133 """
134 Return the enumeration value's name as it is written in a GraphML document.
135
136 :returns: Name of the enumeration value in lower case.
137 """
138 return f"{self.name.lower()}"
139
140
141@export
142class IDStyle(Enum):
143 """An enumeration describing the style of identifiers (IDs)."""
144 Canonical = auto()
145 Free = auto()
146
147 def __str__(self) -> str:
148 """
149 Return the enumeration value's name as it is written in a GraphML document.
150
151 :returns: Name of the enumeration value in lower case.
152 """
153 return f"{self.name.lower()}"
154
155
156@export
157class Base(metaclass=ExtendedType, slots=True):
158 """
159 Base-class for all GraphML data model classes.
160 """
161 @readonly
162 def HasClosingTag(self) -> bool:
163 """
164 Check if this XML element is written with a separate closing tag.
165
166 :returns: ``True``, if the element needs a closing tag.
167 """
168 return True
169
170 @abstractmethod
171 def Tag(self, indent: int = 0) -> str:
172 """
173 Return this element as a self-closing XML tag.
174
175 An element always written with an opening and a closing tag overrides it with
176 :deco:`~pyTooling.Decorators.notimplemented`.
177
178 :param indent: Optional, indentation level of the XML element.
179 :returns: The XML tag, indented and terminated by a newline.
180 """
181
182 @abstractmethod
183 def OpeningTag(self, indent: int = 0) -> str:
184 """
185 Return the opening XML tag of this element.
186
187 An element always written as a single tag overrides it with :deco:`~pyTooling.Decorators.notimplemented`.
188
189 :param indent: Optional, indentation level of the XML element.
190 :returns: The opening XML tag, indented and terminated by a newline.
191 """
192
193 @abstractmethod
194 def ClosingTag(self, indent: int = 0) -> str:
195 """
196 Return the closing XML tag of this element.
197
198 An element always written as a single tag overrides it with :deco:`~pyTooling.Decorators.notimplemented`.
199
200 :param indent: Optional, indentation level of the XML element.
201 :returns: The closing XML tag, indented and terminated by a newline.
202 """
203
204 @abstractmethod
205 def ToStringLines(self, indent: int = 0) -> list[str]:
206 """
207 Render this element as a list of XML lines.
208
209 :param indent: Optional, indentation level of the XML element.
210 :returns: List of XML lines describing this element.
211 """
212
213
214@export
215class BaseWithID(Base):
216 """Base-class for all GraphML elements carrying a document-wide unique ID."""
217 _id: Nullable[str] #: Unique identifier of this GraphML element. ``None`` if it has none, e.g. an edge.
218
219 def __init__(self, identifier: Nullable[str]) -> None:
220 """
221 Initialize a GraphML element with its unique ID.
222
223 :param identifier: Unique ID of the element within the GraphML document, or ``None``.
224 """
225 super().__init__()
226 self._id = identifier
227
228 @readonly
229 def ID(self) -> Nullable[str]:
230 """
231 Read-only property to access the element's unique ID (:attr:`_id`).
232
233 :returns: Unique ID of the element, or ``None`` if it has none.
234 """
235 return self._id
236
237
238@export
239class BaseWithData(BaseWithID):
240 """Base-class for all GraphML elements that can carry attached data items (key-value-pairs)."""
241 _data: list[_Data] #: Data items (key-value-pairs) attached to this GraphML element.
242
243 def __init__(self, identifier: Nullable[str]) -> None:
244 """
245 Initialize a GraphML element with its unique ID and an empty list of data items.
246
247 :param identifier: Unique ID of the element within the GraphML document, or ``None``.
248 """
249 super().__init__(identifier)
250
251 self._data = []
252
253 @readonly
254 def Data(self) -> list[Data]:
255 """
256 Read-only property to access the data elements attached to this element (:attr:`_data`).
257
258 :returns: List of data elements.
259 """
260 return self._data
261
262 def AddData(self, data: Data) -> Data:
263 """
264 Attach a data item (key-value-pair) to this element.
265
266 :param data: The data item to attach.
267 :returns: The attached data item, so it can be used in the calling expression.
268 """
269 self._data.append(data)
270 return data
271
272
273@export
274class Key(BaseWithID):
275 """
276 Declares an attribute that data items can refer to.
277
278 A GraphML document declares its attributes once - name, data type, and the element kind they apply to - and every
279 :class:`Data` item then references such a key by ID.
280 """
281 _context: AttributeContext #: GraphML element kind this key can be used on.
282 _attributeName: str #: Name of the attribute described by this key.
283 _attributeType: AttributeTypes #: Data type of the attribute described by this key.
284
285 def __init__(self, identifier: str, context: AttributeContext, name: str, type: AttributeTypes) -> None:
286 """
287 Initialize a key declaring an attribute.
288
289 :param identifier: Unique ID of the key within the GraphML document.
290 :param context: GraphML element kind this key can be used on.
291 :param name: Name of the declared attribute.
292 :param type: Data type of the declared attribute.
293 """
294 super().__init__(identifier)
295
296 self._context = context
297 self._attributeName = name
298 self._attributeType = type
299
300 @readonly
301 def Context(self) -> AttributeContext:
302 """
303 Read-only property to access the context this key applies to (:attr:`_context`).
304
305 :returns: The attribute's context (graph, node, edge, ...).
306 """
307 return self._context
308
309 @readonly
310 def AttributeName(self) -> str:
311 """
312 Read-only property to access the name of the described attribute (:attr:`_attributeName`).
313
314 :returns: Name of the attribute.
315 """
316 return self._attributeName
317
318 @readonly
319 def AttributeType(self) -> AttributeTypes:
320 """
321 Read-only property to access the type of the described attribute (:attr:`_attributeType`).
322
323 :returns: Type of the attribute.
324 """
325 return self._attributeType
326
327 @readonly
328 def HasClosingTag(self) -> bool:
329 """
330 Check if this XML element is written with a separate closing tag.
331
332 A key is always written as a self-closing tag.
333
334 :returns: ``False``, because a key never has a closing tag.
335 """
336 return False
337
338 def Tag(self, indent: int = 2) -> str:
339 """
340 Return this key as a self-closing XML tag.
341
342 :param indent: Optional, indentation level of the XML element.
343 :returns: The XML tag, indented and terminated by a newline.
344 """
345 identifier = _escapeAttribute(self._id)
346 attributeName = _escapeAttribute(self._attributeName)
347 return f"""{' '*indent}<key id="{identifier}" for="{self._context}" attr.name="{attributeName}" attr.type="{self._attributeType}" />\n"""
348
349 @notimplemented("A key is always written as a self-closing tag.")
350 def OpeningTag(self, indent: int = 2) -> str:
351 """
352 A key has no content, so it is never written with an opening and a closing tag.
353
354 :param indent: Optional, indentation level of the XML element.
355 """
356
357 @notimplemented("A key is always written as a self-closing tag.")
358 def ClosingTag(self, indent: int = 2) -> str:
359 """
360 A key has no content, so it is never written with an opening and a closing tag.
361
362 :param indent: Optional, indentation level of the XML element.
363 """
364
365 def ToStringLines(self, indent: int = 2) -> list[str]:
366 """
367 Render this key as a list of XML lines.
368
369 :param indent: Optional, indentation level of the XML element.
370 :returns: List of XML lines describing this key and everything attached to it.
371 """
372 return [self.Tag(indent)]
373
374
375_Key = Key
376"""Alias of :class:`Key` for annotations in a class with a property named ``Key``, which the class body binds."""
377
378
379@export
380class Data(Base):
381 """A single attached attribute: a value and the :class:`Key` describing it."""
382 _key: _Key #: Key describing name and type of this data item.
383 _data: Any #: Value of this data item.
384
385 def __init__(self, key: Key, data: Any) -> None:
386 """
387 Initialize a data item with the key describing it and its value.
388
389 :param key: Key declaring name and type of this attribute.
390 :param data: Value of this attribute.
391 """
392 super().__init__()
393
394 self._key = key
395 self._data = data
396
397 @readonly
398 def Key(self) -> Key:
399 """
400 Read-only property to access the key describing this data element (:attr:`_key`).
401
402 :returns: The key this data element refers to.
403 """
404 return self._key
405
406 @readonly
407 def Data(self) -> Any:
408 """
409 Read-only property to access the data element's value (:attr:`_data`).
410
411 :returns: Value of the data element.
412 """
413 return self._data
414
415 @readonly
416 def HasClosingTag(self) -> bool:
417 """
418 Check if this XML element is written with a separate closing tag.
419
420 :returns: ``False``, because a data element is written inline.
421 """
422 return False
423
424 def Tag(self, indent: int = 2) -> str:
425 """
426 Return this data item as a self-closing XML tag.
427
428 :param indent: Optional, indentation level of the XML element.
429 :returns: The XML tag, indented and terminated by a newline.
430 """
431 data = str(self._data)
432 data = data.replace("&", "&")
433 data = data.replace("<", "<")
434 data = data.replace(">", ">")
435 data = data.replace("\n", "\\n")
436 return f"""{' '*indent}<data key="{_escapeAttribute(self._key._id)}">{data}</data>\n"""
437
438 @notimplemented("A data item is always written in one line by Tag().")
439 def OpeningTag(self, indent: int = 2) -> str:
440 """
441 A data item holds a value only, so :meth:`Tag` writes it in one line.
442
443 :param indent: Optional, indentation level of the XML element.
444 """
445
446 @notimplemented("A data item is always written in one line by Tag().")
447 def ClosingTag(self, indent: int = 2) -> str:
448 """
449 A data item holds a value only, so :meth:`Tag` writes it in one line.
450
451 :param indent: Optional, indentation level of the XML element.
452 """
453
454 def ToStringLines(self, indent: int = 2) -> list[str]:
455 """
456 Render this data item as a list of XML lines.
457
458 :param indent: Optional, indentation level of the XML element.
459 :returns: List of XML lines describing this data item and everything attached to it.
460 """
461 return [self.Tag(indent)]
462
463
464_Data = Data
465"""Alias of :class:`Data` for annotations in a class with a property named ``Data``, which the class body binds."""
466
467
468@export
469class Node(BaseWithData):
470 """A node (vertex) of a GraphML graph."""
471
472 def __init__(self, identifier: str) -> None:
473 """
474 Initialize a node.
475
476 :param identifier: Unique ID of the node within the GraphML document.
477 """
478 super().__init__(identifier)
479
480 @readonly
481 def HasClosingTag(self) -> bool:
482 """
483 Check if this XML element is written with a separate closing tag.
484
485 :returns: ``True``, if the node carries data elements, otherwise ``False``.
486 """
487 return len(self._data) > 0
488
489 def Tag(self, indent: int = 2) -> str:
490 """
491 Return this node as a self-closing XML tag.
492
493 :param indent: Optional, indentation level of the XML element.
494 :returns: The XML tag, indented and terminated by a newline.
495 """
496 return f"""{' '*indent}<node id="{_escapeAttribute(self._id)}" />\n"""
497
498 def OpeningTag(self, indent: int = 2) -> str:
499 """
500 Return the opening XML tag of this node.
501
502 :param indent: Optional, indentation level of the XML element.
503 :returns: The opening XML tag, indented and terminated by a newline.
504 """
505 return f"""{' '*indent}<node id="{_escapeAttribute(self._id)}">\n"""
506
507 def ClosingTag(self, indent: int = 2) -> str:
508 """
509 Return the closing XML tag of this node.
510
511 :param indent: Optional, indentation level of the XML element.
512 :returns: The closing XML tag, indented and terminated by a newline.
513 """
514 return f"""{' ' * indent}</node>\n"""
515
516 def ToStringLines(self, indent: int = 2) -> list[str]:
517 """
518 Render this node as a list of XML lines.
519
520 :param indent: Optional, indentation level of the XML element.
521 :returns: List of XML lines describing this node and everything attached to it.
522 """
523 if not self.HasClosingTag:
524 return [self.Tag(indent)]
525
526 lines = [self.OpeningTag(indent)]
527 for data in self._data:
528 lines.extend(data.ToStringLines(indent + 1))
529 lines.append(self.ClosingTag(indent))
530
531 return lines
532
533
534@export
535class Edge(BaseWithData):
536 """An edge of a GraphML graph, connecting a source node to a target node."""
537 _source: Node #: Node the edge starts at.
538 _target: Node #: Node the edge ends at.
539
540 def __init__(self, identifier: Nullable[str], source: Node, target: Node) -> None:
541 """
542 Initialize an edge between two nodes.
543
544 :param identifier: Unique ID of the edge within the GraphML document.
545 :param source: Node the edge starts at.
546 :param target: Node the edge ends at.
547 """
548 super().__init__(identifier)
549
550 self._source = source
551 self._target = target
552
553 @readonly
554 def Source(self) -> Node:
555 """
556 Read-only property to access the edge's source node (:attr:`_source`).
557
558 :returns: Source node of the edge.
559 """
560 return self._source
561
562 @readonly
563 def Target(self) -> Node:
564 """
565 Read-only property to access the edge's target node (:attr:`_target`).
566
567 :returns: Target node of the edge.
568 """
569 return self._target
570
571 def _IDAttribute(self) -> str:
572 """
573 Return the ``id`` attribute of this edge's tag, or nothing for an edge without an ID - it is optional in GraphML.
574
575 :returns: The attribute with a leading space, or an empty string.
576 """
577 if self._id is None:
578 return ""
579 else:
580 return f' id="{_escapeAttribute(self._id)}"'
581
582 @readonly
583 def HasClosingTag(self) -> bool:
584 """
585 Check if this XML element is written with a separate closing tag.
586
587 :returns: ``True``, if the edge carries data elements, otherwise ``False``.
588 """
589 return len(self._data) > 0
590
591 def Tag(self, indent: int = 2) -> str:
592 """
593 Return this edge as a self-closing XML tag.
594
595 :param indent: Optional, indentation level of the XML element.
596 :returns: The XML tag, indented and terminated by a newline.
597 """
598 source = _escapeAttribute(self._source._id)
599 target = _escapeAttribute(self._target._id)
600 return f"""{' ' * indent}<edge{self._IDAttribute()} source="{source}" target="{target}" />\n"""
601
602 def OpeningTag(self, indent: int = 2) -> str:
603 """
604 Return the opening XML tag of this edge.
605
606 :param indent: Optional, indentation level of the XML element.
607 :returns: The opening XML tag, indented and terminated by a newline.
608 """
609 source = _escapeAttribute(self._source._id)
610 target = _escapeAttribute(self._target._id)
611 return f"""{' '*indent}<edge{self._IDAttribute()} source="{source}" target="{target}">\n"""
612
613 def ClosingTag(self, indent: int = 2) -> str:
614 """
615 Return the closing XML tag of this edge.
616
617 :param indent: Optional, indentation level of the XML element.
618 :returns: The closing XML tag, indented and terminated by a newline.
619 """
620 return f"""{' ' * indent}</edge>\n"""
621
622 def ToStringLines(self, indent: int = 2) -> list[str]:
623 """
624 Render this edge as a list of XML lines.
625
626 :param indent: Optional, indentation level of the XML element.
627 :returns: List of XML lines describing this edge and everything attached to it.
628 """
629 if not self.HasClosingTag:
630 return [self.Tag(indent)]
631
632 lines = [self.OpeningTag(indent)]
633 for data in self._data:
634 lines.extend(data.ToStringLines(indent + 1))
635 lines.append(self.ClosingTag(indent))
636
637 return lines
638
639
640@export
641class BaseGraph(BaseWithData, mixin=True):
642 """
643 Mixin-class for everything that contains nodes, edges and subgraphs - a graph as well as a subgraph.
644
645 Beside the elements themselves, it carries the document-level settings applied while writing them: the default edge
646 direction, the parsing order, and the ID styles for nodes and edges.
647 """
648 _subgraphs: dict[str, Subgraph] #: Subgraphs of this graph, by ID.
649 _nodes: dict[str, Node] #: Nodes of this graph, by ID.
650 _edges: dict[str, Edge] #: Edges of this graph with an ID, by ID.
651 _edgesWithoutID: list[Edge] #: Edges of this graph without an ID.
652 _edgeDefault: EdgeDefault #: Direction applied to edges that don't specify one.
653 _parseOrder: ParsingOrder #: Order in which nodes and edges may appear in the XML document.
654 _nodeIDStyle: IDStyle #: Whether node IDs are free-form or canonical.
655 _edgeIDStyle: IDStyle #: Whether edge IDs are free-form or canonical.
656
657 def __init__(self, identifier: Nullable[str] = None) -> None:
658 """
659 Initialize an empty graph with the default document settings.
660
661 Edges are directed, nodes are written before edges, and both ID styles are free-form until they are changed.
662
663 :param identifier: Optional, unique ID of the graph within the GraphML document.
664 """
665 super().__init__(identifier)
666
667 self._subgraphs = {}
668 self._nodes = {}
669 self._edges = {}
670 self._edgesWithoutID = []
671 self._edgeDefault = EdgeDefault.Directed
672 self._parseOrder = ParsingOrder.NodesFirst
673 self._nodeIDStyle = IDStyle.Free
674 self._edgeIDStyle = IDStyle.Free
675
676 @readonly
677 def Subgraphs(self) -> dict[str, Subgraph]:
678 """
679 Read-only property to access the graph's subgraphs (:attr:`_subgraphs`).
680
681 :returns: Dictionary of subgraph IDs and subgraphs.
682 """
683 return self._subgraphs
684
685 @readonly
686 def Nodes(self) -> dict[str, Node]:
687 """
688 Read-only property to access the graph's nodes (:attr:`_nodes`).
689
690 :returns: Dictionary of node IDs and nodes.
691 """
692 return self._nodes
693
694 @readonly
695 def Edges(self) -> dict[str, Edge]:
696 """
697 Read-only property to access the graph's edges with an ID (:attr:`_edges`).
698
699 An edge without an ID is in :attr:`EdgesWithoutID`.
700
701 :returns: Dictionary of edge IDs and edges.
702 """
703 return self._edges
704
705 @readonly
706 def EdgesWithoutID(self) -> list[Edge]:
707 """
708 Read-only property to access the graph's edges without an ID (:attr:`_edgesWithoutID`).
709
710 An edge with an ID is in :attr:`Edges`.
711
712 :returns: List of edges without an ID, in the order they were added.
713 """
714 return self._edgesWithoutID
715
716 def AddSubgraph(self, subgraph: Subgraph) -> Subgraph:
717 """
718 Add a subgraph to this graph, which is a node of this graph as well.
719
720 :param subgraph: The subgraph to add.
721 :returns: The added subgraph, so it can be used in the calling expression.
722 """
723 self._subgraphs[subgraph._subgraphID] = subgraph
724 self._nodes[subgraph._id] = subgraph
725 return subgraph
726
727 def GetSubgraph(self, subgraphName: str) -> Subgraph:
728 """
729 Return the subgraph with the given ID.
730
731 :param subgraphName: ID of the subgraph.
732 :returns: The subgraph with that ID.
733 :raises KeyError: If no subgraph has that ID.
734 """
735 return self._subgraphs[subgraphName]
736
737 def AddNode(self, node: Node) -> Node:
738 """
739 Add a node to this graph.
740
741 :param node: The node to add.
742 :returns: The added node, so it can be used in the calling expression.
743 """
744 self._nodes[node._id] = node
745 return node
746
747 def GetNode(self, nodeName: str) -> Node:
748 """
749 Return the node with the given ID.
750
751 :param nodeName: ID of the node.
752 :returns: The node with that ID.
753 :raises KeyError: If no node has that ID.
754 """
755 return self._nodes[nodeName]
756
757 def AddEdge(self, edge: Edge) -> Edge:
758 """
759 Add an edge to this graph.
760
761 :param edge: The edge to add.
762 :returns: The added edge, so it can be used in the calling expression.
763 """
764 if edge._id is None:
765 self._edgesWithoutID.append(edge)
766 else:
767 self._edges[edge._id] = edge
768
769 return edge
770
771 def GetEdge(self, edgeName: str) -> Edge:
772 """
773 Return the edge with the given ID.
774
775 :param edgeName: ID of the edge.
776 :returns: The edge with that ID.
777 :raises KeyError: If no edge has that ID.
778 """
779 return self._edges[edgeName]
780
781 @notimplemented("A graph is always written with an opening and a closing tag.")
782 def Tag(self, indent: int = 1) -> str:
783 """
784 A graph is written with its nodes and edges inside, so it is never written as a self-closing tag.
785
786 :param indent: Optional, indentation level of the XML element.
787 """
788
789 def OpeningTag(self, indent: int = 1) -> str:
790 """
791 Return the opening XML tag of this graph.
792
793 Beside the graph's ID, the tag carries the parsing hints a reader needs: the default edge direction, the
794 number of nodes and edges, the parsing order and both ID styles.
795
796 :param indent: Optional, indentation level of the XML element.
797 :returns: The opening XML tag, indented and terminated by a newline.
798 """
799 return f"""\
800{' '*indent}<graph id="{_escapeAttribute(self._id)}"
801{' '*indent} edgedefault="{self._edgeDefault!s}"
802{' '*indent} parse.nodes="{len(self._nodes)}"
803{' '*indent} parse.edges="{len(self._edgesWithoutID) + len(self._edges)}"
804{' '*indent} parse.order="{self._parseOrder!s}"
805{' '*indent} parse.nodeids="{self._nodeIDStyle!s}"
806{' '*indent} parse.edgeids="{self._edgeIDStyle!s}">
807"""
808
809 def ClosingTag(self, indent: int = 1) -> str:
810 """
811 Return the closing XML tag of this graph.
812
813 :param indent: Optional, indentation level of the XML element.
814 :returns: The closing XML tag, indented and terminated by a newline.
815 """
816 return f"{' '*indent}</graph>\n"
817
818 def ToStringLines(self, indent: int = 1) -> list[str]:
819 """
820 Render this graph as a list of XML lines.
821
822 :param indent: Optional, indentation level of the XML element.
823 :returns: List of XML lines describing this graph and everything it contains.
824 """
825 lines = [self.OpeningTag(indent)]
826 for node in self._nodes.values():
827 lines.extend(node.ToStringLines(indent + 1))
828
829 for edge in (*self._edgesWithoutID, *self._edges.values()):
830 lines.extend(edge.ToStringLines(indent + 1))
831 # for data in self._data:
832 # lines.extend(data.ToStringLines(indent + 1))
833 lines.append(self.ClosingTag(indent))
834
835 return lines
836
837
838@export
839class Graph(BaseGraph):
840 """
841 The root graph of a GraphML document.
842
843 It owns the ID space: every node, edge and subgraph registers itself here, so an ID is used only once per document.
844 """
845 _document: GraphMLDocument #: The GraphML document this graph belongs to.
846 _ids: dict[str, Union[Node, Edge, Subgraph]] #: Every element of this graph by ID, used to keep IDs unique.
847
848 def __init__(self, document: GraphMLDocument, identifier: str) -> None:
849 """
850 Initialize the root graph of a GraphML document.
851
852 :param document: The GraphML document this graph belongs to.
853 :param identifier: Unique ID of the graph within the GraphML document.
854 """
855 super().__init__(identifier)
856 self._document = document
857 self._ids = {}
858
859 def GetByID(self, identifier: str) -> Union[Node, Edge, Subgraph]:
860 """
861 Return the element with the given ID, whichever kind it is.
862
863 :param identifier: ID of the node, edge or subgraph.
864 :returns: The element registered under that ID.
865 :raises KeyError: If no element has that ID.
866 """
867 return self._ids[identifier]
868
869 def AddSubgraph(self, subgraph: Subgraph) -> Subgraph:
870 """
871 Add a subgraph to the root graph and register its ID.
872
873 :param subgraph: The subgraph to add.
874 :returns: The added subgraph, so it can be used in the calling expression.
875 """
876 result = super().AddSubgraph(subgraph)
877 self._ids[subgraph._subgraphID] = subgraph
878 subgraph._root = self
879 return result
880
881 def AddNode(self, node: Node) -> Node:
882 """
883 Add a node to the root graph and register its ID.
884
885 :param node: The node to add.
886 :returns: The added node, so it can be used in the calling expression.
887 """
888 result = super().AddNode(node)
889 self._ids[node._id] = node
890 return result
891
892 def AddEdge(self, edge: Edge) -> Edge:
893 """
894 Add an edge to the root graph and register its ID, if it has one.
895
896 :param edge: The edge to add.
897 :returns: The added edge, so it can be used in the calling expression.
898 """
899 result = super().AddEdge(edge)
900 if edge._id is not None:
901 self._ids[edge._id] = edge
902
903 return result
904
905
906_Graph = Graph
907"""Alias of :class:`Graph` for annotations in a class with a property named ``Graph``, which the class body binds."""
908
909
910@export
911class Subgraph(Node, BaseGraph):
912 """
913 A nested graph, which is a node of its parent graph and a graph of its own.
914
915 It therefore carries two identifiers: the node's ID it is referenced by, and :attr:`_subgraphID` for the graph it
916 contains.
917 """
918 _subgraphID: str #: ID of the subgraph, which is distinct from the node's own ID.
919 _root: Nullable[Graph] #: The graph this subgraph is nested in.
920
921 def __init__(self, nodeIdentifier: str, graphIdentifier: str) -> None:
922 """
923 Initialize a subgraph, which is a node in its parent graph and a graph of its own.
924
925 :param nodeIdentifier: Unique ID of the node representing the subgraph.
926 :param graphIdentifier: Unique ID of the graph contained in that node.
927 """
928 super().__init__(nodeIdentifier)
929 BaseGraph.__init__(self, nodeIdentifier)
930
931 self._subgraphID = graphIdentifier
932 self._root = None
933
934 @readonly
935 def RootGraph(self) -> Graph:
936 """
937 Read-only property to access the graph this subgraph is embedded in (:attr:`_root`).
938
939 :returns: The root graph.
940 """
941 return self._root
942
943 @readonly
944 def SubgraphID(self) -> str:
945 """
946 Read-only property to access the subgraph's ID (:attr:`_subgraphID`).
947
948 :returns: ID of the subgraph.
949 """
950 return self._subgraphID
951
952 @readonly
953 def HasClosingTag(self) -> bool:
954 """
955 Check if this XML element is written with a separate closing tag.
956
957 :returns: ``True``, because a subgraph always has a closing tag.
958 """
959 return True
960
961 def AddNode(self, node: Node) -> Node:
962 """
963 Add a node to this subgraph and register its ID at the root graph.
964
965 :param node: The node to add.
966 :returns: The added node, so it can be used in the calling expression.
967 """
968 result = super().AddNode(node)
969 self._root._ids[node._id] = node
970 return result
971
972 def AddEdge(self, edge: Edge) -> Edge:
973 """
974 Add an edge to this subgraph and register its ID at the root graph, if it has one.
975
976 :param edge: The edge to add.
977 :returns: The added edge, so it can be used in the calling expression.
978 """
979 result = super().AddEdge(edge)
980 if edge._id is not None:
981 self._root._ids[edge._id] = edge
982
983 return result
984
985 @notimplemented("A subgraph is always written with an opening and a closing tag.")
986 def Tag(self, indent: int = 2) -> str:
987 """
988 A subgraph always contains a graph, so it is never written as a self-closing tag.
989
990 :param indent: Optional, indentation level of the XML element.
991 """
992
993 def OpeningTag(self, indent: int = 1) -> str:
994 """
995 Return the opening XML tag of this subgraph.
996
997 :param indent: Optional, indentation level of the XML element.
998 :returns: The opening XML tag, indented and terminated by a newline.
999 """
1000 return f"""\
1001{' ' * indent}<graph id="{_escapeAttribute(self._subgraphID)}"
1002{' ' * indent} edgedefault="{self._edgeDefault!s}"
1003{' ' * indent} parse.nodes="{len(self._nodes)}"
1004{' ' * indent} parse.edges="{len(self._edgesWithoutID) + len(self._edges)}"
1005{' ' * indent} parse.order="{self._parseOrder!s}"
1006{' ' * indent} parse.nodeids="{self._nodeIDStyle!s}"
1007{' ' * indent} parse.edgeids="{self._edgeIDStyle!s}">
1008"""
1009
1010 def ClosingTag(self, indent: int = 2) -> str:
1011 """
1012 Return the closing XML tag of this subgraph.
1013
1014 :param indent: Optional, indentation level of the XML element.
1015 :returns: The closing XML tag, indented and terminated by a newline.
1016 """
1017 return BaseGraph.ClosingTag(self, indent)
1018
1019 def ToStringLines(self, indent: int = 2) -> list[str]:
1020 """
1021 Render this subgraph as a list of XML lines.
1022
1023 :param indent: Optional, indentation level of the XML element.
1024 :returns: List of XML lines describing this subgraph and everything it contains.
1025 """
1026 lines = [super().OpeningTag(indent)]
1027 for data in self._data:
1028 lines.extend(data.ToStringLines(indent + 1))
1029 # lines.extend(Graph.ToStringLines(self, indent + 1))
1030 lines.append(self.OpeningTag(indent + 1))
1031 for node in self._nodes.values():
1032 lines.extend(node.ToStringLines(indent + 2))
1033
1034 for edge in (*self._edgesWithoutID, *self._edges.values()):
1035 lines.extend(edge.ToStringLines(indent + 2))
1036 # for data in self._data:
1037 # lines.extend(data.ToStringLines(indent + 1))
1038 lines.append(self.ClosingTag(indent + 1))
1039 lines.append(super().ClosingTag(indent))
1040
1041 return lines
1042
1043
1044@export
1045class GraphMLDocument(Base):
1046 """
1047 A GraphML document: the root graph, the keys it declares, and the XML boilerplate to write it out.
1048 """
1049
1050 _XML_NS: ClassVar[dict[Nullable[str], str]] = {
1051 None: "http://graphml.graphdrawing.org/xmlns",
1052 "xsi": "http://www.w3.org/2001/XMLSchema-instance"
1053 } #: XML namespaces of a GraphML document.
1054 _XSI: ClassVar[dict[str, str]] = {
1055 "schemaLocation": "http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd"
1056 } #: XML schema instance attributes of a GraphML document.
1057
1058 _graph: _Graph #: The document's root graph.
1059 _keys: dict[str, Key] #: Keys declared by this document, by ID.
1060
1061 def __init__(self, identifier: str = "G") -> None:
1062 """
1063 Initialize a GraphML document with an empty root graph.
1064
1065 :param identifier: Optional, unique ID of the root graph.
1066 """
1067 super().__init__()
1068
1069 self._graph = Graph(self, identifier)
1070 self._keys = {}
1071
1072 @readonly
1073 def Graph(self) -> BaseGraph:
1074 """
1075 Read-only property to access the document's graph (:attr:`_graph`).
1076
1077 :returns: The graph described by this document.
1078 """
1079 return self._graph
1080
1081 @readonly
1082 def Keys(self) -> dict[str, Key]:
1083 """
1084 Read-only property to access the attribute keys declared in this document (:attr:`_keys`).
1085
1086 :returns: Dictionary of key IDs and keys.
1087 """
1088 return self._keys
1089
1090 def AddKey(self, key: Key) -> Key:
1091 """
1092 Declare an attribute, so data items can refer to it.
1093
1094 :param key: The key to declare.
1095 :returns: The declared key, so it can be used in the calling expression.
1096 """
1097 self._keys[key._id] = key
1098 return key
1099
1100 def GetKey(self, keyName: str) -> Key:
1101 """
1102 Return the declared key with the given ID.
1103
1104 :param keyName: ID of the key.
1105 :returns: The key with that ID.
1106 :raises KeyError: If no key has that ID.
1107 """
1108 return self._keys[keyName]
1109
1110 def ContainsKey(self, keyName: str) -> bool:
1111 """
1112 Check if a key with the given ID was declared.
1113
1114 :param keyName: ID of the key.
1115 :returns: ``True``, if such a key exists.
1116 """
1117 return keyName in self._keys
1118
1119 def FromGraph(self, graph: pyToolingGraph) -> None:
1120 """
1121 Fill this document from a :class:`pyTooling.Graph.Graph`.
1122
1123 Vertices become nodes, edges become edges, and the vertex and edge values are attached as data items,
1124 declared by two keys this method adds. A subgraph becomes a GraphML subgraph. A link connects vertices of two
1125 graphs; it becomes an edge of the root graph.
1126
1127 A node's ID is its vertex' ID. A vertex without an ID gets a generated ID ``vertex<number>``, which no vertex' ID
1128 is, because GraphML requires one. Subgraphs are translated ordered by name, so the generated IDs don't change from
1129 one run to the next. A graph without a name keeps the document's graph ID.
1130
1131 :param graph: The graph to translate into this document.
1132 """
1133 document = self
1134 if graph._name is not None:
1135 self._graph._id = graph._name
1136
1137 nodeValue = self.AddKey(Key("nodeValue", AttributeContext.Node, "value", AttributeTypes.String))
1138 edgeValue = self.AddKey(Key("edgeValue", AttributeContext.Edge, "value", AttributeTypes.String))
1139
1140 subgraphs = sorted(graph.Subgraphs, key=lambda subgraph: "" if subgraph._name is None else subgraph._name)
1141 vertices = [vertex for subgraph in subgraphs for vertex in subgraph.IterateVertices()]
1142 vertices += graph.IterateVertices()
1143 identifiers = GraphViz.Graph._Identifiers(vertices)
1144 nodes: dict[int, Node] = {}
1145
1146 def getKey(keyID: str, context: AttributeContext, name: str) -> Key:
1147 """
1148 Nested function returning the key with the given ID, which is declared on first use.
1149
1150 :param keyID: ID of the key.
1151 :param context: Kind of element the key's data items belong to.
1152 :param name: Attribute name of the key.
1153 :returns: The key.
1154 """
1155 if document.ContainsKey(keyID):
1156 return document.GetKey(keyID)
1157
1158 return document.AddKey(Key(keyID, context, name, AttributeTypes.String))
1159
1160 def translateEdge(edge: Union[pyToolingEdge, pyToolingLink], prefix: str) -> Edge:
1161 """
1162 Nested function translating an edge or a link, with its value and key-value pairs, into a GraphML edge.
1163
1164 :param edge: The edge or link to translate.
1165 :param prefix: Prefix of the key IDs for its key-value pairs: ``edge`` or ``link``.
1166 :returns: The GraphML edge between the nodes of its source and destination vertex.
1167 """
1168 newEdge = Edge(edge._id, nodes[id(edge._source)], nodes[id(edge._destination)])
1169 if edge._value is not None:
1170 newEdge.AddData(Data(edgeValue, edge._value))
1171
1172 for key, value in edge._dict.items():
1173 newEdge.AddData(Data(getKey(f"{prefix}{key!s}", AttributeContext.Edge, str(key)), value))
1174
1175 return newEdge
1176
1177 def translateGraph(graphMLGraph: BaseGraph, pyTGraph: Union[pyToolingGraph, pyToolingSubgraph]) -> None:
1178 """
1179 Nested function translating the vertices and edges of a graph or subgraph into GraphML nodes and edges.
1180
1181 :param graphMLGraph: The GraphML graph or subgraph the elements are added to.
1182 :param pyTGraph: The pyTooling graph or subgraph to translate.
1183 """
1184 for vertex in pyTGraph.IterateVertices():
1185 newNode = graphMLGraph.AddNode(Node(identifiers[id(vertex)]))
1186 nodes[id(vertex)] = newNode
1187 if vertex._value is not None:
1188 newNode.AddData(Data(nodeValue, vertex._value))
1189
1190 for key, value in vertex._dict.items():
1191 newNode.AddData(Data(getKey(f"node{key!s}", AttributeContext.Node, str(key)), value))
1192
1193 for edge in pyTGraph.IterateEdges():
1194 graphMLGraph.AddEdge(translateEdge(edge, "edge"))
1195
1196 for subgraph in subgraphs:
1197 nodeGraph = Subgraph(subgraph.Name, "sg" + subgraph.Name)
1198 self._graph.AddSubgraph(nodeGraph)
1199 translateGraph(nodeGraph, subgraph)
1200
1201 translateGraph(self._graph, graph)
1202
1203 # A link is known to both graphs it connects: collect it from all graphs, and write it once into the root graph.
1204 translatedLinks: set[int] = set()
1205 for pyTGraph in (*subgraphs, graph):
1206 for link in pyTGraph.IterateLinks():
1207 if id(link) not in translatedLinks:
1208 translatedLinks.add(id(link))
1209 self._graph.AddEdge(translateEdge(link, "link"))
1210
1211 def FromTree(self, tree: pyToolingNode) -> None:
1212 """
1213 Fill this document from a :class:`pyTooling.Tree.Node`.
1214
1215 Every node of the tree becomes a GraphML node, and every parent-child relation becomes an edge.
1216
1217 A GraphML node's ID is the tree node's ID. A tree node without an ID gets a generated ID ``vertex<number>``, which
1218 no tree node's ID is, because GraphML requires one. A root without an ID keeps the document's graph ID.
1219
1220 :param tree: The root node of the tree to translate into this document.
1221 """
1222 if tree._id is not None:
1223 self._graph._id = tree._id
1224
1225 nodeValue = self.AddKey(Key("nodeValue", AttributeContext.Node, "value", AttributeTypes.String))
1226
1227 treeNodes = [tree]
1228 treeNodes += tree.GetDescendants()
1229 identifiers = GraphViz.Graph._Identifiers(treeNodes)
1230 nodes: dict[int, Node] = {}
1231
1232 for i, treeNode in enumerate(treeNodes):
1233 newNode = self._graph.AddNode(Node(identifiers[id(treeNode)]))
1234 nodes[id(treeNode)] = newNode
1235 if treeNode._value is not None:
1236 newNode.AddData(Data(nodeValue, treeNode._value))
1237
1238 if treeNode is not tree:
1239 self._graph.AddEdge(Edge(f"e{i - 1}", newNode, nodes[id(treeNode._parent)]))
1240
1241 @notimplemented("A GraphML document is always written with an opening and a closing tag.")
1242 def Tag(self, indent: int = 0) -> str:
1243 """
1244 A GraphML document always contains keys and a graph, so it is never written as a self-closing tag.
1245
1246 :param indent: Optional, indentation level of the XML element.
1247 """
1248
1249 def OpeningTag(self, indent: int = 0) -> str:
1250 """
1251 Return the opening XML tag of this document.
1252
1253 :param indent: Optional, indentation level of the XML element.
1254 :returns: The opening XML tag, indented and terminated by a newline.
1255 """
1256 return f"""\
1257{' '*indent}<graphml xmlns="{self._XML_NS[None]}"
1258{' '*indent} xmlns:xsi="{self._XML_NS["xsi"]}"
1259{' '*indent} xsi:schemaLocation="{self._XSI["schemaLocation"]}">
1260"""
1261
1262 def ClosingTag(self, indent: int = 0) -> str:
1263 """
1264 Return the closing XML tag of this document.
1265
1266 :param indent: Optional, indentation level of the XML element.
1267 :returns: The closing XML tag, indented and terminated by a newline.
1268 """
1269 return f"{' '*indent}</graphml>\n"
1270
1271 def ToStringLines(self, indent: int = 0) -> list[str]:
1272 """
1273 Render this document as a list of XML lines.
1274
1275 :param indent: Optional, indentation level of the XML element.
1276 :returns: List of XML lines describing this document and everything it contains.
1277 """
1278 lines = [self.OpeningTag(indent)]
1279 for key in self._keys.values():
1280 lines.extend(key.ToStringLines(indent + 1))
1281 lines.extend(self._graph.ToStringLines(indent + 1))
1282 lines.append(self.ClosingTag(indent))
1283
1284 return lines
1285
1286 def WriteToFile(self, file: Path) -> None:
1287 """
1288 Write this document as a GraphML file.
1289
1290 :param file: Path of the file to write.
1291 """
1292 with file.open("w", encoding="utf-8") as f:
1293 f.write("""<?xml version="1.0" encoding="utf-8"?>""")
1294 f.writelines(self.ToStringLines())