diff --git a/src/python/pants/backend/awslambda/python/target_types.py b/src/python/pants/backend/awslambda/python/target_types.py index 59e665affe0..0ebaf419c43 100644 --- a/src/python/pants/backend/awslambda/python/target_types.py +++ b/src/python/pants/backend/awslambda/python/target_types.py @@ -11,6 +11,8 @@ PythonModuleOwnersRequest, ) from pants.backend.python.dependency_inference.rules import PythonInferSubsystem, import_rules +from pants.backend.python.subsystems.setup import PythonSetup +from pants.backend.python.target_types import PythonResolveField from pants.core.goals.package import OutputPathField from pants.engine.addresses import Address from pants.engine.fs import GlobMatchErrorBehavior, PathGlobs, Paths @@ -130,7 +132,9 @@ class InjectPythonLambdaHandlerDependency(InjectDependenciesRequest): @rule(desc="Inferring dependency from the python_awslambda `handler` field") async def inject_lambda_handler_dependency( - request: InjectPythonLambdaHandlerDependency, python_infer_subsystem: PythonInferSubsystem + request: InjectPythonLambdaHandlerDependency, + python_infer_subsystem: PythonInferSubsystem, + python_setup: PythonSetup, ) -> InjectedDependencies: if not python_infer_subsystem.entry_points: return InjectedDependencies() @@ -143,7 +147,12 @@ async def inject_lambda_handler_dependency( ), ) module, _, _func = handler.val.partition(":") - owners = await Get(PythonModuleOwners, PythonModuleOwnersRequest(module, resolve=None)) + owners = await Get( + PythonModuleOwners, + PythonModuleOwnersRequest( + module, resolve=original_tgt.target[PythonResolveField].normalized_value(python_setup) + ), + ) address = original_tgt.target.address explicitly_provided_deps.maybe_warn_of_ambiguous_dependency_inference( owners.ambiguous, @@ -202,6 +211,7 @@ class PythonAWSLambda(Target): PythonAwsLambdaDependencies, PythonAwsLambdaHandlerField, PythonAwsLambdaRuntime, + PythonResolveField, ) help = ( "A self-contained Python function suitable for uploading to AWS Lambda.\n\n" diff --git a/src/python/pants/backend/google_cloud_function/python/target_types.py b/src/python/pants/backend/google_cloud_function/python/target_types.py index 8c54b999475..dc1562a6fe7 100644 --- a/src/python/pants/backend/google_cloud_function/python/target_types.py +++ b/src/python/pants/backend/google_cloud_function/python/target_types.py @@ -12,6 +12,8 @@ PythonModuleOwnersRequest, ) from pants.backend.python.dependency_inference.rules import PythonInferSubsystem, import_rules +from pants.backend.python.subsystems.setup import PythonSetup +from pants.backend.python.target_types import PythonResolveField from pants.core.goals.package import OutputPathField from pants.engine.addresses import Address from pants.engine.fs import GlobMatchErrorBehavior, PathGlobs, Paths @@ -133,6 +135,7 @@ class InjectPythonCloudFunctionHandlerDependency(InjectDependenciesRequest): async def inject_cloud_function_handler_dependency( request: InjectPythonCloudFunctionHandlerDependency, python_infer_subsystem: PythonInferSubsystem, + python_setup: PythonSetup, ) -> InjectedDependencies: if not python_infer_subsystem.entry_points: return InjectedDependencies() @@ -147,7 +150,12 @@ async def inject_cloud_function_handler_dependency( ), ) module, _, _func = handler.val.partition(":") - owners = await Get(PythonModuleOwners, PythonModuleOwnersRequest(module, resolve=None)) + owners = await Get( + PythonModuleOwners, + PythonModuleOwnersRequest( + module, resolve=original_tgt.target[PythonResolveField].normalized_value(python_setup) + ), + ) address = original_tgt.target.address explicitly_provided_deps.maybe_warn_of_ambiguous_dependency_inference( owners.ambiguous, @@ -229,6 +237,7 @@ class PythonGoogleCloudFunction(Target): PythonGoogleCloudFunctionHandlerField, PythonGoogleCloudFunctionRuntime, PythonGoogleCloudFunctionType, + PythonResolveField, ) help = ( "A self-contained Python function suitable for uploading to Google Cloud Function.\n\n" diff --git a/src/python/pants/backend/python/dependency_inference/rules.py b/src/python/pants/backend/python/dependency_inference/rules.py index b845bf1e268..33e2446c396 100644 --- a/src/python/pants/backend/python/dependency_inference/rules.py +++ b/src/python/pants/backend/python/dependency_inference/rules.py @@ -193,12 +193,7 @@ async def infer_python_dependencies_via_imports( ), ) - resolve = ( - tgt[PythonResolveField].normalized_value(python_setup) - if tgt.has_field(PythonResolveField) - else None - ) - + resolve = tgt[PythonResolveField].normalized_value(python_setup) owners_per_import = await MultiGet( Get(PythonModuleOwners, PythonModuleOwnersRequest(imported_module, resolve=resolve)) for imported_module in parsed_imports diff --git a/src/python/pants/backend/python/lint/flake8/rules.py b/src/python/pants/backend/python/lint/flake8/rules.py index 647f74ada6d..216fe823aad 100644 --- a/src/python/pants/backend/python/lint/flake8/rules.py +++ b/src/python/pants/backend/python/lint/flake8/rules.py @@ -11,7 +11,7 @@ Flake8FirstPartyPlugins, ) from pants.backend.python.subsystems.setup import PythonSetup -from pants.backend.python.util_rules import pex_from_targets +from pants.backend.python.util_rules import pex from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints from pants.backend.python.util_rules.pex import PexRequest, VenvPex, VenvPexProcess from pants.core.goals.lint import REPORT_DIR, LintRequest, LintResult, LintResults @@ -137,4 +137,4 @@ async def flake8_lint( def rules(): - return [*collect_rules(), UnionRule(LintRequest, Flake8Request), *pex_from_targets.rules()] + return [*collect_rules(), UnionRule(LintRequest, Flake8Request), *pex.rules()] diff --git a/src/python/pants/backend/python/lint/flake8/subsystem.py b/src/python/pants/backend/python/lint/flake8/subsystem.py index 8660c008ace..98114891c0f 100644 --- a/src/python/pants/backend/python/lint/flake8/subsystem.py +++ b/src/python/pants/backend/python/lint/flake8/subsystem.py @@ -18,6 +18,7 @@ PythonRequirementsField, PythonSourceField, ) +from pants.backend.python.util_rules import python_sources from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints from pants.backend.python.util_rules.pex import PexRequirements from pants.backend.python.util_rules.python_sources import ( @@ -287,5 +288,6 @@ def rules(): return ( *collect_rules(), *lockfile.rules(), + *python_sources.rules(), UnionRule(GenerateToolLockfileSentinel, Flake8LockfileSentinel), ) diff --git a/src/python/pants/backend/python/subsystems/setup.py b/src/python/pants/backend/python/subsystems/setup.py index 005ea1c5404..4edacebe2a4 100644 --- a/src/python/pants/backend/python/subsystems/setup.py +++ b/src/python/pants/backend/python/subsystems/setup.py @@ -138,12 +138,34 @@ def register_options(cls, register): default={"python-default": "3rdparty/python/default_lock.txt"}, help=( "A mapping of logical names to lockfile paths used in your project.\n\n" - "For now, things only work properly if you define a single resolve and set " - "`[python].experimental_default_resolve` to that value. We are close to " - "properly supporting multiple (disjoint) resolves.\n\n" - "To generate a lockfile, run `./pants generate-lockfiles --resolve=` or " - "`./pants generate-lockfiles` to generate for all resolves (including tool " - "lockfiles).\n\n" + "Many organizations only need a single resolve for their whole project, which is " + "a good default and the simplest thing to do. However, you may need multiple " + "resolves, such as if you use two conflicting versions of a requirement in " + "your repository.\n\n" + "For now, Pants only has first-class support for disjoint resolves, meaning that " + "you cannot ergonomically set a `python_source` target, for example, to work " + "with multiple resolves. Practically, this means that you cannot yet reuse common " + "code, such as util files, across projects using different resolves. Support for " + "overlapping resolves is coming soon.\n\n" + "If you only need a single resolve, run `./pants generate-lockfiles` to generate " + "the lockfile.\n\n" + "If you need multiple resolves:\n\n" + " 1. Via this option, define multiple resolve " + "names and their lockfile paths. The names should be meaningful to your " + "repository, such as `data-science` or `pants-plugins`.\n" + " 2. Set the default with " + "`[python].experimental_default_resolve`.\n" + " 3. Update your `python_requirement` targets with the " + "`experimental_compatible_resolves` field to declare which resolve(s) they should " + "be available in. They default to `[python].experimental_default_resolve`, so you " + "only need to update targets that you want in non-default resolves. " + "(Often you'll set this via the `python_requirements` or `poetry_requirements` " + "target generators)\n" + " 4. Run `./pants generate-lockfiles` to generate the lockfiles. If the results " + "aren't what you'd expect, adjust the prior step.\n" + " 5. Update any targets like `python_source` / `python_sources`, " + "`python_test` / `python_tests`, and `pex_binary` which need to set a non-default " + "resolve with the `experimental_resolve` field.\n\n" "Only applies if `[python].enable_resolves` is true.\n\n" "This option is experimental and may change without the normal deprecation policy." ), diff --git a/src/python/pants/backend/python/target_types.py b/src/python/pants/backend/python/target_types.py index 68cc723e982..fa143997472 100644 --- a/src/python/pants/backend/python/target_types.py +++ b/src/python/pants/backend/python/target_types.py @@ -109,9 +109,11 @@ class PythonResolveField(StringField, AsyncFieldMixin): help = ( "The resolve from `[python].experimental_resolves` to use.\n\n" "If not defined, will default to `[python].default_resolve`.\n\n" - "Only applies if `[python].enable_resolves` is true.\n\n" + "All dependencies must share the same resolve. This means that you can only depend on " + "first-party targets like `python_source` that set their `experimental_resolve` field " + "to the same value, and on `python_requirement` targets that include the resolve in " + "their `experimental_compatible_resolves` field.\n\n" "This field is experimental and may change without the normal deprecation policy." - # TODO: Document expectations for dependencies once we validate that. ) def normalized_value(self, python_setup: PythonSetup) -> str: @@ -136,31 +138,6 @@ def resolve_and_lockfile(self, python_setup: PythonSetup) -> tuple[str, str] | N return (resolve, python_setup.resolves[resolve]) -class PythonCompatibleResolvesField(StringSequenceField, AsyncFieldMixin): - alias = "experimental_compatible_resolves" - required = False - help = ( - "The set of resolves from `[python].experimental_resolves` that this target is " - "compatible with.\n\n" - "If not defined, will default to `[python].default_resolve`.\n\n" - "Only applies if `[python].enable_resolves` is true.\n\n" - "This field is experimental and may change without the normal deprecation policy." - # TODO: Document expectations for dependencies once we validate that. - ) - - def normalized_value(self, python_setup: PythonSetup) -> tuple[str, ...]: - """Get the value after applying the default and validating every key is recognized.""" - value_or_default = self.value or (python_setup.default_resolve,) - invalid_resolves = set(value_or_default) - set(python_setup.resolves) - if invalid_resolves: - raise UnrecognizedResolveNamesError( - sorted(invalid_resolves), - python_setup.resolves.keys(), - description_of_origin=f"the field `{self.alias}` in the target {self.address}", - ) - return value_or_default - - # ----------------------------------------------------------------------------------------------- # `pex_binary` and `pex_binaries` target # ----------------------------------------------------------------------------------------------- @@ -775,6 +752,7 @@ class PythonSourceTarget(Target): *COMMON_TARGET_FIELDS, InterpreterConstraintsField, Dependencies, + PythonResolveField, PythonSourceField, ) help = "A single Python source file." @@ -812,6 +790,7 @@ class PythonTestUtilsGeneratorTarget(Target): *COMMON_TARGET_FIELDS, InterpreterConstraintsField, Dependencies, + PythonResolveField, PythonTestUtilsGeneratingSourcesField, PythonSourcesOverridesField, ) @@ -831,6 +810,7 @@ class PythonSourcesGeneratorTarget(Target): *COMMON_TARGET_FIELDS, InterpreterConstraintsField, Dependencies, + PythonResolveField, PythonSourcesGeneratingSourcesField, PythonSourcesOverridesField, ) @@ -975,20 +955,33 @@ def normalize_module_mapping( return FrozenDict({canonicalize_project_name(k): tuple(v) for k, v in (mapping or {}).items()}) -class PythonRequirementCompatibleResolvesField(PythonCompatibleResolvesField): +class PythonRequirementCompatibleResolvesField(StringSequenceField, AsyncFieldMixin): + alias = "experimental_compatible_resolves" + required = False help = ( "The resolves from `[python].experimental_resolves` that this requirement should be " "included in.\n\n" "If not defined, will default to `[python].default_resolve`.\n\n" "When generating a lockfile for a particular resolve via the `generate-lockfiles` goal, " "it will include all requirements that are declared compatible with that resolve. " - "First-party targets like `python_source` and `pex_binary` then declare which resolve(s) " - "they use via the `experimental_resolve` and `experimental_compatible_resolves` field; so, " - "for your first-party code to use a particular `python_requirement` target, that " - "requirement must be included in the resolve(s) " + "First-party targets like `python_source` and `pex_binary` then declare which resolve " + "they use via the `experimental_resolve` field; so, for your first-party code to use a " + "particular `python_requirement` target, that requirement must be included in the resolve " "used by that code." ) + def normalized_value(self, python_setup: PythonSetup) -> tuple[str, ...]: + """Get the value after applying the default and validating every key is recognized.""" + value_or_default = self.value or (python_setup.default_resolve,) + invalid_resolves = set(value_or_default) - set(python_setup.resolves) + if invalid_resolves: + raise UnrecognizedResolveNamesError( + sorted(invalid_resolves), + python_setup.resolves.keys(), + description_of_origin=f"the field `{self.alias}` in the target {self.address}", + ) + return value_or_default + class PythonRequirementTarget(Target): alias = "python_requirement" diff --git a/src/python/pants/backend/python/util_rules/pex_from_targets.py b/src/python/pants/backend/python/util_rules/pex_from_targets.py index 01a5ef5056f..c6a2ec35bb1 100644 --- a/src/python/pants/backend/python/util_rules/pex_from_targets.py +++ b/src/python/pants/backend/python/util_rules/pex_from_targets.py @@ -5,6 +5,7 @@ import dataclasses import logging +from collections import defaultdict from dataclasses import dataclass from typing import Iterable @@ -15,7 +16,9 @@ from pants.backend.python.target_types import ( MainSpecification, PexLayout, + PythonRequirementCompatibleResolvesField, PythonRequirementsField, + PythonResolveField, parse_requirements_file, ) from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints @@ -40,11 +43,11 @@ from pants.engine.collection import DeduplicatedCollection from pants.engine.fs import Digest, DigestContents, GlobMatchErrorBehavior, MergeDigests, PathGlobs from pants.engine.rules import Get, MultiGet, collect_rules, rule -from pants.engine.target import TransitiveTargets, TransitiveTargetsRequest +from pants.engine.target import Target, TransitiveTargets, TransitiveTargetsRequest 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 path_safe +from pants.util.strutil import bullet_list, path_safe logger = logging.getLogger(__name__) @@ -187,6 +190,121 @@ async def interpreter_constraints_for_targets( return interpreter_constraints +@dataclass(frozen=True) +class ChosenPythonResolve: + name: str + lockfile_path: str + + +@dataclass(frozen=True) +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] + ) -> 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) + elif tgt.has_field(PythonRequirementCompatibleResolvesField): + resolves = tgt[PythonRequirementCompatibleResolvesField].normalized_value( + python_setup + ) + for resolve in resolves: + 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#codeexperimental_resolvecode')}) or " + f"[compatible_resolves]({doc_url('reference-python_requirement#codeexperimental_compatible_resolvescode')}) " + "fields) in common." + ) + + +@rule +async def choose_python_resolve( + request: ChosenPythonResolveRequest, python_setup: PythonSetup +) -> ChosenPythonResolve: + # If there are no targets, we fall back to the default resolve. This is relevant, + # for example, when running `./pants repl` with no specs. + if not request.addresses: + return ChosenPythonResolve( + name=python_setup.default_resolve, + lockfile_path=python_setup.resolves[python_setup.default_resolve], + ) + + transitive_targets = await Get(TransitiveTargets, TransitiveTargetsRequest(request.addresses)) + + # First, choose the resolve by inspecting the root targets. + root_resolves = { + root[PythonResolveField].normalized_value(python_setup) + for root in transitive_targets.roots + if root.has_field(PythonResolveField) + } + if not root_resolves: + root_targets = bullet_list( + f"{tgt.address.spec} ({tgt.alias})" for tgt in transitive_targets.roots + ) + raise AssertionError( + "Used `ChosenPythonResolveRequest` with input addresses that don't have the " + f"`PythonResolveField` field registered:\n\n{root_targets}\n\n" + "If you encountered this bug while using core Pants functionality, please open a " + "bug at https://github.com/pantsbuild/pants/issues/new with this error message when " + "`--print-stacktrace` is enabled. If this is from your own plugin, register " + "`PythonResolveField` on the relevant target types." + ) + if len(root_resolves) > 1: + raise NoCompatibleResolveException( + python_setup, + "The input targets did not have a resolve in common", + transitive_targets.roots, + ) + + chosen_resolve = next(iter(root_resolves)) + + # Then, validate that all transitive deps are compatible. + for tgt in transitive_targets.dependencies: + invalid_resolve_field = ( + tgt.has_field(PythonResolveField) + and tgt[PythonResolveField].normalized_value(python_setup) != chosen_resolve + ) + invalid_compatible_resolves_field = tgt.has_field( + PythonRequirementCompatibleResolvesField + ) and not any( + resolve == chosen_resolve + for resolve in tgt[PythonRequirementCompatibleResolvesField].normalized_value( + python_setup + ) + ) + if invalid_resolve_field or invalid_compatible_resolves_field: + 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, + ) + + return ChosenPythonResolve( + name=chosen_resolve, lockfile_path=python_setup.resolves[chosen_resolve] + ) + + class GlobalRequirementConstraints(DeduplicatedCollection[PipRequirement]): """Global constraints specified by the `[python].requirement_constraints` setting, if any.""" @@ -376,18 +494,19 @@ async def get_repository_pex( "`[python].requirement_constraints` must also be set." ) elif python_setup.enable_resolves: - # TODO: compute the resolve based on request.addresses, and validate that the - # transitive closure is compatible. See coursier_fetch.py for inspiration. - resolve = python_setup.default_resolve - lockfile = python_setup.resolves[resolve] + chosen_resolve = await Get( + ChosenPythonResolve, ChosenPythonResolveRequest(request.addresses) + ) repository_pex_request = PexRequest( - description=f"Installing {lockfile} for the resolve `{resolve}`", - output_filename=f"{path_safe(resolve)}_lockfile.pex", + description=( + f"Installing {chosen_resolve.lockfile_path} for the resolve `{chosen_resolve.name}`" + ), + output_filename=f"{path_safe(chosen_resolve.name)}_lockfile.pex", internal_only=request.internal_only, requirements=Lockfile( - file_path=lockfile, + file_path=chosen_resolve.lockfile_path, file_path_description_of_origin=( - f"the resolve `{resolve}` (from `[python].experimental_resolves`)" + f"the resolve `{chosen_resolve.name}` (from `[python].experimental_resolves`)" ), # TODO(#12314): Hook up lockfile staleness check. lockfile_hex_digest=None, diff --git a/src/python/pants/backend/python/util_rules/pex_from_targets_test.py b/src/python/pants/backend/python/util_rules/pex_from_targets_test.py index 1239d5a7948..0e9040ef655 100644 --- a/src/python/pants/backend/python/util_rules/pex_from_targets_test.py +++ b/src/python/pants/backend/python/util_rules/pex_from_targets_test.py @@ -14,14 +14,29 @@ import pytest from _pytest.tmpdir import TempPathFactory -from pants.backend.python.target_types import PythonRequirementTarget, PythonSourcesGeneratorTarget +from pants.backend.python.subsystems.setup import PythonSetup +from pants.backend.python.target_types import ( + PythonRequirementCompatibleResolvesField, + PythonRequirementsField, + PythonRequirementTarget, + PythonResolveField, + PythonSourceField, + PythonSourcesGeneratorTarget, + PythonSourceTarget, + PythonTestTarget, +) from pants.backend.python.util_rules import pex_from_targets from pants.backend.python.util_rules.pex import Pex, PexPlatforms, PexRequest, PexRequirements from pants.backend.python.util_rules.pex_from_targets import ( + ChosenPythonResolve, + ChosenPythonResolveRequest, GlobalRequirementConstraints, + NoCompatibleResolveException, PexFromTargetsRequest, ) from pants.build_graph.address import Address +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 @@ -34,11 +49,110 @@ def rule_runner() -> RuleRunner: *pex_from_targets.rules(), QueryRule(PexRequest, (PexFromTargetsRequest,)), QueryRule(GlobalRequirementConstraints, ()), + QueryRule(ChosenPythonResolve, [ChosenPythonResolveRequest]), + ], + target_types=[ + PythonSourcesGeneratorTarget, + PythonRequirementTarget, + PythonSourceTarget, + PythonTestTarget, ], - target_types=[PythonSourcesGeneratorTarget, PythonRequirementTarget], ) +def test_no_compatible_resolve_error() -> None: + python_setup = create_subsystem(PythonSetup, experimental_resolves={"a": "", "b": ""}) + targets = [ + PythonRequirementTarget( + { + PythonRequirementsField.alias: [], + PythonRequirementCompatibleResolvesField.alias: ["a", "b"], + }, + 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: + * //:t1 + * //:t3 + """ + ) + ) + + +def test_choose_compatible_resolve(rule_runner: RuleRunner) -> None: + def create_build(*, req_resolves: list[str], source_resolve: str, test_resolve: str) -> str: + return dedent( + f"""\ + python_source(name="dep", source="dep.py", experimental_resolve="{source_resolve}") + python_requirement( + name="req", requirements=[], experimental_compatible_resolves={repr(req_resolves)} + ) + python_test( + name="test", + source="tests.py", + dependencies=[":dep", ":req"], + experimental_resolve="{test_resolve}", + ) + """ + ) + + rule_runner.set_options(["--python-experimental-resolves={'a': '', 'b': ''}"]) + rule_runner.write_files( + { + # Note that each of these BUILD files are entirely self-contained. + "valid/BUILD": create_build( + req_resolves=["a", "b"], source_resolve="a", test_resolve="a" + ), + "invalid_dep_resolve_field/BUILD": create_build( + req_resolves=["a"], source_resolve="a", test_resolve="b" + ), + "invalid_dep_compatible_resolves_field/BUILD": create_build( + req_resolves=["b"], source_resolve="a", test_resolve="a" + ), + } + ) + + def choose_resolve(addresses: list[Address]) -> str: + return rule_runner.request( + ChosenPythonResolve, [ChosenPythonResolveRequest(Addresses(addresses))] + ).name + + assert choose_resolve([Address("valid", target_name="test")]) == "a" + assert choose_resolve([Address("valid", target_name="dep")]) == "a" + + with engine_error(NoCompatibleResolveException, contains="its dependencies are not compatible"): + choose_resolve([Address("invalid_dep_resolve_field", target_name="test")]) + with engine_error( + NoCompatibleResolveException, contains="input targets did not have a resolve" + ): + choose_resolve( + [ + Address("invalid_dep_resolve_field", target_name="test"), + Address("invalid_dep_resolve_field", target_name="dep"), + ] + ) + + with engine_error(NoCompatibleResolveException): + choose_resolve([Address("invalid_dep_compatible_resolves_field", target_name="test")]) + + @dataclass(frozen=True) class Project: name: str