blob: b56aca2a25513608f01cff505aa79240439a09ca [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
Michael Schaffner31fb03b2021-07-02 14:20:04 -0700278class NinjaToolReq(ToolReq):
279 tool_cmd = ['ninja', '--version']
280
281 def to_semver(self, version, from_req):
282 # There exist different version string variants that we need to be
283 # able to parse. Some only contain the semantic version, e.g. "1.10.0",
284 # while others contain an additional suffix, e.g.
285 # "1.10.0.git.kitware.jobserver-1". This parser only extracts the first
286 # three digits and ignores the rest.
287 m = re.fullmatch(r'([0-9]+)\.([0-9]+)\.([0-9]+).*', version)
288 if m is None:
289 raise ValueError("{} has invalid version string format."
290 .format(version))
291
292 return '.'.join(m.group(1, 2, 3))
293
294
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000295class PyModuleToolReq(ToolReq):
296 '''A tool in a Python module (its version can be found by running pip)'''
297 version_regex = re.compile(r'Version: (.*)')
298
299 def _get_tool_cmd(self):
300 return ['pip3', 'show', self.tool]
301
302
303def dict_to_tool_req(path, tool, raw):
304 '''Parse a dict (as read from Python) as a ToolReq
305
306 Required keys: version. Optional keys: as_needed.
307
308 '''
309 where = 'Dict for {} in __TOOL_REQUIREMENTS__'.format(tool)
310 # We operate in place on the dictionary. Take a copy to avoid an
311 # obnoxious API.
312 raw = raw.copy()
313
314 if 'min_version' not in raw:
315 raise ReqErr(path,
316 '{} is missing required key: "min_version".'
317 .format(where))
318 min_version = raw['min_version']
319 if not isinstance(min_version, str):
320 raise ReqErr(path,
321 '{} has min_version that is not a string.'
322 .format(where))
323 del raw['min_version']
324
325 as_needed = False
326 if 'as_needed' in raw:
327 as_needed = raw['as_needed']
328 if not isinstance(as_needed, bool):
329 raise ReqErr(path,
330 '{} has as_needed that is not a bool.'
331 .format(where))
332 del raw['as_needed']
333
334 if raw:
335 raise ReqErr(path,
336 '{} has unexpected keys: {}.'
337 .format(where, ', '.join(raw.keys())))
338
339 classes = {
340 'edalize': PyModuleToolReq,
341 'vcs': VcsToolReq,
342 'verible': VeribleToolReq,
Pirmin Vogel37ee04a2021-05-25 13:38:27 +0200343 'verilator': VerilatorToolReq,
344 'vivado': VivadoToolReq,
Michael Schaffner31fb03b2021-07-02 14:20:04 -0700345 'ninja': NinjaToolReq
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000346 }
347 cls = classes.get(tool, ToolReq)
348
349 ret = cls(tool, min_version)
350 ret.as_needed = as_needed
351 return ret
352
353
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100354def read_tool_requirements(path=None):
355 '''Read tool requirements from a Python file'''
356 if path is None:
357 path = get_tool_requirements_path()
358
359 with open(path, 'r') as pyfile:
360 globs = {}
361 exec(pyfile.read(), globs)
362
363 # We expect the exec call to have populated globs with a
364 # __TOOL_REQUIREMENTS__ dictionary.
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000365 raw = globs.get('__TOOL_REQUIREMENTS__')
366 if raw is None:
367 raise ReqErr(path,
368 'The Python file at did not define '
369 '__TOOL_REQUIREMENTS__.')
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100370
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000371 # raw should be a dictionary (keyed by tool name)
372 if not isinstance(raw, dict):
373 raise ReqErr(path, '__TOOL_REQUIREMENTS__ is not a dict.')
374
375 reqs = {}
376 for tool, raw_val in raw.items():
377 if not isinstance(tool, str):
378 raise ReqErr(path,
379 'Invalid key in __TOOL_REQUIREMENTS__: {!r}'
380 .format(tool))
381
382 if isinstance(raw_val, str):
383 # Shorthand notation: value is just a string, which we
384 # interpret as a minimum version
385 raw_val = {'min_version': raw_val}
386
387 if not isinstance(raw_val, dict):
388 raise ReqErr(path,
389 'Value for {} in __TOOL_REQUIREMENTS__ '
390 'is not a string or dict.'.format(tool))
391
392 reqs[tool] = dict_to_tool_req(path, tool, raw_val)
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100393
394 return reqs
395
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100396
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100397def main():
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000398 parser = argparse.ArgumentParser()
399 parser.add_argument('tool', nargs='*')
400 args = parser.parse_args()
401
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100402 # Get tool requirements
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000403 try:
404 tool_requirements = read_tool_requirements()
405 except ReqErr as err:
406 log.error(str(err))
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100407 return 1
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100408
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000409 pending_tools = set(args.tool)
410 missing_tools = []
411 for tool, req in tool_requirements.items():
412 if req.as_needed and tool not in pending_tools:
413 continue
414 pending_tools.discard(tool)
415
416 good, msg = req.check()
417 if not good:
418 log.error('Failed tool requirement for {}: {}'
419 .format(tool, msg))
420 missing_tools.append(tool)
421 else:
422 log.info('Tool {} present: {}'
423 .format(tool, msg))
424
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100425 all_good = True
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000426 if missing_tools:
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100427 log.error("Tool requirements not fulfilled. "
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000428 "Please update tools ({}) and retry."
429 .format(', '.join(missing_tools)))
430 all_good = False
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100431
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000432 if pending_tools:
433 log.error("Some tools specified on command line don't appear in "
434 "tool requirements file: {}"
435 .format(', '.join(sorted(pending_tools))))
436 all_good = False
437
438 return 0 if all_good else 1
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100439
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100440
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100441if __name__ == "__main__":
442 sys.exit(main())