Arnon Sharlin | 9ce762f | 2021-02-16 09:45:38 +0200 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 2 | # 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 Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 6 | import argparse |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 7 | from distutils.version import StrictVersion |
| 8 | import logging as log |
| 9 | import os |
Philipp Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 10 | import re |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 11 | import shlex |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 12 | import subprocess |
| 13 | import sys |
| 14 | |
| 15 | # Display INFO log messages and up. |
| 16 | log.basicConfig(level=log.INFO, format="%(levelname)s: %(message)s") |
| 17 | |
Philipp Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 18 | |
| 19 | def 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 Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 28 | class 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 | |
| 38 | class 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 | |
| 194 | class 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 | |
| 211 | class 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 Vogel | 37ee04a | 2021-05-25 13:38:27 +0200 | [diff] [blame] | 226 | class 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 Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 241 | class 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 Schaffner | 31fb03b | 2021-07-02 14:20:04 -0700 | [diff] [blame] | 278 | class 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 Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 295 | class 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 | |
| 303 | def 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 Vogel | 37ee04a | 2021-05-25 13:38:27 +0200 | [diff] [blame] | 343 | 'verilator': VerilatorToolReq, |
| 344 | 'vivado': VivadoToolReq, |
Michael Schaffner | 31fb03b | 2021-07-02 14:20:04 -0700 | [diff] [blame] | 345 | 'ninja': NinjaToolReq |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 346 | } |
| 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 Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 354 | def 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 Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 365 | 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 Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 370 | |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 371 | # 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 Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 393 | |
| 394 | return reqs |
| 395 | |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 396 | |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 397 | def main(): |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 398 | parser = argparse.ArgumentParser() |
| 399 | parser.add_argument('tool', nargs='*') |
| 400 | args = parser.parse_args() |
| 401 | |
Philipp Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 402 | # Get tool requirements |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 403 | try: |
| 404 | tool_requirements = read_tool_requirements() |
| 405 | except ReqErr as err: |
| 406 | log.error(str(err)) |
Philipp Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 407 | return 1 |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 408 | |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 409 | 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 Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 425 | all_good = True |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 426 | if missing_tools: |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 427 | log.error("Tool requirements not fulfilled. " |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 428 | "Please update tools ({}) and retry." |
| 429 | .format(', '.join(missing_tools))) |
| 430 | all_good = False |
Philipp Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 431 | |
Rupert Swarbrick | 9ab407e | 2021-01-25 11:40:17 +0000 | [diff] [blame] | 432 | 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 Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 439 | |
Philipp Wagner | 82ed76e | 2020-05-26 11:07:48 +0100 | [diff] [blame] | 440 | |
Pirmin Vogel | ed097cc | 2020-03-09 11:35:21 +0100 | [diff] [blame] | 441 | if __name__ == "__main__": |
| 442 | sys.exit(main()) |