Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle URL requirements with constraints files. #11907

Merged
merged 8 commits into from
Apr 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/python/pants/backend/python/util_rules/pex_from_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,25 @@ async def pex_from_targets(
# In requirement strings, Foo_-Bar.BAZ and foo-bar-baz refer to the same project. We let
# packaging canonicalize for us.
# See: https://www.python.org/dev/peps/pep-0503/#normalized-names
exact_req_projects = {
canonicalize_project_name(Requirement.parse(req).project_name) for req in exact_reqs
}

url_reqs = set() # E.g., 'foobar@ git+https://github.com/foo/bar.git@branch'
Copy link
Contributor

Choose a reason for hiding this comment

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

This PR adds a lot of randomness from all appearances. Lots of sets and set math. It seems the original order of the constraints and or requirements should be kept.

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 had the same thought, but the PexRequirements ctor sorts its argument. So we were already not preserving the original order, but we were (and still are) eliminating randomness.

name_reqs = set() # E.g., foobar>=1.2.3
name_req_projects = set()

for req_str in exact_reqs:
req = Requirement.parse(req_str)
if req.url: # type: ignore[attr-defined]
url_reqs.add(req)
else:
name_reqs.add(req)
name_req_projects.add(canonicalize_project_name(req.project_name))

constraint_file_projects = {
canonicalize_project_name(req.project_name) for req in constraints_file_reqs
}
unconstrained_projects = exact_req_projects - constraint_file_projects
# Constraints files must only contain name reqs, not URL reqs (those are already
# constrained by their very nature). See https://github.com/pypa/pip/issues/8210.
Copy link
Contributor

Choose a reason for hiding this comment

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

Noting that Pip now has support for URL reqs in constraint files via: pypa/pip#9673
Pex won't be using that Pip for a while, but just noting.

pypa/pip#8210 spawned -> pypa/pip#8253 which was fixed by -> pypa/pip#9673

unconstrained_projects = name_req_projects - constraint_file_projects
if unconstrained_projects:
constraints_descr = (
f"constraints file {constraints_file.path}"
Expand All @@ -272,15 +284,27 @@ async def pex_from_targets(
"file does not cover all requirements."
)
else:
# To get a full set of requirements we must add the URL requirements to the
# constraints file, since the latter cannot contain URL requirements.
# NB: We can only add the URL requirements we know about here, i.e., those that
# are transitive deps of the targets in play. There may be others in the repo.
# So we may end up creating a few different repository pexes, each with identical
# name requirements but different subsets of URL requirements. Fortunately since
# all these repository pexes will have identical pinned versions of everything,
# this is not a correctness issue, only a performance one.
# TODO: Address this as part of providing proper lockfile support. However we
# generate lockfiles, they must be able to include URL requirements.
all_constraints = {str(req) for req in (constraints_file_reqs | url_reqs)}
repository_pex = await Get(
Pex,
PexRequest(
description=f"Resolving {python_setup.requirement_constraints}",
output_filename="repository.pex",
internal_only=request.internal_only,
requirements=PexRequirements(str(req) for req in constraints_file_reqs),
requirements=PexRequirements(all_constraints),
interpreter_constraints=interpreter_constraints,
platforms=request.platforms,
additional_args=["-vvv"],
),
)
elif (
Expand Down
95 changes: 66 additions & 29 deletions src/python/pants/backend/python/util_rules/pex_from_targets_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from pants.engine.internals.scheduler import ExecutionError
from pants.python.python_setup import ResolveAllConstraintsOption
from pants.testutil.rule_runner import QueryRule, RuleRunner
from pants.util.contextutil import pushd


@pytest.fixture
Expand All @@ -39,35 +40,50 @@ class Project:
version: str


def create_dists(workdir: Path, project: Project, *projects: Project) -> PurePath:
project_dirs = []
for proj in (project, *projects):
project_dir = workdir / "projects" / proj.name
project_dir.mkdir(parents=True)
project_dirs.append(project_dir)

(project_dir / "pyproject.toml").write_text(
dedent(
"""\
[build-system]
requires = ["setuptools==54.1.2", "wheel==0.36.2"]
build-backend = "setuptools.build_meta"
"""
)
build_deps = ["setuptools==54.1.2", "wheel==0.36.2"]


def create_project_dir(workdir: Path, project: Project) -> PurePath:
project_dir = workdir / "projects" / project.name
project_dir.mkdir(parents=True)

(project_dir / "pyproject.toml").write_text(
dedent(
f"""\
[build-system]
requires = {build_deps}
build-backend = "setuptools.build_meta"
"""
)
(project_dir / "setup.cfg").write_text(
dedent(
f"""\
)
(project_dir / "setup.cfg").write_text(
dedent(
f"""\
[metadata]
name = {proj.name}
version = {proj.version}
name = {project.name}
version = {project.version}
"""
)
)
)
return project_dir


def create_dists(workdir: Path, project: Project, *projects: Project) -> PurePath:
project_dirs = [create_project_dir(workdir, proj) for proj in (project, *projects)]

pex = workdir / "pex"
subprocess.run(
args=[sys.executable, "-m", "pex", *project_dirs, "--include-tools", "-o", pex], check=True
args=[
sys.executable,
"-m",
"pex",
*project_dirs,
*build_deps,
"--include-tools",
"-o",
pex,
],
check=True,
)

find_links = workdir / "find-links"
Expand Down Expand Up @@ -113,15 +129,30 @@ def test_constraints_validation(tmp_path_factory: TempPathFactory, rule_runner:
Project("QUX", "3.4.5"),
)

# Turn the project dir into a git repo, so it can be cloned.
foorl_dir = create_project_dir(tmp_path_factory.mktemp("git"), Project("foorl", "9.8.7"))
with pushd(str(foorl_dir)):
subprocess.check_call(["git", "init"])
subprocess.check_call(["git", "config", "user.name", "dummy"])
subprocess.check_call(["git", "config", "user.email", "dummy@dummy.com"])
subprocess.check_call(["git", "add", "--all"])
subprocess.check_call(["git", "commit", "-m", "initial commit"])
subprocess.check_call(["git", "branch", "9.8.7"])

# This string won't parse as a Requirement if it doesn't contain a netloc,
# so we explicitly mention localhost.
url_req = f"foorl@ git+file://localhost{foorl_dir.as_posix()}@9.8.7"

rule_runner.add_to_build_file(
"",
dedent(
"""
f"""
python_requirement_library(name="foo", requirements=["foo-bar>=0.1.2"])
python_requirement_library(name="bar", requirements=["bar==5.5.5"])
python_requirement_library(name="baz", requirements=["baz"])
python_requirement_library(name="foorl", requirements=["{url_req}"])
python_library(name="util", sources=[], dependencies=[":foo", ":bar"])
python_library(name="app", sources=[], dependencies=[":util", ":baz"])
python_library(name="app", sources=[], dependencies=[":util", ":baz", ":foorl"])
"""
),
)
Expand All @@ -135,6 +166,8 @@ def test_constraints_validation(tmp_path_factory: TempPathFactory, rule_runner:
bar==5.5.5
baz==2.2.2
qux==3.4.5
# Note that pip does not allow URL requirements in constraints files,
# so there is no mention of foorl here.
"""
),
)
Expand Down Expand Up @@ -162,27 +195,31 @@ def get_pex_request(
return rule_runner.request(PexRequest, [request])

pex_req1 = get_pex_request("constraints1.txt", ResolveAllConstraintsOption.NEVER)
assert pex_req1.requirements == PexRequirements(["foo-bar>=0.1.2", "bar==5.5.5", "baz"])
assert pex_req1.requirements == PexRequirements(
["foo-bar>=0.1.2", "bar==5.5.5", "baz", url_req]
)
assert pex_req1.repository_pex is None

pex_req1_direct = get_pex_request(
"constraints1.txt", ResolveAllConstraintsOption.NEVER, direct_deps_only=True
)
assert pex_req1_direct.requirements == PexRequirements(["baz"])
assert pex_req1_direct.requirements == PexRequirements(["baz", url_req])
assert pex_req1_direct.repository_pex is None

pex_req2 = get_pex_request("constraints1.txt", ResolveAllConstraintsOption.ALWAYS)
assert pex_req2.requirements == PexRequirements(["foo-bar>=0.1.2", "bar==5.5.5", "baz"])
assert pex_req2.requirements == PexRequirements(
["foo-bar>=0.1.2", "bar==5.5.5", "baz", url_req]
)
assert pex_req2.repository_pex is not None
repository_pex = pex_req2.repository_pex
assert ["Foo._-BAR==1.0.0", "bar==5.5.5", "baz==2.2.2", "qux==3.4.5"] == requirements(
assert ["Foo._-BAR==1.0.0", "bar==5.5.5", "baz==2.2.2", "foorl", "qux==3.4.5"] == requirements(
rule_runner, repository_pex
)

pex_req2_direct = get_pex_request(
"constraints1.txt", ResolveAllConstraintsOption.ALWAYS, direct_deps_only=True
)
assert pex_req2_direct.requirements == PexRequirements(["baz"])
assert pex_req2_direct.requirements == PexRequirements(["baz", url_req])
assert pex_req2_direct.repository_pex == repository_pex

with pytest.raises(ExecutionError) as err:
Expand Down