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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dotdiff = dotdiff.all_diffs:run_on_all_changed_dot_files
@@ -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