Skip to content

Commit 45ca5b4

Browse files
authored
Support for using a single global resolve. (#10405)
Introduces a --python-setup-use-all-requirements flag. If set, instead of resolving just the exact requirements needed, we resolve the entire lockfile. This prevents a huge amount of resolving work of all the various requirement subsets needed by specific tests. [ci skip-rust-tests]
1 parent 22cc05f commit 45ca5b4

File tree

5 files changed

+187
-4
lines changed

5 files changed

+187
-4
lines changed

pants.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ root_patterns = [
5353
"/build-support/migration-support"
5454
]
5555

56+
[python-setup]
57+
# TODO: Set up an actual lockfile. For now we just "lock" our requirements.txt to itself,
58+
# so we can use --python-setup-resolve-all-constraints (which requires a lockfile).
59+
requirement_constraints = "3rdparty/python/requirements.txt"
60+
5661
[black]
5762
config = "pyproject.toml"
5863

src/python/pants/backend/python/rules/pex_from_targets.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

44
import dataclasses
5+
import logging
56
from dataclasses import dataclass
67
from typing import Iterable, Optional, Tuple
78

9+
from pkg_resources import Requirement, parse_requirements
10+
811
from pants.backend.python.rules.pex import (
912
PexInterpreterConstraints,
1013
PexPlatforms,
@@ -21,13 +24,17 @@
2124
PythonRequirementsField,
2225
)
2326
from pants.engine.addresses import Addresses
24-
from pants.engine.fs import Digest, MergeDigests
27+
from pants.engine.fs import Digest, DigestContents, MergeDigests, PathGlobs, Snapshot
2528
from pants.engine.rules import RootRule, rule
2629
from pants.engine.selectors import Get
2730
from pants.engine.target import TransitiveTargets
31+
from pants.option.custom_types import GlobExpansionConjunction
32+
from pants.option.global_options import GlobMatchErrorBehavior
2833
from pants.python.python_setup import PythonSetup
2934
from pants.util.meta import frozen_after_init
3035

36+
logger = logging.getLogger(__name__)
37+
3138

3239
@frozen_after_init
3340
@dataclass(unsafe_hash=True)
@@ -59,7 +66,7 @@ def __init__(
5966
include_source_files: bool = True,
6067
additional_sources: Optional[Digest] = None,
6168
additional_inputs: Optional[Digest] = None,
62-
description: Optional[str] = None
69+
description: Optional[str] = None,
6370
) -> None:
6471
self.addresses = addresses
6572
self.output_filename = output_filename
@@ -112,7 +119,7 @@ async def pex_from_targets(request: PexFromTargetsRequest, python_setup: PythonS
112119
python_setup,
113120
)
114121

115-
requirements = PexRequirements.create_from_requirement_fields(
122+
exact_reqs = PexRequirements.create_from_requirement_fields(
116123
(
117124
tgt[PythonRequirementsField]
118125
for tgt in all_targets
@@ -121,6 +128,47 @@ async def pex_from_targets(request: PexFromTargetsRequest, python_setup: PythonS
121128
additional_requirements=request.additional_requirements,
122129
)
123130

131+
requirements = exact_reqs
132+
133+
if python_setup.requirement_constraints:
134+
exact_req_projects = {Requirement.parse(req).project_name for req in exact_reqs}
135+
constraint_file_snapshot = await Get(
136+
Snapshot,
137+
PathGlobs(
138+
[python_setup.requirement_constraints],
139+
glob_match_error_behavior=GlobMatchErrorBehavior.error,
140+
conjunction=GlobExpansionConjunction.all_match,
141+
description_of_origin="the option `--python-setup-requirement-constraints`",
142+
),
143+
)
144+
constraints_file_contents = await Get(
145+
DigestContents, Digest, constraint_file_snapshot.digest
146+
)
147+
constraints_file_reqs = set(
148+
parse_requirements(next(iter(constraints_file_contents)).content.decode())
149+
)
150+
constraint_file_projects = {req.project_name for req in constraints_file_reqs}
151+
unconstrained_projects = exact_req_projects - constraint_file_projects
152+
if unconstrained_projects:
153+
logger.warning(
154+
f"The constraints file {python_setup.requirement_constraints} does not contain "
155+
f"entries for the following requirements: {', '.join(unconstrained_projects)}"
156+
)
157+
158+
if python_setup.options.resolve_all_constraints:
159+
if unconstrained_projects:
160+
logger.warning(
161+
"Ignoring resolve_all_constraints setting in [python_setup] scope"
162+
"Because constraints file does not cover all requirements."
163+
)
164+
else:
165+
requirements = PexRequirements(str(req) for req in constraints_file_reqs)
166+
elif python_setup.options.resolve_all_constraints:
167+
raise ValueError(
168+
"resolve_all_constraints in the [python-setup] scope is set, so "
169+
"requirement_constraints in [python-setup] must also be provided."
170+
)
171+
124172
return PexRequest(
125173
output_filename=request.output_filename,
126174
requirements=requirements,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
4+
from textwrap import dedent
5+
from typing import Optional
6+
7+
from pants.backend.python.rules import pex_from_targets, python_sources
8+
from pants.backend.python.rules.pex import PexRequest, PexRequirements
9+
from pants.backend.python.rules.pex_from_targets import PexFromTargetsRequest
10+
from pants.backend.python.rules.python_sources import StrippedPythonSourcesRequest
11+
from pants.backend.python.target_types import PythonLibrary, PythonRequirementLibrary
12+
from pants.build_graph.address import Address
13+
from pants.build_graph.build_file_aliases import BuildFileAliases
14+
from pants.engine.addresses import Addresses
15+
from pants.engine.internals.scheduler import ExecutionError
16+
from pants.engine.rules import RootRule, SubsystemRule
17+
from pants.engine.selectors import Params
18+
from pants.python.python_requirement import PythonRequirement
19+
from pants.python.python_setup import PythonSetup
20+
from pants.testutil.option.util import create_options_bootstrapper
21+
from pants.testutil.test_base import TestBase
22+
23+
24+
class PexTest(TestBase):
25+
@classmethod
26+
def rules(cls):
27+
return (
28+
*super().rules(),
29+
*pex_from_targets.rules(),
30+
*python_sources.rules(),
31+
RootRule(PexFromTargetsRequest),
32+
RootRule(StrippedPythonSourcesRequest),
33+
SubsystemRule(PythonSetup),
34+
)
35+
36+
@classmethod
37+
def alias_groups(cls):
38+
return BuildFileAliases(objects={"python_requirement": PythonRequirement})
39+
40+
@classmethod
41+
def target_types(cls):
42+
return [PythonLibrary, PythonRequirementLibrary]
43+
44+
def test_constraints_validation(self) -> None:
45+
46+
self.add_to_build_file(
47+
"",
48+
dedent(
49+
"""
50+
python_requirement_library(name="foo",
51+
requirements=[python_requirement("foo>=0.1.2")])
52+
python_requirement_library(name="bar",
53+
requirements=[ python_requirement("bar==5.5.5")])
54+
python_requirement_library(name="baz",
55+
requirements=[python_requirement("baz")])
56+
python_library(name="tgt", sources=[], dependencies=[":foo", ":bar", ":baz"])
57+
"""
58+
),
59+
)
60+
self.create_file(
61+
"constraints1.txt",
62+
dedent(
63+
"""
64+
foo==1.0.0
65+
bar==5.5.5
66+
baz==2.2.2
67+
qux==3.4.5
68+
"""
69+
),
70+
)
71+
self.create_file(
72+
"constraints2.txt",
73+
dedent(
74+
"""
75+
foo==1.0.0
76+
bar==5.5.5
77+
qux==3.4.5
78+
"""
79+
),
80+
)
81+
82+
request = PexFromTargetsRequest(
83+
Addresses((Address.parse("//:tgt"),)), output_filename="dummy.pex"
84+
)
85+
86+
def get_pex_request(constraints_file: Optional[str], resolve_all: bool) -> PexRequest:
87+
args = [
88+
"--backend-packages=pants.backend.python",
89+
f"--python-setup-resolve-all-constraints={resolve_all}",
90+
]
91+
if constraints_file:
92+
args.append(f"--python-setup-requirement-constraints={constraints_file}")
93+
return self.request_single_product(
94+
PexRequest, Params(request, create_options_bootstrapper(args=args))
95+
)
96+
97+
pex_req1 = get_pex_request("constraints1.txt", False)
98+
assert pex_req1.requirements == PexRequirements(["foo>=0.1.2", "bar==5.5.5", "baz"])
99+
100+
pex_req2 = get_pex_request("constraints1.txt", True)
101+
assert pex_req2.requirements == PexRequirements(
102+
["foo==1.0.0", "bar==5.5.5", "baz==2.2.2", "qux==3.4.5"]
103+
)
104+
105+
with self.assertRaises(ExecutionError) as err:
106+
get_pex_request(None, True)
107+
assert len(err.exception.wrapped_exceptions) == 1
108+
assert isinstance(err.exception.wrapped_exceptions[0], ValueError)
109+
assert (
110+
"resolve_all_constraints in the [python-setup] scope is set, so "
111+
"requirement_constraints in [python-setup] must also be provided."
112+
) in str(err.exception)

src/python/pants/engine/fs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def __init__(
7070
:param globs: globs to match, e.g. `foo.txt` or `**/*.txt`. To exclude something, prefix it
7171
with `!`, e.g. `!ignore.py`.
7272
:param glob_match_error_behavior: whether to warn or error upon match failures
73-
:param conjunction: whether all `include`s must match or only at least one must match
73+
:param conjunction: whether all `globs` must match or only at least one must match
7474
:param description_of_origin: a human-friendly description of where this PathGlobs request
7575
is coming from, used to improve the error message for
7676
unmatched globs. For example, this might be the text string

src/python/pants/python/python_setup.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ def register_options(cls, register):
5050
"on the format of constraint files and how constraints are applied in Pex and Pip."
5151
),
5252
)
53+
register(
54+
"--resolve-all-constraints",
55+
advanced=True,
56+
default=False,
57+
type=bool,
58+
help=(
59+
"If set, and the requirements of the code being operated on are a subset of the "
60+
"constraints file, then the entire constraints file will be used instead of the "
61+
"subset. If unset, or any requirement of the code being operated on is not in the "
62+
"constraints file, each subset will be independently resolved as needed, which is "
63+
"more correct - work is only invalidated if a requirement it actually depends on "
64+
"changes - but also a lot slower, due to the extra resolving. "
65+
"You may wish to leave this option set for normal work, such as running tests, "
66+
"but selectively turn it off via command-line-flag when building deployable "
67+
"binaries, so that you only deploy the requirements you actually need for a "
68+
"given binary."
69+
),
70+
)
5371
register(
5472
"--platforms",
5573
advanced=True,

0 commit comments

Comments
 (0)