Add checker.ConstraintSuite.

- A ConstraintSuite is subclassed to define constraints,
similar to how unittest.TestCase is subclassed to define
test methods.
- Right now just checks the instance for methods named
"check..." and runs them. A check method should raise
an exception if a constraint is violated. Check methods
are passed a program and project ConfigBundle.
- Will improve error reporting in the future (e.g. run all
checks instead of failing on the first one, return names
of failing checks, etc.)

BUG=chromium:1051187
TEST=python3 -m unittest discover -p *test.py

Change-Id: Ic155c9f1a2b7b442c025fde460c503f3ce9c6c50
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/config/+/2054750
Commit-Queue: Andrew Lamb <andrewlamb@chromium.org>
Tested-by: Andrew Lamb <andrewlamb@chromium.org>
Reviewed-by: David Burger <dburger@chromium.org>
diff --git a/payload_utils/checker/constraint_suite.py b/payload_utils/checker/constraint_suite.py
new file mode 100644
index 0000000..085c587
--- /dev/null
+++ b/payload_utils/checker/constraint_suite.py
@@ -0,0 +1,62 @@
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Defines ConstraintSuite, which is subclassed to define constraints."""
+
+import inspect
+
+from bindings.src.config.api import config_bundle_pb2
+
+
+class InvalidConstraintSuiteError(Exception):
+  """Exception raised when an invalid ConstraintSuite is defined."""
+
+
+class ConstraintSuite:
+  """A class whose instances are suites of constraints.
+
+  Constraint authors should subclass ConstraintSuite and add methods starting
+  with "check" for each constraint they want to enforce. Each check method
+  should accept two ConfigBundles, with parameter names "project_config" and
+  "program_config". A check method is considered failed iff it raises an
+  Exception.
+
+  A ConstraintSuite subclass should group related constraints. For example a
+  suite to check form factor constraints could look like:
+
+  class FormFactorConstraintSuite(ConstraintSuite):
+
+    def checkFormFactorDefined(self, program_config, project_config):
+      ...
+
+    def checkFormFactorAllowedByProgram(self, program_config, project_config):
+      ...
+
+  A ConstraintSuite that defines no check methods will raise an exception on
+  initialization.
+  """
+
+  def __init__(self):
+    super().__init__()
+
+    def is_check(name, value):
+      return name.startswith('check') and inspect.ismethod(value)
+
+    self._checks = [
+        value for name, value in inspect.getmembers(self)
+        if is_check(name, value)
+    ]
+
+    if not self._checks:
+      raise InvalidConstraintSuiteError('No checks found on %s' % type(self))
+
+  def run_checks(self, program_config: config_bundle_pb2.ConfigBundle,
+                 project_config: config_bundle_pb2.ConfigBundle):
+    """Runs all of the checks on an instance.
+
+    Args:
+      program_config: The program's config, to pass to each check.
+      project_config: The project's config, to pass to each check.
+    """
+    for method in self._checks:
+      method(program_config=program_config, project_config=project_config)
diff --git a/payload_utils/checker/constraint_suite_test.py b/payload_utils/checker/constraint_suite_test.py
new file mode 100644
index 0000000..1a87e0a
--- /dev/null
+++ b/payload_utils/checker/constraint_suite_test.py
@@ -0,0 +1,69 @@
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Tests for constraint_suite."""
+
+import unittest
+
+from bindings.src.config.api.config_bundle_pb2 import ConfigBundle
+from bindings.src.config.api.design_pb2 import DesignList, Design
+from bindings.src.config.api.program_pb2 import ProgramList, Program
+
+from checker.constraint_suite import (ConstraintSuite,
+                                      InvalidConstraintSuiteError)
+
+
+class ValidConstraintSuite(ConstraintSuite):
+  """A valid constraint suite, that defines two checks."""
+
+  def _helper_method(self):
+    assert False, "helper_method should never be called"
+
+  def check_program_valid(self, program_config, project_config):
+    if program_config.programs.value[0].name != 'TestProgram1':
+      raise AssertionError("Program name must be 'TestProgram1'")
+
+  def check_project_valid(self, program_config, project_config):
+    if project_config.designs.value[0].name != 'TestDesign1':
+      raise AssertionError("Design name must be 'TestDesign1'")
+
+
+class InvalidConstraintSuite(ConstraintSuite):
+  """An invalid constraint suite, that defines no checks."""
+
+  def helper_method1(self):
+    pass
+
+  def helper_method2(self):
+    pass
+
+
+class ConstraintSuiteTest(unittest.TestCase):
+
+  def test_runs_checks(self):
+    """Tests running checks on a project that fulfills constraints."""
+    program_config = ConfigBundle(
+        programs=ProgramList(value=[Program(name='TestProgram1')]))
+    project_config = ConfigBundle(
+        designs=DesignList(value=[Design(name='TestDesign1')]))
+
+    ValidConstraintSuite().run_checks(
+        program_config=program_config, project_config=project_config)
+
+  def test_runs_checks_fails_constraint(self):
+    """Tests running checks on a project that violates constraints."""
+    program_config = ConfigBundle(
+        programs=ProgramList(value=[Program(name='TestProgram2')]))
+    project_config = ConfigBundle(
+        designs=DesignList(value=[Design(name='TestDesign1')]))
+
+    with self.assertRaisesRegex(AssertionError,
+                                "Program name must be 'TestProgram1'"):
+      ValidConstraintSuite().run_checks(
+          program_config=program_config, project_config=project_config)
+
+  def test_runs_checks_invalid_suite(self):
+    """Tests creating a ConstraintSuite with no checks."""
+    with self.assertRaisesRegex(InvalidConstraintSuiteError,
+                                'No checks found on.*InvalidConstraintSuite'):
+      InvalidConstraintSuite()