First check-in of configuration overhead measurement tool

i.e. first check-in of [Measuring Configuration Overhead](https://docs.google.com/document/d/10ZxO2wZdKJATnYBqAm22xT1k5r4Vp6QX96TkqSUIhs0/edit).

This just establishes supporting structure. The tool is not yet functional.

Specifically:
- `types.py`: defines data structures for "configuration" and "configured target"
- `bazel_api.py`: API to translate `bazel cquery` and `bazel config` calls into the above data structures
- `bazel_api_test.py`: tests
- `ctexplain.py`: stump of an entry point

The tests utilize an existing Python test framework for invoking Bazel (`//src/test/py/bazel:test_base`).

Work towards https://github.com/bazelbuild/bazel/issues/10613

Closes #11511.

PiperOrigin-RevId: 321409588
diff --git a/tools/ctexplain/bazel_api_test.py b/tools/ctexplain/bazel_api_test.py
new file mode 100644
index 0000000..3587251
--- /dev/null
+++ b/tools/ctexplain/bazel_api_test.py
@@ -0,0 +1,147 @@
+# Lint as: python3
+# Copyright 2020 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for bazel_api.py."""
+import os
+import unittest
+from src.test.py.bazel import test_base
+from tools.ctexplain.bazel_api import BazelApi
+from tools.ctexplain.types import HostConfiguration
+from tools.ctexplain.types import NullConfiguration
+
+
+class BazelApiTest(test_base.TestBase):
+
+  _bazel_api: BazelApi = None
+
+  def setUp(self):
+    test_base.TestBase.setUp(self)
+    self._bazel_api = BazelApi(self.RunBazel)
+    self.ScratchFile('WORKSPACE')
+    self.CreateWorkspaceWithDefaultRepos('repo/WORKSPACE')
+
+  def tearDown(self):
+    test_base.TestBase.tearDown(self)
+
+  def testBasicCquery(self):
+    self.ScratchFile('testapp/BUILD', [
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    res = self._bazel_api.cquery(['//testapp:all'])
+    success = res[0]
+    cts = res[2]
+    self.assertTrue(success)
+    self.assertEqual(len(cts), 1)
+    self.assertEqual(cts[0].label, '//testapp:fg')
+    self.assertIsNone(cts[0].config)
+    self.assertGreater(len(cts[0].config_hash), 10)
+    self.assertIn('PlatformConfiguration', cts[0].transitive_fragments)
+
+  def testFailedCquery(self):
+    self.ScratchFile('testapp/BUILD', [
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    (success, stderr, cts) = self._bazel_api.cquery(['//testapp:typo'])
+    self.assertFalse(success)
+    self.assertEqual(len(cts), 0)
+    self.assertIn("target 'typo' not declared in package 'testapp'",
+                  os.linesep.join(stderr))
+
+  def testTransitiveFragmentsAccuracy(self):
+    self.ScratchFile('testapp/BUILD', [
+        'filegroup(name = "fg", srcs = ["a.file"])',
+        'filegroup(name = "ccfg", srcs = [":ccbin"])',
+        'cc_binary(name = "ccbin", srcs = ["ccbin.cc"])'
+    ])
+    cts1 = self._bazel_api.cquery(['//testapp:fg'])[2]
+    self.assertNotIn('CppConfiguration', cts1[0].transitive_fragments)
+    cts2 = self._bazel_api.cquery(['//testapp:ccfg'])[2]
+    self.assertIn('CppConfiguration', cts2[0].transitive_fragments)
+
+  def testGetTargetConfig(self):
+    self.ScratchFile('testapp/BUILD', [
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    cts = self._bazel_api.cquery(['//testapp:fg'])[2]
+    config = self._bazel_api.get_config(cts[0].config_hash)
+    expected_fragments = ['PlatformConfiguration', 'JavaConfiguration']
+    for exp in expected_fragments:
+      self.assertIn(exp, config.fragments)
+    core_options = config.options['CoreOptions']
+    self.assertIsNotNone(core_options)
+    self.assertIn(('stamp', 'false'), core_options.items())
+
+  def testGetHostConfig(self):
+    self.ScratchFile('testapp/BUILD', [
+        'genrule(',
+        '    name = "g",',
+        '    srcs = [],',
+        '    cmd = "",',
+        '    outs = ["g.out"],',
+        '    tools = [":fg"])',
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    query = ['//testapp:fg', '--universe_scope=//testapp:g']
+    cts = self._bazel_api.cquery(query)[2]
+    config = self._bazel_api.get_config(cts[0].config_hash)
+    self.assertIsInstance(config, HostConfiguration)
+    # We don't currently populate or read a host configuration's details.
+    self.assertEqual(len(config.fragments), 0)
+    self.assertEqual(len(config.options), 0)
+
+  def testGetNullConfig(self):
+    self.ScratchFile('testapp/BUILD', [
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    cts = self._bazel_api.cquery(['//testapp:a.file'])[2]
+    config = self._bazel_api.get_config(cts[0].config_hash)
+    self.assertIsInstance(config, NullConfiguration)
+    # Null configurations have no information by definition.
+    self.assertEqual(len(config.fragments), 0)
+    self.assertEqual(len(config.options), 0)
+
+  def testConfigWithDefines(self):
+    self.ScratchFile('testapp/BUILD', [
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    cquery_args = ['//testapp:fg', '--define', 'a=b']
+    cts = self._bazel_api.cquery(cquery_args)[2]
+    config = self._bazel_api.get_config(cts[0].config_hash)
+    user_defined_options = config.options['user-defined']
+    self.assertIsNotNone(user_defined_options)
+    self.assertDictEqual(user_defined_options._dict, {'--define:a': 'b'})
+
+  def testConfigWithStarlarkFlags(self):
+    self.ScratchFile('testapp/defs.bzl', [
+        'def _flag_impl(settings, attr):', '  pass', 'string_flag = rule(',
+        '  implementation = _flag_impl,',
+        '  build_setting = config.string(flag = True)'
+        ')'
+    ])
+    self.ScratchFile('testapp/BUILD', [
+        'load(":defs.bzl", "string_flag")',
+        'string_flag(name = "my_flag", build_setting_default = "nada")',
+        'filegroup(name = "fg", srcs = ["a.file"])',
+    ])
+    cquery_args = ['//testapp:fg', '--//testapp:my_flag', 'algo']
+    cts = self._bazel_api.cquery(cquery_args)[2]
+    config = self._bazel_api.get_config(cts[0].config_hash)
+    user_defined_options = config.options['user-defined']
+    self.assertIsNotNone(user_defined_options)
+    self.assertDictEqual(user_defined_options._dict,
+                         {'//testapp:my_flag': 'algo'})
+
+
+if __name__ == '__main__':
+  unittest.main()