blob: 33a1708531970a0002c13668eb7a17cba81d8f8e [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2021 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.
"""Wraps a command so that its outputs are timestamp-fresh only if their contents change.
Every declared output is renamed with a temporary suffix in the command.
If the command succeeds, the temporary file is moved over the original declared
output if the output did not already exist or the contents are different.
This conditional move is done for every declared output that appears in the
arguments list.
This is intended to be used in build systems like Ninja that support `restat`:
treating unchanged outputs as up-to-date, which has the potential to prune
the action graph on-the-fly.
Assumptions:
Output files can be whole shell tokens in the command's arguments.
We also support filenames as lexical substrings in tokens like
"--flag=out1,out2" or just "out1,out2".
If x is a writeable path (output), then x.any_suffix is also writeable.
If x is a writeable path (output), then dirname(x) is also writeable.
Command being wrapped does not change behavior with the name of its output
arguments.
If any of the above assumptions do not hold, then we recommend --disable
wrapping.
"""
import argparse
import filecmp
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Callable, Collection, Dict, Iterable, Sequence, Tuple
import dataclasses
_SCRIPT_BASENAME = Path(__file__).name
_SCRIPT_DIR = Path(__file__).parent
_PROJECT_ROOT = _SCRIPT_DIR.resolve().parent.parent
_PROJECT_ROOT_REL = os.path.relpath(
_PROJECT_ROOT, start=Path(os.curdir).absolute()
)
_DETAIL_DIFF_SCRIPT = Path("build/rbe/detail-diff.sh")
def msg(text: str):
print(f"[{_SCRIPT_BASENAME}] {text}")
def _partition(
iterable: Iterable[Any], predicate: Callable[[Any], bool]
) -> Tuple[Sequence[Any], Sequence[Any]]:
"""Splits sequence into two sequences based on predicate function."""
trues = []
falses = []
for item in iterable:
if predicate(item):
trues.append(item)
else:
falses.append(item)
return trues, falses
def files_match(file1: Path, file2: Path):
"""Compares two files, returns True if they both exist and match."""
# filecmp.cmp does not invoke any subprocesses.
return filecmp.cmp(file1, file2, shallow=False)
def ensure_file_exists(path: Path):
"""Assert that a file exists, or wait for it to appear.
It has been shown that some fault tolerance is needed
regarding expecting files to be produced by a subprocess.
Args:
path: path to file that is expected to exist.
Raises:
FileNotFoundError if path does not exist, even after waiting.
"""
for delay in (3, 6, 15):
if Path.exists():
return
# This branch should be highly unlikely, so it is allowed to be slow.
# Either the original command failed to produce this file, or something
# could be wrong with file system synchronization or delays.
# Flush writes, sleep, try again.
msg(
f"Expected output file not found: {path} (Retrying after {delay}s ...)"
)
os.sync()
time.sleep(delay)
raise FileNotFoundError(
f"[{_SCRIPT_BASENAME}] *** Expected output file not found: {path}"
)
def detail_diff(left: Path, right: Path):
"""Richer difference analysis."""
print(f" {left} vs. {right}")
subprocess.call(
[
_PROJECT_ROOT_REL / _DETAIL_DIFF_SCRIPT,
str(left),
str(right),
]
)
def remove_if_exists(path: Path):
if path.exists():
path.unlink()
def execute_main_command(*args, **kwargs) -> int:
"""Forwards to subprocess.call, for ease of mocking."""
return subprocess.call(*args, **kwargs)
# TODO: de-dupe this from cl_utils after moving this source file
def copy_preserve_subpath(src: Path, dest_dir: Path):
"""Like copy(), but preserves the relative path of src in the destination."""
assert (
not src.is_absolute()
), f"source file to be copied should be relative, but got: {src}"
dest_subdir = dest_dir / src.parent
dest_subdir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest_subdir)
def retry_file_op_once_with_delay(
fileop: Callable[[], Any], failmsg: str, delay: int
):
"""Insanity is doing the same thing and expecting a different result."""
try:
fileop()
except FileNotFoundError:
# one-time retry
msg(f"{failmsg} (Retrying once after {delay}s.)")
time.sleep(delay)
fileop()
# If this fails again, exception will be raised.
def move_if_different(src: Path, dest: Path, verbose: bool = False) -> bool:
"""Moves src -> dest if their contents differ.
Args:
src: source path
dest: destination path
verbose: if True, print whether a move actually happened.
Returns:
True if move occurred,
False if the destination already matches source.
"""
ensure_file_exists(src)
if not dest.exists() or not files_match(dest, src):
if verbose:
msg(f" === Updated: {dest}")
src.rename(dest)
return True
else:
if verbose:
msg(f" === Cached: {dest}")
src.unlink()
return False
@dataclasses.dataclass
class TempFileTransform(object):
"""Represents a file name transform.
At least temp_dir or suffix or basename_prefix must be non-blank.
temp_dir: Write temporary files in here.
If blank, paths are relative to working directory.
suffix: Add this suffix to temporary files, e.g. ".tmp".
basename_prefix: Add this prefix to the basename of the path.
This can be a good choice over suffix when the underlying tool behavior
is sensitive to the output file extension.
Example: "foo/bar.txt", with prefix="tmp-" -> foo/tmp-bar.txt
"""
temp_dir: Path = None
suffix: str = ""
basename_prefix: str = ""
@property
def valid(self):
return self.temp_dir or self.suffix or self.basename_prefix
def transform(self, path: Path) -> Path:
subpath = path.parent / (self.basename_prefix + path.name + self.suffix)
return self.temp_dir / subpath if self.temp_dir else subpath
def split_transform_join(
token: str, sep: str, transform: Callable[[str], str]
) -> str:
return sep.join(transform(x) for x in token.split(sep))
def lexically_rewrite_token(token: str, transform: Callable[[str], str]) -> str:
"""Lexically replaces substrings between delimiters.
This is useful for transforming substrings of text.
This can transform "--foo=bar,baz" into
f("--foo") + "=" + f("bar") + "," + f("baz")
Args:
token: text to transform, like a shell token.
transform: text transformation.
Returns:
text with substrings transformed.
"""
def inner_transform(text: str) -> str:
return split_transform_join(text, ",", transform)
return split_transform_join(token, "=", inner_transform)
class OutputSubstitution(object):
def __init__(self, spec: str):
"""Constructs an OutputSubstitution.
Args:
spec: a string that is either:
* a filename to substitute
* a specification of the form 'substitute_after:OPTION:FILENAME'
where OPTION is a flag to match, like '--output',
and FILENAME is the output file name to substitute.
File names may not contain the characters: =:,
See help for the --output option.
"""
if spec.startswith("substitute_after:"):
tokens = spec.split(":")
if len(tokens) != 3:
raise ValueError(
f"Expecting a substitution specification FILENAME or "
+ f"substitute_after:OPTION:FILENAME, but got {spec}."
)
self._match_previous_option = tokens[1]
self._output_name = tokens[2]
else:
# if blank, this will not be used for matching
self._match_previous_option = ""
self._output_name = spec
@property
def match_previous_option(self) -> str:
return self._match_previous_option
@property
def output_name(self) -> str:
return self._output_name
@dataclasses.dataclass
class Action(object):
"""Represents a set of parameters of a single build action."""
command: Sequence[str] = dataclasses.field(default_factory=list)
substitutions: Dict[str, str] = dataclasses.field(
default_factory=dict
) # FrozenDict
label: str = ""
def substitute_command(
self, tempfile_transform: TempFileTransform
) -> Tuple[Sequence[str], Dict[Path, Path]]:
# renamed_outputs: keys: original file names, values: transformed temporary file names
renamed_outputs: Dict[Path, Path] = {}
def replace_output_filename(arg: str, prev_opt: str) -> Path:
arg_path = Path(arg)
if arg in self.substitutions:
match_previous = self.substitutions[arg]
# Some output filenames requires the previous option to match.
if match_previous != "" and prev_opt != match_previous:
return arg_path
new_arg = tempfile_transform.transform(arg_path)
if arg_path != new_arg:
renamed_outputs[arg_path] = new_arg
return new_arg
else:
return arg_path
substituted_command = []
# Subprocess calls do not work for commands that start with VAR=VALUE
# environment variables, which is remedied by prefixing with 'env'.
if self.command and "=" in self.command[0]:
substituted_command += ["/usr/bin/env"]
substituted_command += [
lexically_rewrite_token(
tok, lambda x: str(replace_output_filename(x, prev_opt))
)
for prev_opt, tok in zip([""] + self.command[:-1], self.command)
]
return substituted_command, renamed_outputs
def run_cached(
self,
tempfile_transform: TempFileTransform,
verbose: bool = False,
dry_run: bool = False,
) -> int:
"""Runs a modified command and conditionally moves outputs in-place.
Args:
tempfile_transform: describes transformation to temporary file name.
verbose: If True, print substituted command before running it.
dry_run: If True, print substituted command and stop.
"""
# renamed_outputs: keys: original file names, values: transformed temporary file names
substituted_command, renamed_outputs = self.substitute_command(
tempfile_transform
)
if verbose or dry_run:
for orig, renamed in renamed_outputs.items():
msg(f"=== renamed: {orig} -> {renamed}")
cmd_str = " ".join(substituted_command)
msg(f"=== substituted command: {cmd_str}")
if dry_run:
return 0
# mkdir when needed.
if tempfile_transform.temp_dir:
for new_arg in renamed_outputs.values():
new_arg.parent.mkdir(parents=True, exist_ok=True)
# Run the modified command.
retval = execute_main_command(substituted_command)
if retval != 0:
# Option: clean-up .tmp files or leave them for inspection
return retval
# Otherwise command succeeded, so conditionally move outputs in-place.
# TODO(fangism): This loop could be parallelized.
move_err = False
for orig_out, temp_out in renamed_outputs.items():
try:
retry_file_op_once_with_delay(
lambda: move_if_different(
src=temp_out, dest=orig_out, verbose=verbose
),
f"Failed to update {temp_out} -> {orig_out}.",
5,
)
except FileNotFoundError as e:
msg(str(e))
move_err = True
if move_err:
msg(" *** Aborting due to previous error.")
return 1
if verbose:
unrenamed_outputs = set(self.substitutions.keys()) - set(
renamed_outputs.keys()
)
if unrenamed_outputs:
# Having un-renamed outputs is not an error, but rather an indicator
# of a potentially missed opportunity to cache unchanged outputs.
unrenamed_formatted = " ".join(unrenamed_outputs)
msg(f" === Un-renamed outputs: {unrenamed_formatted}")
return 0
def run_repeatedly_and_compare_outputs(
self,
tempfile_transform: TempFileTransform,
diff_action: Callable[[Path, Path], Any],
max_attempts: int = 1,
verbose: bool = False,
) -> int:
"""Runs a command N times, copying declared outputs in between.
Compare both sets of outputs, and error out if any differ.
The advantage of this variant over others is that it eliminates
output-path sensitivities by running the *same* command N times.
One possible disadvantage is that this may expose behavioral
differences due to the non/pre-existence of outputs ahead of
running the command.
Args:
tempfile_transform: used to rename backup copies of outputs.
diff_action: action to run on files with differences.
max_attempts: number of times to re-run and compare (after the first).
verbose: if True, print more diagnostics.
Returns:
exit code 0 on command success and outputs match, else nonzero.
"""
# Run the command the first time.
retval = execute_main_command(self.command)
# If the command failed, skip re-running.
if retval != 0:
return retval
# Backup a copy of all declared outputs.
renamed_outputs: Dict[Path, Path] = {}
for out in self.substitutions:
# TODO(fangism): what do we do about symlinks?
# TODO(fangism): An output *directory* is unexpected, coming from GN,
# but has been observed. For now skip it.
out_path = Path(out)
if out_path.is_file():
renamed_outputs[out_path] = tempfile_transform.transform(
out_path
)
# A nonexistent output would be caught by action_tracer.py.
for out, backup in renamed_outputs.items():
if tempfile_transform.temp_dir:
backup.parent.mkdir(parents=True, exist_ok=True)
# preserve metadata such as timestamp
shutil.copy2(out, backup, follow_symlinks=False)
# Delaying re-run by 1s greatly increases the chances of catching
# textual clock time differences, like __TIME__ in C-preprocessing.
time.sleep(1)
for i in range(max_attempts):
# Expect re-running command to overwrite existing outputs.
rerun_retval = execute_main_command(self.command)
if rerun_retval != 0:
msg(
f"Re-run attempt {i} of command {self.command} failed, where previous runs succeeded!?"
)
return rerun_retval
verify_status = verify_files_match(
fileset=renamed_outputs,
label=self.label,
renamed=False,
diff_action=diff_action,
# re-use for multiple attempts, clean up before returning.
keep_backups=True,
)
if verify_status != 0:
msg(
f"Inconsistent output found on re-run attempt {i}/{max_attempts}."
)
# Keep around mismatched outputs.
return verify_status
# Reaching this point means all re-runs produced identical outputs.
# On success, remove the backup copies to save space.
for backup in renamed_outputs.values():
remove_if_exists(backup)
return 0
def run_twice_with_substitution_and_compare_outputs(
self,
tempfile_transform: TempFileTransform,
diff_action: Callable[[Path, Path], Any],
verbose: bool = False,
) -> int:
"""Runs a command twice, the second time with renamed outputs, and compares.
Caveat: If the contents if the outputs are sensitive to the names of the
outputs, this will find too many differences.
Args:
tempfile_transform: used to rename backup copies of outputs.
diff_action: action to run on files with differences.
verbose: if True, print more diagnostics.
Returns:
exit code 0 on command success and outputs match, else nonzero.
"""
# renamed_outputs: keys: original file names, values: transformed temporary file names
substituted_command, renamed_outputs = self.substitute_command(
tempfile_transform
)
if verbose:
cmd_str = " ".join(substituted_command)
print(f"=== substituted command: {cmd_str}")
# mkdir when needed.
if tempfile_transform.temp_dir:
for new_arg in renamed_outputs.values():
new_arg.parent.mkdir(parents=True, exist_ok=True)
# Run the original command.
retval = execute_main_command(self.command)
# If the command failed, skip re-running.
if retval != 0:
return retval
# Otherwise command succeeded, re-run with different output locations.
rerun_retval = execute_main_command(substituted_command)
if rerun_retval != 0:
print(
f"Re-run failed with substituted outputs of target [{self.label}]: {substituted_command}"
)
return rerun_retval
return verify_files_match(
fileset=renamed_outputs,
diff_action=diff_action,
label=self.label,
renamed=True,
keep_backups=False,
)
def verify_files_match(
fileset: Dict[Path, Path],
diff_action: Callable[[Path, Path], Any],
label: str,
renamed: bool,
keep_backups: bool,
) -> int:
"""Compare outputs and report differences.
Remove matching copies.
Args:
fileset: {file: backup} key-value pairs of files to compare. Backup files
that match are removed to save space, while the .keys() files are kept.
diff_action: action to run on files with differences, e.g. more
detailed diff analysis, copy files.
label: An identifier for the action that was run, for diagnostics.
renamed: True if files to compare were renamed.
keep_backups: If False, delete matching copies after comparison.
Returns:
exit code 0 if all files matched, else 1.
"""
matching_files, different_files = _partition(
fileset.items(),
# If either file is missing, this will fail, which indicates that
# something is not working as expected.
lambda pair: files_match(pair[0], pair[1]),
)
if not keep_backups:
# Remove any files that matched to save space.
for _, temp_out in matching_files:
temp_out.unlink()
if different_files:
label_text = ""
if label:
label_text = " for target [{label}]"
renamed_text = ""
if renamed:
renamed_text = " with renamed outputs"
print(
f"Repeating command{label_text}{renamed_text} produces different results:"
)
for orig, temp in different_files:
diff_action(orig, temp)
# Note: Even though the original command succeeded, forcing this to
# fail may influence tools that examine the freshness of outputs
# relative to the last succeeded command.
return 1
return 0
def _main_arg_parser() -> argparse.ArgumentParser:
"""Construct the argument parser, called by main()."""
parser = argparse.ArgumentParser(
description="Wraps a GN action to preserve unchanged outputs",
argument_default=[],
)
# label is only used for diagnostics
parser.add_argument(
"--label",
type=str,
default="",
help="The wrapped target's label",
)
parser.add_argument(
"--outputs",
nargs="*",
help="An action's declared outputs. "
+ "When an element is a plain file name, all occurrences of that file name "
+ "will be substituted in the command that writes temporary outputs. "
+ "When an element has the form 'substitute_after:OPTION:FILENAME', "
+ "only occurrences of FILENAME found in the option argument of OPTION "
+ "will be substituted (examples: OPTION=-o or OPTION=--out). "
+ "The latter form is recommended when an output filename can occur in "
+ "multiple locations in a command line. "
+ "File names must not contain =,: characters.",
)
parser.add_argument(
"--temp-suffix",
type=str,
default="",
help="Suffix to use for temporary outputs",
)
parser.add_argument(
"--temp-prefix",
type=str,
default="tmp-",
help="Basename prefix to use for temporary outputs",
)
parser.add_argument(
"--temp-dir",
type=Path,
default=None,
help="Temporary directory for writing, can be relative to working directory or absolute.",
)
parser.add_argument(
"--verbose",
action="store_true",
default=False,
help="Print information about which outputs were renamed/cached.",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="Show transformed command and exit.",
)
parser.add_argument(
"--disable",
action="store_false",
dest="enable",
default=True,
help="If disabled, run the original command as-is.",
)
parser.add_argument(
"--check-repeatability",
action="store_true",
default=False,
help="Check for repeatability: run the command twice, with different outputs, and compare.",
)
parser.add_argument(
"--max-attempts",
type=int,
default=1,
help="For --check-repeatability, re-run and compare this many times.",
)
parser.add_argument(
"--rename-outputs",
action="store_true",
default=False,
help="When checking for repeatability: rename command-line outputs on the second run.",
)
parser.add_argument(
"--miscomparison-export-dir",
type=Path,
default=None,
metavar="DIR",
help="When using --check-repeatability, save unexpectedly different artifacts to this directory, preserving relative path under the working directory.",
),
# Positional args are the command and arguments to run.
parser.add_argument("command", nargs="*", help="The command to run")
return parser
_MAIN_ARG_PARSER = _main_arg_parser()
def default_diff_action(
left: Path, right: Path, miscomparison_export_dir: Path = None
):
# Run detailed analysis, using various binary dumps.
detail_diff(left, right)
# Keep around different outputs for analysis.
if miscomparison_export_dir:
copy_preserve_subpath(left, miscomparison_export_dir)
copy_preserve_subpath(right, miscomparison_export_dir)
def main():
args = _MAIN_ARG_PARSER.parse_args()
tempfile_transform = TempFileTransform(
temp_dir=args.temp_dir,
suffix=args.temp_suffix,
basename_prefix=args.temp_prefix,
)
if not tempfile_transform.valid:
raise ValueError(
"Need either --temp-dir or --temp-suffix, but both are missing."
)
wrap = args.enable
# Decided whether or not to wrap the action script.
ignored_scripts = {
# If the action is only copying or linking, don't bother wrapping.
"ln",
"cp", # TODO: Could conditionally copy if different.
"rsync",
}
script = Path(args.command[0])
if script.name in ignored_scripts:
wrap = False
# If disabled, run the original command as-is.
if not wrap:
return execute_main_command(args.command)
# Otherwise, rewrite the command using temporary outputs.
outputs = set(args.outputs)
try:
substitutions = [OutputSubstitution(x) for x in args.outputs]
except ValueError as e:
print(str(e))
return 1
substitutions_dict = {
x.output_name: x.match_previous_option for x in substitutions
}
# Run a modified command that can leave unchanged outputs untouched.
action = Action(
command=args.command,
substitutions=substitutions_dict,
label=args.label,
)
# Run one of the following modes:
# check_repeatability: run the command twice, and compare the outputs.
# [default]: redirect outputs to temporary locations, and move them
# in-place to their original locations if contents have not changed.
if args.check_repeatability:
def _diff_action(left: Path, right: Path):
default_diff_action(left, right, args.miscomparison_export_dir)
if args.rename_outputs:
# This check variant will find path-sensitive outputs
# (for example, outputs that contain their own path),
# and nondeterminstic outputs.
return action.run_twice_with_substitution_and_compare_outputs(
tempfile_transform=tempfile_transform,
diff_action=_diff_action,
verbose=args.verbose,
)
else:
# This check will only find nondeterministic outputs.
# For example, those affected by the current time.
return action.run_repeatedly_and_compare_outputs(
tempfile_transform=tempfile_transform,
diff_action=_diff_action,
max_attempts=args.max_attempts,
verbose=args.verbose,
)
return action.run_cached(
tempfile_transform=tempfile_transform,
verbose=args.verbose,
dry_run=args.dry_run,
)
if __name__ == "__main__":
sys.exit(main())