diff --git a/piptools/repositories/local.py b/piptools/repositories/local.py index e2a96be4d..4cb04178b 100644 --- a/piptools/repositories/local.py +++ b/piptools/repositories/local.py @@ -63,9 +63,7 @@ def find_best_match(self, ireq, prereleases=None): existing_pin = self.existing_pins.get(key) if existing_pin and ireq_satisfied_by_existing_pin(ireq, existing_pin): project, version, _ = as_tuple(existing_pin) - return make_install_requirement( - project, version, ireq.extras, constraint=ireq.constraint - ) + return make_install_requirement(project, version, ireq) else: return self.repository.find_best_match(ireq, prereleases) diff --git a/piptools/repositories/pypi.py b/piptools/repositories/pypi.py index 433187dda..d64f48027 100644 --- a/piptools/repositories/pypi.py +++ b/piptools/repositories/pypi.py @@ -159,8 +159,7 @@ def find_best_match(self, ireq, prereleases=None): return make_install_requirement( best_candidate.name, best_candidate.version, - ireq.extras, - constraint=ireq.constraint, + ireq, ) def resolve_reqs(self, download_dir, ireq, wheel_cache): diff --git a/piptools/utils.py b/piptools/utils.py index 5ed2c38b9..cde26a5e1 100644 --- a/piptools/utils.py +++ b/piptools/utils.py @@ -21,6 +21,7 @@ from pip._internal.vcs import is_url from pip._vendor.packaging.markers import Marker from pip._vendor.packaging.specifiers import SpecifierSet +from pip._vendor.packaging.version import Version _KT = TypeVar("_KT") _VT = TypeVar("_VT") @@ -66,16 +67,25 @@ def comment(text: str) -> str: def make_install_requirement( - name: str, version: str, extras: Iterable[str], constraint: bool = False + name: str, version: Union[str, Version], ireq: InstallRequirement ) -> InstallRequirement: # If no extras are specified, the extras string is blank extras_string = "" + extras = ireq.extras if extras: # Sort extras for stability extras_string = f"[{','.join(sorted(extras))}]" + version_pin_operator = "==" + version_as_str = str(version) + for specifier in ireq.specifier: + if specifier.operator == "===" and specifier.version == version_as_str: + version_pin_operator = "===" + break + return install_req_from_line( - str(f"{name}{extras_string}=={version}"), constraint=constraint + str(f"{name}{extras_string}{version_pin_operator}{version}"), + constraint=ireq.constraint, ) diff --git a/tests/conftest.py b/tests/conftest.py index 0936845ed..c7021951c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,9 +68,7 @@ def find_best_match(self, ireq, prereleases=False): ] raise NoCandidateFound(ireq, tried_versions, ["https://fake.url.foo"]) best_version = max(versions, key=Version) - return make_install_requirement( - key_from_ireq(ireq), best_version, ireq.extras, constraint=ireq.constraint - ) + return make_install_requirement(key_from_ireq(ireq), best_version, ireq) def get_dependencies(self, ireq): if ireq.editable or is_url_requirement(ireq): diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py index 603d3f83f..5f50a27aa 100644 --- a/tests/test_cli_compile.py +++ b/tests/test_cli_compile.py @@ -1555,3 +1555,74 @@ def test_duplicate_reqs_combined( assert out.exit_code == 0, out assert str(test_package_2) in out.stderr assert "test-package-1==0.1" in out.stderr + + +@pytest.mark.parametrize( + ("pkg2_install_requires", "req_in_content", "out_expected_content"), + ( + pytest.param( + "", + ["test-package-1===0.1.0\n"], + ["test-package-1===0.1.0"], + id="pin package with ===", + ), + pytest.param( + "", + ["test-package-1==0.1.0\n"], + ["test-package-1==0.1.0"], + id="pin package with ==", + ), + pytest.param( + "test-package-1==0.1.0", + ["test-package-1===0.1.0\n", "test-package-2==0.1.0\n"], + ["test-package-1===0.1.0", "test-package-2==0.1.0"], + id="dep === pin preferred over == pin, main package == pin", + ), + pytest.param( + "test-package-1==0.1.0", + ["test-package-1===0.1.0\n", "test-package-2===0.1.0\n"], + ["test-package-1===0.1.0", "test-package-2===0.1.0"], + id="dep === pin preferred over == pin, main package === pin", + ), + pytest.param( + "test-package-1==0.1.0", + ["test-package-2===0.1.0\n"], + ["test-package-1==0.1.0", "test-package-2===0.1.0"], + id="dep == pin conserved, main package === pin", + ), + ), +) +def test_triple_equal_pinned_dependency_is_used( + runner, + make_package, + make_wheel, + tmpdir, + pkg2_install_requires, + req_in_content, + out_expected_content, +): + """ + Test that pip-compile properly emits the pinned requirement with === + torchvision 0.8.2 requires torch==1.7.1 which can resolve to versions with + patches (e.g. torch 1.7.1+cu110), we want torch===1.7.1 without patches + """ + + dists_dir = tmpdir / "dists" + + test_package_1 = make_package("test_package_1", version="0.1.0") + make_wheel(test_package_1, dists_dir) + + test_package_2 = make_package( + "test_package_2", version="0.1.0", install_requires=[pkg2_install_requires] + ) + make_wheel(test_package_2, dists_dir) + + with open("requirements.in", "w") as reqs_in: + for line in req_in_content: + reqs_in.write(line) + + out = runner.invoke(cli, ["--find-links", str(dists_dir)]) + + assert out.exit_code == 0, out + for line in out_expected_content: + assert line in out.stderr