blob: 5c1d3864b2ed7af215844bdaa63ca9875e503fcf [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
226class VcsToolReq(ToolReq):
227 tool_cmd = ['vcs', '-full64', '-ID']
228 tool_env = {'VCS_ARCH_OVERRIDE': 'linux'}
229 version_regex = re.compile(r'Compiler version = VCS [A-Z]-(.*)')
230
231 def to_semver(self, version, from_req):
232 # VCS has a rather strange numbering scheme, where the most general
233 # versions look something like this:
234 #
235 # Q-2020.03-SP1-2
236 #
237 # Our version_regex strips out the "Q" part (a "platform prefix")
238 # already. A version always has the "2020.03" (year and month) part,
239 # and may also have an -SP<n> and/or -<patch> suffix.
240 #
241 # Since StrictVersion expects a 3 digit versioning scheme, we multiply
242 # any SP number by 100, which should work as long as the patch version
243 # isn't greater than 99.
244 #
245 # Some VCS builds also report other cruft (like _Full64) after this
246 # version number. If from_req is False, allow (and ignore) that too.
247 regex = r'([0-9]+).([0-9]+)(?:-SP([0-9]+))?(?:-([0-9]+))?'
248 if from_req:
249 regex += '$'
250
251 match = re.match(regex, version)
252 if match is None:
253 raise ValueError("{!r} is not a recognised VCS version string."
254 .format(version))
255 major = match.group(1)
256 minor = match.group(2)
257 sp = int(match.group(3) or 0)
258 patch = int(match.group(4) or 0)
259 comb = str(sp * 100 + patch)
260 return '{}.{}{}'.format(major, minor, comb)
261
262
263class PyModuleToolReq(ToolReq):
264 '''A tool in a Python module (its version can be found by running pip)'''
265 version_regex = re.compile(r'Version: (.*)')
266
267 def _get_tool_cmd(self):
268 return ['pip3', 'show', self.tool]
269
270
271def dict_to_tool_req(path, tool, raw):
272 '''Parse a dict (as read from Python) as a ToolReq
273
274 Required keys: version. Optional keys: as_needed.
275
276 '''
277 where = 'Dict for {} in __TOOL_REQUIREMENTS__'.format(tool)
278 # We operate in place on the dictionary. Take a copy to avoid an
279 # obnoxious API.
280 raw = raw.copy()
281
282 if 'min_version' not in raw:
283 raise ReqErr(path,
284 '{} is missing required key: "min_version".'
285 .format(where))
286 min_version = raw['min_version']
287 if not isinstance(min_version, str):
288 raise ReqErr(path,
289 '{} has min_version that is not a string.'
290 .format(where))
291 del raw['min_version']
292
293 as_needed = False
294 if 'as_needed' in raw:
295 as_needed = raw['as_needed']
296 if not isinstance(as_needed, bool):
297 raise ReqErr(path,
298 '{} has as_needed that is not a bool.'
299 .format(where))
300 del raw['as_needed']
301
302 if raw:
303 raise ReqErr(path,
304 '{} has unexpected keys: {}.'
305 .format(where, ', '.join(raw.keys())))
306
307 classes = {
308 'edalize': PyModuleToolReq,
309 'vcs': VcsToolReq,
310 'verible': VeribleToolReq,
311 'verilator': VerilatorToolReq
312 }
313 cls = classes.get(tool, ToolReq)
314
315 ret = cls(tool, min_version)
316 ret.as_needed = as_needed
317 return ret
318
319
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100320def read_tool_requirements(path=None):
321 '''Read tool requirements from a Python file'''
322 if path is None:
323 path = get_tool_requirements_path()
324
325 with open(path, 'r') as pyfile:
326 globs = {}
327 exec(pyfile.read(), globs)
328
329 # We expect the exec call to have populated globs with a
330 # __TOOL_REQUIREMENTS__ dictionary.
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000331 raw = globs.get('__TOOL_REQUIREMENTS__')
332 if raw is None:
333 raise ReqErr(path,
334 'The Python file at did not define '
335 '__TOOL_REQUIREMENTS__.')
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100336
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000337 # raw should be a dictionary (keyed by tool name)
338 if not isinstance(raw, dict):
339 raise ReqErr(path, '__TOOL_REQUIREMENTS__ is not a dict.')
340
341 reqs = {}
342 for tool, raw_val in raw.items():
343 if not isinstance(tool, str):
344 raise ReqErr(path,
345 'Invalid key in __TOOL_REQUIREMENTS__: {!r}'
346 .format(tool))
347
348 if isinstance(raw_val, str):
349 # Shorthand notation: value is just a string, which we
350 # interpret as a minimum version
351 raw_val = {'min_version': raw_val}
352
353 if not isinstance(raw_val, dict):
354 raise ReqErr(path,
355 'Value for {} in __TOOL_REQUIREMENTS__ '
356 'is not a string or dict.'.format(tool))
357
358 reqs[tool] = dict_to_tool_req(path, tool, raw_val)
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100359
360 return reqs
361
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100362
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100363def main():
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000364 parser = argparse.ArgumentParser()
365 parser.add_argument('tool', nargs='*')
366 args = parser.parse_args()
367
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100368 # Get tool requirements
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000369 try:
370 tool_requirements = read_tool_requirements()
371 except ReqErr as err:
372 log.error(str(err))
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100373 return 1
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100374
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000375 pending_tools = set(args.tool)
376 missing_tools = []
377 for tool, req in tool_requirements.items():
378 if req.as_needed and tool not in pending_tools:
379 continue
380 pending_tools.discard(tool)
381
382 good, msg = req.check()
383 if not good:
384 log.error('Failed tool requirement for {}: {}'
385 .format(tool, msg))
386 missing_tools.append(tool)
387 else:
388 log.info('Tool {} present: {}'
389 .format(tool, msg))
390
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100391 all_good = True
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000392 if missing_tools:
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100393 log.error("Tool requirements not fulfilled. "
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000394 "Please update tools ({}) and retry."
395 .format(', '.join(missing_tools)))
396 all_good = False
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100397
Rupert Swarbrick9ab407e2021-01-25 11:40:17 +0000398 if pending_tools:
399 log.error("Some tools specified on command line don't appear in "
400 "tool requirements file: {}"
401 .format(', '.join(sorted(pending_tools))))
402 all_good = False
403
404 return 0 if all_good else 1
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100405
Philipp Wagner82ed76e2020-05-26 11:07:48 +0100406
Pirmin Vogeled097cc2020-03-09 11:35:21 +0100407if __name__ == "__main__":
408 sys.exit(main())