| # Copyright 2024 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Handle the list of Ninja artifacts corresponding to the last build command.""" |
| |
| import subprocess |
| import typing as T |
| from pathlib import Path |
| |
| # The name of the file listing the dependencies of the Ninja build plan, |
| # as generated by GN. This contains a single statement that looks like: |
| # |
| # ``` |
| # build.ninja.stamp: <dep1> <dep2> <dep3> ... |
| # ``` |
| # |
| # Where each <dep> points to a BUILD.gn or .gni file, using paths relative |
| # to the build directory (e.g. `../../src/BUILD.gn`). |
| # |
| NINJA_BUILD_PLAN_DEPS_FILE = "build.ninja.d" |
| |
| # The name of the file that contains the list of targets passed to the |
| # last `fx build` or `fint build` invocation. |
| LAST_NINJA_TARGETS_FILE = "last_ninja_build_targets.txt" |
| |
| # The name of the file that will contain the cached value of all artifacts |
| # that are transitive inputs for the targets of the last build. |
| LAST_NINJA_ARTIFACTS_FILE = "last_ninja_artifacts.txt" |
| |
| # The name of the file that will contain the cached value of all source files |
| # that are transitive inputs for the targets of the last build. |
| LAST_NINJA_SOURCES_FILE = "last_ninja_inputs.txt" |
| |
| |
| class NinjaRunner(object): |
| """Wrapper class to invoke Ninja, can be overridden for tests.""" |
| |
| def __init__(self, ninja: Path): |
| self._ninja = ninja |
| |
| def run_and_extract_output(self, build_dir: str, cmd: T.List[str]) -> str: |
| ret = subprocess.run( |
| [str(self._ninja), "-C", str(build_dir)] + cmd, |
| capture_output=True, |
| text=True, |
| ) |
| ret.check_returncode() |
| return ret.stdout |
| |
| |
| def get_last_build_targets_path(build_dir: Path) -> Path: |
| """Return the path of the file listing the targets passed to the last Ninja build.""" |
| return build_dir / LAST_NINJA_TARGETS_FILE |
| |
| |
| def get_last_build_targets(build_dir: Path) -> T.Sequence[str]: |
| """Return the list of targets passed to the last Ninja build.""" |
| last_ninja_targets_path = get_last_build_targets_path(build_dir) |
| last_ninja_targets = [] |
| if last_ninja_targets_path.exists(): |
| data = last_ninja_targets_path.read_text().strip() |
| if data: |
| last_ninja_targets = data.split(" ") |
| |
| if last_ninja_targets: |
| return last_ninja_targets |
| |
| # Fallback if the file doesn't exist of is empty. |
| return [":default"] |
| |
| |
| def get_build_plan_deps(build_dir: Path) -> T.Sequence[str]: |
| """Return the list of Ninja build plan dependencies. |
| |
| This is the list of BUILD.gn or .gni files that, if changed, would trigger |
| a regeneration of the Ninja build plan. |
| |
| Args: |
| build_dir: Ninja build directory path. |
| Returns: |
| A list of path strings, relative to build_dir. |
| """ |
| deps_path = build_dir / NINJA_BUILD_PLAN_DEPS_FILE |
| deps_content = deps_path.read_text().strip() |
| |
| # For faster parsing, assert that there are no quoted dependency paths |
| # in that file. |
| assert ( |
| deps_content.find("'") < 0 |
| ), f"Unexpected quoted path in {deps_path.resolve()}" |
| |
| deps = deps_content.split(" ") |
| assert deps, f"Empty file: {deps_path.resolve()}" |
| |
| assert ( |
| deps[0] == "build.ninja.stamp:" |
| ), f"Unexpected {NINJA_BUILD_PLAN_DEPS_FILE} content: {deps[0:4]}" |
| return deps[1:] |
| |
| |
| def check_output_needs_update( |
| output_file: Path, input_files: T.Sequence[Path] |
| ) -> bool: |
| """Returns True if an output file needs to be updated. |
| |
| Args: |
| output_file: Output file path. |
| input_files: A list of input file paths. |
| Returns: |
| True if the output_file needs to be re-generated. |
| """ |
| if not output_file.exists(): |
| return True |
| |
| output_timestamp = output_file.stat(follow_symlinks=False).st_mtime |
| for input_file in input_files: |
| if ( |
| not input_file.exists() |
| or input_file.stat(follow_symlinks=False).st_mtime |
| > output_timestamp |
| ): |
| return True |
| |
| return False |
| |
| |
| def get_last_build_artifacts( |
| build_dir: Path, ninja_runner: NinjaRunner |
| ) -> T.Sequence[str]: |
| """Return the list of all Ninja artifacts corresponding to the last `fx` or `fint` build. |
| |
| Args: |
| build_dir: Path to the build directory. |
| ninja_runner: A NinjaRunner instance, used to invoke Ninja if needed. |
| Returns: |
| A list of Ninja output paths, relative to the build directory. |
| """ |
| last_ninja_targets = get_last_build_targets(build_dir) |
| |
| # Determine whether ninja_artifacts.txt needs to be re-generated. |
| # This happens when last_ninja_build_targets.txt is modified, or when |
| # any of the build plan inputs (e.g. BUILD.gn files) are modified |
| # since the last call. These are listed in build.ninja.d which starts |
| # with `build.ninja.stamp: ` followed by paths, relative to the |
| # build directory. |
| ninja_artifacts_path = build_dir / LAST_NINJA_ARTIFACTS_FILE |
| ninja_artifacts_deps = [get_last_build_targets_path(build_dir)] + [ |
| build_dir / dep for dep in get_build_plan_deps(build_dir) |
| ] |
| |
| if check_output_needs_update(ninja_artifacts_path, ninja_artifacts_deps): |
| # Invoke Ninja to regenerate a new set of inputs. Then write results to disk. |
| ninja_output = ninja_runner.run_and_extract_output( |
| str(build_dir), ["-t", "outputs"] + last_ninja_targets |
| ) |
| ninja_artifacts = ninja_output.splitlines() |
| ninja_artifacts_path.write_text("\n".join(ninja_artifacts)) |
| else: |
| # Read previous results from disk. |
| ninja_artifacts = ninja_artifacts_path.read_text().splitlines() |
| |
| return ninja_artifacts |
| |
| |
| def get_last_build_sources( |
| build_dir: Path, ninja_runner: NinjaRunner |
| ) -> T.Sequence[str]: |
| """Return the list of all Ninja sources to the last `fx` or `fint` build. |
| |
| Args: |
| build_dir: Path to the build directory. |
| ninja_runner: A NinjaRunner instance, used to invoke Ninja if needed. |
| Returns: |
| A list of Ninja input paths, relative to the build directory. |
| Note that they will all start with ../. |
| """ |
| last_ninja_targets = get_last_build_targets(build_dir) |
| |
| # Determine whether ninja_artifacts.txt needs to be re-generated. |
| # This happens when last_ninja_build_targets.txt is modified, or when |
| # any of the build plan sources (e.g. BUILD.gn files) are modified |
| # since the last call. These are listed in build.ninja.d which starts |
| # with `build.ninja.stamp: ` followed by paths, relative to the |
| # build directory. |
| ninja_sources_path = build_dir / LAST_NINJA_SOURCES_FILE |
| ninja_sources_deps = [get_last_build_targets_path(build_dir)] + [ |
| build_dir / dep for dep in get_build_plan_deps(build_dir) |
| ] |
| |
| if check_output_needs_update(ninja_sources_path, ninja_sources_deps): |
| # Invoke Ninja to regenerate a new set of sources. Then write results to disk. |
| ninja_output = ninja_runner.run_and_extract_output( |
| str(build_dir), |
| ["-t", "inputs", "--no-shell-escape", "--dependency-order"] |
| + last_ninja_targets, |
| ) |
| ninja_sources = [ |
| line for line in ninja_output.splitlines() if line.startswith("../") |
| ] |
| ninja_sources_path.write_text("\n".join(ninja_sources)) |
| else: |
| # Read previous results from disk. |
| ninja_sources = ninja_sources_path.read_text().splitlines() |
| |
| return ninja_sources |