Skip to content

Commit

Permalink
Added the paths goal to render all paths between two targets (#13778)
Browse files Browse the repository at this point in the history
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"
  ]
]
```
  • Loading branch information
yoav-orca authored Dec 2, 2021
1 parent 8d1c4c4 commit eb7ff2f
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 0 deletions.
141 changes: 141 additions & 0 deletions src/python/pants/backend/project_info/paths.py
Original file line number Diff line number Diff line change
@@ -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()
83 changes: 83 additions & 0 deletions src/python/pants/backend/project_info/paths_test.py
Original file line number Diff line number Diff line change
@@ -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"],
],
)
2 changes: 2 additions & 0 deletions src/python/pants/backend/project_info/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
filter_targets,
list_roots,
list_targets,
paths,
peek,
source_file_validator,
)
Expand All @@ -25,6 +26,7 @@ def rules():
*filter_targets.rules(),
*list_roots.rules(),
*list_targets.rules(),
*paths.rules(),
*peek.rules(),
*source_file_validator.rules(),
]

0 comments on commit eb7ff2f

Please sign in to comment.