chromium/components/cronet/tools/utils.py

#!/usr/bin/env python3
#
# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
Contains general-purpose methods that can be used to execute shell,
GN and Ninja commands.
"""

import subprocess
import os
import re
import pathlib
import difflib

REPOSITORY_ROOT = os.path.abspath(
    os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))

_MB_PATH = os.path.join(REPOSITORY_ROOT, 'tools/mb/mb.py')
_GN_PATH = os.path.join(REPOSITORY_ROOT, 'buildtools/linux64/gn')
_GN_ARG_MATCHER = re.compile("^.*=.*$")


def run(command, **kwargs):
  """See the official documentation for subprocess.call.

  Args:
    command (list[str]): command to be executed

  Returns:
    int: the return value of subprocess.call
  """
  print(command, kwargs)
  return subprocess.call(command, **kwargs)


def run_shell(command, extra_options=''):
  """Runs a shell command.

  Runs a shell command with no escaping. It is recommended
  to use `run` instead.
  """
  command = command + ' ' + extra_options
  print(command)
  return os.system(command)


def gn(out_dir, gn_args, gn_extra=None, **kwargs):
  """ Executes `gn gen`.

  Runs `gn gen |out_dir| |gn_args + gn_extra|` which will generate
  a GN configuration that lives under |out_dir|. This is done
  locally on the same chromium checkout.

  Args:
    out_dir (str): Path to delegate to `gn gen`.
    gn_args (str): Args as a string delimited by space.
    gn_extra (str): extra args as a string delimited by space.

  Returns:
    Exit code of running `gn gen` command with argument provided.
  """
  cmd = [_GN_PATH, 'gen', out_dir, '--args=%s' % gn_args]
  if gn_extra:
    cmd += gn_extra
  return run(cmd, **kwargs)


def compare_text_and_generate_diff(generated_text, golden_text,
                                   golden_file_path):
  """
  Compares the generated text with the golden text.

  returns a diff that can be applied with `patch` if exists.
  """
  golden_lines = [line.rstrip() for line in golden_text.splitlines()]
  generated_lines = [line.rstrip() for line in generated_text.splitlines()]
  if golden_lines == generated_lines:
    return None

  expected_path = os.path.relpath(golden_file_path, REPOSITORY_ROOT)

  diff = difflib.unified_diff(
      golden_lines,
      generated_lines,
      fromfile=os.path.join('before', expected_path),
      tofile=os.path.join('after', expected_path),
      n=0,
      lineterm='',
  )

  return '\n'.join(diff)


def read_file(path):
  """Reads a file as a string"""
  return pathlib.Path(path).read_text()


def build(out_dir, build_target, extra_options=None):
  """Runs `autoninja build`.

  Runs `autoninja -C |out_dir| |build_target| |extra_options|` which will build
  the target |build_target| for the GN configuration living under |out_dir|.
  This is done locally on the same chromium checkout.

  Returns:
    Exit code of running `autoninja ..` command with the argument provided.
  """
  cmd = ['autoninja', '-C', out_dir, build_target]
  if extra_options:
    cmd += extra_options
  return run(cmd)


def android_gn_gen(is_release, target_cpu, out_dir):
  """Runs `gn gen` using Cronet's android gn_args.

  Creates a local GN configuration under |out_dir| with the provided argument
  as input to `get_android_gn_args`, see the documentation of
  `get_android_gn_args` for more information.
  """
  return gn(out_dir, ' '.join(get_android_gn_args(is_release, target_cpu)))


def get_android_gn_args(is_release, target_cpu):
  """Fetches the gn args for a specific builder.

  Returns a list of gn args used by the builders whose target cpu
  is |target_cpu| and (dev or rel) depending on is_release.

  See https://ci.chromium.org/p/chromium/g/chromium.android/console for
  a list of the builders

  Example:

  get_android_gn_args(true, 'x86') -> GN Args for `android-cronet-x86-rel`
  get_android_gn_args(false, 'x86') -> GN Args for `android-cronet-x86-dev`
  """
  group_name = 'chromium.android'
  builder_name = _map_config_to_android_builder(is_release, target_cpu)
  # Ideally we would call `mb_py gen` directly, but we need to filter out the
  # use_remoteexec arg, as that cannot be used in a local environment.
  gn_args = subprocess.check_output(
      ['python3', _MB_PATH, 'lookup', '-m', group_name, '-b',
       builder_name]).decode('utf-8').strip()
  return filter_gn_args(gn_args.split("\n"), [])


def get_path_from_gn_label(gn_label: str) -> str:
  """Returns the path part from a GN Label

  GN label consist of two parts, path and target_name, this will
  remove the target name and return the path or throw an error
  if it can't remove the target_name or if it doesn't exist.
  """
  if ":" not in gn_label:
    raise ValueError(f"Provided gn label {gn_label} is not a proper label")
  return gn_label[:gn_label.find(":")]


def _map_config_to_android_builder(is_release, target_cpu):
  target_cpu_to_base_builder = {
      'x86': 'android-cronet-x86',
      'x64': 'android-cronet-x64',
      'arm': 'android-cronet-arm',
      'arm64': 'android-cronet-arm64',
      'riscv64': 'android-cronet-riscv64',
  }
  if target_cpu not in target_cpu_to_base_builder:
    raise ValueError('Unsupported target CPU')

  builder_name = target_cpu_to_base_builder[target_cpu]
  if is_release:
    builder_name += '-rel'
  else:
    builder_name += '-dbg'
  return builder_name


def _should_remove_arg(arg, keys):
  """An arg is removed if its key appear in the list of |keys|"""
  return arg.split("=")[0].strip() in keys


def filter_gn_args(gn_args, keys_to_remove):
  """Returns a list of filtered GN args.

  (1) GN arg's returned must match the regex |_GN_ARG_MATCHER|.
  (2) GN arg's key must not be in |keys_to_remove|.

  Args:
    gn_args: list of GN args.
    keys_to_remove: List of string that will be removed from gn_args.
  """
  filtered_args = []
  for arg in gn_args:
    if _GN_ARG_MATCHER.match(arg) and not _should_remove_arg(
        arg, keys_to_remove):
      filtered_args.append(arg)
  return filtered_args