diff --git a/tools/android/dependency_analysis/class_dependency.py b/tools/android/dependency_analysis/class_dependency.py index 567a5168bfd513..d82bc10fbbbef3 100644 --- a/tools/android/dependency_analysis/class_dependency.py +++ b/tools/android/dependency_analysis/class_dependency.py @@ -4,29 +4,28 @@ # found in the LICENSE file. """Implementation of the graph module for a [Java class] dependency graph.""" -import re -from typing import Set, Tuple +from typing import Optional, Set, Tuple import graph import class_json_consts -# Matches w/o parens: (some.package.name).(class)$($optional$nested$class) -JAVA_CLASS_FULL_NAME_REGEX = re.compile( - r'^(?P.*)\.(?P.*?)(\$(?P.*))?$') - def java_class_params_to_key(package: str, class_name: str): """Returns the unique key created from a package and class name.""" return f'{package}.{class_name}' -def split_nested_class_from_key(key: str) -> Tuple[str, str]: - """Splits a jdeps class name into its key and nested class, if any.""" - re_match = JAVA_CLASS_FULL_NAME_REGEX.match(key) - package = re_match.group('package') - class_name = re_match.group('class_name') - nested = re_match.group('nested') - return java_class_params_to_key(package, class_name), nested +def split_nested_class_from_key(key: str) -> Tuple[str, Optional[str]]: + """Splits a jdeps class name into its key and nested class, if any. + + E.g. package.class => 'package.class', None + package.class$nested => 'package.class', 'nested' + """ + first_dollar_sign = key.find('$') + if first_dollar_sign == -1: + return key, None + else: + return key[:first_dollar_sign], key[first_dollar_sign + 1:] class JavaClass(graph.Node): @@ -113,14 +112,14 @@ def get_node_metadata(self): } -class JavaClassDependencyGraph(graph.Graph): +class JavaClassDependencyGraph(graph.Graph[JavaClass]): """A graph representation of the dependencies between Java classes. A directed edge A -> B indicates that A depends on B. """ def create_node_from_key(self, key: str): - """See comment above the regex definition.""" - re_match = JAVA_CLASS_FULL_NAME_REGEX.match(key) - package = re_match.group('package') - class_name = re_match.group('class_name') - return JavaClass(package, class_name) + """Splits the key into package and class_name.""" + key_without_nested_class, _ = split_nested_class_from_key(key) + last_period = key_without_nested_class.rfind('.') + return JavaClass(package=key_without_nested_class[:last_period], + class_name=key_without_nested_class[last_period + 1:]) diff --git a/tools/android/dependency_analysis/class_dependency_unittest.py b/tools/android/dependency_analysis/class_dependency_unittest.py index d48b55354cee83..12752a8ec0b96d 100755 --- a/tools/android/dependency_analysis/class_dependency_unittest.py +++ b/tools/android/dependency_analysis/class_dependency_unittest.py @@ -4,6 +4,7 @@ # found in the LICENSE file. """Unit tests for dependency_analysis.class_dependency.""" +import unittest import unittest.mock import class_dependency diff --git a/tools/android/dependency_analysis/generate_json_dependency_graph.py b/tools/android/dependency_analysis/generate_json_dependency_graph.py index 0ffaa1e1088cc3..cc3dff23e959ee 100755 --- a/tools/android/dependency_analysis/generate_json_dependency_graph.py +++ b/tools/android/dependency_analysis/generate_json_dependency_graph.py @@ -6,33 +6,50 @@ import argparse import functools +import logging import math import multiprocessing import pathlib import os +import subprocess +import sys -from typing import List, Tuple +from typing import List, Tuple, Union import class_dependency import git_utils import package_dependency import serialization import subprocess_utils +import target_dependency -DEFAULT_ROOT_TARGET = 'chrome/android:monochrome_public_bundle' +_SRC_PATH = pathlib.Path(__file__).parents[3].resolve() +sys.path.append(str(_SRC_PATH / 'build' / 'android')) +from pylib import constants +_DEFAULT_ROOT_TARGET = 'chrome/android:monochrome_public_bundle' +_DEFAULT_PREFIX = 'org.chromium.' +_TARGETS_WITH_NO_SOURCE_FILES = set([ + '//components/module_installer/android:module_interface_java', + '//base:jni_java' +]) -def class_is_interesting(name: str): + +def _relsrc(path: Union[str, pathlib.Path]): + return pathlib.Path(path).relative_to(_SRC_PATH) + + +def class_is_interesting(name: str, prefixes: Tuple[str]): """Checks if a jdeps class is a class we are actually interested in.""" - if name.startswith('org.chromium.'): + if not prefixes or name.startswith(prefixes): return True return False -# pylint: disable=useless-object-inheritance -class JavaClassJdepsParser(object): +class JavaClassJdepsParser: """A parser for jdeps class-level dependency output.""" - def __init__(self): # pylint: disable=missing-function-docstring + + def __init__(self): self._graph = class_dependency.JavaClassDependencyGraph() @property @@ -43,12 +60,16 @@ def graph(self): """ return self._graph - def parse_raw_jdeps_output(self, build_target: str, jdeps_output: str): + def parse_raw_jdeps_output(self, build_target: str, jdeps_output: str, + prefixes: Tuple[str]): """Parses the entirety of the jdeps output.""" for line in jdeps_output.split('\n'): - self.parse_line(build_target, line) + self.parse_line(build_target, line, prefixes) - def parse_line(self, build_target: str, line: str): + def parse_line(self, + build_target: str, + line: str, + prefixes: Tuple[str] = (_DEFAULT_PREFIX, )): """Parses a line of jdeps output. The assumed format of the line starts with 'name_1 -> name_2'. @@ -63,7 +84,7 @@ def parse_line(self, build_target: str, line: str): dep_from = parsed[0] dep_to = parsed[2] - if not class_is_interesting(dep_from): + if not class_is_interesting(dep_from, prefixes): return key_from, nested_from = class_dependency.split_nested_class_from_key( @@ -72,7 +93,7 @@ def parse_line(self, build_target: str, line: str): key_from) from_node.add_build_target(build_target) - if not class_is_interesting(dep_to): + if not class_is_interesting(dep_to, prefixes): return key_to, nested_to = class_dependency.split_nested_class_from_key( @@ -87,11 +108,36 @@ def parse_line(self, build_target: str, line: str): from_node.add_nested_class(nested_to) -def _run_jdeps(jdeps_path: str, filepath: pathlib.Path) -> str: - """Runs jdeps on the given filepath and returns the output.""" - print(f'Running jdeps and parsing output for {filepath}') - return subprocess_utils.run_command( - [jdeps_path, '-R', '-verbose:class', filepath]) +def _run_jdeps(jdeps_path: pathlib.Path, filepath: pathlib.Path) -> str: + """Runs jdeps on the given filepath and returns the output. + + Uses a simple file cache for the output of jdeps. If the jar file's mtime is + older than the jdeps cache then just use the cached content instead. + Otherwise jdeps is run again and the output used to update the file cache. + + Tested Nov 2nd, 2022: + - With all cache hits, script takes 13 seconds. + - Without the cache, script takes 1 minute 14 seconds. + """ + assert filepath.exists(), ( + f'Jar file missing for jdeps {filepath}, perhaps some targets need to ' + 'be added to _TARGETS_WITH_NO_SOURCE_FILES?') + + cache_path = filepath.with_suffix('.jdeps_cache') + if (cache_path.exists() + and cache_path.stat().st_mtime > filepath.stat().st_mtime): + logging.debug(f'Found valid jdeps cache at {_relsrc(cache_path)}') + with cache_path.open() as f: + return f.read() + + # Cache either doesn't exist or is older than the jar file. + logging.debug(f'Running jdeps and parsing output for {_relsrc(filepath)}') + output = subprocess_utils.run_command( + [str(jdeps_path), '-R', '-verbose:class', + str(filepath)]) + with cache_path.open('w') as f: + f.write(output) + return output def _run_gn_desc_list_dependencies(build_output_dir: str, target: str, @@ -124,19 +170,25 @@ def list_original_targets_and_jars(gn_desc_output: str, build_output_dir: str, original_build_target = build_target.replace('__compile_java', '') jar_path = _get_jar_path_for_target(build_output_dir, build_target, cr_position) + # Bundle module targets have no javac jars. + if (original_build_target.endswith('_bundle_module') + or original_build_target in _TARGETS_WITH_NO_SOURCE_FILES): + assert not jar_path.exists(), ( + f'Perhaps a source file was added to {original_build_target}?') + continue jar_tuples.append((original_build_target, jar_path)) return jar_tuples def _get_jar_path_for_target(build_output_dir: str, build_target: str, - cr_position: int) -> str: + cr_position: int) -> pathlib.Path: + """Calculates the output location of a jar for a java build target.""" if cr_position == 0: # Not running on main branch, use current convention. subdirectory = 'obj' elif cr_position < 761560: # crrev.com/c/2161205 subdirectory = 'gen' else: subdirectory = 'obj' - """Calculates the output location of a jar for a java build target.""" target_path, target_name = build_target.split(':') assert target_path.startswith('//'), \ f'Build target should start with "//" but is: "{build_target}"' @@ -153,22 +205,30 @@ def main(): description='Runs jdeps (dependency analysis tool) on all JARs a root ' 'build target depends on and writes the resulting dependency graph ' 'into a JSON file. The default root build target is ' - 'chrome/android:monochrome_public_bundle.') + 'chrome/android:monochrome_public_bundle and the default prefix is ' + '"org.chromium.".') required_arg_group = arg_parser.add_argument_group('required arguments') - required_arg_group.add_argument('-C', - '--build_output_dir', - required=True, - help='Build output directory.') required_arg_group.add_argument( '-o', '--output', required=True, help='Path to the file to write JSON output to. Will be created ' - 'if it does not yet exist and overwrite existing ' - 'content if it does.') + 'if it does not yet exist and overwrite existing content if it does.') + arg_parser.add_argument( + '-C', + '--build_output_dir', + help='Build output directory, will guess if not provided.') + arg_parser.add_argument( + '-p', + '--prefixes', + default=_DEFAULT_PREFIX, + help='A comma-separated list of prefixes to filter ' + 'classes. Class paths that do not match any of the ' + 'prefixes are ignored in the graph. Pass in an ' + 'empty string to turn off filtering.') arg_parser.add_argument('-t', '--target', - default=DEFAULT_ROOT_TARGET, + default=_DEFAULT_ROOT_TARGET, help='Root build target.') arg_parser.add_argument('-d', '--checkout-dir', @@ -180,8 +240,19 @@ def main(): '--gn-path', default='gn', help='Path to the gn executable.') + arg_parser.add_argument('-v', + '--verbose', + action='store_true', + help='Used to display detailed logging.') arguments = arg_parser.parse_args() + if arguments.verbose: + level = logging.DEBUG + else: + level = logging.INFO + logging.basicConfig( + level=level, format='%(levelname).1s %(relativeCreated)6d %(message)s') + if arguments.checkout_dir: src_path = pathlib.Path(arguments.checkout_dir) else: @@ -198,14 +269,32 @@ def main(): cr_position_str = git_utils.get_last_commit_cr_position() cr_position = int(cr_position_str) if cr_position_str else 0 - print('Getting list of dependency jars...') + if arguments.build_output_dir: + constants.SetOutputDirectory(arguments.build_output_dir) + constants.CheckOutputDirectory() + arguments.build_output_dir = constants.GetOutDirectory() + logging.info(f'Using output dir: {_relsrc(arguments.build_output_dir)}') + + logging.info('Getting list of dependency jars...') gn_desc_output = _run_gn_desc_list_dependencies(arguments.build_output_dir, arguments.target, arguments.gn_path) target_jars: JarTargetList = list_original_targets_and_jars( gn_desc_output, arguments.build_output_dir, cr_position) - print('Running jdeps...') + # Need to trim off leading // to convert gn target to ninja target. + missing_targets = [ + target_name[2:] for target_name, path in target_jars + if not path.exists() + ] + if missing_targets: + logging.warning( + f'Missing {len(missing_targets)} jars, re-building the targets.') + subprocess.run(['autoninja', '-C', arguments.build_output_dir] + + missing_targets, + check=True) + + logging.info('Running jdeps...') # jdeps already has some parallelism jdeps_process_number = math.ceil(multiprocessing.cpu_count() / 2) with multiprocessing.Pool(jdeps_process_number) as pool: @@ -213,24 +302,34 @@ def main(): jdeps_outputs = pool.map(functools.partial(_run_jdeps, jdeps_path), jar_paths) - print('Parsing jdeps output...') + logging.info('Parsing jdeps output...') + prefixes = tuple(arguments.prefixes.split(',')) jdeps_parser = JavaClassJdepsParser() for raw_jdeps_output, (build_target, _) in zip(jdeps_outputs, target_jars): - jdeps_parser.parse_raw_jdeps_output(build_target, raw_jdeps_output) + logging.debug(f'Parsing jdeps for {build_target}') + jdeps_parser.parse_raw_jdeps_output(build_target, + raw_jdeps_output, + prefixes=prefixes) class_graph = jdeps_parser.graph - print(f'Parsed class-level dependency graph, ' - f'got {class_graph.num_nodes} nodes ' - f'and {class_graph.num_edges} edges.') + logging.info(f'Parsed class-level dependency graph, ' + f'got {class_graph.num_nodes} nodes ' + f'and {class_graph.num_edges} edges.') package_graph = package_dependency.JavaPackageDependencyGraph(class_graph) - print(f'Created package-level dependency graph, ' - f'got {package_graph.num_nodes} nodes ' - f'and {package_graph.num_edges} edges.') - - print(f'Dumping JSON representation to {arguments.output}.') - serialization.dump_class_and_package_graphs_to_file( - class_graph, package_graph, arguments.output) + logging.info(f'Created package-level dependency graph, ' + f'got {package_graph.num_nodes} nodes ' + f'and {package_graph.num_edges} edges.') + + target_graph = target_dependency.JavaTargetDependencyGraph(class_graph) + logging.info(f'Created target-level dependency graph, ' + f'got {target_graph.num_nodes} nodes ' + f'and {target_graph.num_edges} edges.') + + logging.info(f'Dumping JSON representation to {arguments.output}.') + serialization.dump_class_and_package_and_target_graphs_to_file( + class_graph, package_graph, target_graph, arguments.output) + logging.info('Done') if __name__ == '__main__': diff --git a/tools/android/dependency_analysis/generate_json_dependency_graph_unittest.py b/tools/android/dependency_analysis/generate_json_dependency_graph_unittest.py index 74e61315e62678..ca0addd6220904 100755 --- a/tools/android/dependency_analysis/generate_json_dependency_graph_unittest.py +++ b/tools/android/dependency_analysis/generate_json_dependency_graph_unittest.py @@ -31,25 +31,34 @@ def test_class_is_interesting(self): """Tests that the helper identifies a valid Chromium class name.""" self.assertTrue( generate_json_dependency_graph.class_is_interesting( - 'org.chromium.chrome.browser.Foo')) + 'org.chromium.chrome.browser.Foo', + prefixes=('org.chromium.', ))) def test_class_is_interesting_longer(self): """Tests that the helper identifies a valid Chromium class name.""" self.assertTrue( generate_json_dependency_graph.class_is_interesting( - 'org.chromium.chrome.browser.foo.Bar')) + 'org.chromium.chrome.browser.foo.Bar', + prefixes=('org.chromium.', ))) def test_class_is_interesting_negative(self): """Tests that the helper ignores a non-Chromium class name.""" self.assertFalse( generate_json_dependency_graph.class_is_interesting( - 'org.notchromium.chrome.browser.Foo')) + 'org.notchromium.chrome.browser.Foo', + prefixes=('org.chromium.', ))) def test_class_is_interesting_not_interesting(self): """Tests that the helper ignores a builtin class name.""" self.assertFalse( generate_json_dependency_graph.class_is_interesting( - 'java.lang.Object')) + 'java.lang.Object', prefixes=('org.chromium.', ))) + + def test_class_is_interesting_everything_interesting(self): + """Tests that the helper allows anything when no prefixes are passed.""" + self.assertTrue( + generate_json_dependency_graph.class_is_interesting( + 'java.lang.Object', prefixes=tuple())) def test_list_original_targets_and_jars_legacy(self): result = generate_json_dependency_graph.list_original_targets_and_jars( diff --git a/tools/android/dependency_analysis/graph.py b/tools/android/dependency_analysis/graph.py index 0efeb7dfb77f60..82e7b5022f8e81 100644 --- a/tools/android/dependency_analysis/graph.py +++ b/tools/android/dependency_analysis/graph.py @@ -4,7 +4,7 @@ """Utility classes (and functions, in the future) for graph operations.""" import functools -from typing import Dict, List, Optional, Tuple +from typing import Dict, Generic, List, Optional, Tuple, TypeVar def sorted_nodes_by_name(nodes): @@ -18,10 +18,10 @@ def sorted_edges_by_name(edges): Prioritizes sorting by the first node in an edge.""" return sorted(edges, key=lambda edge: (edge[0].name, edge[1].name)) - @functools.total_ordering -class Node(object): # pylint: disable=useless-object-inheritance +class Node: """A node/vertex in a directed graph.""" + def __init__(self, unique_key: str): """Initializes a new node with the given key. @@ -32,13 +32,13 @@ def __init__(self, unique_key: str): self._outbound = set() self._inbound = set() - def __eq__(self, other: 'Node'): # pylint: disable=missing-function-docstring + def __eq__(self, other: 'Node'): return self._unique_key == other._unique_key - def __lt__(self, other: 'Node'): # pylint: disable=missing-function-docstring + def __lt__(self, other: 'Node'): return self._unique_key < other._unique_key - def __hash__(self): # pylint: disable=missing-function-docstring + def __hash__(self): return hash(self._unique_key) def __str__(self) -> str: @@ -74,13 +74,18 @@ def get_node_metadata(self) -> Optional[Dict]: return None -class Graph(object): # pylint: disable=useless-object-inheritance +T = TypeVar('T', bound=Node) + + +class Graph(Generic[T]): """A directed graph data structure. - Maintains an internal Dict[str, Node] _key_to_node - mapping the unique key of nodes to their Node objects. + Maintains an internal Dict[str, T] _key_to_node mapping the unique key of + nodes to their Node objects. Allows subclasses to specify their own Node + subclasses via Generic typing. """ - def __init__(self): # pylint: disable=missing-function-docstring + + def __init__(self): self._key_to_node = {} self._edges = [] @@ -95,26 +100,27 @@ def num_edges(self) -> int: return len(self.edges) @property - def nodes(self) -> List[Node]: + def nodes(self) -> List[T]: """A list of Nodes in the graph.""" return list(self._key_to_node.values()) @property - def edges(self) -> List[Tuple[Node, Node]]: + def edges(self) -> List[Tuple[T, T]]: """A list of tuples (begin, end) representing directed edges.""" return self._edges - def get_node_by_key(self, key: str): # pylint: disable=missing-function-docstring + def get_node_by_key(self, key: str) -> Optional[T]: + """Returns a node by that key or None if no such node exists.""" return self._key_to_node.get(key) - def create_node_from_key(self, key: str): + def create_node_from_key(self, key: str) -> T: """Given a unique key, creates and returns a Node object. Should be overridden by child classes. """ - return Node(key) + return Node(key) # type: ignore - def add_node_if_new(self, key: str) -> Node: + def add_node_if_new(self, key: str) -> T: """Adds a Node to the graph. A new Node object is constructed from the given key and added. @@ -126,11 +132,12 @@ def add_node_if_new(self, key: str) -> Node: Returns: The Node with the given key in the graph. """ - node = self._key_to_node.get(key) - if node is None: + try: + return self._key_to_node[key] + except KeyError: node = self.create_node_from_key(key) self._key_to_node[key] = node - return node + return node def add_edge_if_new(self, src: str, dest: str) -> bool: """Adds a directed edge to the graph. diff --git a/tools/android/dependency_analysis/group_json_consts.py b/tools/android/dependency_analysis/group_json_consts.py new file mode 100644 index 00000000000000..2882a47bf2d091 --- /dev/null +++ b/tools/android/dependency_analysis/group_json_consts.py @@ -0,0 +1,10 @@ +# Copyright 2022 The Chromium Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +"""Various group dependency constants used in de/serialization.""" + +# Node-specific constants +CLASSES = 'classes' # Internal classes of a group. + +# Edge-specific constants. +CLASS_EDGES = 'class_edges' # The class edges comprising a group edge. diff --git a/tools/android/dependency_analysis/java_group.py b/tools/android/dependency_analysis/java_group.py new file mode 100644 index 00000000000000..6b0303d90b488e --- /dev/null +++ b/tools/android/dependency_analysis/java_group.py @@ -0,0 +1,91 @@ +# Copyright 2022 The Chromium Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +"""Implementation for managing a group of JavaClass nodes.""" + +import collections +from typing import Set, Tuple + +import class_dependency +import graph +import group_json_consts + + +class JavaGroup(graph.Node): + """A representation of a group of classes.""" + + def __init__(self, group_name: str): + """Initializes a new Java group structure. + + Args: + group_name: The name of this group. + """ + super().__init__(group_name) + + self._classes = {} + self._class_dependency_edges = collections.defaultdict(set) + + @property + def classes(self): + """A map { name -> JavaClass } of classes within this group.""" + return self._classes + + def add_class(self, java_class: class_dependency.JavaClass): + """Adds a JavaClass to the group, if its key doesn't already exist. + + Notably, this does /not/ automatically update the inbound/outbound + dependencies of the group with the dependencies of the class. + """ + if java_class.name not in self._classes: + self._classes[java_class.name] = java_class + + def add_class_dependency_edge(self, end_group: 'JavaGroup', + begin_class: class_dependency.JavaClass, + end_class: class_dependency.JavaClass): + """Adds a class dependency edge as part of a group dependency. + + Each group dependency is comprised of one or more class dependencies, + we manually update the nodes with this info when parsing class graphs. + + Args: + end_group: the end node of the group dependency edge which starts + from this node. + begin_class: the start node of the class dependency edge. + end_class: the end node of the class dependency edge. + """ + class_edge = (begin_class, end_class) + if class_edge not in self._class_dependency_edges[end_group]: + self._class_dependency_edges[end_group].add(class_edge) + + def get_class_dependencies_in_outbound_edge(self, end_node: 'JavaGroup' + ) -> Set[Tuple]: + """Breaks down a group dependency edge into class dependencies. + + For group A to depend on another group B, there must exist at least one + class in A depending on a class in B. This method, given a group + dependency edge A -> B, returns a set of class dependency edges + satisfying (class in A) -> (class in B). + + Args: + end_node: The destination node. This method will return the class + dependencies forming the edge from the current node to end_node. + + Returns: + A set of tuples of `JavaClass` nodes, where a tuple (a, b) + indicates a class dependency a -> b. If there are no relevant + class dependencies, returns an empty set. + """ + return self._class_dependency_edges[end_node] + + def get_node_metadata(self): + """Generates JSON metadata for the current node. + + The list of classes is sorted in order to help with testing. + Structure: + { + 'classes': [ class_key, ... ], + } + """ + return { + group_json_consts.CLASSES: sorted(self.classes.keys()), + } diff --git a/tools/android/dependency_analysis/java_group_unittest.py b/tools/android/dependency_analysis/java_group_unittest.py new file mode 100755 index 00000000000000..befaa88d2d9ba1 --- /dev/null +++ b/tools/android/dependency_analysis/java_group_unittest.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# Copyright 2022 The Chromium Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +"""Unit tests for dependency_analysis.java_group.""" + +import unittest +import unittest.mock + +import java_group + + +def create_mock_java_class(cls='class'): + """Returns a Mock of JavaClass. + + The fields `class_name`, and `name` will be initialized. + """ + mock_class = unittest.mock.Mock() + mock_class.class_name = cls + mock_class.name = f'package.{cls}' + return mock_class + + +class TestJavaPackage(unittest.TestCase): + """Unit tests for dependency_analysis.class_dependency.JavaGroup.""" + TEST_GRP_1 = 'group1' + TEST_GRP_2 = 'group2' + TEST_CLS_1 = 'class1' + TEST_CLS_2 = 'class2' + TEST_CLS_3 = 'class3' + + def test_initialization(self): + """Tests that JavaGroup is initialized correctly.""" + test_node = java_group.JavaGroup(self.TEST_GRP_1) + self.assertEqual(test_node.name, self.TEST_GRP_1) + self.assertEqual(test_node.classes, {}) + + def test_add_class(self): + """Tests adding a single class to this group.""" + test_node = java_group.JavaGroup(self.TEST_GRP_1) + mock_class_node = create_mock_java_class() + test_node.add_class(mock_class_node) + self.assertEqual(test_node.classes, + {mock_class_node.name: mock_class_node}) + + def test_add_class_multiple(self): + """Tests adding multiple classes to this group.""" + test_node = java_group.JavaGroup(self.TEST_GRP_1) + mock_class_node_1 = create_mock_java_class(cls=self.TEST_CLS_1) + mock_class_node_2 = create_mock_java_class(cls=self.TEST_CLS_2) + test_node.add_class(mock_class_node_1) + test_node.add_class(mock_class_node_2) + self.assertEqual( + test_node.classes, { + mock_class_node_1.name: mock_class_node_1, + mock_class_node_2.name: mock_class_node_2 + }) + + def test_add_class_duplicate(self): + """Tests that adding the same class twice will not dupe.""" + test_node = java_group.JavaGroup(self.TEST_GRP_1) + mock_class_node = create_mock_java_class() + test_node.add_class(mock_class_node) + test_node.add_class(mock_class_node) + self.assertEqual(test_node.classes, + {mock_class_node.name: mock_class_node}) + + def test_get_class_dependencies_in_outbound_edge(self): + """Tests adding/getting class dependency edges for a package edge.""" + start_node = java_group.JavaGroup(self.TEST_GRP_1) + end_node = java_group.JavaGroup(self.TEST_GRP_2) + + # Create three class nodes (1, 2, 3) + mock_class_node_1 = create_mock_java_class(cls=self.TEST_CLS_1) + mock_class_node_2 = create_mock_java_class(cls=self.TEST_CLS_2) + mock_class_node_3 = create_mock_java_class(cls=self.TEST_CLS_3) + + # Add the class dependencies (1 -> 2), (1 -> 2) (duplicate), (1 -> 3) + start_node.add_class_dependency_edge(end_node, mock_class_node_1, + mock_class_node_2) + start_node.add_class_dependency_edge(end_node, mock_class_node_1, + mock_class_node_2) + start_node.add_class_dependency_edge(end_node, mock_class_node_1, + mock_class_node_3) + + # Expected output: the two deduped dependencies (1 -> 2), (1 -> 3) + # making up the edge (start_node, end_node). + deps = start_node.get_class_dependencies_in_outbound_edge(end_node) + self.assertEqual(len(deps), 2) + self.assertEqual( + deps, {(mock_class_node_1, mock_class_node_2), + (mock_class_node_1, mock_class_node_3)}) + + def test_get_class_dependencies_in_outbound_edge_not_outbound(self): + """Tests getting dependencies for a non-outbound edge.""" + test_node_1 = java_group.JavaGroup(self.TEST_GRP_1) + test_node_2 = java_group.JavaGroup(self.TEST_GRP_2) + + # Expected output: an empty set, since there are no class dependencies + # comprising a package dependency edge that doesn't exist. + deps = test_node_1.get_class_dependencies_in_outbound_edge(test_node_2) + self.assertEqual(deps, set()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/android/dependency_analysis/json_consts.py b/tools/android/dependency_analysis/json_consts.py index d755b3f5c8958d..db0de94f4a5bc9 100644 --- a/tools/android/dependency_analysis/json_consts.py +++ b/tools/android/dependency_analysis/json_consts.py @@ -18,6 +18,7 @@ COMMIT_TIME = 'commit_time' # Miscellaneous attributes. +TARGET_GRAPH = 'target_graph' PACKAGE_GRAPH = 'package_graph' CLASS_GRAPH = 'class_graph' BUILD_METADATA = 'build_metadata' diff --git a/tools/android/dependency_analysis/package_dependency.py b/tools/android/dependency_analysis/package_dependency.py index 646bcc43b79365..f52b827a2ff01c 100644 --- a/tools/android/dependency_analysis/package_dependency.py +++ b/tools/android/dependency_analysis/package_dependency.py @@ -3,94 +3,17 @@ # found in the LICENSE file. """Implementation of the graph module for a [Java package] dependency graph.""" -import collections -from typing import Set, Tuple - import class_dependency import graph -import package_json_consts +import group_json_consts +import java_group -class JavaPackage(graph.Node): +class JavaPackage(java_group.JavaGroup): """A representation of a Java package.""" - def __init__(self, package_name: str): - """Initializes a new Java package structure. - - Args: - package_name: The name of the package. - """ - super().__init__(package_name) - - self._classes = {} - self._class_dependency_edges = collections.defaultdict(set) - - @property - def classes(self): - """A map { name -> JavaClass } of classes within this package.""" - return self._classes - - def add_class(self, java_class: class_dependency.JavaClass): - """Adds a JavaClass to the package, if its key doesn't already exist. - - Notably, this does /not/ automatically update the inbound/outbound - dependencies of the package with the dependencies of the class. - """ - if java_class.name not in self._classes: - self._classes[java_class.name] = java_class - - def add_class_dependency_edge(self, end_package: 'JavaPackage', - begin_class: class_dependency.JavaClass, - end_class: class_dependency.JavaClass): - """Adds a class dependency edge as part of a package dependency. - - Each package dependency is comprised of one or more class dependencies, - we manually update the nodes with this info when parsing class graphs. - - Args: - end_package: the end node of the package dependency edge - which starts from this node. - begin_class: the start node of the class dependency edge. - end_class: the end node of the class dependency edge. - """ - class_edge = (begin_class, end_class) - if class_edge not in self._class_dependency_edges[end_package]: - self._class_dependency_edges[end_package].add(class_edge) - - def get_class_dependencies_in_outbound_edge( - self, end_node: 'JavaPackage') -> Set[Tuple]: - """Breaks down a package dependency edge into class dependencies. - - For package A to depend on another package B, there must exist - at least one class in A depending on a class in B. This method, given - a package dependency edge A -> B, returns a set of class - dependency edges satisfying (class in A) -> (class in B). - - Args: - end_node: The destination node. This method will return the class - dependencies forming the edge from the current node to end_node. - - Returns: - A set of tuples of `JavaClass` nodes, where a tuple (a, b) - indicates a class dependency a -> b. If there are no relevant - class dependencies, returns an empty set. - """ - return self._class_dependency_edges[end_node] - - def get_node_metadata(self): - """Generates JSON metadata for the current node. - - The list of classes is sorted in order to help with testing. - Structure: - { - 'classes': [ class_key, ... ], - } - """ - return { - package_json_consts.CLASSES: sorted(self.classes.keys()), - } -class JavaPackageDependencyGraph(graph.Graph): +class JavaPackageDependencyGraph(graph.Graph[JavaPackage]): """A graph representation of the dependencies between Java packages. A directed edge A -> B indicates that A depends on B. @@ -116,6 +39,8 @@ def __init__(self, class_graph: class_dependency.JavaClassDependencyGraph): begin_package_node = self.get_node_by_key(begin_package) end_package_node = self.get_node_by_key(end_package) + assert begin_package_node is not None + assert end_package_node is not None begin_package_node.add_class(begin_class) end_package_node.add_class(end_class) begin_package_node.add_class_dependency_edge( @@ -137,7 +62,7 @@ def get_edge_metadata(self, begin_node, end_node): } """ return { - package_json_consts.CLASS_EDGES: + group_json_consts.CLASS_EDGES: sorted( [begin.name, end.name] for begin, end in begin_node.get_class_dependencies_in_outbound_edge(end_node)), diff --git a/tools/android/dependency_analysis/package_dependency_unittest.py b/tools/android/dependency_analysis/package_dependency_unittest.py index 09a1bf3e569f21..198f3e9f7e1d79 100755 --- a/tools/android/dependency_analysis/package_dependency_unittest.py +++ b/tools/android/dependency_analysis/package_dependency_unittest.py @@ -4,6 +4,7 @@ # found in the LICENSE file. """Unit tests for dependency_analysis.package_dependency.""" +import unittest import unittest.mock import package_dependency @@ -21,87 +22,6 @@ def create_mock_java_class(pkg='package', cls='class'): return mock_class -class TestJavaPackage(unittest.TestCase): - """Unit tests for dependency_analysis.class_dependency.JavaPackage.""" - TEST_PKG_1 = 'package1' - TEST_PKG_2 = 'package2' - TEST_CLS_1 = 'class1' - TEST_CLS_2 = 'class2' - TEST_CLS_3 = 'class3' - - def test_initialization(self): - """Tests that JavaPackage is initialized correctly.""" - test_node = package_dependency.JavaPackage(self.TEST_PKG_1) - self.assertEqual(test_node.name, self.TEST_PKG_1) - self.assertEqual(test_node.classes, {}) - - def test_add_class(self): - """Tests adding a single class to this package.""" - test_node = package_dependency.JavaPackage(self.TEST_PKG_1) - mock_class_node = create_mock_java_class() - test_node.add_class(mock_class_node) - self.assertEqual(test_node.classes, - {mock_class_node.name: mock_class_node}) - - def test_add_class_multiple(self): - """Tests adding multiple classes to this package.""" - test_node = package_dependency.JavaPackage(self.TEST_PKG_1) - mock_class_node_1 = create_mock_java_class(cls=self.TEST_CLS_1) - mock_class_node_2 = create_mock_java_class(cls=self.TEST_CLS_2) - test_node.add_class(mock_class_node_1) - test_node.add_class(mock_class_node_2) - self.assertEqual( - test_node.classes, { - mock_class_node_1.name: mock_class_node_1, - mock_class_node_2.name: mock_class_node_2 - }) - - def test_add_class_duplicate(self): - """Tests that adding the same class twice will not dupe.""" - test_node = package_dependency.JavaPackage(self.TEST_PKG_1) - mock_class_node = create_mock_java_class() - test_node.add_class(mock_class_node) - test_node.add_class(mock_class_node) - self.assertEqual(test_node.classes, - {mock_class_node.name: mock_class_node}) - - def test_get_class_dependencies_in_outbound_edge(self): - """Tests adding/getting class dependency edges for a package edge.""" - start_node = package_dependency.JavaPackage(self.TEST_PKG_1) - end_node = package_dependency.JavaPackage(self.TEST_PKG_2) - - # Create three class nodes (1, 2, 3) - mock_class_node_1 = create_mock_java_class(cls=self.TEST_CLS_1) - mock_class_node_2 = create_mock_java_class(cls=self.TEST_CLS_2) - mock_class_node_3 = create_mock_java_class(cls=self.TEST_CLS_3) - - # Add the class dependencies (1 -> 2), (1 -> 2) (duplicate), (1 -> 3) - start_node.add_class_dependency_edge(end_node, mock_class_node_1, - mock_class_node_2) - start_node.add_class_dependency_edge(end_node, mock_class_node_1, - mock_class_node_2) - start_node.add_class_dependency_edge(end_node, mock_class_node_1, - mock_class_node_3) - - # Expected output: the two deduped dependencies (1 -> 2), (1 -> 3) - # making up the edge (start_node, end_node). - deps = start_node.get_class_dependencies_in_outbound_edge(end_node) - self.assertEqual(len(deps), 2) - self.assertEqual( - deps, {(mock_class_node_1, mock_class_node_2), - (mock_class_node_1, mock_class_node_3)}) - - def test_get_class_dependencies_in_outbound_edge_not_outbound(self): - """Tests getting dependencies for a non-outbound edge.""" - test_node_1 = package_dependency.JavaPackage(self.TEST_PKG_1) - test_node_2 = package_dependency.JavaPackage(self.TEST_PKG_2) - - # Expected output: an empty set, since there are no class dependencies - # comprising a package dependency edge that doesn't exist. - deps = test_node_1.get_class_dependencies_in_outbound_edge(test_node_2) - self.assertEqual(deps, set()) - - class TestJavaPackageDependencyGraph(unittest.TestCase): """Unit tests for JavaPackageDependencyGraph. diff --git a/tools/android/dependency_analysis/package_json_consts.py b/tools/android/dependency_analysis/package_json_consts.py deleted file mode 100644 index 84188242beb1cf..00000000000000 --- a/tools/android/dependency_analysis/package_json_consts.py +++ /dev/null @@ -1,10 +0,0 @@ -# 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. -"""Various package dependency constants used in de/serialization.""" - -# Node-specific constants -CLASSES = 'classes' # Internal classes of a package. - -# Edge-specific constants. -CLASS_EDGES = 'class_edges' # The class edges comprising a package edge. diff --git a/tools/android/dependency_analysis/serialization.py b/tools/android/dependency_analysis/serialization.py index 2b1f019cd29093..b9b7c4e839b822 100644 --- a/tools/android/dependency_analysis/serialization.py +++ b/tools/android/dependency_analysis/serialization.py @@ -4,7 +4,7 @@ """Helper module for the de/serialization of graphs to/from files.""" import json -from typing import Dict, Tuple +from typing import Dict, Tuple, Union import class_dependency import class_json_consts @@ -12,6 +12,7 @@ import graph import json_consts import package_dependency +import target_dependency def create_json_obj_from_node(node: graph.Node) -> Dict: @@ -23,7 +24,7 @@ def create_json_obj_from_node(node: graph.Node) -> Dict: 'meta': { see Node.get_node_metadata }, } """ - json_obj = { + json_obj: Dict[str, Union[str, dict]] = { json_consts.NAME: node.name, } node_meta = node.get_node_metadata() @@ -104,13 +105,14 @@ def create_build_metadata() -> Dict: } -def dump_class_and_package_graphs_to_file( +def dump_class_and_package_and_target_graphs_to_file( class_graph: class_dependency.JavaClassDependencyGraph, package_graph: package_dependency.JavaPackageDependencyGraph, + target_graph: target_dependency.JavaTargetDependencyGraph, filename: str): - """Dumps a JSON representation of the class + package graph to a file. + """Dumps a JSON representation of the class/package/target graph to a file. - We dump both graphs together because the package graph in-memory holds + We dump the graphs together because the package graph in-memory holds references to class nodes (for storing class edges comprising a package edge), and hence the class graph is needed to recreate the package graph. Since our use cases always want the package graph over the @@ -120,11 +122,13 @@ class graph, there currently no point in dumping the class graph separately. { 'class_graph': { see JavaClassDependencyGraph.to_json }, 'package_graph': { see JavaPackageDependencyGraph.to_json }, + 'target_graph': { see JavaTargetDependencyGraph.to_json }, } """ json_obj = { json_consts.CLASS_GRAPH: create_json_obj_from_graph(class_graph), json_consts.PACKAGE_GRAPH: create_json_obj_from_graph(package_graph), + json_consts.TARGET_GRAPH: create_json_obj_from_graph(target_graph), json_consts.BUILD_METADATA: create_build_metadata(), } with open(filename, 'w') as json_file: diff --git a/tools/android/dependency_analysis/serialization_unittest.py b/tools/android/dependency_analysis/serialization_unittest.py index 4de0a43748e002..e96c4c060312be 100755 --- a/tools/android/dependency_analysis/serialization_unittest.py +++ b/tools/android/dependency_analysis/serialization_unittest.py @@ -4,6 +4,7 @@ # found in the LICENSE file. """Unit tests for dependency_analysis.serialization.""" +import unittest import unittest.mock import class_dependency @@ -11,7 +12,7 @@ import graph import json_consts import package_dependency -import package_json_consts +import group_json_consts import serialization @@ -85,13 +86,13 @@ class TestSerialization(unittest.TestCase): { json_consts.NAME: 'p1', json_consts.META: { - package_json_consts.CLASSES: [CLASS_1, CLASS_2], + group_json_consts.CLASSES: [CLASS_1, CLASS_2], }, }, { json_consts.NAME: 'p2', json_consts.META: { - package_json_consts.CLASSES: [CLASS_3], + group_json_consts.CLASSES: [CLASS_3], }, }, ], @@ -100,7 +101,7 @@ class TestSerialization(unittest.TestCase): json_consts.BEGIN: 'p1', json_consts.END: 'p1', json_consts.META: { - package_json_consts.CLASS_EDGES: [ + group_json_consts.CLASS_EDGES: [ [CLASS_1, CLASS_2], ], }, @@ -109,7 +110,7 @@ class TestSerialization(unittest.TestCase): json_consts.BEGIN: 'p1', json_consts.END: 'p2', json_consts.META: { - package_json_consts.CLASS_EDGES: [ + group_json_consts.CLASS_EDGES: [ [CLASS_1, CLASS_3], [CLASS_2, CLASS_3], ], diff --git a/tools/android/dependency_analysis/target_dependency.py b/tools/android/dependency_analysis/target_dependency.py new file mode 100644 index 00000000000000..5b333826b02540 --- /dev/null +++ b/tools/android/dependency_analysis/target_dependency.py @@ -0,0 +1,98 @@ +# Copyright 2022 The Chromium Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +"""Implementation of the graph module for a build target dependency graph.""" + +import class_dependency +import graph +import group_json_consts +import java_group + +_IGNORED_CLASSES = set([ + # Every target that uses JNI depends on this generated file, so it's not + # useful to include this in dependency resolution, it results in a ton of + # spurious edges (e.g. A -> GEN_JNI and B -> GEN_JNI does not mean the + # targets A and B depend on each other). + 'org.chromium.base.natives.GEN_JNI', +]) +# Usually a class is in exactly one target, but due to jar_excluded_patterns and +# android_library_factory some are in two or three. If there is a class that is +# in more than 3 build targets, it's likely a good candidate to be added to the +# _IGNORED_CLASSES list. +_MAX_CONCURRENT_BUILD_TARGETS = 3 + + +class JavaTarget(java_group.JavaGroup): + """A representation of a Java target.""" + + +class JavaTargetDependencyGraph(graph.Graph[JavaTarget]): + """A graph representation of the dependencies between Java build targets. + + A directed edge A -> B indicates that A depends on B. + """ + + def __init__(self, class_graph: class_dependency.JavaClassDependencyGraph): + """Initializes a new target-level dependency graph + by "collapsing" a class-level dependency graph into its targets. + + Args: + class_graph: A class-level graph to collapse to a target-level one. + """ + super().__init__() + + # Create list of all targets using class nodes + # so we don't miss targets with no dependencies (edges). + for class_node in class_graph.nodes: + if class_node.name in _IGNORED_CLASSES: + continue + targets = class_node.build_targets + assert len(targets) <= _MAX_CONCURRENT_BUILD_TARGETS, ( + f'{class_node.name} is in {len(targets)} build targets, which ' + 'is more than expected. Perhaps it needs to be added to ' + 'the list of _IGNORED_CLASSES?') + for build_target in targets: + self.add_node_if_new(build_target) + + for begin_class, end_class in class_graph.edges: + if (begin_class.name in _IGNORED_CLASSES + or end_class.name in _IGNORED_CLASSES): + continue + for begin_target in begin_class.build_targets: + for end_target in end_class.build_targets: + # Avoid intra-target deps. + if begin_target == end_target: + continue + + self.add_edge_if_new(begin_target, end_target) + + begin_target_node = self.get_node_by_key(begin_target) + end_target_node = self.get_node_by_key(end_target) + assert begin_target_node is not None + assert end_target_node is not None + begin_target_node.add_class(begin_class) + end_target_node.add_class(end_class) + begin_target_node.add_class_dependency_edge( + end_target_node, begin_class, end_class) + + def create_node_from_key(self, key: str): + """Create a JavaTarget node from the given key (target name).""" + return JavaTarget(key) + + def get_edge_metadata(self, begin_node, end_node): + """Generates JSON metadata for the current edge. + + The list of edges is sorted in order to help with testing. + Structure: + { + 'class_edges': [ + [begin_key, end_key], ... + ], + } + """ + return { + group_json_consts.CLASS_EDGES: + sorted( + [begin.name, end.name] for begin, end in + begin_node.get_class_dependencies_in_outbound_edge(end_node)), + } diff --git a/tools/android/dependency_analysis/target_dependency_unittest.py b/tools/android/dependency_analysis/target_dependency_unittest.py new file mode 100755 index 00000000000000..7526b7cb98adb9 --- /dev/null +++ b/tools/android/dependency_analysis/target_dependency_unittest.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# Copyright 2022 The Chromium Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +"""Unit tests for dependency_analysis.target_dependency.""" + +from typing import List, Optional +import unittest +import unittest.mock + +import target_dependency + + +def create_mock_java_class(targets: Optional[List[str]] = None, + pkg='package', + cls='class'): + mock_class = unittest.mock.Mock() + mock_class.class_name = cls + mock_class.package = pkg + mock_class.name = f'{pkg}.{cls}' + mock_class.build_targets = targets + return mock_class + + +class TestJavaTargetDependencyGraph(unittest.TestCase): + """Unit tests for JavaTargetDependencyGraph. + + Full name: dependency_analysis.class_dependency.JavaTargetDependencyGraph. + """ + TEST_TARGET1 = 'target1' + TEST_TARGET2 = 'target2' + TEST_CLS = 'class' + + def test_initialization(self): + """Tests that initialization collapses a class dependency graph.""" + # Create three class nodes (1, 2, 3) in two targets: [1, 2] and [3]. + + mock_class_node_1 = create_mock_java_class(targets=[self.TEST_TARGET1]) + mock_class_node_2 = create_mock_java_class(targets=[self.TEST_TARGET1]) + mock_class_node_3 = create_mock_java_class(targets=[self.TEST_TARGET2]) + + # Create dependencies (1 -> 3) and (3 -> 2). + mock_class_graph = unittest.mock.Mock() + mock_class_graph.nodes = [ + mock_class_node_1, mock_class_node_2, mock_class_node_3 + ] + mock_class_graph.edges = [(mock_class_node_1, mock_class_node_3), + (mock_class_node_3, mock_class_node_2)] + + test_graph = target_dependency.JavaTargetDependencyGraph( + mock_class_graph) + + # Expected output: two-node target graph with a bidirectional edge. + self.assertEqual(test_graph.num_nodes, 2) + self.assertEqual(test_graph.num_edges, 2) + self.assertIsNotNone(test_graph.get_node_by_key(self.TEST_TARGET1)) + self.assertIsNotNone(test_graph.get_node_by_key(self.TEST_TARGET2)) + # Ensure there is a bidirectional edge. + (edge_1_start, edge_1_end), (edge_2_start, + edge_2_end) = test_graph.edges + self.assertEqual(edge_1_start, edge_2_end) + self.assertEqual(edge_2_start, edge_1_end) + + def test_initialization_no_dependencies(self): + """Tests that a target with no external dependencies is included.""" + # Create one class node (1) in one target: [1]. + mock_class_node = create_mock_java_class(targets=[self.TEST_TARGET1]) + + # Do not create any dependencies. + mock_class_graph = unittest.mock.Mock() + mock_class_graph.nodes = [mock_class_node] + mock_class_graph.edges = [] + + test_graph = target_dependency.JavaTargetDependencyGraph( + mock_class_graph) + + # Expected output: one-node package graph with no edges. + self.assertEqual(test_graph.num_nodes, 1) + self.assertEqual(test_graph.num_edges, 0) + self.assertIsNotNone(test_graph.get_node_by_key(self.TEST_TARGET1)) + + def test_initialization_internal_dependencies(self): + """Tests that a target with only internal dependencies has no edges. + + It is not useful to include intra-target dependencies in a build target + dependency graph. + """ + # Create two class nodes (1, 2) in one target: [1, 2]. + mock_class_node_1 = create_mock_java_class(targets=[self.TEST_TARGET1]) + mock_class_node_2 = create_mock_java_class(targets=[self.TEST_TARGET1]) + + # Create a dependency (1 -> 2). + mock_class_graph = unittest.mock.Mock() + mock_class_graph.nodes = [mock_class_node_1, mock_class_node_2] + mock_class_graph.edges = [(mock_class_node_1, mock_class_node_2)] + + test_graph = target_dependency.JavaTargetDependencyGraph( + mock_class_graph) + + # Expected output: one-node package graph with no edges. + self.assertEqual(test_graph.num_nodes, 1) + self.assertEqual(test_graph.num_edges, 0) + self.assertIsNotNone(test_graph.get_node_by_key(self.TEST_TARGET1)) + + def test_initialization_allows_multiple_targets_per_class(self): + """Tests that initialization handles a class in multiple targets.""" + # Create three class nodes (1, 2, 3) in in two targets [1, 2], [1, 3]. + + mock_class_node_1 = create_mock_java_class( + targets=[self.TEST_TARGET1, self.TEST_TARGET2]) + mock_class_node_2 = create_mock_java_class(targets=[self.TEST_TARGET1]) + mock_class_node_3 = create_mock_java_class(targets=[self.TEST_TARGET2]) + + # Create dependencies (1 -> 3) and (3 -> 2). + mock_class_graph = unittest.mock.Mock() + mock_class_graph.nodes = [ + mock_class_node_1, mock_class_node_2, mock_class_node_3 + ] + mock_class_graph.edges = [(mock_class_node_1, mock_class_node_3), + (mock_class_node_3, mock_class_node_2)] + + test_graph = target_dependency.JavaTargetDependencyGraph( + mock_class_graph) + + # Expected output: two-node target graph with a bidirectional edge and + # no self edge: target1 <=> target2 + self.assertEqual(test_graph.num_nodes, 2) + self.assertEqual(test_graph.num_edges, 2) + target1_node = test_graph.get_node_by_key(self.TEST_TARGET1) + target2_node = test_graph.get_node_by_key(self.TEST_TARGET2) + self.assertIsNotNone(target1_node) + self.assertIsNotNone(target2_node) + # Ensure there is a bidirectional edge. + (edge_1_start, edge_1_end), (edge_2_start, + edge_2_end) = test_graph.edges + self.assertEqual(edge_1_start, edge_2_end) + self.assertEqual(edge_2_start, edge_1_end) + + def test_create_node_from_key(self): + """Tests that a JavaTarget is correctly generated.""" + mock_class_graph = unittest.mock.Mock() + mock_class_graph.nodes = [] + mock_class_graph.edges = [] + test_graph = target_dependency.JavaTargetDependencyGraph( + mock_class_graph) + + created_node = test_graph.create_node_from_key(self.TEST_TARGET1) + self.assertEqual(created_node.name, self.TEST_TARGET1) + + +if __name__ == '__main__': + unittest.main()