chromium/tools/android/dependency_analysis/print_class_dependencies.py

#!/usr/bin/env python3
# Copyright 2020 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Command-line tool for printing class-level dependencies."""

import argparse
from dataclasses import dataclass
from typing import List, Set, Tuple

import chrome_names
import class_dependency
import graph
import package_dependency
import print_dependencies_helper
import serialization


# Return values of categorize_dependency().
IGNORE = 'ignore'
CLEAR = 'clear'
PRINT = 'print'


@dataclass
class PrintMode:
    """Options of how and which dependencies to output."""
    inbound: bool
    outbound: bool
    ignore_modularized: bool
    ignore_audited_here: bool
    ignore_same_package: bool
    fully_qualified: bool


class TargetDependencies:
    """Build target dependencies that the set of classes depends on."""

    def __init__(self):
        # Build targets usable by modularized code that need to be depended on.
        self.cleared: Set[str] = set()

        # Build targets usable by modularized code that might need to be
        # depended on. This happens rarely, when a class dependency is in
        # multiple build targets (Android resource .R classes, or due to
        # bytecode rewriting).
        self.dubious: Set[str] = set()

    def update_with_class_node(self, class_node: class_dependency.JavaClass):
        if len(class_node.build_targets) == 1:
            self.cleared.update(class_node.build_targets)
        else:
            self.dubious.update(class_node.build_targets)

    def merge(self, other: 'TargetDependencies'):
        self.cleared.update(other.cleared)
        self.dubious.update(other.dubious)

    def print(self):
        if self.cleared:
            print()
            print('Cleared dependencies:')
            for dep in sorted(self.cleared):
                print(dep)
        if self.dubious:
            print()
            print('Dubious dependencies due to classes with multiple build '
                  'targets. Only some of these are required:')
            for dep in sorted(self.dubious):
                print(dep)


INBOUND = 'inbound'
OUTBOUND = 'outbound'
ALLOWED_PREFIXES = {
    '//base/',
    '//base:',
    '//chrome/browser/',
    '//components/',
    '//content/',
    '//ui/',
    '//url:',
}
IGNORED_CLASSES = {'org.chromium.base.natives.GEN_JNI'}


def get_class_name_to_display(fully_qualified_name: str,
                              print_mode: PrintMode) -> str:
    if print_mode.fully_qualified:
        return fully_qualified_name
    else:
        return chrome_names.shorten_class(fully_qualified_name)


def get_build_target_name_to_display(build_target: str,
                                     print_mode: PrintMode) -> str:
    if print_mode.fully_qualified:
        return build_target
    else:
        return chrome_names.shorten_build_target(build_target)


def is_allowed_target_dependency(build_target: str) -> bool:
    return any(build_target.startswith(p) for p in ALLOWED_PREFIXES)


def is_ignored_class_dependency(class_name: str) -> bool:
    return class_name in IGNORED_CLASSES


def categorize_dependency(from_class: class_dependency.JavaClass,
                          to_class: class_dependency.JavaClass,
                          ignore_modularized: bool, print_mode: PrintMode,
                          audited_classes: Set[str]) -> str:
    """Decides if a class dependency should be printed, cleared, or ignored."""
    if is_ignored_class_dependency(to_class.name):
        return IGNORE
    if ignore_modularized and all(
            is_allowed_target_dependency(target)
            for target in to_class.build_targets):
        return CLEAR
    if (print_mode.ignore_same_package
            and to_class.package == from_class.package):
        return IGNORE
    if print_mode.ignore_audited_here and to_class.name in audited_classes:
        return IGNORE
    return PRINT


def print_class_dependencies(to_classes: List[class_dependency.JavaClass],
                             print_mode: PrintMode,
                             from_class: class_dependency.JavaClass,
                             direction: str,
                             audited_classes: Set[str]) -> TargetDependencies:
    """Prints the class dependencies to or from a class, grouped by target.

    If direction is OUTBOUND and print_mode.ignore_modularized is True, omits
    modularized outbound dependencies and returns the build targets that need
    to be added for those dependencies. In other cases, returns an empty
    TargetDependencies.

    If print_mode.ignore_same_package is True, omits outbound dependencies in
    the same package.
    """
    ignore_modularized = direction == OUTBOUND and print_mode.ignore_modularized
    bullet_point = '<-' if direction == INBOUND else '->'

    print_backlog: List[Tuple[int, str]] = []

    # TODO(crbug.com/40147556): This is not quite correct because
    # sets considered equal can be converted to different strings. Fix this by
    # making JavaClass.build_targets return a List instead of a Set.
    suspect_dependencies = 0

    target_dependencies = TargetDependencies()
    to_classes = sorted(to_classes, key=lambda c: str(c.build_targets))
    last_build_target = None

    for to_class in to_classes:
        # Check if dependency should be ignored due to --ignore-modularized,
        # --ignore-same-package, or due to being an ignored class.
        # Check if dependency should be listed as a cleared dep.
        ignore_allow = categorize_dependency(from_class, to_class,
                                             ignore_modularized, print_mode,
                                             audited_classes)
        if ignore_allow == CLEAR:
            target_dependencies.update_with_class_node(to_class)
            continue
        elif ignore_allow == IGNORE:
            continue

        # Print the dependency
        suspect_dependencies += 1
        build_target = str(to_class.build_targets)
        if last_build_target != build_target:
            build_target_names = [
                get_build_target_name_to_display(target, print_mode)
                for target in to_class.build_targets
            ]
            build_target_names_string = ", ".join(build_target_names)
            print_backlog.append((4, f'[{build_target_names_string}]'))
            last_build_target = build_target
        display_name = get_class_name_to_display(to_class.name, print_mode)
        print_backlog.append((8, f'{bullet_point} {display_name}'))

    # Print header
    class_name = get_class_name_to_display(from_class.name, print_mode)
    if ignore_modularized:
        cleared = len(to_classes) - suspect_dependencies
        print(f'{class_name} has {suspect_dependencies} outbound dependencies '
              f'that may need to be broken (omitted {cleared} cleared '
              f'dependencies):')
    else:
        if direction == INBOUND:
            print(f'{class_name} has {len(to_classes)} inbound dependencies:')
        else:
            print(f'{class_name} has {len(to_classes)} outbound dependencies:')

    # Print build targets and dependencies
    for indent, message in print_backlog:
        indents = ' ' * indent
        print(f'{indents}{message}')

    return target_dependencies


