# ===----------------------------------------------------------------------===##
#
# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# ===----------------------------------------------------------------------===##
import os
import pickle
import platform
import shlex
import shutil
import tempfile
import libcxx.test.format
import lit
import lit.LitConfig
import lit.Test
import lit.TestRunner
import lit.util
class ConfigurationError(Exception):
pass
class ConfigurationCompilationError(ConfigurationError):
pass
class ConfigurationRuntimeError(ConfigurationError):
pass
def _memoizeExpensiveOperation(extractCacheKey):
"""
Allows memoizing a very expensive operation.
We pickle the cache key to make sure we store an immutable representation
of it. If we stored an object and the object was referenced elsewhere, it
could be changed from under our feet, which would break the cache.
We also store the cache for a given function persistently across invocations
of Lit. This dramatically speeds up the configuration of the test suite when
invoking Lit repeatedly, which is important for developer workflow. However,
with the current implementation that does not synchronize updates to the
persistent cache, this also means that one should not call a memoized
operation from multiple threads. This should normally not be a problem
since Lit configuration is single-threaded.
"""
def decorator(function):
def f(config, *args, **kwargs):
cacheRoot = os.path.join(config.test_exec_root, "__config_cache__")
persistentCache = os.path.join(cacheRoot, function.__name__)
if not os.path.exists(cacheRoot):
os.makedirs(cacheRoot)
cache = {}
# Load a cache from a previous Lit invocation if there is one.
if os.path.exists(persistentCache):
with open(persistentCache, "rb") as cacheFile:
cache = pickle.load(cacheFile)
cacheKey = pickle.dumps(extractCacheKey(config, *args, **kwargs))
if cacheKey not in cache:
cache[cacheKey] = function(config, *args, **kwargs)
# Update the persistent cache so it knows about the new key
# We write to a PID-suffixed file and rename the result to
# ensure that the cache is not corrupted when running the test
# suite with multiple shards. Since this file is in the same
# directory as the destination, os.replace() will be atomic.
unique_suffix = ".tmp." + str(os.getpid())
with open(persistentCache + unique_suffix, "wb") as cacheFile:
pickle.dump(cache, cacheFile)
os.replace(persistentCache + unique_suffix, persistentCache)
return cache[cacheKey]
return f
return decorator
def _executeWithFakeConfig(test, commands):
"""
Returns (stdout, stderr, exitCode, timeoutInfo, parsedCommands)
"""
litConfig = lit.LitConfig.LitConfig(
progname="lit",
path=[],
quiet=False,
useValgrind=False,
valgrindLeakCheck=False,
valgrindArgs=[],
noExecute=False,
debug=False,
isWindows=platform.system() == "Windows",
order="smart",
params={},
)
return libcxx.test.format._executeScriptInternal(test, litConfig, commands)
def _makeConfigTest(config):
# Make sure the support directories exist, which is needed to create
# the temporary file %t below.
sourceRoot = os.path.join(config.test_exec_root, "__config_src__")
execRoot = os.path.join(config.test_exec_root, "__config_exec__")
for supportDir in (sourceRoot, execRoot):
if not os.path.exists(supportDir):
os.makedirs(supportDir)
# Create a dummy test suite and single dummy test inside it. As part of
# the Lit configuration, automatically do the equivalent of 'mkdir %T'
# and 'rm -r %T' to avoid cluttering the build directory.
suite = lit.Test.TestSuite("__config__", sourceRoot, execRoot, config)
tmp = tempfile.NamedTemporaryFile(dir=sourceRoot, delete=False, suffix=".cpp")
tmp.close()
pathInSuite = [os.path.relpath(tmp.name, sourceRoot)]
class TestWrapper(lit.Test.Test):
def __enter__(self):
testDir, _ = libcxx.test.format._getTempPaths(self)
os.makedirs(testDir)
return self
def __exit__(self, *args):
testDir, _ = libcxx.test.format._getTempPaths(self)
shutil.rmtree(testDir)
os.remove(tmp.name)
return TestWrapper(suite, pathInSuite, config)
@_memoizeExpensiveOperation(lambda c, s, f=[]: (c.substitutions, c.environment, s, f))
def sourceBuilds(config, source, additionalFlags=[]):
"""
Return whether the program in the given string builds successfully.
This is done by compiling and linking a program that consists of the given
source with the %{cxx} substitution, and seeing whether that succeeds. If
any additional flags are passed, they are appended to the compiler invocation.
"""
with _makeConfigTest(config) as test:
with open(test.getSourcePath(), "w") as sourceFile:
sourceFile.write(source)
_, _, exitCode, _, _ = _executeWithFakeConfig(
test, ["%{{build}} {}".format(" ".join(additionalFlags))]
)
return exitCode == 0
@_memoizeExpensiveOperation(
lambda c, p, args=None: (c.substitutions, c.environment, p, args)
)
def programOutput(config, program, args=None):
"""
Compiles a program for the test target, run it on the test target and return
the output.
Note that execution of the program is done through the %{exec} substitution,
which means that the program may be run on a remote host depending on what
%{exec} does.
"""
if args is None:
args = []
with _makeConfigTest(config) as test:
with open(test.getSourcePath(), "w") as source:
source.write(program)
_, err, exitCode, _, buildcmd = _executeWithFakeConfig(test, ["%{build}"])
if exitCode != 0:
raise ConfigurationCompilationError(
"Failed to build program, cmd:\n{}\nstderr is:\n{}".format(
buildcmd, err
)
)
out, err, exitCode, _, runcmd = _executeWithFakeConfig(
test, ["%{{run}} {}".format(" ".join(args))]
)
if exitCode != 0:
raise ConfigurationRuntimeError(
"Failed to run program, cmd:\n{}\nstderr is:\n{}".format(runcmd, err)
)
return out
@_memoizeExpensiveOperation(
lambda c, p, args=None: (c.substitutions, c.environment, p, args)
)
def programSucceeds(config, program, args=None):
"""
Compiles a program for the test target, run it on the test target and return
whether it completed successfully.
Note that execution of the program is done through the %{exec} substitution,
which means that the program may be run on a remote host depending on what
%{exec} does.
"""
try:
programOutput(config, program, args)
except ConfigurationRuntimeError:
return False
return True
@_memoizeExpensiveOperation(lambda c, f: (c.substitutions, c.environment, f))
def tryCompileFlag(config, flag):
"""
Try using the given compiler flag and return the exit code along with stdout and stderr.
"""
# fmt: off
with _makeConfigTest(config) as test:
out, err, exitCode, timeoutInfo, _ = _executeWithFakeConfig(test, [
"%{{cxx}} -xc++ {} -Werror -fsyntax-only %{{flags}} %{{compile_flags}} {}".format(os.devnull, flag)
])
return exitCode, out, err
# fmt: on
def hasCompileFlag(config, flag):
"""
Return whether the compiler in the configuration supports a given compiler flag.
This is done by executing the %{cxx} substitution with the given flag and
checking whether that succeeds.
"""
(exitCode, _, _) = tryCompileFlag(config, flag)
return exitCode == 0
@_memoizeExpensiveOperation(lambda c, s: (c.substitutions, c.environment, s))
def runScriptExitCode(config, script):
"""
Runs the given script as a Lit test, and returns the exit code of the execution.
The script must be a list of commands, each of which being something that
could appear on the right-hand-side of a `RUN:` keyword.
"""
with _makeConfigTest(config) as test:
_, _, exitCode, _, _ = _executeWithFakeConfig(test, script)
return exitCode
@_memoizeExpensiveOperation(lambda c, s: (c.substitutions, c.environment, s))
def commandOutput(config, command):
"""
Runs the given script as a Lit test, and returns the output.
If the exit code isn't 0 an exception is raised.
The script must be a list of commands, each of which being something that
could appear on the right-hand-side of a `RUN:` keyword.
"""
with _makeConfigTest(config) as test:
out, err, exitCode, _, cmd = _executeWithFakeConfig(test, command)
if exitCode != 0:
raise ConfigurationRuntimeError(
"Failed to run command: {}\nstderr is:\n{}".format(cmd, err)
)
return out
@_memoizeExpensiveOperation(lambda c, l: (c.substitutions, c.environment, l))
def hasAnyLocale(config, locales):
"""
Return whether the runtime execution environment supports a given locale.
Different systems may use different names for a locale, so this function checks
whether any of the passed locale names is supported by setlocale() and returns
true if one of them works.
This is done by executing a program that tries to set the given locale using
%{exec} -- this means that the command may be executed on a remote host
depending on the %{exec} substitution.
"""
program = """
#include <stddef.h>
#if defined(_LIBCPP_HAS_NO_LOCALIZATION)
int main(int, char**) { return 1; }
#else
#include <locale.h>
int main(int argc, char** argv) {
for (int i = 1; i < argc; i++) {
if (::setlocale(LC_ALL, argv[i]) != NULL) {
return 0;
}
}
return 1;
}
#endif
"""
return programSucceeds(config, program, args=[shlex.quote(l) for l in locales])
@_memoizeExpensiveOperation(lambda c, flags="": (c.substitutions, c.environment, flags))
def compilerMacros(config, flags=""):
"""
Return a dictionary of predefined compiler macros.
The keys are strings representing macros, and the values are strings
representing what each macro is defined to.
If the optional `flags` argument (a string) is provided, these flags will
be added to the compiler invocation when generating the macros.
"""
with _makeConfigTest(config) as test:
with open(test.getSourcePath(), "w") as sourceFile:
sourceFile.write(
"""
#if __has_include(<__config>)
# include <__config>
#endif
"""
)
unparsedOutput, err, exitCode, _, cmd = _executeWithFakeConfig(
test, ["%{{cxx}} %s -dM -E %{{flags}} %{{compile_flags}} {}".format(flags)]
)
if exitCode != 0:
raise ConfigurationCompilationError(
"Failed to retrieve compiler macros, compiler invocation is:\n{}\nstderr is:\n{}".format(
cmd, err
)
)
parsedMacros = dict()
defines = (
l.strip() for l in unparsedOutput.split("\n") if l.startswith("#define ")
)
for line in defines:
line = line[len("#define ") :]
macro, _, value = line.partition(" ")
parsedMacros[macro] = value
return parsedMacros
def featureTestMacros(config, flags=""):
"""
Return a dictionary of feature test macros.
The keys are strings representing feature test macros, and the values are
integers representing the value of the macro.
"""
allMacros = compilerMacros(config, flags)
return {
m: int(v.rstrip("LlUu"))
for (m, v) in allMacros.items()
if m.startswith("__cpp_")
}
def _getSubstitution(substitution, config):
for (orig, replacement) in config.substitutions:
if orig == substitution:
return replacement
raise ValueError('Substitution {} is not in the config.'.format(substitution))
def _appendToSubstitution(substitutions, key, value):
return [(k, v + " " + value) if k == key else (k, v) for (k, v) in substitutions]
def _prependToSubstitution(substitutions, key, value):
return [(k, value + " " + v) if k == key else (k, v) for (k, v) in substitutions]
def _ensureFlagIsSupported(config, flag):
(exitCode, out, err) = tryCompileFlag(config, flag)
assert (
exitCode == 0
), f"Trying to enable compiler flag {flag}, which is not supported. stdout was:\n{out}\n\nstderr was:\n{err}"
class ConfigAction(object):
"""
This class represents an action that can be performed on a Lit TestingConfig
object.
Examples of such actions are adding or modifying substitutions, Lit features,
etc. This class only provides the interface of such actions, and it is meant
to be subclassed appropriately to create new actions.
"""
def applyTo(self, config):
"""
Applies the action to the given configuration.
This should modify the configuration object in place, and return nothing.
If applying the action to the configuration would yield an invalid
configuration, and it is possible to diagnose it here, this method
should produce an error. For example, it should be an error to modify
a substitution in a way that we know for sure is invalid (e.g. adding
a compiler flag when we know the compiler doesn't support it). Failure
to do so early may lead to difficult-to-diagnose issues down the road.
"""
pass
def pretty(self, config, litParams):
"""
Returns a short and human-readable string describing what this action does.
This is used for logging purposes when running the test suite, so it should
be kept concise.
"""
pass
class AddFeature(ConfigAction):
"""
This action defines the given Lit feature when running the test suite.
The name of the feature can be a string or a callable, in which case it is
called with the configuration to produce the feature name (as a string).
"""
def __init__(self, name):
self._name = name
def _getName(self, config):
name = self._name(config) if callable(self._name) else self._name
if not isinstance(name, str):
raise ValueError(
"Lit feature did not resolve to a string (got {})".format(name)
)
return name
def applyTo(self, config):
config.available_features.add(self._getName(config))
def pretty(self, config, litParams):
return "add Lit feature {}".format(self._getName(config))
class AddFlag(ConfigAction):
"""
This action adds the given flag to the %{flags} substitution.
The flag can be a string or a callable, in which case it is called with the
configuration to produce the actual flag (as a string).
"""
def __init__(self, flag):
self._getFlag = lambda config: flag(config) if callable(flag) else flag
def applyTo(self, config):
flag = self._getFlag(config)
_ensureFlagIsSupported(config, flag)
config.substitutions = _appendToSubstitution(
config.substitutions, "%{flags}", flag
)
def pretty(self, config, litParams):
return "add {} to %{{flags}}".format(self._getFlag(config))
class AddFlagIfSupported(ConfigAction):
"""
This action adds the given flag to the %{flags} substitution, only if
the compiler supports the flag.
The flag can be a string or a callable, in which case it is called with the
configuration to produce the actual flag (as a string).
"""
def __init__(self, flag):
self._getFlag = lambda config: flag(config) if callable(flag) else flag
def applyTo(self, config):
flag = self._getFlag(config)
if hasCompileFlag(config, flag):
config.substitutions = _appendToSubstitution(
config.substitutions, "%{flags}", flag
)
def pretty(self, config, litParams):
return "add {} to %{{flags}}".format(self._getFlag(config))
class AddCompileFlag(ConfigAction):
"""
This action adds the given flag to the %{compile_flags} substitution.
The flag can be a string or a callable, in which case it is called with the
configuration to produce the actual flag (as a string).
"""
def __init__(self, flag):
self._getFlag = lambda config: flag(config) if callable(flag) else flag
def applyTo(self, config):
flag = self._getFlag(config)
_ensureFlagIsSupported(config, flag)
config.substitutions = _appendToSubstitution(
config.substitutions, "%{compile_flags}", flag
)
def pretty(self, config, litParams):
return "add {} to %{{compile_flags}}".format(self._getFlag(config))
class AddLinkFlag(ConfigAction):
"""
This action appends the given flag to the %{link_flags} substitution.
The flag can be a string or a callable, in which case it is called with the
configuration to produce the actual flag (as a string).
"""
def __init__(self, flag):
self._getFlag = lambda config: flag(config) if callable(flag) else flag
def applyTo(self, config):
flag = self._getFlag(config)
_ensureFlagIsSupported(config, flag)
config.substitutions = _appendToSubstitution(
config.substitutions, "%{link_flags}", flag
)
def pretty(self, config, litParams):
return "append {} to %{{link_flags}}".format(self._getFlag(config))
class PrependLinkFlag(ConfigAction):
"""
This action prepends the given flag to the %{link_flags} substitution.
The flag can be a string or a callable, in which case it is called with the
configuration to produce the actual flag (as a string).
"""
def __init__(self, flag):
self._getFlag = lambda config: flag(config) if callable(flag) else flag
def applyTo(self, config):
flag = self._getFlag(config)
_ensureFlagIsSupported(config, flag)
config.substitutions = _prependToSubstitution(
config.substitutions, "%{link_flags}", flag
)
def pretty(self, config, litParams):
return "prepend {} to %{{link_flags}}".format(self._getFlag(config))
class AddOptionalWarningFlag(ConfigAction):
"""
This action adds the given warning flag to the %{compile_flags} substitution,
if it is supported by the compiler.
The flag can be a string or a callable, in which case it is called with the
configuration to produce the actual flag (as a string).
"""
def __init__(self, flag):
self._getFlag = lambda config: flag(config) if callable(flag) else flag
def applyTo(self, config):
flag = self._getFlag(config)
# Use -Werror to make sure we see an error about the flag being unsupported.
if hasCompileFlag(config, "-Werror " + flag):
config.substitutions = _appendToSubstitution(
config.substitutions, "%{compile_flags}", flag
)
def pretty(self, config, litParams):
return "add {} to %{{compile_flags}}".format(self._getFlag(config))
class AddSubstitution(ConfigAction):
"""
This action adds the given substitution to the Lit configuration.
The substitution can be a string or a callable, in which case it is called
with the configuration to produce the actual substitution (as a string).
"""
def __init__(self, key, substitution):
self._key = key
self._getSub = (
lambda config: substitution(config)
if callable(substitution)
else substitution
)
def applyTo(self, config):
key = self._key
sub = self._getSub(config)
config.substitutions.append((key, sub))
def pretty(self, config, litParams):
return "add substitution {} = {}".format(self._key, self._getSub(config))
class Feature(object):
"""
Represents a Lit available feature that is enabled whenever it is supported.
A feature like this informs the test suite about a capability of the compiler,
platform, etc. Unlike Parameters, it does not make sense to explicitly
control whether a Feature is enabled -- it should be enabled whenever it
is supported.
"""
def __init__(self, name, actions=None, when=lambda _: True):
"""
Create a Lit feature for consumption by a test suite.
- name
The name of the feature. This is what will end up in Lit's available
features if the feature is enabled. This can be either a string or a
callable, in which case it is passed the TestingConfig and should
generate a string representing the name of the feature.
- actions
An optional list of ConfigActions to apply when the feature is supported.
An AddFeature action is always created regardless of any actions supplied
here -- these actions are meant to perform more than setting a corresponding
Lit feature (e.g. adding compiler flags). If 'actions' is a callable, it
is called with the current configuration object to generate the actual
list of actions.
- when
A callable that gets passed a TestingConfig and should return a
boolean representing whether the feature is supported in that
configuration. For example, this can use `hasCompileFlag` to
check whether the compiler supports the flag that the feature
represents. If omitted, the feature will always be considered
supported.
"""
self._name = name
self._actions = [] if actions is None else actions
self._isSupported = when
def _getName(self, config):
name = self._name(config) if callable(self._name) else self._name
if not isinstance(name, str):
raise ValueError(
"Feature did not resolve to a name that's a string, got {}".format(name)
)
return name
def getActions(self, config):
"""
Return the list of actions associated to this feature.
If the feature is not supported, an empty list is returned.
If the feature is supported, an `AddFeature` action is automatically added
to the returned list of actions, in addition to any actions provided on
construction.
"""
if not self._isSupported(config):
return []
else:
actions = (
self._actions(config) if callable(self._actions) else self._actions
)
return [AddFeature(self._getName(config))] + actions
def pretty(self, config):
"""
Returns the Feature's name.
"""
return self._getName(config)
def _str_to_bool(s):
"""
Convert a string value to a boolean.
True values are "y", "yes", "t", "true", "on" and "1", regardless of capitalization.
False values are "n", "no", "f", "false", "off" and "0", regardless of capitalization.
"""
trueVals = ["y", "yes", "t", "true", "on", "1"]
falseVals = ["n", "no", "f", "false", "off", "0"]
lower = s.lower()
if lower in trueVals:
return True
elif lower in falseVals:
return False
else:
raise ValueError("Got string '{}', which isn't a valid boolean".format(s))
def _parse_parameter(s, type):
if type is bool and isinstance(s, str):
return _str_to_bool(s)
elif type is list and isinstance(s, str):
return [x.strip() for x in s.split(",") if x.strip()]
return type(s)
class Parameter(object):
"""
Represents a parameter of a Lit test suite.
Parameters are used to customize the behavior of test suites in a user
controllable way. There are two ways of setting the value of a Parameter.
The first one is to pass `--param <KEY>=<VALUE>` when running Lit (or
equivalently to set `litConfig.params[KEY] = VALUE` somewhere in the
Lit configuration files. This method will set the parameter globally for
all test suites being run.
The second method is to set `config.KEY = VALUE` somewhere in the Lit
configuration files, which sets the parameter only for the test suite(s)
that use that `config` object.
Parameters can have multiple possible values, and they can have a default
value when left unspecified. They can also have any number of ConfigActions
associated to them, in which case the actions will be performed on the
TestingConfig if the parameter is enabled. Depending on the actions
associated to a Parameter, it may be an error to enable the Parameter
if some actions are not supported in the given configuration. For example,
trying to set the compilation standard to C++23 when `-std=c++23` is not
supported by the compiler would be an error.
"""
def __init__(self, name, type, help, actions, choices=None, default=None):
"""
Create a Lit parameter to customize the behavior of a test suite.
- name
The name of the parameter that can be used to set it on the command-line.
On the command-line, the parameter can be set using `--param <name>=<value>`
when running Lit. This must be non-empty.
- choices
An optional non-empty set of possible values for this parameter. If provided,
this must be anything that can be iterated. It is an error if the parameter
is given a value that is not in that set, whether explicitly or through a
default value.
- type
A callable that can be used to parse the value of the parameter given
on the command-line. As a special case, using the type `bool` also
allows parsing strings with boolean-like contents, and the type `list`
will parse a string delimited by commas into a list of the substrings.
- help
A string explaining the parameter, for documentation purposes.
TODO: We should be able to surface those from the Lit command-line.
- actions
A callable that gets passed the parsed value of the parameter (either
the one passed on the command-line or the default one), and that returns
a list of ConfigAction to perform given the value of the parameter.
All the ConfigAction must be supported in the given configuration.
- default
An optional default value to use for the parameter when no value is
provided on the command-line. If the default value is a callable, it
is called with the TestingConfig and should return the default value
for the parameter. Whether the default value is computed or specified
directly, it must be in the 'choices' provided for that Parameter.
"""
self._name = name
if len(self._name) == 0:
raise ValueError("Parameter name must not be the empty string")
if choices is not None:
self._choices = list(choices) # should be finite
if len(self._choices) == 0:
raise ValueError(
"Parameter '{}' must be given at least one possible value".format(
self._name
)
)
else:
self._choices = None
self._parse = lambda x: _parse_parameter(x, type)
self._help = help
self._actions = actions
self._default = default
def _getValue(self, config, litParams):
"""
Return the value of the parameter given the configuration objects.
"""
param = getattr(config, self.name, None)
param = litParams.get(self.name, param)
if param is None and self._default is None:
raise ValueError(
"Parameter {} doesn't have a default value, but it was not specified in the Lit parameters or in the Lit config".format(
self.name
)
)
getDefault = (
lambda: self._default(config) if callable(self._default) else self._default
)
if param is not None:
(pretty, value) = (param, self._parse(param))
else:
value = getDefault()
pretty = "{} (default)".format(value)
if self._choices and value not in self._choices:
raise ValueError(
"Got value '{}' for parameter '{}', which is not in the provided set of possible choices: {}".format(
value, self.name, self._choices
)
)
return (pretty, value)
@property
def name(self):
"""
Return the name of the parameter.
This is the name that can be used to set the parameter on the command-line
when running Lit.
"""
return self._name
def getActions(self, config, litParams):
"""
Return the list of actions associated to this value of the parameter.
"""
(_, parameterValue) = self._getValue(config, litParams)
return self._actions(parameterValue)
def pretty(self, config, litParams):
"""
Return a pretty representation of the parameter's name and value.
"""
(prettyParameterValue, _) = self._getValue(config, litParams)
return "{}={}".format(self.name, prettyParameterValue)