mirror of
https://github.com/revng/revng
synced 2026-06-21 14:07:57 +00:00
b9726330f6
* Rename from compare-json.py to compare-yaml * Handle YAML * Support certain characteristics of the model * Use subgraph matching to perform comparisons * Introduce some testing * Drop Python 2 support * Reformat
343 lines
11 KiB
Python
Executable File
343 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# This script compares two YAML files. In general, the reference YAML file is
|
|
# supposed to be included in the other one. For instance, {"a": 1, "b": 2}
|
|
# contains {"b": 2}. This is useful for enforcing the content of certain parts
|
|
# of a YAML file, in particular during testing.
|
|
|
|
# TODO: we need to have more reliable way to detect and dereference references
|
|
# and ignore "fragile" keys.
|
|
# TODO: we need to use an optional schema to know what lists are actually sets
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
import yaml
|
|
|
|
from networkx import DiGraph
|
|
from networkx.algorithms.isomorphism import DiGraphMatcher
|
|
|
|
args = None
|
|
|
|
fragile_keys = set(["ID"])
|
|
|
|
def log(message):
|
|
sys.stderr.write(message + "\n")
|
|
|
|
def is_reference(str):
|
|
return str.startswith("/Types/")
|
|
|
|
def dereference(root, str):
|
|
assert is_reference(str)
|
|
match = re.match(r"/Types/.*-([^-]*)", str)
|
|
|
|
if match:
|
|
type_id = int(match.groups()[0])
|
|
for item in root["Types"]:
|
|
if item["ID"] == type_id:
|
|
return item
|
|
|
|
return None
|
|
|
|
class Tag:
|
|
def __init__(self, tag):
|
|
self.tag = tag
|
|
def __eq__(self, other):
|
|
return self.tag == other.tag
|
|
|
|
class YAMLMatcher(DiGraphMatcher):
|
|
def __init__(self, input_graph: "YAMLGraph",
|
|
reference_graph: "YAMLGraph",
|
|
exact):
|
|
super().__init__(input_graph.graph, reference_graph.graph)
|
|
self.input_graph = input_graph
|
|
self.reference_graph = reference_graph
|
|
self.exact = exact
|
|
|
|
def semantic_feasibility(self, input_id, reference_id):
|
|
input_object = self.input_graph.node_map[input_id]
|
|
input_type = type(input_object)
|
|
|
|
reference_object = self.reference_graph.node_map[reference_id]
|
|
reference_type = type(reference_object)
|
|
|
|
same_type = reference_type is input_type
|
|
|
|
if same_type:
|
|
if input_type is dict:
|
|
input_object = YAMLGraph.filter(input_object)
|
|
reference_object = YAMLGraph.filter(reference_object)
|
|
|
|
if self.exact:
|
|
return input_object == reference_object
|
|
|
|
for key, value in reference_object.items():
|
|
if not key in input_object or input_object[key] != value:
|
|
return False
|
|
|
|
return True
|
|
elif input_type is list:
|
|
return True
|
|
else:
|
|
return input_object == reference_object
|
|
else:
|
|
return False
|
|
|
|
class YAMLGraph:
|
|
def __init__(self, root):
|
|
self.graph = DiGraph()
|
|
self.node_map = {}
|
|
self.root = root
|
|
self.visit_object(root)
|
|
self.node_colors = {}
|
|
|
|
def add_node(self, object):
|
|
object_id = id(object)
|
|
self.node_map[object_id] = object
|
|
self.graph.add_node(object_id)
|
|
|
|
def add_edge(self, source_id, destination_id, label):
|
|
label_object = Tag(label)
|
|
label_id = id(label_object)
|
|
self.add_node(label_object)
|
|
self.graph.add_edge(source_id, label_id)
|
|
self.graph.add_edge(label_id, destination_id)
|
|
|
|
def visit_object(self, object):
|
|
object_type = type(object)
|
|
object_id = id(object)
|
|
|
|
if object_id in self.node_map:
|
|
return
|
|
|
|
self.add_node(object)
|
|
|
|
if object_type is list:
|
|
for index, item in enumerate(object):
|
|
self.visit_object(item)
|
|
if args.exact:
|
|
self.add_edge(object_id, id(item), index)
|
|
else:
|
|
self.add_edge(object_id, id(item), "")
|
|
elif object_type is dict:
|
|
for key, value in object.items():
|
|
value_type = type(value)
|
|
if value_type is dict or value_type is list:
|
|
self.visit_object(value)
|
|
self.add_edge(object_id, id(value), key)
|
|
if value_type is str and is_reference(value):
|
|
item = dereference(self.root, value)
|
|
if item is not None:
|
|
self.visit_object(item)
|
|
self.add_edge(object_id, id(item), key)
|
|
|
|
@staticmethod
|
|
def filter(object):
|
|
assert type(object) is dict
|
|
filtered_dict = dict(object)
|
|
to_remove = []
|
|
for key, value in filtered_dict.items():
|
|
value_type = type(value)
|
|
if (key in fragile_keys
|
|
or (value_type is list
|
|
or value_type is dict
|
|
or value_type is str and is_reference(value))):
|
|
to_remove.append(key)
|
|
|
|
for key in to_remove:
|
|
del filtered_dict[key]
|
|
|
|
return filtered_dict
|
|
|
|
@staticmethod
|
|
def get_label(object):
|
|
object_type = type(object)
|
|
if object_type is list:
|
|
return f"List[{len(object)}]"
|
|
elif object_type is Tag:
|
|
return str(object.tag)
|
|
elif object_type is dict:
|
|
return yaml.dump(YAMLGraph.filter(object))
|
|
else:
|
|
return str(object)
|
|
|
|
@staticmethod
|
|
def escape(data):
|
|
return data.replace('"', '\\"').replace("\n", "\\l")
|
|
|
|
def write(self, path):
|
|
with open(path, "w") as output_file:
|
|
# Emit header
|
|
output_file.write("digraph {\n")
|
|
output_file.write(" node [shape=box];\n")
|
|
|
|
# Emit nodes
|
|
for object_id in self.graph.nodes:
|
|
object = self.node_map[object_id]
|
|
if type(object) is Tag:
|
|
source_id = list(self.graph.predecessors(object_id))[0]
|
|
destination_id = list(self.graph.successors(object_id))[0]
|
|
label = self.escape(self.get_label(object.tag))
|
|
output_file.write(f""" n{source_id} ->""")
|
|
output_file.write(f""" n{destination_id}""")
|
|
output_file.write(f""" [label="{label}"];\n""")
|
|
else:
|
|
extra = ""
|
|
if object_id in self.node_colors:
|
|
extra += f',style=filled'
|
|
extra += f',fillcolor="{self.node_colors[object_id]}"'
|
|
label = self.escape(self.get_label(object))
|
|
output_file.write(f""" n{object_id}""")
|
|
output_file.write(f""" [label="{label}"{extra}];\n""")
|
|
|
|
# Emit footer
|
|
output_file.write("}\n")
|
|
|
|
def color_match(self, other, matcher):
|
|
colors = [
|
|
"#ff5f7e", "#4f7d9d", "#8873b3", "#d273b7",
|
|
"#f672a9", "#fb7f8c", "#ff7c43", "#ffa600"
|
|
]
|
|
for match in matcher.match():
|
|
for index, (key, value) in enumerate(match.items()):
|
|
color = colors[index % len(colors)]
|
|
self.node_colors[value] = color
|
|
other.node_colors[key] = color
|
|
return
|
|
|
|
def is_subgraph(self, other, color=False):
|
|
matcher = YAMLMatcher(other, self)
|
|
result = matcher.subgraph_is_isomorphic()
|
|
|
|
if result and color:
|
|
self.color_match(other, match)
|
|
|
|
return result
|
|
|
|
def is_subgraph(self, other, color=False):
|
|
matcher = YAMLMatcher(other, self, False)
|
|
result = matcher.subgraph_is_isomorphic()
|
|
|
|
if result and color:
|
|
self.color_match(other, matcher)
|
|
|
|
return result
|
|
|
|
def is_equal(self, other, color=False):
|
|
matcher = YAMLMatcher(other, self, True)
|
|
result = matcher.is_isomorphic()
|
|
|
|
if result and color:
|
|
self.color_match(match)
|
|
|
|
return result
|
|
|
|
def selftest():
|
|
# Test --exact
|
|
args.exact = True
|
|
def test(input, reference):
|
|
return YAMLGraph(reference).is_equal(YAMLGraph(input))
|
|
assert test({}, {})
|
|
assert test(3, 3)
|
|
assert not test(2, 3)
|
|
assert test([3], [3])
|
|
assert not test([2], [3])
|
|
assert not test([], {})
|
|
assert test({"a": 2}, {"a": 2})
|
|
assert not test({"a": 2}, {})
|
|
assert not test({"a": 2, "b": 3}, {"b": 2, "a": 3})
|
|
assert test([1,2,3], [1,2,3])
|
|
assert not test([1,3,2], [1,2,3])
|
|
|
|
# Test approximate for inclusion
|
|
args.exact = False
|
|
def test(input, reference):
|
|
return YAMLGraph(reference).is_subgraph(YAMLGraph(input))
|
|
assert test({}, {})
|
|
assert not test({}, {"a": 2})
|
|
assert test([3], [3])
|
|
assert not test([2], [3])
|
|
assert not test([], {})
|
|
assert test({"a": 2}, {"a": 2})
|
|
assert test({"a": 2}, {})
|
|
assert test({"a": 2, "b": 3}, {"b": 3, "a": 2})
|
|
assert test([1,2,3], [1,2,3])
|
|
assert test([1,3,2], [1,2,3])
|
|
|
|
# Test references
|
|
reference = {
|
|
"a": "/Types/Type-1",
|
|
"Types": [{"ID": 1, "b": 3}]
|
|
}
|
|
assert test({
|
|
"a": "/Types/Type-2",
|
|
"Types": [{"ID": 1, "b": 5}, {"ID": 2, "b": 3}]
|
|
},
|
|
reference)
|
|
assert not test({
|
|
"a": "/Types/Type-2",
|
|
"Types": [{"ID": 1, "b": 5}, {"ID": 2, "b": 4}]
|
|
}, reference)
|
|
|
|
return 0
|
|
|
|
class SafeLoaderIgnoreUnknown(yaml.SafeLoader):
|
|
def ignore_unknown(self, node):
|
|
return self.construct_mapping(node)
|
|
|
|
SafeLoaderIgnoreUnknown.add_constructor(None,
|
|
SafeLoaderIgnoreUnknown.ignore_unknown)
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Compare a YAML file against "
|
|
"a reference.")
|
|
parser.add_argument("input",
|
|
metavar="INPUT",
|
|
help="The input file.")
|
|
parser.add_argument("reference",
|
|
metavar="REFERENCE",
|
|
help="The reference file.")
|
|
parser.add_argument("--exact",
|
|
action="store_true",
|
|
help=("Match exactly, containing the reference is not "
|
|
+ "enough."))
|
|
parser.add_argument("--not",
|
|
action="store_true",
|
|
help="If it matches, return an error.")
|
|
parser.add_argument("--dump-graphs",
|
|
action="store_true",
|
|
help="Dump INPUT.dot and REFERENCE.dot.")
|
|
parser.add_argument("--selftest",
|
|
action="store_true",
|
|
help="Run internal tests.")
|
|
|
|
global args
|
|
args = parser.parse_args()
|
|
|
|
if args.selftest:
|
|
return selftest()
|
|
|
|
with open(args.reference) as reference_file, open(args.input) as input_file:
|
|
reference = yaml.load(reference_file, Loader=SafeLoaderIgnoreUnknown)
|
|
input = yaml.load(input_file, Loader=SafeLoaderIgnoreUnknown)
|
|
|
|
reference_graph = YAMLGraph(reference)
|
|
input_graph = YAMLGraph(input)
|
|
|
|
if args.exact:
|
|
result = reference_graph.is_equal(input_graph, args.dump_graphs)
|
|
else:
|
|
result = reference_graph.is_subgraph(input_graph, args.dump_graphs)
|
|
|
|
if args.dump_graphs:
|
|
reference_graph.write(f"{args.reference}.dot")
|
|
input_graph.write(f"{args.input}.dot")
|
|
|
|
if args.__dict__["not"]:
|
|
result = not result
|
|
|
|
return 0 if result else 1
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|