blob: ba600e62946ee629a2106ca5c18672ee180938ba [file] [log] [blame]
# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
Conditions represent a build configuration under which to disable a test.
This file contains a canonical list of conditions and their properties, and code
for composing, parsing, and simplifying them. This is independent of their
representation within any particular test format.
"""
import collections
import functools
import itertools
import types
from typing import List, Optional, Union, Set, Tuple
import errors
class BaseCondition:
"""BaseCondition is a class for sentinel values ALWAYS and NEVER."""
# These represent a condition that's always true, and a condition that's never
# true.
ALWAYS = BaseCondition()
NEVER = BaseCondition()
@functools.total_ordering
class Terminal:
"""A boolean variable, the value of which depends on the configuration."""
def __init__(self, name: str, group: Optional[str]):
"""
Args:
name: The generic name for this terminal. Used to specify conditions on
the command line.
group: The group to which this condition belongs. Every Terminal with the
same group is mutually exclusive. For example, "os" - we can't be
compiling for Linux and Mac at the same time.
We also add fields for each test format, initialised to None. It's up to the
file defining the relevant code to fill these out.
"""
self.name: str = name
self.group: Optional[str] = group
self.gtest_info = None
self.expectations_info = None
def __str__(self):
return (f"Terminal('{self.name}')")
def __repr__(self):
return str(self)
def __lt__(self, other):
"""Define a consistent ordering for conditions written into test files"""
if not isinstance(other, Terminal):
return False
# Ungrouped terminals should compare greater than grouped, and compare based
# on name to each-other.
if (self.group is not None) == (other.group is not None):
return (self.group, self.name) < (other.group, other.name)
return self.group is not None
# TODO: We could probably just use object identity here (and for __hash__),
# since we only use a fixed set of Terminal objects.
def __eq__(self, other):
# Names are expected to be unique keys
return isinstance(other, Terminal) and self.name == other.name
def __hash__(self):
return hash(self.name)
# TODO: We should think about how to incorporate overlapping conditions. For
# instance, multiple versions of the same OS, where we might just specify "Mac"
# to refer to any version, or "Mac-10.15" for that specific version. Or "x86" to
# refer to the architecture as a whole, regardless of 32 vs 64-bit.
#
# We could handle this via expanding the higher-level one, for instance "Mac"
# being parsed directly into (or, ["Mac-10.15", "Mac-11", ...]). But we'd also
# need to handle this on the other end, to reduce it back down after
# simplifying.
TERMINALS = [
Terminal('android', group='os'),
Terminal('chromeos', group='os'),
Terminal('fuchsia', group='os'),
Terminal('ios', group='os'),
Terminal('linux', group='os'),
Terminal('mac', group='os'),
Terminal('win', group='os'),
Terminal('arm64', group='arch'),
Terminal('x86', group='arch'),
Terminal('x86-64', group='arch'),
Terminal('lacros', group='lacros/ash'),
Terminal('ash', group='lacros/ash'),
Terminal('asan', group=None),
Terminal('msan', group=None),
Terminal('tsan', group=None),
]
# Terminals should have unique names.
assert len({t.name for t in TERMINALS}) == len(TERMINALS)
# A condition can be one of three things:
# 1. A BaseCondition (ALWAYS or NEVER).
# 2. An operator, represented as a tuple with the operator name followed by its
# arguments.
# 3. A Terminal.
Condition = Union[BaseCondition, tuple, Terminal]
def get_term(name: str) -> Terminal:
"""Look up a Terminal by name."""
t = next((t for t in TERMINALS if t.name == name), None)
if t is not None:
return t
raise ValueError(f"Unknown condition '{name}'")
# TODO: We should check that the parsed condition makes sense with respect to
# condition groups. For instance, the condition 'linux & mac' can never be true.
def parse(condition_strs: List[str]) -> Condition:
"""Parse a list of condition strings, as passed on the command line.
Each element of condition_strs is a set of Terminal names joined with '&'s.
The list is implicitly 'or'ed together.
"""
# When no conditions are given, this is taken to mean "always".
if not condition_strs:
return ALWAYS
try:
return op_of('or', [
op_of('and', [get_term(x.strip()) for x in cond.split('&')])
for cond in condition_strs
])
except ValueError as e:
# Catching the exception raised by get_term.
valid_conds = '\n'.join(sorted(f'\t{term.name}' for term in TERMINALS))
raise errors.UserError(f"{e}\nValid conditions are:\n{valid_conds}")
def op_of(op: str, args: List[Condition]) -> Condition:
"""Make an operator, simplifying the single-argument case."""
if len(args) == 1:
return args[0]
return (op, args)
def merge(existing_cond: Condition, new_cond: Condition) -> Condition:
"""Merge two conditions together.
Given an existing condition, parsed from a file, and a new condition to be
added, combine the two to produce a merged condition.
"""
# If currently ALWAYS, merging would only ever produce ALWAYS too. In this
# case the user likely want to change the conditions or re-enable.
if existing_cond == ALWAYS:
return new_cond
# If currently NEVER, ignore the current value - NEVER or X = X
if existing_cond == NEVER:
return new_cond
# If new cond is ALWAYS, ignore the current value - X or ALWAYS = ALWAYS
if new_cond == ALWAYS:
return ALWAYS
# Similar to the first branch, if the user has specified NEVER then ignore the
# current value, as they're re-enabling it.
if new_cond == NEVER:
return NEVER
# Otherwise, take the union of the two conditions
cond = ('or', [existing_cond, new_cond])
return simplify(cond)
def generate_condition_groups(terms: List[Terminal]) -> List[Set[Terminal]]:
"""Partition a list of Terminals by their 'group' attribute."""
by_group = collections.defaultdict(set)
ungrouped = []
for term in terms:
if term.group is not None:
by_group[term.group].add(term)
else:
# Every Terminal without a 'group' attribute gets its own group, as
# they're not mutually exclusive with anything.
ungrouped.append({term})
groups = list(by_group.values())
groups += ungrouped
return groups
# Pre-compute condition groups for use when simplifying.
CONDITION_GROUPS = generate_condition_groups(TERMINALS)
def simplify(cond: Condition) -> Condition:
"""Given a Condition, produce an equivalent but simpler Condition.
This function uses the Quine-McCluskey algorithm. It's not implemented very
efficiently, but it works and is fast enough for now.
"""
if isinstance(cond, BaseCondition):
return cond
# Quine-McCluskey uses three values - true, false, and "don't care". The
# latter represents two things:
# * For values of input variables, the case where either true or false will
# suffice. This is used when combining conditions that differ only in that
# variable into one where that variable isn't specified.
# * For resulting values of the function, the case where we don't care what
# value the function produces. We use this for mutually exclusive
# conditions. For example, (linux & mac) is impossible, so we assign this a
# "don't care" value. This avoids producing a bunch of redundant stuff like
# (linux & ~mac).
DONT_CARE = 2
# First, compute the truth table of the function. We produce a set of
# "minterms", which are the combinations of input values for which the output
# is 1. We also produce a set of "don't care" values, which are the
# combinations of input values for which we don't care what the output is.
#
# Both of these are represented via tuples of {0, 1} values, which the value
# at index 'i' corresponds to variables[i].
# TODO: This could use a more efficient representation. Some packed integer
# using two bits per element or something.
variables = list(sorted(find_terminals(cond)))
dont_cares = []
min_terms = []
for possible_input in itertools.product([0, 1], repeat=len(variables)):
# Generate every possible input, and evaluate the condition for that input.
# This is exponential in the number of variables, but in practice the number
# should be low (and is strictly bounded by len(TERMINALS)).
true_vars = {variables[i] for i, val in enumerate(possible_input) if val}
if any(len(group & true_vars) > 1 for group in CONDITION_GROUPS):
# Any combination which sets more than one variable from the same group to
# 1 is impossible, so we don't care about the output.
dont_cares.append(possible_input)
elif evaluate(cond, true_vars):
min_terms.append(possible_input)
# The meat of the algorithm. Try to combine minterms which differ by only a
# single variable.
# For example, (0, 1) and (0, 0) can be combined into (0, DONT_CARE), as the
# value of the second variable doesn't affect the output.
#
# We work in rounds, combining together all minterms from the previous round
# that can be. This may include combining the same minterm with multiple
# different minterms. Keep going until no more minterms can be combined.
#
# Any minterm which can't be combined with another is a "prime implicant",
# that is, it's a necessary part of the representation of the function. The
# union of all prime implicants specifies the function.
combined_some_minterms = True
prev_round = set(min_terms + dont_cares)
prime_implicants: List[Tuple] = []
while combined_some_minterms:
new_implicants = set()
used = set()
combined_some_minterms = False
# TODO: Rather than taking combinations of the entire set of minterms, we
# can instead group by the number of '1's. Then we only need to combine
# elements from adjacent groups.
for a, b in itertools.combinations(prev_round, 2):
diff_index = None
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
if diff_index is not None:
# In this case there are at least two points of difference, so these
# two can't be combined.
break
diff_index = i
else:
if diff_index is not None:
# Replace the sole differing variable with DONT_CARE to produce the
# combined minterm. Flag both inputs as having been used, and
# therefore as not being prime implicants.
new_implicants.add(a[:diff_index] + (DONT_CARE, ) +
a[diff_index + 1:])
used |= {a, b}
combined_some_minterms = True
# Collect any minterms that weren't used in this round as prime implicants.
prime_implicants.extend(prev_round - used)
prev_round = new_implicants
# TODO: This isn't yet minimal - the set of prime implicants may have some
# redundancy which can be reduced further. For now we just accept that and
# use this set as-is. If we encounter any case for which we don't produce the
# minimal result, we'll need to implement something like Petrick's method.
# Finally, create our simplified condition using the computed set of prime
# implicants.
# TODO: Ordering. We should define some stable ordering to use. We probably
# want to group stuff based on CONDITION_GROUPS, so all the OS-related
# conditions are together, for instance. And then alphabetically within that.
or_args: List[Condition] = []
for pi in sorted(prime_implicants):
and_args: List[Condition] = []
for i, x in enumerate(pi):
if x == DONT_CARE:
continue
var = variables[i]
if x == 0:
and_args.append(('not', var))
else:
assert x == 1
and_args.append(var)
or_args.append(op_of('and', and_args))
return op_of('or', or_args)
def find_terminals(cond: Condition) -> Set[Terminal]:
"""Find all leaf Terminal nodes of this Condition."""
if isinstance(cond, BaseCondition):
return set()
if isinstance(cond, Terminal):
return {cond}
assert isinstance(cond, tuple)
op, args = cond
if op == 'not':
return find_terminals(args)
return {var for arg in args for var in find_terminals(arg)}
def evaluate(cond: Condition, true_vars: Set[Terminal]) -> bool:
"""Evaluate a condition with a given set of true variables."""
if isinstance(cond, BaseCondition):
return cond is ALWAYS
if isinstance(cond, Terminal):
return cond in true_vars
# => must be a tuple
op, args = cond
if op == 'not':
return not evaluate(args, true_vars)
return {'or': any, 'and': all}[op](evaluate(arg, true_vars) for arg in args)