Support "*" wildcard in API export

The current implementation of ApiPackageFinder matches the top-level
contribution target using the name of the api_domain in
api_packages.json. Since mcombo files accept "*" as a wildcard to match
all apexes, add a new function to the ApiPackageFinder API that accepts
a generic filter.

api_packages.json now accepts an additional key `is_apex` to identify
whether an api_domain is an apex

Test: python -m test_find_api_packages

Change-Id: I0570948e7975a0e949ad5665952ff7a073d48990
diff --git a/inner_build/find_api_packages.py b/inner_build/find_api_packages.py
index 3db8e82..27a0a5f 100644
--- a/inner_build/find_api_packages.py
+++ b/inner_build/find_api_packages.py
@@ -17,8 +17,6 @@
 the Fully Qualified Bazel label of API domain contributions to API surfaces.
 """
 
-from collections import namedtuple
-
 import json
 import os
 
@@ -33,9 +31,8 @@
 # Directories inside inner_tree that will be searched for api_packages.json
 # This pruning improves the speed of the API export process
 INNER_TREE_SEARCH_DIRS = [
-    (
-        "build", "orchestrator"
-    ),  # TODO: Remove once build/orchestrator stops contributing to system.
+    ("build", "orchestrator"
+     ),  # TODO: Remove once build/orchestrator stops contributing to system.
     ("frameworks", "base"),
     ("packages", "modules")
 ]
@@ -56,15 +53,27 @@
 
 
 class ApiPackageDecodeException(Exception):
+
     def __init__(self, filepath: str, msg: str):
         self.filepath = filepath
         msg = f"Found malformed api_packages.json file at {filepath}: " + msg
         super().__init__(msg)
 
 
-ContributionData = namedtuple("ContributionData",
-                              ("api_domain", "api_contribution_bazel_label"))
+class ContributionData:
+    """Class to represent metadata of API contributions in api_packages.json."""
 
+    def __init__(self, api_domain, api_bazel_label, is_apex=False):
+        self.api_domain = api_domain
+        self.api_contribution_bazel_label = api_bazel_label
+        self.is_apex = is_apex
+
+    def __repr__(self):
+        props = [f"api_domain={self.api_domain}"]
+        props.append(f"api_contribution_bazel_label={self.api_contribution_bazel_label}")
+        props.append(f"is_apex={self.is_apex}")
+        props_joined = ", ".join(props)
+        return f"ContributionData({props_joined})"
 
 def read(filepath: str) -> ContributionData:
     """Deserialize the contents of the json file at <filepath>
@@ -78,6 +87,7 @@
         domain = json_contents.get("api_domain")
         package = json_contents.get("api_package")
         target = json_contents.get("api_target", "") or DEFAULT_API_TARGET
+        is_apex = json_contents.get("is_apex", False)
         if not domain:
             raise ApiPackageDecodeException(
                 filepath,
@@ -87,7 +97,8 @@
                 filepath,
                 "api_package is a required field in api_packages.json")
         return ContributionData(domain,
-                                BazelLabel(package=package, target=target))
+                                BazelLabel(package=package, target=target),
+                                is_apex=is_apex)
 
     with open(filepath, encoding='iso-8859-1') as f:
         try:
