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