def print_class_dependencies_for_key(
        class_graph: class_dependency.JavaClassDependencyGraph, key: str,
        print_mode: PrintMode,
        audited_classes: Set[str]) -> TargetDependencies:
    """Prints dependencies for a valid key into the class graph."""
    target_dependencies = TargetDependencies()
    node: class_dependency.JavaClass = class_graph.get_node_by_key(key)

    if print_mode.inbound:
        print_class_dependencies(graph.sorted_nodes_by_name(node.inbound),
                                 print_mode, node, INBOUND, audited_classes)

    if print_mode.outbound:
        target_dependencies = print_class_dependencies(
            graph.sorted_nodes_by_name(node.outbound), print_mode, node,
            OUTBOUND, audited_classes)
    return target_dependencies


def main():
    """Prints class-level dependencies for one or more input classes."""
    arg_parser = argparse.ArgumentParser(
        description='Given a JSON dependency graph, output '
        'the class-level dependencies for a given list of classes.')
    required_arg_group = arg_parser.add_argument_group('required arguments')
    required_arg_group.add_argument(
        '-f',
        '--file',
        required=True,
        help='Path to the JSON file containing the dependency graph. '
        'See the README on how to generate this file.')
    required_arg_group_either = arg_parser.add_argument_group(
        'required arguments (at least one)')
    required_arg_group_either.add_argument(
        '-c',
        '--classes',
        dest='class_names',
        help='Case-sensitive name of the classes to print dependencies for. '
        'Matches either the simple class name without package or the fully '
        'qualified class name. For example, `AppHooks` matches '
        '`org.chromium.browser.AppHooks`. Specify multiple classes with a '
        'comma-separated list, for example '
        '`ChromeActivity,ChromeTabbedActivity`')
    required_arg_group_either.add_argument(
        '-p',
        '--packages',
        dest='package_names',
        help='Case-sensitive name of the packages to print dependencies for, '
        'such as `org.chromium.browser`. Specify multiple packages with a '
        'comma-separated list.`')
    direction_arg_group = arg_parser.add_mutually_exclusive_group()
    direction_arg_group.add_argument('--inbound',
                                     dest='inbound_only',
                                     action='store_true',
                                     help='Print inbound dependencies only.')
    direction_arg_group.add_argument('--outbound',
                                     dest='outbound_only',
                                     action='store_true',
                                     help='Print outbound dependencies only.')
    arg_parser.add_argument('--fully-qualified',
                            action='store_true',
                            help='Use fully qualified class names instead of '
                            'shortened ones.')
    arg_parser.add_argument('--ignore-modularized',
                            action='store_true',
                            help='Do not print outbound dependencies on '
                            'allowed (modules, components, base, etc.) '
                            'dependencies.')
    arg_parser.add_argument('--ignore-audited-here',
                            action='store_true',
                            help='Do not print outbound dependencies on '
                            'other classes being audited in this run.')
    arg_parser.add_argument('--ignore-same-package',
                            action='store_true',
                            help='Do not print outbound dependencies on '
                            'classes in the same package.')
    arguments = arg_parser.parse_args()

    if not arguments.class_names and not arguments.package_names:
        raise ValueError('Either -c/--classes or -p/--packages need to be '
                         'specified.')

    print_mode = PrintMode(inbound=not arguments.outbound_only,
                           outbound=not arguments.inbound_only,
                           ignore_modularized=arguments.ignore_modularized,
                           ignore_audited_here=arguments.ignore_audited_here,
                           ignore_same_package=arguments.ignore_same_package,
                           fully_qualified=arguments.fully_qualified)

    class_graph, package_graph, _ = \
        serialization.load_class_and_package_graphs_from_file(arguments.file)

    valid_class_names = []
    if arguments.class_names:
        valid_class_names.extend(
            print_dependencies_helper.get_valid_classes_from_class_input(
                class_graph, arguments.class_names))
    if arguments.package_names:
        valid_class_names.extend(
            print_dependencies_helper.get_valid_classes_from_package_input(
                package_graph, arguments.package_names))

    target_dependencies = TargetDependencies()
    for i, fully_qualified_class_name in enumerate(valid_class_names):
        if i > 0:
            print()

        new_target_deps = print_class_dependencies_for_key(
            class_graph, fully_qualified_class_name, print_mode,
            set(valid_class_names))
        target_dependencies.merge(new_target_deps)

    target_dependencies.print()


if __name__ == '__main__':
    main()