| # 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 errno |
| import filecmp |
| import os |
| import sys |
| import plasa_differ |
| from enum import Enum |
| |
| |
| class Policy(Enum): |
| # update_golden tells this script to overwrite the existing golden with |
| # the current golden. This is useful for batch updates. |
| update_golden = "update_golden" |
| |
| # ack_changes tells this script to fail if any changes are detected. |
| # It will tell the user how to update the golden to acknowledge the |
| # changes. |
| ack_changes = "ack_changes" |
| |
| # no_breaking_changes tells this script to fail if breaking changes |
| # are detected. If non-breaking changes are detected it will tell |
| # the user how to update the golden to acknowledge the changes. |
| no_breaking_changes = "no_breaking_changes" |
| |
| # no_changes tells this script to fail if any changes are detected. |
| # It will not ask the user to update the golden. |
| no_changes = "no_changes" |
| |
| def __str__(self): |
| return self.value |
| |
| |
| FIDL_VERSIONING_DOCS_URL = ( |
| "https://fuchsia.dev/fuchsia-src/reference/fidl/language/versioning" |
| ) |
| |
| FIDL_AVAILABILITY_HINT = ( |
| f"Are you missing an @available annotation?\n" |
| f"For more information see {FIDL_VERSIONING_DOCS_URL}\n" |
| ) |
| |
| |
| class CompatibilityError(Exception): |
| """Exception raised when breaking API changes are detected""" |
| |
| def __init__(self, api_level, breaking_changes, current, golden): |
| self.api_level = api_level |
| self.breaking_changes = breaking_changes |
| self.current = current |
| self.golden = golden |
| |
| def __str__(self): |
| formatted_breaking_changes = "\n - ".join(self.breaking_changes) |
| return ( |
| f"These changes are incompatible with API level {self.api_level}\n" |
| f"{formatted_breaking_changes}\n\n" |
| f"{FIDL_AVAILABILITY_HINT}" |
| ) |
| |
| |
| class GoldenMismatchError(Exception): |
| """Exception raised when a stale golden file is detected.""" |
| |
| def __init__( |
| self, |
| api_level, |
| current, |
| golden, |
| show_update_hint=False, |
| show_fidl_availability_hint=True, |
| ): |
| self.api_level = api_level |
| self.current = current |
| self.golden = golden |
| self.show_update_hint = show_update_hint |
| self.show_fidl_availability_hint = show_fidl_availability_hint |
| |
| def __str__(self): |
| hints = [] |
| if self.show_fidl_availability_hint: |
| hints.append(FIDL_AVAILABILITY_HINT) |
| |
| if self.show_update_hint: |
| cmd = update_cmd(self.current, self.golden) |
| hints.append( |
| f"Please acknowledge this change by updating the golden.\n" |
| f"To do this, please run:\n" |
| f" {cmd}\n" |
| ) |
| |
| hint_lines = "\n".join(hints) |
| return f"Detected changes to API level {self.api_level}\n" + hint_lines |
| |
| |
| def golden_not_found_error(filename): |
| message = ( |
| f"The golden file {filename} does not exist.\n" |
| f"If this is a new FIDL API you must first create this file.\n" |
| f"To do so, run:\n" |
| f" touch {os.path.abspath(filename)}\n" |
| ) |
| return FileNotFoundError(errno.ENOENT, message, filename) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "--policy", |
| help="How to handle failures", |
| type=Policy, |
| default=Policy.no_breaking_changes, |
| choices=list(Policy), |
| ) |
| parser.add_argument( |
| "--depfile", help="Where to write the depfile", required=True |
| ) |
| parser.add_argument( |
| "--api-level", help="The API level being tested", required=True |
| ) |
| parser.add_argument( |
| "--golden", help="Path to the golden file", required=True |
| ) |
| parser.add_argument( |
| "--current", help="Path to the local file", required=True |
| ) |
| parser.add_argument( |
| "--stamp", help="Path to the victory file", required=True |
| ) |
| parser.add_argument( |
| "--fidl_api_diff_path", |
| help="Path to the fidl_api_diff binary", |
| required=True, |
| ) |
| args = parser.parse_args() |
| |
| dependencies = [args.current, args.golden] |
| |
| if os.path.getsize(args.current) == 0 and not os.path.exists(args.golden): |
| # Skip testing if current is empty and golden is missing. |
| # Also, do not generate such files when updating goldens. |
| |
| # Do not depend on files that do not and should not exist. |
| dependencies.remove(args.golden) |
| |
| err = None |
| elif args.policy == Policy.update_golden: |
| err = update_golden(args) |
| elif not os.path.exists(args.golden): |
| # In all remaining cases, the golden must exist. |
| err = golden_not_found_error(args.golden) |
| elif args.policy == Policy.no_breaking_changes: |
| err = fail_on_breaking_changes(args) |
| elif args.policy == Policy.no_changes: |
| err = fail_on_changes(args) |
| elif args.policy == Policy.ack_changes: |
| err = fail_on_unacknowledged_changes(args) |
| else: |
| raise ValueError("unknown policy: {}".format(args.policy)) |
| |
| with open(args.depfile, "w") as f: |
| f.write("{}: {}\n".format(args.stamp, " ".join(dependencies))) |
| |
| with open(args.stamp, "w") as stamp_file: |
| stamp_file.write("Golden!\n") |
| |
| if not err: |
| return 0 |
| |
| print("\nERROR: ", err) |
| return 1 |
| |
| |
| def update_golden(args): |
| import subprocess |
| |
| c = update_cmd(args.current, args.golden) |
| if c is not None: |
| subprocess.run(c.split()) |
| return None |
| |
| |
| def fail_on_breaking_changes(args): |
| """Fails if current is not backward compatible with golden or if |
| current and golden aren't identical. |
| """ |
| differ = plasa_differ.PlasaDiffer(args.fidl_api_diff_path) |
| breaking_changes = differ.find_breaking_changes_in_fragment_file( |
| args.golden, args.current |
| ) |
| |
| if breaking_changes: |
| return CompatibilityError( |
| api_level=args.api_level, |
| breaking_changes=breaking_changes, |
| current=args.current, |
| golden=args.golden, |
| ) |
| |
| if not filecmp.cmp(args.golden, args.current): |
| return GoldenMismatchError( |
| api_level=args.api_level, |
| current=args.current, |
| golden=args.golden, |
| show_update_hint=True, |
| # No breaking changes so the user must be using `@available` correctly. |
| show_fidl_availability_hint=False, |
| ) |
| |
| return None |
| |
| |
| def fail_on_unacknowledged_changes(args): |
| """Asks the user to fix the golden if current and golden aren't identical.""" |
| if not filecmp.cmp(args.golden, args.current): |
| return GoldenMismatchError( |
| api_level=args.api_level, |
| current=args.current, |
| golden=args.golden, |
| show_update_hint=True, |
| ) |
| return None |
| |
| |
| def fail_on_changes(args): |
| """Fails if current and golden aren't identical.""" |
| if not filecmp.cmp(args.golden, args.current): |
| return GoldenMismatchError( |
| api_level=args.api_level, |
| current=args.current, |
| golden=args.golden, |
| ) |
| return None |
| |
| |
| # Updates `golden` if it does not exist or is different from `current`. |
| def update_cmd(current, golden): |
| # Disable shallow comparison which doesn't prevent unnecessary writes. It |
| # ignores file contents and only compares file type, size and mod time. |
| if not os.path.exists(golden) or not filecmp.cmp( |
| current, golden, shallow=False |
| ): |
| return "cp {} {}".format( |
| os.path.abspath(current), os.path.abspath(golden) |
| ) |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |