blob: f706625f62695c476ef59bdbf1d7c4a022c8786c [file] [log] [blame]
Arnon Sharlin9ce762f2021-02-16 09:45:38 +02001#!/usr/bin/env python3
Pirmin Vogeled097cc2020-03-09 11:35:21 +01002# Copyright lowRISC contributors.
3# Licensed under the Apache License, Version 2.0, see LICENSE for details.
4# SPDX-License-Identifier: Apache-2.0
5
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +00006import argparse
Pirmin Vogeled097cc2020-03-09 11:35:21 +01007from distutils.version import StrictVersion
8import logging as log
9import os
Philipp Wagner82ed76e2020-05-26 11:07:48 +010010import re
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +000011import shlex
Pirmin Vogeled097cc2020-03-09 11:35:21 +010012import subprocess
13import sys
14
15# Display INFO log messages and up.
16log.basicConfig(level=log.INFO, format="%(levelname)s: %(message)s")
17
Philipp Wagner82ed76e2020-05-26 11:07:48 +010018
19def get_tool_requirements_path():
20 '''Return the path to tool_requirements.py, at the top of the repo'''
21 # top_src_dir is the top of the repository
22 top_src_dir = os.path.normpath(os.path.join(os.path.dirname(__file__),
23 '..'))
24
25 return os.path.join(top_src_dir, 'tool_requirements.py')
26
27
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +000028class ReqErr(Exception):
29 def __init__(self, path, msg):
30 self.path = path
31 self.msg = msg
32
33 def __str__(self):
34 return ('Error parsing tool requirements from {!r}: {}'
35 .format(self.path, self.msg))
36
37
38class ToolReq:
39 # A subclass can set this to configure the command that's run to get the
40 # version of a tool. If tool_cmd is None, get_version will call "self.tool
41 # --version".
42 tool_cmd = None
43
44 # Used by get_version. If not None, this is a dictionary that's added to
45 # the environment when running the command.
46 tool_env = None
47
48 # A subclass can set this to configure _parse_version_output. If set, it
49 # should be a Regex object with a single capturing group that captures the
50 # version.
51 version_regex = None
52
53 def __init__(self, tool, min_version):
54 self.tool = tool
55 self.min_version = min_version
56 self.optional = False
57
58 def _get_tool_cmd(self):
59 '''Return the command to run to get the installed version'''
60 return self.tool_cmd or [self.tool, '--version']
61
62 def _get_version(self):
63 '''Run the tool to get the installed version.
64
65 Raises a RuntimeError on failure. The default version uses the class
66 variable tool_cmd to figure out what to run.
67
68 '''
69
70 def _parse_version_output(self, stdout):
71 '''Parse the nonempty stdout to get a version number
72
73 Raises a ValueError on failure. The default implementation returns the
74 last word of the first line if version_regex is None or the first match
75 for version_regex if it is not None.
76
77 '''
78 if self.version_regex is None:
79 line0 = stdout.split('\n', 1)[0]
80 words = line0.rsplit(None, 1)
81 if not words:
82 raise ValueError('Empty first line.')
83
84 return words[-1]
85
86 for line in stdout.split('\n'):
87 match = self.version_regex.match(line.rstrip())
88 if match is not None:
89 return match.group(1)
90 raise ValueError('No line matched version regex.')
91
92 def get_version(self):
93 '''Run the tool to get a version.
94
95 Returns a version string on success. Raises a RuntimeError on failure.
96 The default version uses the class variable tool_cmd to figure out what
97 to run.
98
99 '''
100 cmd = self._get_tool_cmd()
101 cmd_txt = ' '.join(shlex.quote(w) for w in cmd)
102
103 env = None
104 if self.tool_env is not None:
105 env = os.environ.copy()
106 env.update(self.tool_env)
107
108 try:
109 proc = subprocess.run(cmd,
110 check=True,
111 stdout=subprocess.PIPE,
112 universal_newlines=True,
113 env=env)
114 except (subprocess.CalledProcessError, FileNotFoundError) as err:
115 env_msg = ('' if not self.tool_env else
116 ' (with environment overrides: {})'
117 .format(', '.join('{}={}'.format(k, v)
118 for k, v in self.tool_env.items())))
119 raise RuntimeError('Failed to run {!r}{} to check version: {}'
120 .format(cmd_txt, env_msg, err))
121
122 if not proc.stdout:
123 raise RuntimeError('No output from running {!r} to check version.'
124 .format(cmd_txt))
125
126 try:
127 return self._parse_version_output(proc.stdout)
128 except ValueError as err:
129 raise RuntimeError('Bad output from running {!r} '
130 'to check version: {}'
131 .format(cmd_txt, err))
132
133 def to_semver(self, version, from_req):
134 '''Convert a tool version to semantic versioning format
135
136 If from_req is true, this version comes from the requirements file
137 (rather than being reported from an installed application). That might
138 mean stricter checking. If version is not a known format, raises a
139 ValueError.
140
141 '''
142 return version
143
144 def check(self):
145 '''Get the installed version and check it matches the requirements
146
147 Returns (is_good, msg). is_good is true if we matched the requirements
148 and false otherwise. msg describes what happened (an error message on
149 failure, or extra information on success).
150
151 '''
152 try:
153 min_semver = self.to_semver(self.min_version, True)
154 except ValueError as err:
155 return (False,
156 'Failed to convert requirement to semantic version: {}'
157 .format(err))
158 try:
159 min_sv = StrictVersion(min_semver)
160 except ValueError as err:
161 return (False,
162 'Bad semver inferred from required version ({}): {}'
163 .format(min_semver, err))
164
165 try:
166 actual_version = self.get_version()
167 except RuntimeError as err:
168 return (False, str(err))
169
170 try:
171 actual_semver = self.to_semver(actual_version, False)
172 except ValueError as err:
173 return (False,
174 'Failed to convert installed to semantic version: {}'
175 .format(err))
176 try:
177 actual_sv = StrictVersion(actual_semver)
178 except ValueError as err:
179 return (False,
180 'Bad semver inferred from installed version ({}): {}'
181 .format(actual_semver, err))
182
183 if actual_sv < min_sv:
184 return (False,
185 'Installed version is too old: '
186 'found version {}, but need at least {}'
187 .format(actual_version, self.min_version))
188
189 return (True,
190 'Sufficiently recent version (found {}; needed {})'
191 .format(actual_version, self.min_version))
192
193
194class VerilatorToolReq(ToolReq):
195 def get_version(self):
196 try:
197 # Note: "verilator" needs to be called through a shell and with all
198 # arguments in a string, as it doesn't have a shebang, but instead
199 # relies on perl magic to parse command line arguments.
200 version_str = subprocess.run('verilator --version', shell=True,
201 check=True, stdout=subprocess.PIPE,
202 stderr=subprocess.STDOUT,
203 universal_newlines=True)
204 except subprocess.CalledProcessError as err:
205 raise RuntimeError('Unable to call Verilator to check version: {}'
206 .format(err)) from None
207
208 return version_str.stdout.split(' ')[1].strip()
209
210
211class VeribleToolReq(ToolReq):
212 tool_cmd = ['verible-verilog-lint', '--version']
213
214 def to_semver(self, version, from_req):
215 # Drop the hash suffix and convert into version string that
216 # is compatible with StrictVersion in check_version below.
217 # Example: v0.0-808-g1e17daa -> 0.0.808
218 m = re.fullmatch(r'v([0-9]+)\.([0-9]+)-([0-9]+)-g[0-9a-f]+$', version)
219 if m is None:
220 raise ValueError("{} has invalid version string format."
221 .format(version))
222
223 return '.'.join(m.group(1, 2, 3))
224
225
Pirmin Vogel37ee04a2021-05-25 13:38:27 +0200226class VivadoToolReq(ToolReq):
227 tool_cmd = ['vivado', '-version']
228 version_regex = re.compile(r'Vivado v(.*)\s')
229
230 def to_semver(self, version, from_req):
231 # Regular Vivado releases just have a major and minor version.
232 # In this case, we set the patch level to 0.
233 m = re.fullmatch(r'([0-9]+)\.([0-9]+)(?:\.([0-9]+))?', version)
234 if m is None:
235 raise ValueError("{} has invalid version string format."
236 .format(version))
237
238 return '.'.join((m.group(1), m.group(2), m.group(3) or '0'))
239
240
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000241class VcsToolReq(ToolReq):
242 tool_cmd = ['vcs', '-full64', '-ID']
243 tool_env = {'VCS_ARCH_OVERRIDE': 'linux'}
244 version_regex = re.compile(r'Compiler version = VCS [A-Z]-(.*)')
245
246 def to_semver(self, version, from_req):
247 # VCS has a rather strange numbering scheme, where the most general
248 # versions look something like this:
249 #
250 # Q-2020.03-SP1-2
251 #
252 # Our version_regex strips out the "Q" part (a "platform prefix")
253 # already. A version always has the "2020.03" (year and month) part,
254 # and may also have an -SP<n> and/or -<patch> suffix.
255 #
256 # Since StrictVersion expects a 3 digit versioning scheme, we multiply
257 # any SP number by 100, which should work as long as the patch version
258 # isn't greater than 99.
259 #
260 # Some VCS builds also report other cruft (like _Full64) after this
261 # version number. If from_req is False, allow (and ignore) that too.
262 regex = r'([0-9]+).([0-9]+)(?:-SP([0-9]+))?(?:-([0-9]+))?'
263 if from_req:
264 regex += '$'
265
266 match = re.match(regex, version)
267 if match is None:
268 raise ValueError("{!r} is not a recognised VCS version string."
269 .format(version))
270 major = match.group(1)
271 minor = match.group(2)
272 sp = int(match.group(3) or 0)
273 patch = int(match.group(4) or 0)
274 comb = str(sp * 100 + patch)
275 return '{}.{}{}'.format(major, minor, comb)
276
277
278class PyModuleToolReq(ToolReq):
279 '''A tool in a Python module (its version can be found by running pip)'''
280 version_regex = re.compile(r'Version: (.*)')
281
282 def _get_tool_cmd(self):
283 return ['pip3', 'show', self.tool]
284
285
286def dict_to_tool_req(path, tool, raw):
287 '''Parse a dict (as read from Python) as a ToolReq
288
289 Required keys: version. Optional keys: as_needed.
290
291 '''
292 where = 'Dict for {} in __TOOL_REQUIREMENTS__'.format(tool)
293 # We operate in place on the dictionary. Take a copy to avoid an
294 # obnoxious API.
295 raw = raw.copy()
296
297 if 'min_version' not in raw:
298 raise ReqErr(path,
299 '{} is missing required key: "min_version".'
300 .format(where))
301 min_version = raw['min_version']
302 if not isinstance(min_version, str):
303 raise ReqErr(path,
304 '{} has min_version that is not a string.'
305 .format(where))
306 del raw['min_version']
307
308 as_needed = False
309 if 'as_needed' in raw:
310 as_needed = raw['as_needed']
311 if not isinstance(as_needed, bool):
312 raise ReqErr(path,
313 '{} has as_needed that is not a bool.'
314 .format(where))
315 del raw['as_needed']
316
317 if raw:
318 raise ReqErr(path,
319 '{} has unexpected keys: {}.'
320 .format(where, ', '.join(raw.keys())))
321
322 classes = {
323 'edalize': PyModuleToolReq,
324 'vcs': VcsToolReq,
325 'verible': VeribleToolReq,
Pirmin Vogel37ee04a2021-05-25 13:38:27 +0200326 'verilator': VerilatorToolReq,
327 'vivado': VivadoToolReq,
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000328 }
329 cls = classes.get(tool, ToolReq)
330
331 ret = cls(tool, min_version)
332 ret.as_needed = as_needed
333 return ret
334
335
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100336def read_tool_requirements(path=None):
337 '''Read tool requirements from a Python file'''
338 if path is None:
339 path = get_tool_requirements_path()
340
341 with open(path, 'r') as pyfile:
342 globs = {}
343 exec(pyfile.read(), globs)
344
345 # We expect the exec call to have populated globs with a
346 # __TOOL_REQUIREMENTS__ dictionary.
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000347 raw = globs.get('__TOOL_REQUIREMENTS__')
348 if raw is None:
349 raise ReqErr(path,
350 'The Python file at did not define '
351 '__TOOL_REQUIREMENTS__.')
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100352
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000353 # raw should be a dictionary (keyed by tool name)
354 if not isinstance(raw, dict):
355 raise ReqErr(path, '__TOOL_REQUIREMENTS__ is not a dict.')
356
357 reqs = {}
358 for tool, raw_val in raw.items():
359 if not isinstance(tool, str):
360 raise ReqErr(path,
361 'Invalid key in __TOOL_REQUIREMENTS__: {!r}'
362 .format(tool))
363
364 if isinstance(raw_val, str):
365 # Shorthand notation: value is just a string, which we
366 # interpret as a minimum version
367 raw_val = {'min_version': raw_val}
368
369 if not isinstance(raw_val, dict):
370 raise ReqErr(path,
371 'Value for {} in __TOOL_REQUIREMENTS__ '
372 'is not a string or dict.'.format(tool))
373
374 reqs[tool] = dict_to_tool_req(path, tool, raw_val)
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100375
376 return reqs
377
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100378
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100379def main():
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000380 parser = argparse.ArgumentParser()
381 parser.add_argument('tool', nargs='*')
382 args = parser.parse_args()
383
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100384 # Get tool requirements
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000385 try:
386 tool_requirements = read_tool_requirements()
387 except ReqErr as err:
388 log.error(str(err))
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100389 return 1
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100390
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000391 pending_tools = set(args.tool)
392 missing_tools = []
393 for tool, req in tool_requirements.items():
394 if req.as_needed and tool not in pending_tools:
395 continue
396 pending_tools.discard(tool)
397
398 good, msg = req.check()
399 if not good:
400 log.error('Failed tool requirement for {}: {}'
401 .format(tool, msg))
402 missing_tools.append(tool)
403 else:
404 log.info('Tool {} present: {}'
405 .format(tool, msg))
406
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100407 all_good = True
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000408 if missing_tools:
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100409 log.error("Tool requirements not fulfilled. "
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000410 "Please update tools ({}) and retry."
411 .format(', '.join(missing_tools)))
412 all_good = False
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100413
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000414 if pending_tools:
415 log.error("Some tools specified on command line don't appear in "
416 "tool requirements file: {}"
417 .format(', '.join(sorted(pending_tools))))
418 all_good = False
419
420 return 0 if all_good else 1
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100421
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100422
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100423if __name__ == "__main__":
424 sys.exit(main())