blob: 83bc92b484cdfb2e9ad0bffd8524fdd3adcbdba2 [file] [edit]
#!/usr/bin/env python3
# Copyright (C) 2025 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 pathlib
import tempfile
import unittest
import source_trimmer
class SourceTrimmerTest(unittest.TestCase):
def setUp(self):
self.manifest_content = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<project groups="pdk" name="platform/bionic" path="bionic" remote="ohd" />
<project groups="pdk-fs,pdk" name="platform/bootable/recovery" path="bootable/recovery" remote="ohd" />
<project groups="pdk-desktop" name="platform/build" path="build/make" remote="ohd" />
<project name="platform/system/core" path="system/core" remote="ohd" />
<project groups="group1" name="platform/lib/foo" path="external/foo" remote="ohd" />
<project groups="group1,group2" name="platform/lib/bar" remote="ohd" />
</manifest>
"""
self.manifest_with_ops = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<project groups="pdk" name="platform/bionic" path="bionic">
<copyfile src="INSTRUCTIONS" dest="BIONIC_INSTRUCTIONS"/>
</project>
<project groups="group1" name="platform/vendor/foo" path="vendor/foo">
<linkfile src="common/Android.bp" dest="vendor/foo/Android.bp"/>
<copyfile src="data/file.txt" dest="data/foo/file.txt"/>
</project>
<project groups="group2" name="platform/vendor/bar" path="vendor/bar">
<linkfile src="this/points/nowhere" dest="dangling_link"/>
</project>
</manifest>
"""
def test_get_projects_from_manifest(self):
"""Test that the manifest is parsed correctly."""
projects = source_trimmer.get_projects_from_manifest(self.manifest_content)
expected_projects = [
source_trimmer.Project(
name="platform/bionic", path="bionic", groups=["pdk"], operations=[]
),
source_trimmer.Project(
name="platform/bootable/recovery",
path="bootable/recovery",
groups=["pdk-fs", "pdk"],
operations=[],
),
source_trimmer.Project(
name="platform/build",
path="build/make",
groups=["pdk-desktop"],
operations=[],
),
source_trimmer.Project(
name="platform/system/core",
path="system/core",
groups=[],
operations=[],
),
source_trimmer.Project(
name="platform/lib/foo",
path="external/foo",
groups=["group1"],
operations=[],
),
source_trimmer.Project(
name="platform/lib/bar",
path="platform/lib/bar",
groups=["group1", "group2"],
operations=[],
),
]
self.assertEqual(projects, expected_projects)
def test_get_projects_from_manifest_with_operations(self):
"""Test parsing linkfile/copyfile operations."""
projects = source_trimmer.get_projects_from_manifest(self.manifest_with_ops)
expected_projects = [
source_trimmer.Project(
name="platform/bionic",
path="bionic",
groups=["pdk"],
operations=[
source_trimmer.FileOperation(
type=source_trimmer.OperationType.COPYFILE,
src="INSTRUCTIONS",
dest="BIONIC_INSTRUCTIONS",
)
],
),
source_trimmer.Project(
name="platform/vendor/foo",
path="vendor/foo",
groups=["group1"],
operations=[
source_trimmer.FileOperation(
type=source_trimmer.OperationType.LINKFILE,
src="common/Android.bp",
dest="vendor/foo/Android.bp",
),
source_trimmer.FileOperation(
type=source_trimmer.OperationType.COPYFILE,
src="data/file.txt",
dest="data/foo/file.txt",
),
],
),
source_trimmer.Project(
name="platform/vendor/bar",
path="vendor/bar",
groups=["group2"],
operations=[
source_trimmer.FileOperation(
type=source_trimmer.OperationType.LINKFILE,
src="this/points/nowhere",
dest="dangling_link",
)
],
),
]
self.assertEqual(projects, expected_projects)
def test_get_projects_from_manifest_with_glob_src(self):
"""Test the manifest is not parsed if the src of linkfile is a glob pattern."""
manifest_content_linkfile = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<project groups="pdk" name="platform/bionic" path="bionic">
<linkfile src="**/*.txt" dest="BIONIC_INSTRUCTIONS"/>
</project>
</manifest>
"""
projects = source_trimmer.get_projects_from_manifest(manifest_content_linkfile)
self.assertIsNone(projects)
def test_find_projects_to_remove(self):
"""Test that the projects to remove are identified correctly."""
all_projects = source_trimmer.get_projects_from_manifest(self.manifest_content)
groups_to_keep = ["pdk"]
projects_to_remove = source_trimmer.find_projects_to_remove(
all_projects, groups_to_keep
)
# platform/bionic and platform/bootable/recovery should be kept.
expected_to_remove = [
source_trimmer.Project(
name="platform/build",
path="build/make",
groups=["pdk-desktop"],
operations=[],
),
source_trimmer.Project(
name="platform/system/core",
path="system/core",
groups=[],
operations=[],
),
source_trimmer.Project(
name="platform/lib/foo",
path="external/foo",
groups=["group1"],
operations=[],
),
source_trimmer.Project(
name="platform/lib/bar",
path="platform/lib/bar",
groups=["group1", "group2"],
operations=[],
),
]
self.assertEqual(projects_to_remove, expected_to_remove)
def test_find_projects_to_remove_multiple_groups(self):
"""Test that the projects to remove are identified correctly when multiple groups are specified."""
all_projects = source_trimmer.get_projects_from_manifest(self.manifest_content)
groups_to_keep = ["pdk", "pdk-desktop"]
projects_to_remove = source_trimmer.find_projects_to_remove(
all_projects, groups_to_keep
)
# platform/bionic, platform/bootable/recovery and platform/build should be kept.
expected_to_remove = [
source_trimmer.Project(
name="platform/system/core",
path="system/core",
groups=[],
operations=[],
),
source_trimmer.Project(
name="platform/lib/foo",
path="external/foo",
groups=["group1"],
operations=[],
),
source_trimmer.Project(
name="platform/lib/bar",
path="platform/lib/bar",
groups=["group1", "group2"],
operations=[],
),
]
self.assertEqual(projects_to_remove, expected_to_remove)
def test_remove_project_directories(self):
"""Test that the project directories are removed correctly."""
projects_to_remove = [
source_trimmer.Project(
name="platform/build",
path="build/make",
groups=["pdk-desktop"],
operations=[],
),
source_trimmer.Project(
name="platform/system/core",
path="system/core",
groups=[],
operations=[],
),
]
with tempfile.TemporaryDirectory() as tmpdir:
checkout_root = pathlib.Path(tmpdir)
# Create dummy project directories.
(checkout_root / "build/make").mkdir(parents=True)
(checkout_root / "system/core").mkdir(parents=True)
(checkout_root / "bionic").mkdir(parents=True) # Should be kept.
source_trimmer.remove_project_directories(projects_to_remove, checkout_root)
self.assertFalse((checkout_root / "build/make").exists())
self.assertFalse((checkout_root / "system/core").exists())
self.assertTrue((checkout_root / "bionic").exists())
# Check that empty parent 'build' is also removed.
self.assertFalse((checkout_root / "build").exists())
def test_remove_project_directories_dry_run(self):
"""Test that the project directories are not removed in dry run mode."""
projects_to_remove = [
source_trimmer.Project(
name="platform/build",
path="build/make",
groups=["pdk-desktop"],
operations=[],
),
]
with tempfile.TemporaryDirectory() as tmpdir:
checkout_root = pathlib.Path(tmpdir)
(checkout_root / "build/make").mkdir(parents=True)
source_trimmer.remove_project_directories(
projects_to_remove, checkout_root, dry_run=True
)
self.assertTrue((checkout_root / "build/make").exists())
self.assertTrue((checkout_root / "build").exists())
def test_undo_project_file_operations(self):
"""Test removal of files from linkfile/copyfile operations."""
project = source_trimmer.Project(
name="test-project",
path="vendor/test",
groups=["test"],
operations=[
source_trimmer.FileOperation(
type="copyfile", src="a.txt", dest="top_level.txt"
),
source_trimmer.FileOperation(
type="linkfile", src="b.txt", dest="link_b.txt"
),
source_trimmer.FileOperation(
type="copyfile", src="c.txt", dest="subdir/c.txt"
),
source_trimmer.FileOperation(
type="linkfile", src="d.txt", dest="nonexistent.txt"
),
source_trimmer.FileOperation(
type="copyfile", src="e.txt", dest="dir_dest"
),
],
)
with tempfile.TemporaryDirectory() as tmpdir:
checkout_root = pathlib.Path(tmpdir)
# Setup dummy files and links.
(checkout_root / "top_level.txt").write_text("a")
(checkout_root / "link_b.txt").symlink_to("vendor/test/b.txt")
(checkout_root / "subdir").mkdir()
(checkout_root / "subdir/c.txt").write_text("c")
(checkout_root / "dir_dest").mkdir()
# Files that should remain.
(checkout_root / "other_file.txt").write_text("keep")
source_trimmer.undo_project_file_operations(
project, checkout_root, dry_run=False
)
self.assertFalse((checkout_root / "top_level.txt").exists())
self.assertFalse((checkout_root / "link_b.txt").exists())
self.assertFalse((checkout_root / "link_b.txt").is_symlink())
self.assertFalse((checkout_root / "subdir/c.txt").exists())
self.assertTrue((checkout_root / "subdir").exists())
self.assertFalse((checkout_root / "nonexistent.txt").exists())
self.assertTrue((checkout_root / "dir_dest").exists())
self.assertTrue((checkout_root / "other_file.txt").exists())
def test_undo_project_file_operations_dry_run(self):
"""Test dry run for undoing file operations."""
project = source_trimmer.Project(
name="test-project",
path="vendor/test",
groups=["test"],
operations=[
source_trimmer.FileOperation(
type="copyfile", src="a.txt", dest="top_level.txt"
),
source_trimmer.FileOperation(
type="linkfile", src="b.txt", dest="link_b.txt"
),
],
)
with tempfile.TemporaryDirectory() as tmpdir:
checkout_root = pathlib.Path(tmpdir)
(checkout_root / "top_level.txt").write_text("a")
(checkout_root / "link_b.txt").symlink_to("vendor/test/b.txt")
source_trimmer.undo_project_file_operations(
project, checkout_root, dry_run=True
)
self.assertTrue((checkout_root / "top_level.txt").exists())
self.assertTrue((checkout_root / "link_b.txt").is_symlink())
def test_keep_projects_overrides_removal(self):
"""Test that projects to keep are not removed, even if they would be marked for removal."""
# These projects would be marked for removal by find_projects_to_remove.
projects_to_remove = [
source_trimmer.Project(
name="platform/build",
path="build/make",
groups=["pdk-desktop"],
operations=[],
),
source_trimmer.Project(
name="platform/system/core",
path="system/core",
groups=[],
operations=[],
),
source_trimmer.Project(
name="platform/lib/foo",
path="external/foo",
groups=["another-group"],
operations=[],
),
]
keep_project_names = set(["platform/system/core", "platform/lib/foo"])
# This logic is what's in the main() function of the script.
final_projects_to_remove = [
p for p in projects_to_remove if p["name"] not in keep_project_names
]
self.assertEqual(len(final_projects_to_remove), 1)
self.assertEqual(final_projects_to_remove[0]["name"], "platform/build")
def test_process_groups(self):
"""Tests various scenarios for process_groups_to_keep."""
test_cases = [
("single", ["pdk"], ["pdk"]),
("comma_separated", ["pdk,pdk-fs"], ["pdk", "pdk-fs"]),
("space_separated", ["pdk pdk-fs"], ["pdk", "pdk-fs"]),
(
"mixed_separators",
["pdk, pdk-fs pdk-desktop"],
["pdk", "pdk-fs", "pdk-desktop"],
),
(
"multiple_args",
["pdk,pdk-fs", "pdk-desktop"],
["pdk", "pdk-fs", "pdk-desktop"],
),
("extra_spaces", [" pdk , pdk-fs "], ["pdk", "pdk-fs"]),
("ignore_empty", ["pdk,, pdk-fs", "", " "], ["pdk", "pdk-fs"]),
("empty_input", [], []),
("only_separators", [", ,", " ", ","], []),
]
for name, raw_groups, expected in test_cases:
with self.subTest(name=name):
result = source_trimmer.process_groups_to_keep(raw_groups)
self.assertEqual(result, expected, msg=f"Failed test case: {name}")
def test_generate_filtered_manifest_strict_filtering(self):
"""Test manifest filtering with extra attributes removed."""
manifest_input = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<default revision="master" remote="arsp" />
<default revision="master" remote="ohd" />
<remote name="arsp" fetch=".." />
<remote name="ohd" fetch=".." />
<project groups="keep" name="platform/keep" path="keep" remote="ohd" />
<project groups="discard" name="platform/drop" path="drop" remote="ohd" />
<repo-hooks in-project="platform/admin" enabled-list="pre-upload" />
</manifest>
"""
projects_to_keep = {"platform/keep"}
with tempfile.TemporaryDirectory() as tmpdir:
output_path = pathlib.Path(tmpdir) / "filtered_manifest.xml"
source_trimmer.generate_arsp_filtered_manifest(
manifest_input, projects_to_keep, output_path
)
tree = source_trimmer.ET.parse(output_path)
root = tree.getroot()
# Verify the correct tags remain.
tags = [child.tag for child in root]
self.assertIn("project", tags)
self.assertIn("repo-hooks", tags)
self.assertIn("default", tags)
self.assertIn("remote", tags)
# Verify non-arsp remotes and defaults were removed.
remotes = root.findall("remote")
self.assertEqual(len(remotes), 1)
self.assertEqual(remotes[0].get("name"), "arsp")
defaults = root.findall("default")
self.assertEqual(len(defaults), 1)
self.assertEqual(defaults[0].get("remote"), "arsp")
# Verify 'remote' attribute is stripped from the kept project.
project = root.find("project")
self.assertEqual(project.get("name"), "platform/keep")
self.assertNotIn("remote", project.attrib)
# Verify other attributes like 'path' remain.
self.assertEqual(project.get("path"), "keep")
# Verify repo-hooks is preserved.
hooks = root.find("repo-hooks")
self.assertEqual(hooks.get("in-project"), "platform/admin")
if __name__ == "__main__":
unittest.main()