api: crchcecker ignore version < 1.0.0 and outside of src directory
[vpp.git] / extras / scripts / crcchecker.py
1 #!/usr/bin/env python3
2
3 '''
4 crcchecker is a tool to used to enforce that .api messages do not change.
5 API files with a semantic version < 1.0.0 are ignored.
6 '''
7
8 import sys
9 import os
10 import json
11 import argparse
12 import re
13 from subprocess import run, PIPE, check_output, CalledProcessError
14
15 # pylint: disable=subprocess-run-check
16
17 ROOTDIR = os.path.dirname(os.path.realpath(__file__)) + '/../..'
18 APIGENBIN = f'{ROOTDIR}/src/tools/vppapigen/vppapigen.py'
19
20
21 def crc_from_apigen(revision, filename):
22     '''Runs vppapigen with crc plugin returning a JSON object with CRCs for
23     all APIs in filename'''
24     if not revision and not os.path.isfile(filename):
25         print(f'skipping: {filename}', file=sys.stderr)
26         return {}
27
28     if revision:
29         apigen = (f'{APIGENBIN} --git-revision {revision} --includedir src '
30                   f'--input {filename} CRC')
31     else:
32         apigen = (f'{APIGENBIN} --includedir src --input {filename} CRC')
33     returncode = run(apigen.split(), stdout=PIPE, stderr=PIPE)
34     if returncode.returncode == 2:  # No such file
35         print(f'skipping: {revision}:{filename} {returncode}', file=sys.stderr)
36         return {}
37     if returncode.returncode != 0:
38         print(f'vppapigen failed for {revision}:{filename} with '
39               'command\n {apigen}\n error: {rv}',
40               returncode.stderr.decode('ascii'), file=sys.stderr)
41         sys.exit(-2)
42
43     return json.loads(returncode.stdout)
44
45
46 def dict_compare(dict1, dict2):
47     '''Compare two dictionaries returning added, removed, modified
48     and equal entries'''
49     d1_keys = set(dict1.keys())
50     d2_keys = set(dict2.keys())
51     intersect_keys = d1_keys.intersection(d2_keys)
52     added = d1_keys - d2_keys
53     removed = d2_keys - d1_keys
54     modified = {o: (dict1[o], dict2[o]) for o in intersect_keys
55                 if dict1[o]['crc'] != dict2[o]['crc']}
56     same = set(o for o in intersect_keys if dict1[o] == dict2[o])
57     return added, removed, modified, same
58
59
60 def filelist_from_git_ls():
61     '''Returns a list of all api files in the git repository'''
62     filelist = []
63     git_ls = 'git ls-files *.api'
64     returncode = run(git_ls.split(), stdout=PIPE, stderr=PIPE)
65     if returncode.returncode != 0:
66         sys.exit(returncode.returncode)
67
68     for line in returncode.stdout.decode('ascii').split('\n'):
69         if line:
70             filelist.append(line)
71     return filelist
72
73
74 def is_uncommitted_changes():
75     '''Returns true if there are uncommitted changes in the repo'''
76     git_status = 'git status --porcelain -uno'
77     returncode = run(git_status.split(), stdout=PIPE, stderr=PIPE)
78     if returncode.returncode != 0:
79         sys.exit(returncode.returncode)
80
81     if returncode.stdout:
82         return True
83     return False
84
85
86 def filelist_from_git_grep(filename):
87     '''Returns a list of api files that this <filename> api files imports.'''
88     filelist = []
89     try:
90         returncode = check_output(f'git grep -e "import .*{filename}"'
91                                   ' -- *.api',
92                                   shell=True)
93     except CalledProcessError:
94         return []
95     for line in returncode.decode('ascii').split('\n'):
96         if line:
97             filename, _ = line.split(':')
98             filelist.append(filename)
99     return filelist
100
101
102 def filelist_from_patchset(pattern):
103     '''Returns list of api files in changeset and the list of api
104     files they import.'''
105     filelist = []
106     git_cmd = ('((git diff HEAD~1.. --name-only;git ls-files -m) | '
107                'sort -u | grep "\\.api$")')
108     try:
109         res = check_output(git_cmd, shell=True)
110     except CalledProcessError:
111         return []
112
113     # Check for dependencies (imports)
114     imported_files = []
115     for line in res.decode('ascii').split('\n'):
116         if not line:
117             continue
118         if not re.search(pattern, line):
119             continue
120         filelist.append(line)
121         imported_files.extend(filelist_from_git_grep(os.path.basename(line)))
122
123     filelist.extend(imported_files)
124     return set(filelist)
125
126
127 def is_deprecated(message):
128     '''Given a message, return True if message is deprecated'''
129     if 'options' in message:
130         if 'deprecated' in message['options']:
131             return True
132         # recognize the deprecated format
133         if 'status' in message['options'] and \
134            message['options']['status'] == 'deprecated':
135             print("WARNING: please use 'option deprecated;'")
136             return True
137     return False
138
139
140 def is_in_progress(message):
141     '''Given a message, return True if message is marked as in_progress'''
142     if 'options' in message:
143         if 'in_progress' in message['options']:
144             return True
145         # recognize the deprecated format
146         if 'status' in message['options'] and \
147            message['options']['status'] == 'in_progress':
148             print("WARNING: please use 'option in_progress;'")
149             return True
150     return False
151
152
153 def report(new, old):
154     '''Given a dictionary of new crcs and old crcs, print all the
155     added, removed, modified, in-progress, deprecated messages.
156     Return the number of backwards incompatible changes made.'''
157
158     # pylint: disable=too-many-branches
159
160     new.pop('_version', None)
161     old.pop('_version', None)
162     added, removed, modified, _ = dict_compare(new, old)
163     backwards_incompatible = 0
164
165     # print the full list of in-progress messages
166     # they should eventually either disappear of become supported
167     for k in new.keys():
168         newversion = int(new[k]['version'])
169         if newversion == 0 or is_in_progress(new[k]):
170             print(f'in-progress: {k}')
171     for k in added:
172         print(f'added: {k}')
173     for k in removed:
174         oldversion = int(old[k]['version'])
175         if oldversion > 0 and not is_deprecated(old[k]) and not \
176            is_in_progress(old[k]):
177             backwards_incompatible += 1
178             print(f'removed: ** {k}')
179         else:
180             print(f'removed: {k}')
181     for k in modified.keys():
182         oldversion = int(old[k]['version'])
183         newversion = int(new[k]['version'])
184         if oldversion > 0 and not is_in_progress(old[k]):
185             backwards_incompatible += 1
186             print(f'modified: ** {k}')
187         else:
188             print(f'modified: {k}')
189
190     # check which messages are still there but were marked for deprecation
191     for k in new.keys():
192         newversion = int(new[k]['version'])
193         if newversion > 0 and is_deprecated(new[k]):
194             if k in old:
195                 if not is_deprecated(old[k]):
196                     print(f'deprecated: {k}')
197             else:
198                 print(f'added+deprecated: {k}')
199
200     return backwards_incompatible
201
202
203 def check_patchset():
204     '''Compare the changes to API messages in this changeset.
205     Ignores API files with version < 1.0.0.
206     Only considers API files located under the src directory in the repo.
207     '''
208     files = filelist_from_patchset('^src/')
209     revision = 'HEAD~1'
210
211     oldcrcs = {}
212     newcrcs = {}
213     for filename in files:
214         # Ignore files that have version < 1.0.0
215         _ = crc_from_apigen(None, filename)
216         if _['_version']['major'] == '0':
217             continue
218
219         newcrcs.update(_)
220         oldcrcs.update(crc_from_apigen(revision, filename))
221
222     backwards_incompatible = report(newcrcs, oldcrcs)
223     if backwards_incompatible:
224         # alert on changing production API
225         print("crcchecker: Changing production APIs in an incompatible way",
226               file=sys.stderr)
227         sys.exit(-1)
228     else:
229         print('*' * 67)
230         print('* VPP CHECKAPI SUCCESSFULLY COMPLETED')
231         print('*' * 67)
232
233
234 def main():
235     '''Main entry point.'''
236     parser = argparse.ArgumentParser(description='VPP CRC checker.')
237     parser.add_argument('--git-revision',
238                         help='Git revision to compare against')
239     parser.add_argument('--dump-manifest', action='store_true',
240                         help='Dump CRC for all messages')
241     parser.add_argument('--check-patchset', action='store_true',
242                         help='Check patchset for backwards incompatbile changes')
243     parser.add_argument('files', nargs='*')
244     parser.add_argument('--diff', help='Files to compare (on filesystem)',
245                         nargs=2)
246
247     args = parser.parse_args()
248
249     if args.diff and args.files:
250         parser.print_help()
251         sys.exit(-1)
252
253     # Diff two files
254     if args.diff:
255         oldcrcs = crc_from_apigen(None, args.diff[0])
256         newcrcs = crc_from_apigen(None, args.diff[1])
257         backwards_incompatible = report(newcrcs, oldcrcs)
258         sys.exit(0)
259
260     # Dump CRC for messages in given files / revision
261     if args.dump_manifest:
262         files = args.files if args.files else filelist_from_git_ls()
263         crcs = {}
264         for filename in files:
265             crcs.update(crc_from_apigen(args.git_revision, filename))
266         for k, value in crcs.items():
267             print(f'{k}: {value}')
268         sys.exit(0)
269
270     # Find changes between current patchset and given revision (previous)
271     if args.check_patchset:
272         if args.git_revision:
273             print('Argument git-revision ignored', file=sys.stderr)
274         # Check there are no uncomitted changes
275         if is_uncommitted_changes():
276             print('Please stash or commit changes in workspace',
277                   file=sys.stderr)
278             sys.exit(-1)
279         check_patchset()
280         sys.exit(0)
281
282     # Find changes between current workspace and revision
283     # Find changes between a given file and a revision
284     files = args.files if args.files else filelist_from_git_ls()
285
286     revision = args.git_revision if args.git_revision else 'HEAD~1'
287
288     oldcrcs = {}
289     newcrcs = {}
290     for file in files:
291         newcrcs.update(crc_from_apigen(None, file))
292         oldcrcs.update(crc_from_apigen(revision, file))
293
294     backwards_incompatible = report(newcrcs, oldcrcs)
295
296     if args.check_patchset:
297         if backwards_incompatible:
298             # alert on changing production API
299             print("crcchecker: Changing production APIs in an incompatible way", file=sys.stderr)
300             sys.exit(-1)
301         else:
302             print('*' * 67)
303             print('* VPP CHECKAPI SUCCESSFULLY COMPLETED')
304             print('*' * 67)
305
306
307 if __name__ == '__main__':
308     main()