Skip to content
Open
2 changes: 2 additions & 0 deletions docs/notes/2.32.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ The `runtime` field of [`aws_python_lambda_layer`](https://www.pantsbuild.org/2.

The `grpc-python-plugin` tool now uses an updated `v1.73.1` plugin built from <https://github.com/nhurden/protoc-gen-grpc-python-prebuilt]. This also brings `macos_arm64` support.

Fixed a bug in how dependencies are validated which prevented resolve-specific interpreter constraints from taking effect when `[python].default_to_resolve_interpreter_constraints` was enabled. Instead, code would fall back to the global interpreter constraints which is not correct when `default_to_resolve_interpreter_constraints` is in use.

#### Shell

#### Javascript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def build_config(
"python": {
"enable_resolves": True,
"interpreter_constraints": [f"=={platform.python_version()}"],
"default_resolve": "a",
"resolves": {
"a": "3rdparty/a.lock",
"b": "3rdparty/b.lock",
Expand Down
1 change: 1 addition & 0 deletions src/python/pants/backend/python/goals/export_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ def test_export_venv_new_codepath(
[
*pants_args_for_python_lockfiles,
f"--python-interpreter-constraints=['=={current_interpreter}']",
"--python-default-resolve=a",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing this bug resulting in these uses of python_distribution defaulting to python-default resolve even though only a and b resolves are defined. python_distribution does not appear to have resolve field, is that intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @benjyw re the above question

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably it is intentional, since the python_distribution is just some metadata on how to build a wheel, and the resolve doesn't enter into that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then it seems incorrect for the PR's solution to even apply the notion of a resolve to python_distribution then. Do you concur?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests began failing with this PR's solution in place and I added default resolve options to "fix" the test failures. Maybe this PR needs to account for the optionality of PythonResolveField | None in DependencyValidationFieldSet or that optionality is incorrect and FieldSets should never have optional types?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that is the only Pythonic target type with no resolve field, so maybe even just for uniformity... The trouble is that "empty resolve field" defaults to the repo default, IIRC, and if that is not compatible with the resolve of the dependencies then things would blow up, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dug in more on the issue today. The issue here is that when an optional field in a FieldSet is missing from a target, then the field is instantiated with its default value instead of being set to None as a result of Target.get.

So DependencyValidationFieldSet is trying to use a semantic (i.e., return None for missing fields) which FieldSet does not support at all. And that is why the resolve-specific ICs are not applied at

target_ics = request.field_set.interpreter_constraints.value_or_configured_default(
python_setup, request.field_set.resolve
)
since resolve on DependencyValidationFieldSet is always None.

So potential solutions:

  1. python_distribution appears to be the only Python target without a resolve field. Yet, the source code for the distribution would need to be specified as dependencies and would need to be from a single resolve, right? So one view is that python_distribution should have a resolve field. Could a python_distribution ever be compatible with dependency targets from multiple resolves?

  2. We keep python_distribution without a resolve field and just fix the FieldSet system to understand Field | None. I have that support already in place locally. It will be pushed after this comment. It also validates that all FieldSets have fields annotated with types either Field or Field | None.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given what you've discovered, seems like 1. should work? IIUC the behavior is effectively this already (since the target doesn't have this field, we assume the default resolve, not no resolve)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the original fix, the code assumes the default resolve. With the modified fix in today's commits, the FieldSet machinery is able to return None for PythonResolveField | None as a reader might have assumed to be the case. So with the modified fix (properly supporting optionality), there is no need for python_distribution to gain a resolve field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the original fix could have broken existing users by requiring them to set a valid default resolve even though their code base wasn't using the default resolve if they had a python_distribution target.

"--python-resolves={'a': 'lock.txt', 'b': 'lock.txt'}",
"--export-resolve=a",
"--export-resolve=b",
Expand Down
108 changes: 107 additions & 1 deletion src/python/pants/backend/python/target_types_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest

from pants.backend.python import target_types_rules
from pants.backend.python.dependency_inference import rules as python_dependency_inference_rules
from pants.backend.python.dependency_inference.rules import import_rules
from pants.backend.python.macros.python_artifact import PythonArtifact
from pants.backend.python.target_types import (
Expand All @@ -21,16 +22,20 @@
PexEntryPointField,
PexExecutableField,
PexScriptField,
PythonDependenciesField,
PythonDistribution,
PythonRequirementsField,
PythonRequirementTarget,
PythonResolveField,
PythonSourcesGeneratorTarget,
PythonSourceTarget,
ResolvedPexEntryPoint,
ResolvePexEntryPointRequest,
ResolvePythonDistributionEntryPointsRequest,
normalize_module_mapping,
)
from pants.backend.python.target_types_rules import (
DependencyValidationFieldSet,
InferPexBinaryEntryPointDependency,
InferPythonDistributionDependencies,
PexBinaryEntryPointDependencyInferenceFieldSet,
Expand All @@ -40,10 +45,12 @@
from pants.backend.python.util_rules import python_sources
from pants.core.goals.generate_lockfiles import UnrecognizedResolveNamesError
from pants.core.util_rules.unowned_dependency_behavior import UnownedDependencyError
from pants.engine.addresses import Address
from pants.engine.addresses import Address, Addresses
from pants.engine.internals.graph import _TargetParametrizations, _TargetParametrizationsRequest
from pants.engine.internals.parametrize import Parametrize
from pants.engine.internals.scheduler import ExecutionError
from pants.engine.target import (
DependenciesRequest,
InferredDependencies,
InvalidFieldException,
InvalidFieldTypeException,
Expand Down Expand Up @@ -610,3 +617,102 @@ def gen_pex_binary_tgt(entry_point: str, tags: list[str] | None = None) -> PexBi
gen_pex_binary_tgt("subdir.f.py", tags=["overridden"]),
gen_pex_binary_tgt("subdir.f:main"),
}


def test_python_dependency_validation_with_parametrized_resolve_repro() -> None:
rule_runner = RuleRunner(
rules=[
*target_types_rules.rules(),
*python_sources.rules(),
*python_dependency_inference_rules.rules(),
QueryRule(_TargetParametrizations, [_TargetParametrizationsRequest]),
QueryRule(Addresses, [DependenciesRequest]),
],
target_types=[PythonSourceTarget, PythonSourcesGeneratorTarget],
objects={"parametrize": Parametrize},
)
rule_runner.write_files(
{
"pants.toml": dedent(
"""\
[python]
enable_resolves = true
default_resolve = "a"
interpreter_constraints = ["==3.11.*", "==3.14.*"]
interpreter_versions_universe = ["3.11", "3.14"]
default_to_resolve_interpreter_constraints = true

[python.resolves]
"a" = "a.lock"
"b" = "b.lock"

[python.resolves_to_interpreter_constraints]
"a" = ["==3.11.*"]
"b" = ["==3.14.*"]

[python-infer]
imports = true

[source]
root_patterns = ["/src/python"]
"""
),
"src/python/pkg/base.py": "class Base:\n pass\n",
"src/python/pkg/derived.py": (
"from pkg.base import Base\n\nclass Derived(Base):\n pass\n"
),
"src/python/pkg/BUILD": dedent(
"""\
python_sources(
sources=["base.py", "derived.py"],
**parametrize("b", resolve="b"),
**parametrize("a", resolve="a"),
overrides={
"derived.py": {
"dependencies": ["./base.py"],
},
},
)
"""
),
}
)
rule_runner.set_options(["--pants-config-files=['pants.toml']"])
generated_targets = [
tgt
for tgt in rule_runner.request(
_TargetParametrizations,
[
_TargetParametrizationsRequest(
Address("src/python/pkg"), description_of_origin="tests"
)
],
).parametrizations.values()
if isinstance(tgt, PythonSourceTarget)
]
assert len(generated_targets) == 4
base_a = next(
tgt
for tgt in generated_targets
if tgt.address.relative_file_path == "base.py"
and tgt.address.parameters.get("parametrize") == "a"
)
derived_a = next(
tgt
for tgt in generated_targets
if tgt.address.relative_file_path == "derived.py"
and tgt.address.parameters.get("parametrize") == "a"
)

# The generated targets have the correct resolve.
assert base_a[PythonResolveField].value == "a"
assert derived_a[PythonResolveField].value == "a"
validation_field_set = DependencyValidationFieldSet.create(derived_a)
assert validation_field_set.resolve is not None
assert validation_field_set.resolve.value == "a"

resolved = rule_runner.request(
Addresses,
[DependenciesRequest(derived_a[PythonDependenciesField])],
)
assert tuple(resolved) == (base_a.address,)
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def test_sort_all_python_distributions_by_resolve(rule_runner: PythonRuleRunner)
rule_runner.set_options(
[
"--python-enable-resolves=True",
"--python-default-resolve=a",
"--python-resolves={'a': 'lock.txt', 'b': 'lock.txt'}",
# Turn off lockfile validation to make the test simpler.
"--python-invalid-lockfile-behavior=ignore",
Expand Down
25 changes: 23 additions & 2 deletions src/python/pants/engine/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from enum import Enum
from operator import attrgetter
from pathlib import PurePath
from types import UnionType
from typing import (
AbstractSet,
Any,
Expand All @@ -27,8 +28,11 @@
Protocol,
Self,
TypeVar,
Union,
cast,
final,
get_args,
get_origin,
get_type_hints,
)

Expand Down Expand Up @@ -1475,11 +1479,28 @@ def create(cls: type[_FS], tgt: Target) -> _FS:
@final
@memoized_classproperty
def fields(cls) -> FrozenDict[str, type[Field]]:
def field_type_from_annotation(annotation: Any) -> type[Field] | None:
if isinstance(annotation, type) and issubclass(annotation, Field):
return annotation

origin = get_origin(annotation)
if origin not in (Union, UnionType):
return None

field_types = [
arg
for arg in get_args(annotation)
if isinstance(arg, type) and issubclass(arg, Field)
]
if len(field_types) != 1:
return None
return field_types[0]

return FrozenDict(
(
(name, field_type)
for name, field_type in get_type_hints(cls).items()
if isinstance(field_type, type) and issubclass(field_type, Field)
for name, annotation in get_type_hints(cls).items()
if (field_type := field_type_from_annotation(annotation)) is not None
)
)

Expand Down
13 changes: 13 additions & 0 deletions src/python/pants/engine/target_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,12 @@ class OptionalFieldSet(FieldSet):
def opt_out(cls, tgt: Target) -> bool:
return tgt.get(OptOutField).value is True

@dataclass(frozen=True)
class OptionalUnionFieldSet(FieldSet):
required_fields = ()

optional: OptionalField | None = None

required_addr = Address("", target_name="required")
required_tgt = TargetWithRequired({RequiredField.alias: "configured"}, required_addr)
optional_addr = Address("", target_name="unrelated")
Expand Down Expand Up @@ -682,6 +688,13 @@ def opt_out(cls, tgt: Target) -> bool:

assert OptionalFieldSet.create(optional_tgt).optional.value == "configured"
assert OptionalFieldSet.create(no_fields_tgt).optional.value == OptionalField.default
assert OptionalUnionFieldSet.fields == FrozenDict({"optional": OptionalField})
optional_union_field = OptionalUnionFieldSet.create(optional_tgt).optional
assert optional_union_field is not None
assert optional_union_field.value == "configured"
default_union_field = OptionalUnionFieldSet.create(no_fields_tgt).optional
assert default_union_field is not None
assert default_union_field.value == OptionalField.default


# -----------------------------------------------------------------------------------------------
Expand Down
Loading