blob: cb039314ca08ed97e24a799612f345953fbe65c1 [file]
# Copyright (C) 2026 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 subprocess
import re
import dataclasses
from typing import Any, Callable, Optional
from pathlib import Path
from .env import BuildContext
from .constants import SOONG_UI_BASH
from interface.errors import ToolError
class BuildError(ToolError):
"""Custom exception for build failures."""
def __init__(self, message: str, exit_code: int, logs: str) -> None:
super().__init__(message)
self.exit_code = exit_code
self.logs = logs
@dataclasses.dataclass(frozen=True)
class BuildFailure:
"""Represents a specific failure in the build."""
message: str
target: Optional[str] = None
command: Optional[str] = None
outputs: Optional[str] = None
@dataclasses.dataclass(frozen=True)
class BuildResult:
"""Represents the outcome of a build."""
success: bool
exit_code: int
failure_details: Optional[list[BuildFailure]] = None
def strip_ansi_codes(text: str) -> str:
"""Removes ANSI escape sequences from a string."""
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
return ansi_escape.sub('', text)
def parse_build_log(raw_log: str) -> list[BuildFailure]:
"""
Parses the Android error.log content into a structured list of failures.
Returns a list of BuildFailure objects.
"""
# Remove ANSI codes to ensure clean regex matching
clean_log = strip_ansi_codes(raw_log)
# Regex to extract structured Soong errors
# Format is defined in build/soong/ui/status/log.go
soong_pattern = re.compile(
r"FAILED:\s+(?P<target>.*)\n"
r"(?:Outputs:\s+(?P<outputs>.*)\n)?"
r"(?:Error:\s+(?P<error_summary>.*)\n)?"
r"(?:Command:\s+(?P<command>.*)\n)?"
r"Output:\n(?P<output>[\s\S]*?)(?=\n\nFAILED:|$)"
)
matches = list(soong_pattern.finditer(clean_log))
if matches:
# Scenario 1: Structured Log Found
structured_failures = []
for m in matches:
data = m.groupdict()
failure = BuildFailure(
target=(data.get("target") or "").strip(),
command=(data.get("command") or "").strip(),
outputs=(data.get("outputs") or "").strip(),
message=(data.get("output") or "").strip() or "Build failed."
)
structured_failures.append(failure)
return structured_failures
else:
# Scenario 2: Unstructured / Ninja Error
return [BuildFailure(message=clean_log.strip())]
def _execute_build_command(ctx: BuildContext, targets: list[str], enforce_no_reanalysis: bool = False, progress_callback: Optional[Callable[[float, Optional[float]], None]] = None) -> int:
"""Helper to run a single Soong build command."""
env = ctx.env
android_build_top = env.get('ANDROID_BUILD_TOP')
if not android_build_top:
raise BuildError("ANDROID_BUILD_TOP not found in environment.", 1, "")
soong_ui_path = Path(android_build_top) / SOONG_UI_BASH
command = [str(soong_ui_path), "--make-mode"]
if enforce_no_reanalysis:
command.append("--enforce-no-reanalysis")
command.extend(targets)
process = subprocess.Popen(
command,
env=env,
cwd=android_build_top,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding='utf-8',
text=True
)
# Progress regex: [ 10% 100/1000] ... or [ 8% 14/172 3s remaining] ...
# Groups: 1=percent, 2=current, 3=total
progress_re = re.compile(r"^\[\s*(\d+)%\s+(\d+)/(\d+)(?:.*)?\]")
if process.stdout:
for line in iter(process.stdout.readline, ''):
if progress_callback:
match = progress_re.match(line)
if match:
current = float(match.group(2))
total = float(match.group(3))
progress_callback(current, total)
if process.stderr:
for line in iter(process.stderr.readline, ''):
pass
return process.wait()
def _create_failed_result(ctx: BuildContext, exit_code: int) -> BuildResult:
"""Helper to parse logs and create a failure result."""
env = ctx.env
android_build_top = env.get('ANDROID_BUILD_TOP', "")
out_dir = env.get("OUT_DIR", "out")
error_log_path = Path(android_build_top) / out_dir / "error.log"
# Default fallback if no log exists
failure_details: list[BuildFailure] = [BuildFailure(message="No error.log found.")]
if error_log_path.exists():
with open(error_log_path, "r") as f:
failure_details = parse_build_log("".join(f.readlines()))
return BuildResult(success=False, exit_code=exit_code, failure_details=failure_details)
def build_targets(ctx: BuildContext, targets: list[str], clean: bool = False, enforce_no_reanalysis: bool = False, progress_callback: Optional[Callable[[float, Optional[float]], None]] = None) -> BuildResult:
"""
Executes an Android build for the given targets.
Use this to compile code, generate artifacts, or run phony targets like 'nothing' or 'module-info'.
Returns a BuildResult object with success status and failure details if applicable.
Args:
ctx: Evaluated build configuration.
targets: List of build targets (e.g., 'SystemUI', 'nothing'). These can be module names or Ninja targets.
clean: If True, runs 'installclean' before building.
enforce_no_reanalysis: If True, blocks re-running analysis and throws an error if reanalysis is required.
progress_callback: Optional callback for progress reporting.
"""
# Step 1: Installclean if requested
if clean:
exit_code = _execute_build_command(ctx, ["installclean"], enforce_no_reanalysis=False, progress_callback=None)
if exit_code != 0:
return _create_failed_result(ctx, exit_code)
# Step 2: Main Build
exit_code = _execute_build_command(ctx, targets, enforce_no_reanalysis=enforce_no_reanalysis, progress_callback=progress_callback)
if exit_code == 0:
return BuildResult(success=True, exit_code=0)
return _create_failed_result(ctx, exit_code)