execweave 0.6.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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
execweave/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ExecWeave runtime graph package."""
2
+
3
+ __version__ = "0.6.0"
execweave/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
execweave/analysis.py ADDED
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ from collections import Counter, defaultdict, deque
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import PurePath
7
+ from typing import Any
8
+
9
+
10
+ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
11
+ _SENSITIVE_MARKERS = (
12
+ "/.ssh/",
13
+ "\\.ssh\\",
14
+ "/.aws/credentials",
15
+ "\\.aws\\credentials",
16
+ "/.config/gcloud/",
17
+ "\\.config\\gcloud\\",
18
+ "/.azure/",
19
+ "\\.azure\\",
20
+ "/.kube/config",
21
+ "\\.kube\\config",
22
+ "/.docker/config.json",
23
+ "\\.docker\\config.json",
24
+ "/.npmrc",
25
+ "\\.npmrc",
26
+ "/.pypirc",
27
+ "\\.pypirc",
28
+ "/.netrc",
29
+ "\\.netrc",
30
+ )
31
+ _SENSITIVE_BASENAMES = {
32
+ ".env",
33
+ "id_rsa",
34
+ "id_dsa",
35
+ "id_ecdsa",
36
+ "id_ed25519",
37
+ "credentials",
38
+ "credentials.json",
39
+ "service-account.json",
40
+ }
41
+ _FILE_RELATIONS = {"OPENED_READ", "OPENED_READ_WRITE", "OPENED_WRITE"}
42
+ _NETWORK_RELATIONS = {"CONNECTED_TO", "CONNECT_ATTEMPTED"}
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Finding:
47
+ rule_id: str
48
+ severity: str
49
+ title: str
50
+ summary: str
51
+ node_ids: list[str] = field(default_factory=list)
52
+ edge_ids: list[str] = field(default_factory=list)
53
+ evidence_event_ids: list[str] = field(default_factory=list)
54
+ attributes: dict[str, Any] = field(default_factory=dict)
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ return asdict(self)
58
+
59
+
60
+ def _node_map(graph: dict[str, Any]) -> dict[str, dict[str, Any]]:
61
+ return {
62
+ node["id"]: node
63
+ for node in graph.get("nodes", [])
64
+ if isinstance(node, dict) and isinstance(node.get("id"), str)
65
+ }
66
+
67
+
68
+ def _resource_text(node: dict[str, Any]) -> str:
69
+ return f"{node.get('id', '')} {node.get('name', '')}".lower()
70
+
71
+
72
+ def _is_sensitive_file(node: dict[str, Any]) -> bool:
73
+ if node.get("type") != "file":
74
+ return False
75
+ text = _resource_text(node)
76
+ if any(marker in text for marker in _SENSITIVE_MARKERS):
77
+ return True
78
+ name = str(node.get("name") or "").lower()
79
+ if name in _SENSITIVE_BASENAMES:
80
+ return True
81
+ node_id = str(node.get("id") or "")
82
+ _, _, raw_path = node_id.partition(":")
83
+ if raw_path:
84
+ try:
85
+ return PurePath(raw_path).name.lower() in _SENSITIVE_BASENAMES
86
+ except (TypeError, ValueError):
87
+ return False
88
+ return False
89
+
90
+
91
+ def _endpoint_host(node: dict[str, Any]) -> str | None:
92
+ if node.get("type") != "network_endpoint":
93
+ return None
94
+ raw = str(node.get("name") or "")
95
+ if not raw:
96
+ node_id = str(node.get("id") or "")
97
+ _, _, raw = node_id.partition(":")
98
+ if not raw:
99
+ return None
100
+ if raw.startswith("[") and "]" in raw:
101
+ return raw[1 : raw.index("]")]
102
+ if raw.count(":") == 1:
103
+ return raw.rsplit(":", 1)[0]
104
+ return raw
105
+
106
+
107
+ def _is_external_endpoint(node: dict[str, Any]) -> bool:
108
+ host = _endpoint_host(node)
109
+ if host is None:
110
+ return False
111
+ normalized = host.strip().lower()
112
+ if normalized in {"localhost", "ip6-localhost"}:
113
+ return False
114
+ try:
115
+ address = ipaddress.ip_address(normalized)
116
+ except ValueError:
117
+ return True
118
+ return not (
119
+ address.is_loopback
120
+ or address.is_private
121
+ or address.is_link_local
122
+ or address.is_multicast
123
+ or address.is_unspecified
124
+ or address.is_reserved
125
+ )
126
+
127
+
128
+ def _event_ids(edge: dict[str, Any], limit: int = 24) -> list[str]:
129
+ values = edge.get("event_ids") or []
130
+ return [str(value) for value in values if isinstance(value, str)][:limit]
131
+
132
+
133
+ def _edge_sequence(edge: dict[str, Any], key: str) -> int | None:
134
+ value = edge.get(key)
135
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
136
+
137
+
138
+ def _causal_spawn_adjacency(
139
+ edges: list[dict[str, Any]],
140
+ ) -> dict[str, list[dict[str, Any]]]:
141
+ adjacency: dict[str, list[dict[str, Any]]] = defaultdict(list)
142
+ for edge in edges:
143
+ if edge.get("relation") != "SPAWNED" or edge.get("causal") is not True:
144
+ continue
145
+ source = edge.get("source")
146
+ target = edge.get("target")
147
+ if isinstance(source, str) and isinstance(target, str):
148
+ adjacency[source].append(edge)
149
+ for values in adjacency.values():
150
+ values.sort(
151
+ key=lambda edge: (
152
+ _edge_sequence(edge, "first_sequence") or 0,
153
+ str(edge.get("target") or ""),
154
+ )
155
+ )
156
+ return dict(adjacency)
157
+
158
+
159
+ def _descendant_spawn_paths(
160
+ process_id: str,
161
+ adjacency: dict[str, list[dict[str, Any]]],
162
+ *,
163
+ after_sequence: int | None,
164
+ max_depth: int = 4,
165
+ ) -> list[tuple[str, list[dict[str, Any]]]]:
166
+ """Return chronological causal SPAWNED paths created after a source observation."""
167
+ results: list[tuple[str, list[dict[str, Any]]]] = []
168
+ queue: deque[tuple[str, list[dict[str, Any]], set[str], int | None]] = deque()
169
+ queue.append((process_id, [], {process_id}, after_sequence))
170
+
171
+ while queue:
172
+ current, path, seen, minimum_sequence = queue.popleft()
173
+ if len(path) >= max_depth:
174
+ continue
175
+ for edge in adjacency.get(current, []):
176
+ child = edge.get("target")
177
+ if not isinstance(child, str) or child in seen:
178
+ continue
179
+ spawn_sequence = _edge_sequence(edge, "first_sequence")
180
+ if minimum_sequence is not None:
181
+ if spawn_sequence is None or spawn_sequence < minimum_sequence:
182
+ continue
183
+ next_path = [*path, edge]
184
+ results.append((child, next_path))
185
+ queue.append(
186
+ (
187
+ child,
188
+ next_path,
189
+ {*seen, child},
190
+ spawn_sequence if spawn_sequence is not None else minimum_sequence,
191
+ )
192
+ )
193
+ return results
194
+
195
+
196
+ def analyze_graph(graph: dict[str, Any]) -> dict[str, Any]:
197
+ """Run conservative, explainable rules over one execution graph.
198
+
199
+ Findings never upgrade co-occurrence into byte-level data-flow. Sensitive-file
200
+ access followed by network activity is a prioritization signal, not proof that
201
+ file contents were transmitted. The same rule applies across child processes:
202
+ SPAWNED proves process lineage, not data inheritance or IPC.
203
+ """
204
+ nodes = _node_map(graph)
205
+ edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
206
+ findings: list[Finding] = []
207
+ sensitive_by_process: dict[str, list[dict[str, Any]]] = {}
208
+ external_by_process: dict[str, list[dict[str, Any]]] = {}
209
+
210
+ for edge in edges:
211
+ source_id = edge.get("source")
212
+ target_id = edge.get("target")
213
+ relation = edge.get("relation")
214
+ if not isinstance(source_id, str) or not isinstance(target_id, str):
215
+ continue
216
+ source = nodes.get(source_id)
217
+ target = nodes.get(target_id)
218
+ if source is None or target is None or source.get("type") != "process":
219
+ continue
220
+
221
+ if relation in _FILE_RELATIONS and _is_sensitive_file(target):
222
+ causal = edge.get("causal") is True
223
+ severity = "high" if relation in {"OPENED_READ_WRITE", "OPENED_WRITE"} else "medium"
224
+ if not causal:
225
+ severity = "low"
226
+ findings.append(
227
+ Finding(
228
+ rule_id="sensitive-file-access",
229
+ severity=severity,
230
+ title="Process accessed a sensitive-looking file",
231
+ summary=(
232
+ f"{source_id} has {relation} evidence for {target_id}. "
233
+ "Review whether this resource was required for the agent task."
234
+ ),
235
+ node_ids=[source_id, target_id],
236
+ edge_ids=[str(edge.get("id") or "")],
237
+ evidence_event_ids=_event_ids(edge),
238
+ attributes={
239
+ "relation": relation,
240
+ "causal": edge.get("causal"),
241
+ "data_flow_proven": False,
242
+ },
243
+ )
244
+ )
245
+ if causal:
246
+ sensitive_by_process.setdefault(source_id, []).append(edge)
247
+
248
+ if relation in _NETWORK_RELATIONS and _is_external_endpoint(target):
249
+ causal = edge.get("causal") is True
250
+ findings.append(
251
+ Finding(
252
+ rule_id="external-network-contact",
253
+ severity="info" if relation == "CONNECTED_TO" else "low",
254
+ title="Process contacted an external network endpoint",
255
+ summary=f"{source_id} emitted {relation} evidence for {target_id}.",
256
+ node_ids=[source_id, target_id],
257
+ edge_ids=[str(edge.get("id") or "")],
258
+ evidence_event_ids=_event_ids(edge),
259
+ attributes={
260
+ "relation": relation,
261
+ "causal": edge.get("causal"),
262
+ "endpoint_host": _endpoint_host(target),
263
+ },
264
+ )
265
+ )
266
+ if causal:
267
+ external_by_process.setdefault(source_id, []).append(edge)
268
+
269
+ # Same-process correlation with strict chronological ordering when sequences exist.
270
+ for process_id in sorted(set(sensitive_by_process).intersection(external_by_process)):
271
+ for file_edge in sensitive_by_process[process_id]:
272
+ file_target = str(file_edge.get("target") or "")
273
+ file_last = _edge_sequence(file_edge, "last_sequence")
274
+ for net_edge in external_by_process[process_id]:
275
+ net_target = str(net_edge.get("target") or "")
276
+ net_first = _edge_sequence(net_edge, "first_sequence")
277
+ if file_last is not None and net_first is not None and net_first < file_last:
278
+ continue
279
+ relation = str(net_edge.get("relation") or "")
280
+ severity = "high" if relation == "CONNECTED_TO" else "medium"
281
+ findings.append(
282
+ Finding(
283
+ rule_id="possible-sensitive-file-to-network-path",
284
+ severity=severity,
285
+ title="Sensitive-file access was followed by external network activity",
286
+ summary=(
287
+ f"{process_id} accessed {file_target} and later produced {relation} "
288
+ f"evidence for {net_target}. This is a prioritization signal only: "
289
+ "ExecWeave has not proven that bytes from the file were transmitted."
290
+ ),
291
+ node_ids=[process_id, file_target, net_target],
292
+ edge_ids=[
293
+ str(file_edge.get("id") or ""),
294
+ str(net_edge.get("id") or ""),
295
+ ],
296
+ evidence_event_ids=[
297
+ *_event_ids(file_edge, 12),
298
+ *_event_ids(net_edge, 12),
299
+ ],
300
+ attributes={
301
+ "file_relation": file_edge.get("relation"),
302
+ "network_relation": relation,
303
+ "file_last_sequence": file_last,
304
+ "network_first_sequence": net_first,
305
+ "causal_process_attribution": True,
306
+ "data_flow_proven": False,
307
+ "exfiltration_proven": False,
308
+ },
309
+ )
310
+ )
311
+
312
+ # Graph-native delegated path: sensitive access -> causal SPAWNED chain -> network.
313
+ # This proves chronological process lineage only. It does not prove inheritance,
314
+ # IPC, taint propagation, or that the child received bytes from the sensitive file.
315
+ spawn_adjacency = _causal_spawn_adjacency(edges)
316
+ for source_process, file_edges in sorted(sensitive_by_process.items()):
317
+ for file_edge in file_edges:
318
+ file_target = str(file_edge.get("target") or "")
319
+ file_last = _edge_sequence(file_edge, "last_sequence")
320
+ for descendant, spawn_path in _descendant_spawn_paths(
321
+ source_process,
322
+ spawn_adjacency,
323
+ after_sequence=file_last,
324
+ ):
325
+ spawn_last = _edge_sequence(spawn_path[-1], "first_sequence")
326
+ for net_edge in external_by_process.get(descendant, []):
327
+ net_first = _edge_sequence(net_edge, "first_sequence")
328
+ if spawn_last is not None:
329
+ if net_first is None or net_first < spawn_last:
330
+ continue
331
+ relation = str(net_edge.get("relation") or "")
332
+ net_target = str(net_edge.get("target") or "")
333
+ process_chain = [source_process]
334
+ process_chain.extend(str(edge.get("target") or "") for edge in spawn_path)
335
+ spawn_edge_ids = [str(edge.get("id") or "") for edge in spawn_path]
336
+ spawn_event_ids = [
337
+ event_id
338
+ for edge in spawn_path
339
+ for event_id in _event_ids(edge, 8)
340
+ ]
341
+ findings.append(
342
+ Finding(
343
+ rule_id="possible-delegated-sensitive-file-to-network-path",
344
+ severity="medium" if relation == "CONNECTED_TO" else "low",
345
+ title=(
346
+ "Sensitive-file access was followed by child-process "
347
+ "external network activity"
348
+ ),
349
+ summary=(
350
+ f"{source_process} accessed {file_target}, then a causal SPAWNED "
351
+ f"chain reached {descendant}, which later produced {relation} "
352
+ f"evidence for {net_target}. Process lineage is proven, but "
353
+ "ExecWeave has not proven data inheritance, IPC, or exfiltration."
354
+ ),
355
+ node_ids=[source_process, file_target, *process_chain[1:], net_target],
356
+ edge_ids=[
357
+ str(file_edge.get("id") or ""),
358
+ *spawn_edge_ids,
359
+ str(net_edge.get("id") or ""),
360
+ ],
361
+ evidence_event_ids=[
362
+ *_event_ids(file_edge, 8),
363
+ *spawn_event_ids,
364
+ *_event_ids(net_edge, 8),
365
+ ],
366
+ attributes={
367
+ "delegation_hops": len(spawn_path),
368
+ "process_chain": process_chain,
369
+ "file_last_sequence": file_last,
370
+ "spawn_sequences": [
371
+ _edge_sequence(edge, "first_sequence")
372
+ for edge in spawn_path
373
+ ],
374
+ "network_first_sequence": net_first,
375
+ "causal_process_lineage": True,
376
+ "data_inheritance_proven": False,
377
+ "ipc_proven": False,
378
+ "data_flow_proven": False,
379
+ "exfiltration_proven": False,
380
+ },
381
+ )
382
+ )
383
+
384
+ findings.sort(
385
+ key=lambda finding: (
386
+ _SEVERITY_ORDER.get(finding.severity, 99),
387
+ finding.rule_id,
388
+ finding.node_ids,
389
+ )
390
+ )
391
+ counts = Counter(finding.severity for finding in findings)
392
+ return {
393
+ "analysis_schema_version": "0.2",
394
+ "session_id": graph.get("session_id"),
395
+ "finding_count": len(findings),
396
+ "severity_counts": {
397
+ severity: counts.get(severity, 0) for severity in ("high", "medium", "low", "info")
398
+ },
399
+ "limitations": [
400
+ "Findings are rule-based prioritization signals, not proof of malicious intent.",
401
+ "Sensitive-file-to-network findings do not prove byte-level data flow or exfiltration.",
402
+ "SPAWNED lineage does not prove data inheritance, IPC, or taint propagation.",
403
+ "Collector coverage and attribution strength depend on the backend.",
404
+ ],
405
+ "findings": [finding.to_dict() for finding in findings],
406
+ }
execweave/backends.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from .collector import RuntimeCollector
8
+ from .sink import JsonlSink
9
+ from .strace_backend import StraceRuntimeCollector, strace_available
10
+
11
+ BackendName = Literal["auto", "portable", "strace"]
12
+
13
+
14
+ def resolve_backend(requested: BackendName) -> str:
15
+ if requested == "portable":
16
+ return "portable"
17
+ if requested == "strace":
18
+ if not strace_available():
19
+ raise RuntimeError("strace backend requested, but Linux strace is not available")
20
+ return "strace"
21
+ if requested != "auto":
22
+ raise ValueError(f"unknown backend: {requested}")
23
+ return "strace" if strace_available() else "portable"
24
+
25
+
26
+ def create_collector(
27
+ *,
28
+ backend: BackendName,
29
+ session_id: str,
30
+ sink: JsonlSink,
31
+ watch_root: Path,
32
+ poll_interval: float,
33
+ collect_filesystem: bool,
34
+ collect_network: bool,
35
+ keep_raw_trace: bool = False,
36
+ ):
37
+ resolved = resolve_backend(backend)
38
+ if resolved == "strace":
39
+ return StraceRuntimeCollector(
40
+ session_id=session_id,
41
+ sink=sink,
42
+ watch_root=watch_root,
43
+ collect_filesystem=collect_filesystem,
44
+ collect_network=collect_network,
45
+ keep_raw_trace=keep_raw_trace,
46
+ )
47
+ return RuntimeCollector(
48
+ session_id=session_id,
49
+ sink=sink,
50
+ watch_root=watch_root,
51
+ poll_interval=poll_interval,
52
+ collect_filesystem=collect_filesystem,
53
+ collect_network=collect_network,
54
+ )
55
+
56
+
57
+ def backend_diagnostics() -> dict[str, object]:
58
+ return {
59
+ "platform": sys.platform,
60
+ "portable": True,
61
+ "strace": strace_available(),
62
+ "auto_selected": resolve_backend("auto"),
63
+ }
execweave/benchmark.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import statistics
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+ from uuid import uuid4
11
+
12
+ from .backends import BackendName, create_collector, resolve_backend
13
+ from .sink import JsonlSink
14
+
15
+
16
+ def _workload_command() -> list[str]:
17
+ code = (
18
+ "from pathlib import Path; "
19
+ "p=Path('execweave-bench.tmp'); "
20
+ "p.write_text('x'*4096, encoding='utf-8'); "
21
+ "p.read_text(encoding='utf-8'); "
22
+ "p.unlink()"
23
+ )
24
+ return [sys.executable, "-c", code]
25
+
26
+
27
+ def run_benchmark(
28
+ *, backend: BackendName = "auto", iterations: int = 5
29
+ ) -> dict[str, object]:
30
+ if iterations < 1:
31
+ raise ValueError("iterations must be >= 1")
32
+ resolved = resolve_backend(backend)
33
+ baseline: list[float] = []
34
+ instrumented: list[float] = []
35
+ with tempfile.TemporaryDirectory(prefix="execweave-benchmark-") as temp:
36
+ root = Path(temp)
37
+ command = _workload_command()
38
+ for _ in range(iterations):
39
+ start = time.perf_counter()
40
+ subprocess.run(
41
+ command,
42
+ cwd=root,
43
+ check=True,
44
+ stdout=subprocess.DEVNULL,
45
+ stderr=subprocess.DEVNULL,
46
+ )
47
+ baseline.append(time.perf_counter() - start)
48
+ for _ in range(iterations):
49
+ session_id = uuid4().hex
50
+ sink = JsonlSink(root / ".execweave" / "runs" / f"{session_id}.jsonl")
51
+ collector = create_collector(
52
+ backend=backend,
53
+ session_id=session_id,
54
+ sink=sink,
55
+ watch_root=root,
56
+ poll_interval=0.05,
57
+ collect_filesystem=True,
58
+ collect_network=False,
59
+ )
60
+ start = time.perf_counter()
61
+ rc = collector.run(command)
62
+ if rc != 0:
63
+ raise RuntimeError(f"benchmark workload failed with exit code {rc}")
64
+ instrumented.append(time.perf_counter() - start)
65
+ base_median = statistics.median(baseline)
66
+ instrumented_median = statistics.median(instrumented)
67
+ ratio = instrumented_median / base_median if base_median else None
68
+ return {
69
+ "backend": resolved,
70
+ "iterations": iterations,
71
+ "baseline_seconds": baseline,
72
+ "instrumented_seconds": instrumented,
73
+ "baseline_median_seconds": base_median,
74
+ "instrumented_median_seconds": instrumented_median,
75
+ "overhead_ratio": ratio,
76
+ }
77
+
78
+
79
+ def format_benchmark(result: dict[str, object]) -> str:
80
+ return json.dumps(result, indent=2, sort_keys=True)