Add a tool for merging annotation.zip files
We now produce annotation.zip files individually for each module, but
for the public SDK it's easier to distribute a single zip file with
merged contents. Add a tool for merging these (simple) files.
Bug: 187397779
Test: unittest in cl
Test: diff tests in follow-on CL merging all module annotations.zip
Change-Id: Ic5bb94425dccafab43340805f73fafacab53cb28
diff --git a/api/Android.bp b/api/Android.bp
index 8dff60af..0acd759 100644
--- a/api/Android.bp
+++ b/api/Android.bp
@@ -24,9 +24,8 @@
default_applicable_licenses: ["frameworks_base_license"],
}
-python_binary_host {
- name: "api_versions_trimmer",
- srcs: ["api_versions_trimmer.py"],
+python_defaults {
+ name: "python3_version_defaults",
version: {
py2: {
enabled: false,
@@ -38,6 +37,12 @@
},
}
+python_binary_host {
+ name: "api_versions_trimmer",
+ srcs: ["api_versions_trimmer.py"],
+ defaults: ["python3_version_defaults"],
+}
+
python_test_host {
name: "api_versions_trimmer_unittests",
main: "api_versions_trimmer_unittests.py",
@@ -45,17 +50,28 @@
"api_versions_trimmer_unittests.py",
"api_versions_trimmer.py",
],
+ defaults: ["python3_version_defaults"],
test_options: {
unit_test: true,
},
- version: {
- py2: {
- enabled: false,
- },
- py3: {
- enabled: true,
- embedded_launcher: false,
- },
+}
+
+python_binary_host {
+ name: "merge_annotation_zips",
+ srcs: ["merge_annotation_zips.py"],
+ defaults: ["python3_version_defaults"],
+}
+
+python_test_host {
+ name: "merge_annotation_zips_test",
+ main: "merge_annotation_zips_test.py",
+ srcs: [
+ "merge_annotation_zips.py",
+ "merge_annotation_zips_test.py",
+ ],
+ defaults: ["python3_version_defaults"],
+ test_options: {
+ unit_test: true,
},
}
diff --git a/api/merge_annotation_zips.py b/api/merge_annotation_zips.py
new file mode 100755
index 0000000..9c67d7b
--- /dev/null
+++ b/api/merge_annotation_zips.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 The Android Open Source Project
+#
+# 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.
+
+"""Script to merge annotation XML files (created by e.g. metalava)."""
+
+from pathlib import Path
+import sys
+import xml.etree.ElementTree as ET
+import zipfile
+
+
+def validate_xml_assumptions(root):
+ """Verify the format of the annotations XML matches expectations"""
+ prevName = ""
+ assert root.tag == 'root'
+ for child in root:
+ assert child.tag == 'item', 'unexpected tag: %s' % child.tag
+ assert list(child.attrib.keys()) == ['name'], 'unexpected attribs: %s' % child.attrib.keys()
+ assert prevName < child.get('name'), 'items unexpectedly not strictly sorted (possibly duplicate entries)'
+ prevName = child.get('name')
+
+
+def merge_xml(a, b):
+ """Merge two annotation xml files"""
+ for xml in [a, b]:
+ validate_xml_assumptions(xml)
+ a.extend(b[:])
+ a[:] = sorted(a[:], key=lambda x: x.get('name'))
+ validate_xml_assumptions(a)
+
+
+def merge_zip_file(out_dir, zip_file):
+ """Merge the content of the zip_file into out_dir"""
+ for filename in zip_file.namelist():
+ path = Path(out_dir, filename)
+ if path.exists():
+ existing_xml = ET.parse(path)
+ with zip_file.open(filename) as other_file:
+ other_xml = ET.parse(other_file)
+ merge_xml(existing_xml.getroot(), other_xml.getroot())
+ existing_xml.write(path, encoding='UTF-8', xml_declaration=True)
+ else:
+ zip_file.extract(filename, out_dir)
+
+
+def main():
+ out_dir = Path(sys.argv[1])
+ zip_filenames = sys.argv[2:]
+
+ assert not out_dir.exists()
+ out_dir.mkdir()
+ for zip_filename in zip_filenames:
+ with zipfile.ZipFile(zip_filename) as zip_file:
+ merge_zip_file(out_dir, zip_file)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/api/merge_annotation_zips_test.py b/api/merge_annotation_zips_test.py
new file mode 100644
index 0000000..26795c47
--- /dev/null
+++ b/api/merge_annotation_zips_test.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 The Android Open Source Project
+#
+# 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.
+
+import io
+from pathlib import Path
+import tempfile
+import unittest
+import zipfile
+
+import merge_annotation_zips
+
+
+zip_a = {
+ 'android/provider/annotations.xml':
+ """<?xml version="1.0" encoding="UTF-8"?>
+<root>
+ <item name="android.provider.BlockedNumberContract boolean isBlocked(android.content.Context, java.lang.String)">
+ <annotation name="androidx.annotation.WorkerThread"/>
+ </item>
+ <item name="android.provider.SimPhonebookContract.SimRecords android.net.Uri getItemUri(int, int, int) 2">
+ <annotation name="androidx.annotation.IntRange">
+ <val name="from" val="1" />
+ </annotation>
+ </item>
+</root>""",
+ 'android/os/annotations.xml':
+ """<?xml version="1.0" encoding="UTF-8"?>
+<root>
+ <item name="android.app.ActionBar void setCustomView(int) 0">
+ <annotation name="androidx.annotation.LayoutRes"/>
+ </item>
+</root>
+"""
+}
+
+zip_b = {
+ 'android/provider/annotations.xml':
+ """<?xml version="1.0" encoding="UTF-8"?>
+<root>
+ <item name="android.provider.MediaStore QUERY_ARG_MATCH_FAVORITE">
+ <annotation name="androidx.annotation.IntDef">
+ <val name="value" val="{android.provider.MediaStore.MATCH_DEFAULT, android.provider.MediaStore.MATCH_INCLUDE, android.provider.MediaStore.MATCH_EXCLUDE, android.provider.MediaStore.MATCH_ONLY}" />
+ <val name="flag" val="true" />
+ </annotation>
+ </item>
+ <item name="android.provider.MediaStore QUERY_ARG_MATCH_PENDING">
+ <annotation name="androidx.annotation.IntDef">
+ <val name="value" val="{android.provider.MediaStore.MATCH_DEFAULT, android.provider.MediaStore.MATCH_INCLUDE, android.provider.MediaStore.MATCH_EXCLUDE, android.provider.MediaStore.MATCH_ONLY}" />
+ <val name="flag" val="true" />
+ </annotation>
+ </item>
+</root>"""
+}
+
+zip_c = {
+ 'android/app/annotations.xml':
+ """<?xml version="1.0" encoding="UTF-8"?>
+<root>
+ <item name="android.app.ActionBar void setCustomView(int) 0">
+ <annotation name="androidx.annotation.LayoutRes"/>
+ </item>
+</root>"""
+}
+
+merged_provider = """<?xml version='1.0' encoding='UTF-8'?>
+<root>
+ <item name="android.provider.BlockedNumberContract boolean isBlocked(android.content.Context, java.lang.String)">
+ <annotation name="androidx.annotation.WorkerThread" />
+ </item>
+ <item name="android.provider.MediaStore QUERY_ARG_MATCH_FAVORITE">
+ <annotation name="androidx.annotation.IntDef">
+ <val name="value" val="{android.provider.MediaStore.MATCH_DEFAULT, android.provider.MediaStore.MATCH_INCLUDE, android.provider.MediaStore.MATCH_EXCLUDE, android.provider.MediaStore.MATCH_ONLY}" />
+ <val name="flag" val="true" />
+ </annotation>
+ </item>
+ <item name="android.provider.MediaStore QUERY_ARG_MATCH_PENDING">
+ <annotation name="androidx.annotation.IntDef">
+ <val name="value" val="{android.provider.MediaStore.MATCH_DEFAULT, android.provider.MediaStore.MATCH_INCLUDE, android.provider.MediaStore.MATCH_EXCLUDE, android.provider.MediaStore.MATCH_ONLY}" />
+ <val name="flag" val="true" />
+ </annotation>
+ </item>
+<item name="android.provider.SimPhonebookContract.SimRecords android.net.Uri getItemUri(int, int, int) 2">
+ <annotation name="androidx.annotation.IntRange">
+ <val name="from" val="1" />
+ </annotation>
+ </item>
+</root>"""
+
+
+
+class MergeAnnotationZipsTest(unittest.TestCase):
+
+ def test_merge_zips(self):
+ with tempfile.TemporaryDirectory() as out_dir:
+ for zip_content in [zip_a, zip_b, zip_c]:
+ f = io.BytesIO()
+ with zipfile.ZipFile(f, "w") as zip_file:
+ for filename, content in zip_content.items():
+ zip_file.writestr(filename, content)
+ merge_annotation_zips.merge_zip_file(out_dir, zip_file)
+
+ # Unchanged
+ self.assertEqual(zip_a['android/os/annotations.xml'], Path(out_dir, 'android/os/annotations.xml').read_text())
+ self.assertEqual(zip_c['android/app/annotations.xml'], Path(out_dir, 'android/app/annotations.xml').read_text())
+
+ # Merged
+ self.assertEqual(merged_provider, Path(out_dir, 'android/provider/annotations.xml').read_text())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)