forked from nrfconnect/sdk-nrf
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcheck_license.py
executable file
·312 lines (279 loc) · 12.6 KB
/
check_license.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
#!/usr/bin/env python3
#
# Copyright (c) 2022 Nordic Semiconductor ASA
#
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
import argparse
import json
import re
import sys
import shlex
import subprocess
import tempfile
import textwrap
from pathlib import Path
import yaml
import junit_xml
# Messages shown in the output
LICENSE_ALLOWED = '"*" license is allowed for this file.'
NONE_LICENSE_ALLOWED = 'Missing license information is allowed for this file.'
ANY_LICENSE_ALLOWED = 'Any license is allowed for this file.'
LICENSE_WARNING = '"*" license is allowed for this file, but it is not recommended.'
NONE_LICENSE_WARNING = ('Missing license information is allowed for this file, but it is '
'recommended to add one.')
ANY_LICENSE_WARNING = ('Any license is allowed for this file, but it is recommended to use a more '
'suitable one.')
LICENSE_ERROR = '"*" license is not allowed for this file.'
NONE_LICENSE_ERROR = 'Missing license information is not allowed for this file.'
SKIP_MISSING_FILE_TEXT = 'The file does not exist anymore.'
SKIP_DIRECTORY_TEXT = 'This is a directory.'
RECOMMENDATIONS_TEXT = textwrap.dedent('''\
===============================================================================
You have some license problems. Check the following:
* The files in the commits are covered by a license allowed in the nRF Connect
SDK.
* The source files have a correct "SPDX-License-Identifier" tag.
* The libraries have an associated external license file and the tags contained
in it are correct. For details, see documentation for the Software Bill of
Materials script.
===============================================================================
''')
def parse_args():
'''Parse command line arguments.'''
default_allow_list = Path(__file__).parent / 'license_allow_list.yaml'
parser = argparse.ArgumentParser(
description='Check for allowed licenses.',
allow_abbrev=False)
parser.add_argument('-c', '--commits', default='HEAD~1..',
help='Commit range in the form: a..[b], default is HEAD~1..HEAD')
parser.add_argument('-o', '--output', type=Path, default='licenses.xml',
help='''Name of outfile in JUnit format, default is ./licenses.xml''')
parser.add_argument('-l', '--allow-list', type=Path, default=default_allow_list,
help=f'Allow list file, default is {default_allow_list}')
parser.add_argument('--github', action='store_true',
help='Add GitHub Actions Workflow commands to the stdout.')
return parser.parse_args()
def unlink_quietly(path: Path) -> None:
'''Delete a file if it exists.'''
try:
path.unlink()
except FileNotFoundError:
pass
class FileLicenseChecker:
'''Class that checks if a license is allowed for a file.'''
allow_list: 'dict[str, list[tuple[re.Pattern, bool]]]'
def __init__(self, allow_list_file: Path):
with open(allow_list_file, 'r') as fd:
data = yaml.safe_load(fd)
self.allow_list = {}
for key in data:
self.allow_list[key.upper()] = self.parse_re(data[key])
@staticmethod
def parse_re(re_str: str) -> 'list[tuple[re.Pattern, bool]]':
'''
Convert a multiline string to a list of regular expressions and boolean indicating if it is
negative match.
'''
result = []
lines = re_str.strip().splitlines()
for line in lines:
line = line.strip()
if not line:
continue
if line[0] == '!':
value = False
line = line[1:].strip()
else:
value = True
result.append((re.compile(line), value))
return result
def check(self, license_identifier: str, file_path: 'str|Path') -> bool:
'''
Check if a license is allowed for a file. The file is relative to the west workspace.
The license identifier can be prefixed with a '-' to check if the license is allowed,
but with a warning.
'''
file_path = str(file_path).replace('\\', '/')
license_identifier = license_identifier.upper()
if license_identifier not in self.allow_list:
return False
allow = False
for pattern, value in self.allow_list[license_identifier]:
if pattern.search(file_path):
allow = value
return allow
class PatchLicenseChecker:
'''Check licenses for a git patch.'''
args: dict
license_checker: FileLicenseChecker
git_top: Path
west_workspace: Path
junit_test_cases: str
total_tests: int
total_skipped: int
total_errors: int
total_warnings: int
def __init__(self, args: dict):
self.args = args
self.license_checker = FileLicenseChecker(args.allow_list)
def run(self, program: str, *args: 'list[str|Path]', cwd=None) -> str:
'''A helper function to run an external program.'''
run_cmd = (program,) + tuple(str(a) for a in args)
run_str = ' '.join(shlex.quote(arg) for arg in run_cmd)
try:
process = subprocess.Popen(run_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=cwd)
except OSError as e:
self.report('error', f'Failed to run "{run_str}": {e}')
self.write_junit()
sys.exit(2)
stdout, stderr = process.communicate()
stdout = stdout.decode('utf-8')
stderr = stderr.decode('utf-8')
if process.returncode or stderr:
self.report('error', f'Command "{run_str}" exited with {process.returncode}\n'
f'==stdout==\n{stdout}\n==stderr==\n{stderr}')
self.write_junit()
sys.exit(2)
return stdout.rstrip()
def report(self, label: str, message: str, file_name: 'str|Path' = '<none>', license: str = ''):
'''Report results to the user.'''
file_name = str(file_name)
# Print to stdout/stderr with optional GitHub Actions Workflow commands.
if not self.args.github:
if label in ('error', 'warning'):
print(f'{label.upper()}: {file_name}: {message}', file=sys.stderr)
else:
print(f'{label.upper()}: {file_name}: {message}')
else:
print(f'{file_name}: ')
if label in ('error', 'warning'):
if file_name != '<none>':
file_path = (self.west_workspace / file_name).relative_to(self.git_top)
else:
file_path = file_name
print(f'::{label} file={file_path},title=License Problem::' +
message.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A'))
else:
print(f'{label.upper()}: {message}')
# Put result in JUnit file.
test_case = junit_xml.TestCase(file_name + (f':{license}' if license else ''),
'LicenseCheck')
if label == 'error':
test_case.add_failure_info(message)
elif label == 'warning':
test_case.add_skipped_info('WARNING: ' + message)
elif label == 'skip':
test_case.add_skipped_info(message)
self.junit_test_cases.append(test_case)
# Increment counters.
self.total_tests += 1
if label == 'error':
self.total_errors += 1
elif label == 'warning':
self.total_warnings += 1
elif label == 'skip':
self.total_skipped += 1
def generate_list_of_files(self) -> 'list[Path]':
'''
Generate a list of files changed in the git patch. The returned path is relative to the
west workspace.
'''
files = self.run('git', 'diff', '--name-only', '--diff-filter=d', self.args.commits)
files = files.splitlines()
files = [(self.git_top / f.strip()).relative_to(self.west_workspace)
for f in files if f.strip()]
return files
def skip_files(self, files: 'list[Path]') -> 'list[Path]':
'''
Remove files from the list, because they do not exist, or they can have any license.
A new list is returned.
'''
new_list = []
for file_name in files:
if not (self.west_workspace / file_name).exists():
self.report('skip', SKIP_MISSING_FILE_TEXT, file_name)
if not (self.west_workspace / file_name).is_file():
self.report('skip', SKIP_DIRECTORY_TEXT, file_name)
elif self.license_checker.check('ANY', file_name):
self.report('skip', ANY_LICENSE_ALLOWED, file_name)
else:
new_list.append(file_name)
return new_list
def detect_licenses(self, files: 'list[Path]') -> 'list[dict]':
'''Use the "west ncs-sbom" command to detect the licenses of the files.'''
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', prefix='_tmp',
dir=self.west_workspace, delete=False) as tmp:
for file_name in files:
tmp.write(f'{file_name}\n')
tmp_list = Path(tmp.name)
tmp_json = tmp_list.with_suffix('.json')
try:
self.run('west', 'ncs-sbom', '--input-list-file', tmp_list, '--license-detectors',
'spdx-tag,full-text,external-file', '--output-cache-database', tmp_json)
with open(tmp_json, 'r', encoding='utf-8') as fd:
output = json.load(fd)
return output['files']
finally:
unlink_quietly(tmp_list)
unlink_quietly(tmp_json)
def show_results(self, detected: 'list[dict]') -> bool:
'''Interpret detected licenses and report results to the user.'''
for file_name, file_info in detected.items():
if len(file_info['license']) == 0:
file_info['license'] = ['NONE']
for license in file_info['license']:
if self.license_checker.check(license, file_name):
message = LICENSE_ALLOWED if license != 'NONE' else NONE_LICENSE_ALLOWED
self.report('ok', message.replace('*', license), file_name, license)
elif self.license_checker.check('-' + license, file_name):
message = LICENSE_WARNING if license != 'NONE' else NONE_LICENSE_WARNING
self.report('warning', message.replace('*', license), file_name, license)
elif self.license_checker.check('-ANY', file_name):
self.report('warning', ANY_LICENSE_WARNING, file_name, license)
else:
message = LICENSE_ERROR if license != 'NONE' else NONE_LICENSE_ERROR
self.report('error', message.replace('*', license), file_name, license)
if self.total_errors > 0:
print(f'License check failed with {self.total_errors} error(s) '
f'and {self.total_warnings} warning(s)!')
print(RECOMMENDATIONS_TEXT)
return False
elif self.total_warnings > 0:
print(f'License check successful, but with {self.total_warnings} warning(s)!')
print(RECOMMENDATIONS_TEXT)
return True
else:
print('License check successful.')
return True
def write_junit(self):
'''Write the JUnit file.'''
test_suite = junit_xml.TestSuite("LicenseCheck", self.junit_test_cases)
with open(self.args.output, 'w') as fd:
junit_xml.TestSuite.to_file(fd, [test_suite], prettyprint=False)
def check(self) -> bool:
'''Do the license check based on command-line arguments provided in the constructor.'''
self.junit_test_cases = []
self.total_tests = 0
self.total_skipped = 0
self.total_errors = 0
self.total_warnings = 0
self.git_top = Path(self.run('git', 'rev-parse', '--show-toplevel')).resolve()
self.west_workspace = Path(self.git_top).parent
print(f'Repository top directory: {self.git_top}')
print(f'West workspace directory: {self.west_workspace}')
files = self.generate_list_of_files()
files = self.skip_files(files)
detected = self.detect_licenses(files) if files else []
success = self.show_results(detected)
self.write_junit()
return success
def main():
'''Main function.'''
args = parse_args()
checker = PatchLicenseChecker(args)
success = checker.check()
if not success:
sys.exit(1)
if __name__ == '__main__':
main()