blob: 214b688a4364f52e2eefb1bbede4ecf4c0b4fe24 [file] [log] [blame] [edit]
#!/usr/bin/env python3
# Copyright 2022 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import os
import sys
import json
import subprocess
import tempfile
from dataclasses import dataclass
from typing import List
@dataclass
class Fragment:
"""PlaSA Fragment metadata.
All fields in this data class (save for the "cts_path" field) is pulled
directly from the PlaSA manifest file.
"cts_path" points to the location of this fragment file on the host
machine.
"""
file: str
kind: str
path: str
dest: str
cts_path: str
@dataclass
class Match:
"""Data class used to match corresponding fragments between manifest files.
This also stores match result information in the form of an error message
and a compatibility status bit.
"""
before: Fragment
after: Fragment
message: str
is_compatible: bool
def get_message(self):
"""Small helper method to make the output more useful info for errors.
Returns:
A string representing the Fragment key and match error message.
"""
return '{}\n{}'.format(self.before.path, self.message)
@dataclass
class PlasaDiffer:
"""Class that actually performs the PlaSA manifest diffing operation.
Throws an exception if a compatibility issue is detected, or some other
processing error occurs.
"""
before_manifest: str
after_manifest: str
kinds: List[str]
utils_dir: str
def __post_init__(self):
if not self.before_manifest:
raise ('before manifest must not be empty.')
if not self.after_manifest:
raise ('after manifest must not be empty.')
if self.kinds == None or len(self.kinds) == 0:
raise ValueError('kinds list cannot be None or empty.')
if not os.path.exists(self.utils_dir):
raise ValueError('utils_dir path cannot be empty and must exist.')
def load_manifest(self, manifest):
"""Load the PlaSA Manifest JSON file into a dictionary.
The 'path' field of each fragment acts as a unique key, and
allow us to map to the same fragment in other PlaSA manifest files.
Returns:
A dict mapping the fragment 'path' field to the fragment object.
"""
fragments_root = os.path.dirname(manifest)
d = {}
with open(manifest) as f:
data = json.load(f)
for entry in data:
# We may want to run this script in separate processes to allow
# parallel verification. Filter out fragment types that we
# don't care about in this run.
if entry['kind'] in self.kinds:
cts_path = os.path.join(fragments_root, entry['path'])
if not os.path.exists(cts_path):
raise ValueError(
'Fragment path {} does not exist'.format(cts_path))
entry['cts_path'] = cts_path
d[entry['path']] = Fragment(**entry)
return d
def diff_fragments(self, before, after):
"""Diff two fragment files against each other.
Returns:
A string message containing any useful output of the diff tool.
A boolean value: True if the two fragment interfaces are compatible.
"""
if before.kind != after.kind:
return 'Left fragment kind ({}) does not match Right fragment kind ({}).'.format(
before.kind, after.kind), False
if before.kind == 'api_fidl':
return self._diff_fidl(before, after)
else:
return 'Diffing for fragments of type {} is not yet supported.'.format(
before.kind), False
def _diff_fidl(self, before, after):
"""Diff two api_fidl fragment files against each other.
The above method calls this method to diff FIDL fragment files.
"""
tool = os.path.join(self.utils_dir, 'fidl_api_diff')
out_file = tempfile.NamedTemporaryFile()
out_file.close()
args = [
tool,
'--before-file',
before.cts_path,
'--after-file',
after.cts_path,
'--api-diff-file',
out_file.name,
]
try:
p = subprocess.run(args, check=True, capture_output=True, text=True)
# TODO(jcecil): Make fidl_api_diff throw an exception
# when API breaking changes are detected.
with open(out_file.name, 'r') as result:
data = result.read()
result.close()
if 'APIBreaking' in data:
return data, False
return data, True
except subprocess.CalledProcessError as e:
message = 'Return Code {}'.format(e.returncode)
if e.output:
message += "\n{}".format(e.output)
if e.stdout:
message += "\n{}".format(e.stdout)
if e.stderr:
message += "\n{}".format(e.stderr)
return message, False
def run(self):
"""Diff two PlaSA manifest files.
Raises:
RuntimeError if incompatible changes are detected.
"""
before_fragments = self.load_manifest(self.before_manifest)
after_fragments = self.load_manifest(self.after_manifest)
matches = {}
# Match fragments from the before and after PlaSA manifest files,
# and diff them using the provided tools.
# Delete them from the dictionaries so we can detect discrepancies.
for key, before in list(before_fragments.items()):
del before_fragments[key]
is_compatible = False
message = ""
try:
after = after_fragments[key]
del after_fragments[key]
message, is_compatible = self.diff_fragments(before, after)
except KeyError:
after = None
message = "PlaSA element was deleted."
is_compatible = False
finally:
matches[key] = Match(before, after, message, is_compatible)
# PlaSA elements that are newly introduced in the after manifest
# are identified and captured here.
for key, after in list(after_fragments.items()):
del after_fragments[key]
before = None
message = "PlaSA element was added."
# TODO(jcecil): Consider flagging new additions to the PlaSA
# manifest here, to ensure they have sufficient CTS test coverage.
is_compatible = True
matches[key] = Match(before, after, message, is_compatible)
issues = ""
found_issue = False
for m in matches.values():
if not m.is_compatible:
issues = '{}\n-----\n{}'.format(issues, m.get_message())
found_issue = True
if found_issue:
raise RuntimeError(
'Found compatibility issue(s):\n\n{}'.format(issues))
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
'--before_manifest',
help='Path to the old PlaSA manifest file, from the CTS release.',
required=True)
parser.add_argument(
'--after_manifest',
help='Path to the new PlaSA manifest file, from an SDK release.',
required=True)
parser.add_argument(
'--kinds',
nargs='+',
help='Type of PlaSA Fragments to diff.',
required=True)
parser.add_argument(
'--utils_dir',
help='Path to directory holding all the diffing tools.',
required=True)
args = parser.parse_args()
pd = PlasaDiffer(**vars(args))
pd.run()
if __name__ == "__main__":
sys.exit(main())