| #!/usr/bin/env python3 |
| # Copyright lowRISC contributors. |
| # Licensed under the Apache License, Version 2.0, see LICENSE for details. |
| # SPDX-License-Identifier: Apache-2.0 |
| |
| import argparse |
| from distutils.version import StrictVersion |
| import logging as log |
| import os |
| import re |
| import shlex |
| import subprocess |
| import sys |
| |
| # Display INFO log messages and up. |
| log.basicConfig(level=log.INFO, format="%(levelname)s: %(message)s") |
| |
| |
| def get_tool_requirements_path(): |
| '''Return the path to tool_requirements.py, at the top of the repo''' |
| # top_src_dir is the top of the repository |
| top_src_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), |
| '..')) |
| |
| return os.path.join(top_src_dir, 'tool_requirements.py') |
| |
| |
| class ReqErr(Exception): |
| def __init__(self, path, msg): |
| self.path = path |
| self.msg = msg |
| |
| def __str__(self): |
| return ('Error parsing tool requirements from {!r}: {}' |
| .format(self.path, self.msg)) |
| |
| |
| class ToolReq: |
| # A subclass can set this to configure the command that's run to get the |
| # version of a tool. If tool_cmd is None, get_version will call "self.tool |
| # --version". |
| tool_cmd = None |
| |
| # Used by get_version. If not None, this is a dictionary that's added to |
| # the environment when running the command. |
| tool_env = None |
| |
| # A subclass can set this to configure _parse_version_output. If set, it |
| # should be a Regex object with a single capturing group that captures the |
| # version. |
| version_regex = None |
| |
| def __init__(self, tool, min_version): |
| self.tool = tool |
| self.min_version = min_version |
| self.optional = False |
| |
| def _get_tool_cmd(self): |
| '''Return the command to run to get the installed version''' |
| return self.tool_cmd or [self.tool, '--version'] |
| |
| def _get_version(self): |
| '''Run the tool to get the installed version. |
| |
| Raises a RuntimeError on failure. The default version uses the class |
| variable tool_cmd to figure out what to run. |
| |
| ''' |
| |
| def _parse_version_output(self, stdout): |
| '''Parse the nonempty stdout to get a version number |
| |
| Raises a ValueError on failure. The default implementation returns the |
| last word of the first line if version_regex is None or the first match |
| for version_regex if it is not None. |
| |
| ''' |
| if self.version_regex is None: |
| line0 = stdout.split('\n', 1)[0] |
| words = line0.rsplit(None, 1) |
| if not words: |
| raise ValueError('Empty first line.') |
| |
| return words[-1] |
| |
| for line in stdout.split('\n'): |
| match = self.version_regex.match(line.rstrip()) |
| if match is not None: |
| return match.group(1) |
| raise ValueError('No line matched version regex.') |
| |
| def get_version(self): |
| '''Run the tool to get a version. |
| |
| Returns a version string on success. Raises a RuntimeError on failure. |
| The default version uses the class variable tool_cmd to figure out what |
| to run. |
| |
| ''' |
| cmd = self._get_tool_cmd() |
| cmd_txt = ' '.join(shlex.quote(w) for w in cmd) |
| |
| env = None |
| if self.tool_env is not None: |
| env = os.environ.copy() |
| env.update(self.tool_env) |
| |
| try: |
| proc = subprocess.run(cmd, |
| check=True, |
| stdout=subprocess.PIPE, |
| universal_newlines=True, |
| env=env) |
| except (subprocess.CalledProcessError, FileNotFoundError) as err: |
| env_msg = ('' if not self.tool_env else |
| ' (with environment overrides: {})' |
| .format(', '.join('{}={}'.format(k, v) |
| for k, v in self.tool_env.items()))) |
| raise RuntimeError('Failed to run {!r}{} to check version: {}' |
| .format(cmd_txt, env_msg, err)) |
| |
| if not proc.stdout: |
| raise RuntimeError('No output from running {!r} to check version.' |
| .format(cmd_txt)) |
| |
| try: |
| return self._parse_version_output(proc.stdout) |
| except ValueError as err: |
| raise RuntimeError('Bad output from running {!r} ' |
| 'to check version: {}' |
| .format(cmd_txt, err)) |
| |
| def to_semver(self, version, from_req): |
| '''Convert a tool version to semantic versioning format |
| |
| If from_req is true, this version comes from the requirements file |
| (rather than being reported from an installed application). That might |
| mean stricter checking. If version is not a known format, raises a |
| ValueError. |
| |
| ''' |
| return version |
| |
| def check(self): |
| '''Get the installed version and check it matches the requirements |
| |
| Returns (is_good, msg). is_good is true if we matched the requirements |
| and false otherwise. msg describes what happened (an error message on |
| failure, or extra information on success). |
| |
| ''' |
| try: |
| min_semver = self.to_semver(self.min_version, True) |
| except ValueError as err: |
| return (False, |
| 'Failed to convert requirement to semantic version: {}' |
| .format(err)) |
| try: |
| min_sv = StrictVersion(min_semver) |
| except ValueError as err: |
| return (False, |
| 'Bad semver inferred from required version ({}): {}' |
| .format(min_semver, err)) |
| |
| try: |
| actual_version = self.get_version() |
| except RuntimeError as err: |
| return (False, str(err)) |
| |
| try: |
| actual_semver = self.to_semver(actual_version, False) |
| except ValueError as err: |
| return (False, |
| 'Failed to convert installed to semantic version: {}' |
| .format(err)) |
| try: |
| actual_sv = StrictVersion(actual_semver) |
| except ValueError as err: |
| return (False, |
| 'Bad semver inferred from installed version ({}): {}' |
| .format(actual_semver, err)) |
| |
| if actual_sv < min_sv: |
| return (False, |
| 'Installed version is too old: ' |
| 'found version {}, but need at least {}' |
| .format(actual_version, self.min_version)) |
| |
| return (True, |
| 'Sufficiently recent version (found {}; needed {})' |
| .format(actual_version, self.min_version)) |
| |
| |
| class VerilatorToolReq(ToolReq): |
| def get_version(self): |
| try: |
| # Note: "verilator" needs to be called through a shell and with all |
| # arguments in a string, as it doesn't have a shebang, but instead |
| # relies on perl magic to parse command line arguments. |
| version_str = subprocess.run('verilator --version', shell=True, |
| check=True, stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| universal_newlines=True) |
| except subprocess.CalledProcessError as err: |
| raise RuntimeError('Unable to call Verilator to check version: {}' |
| .format(err)) from None |
| |
| return version_str.stdout.split(' ')[1].strip() |
| |
| |
| class VeribleToolReq(ToolReq): |
| tool_cmd = ['verible-verilog-lint', '--version'] |
| |
| def to_semver(self, version, from_req): |
| # Drop the hash suffix and convert into version string that |
| # is compatible with StrictVersion in check_version below. |
| # Example: v0.0-808-g1e17daa -> 0.0.808 |
| m = re.fullmatch(r'v([0-9]+)\.([0-9]+)-([0-9]+)-g[0-9a-f]+$', version) |
| if m is None: |
| raise ValueError("{} has invalid version string format." |
| .format(version)) |
| |
| return '.'.join(m.group(1, 2, 3)) |
| |
| |
| class VivadoToolReq(ToolReq): |
| tool_cmd = ['vivado', '-version'] |
| version_regex = re.compile(r'Vivado v(.*)\s') |
| |
| def to_semver(self, version, from_req): |
| # Regular Vivado releases just have a major and minor version. |
| # In this case, we set the patch level to 0. |
| m = re.fullmatch(r'([0-9]+)\.([0-9]+)(?:\.([0-9]+))?', version) |
| if m is None: |
| raise ValueError("{} has invalid version string format." |
| .format(version)) |
| |
| return '.'.join((m.group(1), m.group(2), m.group(3) or '0')) |
| |
| |
| class VcsToolReq(ToolReq): |
| tool_cmd = ['vcs', '-full64', '-ID'] |
| tool_env = {'VCS_ARCH_OVERRIDE': 'linux'} |
| version_regex = re.compile(r'Compiler version = VCS [A-Z]-(.*)') |
| |
| def to_semver(self, version, from_req): |
| # VCS has a rather strange numbering scheme, where the most general |
| # versions look something like this: |
| # |
| # Q-2020.03-SP1-2 |
| # |
| # Our version_regex strips out the "Q" part (a "platform prefix") |
| # already. A version always has the "2020.03" (year and month) part, |
| # and may also have an -SP<n> and/or -<patch> suffix. |
| # |
| # Since StrictVersion expects a 3 digit versioning scheme, we multiply |
| # any SP number by 100, which should work as long as the patch version |
| # isn't greater than 99. |
| # |
| # Some VCS builds also report other cruft (like _Full64) after this |
| # version number. If from_req is False, allow (and ignore) that too. |
| regex = r'([0-9]+).([0-9]+)(?:-SP([0-9]+))?(?:-([0-9]+))?' |
| if from_req: |
| regex += '$' |
| |
| match = re.match(regex, version) |
| if match is None: |
| raise ValueError("{!r} is not a recognised VCS version string." |
| .format(version)) |
| major = match.group(1) |
| minor = match.group(2) |
| sp = int(match.group(3) or 0) |
| patch = int(match.group(4) or 0) |
| comb = str(sp * 100 + patch) |
| return '{}.{}{}'.format(major, minor, comb) |
| |
| |
| class NinjaToolReq(ToolReq): |
| tool_cmd = ['ninja', '--version'] |
| |
| def to_semver(self, version, from_req): |
| # There exist different version string variants that we need to be |
| # able to parse. Some only contain the semantic version, e.g. "1.10.0", |
| # while others contain an additional suffix, e.g. |
| # "1.10.0.git.kitware.jobserver-1". This parser only extracts the first |
| # three digits and ignores the rest. |
| m = re.fullmatch(r'([0-9]+)\.([0-9]+)\.([0-9]+).*', version) |
| if m is None: |
| raise ValueError("{} has invalid version string format." |
| .format(version)) |
| |
| return '.'.join(m.group(1, 2, 3)) |
| |
| |
| class PyModuleToolReq(ToolReq): |
| '''A tool in a Python module (its version can be found by running pip)''' |
| version_regex = re.compile(r'Version: (.*)') |
| |
| def _get_tool_cmd(self): |
| return ['pip3', 'show', self.tool] |
| |
| |
| def dict_to_tool_req(path, tool, raw): |
| '''Parse a dict (as read from Python) as a ToolReq |
| |
| Required keys: version. Optional keys: as_needed. |
| |
| ''' |
| where = 'Dict for {} in __TOOL_REQUIREMENTS__'.format(tool) |
| # We operate in place on the dictionary. Take a copy to avoid an |
| # obnoxious API. |
| raw = raw.copy() |
| |
| if 'min_version' not in raw: |
| raise ReqErr(path, |
| '{} is missing required key: "min_version".' |
| .format(where)) |
| min_version = raw['min_version'] |
| if not isinstance(min_version, str): |
| raise ReqErr(path, |
| '{} has min_version that is not a string.' |
| .format(where)) |
| del raw['min_version'] |
| |
| as_needed = False |
| if 'as_needed' in raw: |
| as_needed = raw['as_needed'] |
| if not isinstance(as_needed, bool): |
| raise ReqErr(path, |
| '{} has as_needed that is not a bool.' |
| .format(where)) |
| del raw['as_needed'] |
| |
| if raw: |
| raise ReqErr(path, |
| '{} has unexpected keys: {}.' |
| .format(where, ', '.join(raw.keys()))) |
| |
| classes = { |
| 'edalize': PyModuleToolReq, |
| 'vcs': VcsToolReq, |
| 'verible': VeribleToolReq, |
| 'verilator': VerilatorToolReq, |
| 'vivado': VivadoToolReq, |
| 'ninja': NinjaToolReq |
| } |
| cls = classes.get(tool, ToolReq) |
| |
| ret = cls(tool, min_version) |
| ret.as_needed = as_needed |
| return ret |
| |
| |
| def read_tool_requirements(path=None): |
| '''Read tool requirements from a Python file''' |
| if path is None: |
| path = get_tool_requirements_path() |
| |
| with open(path, 'r') as pyfile: |
| globs = {} |
| exec(pyfile.read(), globs) |
| |
| # We expect the exec call to have populated globs with a |
| # __TOOL_REQUIREMENTS__ dictionary. |
| raw = globs.get('__TOOL_REQUIREMENTS__') |
| if raw is None: |
| raise ReqErr(path, |
| 'The Python file at did not define ' |
| '__TOOL_REQUIREMENTS__.') |
| |
| # raw should be a dictionary (keyed by tool name) |
| if not isinstance(raw, dict): |
| raise ReqErr(path, '__TOOL_REQUIREMENTS__ is not a dict.') |
| |
| reqs = {} |
| for tool, raw_val in raw.items(): |
| if not isinstance(tool, str): |
| raise ReqErr(path, |
| 'Invalid key in __TOOL_REQUIREMENTS__: {!r}' |
| .format(tool)) |
| |
| if isinstance(raw_val, str): |
| # Shorthand notation: value is just a string, which we |
| # interpret as a minimum version |
| raw_val = {'min_version': raw_val} |
| |
| if not isinstance(raw_val, dict): |
| raise ReqErr(path, |
| 'Value for {} in __TOOL_REQUIREMENTS__ ' |
| 'is not a string or dict.'.format(tool)) |
| |
| reqs[tool] = dict_to_tool_req(path, tool, raw_val) |
| |
| return reqs |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('tool', nargs='*') |
| args = parser.parse_args() |
| |
| # Get tool requirements |
| try: |
| tool_requirements = read_tool_requirements() |
| except ReqErr as err: |
| log.error(str(err)) |
| return 1 |
| |
| pending_tools = set(args.tool) |
| missing_tools = [] |
| for tool, req in tool_requirements.items(): |
| if req.as_needed and tool not in pending_tools: |
| continue |
| pending_tools.discard(tool) |
| |
| good, msg = req.check() |
| if not good: |
| log.error('Failed tool requirement for {}: {}' |
| .format(tool, msg)) |
| missing_tools.append(tool) |
| else: |
| log.info('Tool {} present: {}' |
| .format(tool, msg)) |
| |
| all_good = True |
| if missing_tools: |
| log.error("Tool requirements not fulfilled. " |
| "Please update tools ({}) and retry." |
| .format(', '.join(missing_tools))) |
| all_good = False |
| |
| if pending_tools: |
| log.error("Some tools specified on command line don't appear in " |
| "tool requirements file: {}" |
| .format(', '.join(sorted(pending_tools)))) |
| all_good = False |
| |
| return 0 if all_good else 1 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |