dotdiff 0.2.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.
- dotdiff/__init__.py +1 -0
- dotdiff/all_diffs.py +37 -0
- dotdiff/diff_generator.py +199 -0
- dotdiff-0.2.0.dist-info/METADATA +25 -0
- dotdiff-0.2.0.dist-info/RECORD +9 -0
- dotdiff-0.2.0.dist-info/WHEEL +5 -0
- dotdiff-0.2.0.dist-info/entry_points.txt +2 -0
- dotdiff-0.2.0.dist-info/licenses/LICENSE +11 -0
- dotdiff-0.2.0.dist-info/top_level.txt +1 -0
dotdiff/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# dotdiff/__init__.py
|
dotdiff/all_diffs.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from git import Repo
|
|
5
|
+
from dotdiff.diff_generator import run_pipeline
|
|
6
|
+
|
|
7
|
+
def get_changed_dot_files(repo_path, commit1="HEAD^", commit2="HEAD"):
|
|
8
|
+
cmd = ["git", "-C", repo_path, "diff", "--name-only", f"{commit1}", f"{commit2}"]
|
|
9
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
|
|
10
|
+
files = result.stdout.strip().split('\n')
|
|
11
|
+
return [f for f in files if f.endswith(".dot")]
|
|
12
|
+
|
|
13
|
+
def run_on_all_changed_dot_files(repo_path, output_dir="dot_diffs", commit1="HEAD^", commit2="HEAD"):
|
|
14
|
+
changed_files = get_changed_dot_files(repo_path, commit1, commit2)
|
|
15
|
+
|
|
16
|
+
if not changed_files or changed_files == ['']:
|
|
17
|
+
print("[✓] No .dot files changed between commits.")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
print(f"[i] Changed .dot files: {changed_files}")
|
|
21
|
+
|
|
22
|
+
for path in changed_files:
|
|
23
|
+
safe_name = path.replace("/", "_").replace("\\", "_")
|
|
24
|
+
output_subdir = os.path.join(output_dir, safe_name)
|
|
25
|
+
print(f"[→] Running diff for {path}")
|
|
26
|
+
run_pipeline(repo_path, path, output_dir=output_subdir, commit_old=commit1, commit_new=commit2)
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
if len(sys.argv) < 2:
|
|
30
|
+
print("Usage: dotdiff-all <repo_path> [<commit1> <commit2>]")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
repo_path = sys.argv[1]
|
|
34
|
+
commit1 = sys.argv[2] if len(sys.argv) > 2 else "HEAD^"
|
|
35
|
+
commit2 = sys.argv[3] if len(sys.argv) > 3 else "HEAD"
|
|
36
|
+
|
|
37
|
+
run_on_all_changed_dot_files(repo_path, commit1=commit1, commit2=commit2)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import pydot
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from git import Repo
|
|
7
|
+
|
|
8
|
+
def parse_dot_content(dot_str):
|
|
9
|
+
graphs = pydot.graph_from_dot_data(dot_str)
|
|
10
|
+
return graphs[0] if graphs else None
|
|
11
|
+
|
|
12
|
+
def normalize_graph(graph):
|
|
13
|
+
nodes = sorted((n.get_name(), dict(n.get_attributes())) for n in graph.get_nodes() if n.get_name() not in ('node', 'graph', 'edge'))
|
|
14
|
+
edges = sorted((e.get_source(), e.get_destination(), dict(e.get_attributes())) for e in graph.get_edges())
|
|
15
|
+
return nodes, edges
|
|
16
|
+
|
|
17
|
+
def diff_graphs(nodes_old, edges_old, nodes_new, edges_new):
|
|
18
|
+
node_names_old = {n[0] for n in nodes_old}
|
|
19
|
+
node_names_new = {n[0] for n in nodes_new}
|
|
20
|
+
|
|
21
|
+
added_nodes = [n for n in nodes_new if n[0] not in node_names_old]
|
|
22
|
+
removed_nodes = [n for n in nodes_old if n[0] not in node_names_new]
|
|
23
|
+
|
|
24
|
+
changed_node_attrs = [
|
|
25
|
+
(n_new[0], n_old[1], n_new[1])
|
|
26
|
+
for n_old in nodes_old
|
|
27
|
+
for n_new in nodes_new
|
|
28
|
+
if n_old[0] == n_new[0] and n_old[1] != n_new[1]
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
added_edges = [e for e in edges_new if e not in edges_old]
|
|
32
|
+
removed_edges = [e for e in edges_old if e not in edges_new]
|
|
33
|
+
|
|
34
|
+
changed_edge_attrs = [
|
|
35
|
+
(e_new[0], e_new[1], e_old[2], e_new[2])
|
|
36
|
+
for e_old in edges_old
|
|
37
|
+
for e_new in edges_new
|
|
38
|
+
if e_old[0:2] == e_new[0:2] and e_old[2] != e_new[2]
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"added_nodes": added_nodes,
|
|
43
|
+
"removed_nodes": removed_nodes,
|
|
44
|
+
"changed_node_attrs": changed_node_attrs,
|
|
45
|
+
"added_edges": added_edges,
|
|
46
|
+
"removed_edges": removed_edges,
|
|
47
|
+
"changed_edge_attrs": changed_edge_attrs
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def create_diff_visualization_full(current_graph, diff, output_path="dot_diff_full_visual.png"):
|
|
51
|
+
graph = pydot.Dot(graph_type=current_graph.get_type())
|
|
52
|
+
|
|
53
|
+
# Keep track of what's already added to avoid duplicates
|
|
54
|
+
added_node_names = set()
|
|
55
|
+
added_edge_keys = set()
|
|
56
|
+
|
|
57
|
+
# Add current nodes and highlight additions/changes
|
|
58
|
+
for node in current_graph.get_nodes():
|
|
59
|
+
name = node.get_name()
|
|
60
|
+
if name in ("node", "graph", "edge"):
|
|
61
|
+
continue
|
|
62
|
+
attrs = node.get_attributes()
|
|
63
|
+
style = {"style": "filled"}
|
|
64
|
+
|
|
65
|
+
# Color based on diff
|
|
66
|
+
if any(n[0] == name for n in diff['added_nodes']):
|
|
67
|
+
style["fillcolor"] = "green"
|
|
68
|
+
elif any(n[0] == name for n in diff['changed_node_attrs']):
|
|
69
|
+
style["fillcolor"] = "yellow"
|
|
70
|
+
else:
|
|
71
|
+
style["fillcolor"] = "white"
|
|
72
|
+
|
|
73
|
+
graph.add_node(pydot.Node(name, **attrs, **style))
|
|
74
|
+
added_node_names.add(name)
|
|
75
|
+
|
|
76
|
+
# Add deleted nodes (from diff)
|
|
77
|
+
for name, attrs in diff['removed_nodes']:
|
|
78
|
+
if name not in added_node_names:
|
|
79
|
+
style = {"style": "filled,dashed", "fillcolor": "red"}
|
|
80
|
+
graph.add_node(pydot.Node(name, **attrs, **style))
|
|
81
|
+
added_node_names.add(name)
|
|
82
|
+
|
|
83
|
+
# Add current edges and highlight additions/changes
|
|
84
|
+
for edge in current_graph.get_edges():
|
|
85
|
+
src = edge.get_source()
|
|
86
|
+
dst = edge.get_destination()
|
|
87
|
+
attrs = edge.get_attributes()
|
|
88
|
+
edge_key = (src, dst)
|
|
89
|
+
color = "black"
|
|
90
|
+
|
|
91
|
+
if edge_key in [(e[0], e[1]) for e in diff['added_edges']]:
|
|
92
|
+
color = "green"
|
|
93
|
+
elif edge_key in [(e[0], e[1]) for e in diff['changed_edge_attrs']]:
|
|
94
|
+
color = "yellow"
|
|
95
|
+
|
|
96
|
+
graph.add_edge(pydot.Edge(src, dst, color=color, **attrs))
|
|
97
|
+
added_edge_keys.add(edge_key)
|
|
98
|
+
|
|
99
|
+
# Add deleted edges
|
|
100
|
+
for src, dst, attrs in diff['removed_edges']:
|
|
101
|
+
edge_key = (src, dst)
|
|
102
|
+
if edge_key not in added_edge_keys:
|
|
103
|
+
graph.add_edge(pydot.Edge(src, dst, color="red", style="dashed", **attrs))
|
|
104
|
+
added_edge_keys.add(edge_key)
|
|
105
|
+
|
|
106
|
+
graph.write_png(output_path)
|
|
107
|
+
print(f"[✓] Full diff visualization (with deletions) saved to: {output_path}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_file_from_commit(repo, commit_hash, file_path):
|
|
111
|
+
try:
|
|
112
|
+
blob = repo.commit(commit_hash).tree / file_path
|
|
113
|
+
return blob.data_stream.read().decode()
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"[!] Error extracting {file_path} from {commit_hash}: {e}")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def run_pipeline(repo_path, dot_file_path, output_dir=".", commit_old="HEAD^", commit_new="HEAD"):
|
|
119
|
+
repo = Repo(repo_path)
|
|
120
|
+
|
|
121
|
+
dot_old = extract_file_from_commit(repo, commit_old, dot_file_path)
|
|
122
|
+
dot_new = extract_file_from_commit(repo, commit_new, dot_file_path)
|
|
123
|
+
|
|
124
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
125
|
+
visual_path = os.path.join(output_dir, "dot_diff_full_visual.png")
|
|
126
|
+
|
|
127
|
+
# Case 1: New file added
|
|
128
|
+
if not dot_old and dot_new:
|
|
129
|
+
print(f"[+] {dot_file_path} was added.")
|
|
130
|
+
graph_new = parse_dot_content(dot_new)
|
|
131
|
+
if not graph_new:
|
|
132
|
+
print(f"[!] Failed to parse new DOT file.")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
nodes_new, edges_new = normalize_graph(graph_new)
|
|
136
|
+
diff = {
|
|
137
|
+
"added_nodes": nodes_new,
|
|
138
|
+
"removed_nodes": [],
|
|
139
|
+
"changed_node_attrs": [],
|
|
140
|
+
"added_edges": edges_new,
|
|
141
|
+
"removed_edges": [],
|
|
142
|
+
"changed_edge_attrs": []
|
|
143
|
+
}
|
|
144
|
+
create_diff_visualization_full(graph_new, diff, output_path=visual_path)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# Case 2: File was deleted
|
|
148
|
+
if dot_old and not dot_new:
|
|
149
|
+
print(f"[-] {dot_file_path} was deleted.")
|
|
150
|
+
graph_old = parse_dot_content(dot_old)
|
|
151
|
+
if not graph_old:
|
|
152
|
+
print(f"[!] Failed to parse old DOT file.")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
nodes_old, edges_old = normalize_graph(graph_old)
|
|
156
|
+
diff = {
|
|
157
|
+
"added_nodes": [],
|
|
158
|
+
"removed_nodes": nodes_old,
|
|
159
|
+
"changed_node_attrs": [],
|
|
160
|
+
"added_edges": [],
|
|
161
|
+
"removed_edges": edges_old,
|
|
162
|
+
"changed_edge_attrs": []
|
|
163
|
+
}
|
|
164
|
+
create_diff_visualization_full(graph_old, diff, output_path=visual_path)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Case 3: Normal comparison
|
|
168
|
+
if dot_old and dot_new:
|
|
169
|
+
graph_old = parse_dot_content(dot_old)
|
|
170
|
+
graph_new = parse_dot_content(dot_new)
|
|
171
|
+
|
|
172
|
+
if not graph_old or not graph_new:
|
|
173
|
+
print("[!] Failed to parse one or both DOT files.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
nodes_old, edges_old = normalize_graph(graph_old)
|
|
177
|
+
nodes_new, edges_new = normalize_graph(graph_new)
|
|
178
|
+
|
|
179
|
+
diff = diff_graphs(nodes_old, edges_old, nodes_new, edges_new)
|
|
180
|
+
|
|
181
|
+
print("[i] Semantic diff JSON:")
|
|
182
|
+
print(json.dumps(diff, indent=2))
|
|
183
|
+
|
|
184
|
+
create_diff_visualization_full(graph_new, diff, output_path=visual_path)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Case 4: File not present in either commit
|
|
188
|
+
print(f"[!] {dot_file_path} not found in either commit.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
if len(sys.argv) != 3:
|
|
193
|
+
print("Usage: python dot_semantic_ci_diff.py <repo_path> <path/to/file.dot>")
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
repo_path = sys.argv[1]
|
|
197
|
+
file_path = sys.argv[2]
|
|
198
|
+
|
|
199
|
+
run_pipeline(repo_path, file_path)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotdiff
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate semantic visual diffs for DOT graph files in Git repos
|
|
5
|
+
Author-email: Austin Song <austinjsong19@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.7
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pydot
|
|
11
|
+
Requires-Dist: GitPython
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# dotdiff
|
|
15
|
+
|
|
16
|
+
A tool to compute semantic differences between `.dot` files in Git repos and generate visual diagrams.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install dotdiff
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
Coming soon...
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dotdiff/__init__.py,sha256=cSqZb_3EOGixp5guExZWJVSShbAkS7GAzxL3bSXqcJc,22
|
|
2
|
+
dotdiff/all_diffs.py,sha256=Tgl4VlDR2AND2REWV11Jlxh2321VzANPdmXh0hsQr7Y,1452
|
|
3
|
+
dotdiff/diff_generator.py,sha256=SyzQeb6Ah6Lu35MDFF8qH-ALVz9kd-7HyTfJkbjFLoo,6831
|
|
4
|
+
dotdiff-0.2.0.dist-info/licenses/LICENSE,sha256=RZ244RdR4HIJ8HKfQOjmBPYTQxoONf9xgmghS-IQT0Y,542
|
|
5
|
+
dotdiff-0.2.0.dist-info/METADATA,sha256=Y95hZCeU1MlvBTsrkHXO2452lw2ZJ20sUOS8zrtL4ok,542
|
|
6
|
+
dotdiff-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
dotdiff-0.2.0.dist-info/entry_points.txt,sha256=1UEQkG0voSaMYGFX2JzddUXfvJcYJsX7Iak1r5qU9Aw,75
|
|
8
|
+
dotdiff-0.2.0.dist-info/top_level.txt,sha256=SyotHTScJ7Pi_OyNmt9W_F-MhOrPXleHAPwKP7lyaec,8
|
|
9
|
+
dotdiff-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
This software is licensed under a "dual license" model.
|
|
2
|
+
|
|
3
|
+
You may choose to use, distribute, and modify this software under the terms of:
|
|
4
|
+
|
|
5
|
+
- The MIT License (see LICENSES/MIT.txt)
|
|
6
|
+
- The GNU General Public License v3.0 (GPL-3.0) (see LICENSES/GPL-3.0.txt)
|
|
7
|
+
- The Apache License, Version 2.0 (see LICENSES/Apache-2.0.txt)
|
|
8
|
+
|
|
9
|
+
You may select the license that best fits your intended use.
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, this software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dotdiff
|