[util] Allow optional tools in check_tool_requirements

tool_requirements.py lists minimum versions for tools, but the
distinction between "tools that absolutely need to be installed" and
"the minimum version of a tool if you want to run it" was baked in to
check_tool_requirements.py

This patch moves that distinction to the (more user-editable)
tool_requirements.py. Tools that are marked as_needed aren't checked
unless they are passed on the command line to
check_tool_requirements.py.

Signed-off-by: Rupert Swarbrick <rswarbrick@lowrisc.org>
diff --git a/doc/ug/install_instructions/index.md b/doc/ug/install_instructions/index.md
index 5ca8710..479e7c0 100644
--- a/doc/ug/install_instructions/index.md
+++ b/doc/ug/install_instructions/index.md
@@ -41,7 +41,7 @@
   GCC 5 or Clang 3.5 should meet this requirement.
 * clang-format.
   The use of clang-format 6.0 is recommended to match the formatting enforced when submitting a pull request.
-* [ninja](https://ninja-build.org/)  {{< tool_version "ninja-build" >}}
+* [ninja](https://ninja-build.org/)  {{< tool_version "ninja" >}}
 * Bash
 * curl
 * xz tools
diff --git a/tool_requirements.py b/tool_requirements.py
index 9f9a799..aeb277a 100644
--- a/tool_requirements.py
+++ b/tool_requirements.py
@@ -4,10 +4,32 @@
 
 # Version requirements for various tools. Checked by tooling (e.g. fusesoc),
 # and inserted into the documentation.
+#
+# Entries are keyed by tool name. The value is either a string giving the
+# minimum version number or is a dictionary. If a dictionary, the following
+# keys are recognised:
+#
+#    min_version: Required string. Minimum version number.
+#
+#    as_needed:   Optional bool. Defaults to False. If set, this tool is not
+#                 automatically required. If it is asked for, the rest of the
+#                 entry gives the required version.
+#
 __TOOL_REQUIREMENTS__ = {
     'edalize': '0.2.0',
-    'hugo_extended': '0.71.0',
-    'ninja-build': '1.8.2',
-    'verible': 'v0.0-808-g1e17daa',
+    'ninja': '1.8.2',
     'verilator': '4.104',
+
+    'hugo_extended': {
+        'min_version': '0.71.0',
+        'as_needed': True
+    },
+    'verible': {
+        'min_version': 'v0.0-808-g1e17daa',
+        'as_needed': True
+    },
+    'vcs': {
+        'min_version': '2020.03-SP2',
+        'as_needed': True
+    }
 }
diff --git a/util/build_docs.py b/util/build_docs.py
index e1ec934..77cfbde 100755
--- a/util/build_docs.py
+++ b/util/build_docs.py
@@ -38,8 +38,8 @@
 
 # Version of hugo extended to be used to build the docs
 try:
-    tool_requirements = check_tool_requirements.read_tool_requirements()
-    HUGO_EXTENDED_VERSION = tool_requirements['hugo_extended']
+    TOOL_REQUIREMENTS = check_tool_requirements.read_tool_requirements()
+    HUGO_EXTENDED_VERSION = TOOL_REQUIREMENTS['hugo_extended'].min_version
 except Exception as e:
     print("Unable to get required hugo version: %s" % str(e), file=sys.stderr)
     sys.exit(1)
@@ -241,16 +241,12 @@
     The version number per tool will be saved in outdir-generated/version_$TOOL_NAME.txt
     """
 
-    # Populate __TOOL_REQUIREMENTS__
-    requirements_file = str(SRCTREE_TOP.joinpath("tool_requirements.py"))
-    exec(open(requirements_file).read(), globals())
-
     # And then write a version file for every tool.
-    for tool in __TOOL_REQUIREMENTS__:  # noqa: F821
+    for tool, req in TOOL_REQUIREMENTS.items():
         version_path = config["outdir-generated"].joinpath('version_' + tool + '.txt')
         version_path.parent.mkdir(parents=True, exist_ok=True)
         with open(str(version_path), mode='w') as fout:
-            fout.write(__TOOL_REQUIREMENTS__[tool])  # noqa: F821
+            fout.write(req.min_version)
 
 
 def generate_dif_docs():
diff --git a/util/check_tool_requirements.py b/util/check_tool_requirements.py
index 2e46ed1..77c469c 100755
--- a/util/check_tool_requirements.py
+++ b/util/check_tool_requirements.py
@@ -3,10 +3,12 @@
 # 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
 
@@ -23,6 +25,298 @@
     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 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 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
+    }
+    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:
@@ -34,148 +328,80 @@
 
         # We expect the exec call to have populated globs with a
         # __TOOL_REQUIREMENTS__ dictionary.
-        reqs = globs.get('__TOOL_REQUIREMENTS__')
-        if reqs is None:
-            log.error('The Python file at {} did not define '
-                      '__TOOL_REQUIREMENTS__.'
-                      .format(path))
-            return None
+        raw = globs.get('__TOOL_REQUIREMENTS__')
+        if raw is None:
+            raise ReqErr(path,
+                         'The Python file at did not define '
+                         '__TOOL_REQUIREMENTS__.')
 
-        # reqs should be a dictionary (mapping tool name to minimum version)
-        if not isinstance(reqs, dict):
-            log.error('The Python file at {} defined '
-                      '__TOOL_REQUIREMENTS__, but it is not a dict.'
-                      .format(path))
-            return None
+        # 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 get_verilator_version():
-    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)
-        return version_str.stdout.split(' ')[1].strip()
-
-    except subprocess.CalledProcessError as e:
-        log.error("Unable to call Verilator to check version: " + str(e))
-        log.error(e.stdout)
-        return None
-
-
-def convert_verible_version(version_string):
-    '''Convert Verible version string to semantic versioning format.'''
-    # 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_string)
-
-    if m is None:
-        log.error("{} has invalid version string format.".format(version_string))
-        return None
-
-    return '.'.join(m.group(1, 2, 3))
-
-
-def get_verible_version():
-    '''Run Verible to check its version'''
-    try:
-        version_str = subprocess.run('verible-verilog-lint --version', shell=True,
-                                     check=True, stdout=subprocess.PIPE,
-                                     stderr=subprocess.STDOUT,
-                                     universal_newlines=True)
-        return version_str.stdout.split('\n')[0].strip()
-
-    except subprocess.CalledProcessError as e:
-        log.error("Unable to call Verible to check version: " + str(e))
-        log.error(e.stdout)
-        return None
-
-
-def pip3_get_version(tool):
-    '''Run pip3 to find the version of an installed module'''
-    cmd = ['pip3', 'show', tool]
-    try:
-        proc = subprocess.run(cmd,
-                              check=True,
-                              stderr=subprocess.STDOUT,
-                              stdout=subprocess.PIPE,
-                              universal_newlines=True)
-    except subprocess.CalledProcessError as err:
-        log.error('pip3 command failed: {}'.format(err))
-        log.error("Failed to get version of {} with pip3: is it installed?"
-                  .format(tool))
-        log.error(err.stdout)
-        return None
-
-    version_re = 'Version: (.*)'
-    for line in proc.stdout.splitlines():
-        match = re.match(version_re, line)
-        if match:
-            return match.group(1)
-
-    # If we get here, we never saw a version line.
-    log.error('No output line from running {} started with "Version: ".'
-              .format(cmd))
-    return None
-
-
-def check_version(requirements, tool_name, getter, version_converter=None):
-    required_version = requirements.get(tool_name)
-    if required_version is None:
-        log.error('Requirements file does not specify version for {}.'
-                  .format(tool_name))
-        return False
-
-    actual_version = getter()
-    if actual_version is None:
-        return False
-
-    # If a version string converter is defined, call it. This is required
-    # for some version strings that are not compatible with StrictVersion.
-    if version_converter is not None:
-        required_version = version_converter(required_version)
-        actual_version = version_converter(actual_version)
-
-    if StrictVersion(actual_version) < StrictVersion(required_version):
-        log.error("%s is too old: found version %s, need at least %s",
-                  tool_name, actual_version, required_version)
-        return False
-    else:
-        log.info("Found sufficiently recent version of %s (found %s, need %s)",
-                 tool_name, actual_version, required_version)
-        return True
-
-
 def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('tool', nargs='*')
+    args = parser.parse_args()
+
     # Get tool requirements
-    tool_requirements = read_tool_requirements()
-    if tool_requirements is None:
+    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
-    all_good &= check_version(tool_requirements,
-                              'verilator',
-                              get_verilator_version)
-    all_good &= check_version(tool_requirements,
-                              'verible',
-                              get_verible_version,
-                              convert_verible_version)
-    all_good &= check_version(tool_requirements,
-                              'edalize',
-                              lambda: pip3_get_version('edalize'))
-
-    if not all_good:
+    if missing_tools:
         log.error("Tool requirements not fulfilled. "
-                  "Please update the tools and retry.")
-        return 1
+                  "Please update tools ({}) and retry."
+                  .format(', '.join(missing_tools)))
+        all_good = False
 
-    return 0
+    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__":