Files
revng-revng/python/scripts/revng-model-compare
2022-02-23 18:02:42 +01:00

387 lines
12 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 collections import defaultdict
from grandiso import find_motifs
from networkx import DiGraph
from networkx.algorithms.shortest_paths.unweighted import all_pairs_shortest_path_length
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
def __str__(self):
return f"tag: {self.tag}"
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 = []
references = []
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):
to_remove.append(key)
if value_type is str and is_reference(value):
references.append(key)
for key in to_remove:
del filtered_dict[key]
for key in references:
filtered_dict[key] = "reference"
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, match):
colors = [
"#ff5f7e",
"#4f7d9d",
"#8873b3",
"#d273b7",
"#f672a9",
"#fb7f8c",
"#ff7c43",
"#ffa600",
]
for index, (reference_id, input_id) in enumerate(match.items()):
color = colors[index % len(colors)]
self.node_colors[reference_id] = color
other.node_colors[input_id] = color
def semantic_feasibility(self, other, exact, input_id, reference_id):
input_object = other.node_map[input_id]
input_type = type(input_object)
reference_object = self.node_map[reference_id]
reference_type = type(reference_object)
if reference_type is not input_type:
return False
if input_type is dict:
input_object = YAMLGraph.filter(input_object)
reference_object = YAMLGraph.filter(reference_object)
if exact:
return input_object == reference_object
for key, value in reference_object.items():
if key.startswith("-"):
if key[1:] in input_object:
# The key starts with `-` but it's present in
# input_object
return False
elif not key in input_object or input_object[key] != value:
# Object entries do not match!
return False
return True
elif input_type is list:
return True
else:
return input_object == reference_object
def is_subgraph(self, other, color):
return self.compare(other, False, color)
def is_equal(self, other, color):
return self.compare(other, True, color)
def compare(self, other, exact, color):
def attr_match(motif_node_id: str, host_node_id: str, motif, host) -> bool:
return self.semantic_feasibility(other, exact, host_node_id, motif_node_id)
# Compute (un)interestingness
interestingness = defaultdict(lambda: 2)
shortest_paths = dict(all_pairs_shortest_path_length(self.graph))
for node_id in self.graph:
# TODO: here we are hardcoding `CustomName`, we need to take
# the time to write a small function computing how
# much disambiguation power a certain reference node
# has based on how many compatible nodes are there in
# the input graph, just looking at the semantic.
has_customname = (
type(self.node_map[node_id]) is dict and "CustomName" in self.node_map[node_id]
)
if self.graph.in_degree(node_id) == 0:
# No incoming edges, it's the entry node
interestingness[node_id] = 1
elif has_customname:
# Those with CustomName take priority
interestingness[node_id] = 2
# Distribute points depending on the distance
for source, data in shortest_paths.items():
if source == node_id:
for destination, distance in data.items():
interestingness[destination] += distance
# Rescale all values between 0 and 1 and turn uninterestigness into
# interestingness
max_value = max(interestingness.values())
for node_id in interestingness:
interestingness[node_id] = interestingness[node_id] / max_value
result = find_motifs(
self.graph,
other.graph,
limit=2,
interestingness=interestingness,
isomorphisms_only=exact,
is_node_attr_match=attr_match,
)
if result and color:
self.color_match(other, result[0])
return len(result) > 0
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 open_argument(path):
if path == "-":
return sys.stdin
else:
return open(path)
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_argument(args.reference) as reference_file, open_argument(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())