GraphML.py

Summary

Lines:

430 of 443 covered (97.1%)

Branches:

59 of 60 taken (98.3%), 1 lines partially

Coverage:

97.2%

Directory:

Graph

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({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"})
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("&", "&amp;")
433		data = data.replace("<", "&lt;")
434		data = data.replace(">", "&gt;")
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())