blob: 82936276915988f9f311013699b7e59dbd609d45 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2022 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.
"""Relativize shell command arguments to be relative.
This script helps convert commands with absolute paths to equivalent
commands using relative paths. Paths are transformed blindly without
verifying existence or validity.
This helps reclient gather arguments under a common --exec_root directory.
Note, however, that this script is unaware of exec_root; it is the
responsibility of the invoker to make sure all path arguments fall
under a common exec_root.
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
from typing import Callable, Sequence
_SCRIPT_BASENAME = os.path.basename(__file__)
def msg(text: str):
print(f"[{_SCRIPT_BASENAME}] {text}")
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)
def greatest_path_parent(p: Path) -> Path:
assert p.is_absolute()
return Path(*p.parts[:2]) # keep the leading '/' and the first component
def relativize_path(arg: str, start: Path) -> str:
"""Convert a path or path substring to relative.
Args:
arg: string that is a path or may contain a path.
start: result paths are relative to this directory (absolute).
Returns:
possibly transformed arg with relative paths.
"""
start_abs = start.absolute()
# Handle known compiler flags like -I/abs/path, -L/abs/path
# Such flags are fused to their arguments without a delimiter.
for flag in ("-I", "-L", "-isystem"):
if arg.startswith(flag):
suffix = arg[len(flag) :]
return flag + relativize_path(suffix, start=start_abs)
try_path = Path(arg)
# Windows-style flags look like absolute paths, e.g. /Foo
# so we leave those alone by checking for existence.
# Only check the existence of the greatest parent, because some paths
# may refer to outputs that do not exist yet.
if try_path.is_absolute() and greatest_path_parent(try_path).exists():
# Can't use Path.relative_to() because arguments
# aren't guaranteed to be subdir of the other.
return os.path.relpath(arg, start=start_abs)
return arg
def relativize_command(
command: Sequence[str], working_dir: Path
) -> Sequence[str]:
"""Transform a command to use relative paths.
Args:
command: the command to transform, sequence of shell tokens.
working_dir: result paths are relative to this (absolute).
Returns:
command using relative paths
"""
relativized_command = []
# Subprocess calls do not work for commands that start with VAR=VALUE
# environment variables, which is remedied by prefixing with 'env'.
if command and "=" in command[0]:
relativized_command += ["/usr/bin/env"]
relativized_command += [
lexically_rewrite_token(tok, lambda x: relativize_path(x, working_dir))
for tok in command
]
return relativized_command
def main_arg_parser() -> argparse.ArgumentParser:
"""Construct the argument parser, called by main()."""
parser = argparse.ArgumentParser(
description="Transforms a command to use relative paths.",
argument_default=[],
)
parser.add_argument(
"--verbose",
action="store_true",
default=False,
help="Print information rewritten command.",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="Show transformed command and exit.",
)
parser.add_argument(
"--cwd",
type=Path,
default=Path(os.curdir),
help="Override the current working dir for relative paths.",
)
parser.add_argument(
"--disable",
action="store_false",
dest="enable",
default=True,
help="If disabled, run the original command as-is.",
)
# Positional args are the command and arguments to run.
parser.add_argument("command", nargs="*", help="The command to run")
return parser
def main(argv: Sequence[str]) -> None:
parser = main_arg_parser()
args = parser.parse_args(argv)
command = args.command
relativized_command = relativize_command(
command=command, working_dir=args.cwd
)
cmd_str = " ".join(relativized_command)
if args.verbose or args.dry_run:
msg(f"Relativized command: {cmd_str}")
if args.dry_run:
return 0
if not args.enable:
return subprocess.call(command)
exit_code = subprocess.call(relativized_command)
if exit_code != 0:
msg(f"*** Relativized command failed (exit={exit_code}): {cmd_str}")
return exit_code
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))