@@ -106,7 +117,8 @@
     {
         "api_domain": "system",
         "api_package": "//build/orchestrator/apis",
-        "api_target": "system"
+        "api_target": "system",
+        "is_apex": false
     }
     ```
 
@@ -120,12 +132,10 @@
             filename=API_PACKAGES_FILENAME,
             ignore_paths=[],
         )
-        self._cache = {}
+        self._cache = None
 
-    def _find_api_label(self, api_domain: str) -> BazelLabel:
-        if api_domain in self._cache:
-            return self._cache.get(api_domain)
-
+    def _create_cache(self) -> None:
+        self._cache = []
         search_paths = [
             os.path.join(self.inner_tree_root, *search_dir)
             for search_dir in INNER_TREE_SEARCH_DIRS
@@ -133,17 +143,18 @@
         for search_path in search_paths:
             for packages_file in self.finder.find(
                     path=search_path, search_depth=self.search_depth):
-                # Read values and add them to cache
-                results = read(packages_file)
-                self._cache[
-                    results.api_domain] = results.api_contribution_bazel_label
-                # If an entry is found, stop searching
-                if api_domain in self._cache:
-                    return self._cache.get(api_domain)
+                api_contributions = read(packages_file)
+                self._cache.append(api_contributions)
 
-        # Do not raise exception if a contribution target could not be found for an api domain
-        # e.g. vendor might not have any api contributions
-        return None
+    def _find_api_label(self, api_domain_filter) -> list[BazelLabel]:
+        # Compare to None and not []. The latter value is possible if a tree has
+        # no API contributoins.
+        if self._cache is None:
+            self._create_cache()
+        return [
+            c.api_contribution_bazel_label for c in self._cache
+            if api_domain_filter(c)
+        ]
 
     def find_api_label_string(self, api_domain: str) -> str:
         """ Return the fully qualified bazel label of the contribution target
@@ -158,10 +169,13 @@
             Bazel label, e.g. //frameworks/base:contribution
             None if a contribution could not be found
         """
-        label = self._find_api_label(api_domain)
-        return label.to_string() if label else None
+        labels = self._find_api_label(lambda x: x.api_domain == api_domain)
+        assert len(
+            labels
+        ) < 2, f"Duplicate contributions found for API domain: {api_domain}"
+        return labels[0].to_string() if labels else None
 
-    def find_api_package(self, api_domain_name: str) -> str:
+    def find_api_package(self, api_domain: str) -> str:
         """ Return the Bazel package of the contribution target
 
         Parameters:
@@ -174,5 +188,27 @@
             Bazel label, e.g. //frameworks/base
             None if a contribution could not be found
         """
-        label = self._find_api_label(api_domain_name)
-        return label.package if label else None
+        labels = self._find_api_label(lambda x: x.api_domain == api_domain)
+        assert len(
+            labels
+        ) < 2, f"Duplicate contributions found for API domain: {api_domain}"
+        return labels[0].package if label else None
+
+    def find_api_label_string_using_filter(self,
+                                           api_domain_filter: callable) -> str:
+        """ Return the Bazel label of the contributing targets
+        that match a search filter.
+
+        Parameters:
+            api_domain_filter: A callback function. The first arg to the function
+            is ContributionData
+
+        Raises:
+            ApiPackageDecodeException: If a malformed api_packages.json is found during search
+
+        Returns:
+            List of Bazel labels, e.g. [//frameworks/base:contribution, //packages/myapex:contribution]
+            None if a contribution could not be found
+        """
+        labels = self._find_api_label(api_domain_filter)
+        return [label.to_string() for label in labels]
diff --git a/inner_build/inner_build_soong.py b/inner_build/inner_build_soong.py
index 427f674..6d97ca5 100755
--- a/inner_build/inner_build_soong.py
+++ b/inner_build/inner_build_soong.py
@@ -108,6 +108,14 @@
             otherpath) < os.path.getmtime(self.fullpath())
 
 
+# ApiPackageFinder filters for special chars in .mcombo files
+_MCOMBO_WILDCARD_FILTERS = {
+    "*": lambda x: x.is_apex,
+    # TODO: Support more wildcards if necessary (e.g. vendor apex, google
+    # variants etc.)
+}
+
+
 class ApiExporterBazel(object):
     """Generate API surface metadata files into a well-known directory
 
@@ -142,9 +150,12 @@
         finder = ApiPackageFinder(inner_tree_root=self.inner_tree)
         contribution_targets = []
         for api_domain in self.api_domains:
-            label = finder.find_api_label_string(api_domain)
-            if label is not None:
-                contribution_targets.append(label)
+            default_name_filter = lambda x: x.api_domain == api_domain
+            api_domain_filter = _MCOMBO_WILDCARD_FILTERS.get(
+                api_domain, default_name_filter)
+            labels = finder.find_api_label_string_using_filter(
+                api_domain_filter)
+            contribution_targets.extend(labels)
         return contribution_targets
 
     def _build_api_domain_contribution_targets(
diff --git a/inner_build/test_find_api_packages.py b/inner_build/test_find_api_packages.py
index 2070166..bd9dace 100644
--- a/inner_build/test_find_api_packages.py
+++ b/inner_build/test_find_api_packages.py
@@ -24,6 +24,7 @@
 
 
 class TestBazelLabel(unittest.TestCase):
+
     def test_label_to_string(self):
         label = BazelLabel(package="//build/bazel", target="mytarget")
         self.assertEqual("//build/bazel:mytarget", label.to_string())
@@ -38,6 +39,7 @@
 
 
 class TestApiPackageReadUtils(unittest.TestCase):
+
     def test_read_empty_file(self):
         test_data = ""
         with patch("builtins.open", mock_open(read_data=test_data)):
@@ -88,6 +90,13 @@
 
 
 class TestApiPackageFinder(unittest.TestCase):
+
+    def _mock_fs(self, mock_data):
+        # Create a mock fs for finder.find
+        # The mock fs contains files in packages/modules.
+        return lambda path, search_depth: list(mock_data.keys(
+        )) if "packages/modules" in path else []
+
     @patch.object(FileFinder, "find")
     def test_exception_if_api_package_file_is_missing(self, find_mock):
         find_mock.return_value = []  # no files found
@@ -97,10 +106,15 @@
 
     @patch("find_api_packages.read")
     @patch.object(FileFinder, "find")
-    def test_exception_if_api_domain_not_found(self, find_mock, read_mock):
+    def test_no_exception_if_api_domain_not_found(self, find_mock, read_mock):
         # api_packages.json files exist in the tree, but none of them contain
         # the api_domain we are interested in.
-        find_mock.return_value = ["somefile.json"]
+
+        # Return a mock file from packages/modules.
+        def _mock_fs(path, search_depth):
+            return ["some_file.json"] if "packages/modules" in path else []
+
+        find_mock.side_effect = _mock_fs
         read_mock.return_value = ContributionData(
             "com.android.foo",
             BazelLabel("//packages/modules/foo", "contributions"))
@@ -113,21 +127,49 @@
 
     @patch("find_api_packages.read")
     @patch.object(FileFinder, "find")
-    def test_first_find_wins(self, find_mock, read_mock):
-        find_mock.return_value = ["first.json", "second.json"]
+    def test_exception_duplicate_entries(self, find_mock, read_mock):
         first_contribution_data = ContributionData(
             "com.android.foo",
             BazelLabel("//packages/modules/foo", "contributions"))
         second_contribution_data = ContributionData(
             "com.android.foo",
             BazelLabel("//packages/modules/foo_other", "contributions"))
-        read_mock.side_effect = [
-            first_contribution_data, second_contribution_data
-        ]
+        mock_data = {
+            "first.json": first_contribution_data,
+            "second.json": second_contribution_data,
+        }
+
+        find_mock.side_effect = self._mock_fs(mock_data)
+        read_mock.side_effect = lambda x: mock_data.get(x)
         api_package_finder = ApiPackageFinder("mock_inner_tree")
-        self.assertEqual(
-            "//packages/modules/foo:contributions",
-            api_package_finder.find_api_label_string("com.android.foo"))
+        with self.assertRaises(AssertionError):
+            api_package_finder.find_api_label_string("com.android.foo")
+
+    @patch("find_api_packages.read")
+    @patch.object(FileFinder, "find")
+    def test_user_provided_filter(self, find_mock, read_mock):
+        foo_contribution_data = ContributionData(
+            "com.android.foo",
+            BazelLabel("//packages/modules/foo", "contributions"))
+        bar_contribution_data = ContributionData(
+            "com.android.bar",
+            BazelLabel("//packages/modules/bar", "contributions"))
+        mock_data = {
+            "foo.json": foo_contribution_data,
+            "bar.json": bar_contribution_data,
+        }
+
+        find_mock.side_effect = self._mock_fs(mock_data)
+        read_mock.side_effect = lambda x: mock_data.get(x)
+        api_package_finder = ApiPackageFinder("mock_inner_tree")
+
+        all_contributions = api_package_finder.find_api_label_string_using_filter(
+            lambda x: True)
+        self.assertEqual(2, len(all_contributions))
+        self.assertEqual("//packages/modules/foo:contributions",
+                         all_contributions[0])
+        self.assertEqual("//packages/modules/bar:contributions",
+                         all_contributions[1])
 
 
 if __name__ == "__main__":