#!/usr/bin/env python3 ''' crcchecker is a tool to used to enforce that .api messages do not change. API files with a semantic version < 1.0.0 are ignored. ''' import sys import os import json import argparse import re from subprocess import run, PIPE, check_output, CalledProcessError # pylint: disable=subprocess-run-check ROOTDIR = os.path.dirname(os.path.realpath(__file__)) + '/../..' APIGENBIN = f'{ROOTDIR}/src/tools/vppapigen/vppapigen.py' def crc_from_apigen(revision, filename): '''Runs vppapigen with crc plugin returning a JSON object with CRCs for all APIs in filename''' if not revision and not os.path.isfile(filename): print(f'skipping: {filename}', file=sys.stderr) # Return instead of return {-1} if revision: apigen = (f'{APIGENBIN} --git-revision {revision} --includedir src ' f'--input {filename} CRC') else: apigen = (f'{APIGENBIN} --includedir src --input {filename} CRC') returncode = run(apigen.split(), stdout=PIPE, stderr=PIPE) if returncode.returncode == 2: # No such file print(f'skipping: {revision}:{filename} {returncode}', file=sys.stderr) return {} if returncode.returncode != 0: print(f'vppapigen failed for {revision}:{filename} with ' 'command\n {apigen}\n error: {rv}', returncode.stderr.decode('ascii'), file=sys.stderr) sys.exit(-2) return json.loads(returncode.stdout) def dict_compare(dict1, dict2): '''Compare two dictionaries returning added, removed, modified and equal entries''' d1_keys = set(dict1.keys()) d2_keys = set(dict2.keys()) intersect_keys = d1_keys.intersection(d2_keys) added = d1_keys - d2_keys removed = d2_keys - d1_keys modified = {o: (dict1[o], dict2[o]) for o in intersect_keys if dict1[o]['crc'] != dict2[o]['crc']} same = set(o for o in intersect_keys if dict1[o] == dict2[o]) return added, removed, modified, same def filelist_from_git_ls(): '''Returns a list of all api files in the git repository''' filelist = [] git_ls = 'git ls-files *.api' returncode = run(git_ls.split(), stdout=PIPE, stderr=PIPE) if returncode.returncode != 0: sys.exit(returncode.returncode) for line in returncode.stdout.decode('ascii').split('\n'): if line: filelist.append(line) return filelist def is_uncommitted_changes(): '''Returns true if there are uncommitted changes in the repo''' git_status = 'git status --porcelain -uno' returncode = run(git_status.split(), stdout=PIPE, stderr=PIPE) if returncode.returncode != 0: sys.exit(returncode.returncode) if returncode.stdout: return True return False def filelist_from_git_grep(filename): '''Returns a list of api files that this api files imports.''' filelist = [] try: returncode = check_output(f'git grep -e "import .*{filename}"' ' -- *.api', shell=True) except CalledProcessError: return [] for line in returncode.decode('ascii').split('\n'): if line: filename, _ = line.split(':') filelist.append(filename) return filelist def filelist_from_patchset(pattern): '''Returns list of api files in changeset and the list of api files they import.''' filelist = [] git_cmd = ('((git diff HEAD~1.. --name-only;git ls-files -m) | ' 'sort -u | grep "\\.api$")') try: res = check_output(git_cmd, shell=True) except CalledProcessError: return [] # Check for dependencies (imports) imported_files = [] for line in res.decode('ascii').split('\n'): if not line: continue if not re.search(pattern, line): continue filelist.append(line) imported_files.extend(filelist_from_git_grep(os.path.basename(line))) filelist.extend(imported_files) return set(filelist) def is_deprecated(message): '''Given a message, return True if message is deprecated''' if 'options' in message: if 'deprecated' in message['options']: return True # recognize the deprecated format if 'status' in message['options'] and \ message['options']['status'] == 'deprecated': print("WARNING: please use 'option deprecated;'") return True return False def is_in_progress(message): '''Given a message, return True if message is marked as in_progress''' if 'options' in message: if 'in_progress' in message['options']: return True # recognize the deprecated format if 'status' in message['options'] and \ message['options']['status'] == 'in_progress': print("WARNING: please use 'option in_progress;'") return True return False def report(new, old): '''Given a dictionary of new crcs and old crcs, print all the added, removed, modified, in-progress, deprecated messages. Return the number of backwards incompatible changes made.''' # pylint: disable=too-many-branches new.pop('_version', None) old.pop('_version', None) added, removed, modified, _ = dict_compare(new, old) backwards_incompatible = 0 # print the full list of in-progress messages # they should eventually either disappear of become supported for k in new.keys(): newversion = int(new[k]['version']) if newversion == 0 or is_in_progress(new[k]): print(f'in-progress: {k}') for k in added: print(f'added: {k}') for k in removed: oldversion = int(old[k]['version']) if oldversion > 0 and not is_deprecated(old[k]) and not \ is_in_progress(old[k]): backwards_incompatible += 1 print(f'removed: ** {k}') else: print(f'removed: {k}') for k in modified.keys(): oldversion = int(old[k]['version']) newversion = int(new[k]['version']) if oldversion > 0 and not is_in_progress(old[k]): backwards_incompatible += 1 print(f'modified: ** {k}') else: print(f'modified: {k}') # check which messages are still there but were marked for deprecation for k in new.keys(): newversion = int(new[k]['version']) if newversion > 0 and is_deprecated(new[k]): if k in old: if not is_deprecated(old[k]): print(f'deprecated: {k}') else: print(f'added+deprecated: {k}') return backwards_incompatible def check_patchset(): '''Compare the changes to API messages in this changeset. Ignores API files with version < 1.0.0. Only considers API files located under the src directory in the repo. ''' files = filelist_from_patchset('^src/') revision = 'HEAD~1' oldcrcs = {} newcrcs = {} for filename in files: # Ignore files that have version < 1.0.0 _ = crc_from_apigen(None, filename) # Ignore removed files if isinstance(_, set) == 0: if isinstance(_, set) == 0 and _['_version']['major'] == '0': continue newcrcs.update(_) oldcrcs.update(crc_from_apigen(revision, filename)) backwards_incompatible = report(newcrcs, oldcrcs) if backwards_incompatible: # alert on changing production API print("crcchecker: Changing production APIs in an incompatible way", file=sys.stderr) sys.exit(-1) else: print('*' * 67) print('* VPP CHECKAPI SUCCESSFULLY COMPLETED') print('*' * 67) def main(): '''Main entry point.''' parser = argparse.ArgumentParser(description='VPP CRC checker.') parser.add_argument('--git-revision', help='Git revision to compare against') parser.add_argument('--dump-manifest', action='store_true', help='Dump CRC for all messages') parser.add_argument('--check-patchset', action='store_true', help='Check patchset for backwards incompatbile changes') parser.add_argument('files', nargs='*') parser.add_argument('--diff', help='Files to compare (on filesystem)', nargs=2) args = parser.parse_args() if args.diff and args.files: parser.print_help() sys.exit(-1) # Diff two files if args.diff: oldcrcs = crc_from_apigen(None, args.diff[0]) newcrcs = crc_from_apigen(None, args.diff[1]) backwards_incompatible = report(newcrcs, oldcrcs) sys.exit(0) # Dump CRC for messages in given files / revision if args.dump_manifest: files = args.files if args.files else filelist_from_git_ls() crcs = {} for filename in files: crcs.update(crc_from_apigen(args.git_revision, filename)) for k, value in crcs.items(): print(f'{k}: {value}') sys.exit(0) # Find changes between current patchset and given revision (previous) if args.check_patchset: if args.git_revision: print('Argument git-revision ignored', file=sys.stderr) # Check there are no uncomitted changes if is_uncommitted_changes(): print('Please stash or commit changes in workspace', file=sys.stderr) sys.exit(-1) check_patchset() sys.exit(0) # Find changes between current workspace and revision # Find changes between a given file and a revision files = args.files if args.files else filelist_from_git_ls() revision = args.git_revision if args.git_revision else 'HEAD~1' oldcrcs = {} newcrcs = {} for file in files: newcrcs.update(crc_from_apigen(None, file)) oldcrcs.update(crc_from_apigen(revision, file)) backwards_incompatible = report(newcrcs, oldcrcs) if args.check_patchset: if backwards_incompatible: # alert on changing production API print("crcchecker: Changing production APIs in an incompatible way", file=sys.stderr) sys.exit(-1) else: print('*' * 67) print('* VPP CHECKAPI SUCCESSFULLY COMPLETED') print('*' * 67) if __name__ == '__main__': main()