Skip to content

Commit

Permalink
Improve error message for dependencies on incompatible Python resol…
Browse files Browse the repository at this point in the history
…ves (Cherry-pick of pantsbuild#15416) (pantsbuild#15440)

Closes pantsbuild#14864.

This code is written generically so that we can hook it up to JVM easily.

[ci skip-rust]
  • Loading branch information
Eric-Arellano committed May 13, 2022
1 parent fcaac98 commit 2a30000
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 96 deletions.
33 changes: 21 additions & 12 deletions src/python/pants/backend/python/goals/repl.py
Expand Up @@ -15,20 +15,21 @@
from pants.backend.python.util_rules.pex_environment import PexEnvironment
from pants.backend.python.util_rules.pex_from_targets import (
InterpreterConstraintsRequest,
NoCompatibleResolveException,
RequirementsPexRequest,
)
from pants.backend.python.util_rules.python_sources import (
PythonSourceFiles,
PythonSourceFilesRequest,
)
from pants.core.goals.generate_lockfiles import NoCompatibleResolveException
from pants.core.goals.repl import ReplImplementation, ReplRequest
from pants.engine.fs import Digest, MergeDigests
from pants.engine.rules import Get, MultiGet, collect_rules, rule
from pants.engine.target import Target, TransitiveTargets, TransitiveTargetsRequest
from pants.engine.unions import UnionRule
from pants.util.docutil import bin_name
from pants.util.logging import LogLevel
from pants.util.strutil import softwrap


def validate_compatible_resolve(root_targets: Iterable[Target], python_setup: PythonSetup) -> None:
Expand All @@ -42,19 +43,27 @@ def validate_compatible_resolve(root_targets: Iterable[Target], python_setup: Py
for root in root_targets
if root.has_field(PythonResolveField)
}

def maybe_get_resolve(t: Target) -> str | None:
if not t.has_field(PythonResolveField):
return None
return t[PythonResolveField].normalized_value(python_setup)

if len(root_resolves) > 1:
raise NoCompatibleResolveException(
python_setup,
"The input targets did not have a resolve in common",
raise NoCompatibleResolveException.bad_input_roots(
root_targets,
(
"To work around this, choose which resolve you want to use from above. "
f'Then, run `{bin_name()} peek :: | jq -r \'.[] | select(.resolve == "example") | '
f'.["address"]\' | xargs {bin_name()} repl`, where you replace "example" with the '
"resolve name, and possibly replace the specs `::` with what you were using "
"before. If the resolve is the `[python].default_resolve`, use "
'`select(.resolve == "example" or .resolve == null)`. These queries will result in '
"opening a REPL with only targets using the desired resolve."
maybe_get_resolve=maybe_get_resolve,
doc_url_slug="python-third-party-dependencies#multiple-lockfiles",
workaround=softwrap(
f"""
To work around this, choose which resolve you want to use from above. Then, run
`{bin_name()} peek :: | jq -r \'.[] | select(.resolve == "example") |
.["address"]\' | xargs {bin_name()} repl`, where you replace "example" with the
resolve name, and possibly replace the specs `::` with what you were using
before. If the resolve is the `[python].default_resolve`, use
`select(.resolve == "example" or .resolve == null)`. These queries will result in
opening a REPL with only targets using the desired resolve.
"""
),
)

Expand Down
Expand Up @@ -19,7 +19,7 @@
from pants.backend.python.target_types_rules import rules as target_types_rules
from pants.backend.python.util_rules import local_dists, pex_from_targets
from pants.backend.python.util_rules.pex import PexProcess
from pants.backend.python.util_rules.pex_from_targets import NoCompatibleResolveException
from pants.core.goals.generate_lockfiles import NoCompatibleResolveException
from pants.core.goals.repl import Repl
from pants.core.goals.repl import rules as repl_rules
from pants.engine.process import Process
Expand Down
60 changes: 17 additions & 43 deletions src/python/pants/backend/python/util_rules/pex_from_targets.py
Expand Up @@ -5,7 +5,6 @@

import dataclasses
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Iterable

Expand Down Expand Up @@ -44,6 +43,7 @@
StrippedPythonSourceFiles,
)
from pants.backend.python.util_rules.python_sources import rules as python_sources_rules
from pants.core.goals.generate_lockfiles import NoCompatibleResolveException
from pants.engine.addresses import Address, Addresses
from pants.engine.collection import DeduplicatedCollection
from pants.engine.fs import Digest, DigestContents, GlobMatchErrorBehavior, MergeDigests, PathGlobs
Expand All @@ -52,7 +52,7 @@
from pants.util.docutil import doc_url
from pants.util.logging import LogLevel
from pants.util.meta import frozen_after_init
from pants.util.strutil import bullet_list, path_safe
from pants.util.strutil import path_safe

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -205,42 +205,17 @@ class ChosenPythonResolveRequest:
addresses: Addresses


# Note: Inspired by `coursier_fetch.py`.
class NoCompatibleResolveException(Exception):
"""No compatible resolve could be found for a set of targets."""

def __init__(
self,
python_setup: PythonSetup,
msg_prefix: str,
relevant_targets: Iterable[Target],
msg_suffix: str = "",
) -> None:
resolves_to_addresses = defaultdict(list)
for tgt in relevant_targets:
if tgt.has_field(PythonResolveField):
resolve = tgt[PythonResolveField].normalized_value(python_setup)
resolves_to_addresses[resolve].append(tgt.address.spec)

formatted_resolve_lists = "\n\n".join(
f"{resolve}:\n{bullet_list(sorted(addresses))}"
for resolve, addresses in sorted(resolves_to_addresses.items())
)
super().__init__(
f"{msg_prefix}:\n\n"
f"{formatted_resolve_lists}\n\n"
"Targets which will be used together must all have the same resolve (from the "
f"[resolve]({doc_url('reference-python_test#coderesolvecode')}) "
"field) in common." + (f"\n\n{msg_suffix}" if msg_suffix else "")
)


@rule
async def choose_python_resolve(
request: ChosenPythonResolveRequest, python_setup: PythonSetup
) -> ChosenPythonResolve:
transitive_targets = await Get(TransitiveTargets, TransitiveTargetsRequest(request.addresses))

def maybe_get_resolve(t: Target) -> str | None:
if not t.has_field(PythonResolveField):
return None
return t[PythonResolveField].normalized_value(python_setup)

# First, choose the resolve by inspecting the root targets.
root_resolves = {
root[PythonResolveField].normalized_value(python_setup)
Expand All @@ -249,10 +224,11 @@ async def choose_python_resolve(
}
if root_resolves:
if len(root_resolves) > 1:
raise NoCompatibleResolveException(
python_setup,
"The input targets did not have a resolve in common",
raise NoCompatibleResolveException.bad_input_roots(
transitive_targets.roots,
maybe_get_resolve=maybe_get_resolve,
doc_url_slug="python-third-party-dependencies#multiple-lockfiles",
workaround=None,
)

chosen_resolve = next(iter(root_resolves))
Expand All @@ -263,14 +239,12 @@ async def choose_python_resolve(
tgt.has_field(PythonResolveField)
and tgt[PythonResolveField].normalized_value(python_setup) != chosen_resolve
):
plural = ("s", "their") if len(transitive_targets.roots) > 1 else ("", "its")
raise NoCompatibleResolveException(
python_setup,
(
f"The resolve chosen for the root target{plural[0]} was {chosen_resolve}, but "
f"some of {plural[1]} dependencies are not compatible with that resolve"
),
transitive_targets.closure,
raise NoCompatibleResolveException.bad_dependencies(
maybe_get_resolve=maybe_get_resolve,
doc_url_slug="python-third-party-dependencies#multiple-lockfiles",
root_resolve=chosen_resolve,
root_targets=transitive_targets.roots,
dependencies=transitive_targets.dependencies,
)

else:
Expand Down
Expand Up @@ -15,14 +15,10 @@

from pants.backend.python import target_types_rules
from pants.backend.python.subsystems import setuptools
from pants.backend.python.subsystems.setup import PythonSetup
from pants.backend.python.subsystems.setuptools import Setuptools
from pants.backend.python.target_types import (
PexLayout,
PythonRequirementsField,
PythonRequirementTarget,
PythonResolveField,
PythonSourceField,
PythonSourcesGeneratorTarget,
PythonSourceTarget,
PythonTestTarget,
Expand All @@ -33,7 +29,6 @@
ChosenPythonResolve,
ChosenPythonResolveRequest,
GlobalRequirementConstraints,
NoCompatibleResolveException,
PexFromTargetsRequest,
)
from pants.backend.python.util_rules.pex_requirements import (
Expand All @@ -44,8 +39,8 @@
)
from pants.backend.python.util_rules.pex_test_utils import get_all_data
from pants.build_graph.address import Address
from pants.core.goals.generate_lockfiles import NoCompatibleResolveException
from pants.engine.addresses import Addresses
from pants.testutil.option_util import create_subsystem
from pants.testutil.rule_runner import QueryRule, RuleRunner, engine_error
from pants.util.contextutil import pushd
from pants.util.ordered_set import OrderedSet
Expand Down Expand Up @@ -73,38 +68,6 @@ def rule_runner() -> RuleRunner:
)


def test_no_compatible_resolve_error() -> None:
python_setup = create_subsystem(PythonSetup, resolves={"a": "", "b": ""}, enable_resolves=True)
targets = [
PythonRequirementTarget(
{PythonRequirementsField.alias: [], PythonResolveField.alias: "a"},
Address("", target_name="t1"),
),
PythonSourceTarget(
{PythonSourceField.alias: "f.py", PythonResolveField.alias: "a"},
Address("", target_name="t2"),
),
PythonSourceTarget(
{PythonSourceField.alias: "f.py", PythonResolveField.alias: "b"},
Address("", target_name="t3"),
),
]
assert str(NoCompatibleResolveException(python_setup, "Prefix", targets)).startswith(
dedent(
"""\
Prefix:
a:
* //:t1
* //:t2
b:
* //:t3
"""
)
)


def test_choose_compatible_resolve(rule_runner: RuleRunner) -> None:
def create_target_files(
directory: str, *, req_resolve: str, source_resolve: str, test_resolve: str
Expand Down
102 changes: 100 additions & 2 deletions src/python/pants/core/goals/generate_lockfiles.py
Expand Up @@ -8,16 +8,18 @@
from collections import defaultdict
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar, Iterable, Sequence
from typing import Callable, ClassVar, Iterable, Sequence

from pants.engine.collection import Collection
from pants.engine.fs import Digest, MergeDigests, Workspace
from pants.engine.goal import Goal, GoalSubsystem
from pants.engine.internals.selectors import Get, MultiGet
from pants.engine.rules import collect_rules, goal_rule
from pants.engine.target import Target
from pants.engine.unions import UnionMembership, union
from pants.option.option_types import StrListOption, StrOption
from pants.util.docutil import bin_name
from pants.util.docutil import bin_name, doc_url
from pants.util.strutil import bullet_list, softwrap

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -373,5 +375,101 @@ async def generate_lockfiles_goal(
return GenerateLockfilesGoal(exit_code=0)


# -----------------------------------------------------------------------------------------------
# Helpers for determining the resolve
# -----------------------------------------------------------------------------------------------


class NoCompatibleResolveException(Exception):
"""No compatible resolve could be found for a set of targets."""

@classmethod
def bad_input_roots(
cls,
targets: Iterable[Target],
*,
maybe_get_resolve: Callable[[Target], str | None],
doc_url_slug: str,
workaround: str | None,
) -> NoCompatibleResolveException:
resolves_to_addresses = defaultdict(list)
for tgt in targets:
maybe_resolve = maybe_get_resolve(tgt)
if maybe_resolve is not None:
resolves_to_addresses[maybe_resolve].append(tgt.address.spec)

formatted_resolve_lists = "\n\n".join(
f"{resolve}:\n{bullet_list(sorted(addresses))}"
for resolve, addresses in sorted(resolves_to_addresses.items())
)
return NoCompatibleResolveException(
f"The input targets did not have a resolve in common.\n\n"
f"{formatted_resolve_lists}\n\n"
"Targets used together must use the same resolve, set by the `resolve` field. For more "
f"information on 'resolves' (lockfiles), see {doc_url(doc_url_slug)}."
+ (f"\n\n{workaround}" if workaround else "")
)

@classmethod
def bad_dependencies(
cls,
*,
maybe_get_resolve: Callable[[Target], str | None],
doc_url_slug: str,
root_resolve: str,
root_targets: Sequence[Target],
dependencies: Iterable[Target],
) -> NoCompatibleResolveException:
if len(root_targets) == 1:
addr = root_targets[0].address
prefix = softwrap(
f"""
The target {addr} uses the `resolve` `{root_resolve}`, but some of its
dependencies are not compatible with that resolve:
"""
)
change_input_targets_instructions = f"of the target {addr}"
else:
assert root_targets
prefix = softwrap(
f"""
The input targets use the `resolve` `{root_resolve}`, but some of their
dependencies are not compatible with that resolve.
Input targets:
{bullet_list(sorted(t.address.spec for t in root_targets))}
Bad dependencies:
"""
)
change_input_targets_instructions = "of the input targets"

deps_strings = []
for dep in dependencies:
maybe_resolve = maybe_get_resolve(dep)
if maybe_resolve is None or maybe_resolve == root_resolve:
continue
deps_strings.append(f"{dep.address} ({maybe_resolve})")

return NoCompatibleResolveException(
softwrap(
f"""
{prefix}
{bullet_list(deps_strings)}
All dependencies must work with the same `resolve`. To fix this, either change
the `resolve=` field on those dependencies to `{root_resolve}`, or change
the `resolve=` {change_input_targets_instructions}. If those dependencies should
work with multiple resolves, use the `parametrize` mechanism with the `resolve=`
field or manually create multiple targets for the same entity.
For more information, see {doc_url(doc_url_slug)}.
"""
)
)


def rules():
return collect_rules()

0 comments on commit 2a30000

Please sign in to comment.