From eb7ff2f1ae33b445605f995e6020472abc720f6d Mon Sep 17 00:00:00 2001 From: Yoav Alon <65133955+yoav-orca@users.noreply.github.com> Date: Thu, 2 Dec 2021 22:06:08 +0200 Subject: [PATCH] Added the `paths` goal to render all paths between two targets (#13778) Fixes #13774 Usage: ``` $ ./pants paths --from=src/python/pants/backend/project_info/paths.py --to=src/python/pants/engine/addresses.py [ [ "src/python/pants/backend/project_info/paths.py", "src/python/pants/engine/addresses.py" ], [ "src/python/pants/backend/project_info/paths.py", "src/python/pants/engine/target.py", "src/python/pants/engine/addresses.py" ] ] ``` --- .../pants/backend/project_info/paths.py | 141 ++++++++++++++++++ .../pants/backend/project_info/paths_test.py | 83 +++++++++++ .../pants/backend/project_info/register.py | 2 + 3 files changed, 226 insertions(+) create mode 100644 src/python/pants/backend/project_info/paths.py create mode 100644 src/python/pants/backend/project_info/paths_test.py diff --git a/src/python/pants/backend/project_info/paths.py b/src/python/pants/backend/project_info/paths.py new file mode 100644 index 00000000000..89b452eb401 --- /dev/null +++ b/src/python/pants/backend/project_info/paths.py @@ -0,0 +1,141 @@ +# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import annotations + +import json +from collections import deque +from typing import Iterable, cast + +from pants.engine.addresses import Address, Addresses, UnparsedAddressInputs +from pants.engine.console import Console +from pants.engine.goal import Goal, GoalSubsystem, Outputting +from pants.engine.rules import Get, MultiGet, collect_rules, goal_rule +from pants.engine.target import Dependencies as DependenciesField +from pants.engine.target import ( + DependenciesRequest, + Targets, + TransitiveTargets, + TransitiveTargetsRequest, +) + + +class PathsSubsystem(Outputting, GoalSubsystem): + name = "paths" + help = "List the paths between two addresses." + + @classmethod + def register_options(cls, register): + super().register_options(register) + register( + "--from", + type=str, + dest="frm", + help="The path starting address", + ) + + register( + "--to", + type=str, + help="The path end address", + ) + + @property + def path_from(self) -> str: + return cast(str, self.options.frm) + + @property + def path_to(self) -> str: + return cast(str, self.options.to) + + +class PathsGoal(Goal): + subsystem_cls = PathsSubsystem + + +def find_paths_breadth_first( + adjacency_lists: dict[Address, Targets], from_target: Address, to_target: Address +) -> Iterable[list[Address]]: + """Yields the paths between from_target to to_target if they exist. + + The paths are returned ordered by length, shortest first. If there are cycles, it checks visited + edges to prevent recrossing them. + """ + + if from_target == to_target: + yield [from_target] + return + + visited_edges = set() + to_walk_paths = deque([[from_target]]) + + while len(to_walk_paths) > 0: + cur_path = to_walk_paths.popleft() + target = cur_path[-1] + + if len(cur_path) > 1: + prev_target: Address | None = cur_path[-2] + else: + prev_target = None + current_edge = (prev_target, target) + + if current_edge not in visited_edges: + for dep in adjacency_lists[target]: + dep_path = cur_path + [dep.address] + if dep.address == to_target: + yield dep_path + else: + to_walk_paths.append(dep_path) + visited_edges.add(current_edge) + + +@goal_rule +async def paths( + console: Console, addresses: Addresses, paths_subsystem: PathsSubsystem +) -> PathsGoal: + + path_from = paths_subsystem.path_from + path_to = paths_subsystem.path_to + + if path_from is None: + raise ValueError("Must set a --paths-from") + + if path_to is None: + raise ValueError("Must set a --paths-to") + + root, destination = await Get( + Addresses, + UnparsedAddressInputs(values=[path_from, path_to], owning_address=None), + ) + + transitive_targets = await Get( + TransitiveTargets, TransitiveTargetsRequest([root], include_special_cased_deps=True) + ) + + if not any(destination == dep.address for dep in transitive_targets.closure): + raise ValueError("The destination is not a dependency of the source") + + adjacent_targets_per_target = await MultiGet( + Get( + Targets, + DependenciesRequest(tgt.get(DependenciesField), include_special_cased_deps=True), + ) + for tgt in transitive_targets.closure + ) + + transitive_targets_closure_addresses = (t.address for t in transitive_targets.closure) + adjacency_lists = dict(zip(transitive_targets_closure_addresses, adjacent_targets_per_target)) + + spec_paths = [] + for path in find_paths_breadth_first(adjacency_lists, root, destination): + spec_path = [address.spec for address in path] + spec_paths.append(spec_path) + + with paths_subsystem.output(console) as write_stdout: + write_stdout(json.dumps(spec_paths, indent=2)) + + return PathsGoal(exit_code=0) + + +def rules(): + return collect_rules() diff --git a/src/python/pants/backend/project_info/paths_test.py b/src/python/pants/backend/project_info/paths_test.py new file mode 100644 index 00000000000..92bd4de956f --- /dev/null +++ b/src/python/pants/backend/project_info/paths_test.py @@ -0,0 +1,83 @@ +# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). +import json +from typing import List + +import pytest + +from pants.backend.project_info.paths import PathsGoal +from pants.backend.project_info.paths import rules as paths_rules +from pants.engine.internals.scheduler import ExecutionError +from pants.engine.target import Dependencies, Target +from pants.testutil.rule_runner import RuleRunner + + +class MockTarget(Target): + alias = "tgt" + core_fields = (Dependencies,) + + +@pytest.fixture +def rule_runner() -> RuleRunner: + runner = RuleRunner(rules=paths_rules(), target_types=[MockTarget]) + runner.add_to_build_file("base", "tgt()") + runner.add_to_build_file("intermediate", "tgt(dependencies=['base'])") + runner.add_to_build_file("intermediate2", "tgt(dependencies=['base'])") + runner.add_to_build_file("leaf", "tgt(dependencies=['intermediate', 'intermediate2'])") + return runner + + +def assert_paths( + rule_runner: RuleRunner, + *, + path_from: str, + path_to: str, + expected: List[List[str]], +) -> None: + args = [] + if path_from: + args += [f"--paths-from={path_from}"] + if path_to: + args += [f"--paths-to={path_to}"] + + result = rule_runner.run_goal_rule(PathsGoal, args=[*args]) + + assert sorted(json.loads(result.stdout)) == sorted(expected) + + +@pytest.mark.parametrize( + "path_from,path_to", [["", ""], ["intermediate:intermediate", ""], ["", "base:base"]] +) +def test_no_targets(rule_runner: RuleRunner, path_from: str, path_to: str) -> None: + with pytest.raises(ExecutionError): + assert_paths(rule_runner, path_from=path_from, path_to=path_to, expected=[]) + + +def test_normal(rule_runner: RuleRunner) -> None: + assert_paths( + rule_runner, + path_from="intermediate:intermediate", + path_to="base:base", + expected=[["intermediate:intermediate", "base:base"]], + ) + + +def test_path_to_self(rule_runner: RuleRunner) -> None: + assert_paths( + rule_runner, + path_from="base:base", + path_to="base:base", + expected=[["base:base"]], + ) + + +def test_multiple_paths(rule_runner: RuleRunner) -> None: + assert_paths( + rule_runner, + path_from="leaf:leaf", + path_to="base:base", + expected=[ + ["leaf:leaf", "intermediate:intermediate", "base:base"], + ["leaf:leaf", "intermediate2:intermediate2", "base:base"], + ], + ) diff --git a/src/python/pants/backend/project_info/register.py b/src/python/pants/backend/project_info/register.py index f99be87b499..f42320cf2fc 100644 --- a/src/python/pants/backend/project_info/register.py +++ b/src/python/pants/backend/project_info/register.py @@ -11,6 +11,7 @@ filter_targets, list_roots, list_targets, + paths, peek, source_file_validator, ) @@ -25,6 +26,7 @@ def rules(): *filter_targets.rules(), *list_roots.rules(), *list_targets.rules(), + *paths.rules(), *peek.rules(), *source_file_validator.rules(), ]