codevanta 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevanta-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Standalone DOT and GraphML export for CODEVANTA's plain-dict graph model.
|
|
2
|
+
|
|
3
|
+
The exporters here are deliberately dependency free: they import only the
|
|
4
|
+
standard library and know nothing about CODEVANTA's analysis classes. Any
|
|
5
|
+
producer that can describe a graph as
|
|
6
|
+
|
|
7
|
+
.. code-block:: python
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"nodes": [{"id": "0x401000", "label": "main", "kind": "entry_point"}],
|
|
11
|
+
"edges": [{"source": "0x401000", "target": "0x401040", "label": "call"}],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
can therefore render it through :func:`to_dot` or :func:`to_graphml` — the call
|
|
15
|
+
graph, the control-flow graph, and future viewers all share one renderer.
|
|
16
|
+
:func:`validate_graph` exposes the same checks directly and returns the
|
|
17
|
+
normalized, deterministically ordered copy that both exporters render, so other
|
|
18
|
+
views (such as :mod:`codevanta.analysis.html_view`) can reuse it instead of
|
|
19
|
+
duplicating the contract.
|
|
20
|
+
|
|
21
|
+
Contract
|
|
22
|
+
--------
|
|
23
|
+
* ``nodes`` items: ``id`` (required, non-blank ``str``, unique), ``label``
|
|
24
|
+
(required ``str``), ``kind`` (optional ``str``).
|
|
25
|
+
* ``edges`` items: ``source`` and ``target`` (required, must be existing node
|
|
26
|
+
ids), ``label`` (optional ``str``, defaults to ``"call"``).
|
|
27
|
+
* Size is bounded by ``max_nodes`` / ``max_edges``, both ``>= 1``.
|
|
28
|
+
* Anything that violates the contract raises :class:`GraphExportError`; a
|
|
29
|
+
partially valid graph is never rendered.
|
|
30
|
+
|
|
31
|
+
Both exporters are deterministic: nodes are emitted sorted by ``id`` and edges
|
|
32
|
+
sorted by ``(source, target, label)``, so two graphs with the same content but a
|
|
33
|
+
different insertion order produce identical output.
|
|
34
|
+
|
|
35
|
+
Graph content is only ever *rendered* — nothing is executed or written to disk;
|
|
36
|
+
callers decide whether to persist the returned string.
|
|
37
|
+
|
|
38
|
+
Limitations
|
|
39
|
+
-----------
|
|
40
|
+
* DOT output escapes ``"``, ``\\``, newline, carriage return and tab, and
|
|
41
|
+
replaces other C0 control characters with ``U+FFFD``. It does not implement
|
|
42
|
+
the full DOT grammar for record/HTML-like labels.
|
|
43
|
+
* GraphML stores node ids in ``id`` attributes; XML attribute-value
|
|
44
|
+
normalization turns tab/newline characters inside an id into spaces, so such
|
|
45
|
+
ids do not round-trip byte-exactly (labels still do).
|
|
46
|
+
* No layout is emitted (no coordinates): renderers run their own layout engine.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import re
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
from typing import Any
|
|
54
|
+
from xml.etree import ElementTree
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"DEFAULT_EDGE_LABEL",
|
|
58
|
+
"DEFAULT_MAX_EDGES",
|
|
59
|
+
"DEFAULT_MAX_NODES",
|
|
60
|
+
"DEFAULT_TITLE",
|
|
61
|
+
"GraphExportError",
|
|
62
|
+
"sanitize_text",
|
|
63
|
+
"to_dot",
|
|
64
|
+
"to_graphml",
|
|
65
|
+
"validate_graph",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
#: Title used when the caller does not pass one.
|
|
69
|
+
DEFAULT_TITLE = "CODEVANTA graph"
|
|
70
|
+
|
|
71
|
+
#: Node budget per export; keeps output bounded for very large graphs.
|
|
72
|
+
DEFAULT_MAX_NODES = 5000
|
|
73
|
+
|
|
74
|
+
#: Edge budget per export.
|
|
75
|
+
DEFAULT_MAX_EDGES = 20000
|
|
76
|
+
|
|
77
|
+
#: Label rendered for an edge that carries no explicit label.
|
|
78
|
+
DEFAULT_EDGE_LABEL = "call"
|
|
79
|
+
|
|
80
|
+
#: Node ``kind`` values drawn as rectangles; everything else is an ellipse.
|
|
81
|
+
_BOX_KINDS = frozenset({"function", "entry_point"})
|
|
82
|
+
|
|
83
|
+
#: GraphML namespace and schema location declared on the root element.
|
|
84
|
+
_GRAPHML_NS = "http://graphml.graphdrawing.org/xmlns"
|
|
85
|
+
_XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
86
|
+
_GRAPHML_SCHEMA = "http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd"
|
|
87
|
+
|
|
88
|
+
#: ``key`` ids used by the GraphML ``data`` elements.
|
|
89
|
+
_KEY_NODE_LABEL = "d0"
|
|
90
|
+
_KEY_NODE_KIND = "d1"
|
|
91
|
+
_KEY_EDGE_LABEL = "d2"
|
|
92
|
+
|
|
93
|
+
#: Characters escaped inside DOT double-quoted strings.
|
|
94
|
+
_DOT_SPECIAL_RE = re.compile(r'[\\"\n\r\t]')
|
|
95
|
+
_DOT_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
96
|
+
|
|
97
|
+
#: Characters that are not representable in XML 1.0 (or in a lone surrogate).
|
|
98
|
+
_ILLEGAL_TEXT_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")
|
|
99
|
+
|
|
100
|
+
#: Replacement for characters that cannot be represented at all.
|
|
101
|
+
_REPLACEMENT_CHAR = "\ufffd"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class GraphExportError(ValueError):
|
|
105
|
+
"""Raised when a graph cannot be rendered in the requested format."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class _Node:
|
|
110
|
+
"""A validated graph node."""
|
|
111
|
+
|
|
112
|
+
id: str
|
|
113
|
+
label: str
|
|
114
|
+
kind: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class _Edge:
|
|
119
|
+
"""A validated graph edge; ``label`` always holds a non-blank label."""
|
|
120
|
+
|
|
121
|
+
source: str
|
|
122
|
+
target: str
|
|
123
|
+
label: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_limit(value: Any, name: str) -> int:
|
|
127
|
+
"""Return ``value`` as an export limit, or raise :class:`GraphExportError`."""
|
|
128
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
129
|
+
raise GraphExportError(f"{name} must be an int, got {type(value).__name__}")
|
|
130
|
+
if value < 1:
|
|
131
|
+
raise GraphExportError(f"{name} must be >= 1, got {value}")
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_title(title: Any) -> str:
|
|
136
|
+
"""Return a usable title, falling back to :data:`DEFAULT_TITLE` when blank."""
|
|
137
|
+
if not isinstance(title, str):
|
|
138
|
+
raise GraphExportError(f"title must be a str, got {type(title).__name__}")
|
|
139
|
+
return title if title.strip() else DEFAULT_TITLE
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_sequence(graph: dict[str, Any], key: str) -> list[Any]:
|
|
143
|
+
"""Return ``graph[key]`` as a list, or raise :class:`GraphExportError`."""
|
|
144
|
+
if key not in graph:
|
|
145
|
+
raise GraphExportError(f"graph is missing required key {key!r}")
|
|
146
|
+
value = graph[key]
|
|
147
|
+
if not isinstance(value, list):
|
|
148
|
+
raise GraphExportError(f"graph[{key!r}] must be a list, got {type(value).__name__}")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _require_text(item: dict[str, Any], key: str, context: str) -> str:
|
|
153
|
+
"""Return ``item[key]`` as a string, or raise :class:`GraphExportError`."""
|
|
154
|
+
if key not in item:
|
|
155
|
+
raise GraphExportError(f"{context}: missing required key {key!r}")
|
|
156
|
+
value = item[key]
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
raise GraphExportError(f"{context}: {key!r} must be a str, got {type(value).__name__}")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_id(item: dict[str, Any], key: str, context: str) -> str:
|
|
163
|
+
"""Return a non-blank identifier, or raise :class:`GraphExportError`."""
|
|
164
|
+
value = _require_text(item, key, context)
|
|
165
|
+
if not value.strip():
|
|
166
|
+
raise GraphExportError(f"{context}: {key!r} must not be blank")
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _optional_text(item: dict[str, Any], key: str, context: str, default: str) -> str:
|
|
171
|
+
"""Return an optional string field, using ``default`` when absent or ``None``."""
|
|
172
|
+
value = item.get(key)
|
|
173
|
+
if value is None:
|
|
174
|
+
return default
|
|
175
|
+
if not isinstance(value, str):
|
|
176
|
+
raise GraphExportError(f"{context}: {key!r} must be a str, got {type(value).__name__}")
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _check_mapping(value: Any, context: str) -> dict[str, Any]:
|
|
181
|
+
"""Return ``value`` as a mapping, or raise :class:`GraphExportError`."""
|
|
182
|
+
if not isinstance(value, dict):
|
|
183
|
+
raise GraphExportError(f"{context} must be a dict, got {type(value).__name__}")
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_node(raw: Any, index: int) -> _Node:
|
|
188
|
+
"""Validate one raw node entry."""
|
|
189
|
+
context = f"node {index}"
|
|
190
|
+
item = _check_mapping(raw, context)
|
|
191
|
+
node_id = _require_id(item, "id", context)
|
|
192
|
+
label = _require_text(item, "label", context)
|
|
193
|
+
kind = _optional_text(item, "kind", context, "")
|
|
194
|
+
return _Node(id=node_id, label=label, kind=kind)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_edge(raw: Any, index: int, node_ids: dict[str, _Node]) -> _Edge:
|
|
198
|
+
"""Validate one raw edge entry and resolve both of its endpoints."""
|
|
199
|
+
context = f"edge {index}"
|
|
200
|
+
item = _check_mapping(raw, context)
|
|
201
|
+
source = _require_id(item, "source", context)
|
|
202
|
+
target = _require_id(item, "target", context)
|
|
203
|
+
if source not in node_ids:
|
|
204
|
+
raise GraphExportError(f"{context}: source {source!r} is not a known node id")
|
|
205
|
+
if target not in node_ids:
|
|
206
|
+
raise GraphExportError(f"{context}: target {target!r} is not a known node id")
|
|
207
|
+
label = _optional_text(item, "label", context, DEFAULT_EDGE_LABEL)
|
|
208
|
+
if not label.strip():
|
|
209
|
+
label = DEFAULT_EDGE_LABEL
|
|
210
|
+
return _Edge(source=source, target=target, label=label)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_graph(
|
|
214
|
+
graph: Any,
|
|
215
|
+
max_nodes: Any,
|
|
216
|
+
max_edges: Any,
|
|
217
|
+
) -> tuple[list[_Node], list[_Edge]]:
|
|
218
|
+
"""Validate the whole graph and return deterministically ordered nodes/edges."""
|
|
219
|
+
if not isinstance(graph, dict):
|
|
220
|
+
raise GraphExportError(f"graph must be a dict, got {type(graph).__name__}")
|
|
221
|
+
node_limit = _check_limit(max_nodes, "max_nodes")
|
|
222
|
+
edge_limit = _check_limit(max_edges, "max_edges")
|
|
223
|
+
|
|
224
|
+
raw_nodes = _check_sequence(graph, "nodes")
|
|
225
|
+
raw_edges = _check_sequence(graph, "edges")
|
|
226
|
+
|
|
227
|
+
if len(raw_nodes) > node_limit:
|
|
228
|
+
raise GraphExportError(f"graph has {len(raw_nodes)} nodes, max_nodes is {node_limit}")
|
|
229
|
+
if len(raw_edges) > edge_limit:
|
|
230
|
+
raise GraphExportError(f"graph has {len(raw_edges)} edges, max_edges is {edge_limit}")
|
|
231
|
+
|
|
232
|
+
nodes: list[_Node] = []
|
|
233
|
+
node_ids: dict[str, _Node] = {}
|
|
234
|
+
for index, raw in enumerate(raw_nodes):
|
|
235
|
+
node = _parse_node(raw, index)
|
|
236
|
+
if node.id in node_ids:
|
|
237
|
+
raise GraphExportError(f"node {index}: duplicate node id {node.id!r}")
|
|
238
|
+
node_ids[node.id] = node
|
|
239
|
+
nodes.append(node)
|
|
240
|
+
|
|
241
|
+
edges = [_parse_edge(raw, index, node_ids) for index, raw in enumerate(raw_edges)]
|
|
242
|
+
|
|
243
|
+
nodes.sort(key=lambda node: node.id)
|
|
244
|
+
edges.sort(key=lambda edge: (edge.source, edge.target, edge.label))
|
|
245
|
+
return nodes, edges
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def validate_graph(
|
|
249
|
+
graph: Any,
|
|
250
|
+
max_nodes: int = DEFAULT_MAX_NODES,
|
|
251
|
+
max_edges: int = DEFAULT_MAX_EDGES,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""Validate ``graph`` and return a normalized, deterministically ordered copy.
|
|
254
|
+
|
|
255
|
+
The checks are exactly the ones :func:`to_dot` and :func:`to_graphml` apply,
|
|
256
|
+
so a graph accepted here renders without further validation. The returned
|
|
257
|
+
mapping never aliases the input: fresh node and edge dicts are built, blank
|
|
258
|
+
edge labels are replaced by ``"call"``, missing ``kind`` values become
|
|
259
|
+
``""``, and both lists are sorted (nodes by ``id``, edges by
|
|
260
|
+
``(source, target, label)``).
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
graph: Mapping with ``"nodes"`` and ``"edges"`` lists (see module docs).
|
|
264
|
+
max_nodes: Maximum number of nodes to accept (``>= 1``).
|
|
265
|
+
max_edges: Maximum number of edges to accept (``>= 1``).
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
``{"nodes": [{"id", "label", "kind"}...], "edges": [{"source",
|
|
269
|
+
"target", "label"}...]}`` with every value a ``str``.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
GraphExportError: If the graph or any limit violates the contract.
|
|
273
|
+
|
|
274
|
+
Examples:
|
|
275
|
+
>>> validate_graph(
|
|
276
|
+
... {
|
|
277
|
+
... "nodes": [{"id": "b", "label": "parse"}, {"id": "a", "label": "main"}],
|
|
278
|
+
... "edges": [{"source": "a", "target": "b"}],
|
|
279
|
+
... }
|
|
280
|
+
... )["edges"]
|
|
281
|
+
[{'source': 'a', 'target': 'b', 'label': 'call'}]
|
|
282
|
+
"""
|
|
283
|
+
nodes, edges = _parse_graph(graph, max_nodes, max_edges)
|
|
284
|
+
return {
|
|
285
|
+
"nodes": [{"id": node.id, "label": node.label, "kind": node.kind} for node in nodes],
|
|
286
|
+
"edges": [
|
|
287
|
+
{"source": edge.source, "target": edge.target, "label": edge.label} for edge in edges
|
|
288
|
+
],
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _sanitize(value: str) -> str:
|
|
293
|
+
"""Replace characters that cannot be represented in output with ``U+FFFD``."""
|
|
294
|
+
return _ILLEGAL_TEXT_RE.sub(_REPLACEMENT_CHAR, value)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def sanitize_text(value: str) -> str:
|
|
298
|
+
"""Replace characters that cannot be represented in output with ``U+FFFD``.
|
|
299
|
+
|
|
300
|
+
Shared by the DOT, GraphML, and HTML exporters so every text surface treats
|
|
301
|
+
illegal characters (control characters, lone surrogates) the same way.
|
|
302
|
+
"""
|
|
303
|
+
return _sanitize(value)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _display_label(label: str, node_id: str) -> str:
|
|
307
|
+
"""Return the label to render, falling back to the node id when blank."""
|
|
308
|
+
return label if label.strip() else node_id
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _dot_quote(value: str) -> str:
|
|
312
|
+
"""Return ``value`` as a double-quoted, escaped DOT string."""
|
|
313
|
+
escaped = _DOT_SPECIAL_RE.sub(lambda match: _DOT_ESCAPES[match.group(0)], _sanitize(value))
|
|
314
|
+
return f'"{escaped}"'
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _dot_shape(kind: str) -> str:
|
|
318
|
+
"""Return the DOT shape for a node ``kind``."""
|
|
319
|
+
return "box" if kind.strip().lower() in _BOX_KINDS else "ellipse"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def to_dot(
|
|
323
|
+
graph: dict[str, Any],
|
|
324
|
+
title: str = DEFAULT_TITLE,
|
|
325
|
+
max_nodes: int = DEFAULT_MAX_NODES,
|
|
326
|
+
max_edges: int = DEFAULT_MAX_EDGES,
|
|
327
|
+
) -> str:
|
|
328
|
+
"""Render ``graph`` as a Graphviz DOT ``digraph``.
|
|
329
|
+
|
|
330
|
+
Node ids and labels are emitted as double-quoted, escaped strings; nodes are
|
|
331
|
+
drawn as ``box`` when their ``kind`` is ``"function"`` or ``"entry_point"``
|
|
332
|
+
and as ``ellipse`` otherwise. Edges always carry a label, defaulting to
|
|
333
|
+
``"call"``. Validation and deterministic ordering are delegated to
|
|
334
|
+
:func:`validate_graph`.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
graph: Mapping with ``"nodes"`` and ``"edges"`` lists (see module docs).
|
|
338
|
+
title: Graph title; blank titles fall back to :data:`DEFAULT_TITLE`.
|
|
339
|
+
max_nodes: Maximum number of nodes to accept (``>= 1``).
|
|
340
|
+
max_edges: Maximum number of edges to accept (``>= 1``).
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
A newline-terminated DOT document.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
GraphExportError: If the graph or any limit violates the contract.
|
|
347
|
+
"""
|
|
348
|
+
validated = validate_graph(graph, max_nodes, max_edges)
|
|
349
|
+
heading = _dot_quote(_check_title(title))
|
|
350
|
+
|
|
351
|
+
lines = [f"digraph {heading} {{", f" graph [label={heading}];"]
|
|
352
|
+
for node in validated["nodes"]:
|
|
353
|
+
label = _dot_quote(_display_label(node["label"], node["id"]))
|
|
354
|
+
lines.append(f" {_dot_quote(node['id'])} [label={label}, shape={_dot_shape(node['kind'])}];")
|
|
355
|
+
|
|
356
|
+
for edge in validated["edges"]:
|
|
357
|
+
lines.append(
|
|
358
|
+
f" {_dot_quote(edge['source'])} -> {_dot_quote(edge['target'])}"
|
|
359
|
+
f" [label={_dot_quote(edge['label'])}];"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
lines.append("}")
|
|
363
|
+
return "\n".join(lines) + "\n"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _append_graphml_key(parent: ElementTree.Element, key_id: str, target: str, name: str) -> None:
|
|
367
|
+
"""Append one GraphML ``key`` declaration for a string attribute."""
|
|
368
|
+
key = ElementTree.SubElement(parent, "key")
|
|
369
|
+
key.set("id", key_id)
|
|
370
|
+
key.set("for", target)
|
|
371
|
+
key.set("attr.name", name)
|
|
372
|
+
key.set("attr.type", "string")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _append_graphml_data(parent: ElementTree.Element, key_id: str, text: str) -> None:
|
|
376
|
+
"""Append one GraphML ``data`` element carrying ``text``."""
|
|
377
|
+
data = ElementTree.SubElement(parent, "data")
|
|
378
|
+
data.set("key", key_id)
|
|
379
|
+
data.text = _sanitize(text)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def to_graphml(
|
|
383
|
+
graph: dict[str, Any],
|
|
384
|
+
title: str = DEFAULT_TITLE,
|
|
385
|
+
max_nodes: int = DEFAULT_MAX_NODES,
|
|
386
|
+
max_edges: int = DEFAULT_MAX_EDGES,
|
|
387
|
+
) -> str:
|
|
388
|
+
"""Render ``graph`` as a valid GraphML 1.0 document.
|
|
389
|
+
|
|
390
|
+
The document declares ``key`` elements for the node ``label``, the node
|
|
391
|
+
``kind`` and the edge ``label`` attributes, a directed ``graph`` element,
|
|
392
|
+
and a ``desc`` element holding the title. Validation and deterministic
|
|
393
|
+
ordering are delegated to :func:`validate_graph`.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
graph: Mapping with ``"nodes"`` and ``"edges"`` lists (see module docs).
|
|
397
|
+
title: Graph title; blank titles fall back to :data:`DEFAULT_TITLE`.
|
|
398
|
+
max_nodes: Maximum number of nodes to accept (``>= 1``).
|
|
399
|
+
max_edges: Maximum number of edges to accept (``>= 1``).
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
An XML declaration followed by the serialized GraphML document.
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
GraphExportError: If the graph or any limit violates the contract.
|
|
406
|
+
"""
|
|
407
|
+
validated = validate_graph(graph, max_nodes, max_edges)
|
|
408
|
+
heading = _check_title(title)
|
|
409
|
+
|
|
410
|
+
root = ElementTree.Element("graphml")
|
|
411
|
+
root.set("xmlns", _GRAPHML_NS)
|
|
412
|
+
root.set("xmlns:xsi", _XSI_NS)
|
|
413
|
+
root.set("xsi:schemaLocation", f"{_GRAPHML_NS} {_GRAPHML_SCHEMA}")
|
|
414
|
+
|
|
415
|
+
_append_graphml_key(root, _KEY_NODE_LABEL, "node", "label")
|
|
416
|
+
_append_graphml_key(root, _KEY_NODE_KIND, "node", "kind")
|
|
417
|
+
_append_graphml_key(root, _KEY_EDGE_LABEL, "edge", "label")
|
|
418
|
+
|
|
419
|
+
graph_element = ElementTree.SubElement(root, "graph")
|
|
420
|
+
graph_element.set("id", "G")
|
|
421
|
+
graph_element.set("edgedefault", "directed")
|
|
422
|
+
|
|
423
|
+
description = ElementTree.SubElement(graph_element, "desc")
|
|
424
|
+
description.text = _sanitize(heading)
|
|
425
|
+
|
|
426
|
+
for node in validated["nodes"]:
|
|
427
|
+
element = ElementTree.SubElement(graph_element, "node")
|
|
428
|
+
element.set("id", _sanitize(node["id"]))
|
|
429
|
+
_append_graphml_data(element, _KEY_NODE_LABEL, _display_label(node["label"], node["id"]))
|
|
430
|
+
if node["kind"].strip():
|
|
431
|
+
_append_graphml_data(element, _KEY_NODE_KIND, node["kind"])
|
|
432
|
+
|
|
433
|
+
for edge in validated["edges"]:
|
|
434
|
+
element = ElementTree.SubElement(graph_element, "edge")
|
|
435
|
+
element.set("source", _sanitize(edge["source"]))
|
|
436
|
+
element.set("target", _sanitize(edge["target"]))
|
|
437
|
+
_append_graphml_data(element, _KEY_EDGE_LABEL, edge["label"])
|
|
438
|
+
|
|
439
|
+
ElementTree.indent(root, space=" ")
|
|
440
|
+
body = ElementTree.tostring(root, encoding="unicode")
|
|
441
|
+
return f'<?xml version="1.0" encoding="UTF-8"?>\n{body}\n'
|