From b3ae7694359972ea05a71a8d6265906e22798103 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Sat, 9 Oct 2021 12:41:28 +0800 Subject: [PATCH 01/16] Add utilities to check PEP 610 to incoming link --- src/pip/_internal/models/link.py | 2 +- src/pip/_internal/utils/direct_url_helpers.py | 43 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py index 6069b278b9b..3ec5fb79d05 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -259,7 +259,7 @@ class _CleanResult(NamedTuple): def _clean_link(link: Link) -> _CleanResult: parsed = link._parsed_url - netloc = parsed.netloc.rsplit("@", 1)[-1] + netloc, _ = split_auth_from_netloc(parsed.netloc) # According to RFC 8089, an empty host in file: means localhost. if parsed.scheme == "file" and not netloc: netloc = "localhost" diff --git a/src/pip/_internal/utils/direct_url_helpers.py b/src/pip/_internal/utils/direct_url_helpers.py index 0e8e5e1608b..795a763f603 100644 --- a/src/pip/_internal/utils/direct_url_helpers.py +++ b/src/pip/_internal/utils/direct_url_helpers.py @@ -1,7 +1,7 @@ -from typing import Optional +from typing import List, Optional from pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo -from pip._internal.models.link import Link +from pip._internal.models.link import Link, links_equivalent from pip._internal.utils.urls import path_to_url from pip._internal.vcs import vcs @@ -85,3 +85,42 @@ def direct_url_from_link( info=ArchiveInfo(hash=hash), subdirectory=link.subdirectory_fragment, ) + + +def _link_from_direct_url(direct_url: DirectUrl) -> Link: + """Create a link from given direct URL construct. + + This function is designed specifically for ``link_matches_direct_url``, and + does NOT losslessly reconstruct the original link that produced the + DirectUrl. Namely: + + * The auth part is ignored (since it does not affect link equivalency). + * Only "subdirectory" and hash fragment parts are considered, and the + ordering of the kept parts are not considered (since only their values + affect link equivalency). + + .. seealso:: ``pip._internal.models.link.links_equivalent()`` + """ + url = direct_url.url + hash_frag: Optional[str] = None + + direct_url_info = direct_url.info + if isinstance(direct_url_info, VcsInfo): + url = f"{url}@{direct_url_info.requested_revision}" + elif isinstance(direct_url_info, ArchiveInfo): + hash_frag = direct_url_info.hash + + fragment_parts: List[str] = [] + if direct_url.subdirectory is not None: + fragment_parts.append(f"subdirectory={direct_url.subdirectory}") + if hash_frag: + fragment_parts.append(hash_frag) + if fragment_parts: + fragment = "&".join(fragment_parts) + url = f"{url}#{fragment}" + + return Link(url) + + +def link_matches_direct_url(link: Link, direct_url: DirectUrl) -> bool: + return links_equivalent(link, _link_from_direct_url(direct_url)) From fd6d34af6b0c2f0e08499b2e62544d937ca3bb3b Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Sun, 10 Oct 2021 04:34:25 +0800 Subject: [PATCH 02/16] Rewrite direct URL reinstallation logic This entirely rewrites the logic when an incoming to-be-installed resolved candidate is resolved from a direct URL requirement. The current logic is: * Always reinstall on --upgrade or --force-reinstall. * Always reinstall locally available wheels. * Always reinstall editables or if an installed editable should be changed to be non-editable. * Always reinstall if local PEP 610 information does not match the incoming candidate. This includes cases where the URLs are not sufficiently similar, or if the resolved VCS revisions differ. * Do not reinstall otherwise. Note that this slightly differs from the proposal raised in previous discussions, where a local non-PEP 508 path would be reinstalled. This is due to pip does not actually carry this information to the resolver and it's not possible to distinguish PEP 508 requirements from bare path arguments. The logic does not change how version-specified candidates are reinstalled. --- .../resolution/resolvelib/resolver.py | 160 ++++++++++++------ 1 file changed, 109 insertions(+), 51 deletions(-) diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index 32ef7899ba6..c436ab6632a 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -10,18 +10,18 @@ from pip._internal.cache import WheelCache from pip._internal.index.package_finder import PackageFinder +from pip._internal.models.direct_url import VcsInfo from pip._internal.operations.prepare import RequirementPreparer from pip._internal.req.req_install import InstallRequirement from pip._internal.req.req_set import RequirementSet from pip._internal.resolution.base import BaseResolver, InstallRequirementProvider -from pip._internal.resolution.resolvelib.provider import PipProvider -from pip._internal.resolution.resolvelib.reporter import ( - PipDebuggingReporter, - PipReporter, -) +from pip._internal.utils.direct_url_helpers import link_matches_direct_url +from pip._internal.vcs.versioncontrol import vcs from .base import Candidate, Requirement from .factory import Factory +from .provider import PipProvider +from .reporter import PipDebuggingReporter, PipReporter if TYPE_CHECKING: from pip._vendor.resolvelib.resolvers import Result as RLResult @@ -69,6 +69,94 @@ def __init__( self.upgrade_strategy = upgrade_strategy self._result: Optional[Result] = None + def _get_ireq( + self, + candidate: Candidate, + direct_url_requested: bool, + ) -> Optional[InstallRequirement]: + ireq = candidate.get_install_requirement() + + # No ireq to install (e.g. extra-ed candidate). Skip. + if ireq is None: + return None + + # The currently installed distribution of the same identifier. + installed_dist = self.factory.get_dist_to_uninstall(candidate) + + if installed_dist is None: # Not installed. Install incoming candidate. + return ireq + + # If we return this ireq, it should trigger uninstallation of the + # existing distribution for reinstallation. + ireq.should_reinstall = True + + # Reinstall if --force-reinstall is set. + if self.factory.force_reinstall: + return ireq + + # The artifact represented by the incoming candidate. + cand_link = candidate.source_link + + # The candidate does not point to an artifact. This means the resolver + # has already decided the installed distribution is good enough. + if cand_link is None: + return None + + # The incoming candidate was produced only from version requirements. + # Reinstall if the installed distribution's version does not match. + if not direct_url_requested: + if installed_dist.version == candidate.version: + return None + return ireq + + # At this point, the incoming candidate was produced from a direct URL. + # Determine whether to upgrade based on flags and whether the installed + # distribution was done via a direct URL. + + # Always reinstall an incoming wheel candidate on the local filesystem. + # This is quite fast anyway, and we can avoid drama when users want + # their in-development direct URL requirement automatically reinstalled. + if cand_link.is_file and cand_link.is_wheel: + return ireq + + # Reinstall if --upgrade is specified. + if self.upgrade_strategy != "to-satisfy-only": + return ireq + + # The PEP 610 direct URL representation of the installed distribution. + dist_direct_url = installed_dist.direct_url + + # The incoming candidate was produced from a direct URL, but the + # currently installed distribution was not. Always reinstall to be sure. + if dist_direct_url is None: + return ireq + + # Editable candidate always triggers reinstallation. + if candidate.is_editable: + return ireq + + # The currently installed distribution is editable, but the incoming + # candidate is not. Uninstall the editable one to match. + if installed_dist.editable: + return ireq + + # Reinstall if the direct URLs don't match. + if not link_matches_direct_url(cand_link, dist_direct_url): + return ireq + + # If VCS, only reinstall if the resolved revisions don't match. + cand_vcs = vcs.get_backend_for_scheme(cand_link.scheme) + dist_direct_info = dist_direct_url.info + if cand_vcs and ireq.source_dir and isinstance(dist_direct_info, VcsInfo): + candidate_rev = cand_vcs.get_revision(ireq.source_dir) + if candidate_rev != dist_direct_info.commit_id: + return ireq + + # Now we know both the installed distribution and incoming candidate + # are based on direct URLs, neither are editable nor VCS, and point to + # equivalent targets. They are probably the same, don't reinstall. + return None + def resolve( self, root_reqs: List[InstallRequirement], check_supported_wheels: bool ) -> RequirementSet: @@ -103,59 +191,29 @@ def resolve( raise error from e req_set = RequirementSet(check_supported_wheels=check_supported_wheels) - for candidate in result.mapping.values(): - ireq = candidate.get_install_requirement() - if ireq is None: - continue + for identifier, candidate in result.mapping.items(): + # Whether the candidate was resolved from direct URL requirements. + direct_url_requested = any( + requirement.get_candidate_lookup()[0] is not None + for requirement in result.criteria[identifier].iter_requirement() + ) - # Check if there is already an installation under the same name, - # and set a flag for later stages to uninstall it, if needed. - installed_dist = self.factory.get_dist_to_uninstall(candidate) - if installed_dist is None: - # There is no existing installation -- nothing to uninstall. - ireq.should_reinstall = False - elif self.factory.force_reinstall: - # The --force-reinstall flag is set -- reinstall. - ireq.should_reinstall = True - elif installed_dist.version != candidate.version: - # The installation is different in version -- reinstall. - ireq.should_reinstall = True - elif candidate.is_editable or installed_dist.editable: - # The incoming distribution is editable, or different in - # editable-ness to installation -- reinstall. - ireq.should_reinstall = True - elif candidate.source_link and candidate.source_link.is_file: - # The incoming distribution is under file:// - if candidate.source_link.is_wheel: - # is a local wheel -- do nothing. - logger.info( - "%s is already installed with the same version as the " - "provided wheel. Use --force-reinstall to force an " - "installation of the wheel.", - ireq.name, - ) - continue - - # is a local sdist or path -- reinstall - ireq.should_reinstall = True - else: + ireq = self._get_ireq(candidate, direct_url_requested) + if ireq is None: continue link = candidate.source_link if link and link.is_yanked: - # The reason can contain non-ASCII characters, Unicode - # is required for Python 2. - msg = ( + reason = link.yanked_reason or "" + logger.warning( "The candidate selected for download or install is a " - "yanked version: {name!r} candidate (version {version} " - "at {link})\nReason for being yanked: {reason}" - ).format( - name=candidate.name, - version=candidate.version, - link=link, - reason=link.yanked_reason or "", + "yanked version: %r candidate (version %s at %s)\n" + "Reason for being yanked: %s", + candidate.name, + candidate.version, + link, + reason, ) - logger.warning(msg) req_set.add_named_requirement(ireq) From 85628f8fc7ab2f2b61574b6ab9194a5477b87eaa Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Sun, 10 Oct 2021 05:07:25 +0800 Subject: [PATCH 03/16] News --- news/5780.feature.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 news/5780.feature.rst diff --git a/news/5780.feature.rst b/news/5780.feature.rst new file mode 100644 index 00000000000..29e54dcff51 --- /dev/null +++ b/news/5780.feature.rst @@ -0,0 +1,6 @@ +Reinstallation and upgrade behaviour of direct URL requirements are completely +rewritten to better match user expectation. Now pip will try to parse the URLs +and VCS information (if applicable) from both the incoming direct URLs and the +already-installed PEP 610 metadata to determine whether reinstallation is +needed. If pip guesses wrong, the user can also force pip to always reinstall +direct URL requirments (but not version-specified ones) with ``--upgrade``. From 9b15bf66a4fb983c4bd5b76a5bcae79b76d68cc7 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Thu, 18 Nov 2021 18:17:13 +0800 Subject: [PATCH 04/16] VCS link equivalency should use commit_id --- src/pip/_internal/utils/direct_url_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/utils/direct_url_helpers.py b/src/pip/_internal/utils/direct_url_helpers.py index 795a763f603..c29b147b234 100644 --- a/src/pip/_internal/utils/direct_url_helpers.py +++ b/src/pip/_internal/utils/direct_url_helpers.py @@ -106,7 +106,7 @@ def _link_from_direct_url(direct_url: DirectUrl) -> Link: direct_url_info = direct_url.info if isinstance(direct_url_info, VcsInfo): - url = f"{url}@{direct_url_info.requested_revision}" + url = f"{url}@{direct_url_info.commit_id}" elif isinstance(direct_url_info, ArchiveInfo): hash_frag = direct_url_info.hash From 21f7a61b128008745face5fd92134ff6b37a89ba Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Thu, 18 Nov 2021 18:17:20 +0800 Subject: [PATCH 05/16] Revise locally available dist reinstallation logic Any direct URL pointing to somewhere on the local filesystem is now automatically reinstalled. --- src/pip/_internal/resolution/resolvelib/resolver.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index c436ab6632a..fd455dda454 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -74,6 +74,11 @@ def _get_ireq( candidate: Candidate, direct_url_requested: bool, ) -> Optional[InstallRequirement]: + """Get the InstallRequirement to install for a candidate. + + Returning None means the candidate is already satisfied by the current + environment state and does not need to be handled. + """ ireq = candidate.get_install_requirement() # No ireq to install (e.g. extra-ed candidate). Skip. @@ -113,10 +118,8 @@ def _get_ireq( # Determine whether to upgrade based on flags and whether the installed # distribution was done via a direct URL. - # Always reinstall an incoming wheel candidate on the local filesystem. - # This is quite fast anyway, and we can avoid drama when users want - # their in-development direct URL requirement automatically reinstalled. - if cand_link.is_file and cand_link.is_wheel: + # Always reinstall a direct candidate if it's on the local file system. + if cand_link.is_file: return ireq # Reinstall if --upgrade is specified. From 90e5f07aee0525ec867f2b56948f60e970344252 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Thu, 18 Nov 2021 19:14:03 +0800 Subject: [PATCH 06/16] Fix test for behaviour change --- tests/functional/test_install_upgrade.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/functional/test_install_upgrade.py b/tests/functional/test_install_upgrade.py index 3dab9161e80..cde0302a48d 100644 --- a/tests/functional/test_install_upgrade.py +++ b/tests/functional/test_install_upgrade.py @@ -229,9 +229,8 @@ def test_uninstall_before_upgrade_from_url(script: PipTestEnvironment) -> None: @pytest.mark.network def test_upgrade_to_same_version_from_url(script: PipTestEnvironment) -> None: """ - When installing from a URL the same version that is already installed, no - need to uninstall and reinstall if --upgrade is not specified. - + A direct URL always triggers reinstallation if the current installation + was not made from a URL, even if they specify the same version. """ result = script.pip("install", "INITools==0.3") result.did_create(script.site_packages / "initools") @@ -241,7 +240,7 @@ def test_upgrade_to_same_version_from_url(script: PipTestEnvironment) -> None: "0.3.tar.gz", ) assert ( - script.site_packages / "initools" not in result2.files_updated + script.site_packages / "initools" in result2.files_updated ), "INITools 0.3 reinstalled same version" result3 = script.pip("uninstall", "initools", "-y") assert_all_changes(result, result3, [script.venv / "build", "cache"]) From 8c2216bc096851077564f4ef765b1488f9f8a619 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Mon, 22 Nov 2021 01:19:27 +0800 Subject: [PATCH 07/16] Allow saving a wheel to pathlib.Path --- tests/lib/wheel.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/lib/wheel.py b/tests/lib/wheel.py index 878364cf792..c238c5b7b03 100644 --- a/tests/lib/wheel.py +++ b/tests/lib/wheel.py @@ -2,6 +2,7 @@ """ import csv import itertools +import pathlib from base64 import urlsafe_b64encode from collections import namedtuple from copy import deepcopy @@ -252,23 +253,23 @@ def __init__(self, name: str, files: Iterable[File]) -> None: self._name = name self._files = files - def save_to_dir(self, path: Union[Path, str]) -> str: + def save_to_dir(self, path: Union[pathlib.Path, Path, str]) -> str: """Generate wheel file with correct name and save into the provided directory. :returns the wheel file path """ - p = Path(path) / self._name + p = pathlib.Path(path) / self._name p.write_bytes(self.as_bytes()) return str(p) - def save_to(self, path: Union[Path, str]) -> str: + def save_to(self, path: Union[pathlib.Path, Path, str]) -> str: """Generate wheel file, saving to the provided path. Any parent directories must already exist. :returns the wheel file path """ - path = Path(path) + path = pathlib.Path(path) path.write_bytes(self.as_bytes()) return str(path) From 3190c360e11ac8d2ca748aa0424ff9a532b866bf Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Sun, 30 Jan 2022 22:44:27 +0800 Subject: [PATCH 08/16] Add failing test for URL reinstall --- tests/functional/test_install_direct_url.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/functional/test_install_direct_url.py b/tests/functional/test_install_direct_url.py index 9d5e0612ea9..02778f496af 100644 --- a/tests/functional/test_install_direct_url.py +++ b/tests/functional/test_install_direct_url.py @@ -63,3 +63,14 @@ def test_install_vcs_constraint_direct_file_url(script: PipTestEnvironment) -> N constraints_file.write_text(f"git+{url}#egg=testpkg") result = script.pip("install", "testpkg", "-c", constraints_file) assert get_created_direct_url(result, "testpkg") + + +@pytest.mark.network +@pytest.mark.usefixtures("with_wheel") +def test_reinstall_vcs_does_not_modify(script: PipTestEnvironment) -> None: + url = "pip-test-package @ git+https://github.com/pypa/pip-test-package@master" + script.pip("install", "--no-cache-dir", url) + + result = script.pip("install", url) + assert "Preparing " in result.stdout, str(result) # Should build. + assert "Installing " not in result.stdout, str(result) # But not install. From df9eaab50d1286d004847614521c5f4c66f1e7f5 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Sun, 30 Jan 2022 23:29:37 +0800 Subject: [PATCH 09/16] Fix PEP 610 info comparison logic to incoming link For VCS URLs, the VCS type must be prefixed to the scheme to correctly reconstruct the URL. --- src/pip/_internal/models/direct_url.py | 18 +++++++++++ src/pip/_internal/utils/direct_url_helpers.py | 4 ++- tests/unit/test_direct_url_helpers.py | 30 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py index e75feda9ca9..eb419428ef9 100644 --- a/src/pip/_internal/models/direct_url.py +++ b/src/pip/_internal/models/direct_url.py @@ -89,6 +89,12 @@ def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["VcsInfo"]: requested_revision=_get(d, str, "requested_revision"), ) + def __repr__(self) -> str: + return ( + f"VcsInfo(vcs={self.vcs!r}, commit_id={self.commit_id!r}, " + f"requested_revision={self.requested_revision!r})" + ) + def _to_dict(self) -> Dict[str, Any]: return _filter_none( vcs=self.vcs, @@ -112,6 +118,9 @@ def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["ArchiveInfo"]: return None return cls(hash=_get(d, str, "hash")) + def __repr__(self) -> str: + return f"ArchiveInfo(hash={self.hash!r})" + def _to_dict(self) -> Dict[str, Any]: return _filter_none(hash=self.hash) @@ -131,6 +140,9 @@ def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["DirInfo"]: return None return cls(editable=_get_required(d, bool, "editable", default=False)) + def __repr__(self) -> str: + return f"DirInfo(editable={self.editable!r})" + def _to_dict(self) -> Dict[str, Any]: return _filter_none(editable=self.editable or None) @@ -149,6 +161,12 @@ def __init__( self.info = info self.subdirectory = subdirectory + def __repr__(self) -> str: + return ( + f"DirectUrl(url={self.url!r}, info={self.info!r}, " + f"subdirectory={self.subdirectory!r})" + ) + def _remove_auth_from_netloc(self, netloc: str) -> str: if "@" not in netloc: return netloc diff --git a/src/pip/_internal/utils/direct_url_helpers.py b/src/pip/_internal/utils/direct_url_helpers.py index c29b147b234..5908ed653a4 100644 --- a/src/pip/_internal/utils/direct_url_helpers.py +++ b/src/pip/_internal/utils/direct_url_helpers.py @@ -106,9 +106,11 @@ def _link_from_direct_url(direct_url: DirectUrl) -> Link: direct_url_info = direct_url.info if isinstance(direct_url_info, VcsInfo): - url = f"{url}@{direct_url_info.commit_id}" + url = f"{direct_url_info.vcs}+{url}@{direct_url_info.commit_id}" elif isinstance(direct_url_info, ArchiveInfo): hash_frag = direct_url_info.hash + elif not isinstance(direct_url_info, DirInfo): + raise ValueError(f"Unsupported direct URL format: {direct_url_info!r}") fragment_parts: List[str] = [] if direct_url.subdirectory is not None: diff --git a/tests/unit/test_direct_url_helpers.py b/tests/unit/test_direct_url_helpers.py index 8d94aeb50b6..5a68f6a7640 100644 --- a/tests/unit/test_direct_url_helpers.py +++ b/tests/unit/test_direct_url_helpers.py @@ -1,11 +1,14 @@ from functools import partial from unittest import mock +import pytest + from pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo from pip._internal.models.link import Link from pip._internal.utils.direct_url_helpers import ( direct_url_as_pep440_direct_reference, direct_url_from_link, + link_matches_direct_url, ) from pip._internal.utils.urls import path_to_url from tests.lib import PipTestEnvironment @@ -170,3 +173,30 @@ def test_from_link_hide_user_password() -> None: link_is_in_wheel_cache=True, ) assert direct_url.to_dict()["url"] == "ssh://git@g.c/u/p.git" + + +@pytest.mark.parametrize( + "link, direct_url", + [ + pytest.param( + Link("git+https://user:password@g.c/u/p.git@commit_id"), + DirectUrl( + "https://user:password@g.c/u/p.git", + VcsInfo("git", "commit_id", requested_revision="revision"), + ), + id="vcs", + ), + pytest.param( + Link("https://g.c/archive.tgz#hash=12345"), + DirectUrl("https://g.c/archive.tgz", ArchiveInfo("hash=12345")), + id="archive", + ), + pytest.param( + Link("file:///home/user/project"), + DirectUrl("file:///home/user/project", DirInfo(editable=False)), + id="dir", + ), + ], +) +def test_link_matches_direct_url(link: Link, direct_url: DirectUrl) -> None: + assert link_matches_direct_url(link, direct_url) From 8b48efd2dc37f820e683671ac27d2ad7eadc4f97 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Tue, 19 Apr 2022 16:34:40 +0800 Subject: [PATCH 10/16] Tweak reinstall logic for VCS resolution --- .../_internal/resolution/resolvelib/resolver.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index fd455dda454..87d709ae458 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -143,21 +143,21 @@ def _get_ireq( if installed_dist.editable: return ireq - # Reinstall if the direct URLs don't match. - if not link_matches_direct_url(cand_link, dist_direct_url): - return ireq + # Now we know both the installed distribution and incoming candidate + # are based on direct URLs, and neither are editable. Don't reinstall + # if the direct URLs match. + if link_matches_direct_url(cand_link, dist_direct_url): + return None - # If VCS, only reinstall if the resolved revisions don't match. + # One exception for VCS. Try to resolve the requested reference to see + # if it matches the commit ID recorded in the installed distribution. + # Reinstall only if the resolved commit IDs don't match. cand_vcs = vcs.get_backend_for_scheme(cand_link.scheme) dist_direct_info = dist_direct_url.info if cand_vcs and ireq.source_dir and isinstance(dist_direct_info, VcsInfo): candidate_rev = cand_vcs.get_revision(ireq.source_dir) if candidate_rev != dist_direct_info.commit_id: return ireq - - # Now we know both the installed distribution and incoming candidate - # are based on direct URLs, neither are editable nor VCS, and point to - # equivalent targets. They are probably the same, don't reinstall. return None def resolve( From 86a212466ee3bfbca9ed289e6504dbb5f1c2552b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sun, 24 Apr 2022 16:24:52 +0200 Subject: [PATCH 11/16] Add test for reinstallation from wheel cache --- tests/functional/test_install_direct_url.py | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/functional/test_install_direct_url.py b/tests/functional/test_install_direct_url.py index 02778f496af..819b61a3d67 100644 --- a/tests/functional/test_install_direct_url.py +++ b/tests/functional/test_install_direct_url.py @@ -3,6 +3,7 @@ from pip._internal.models.direct_url import VcsInfo from tests.lib import PipTestEnvironment, TestData, _create_test_package, path_to_url from tests.lib.direct_url import get_created_direct_url +from tests.lib.path import Path @pytest.mark.usefixtures("with_wheel") @@ -74,3 +75,37 @@ def test_reinstall_vcs_does_not_modify(script: PipTestEnvironment) -> None: result = script.pip("install", url) assert "Preparing " in result.stdout, str(result) # Should build. assert "Installing " not in result.stdout, str(result) # But not install. + + +@pytest.mark.network +@pytest.mark.usefixtures("with_wheel") +def test_reinstall_cached_vcs_does_modify( + script: PipTestEnvironment, tmpdir: Path +) -> None: + # Populate the wheel cache. + script.pip( + "wheel", + "--cache-dir", + tmpdir / "cache", + "--wheel-dir", + tmpdir / "wheelhouse", + "pip-test-package @ git+https://github.com/pypa/pip-test-package" + "@5547fa909e83df8bd743d3978d6667497983a4b7", + ) + # Install a version from git. + script.pip( + "install", + "--cache-dir", + tmpdir / "cache", + "pip-test-package @ git+https://github.com/pypa/pip-test-package@0.1.1", + ) + # Install the same version but from a different commit for which we have the wheel + # in cache, and verify that it does reinstall. + result = script.pip( + "install", + "--cache-dir", + tmpdir / "cache", + "pip-test-package @ git+https://github.com/pypa/pip-test-package" + "@5547fa909e83df8bd743d3978d6667497983a4b7", + ) + assert "Installing " in result.stdout, str(result) # Should install. From 15b39f7c4257b4f29f48e35749ce0d96be2fc0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sun, 24 Apr 2022 16:12:09 +0200 Subject: [PATCH 12/16] Simplify link vs direct url comparison This also resolves direct url comparison when the link was was found in the wheel cache. --- src/pip/_internal/models/direct_url.py | 22 ++++++ .../resolution/resolvelib/resolver.py | 20 ++--- src/pip/_internal/utils/direct_url_helpers.py | 45 +---------- tests/unit/test_direct_url.py | 76 +++++++++++++++++++ tests/unit/test_direct_url_helpers.py | 30 -------- 5 files changed, 106 insertions(+), 87 deletions(-) diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py index eb419428ef9..d753fa3885b 100644 --- a/src/pip/_internal/models/direct_url.py +++ b/src/pip/_internal/models/direct_url.py @@ -79,6 +79,11 @@ def __init__( self.requested_revision = requested_revision self.commit_id = commit_id + def equivalent(self, other: "InfoType") -> bool: + if not isinstance(other, VcsInfo): + return False + return self.vcs == other.vcs and self.commit_id == other.commit_id + @classmethod def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["VcsInfo"]: if d is None: @@ -112,6 +117,11 @@ def __init__( ) -> None: self.hash = hash + def equivalent(self, other: "InfoType") -> bool: + if not isinstance(other, ArchiveInfo): + return False + return self.hash == other.hash + @classmethod def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["ArchiveInfo"]: if d is None: @@ -134,6 +144,11 @@ def __init__( ) -> None: self.editable = editable + def equivalent(self, other: "InfoType") -> bool: + if not isinstance(other, DirInfo): + return False + return self.editable == other.editable + @classmethod def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["DirInfo"]: if d is None: @@ -161,6 +176,13 @@ def __init__( self.info = info self.subdirectory = subdirectory + def equivalent(self, other: "DirectUrl") -> bool: + return ( + self.url == other.url + and self.info.equivalent(other.info) + and self.subdirectory == other.subdirectory + ) + def __repr__(self) -> str: return ( f"DirectUrl(url={self.url!r}, info={self.info!r}, " diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index 87d709ae458..2019ae695b9 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -10,13 +10,11 @@ from pip._internal.cache import WheelCache from pip._internal.index.package_finder import PackageFinder -from pip._internal.models.direct_url import VcsInfo from pip._internal.operations.prepare import RequirementPreparer from pip._internal.req.req_install import InstallRequirement from pip._internal.req.req_set import RequirementSet from pip._internal.resolution.base import BaseResolver, InstallRequirementProvider -from pip._internal.utils.direct_url_helpers import link_matches_direct_url -from pip._internal.vcs.versioncontrol import vcs +from pip._internal.utils.direct_url_helpers import direct_url_from_link from .base import Candidate, Requirement from .factory import Factory @@ -146,19 +144,13 @@ def _get_ireq( # Now we know both the installed distribution and incoming candidate # are based on direct URLs, and neither are editable. Don't reinstall # if the direct URLs match. - if link_matches_direct_url(cand_link, dist_direct_url): + cand_direct_url = direct_url_from_link( + cand_link, ireq.source_dir, ireq.original_link_is_in_wheel_cache + ) + if cand_direct_url.equivalent(dist_direct_url): return None - # One exception for VCS. Try to resolve the requested reference to see - # if it matches the commit ID recorded in the installed distribution. - # Reinstall only if the resolved commit IDs don't match. - cand_vcs = vcs.get_backend_for_scheme(cand_link.scheme) - dist_direct_info = dist_direct_url.info - if cand_vcs and ireq.source_dir and isinstance(dist_direct_info, VcsInfo): - candidate_rev = cand_vcs.get_revision(ireq.source_dir) - if candidate_rev != dist_direct_info.commit_id: - return ireq - return None + return ireq def resolve( self, root_reqs: List[InstallRequirement], check_supported_wheels: bool diff --git a/src/pip/_internal/utils/direct_url_helpers.py b/src/pip/_internal/utils/direct_url_helpers.py index 5908ed653a4..0e8e5e1608b 100644 --- a/src/pip/_internal/utils/direct_url_helpers.py +++ b/src/pip/_internal/utils/direct_url_helpers.py @@ -1,7 +1,7 @@ -from typing import List, Optional +from typing import Optional from pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo -from pip._internal.models.link import Link, links_equivalent +from pip._internal.models.link import Link from pip._internal.utils.urls import path_to_url from pip._internal.vcs import vcs @@ -85,44 +85,3 @@ def direct_url_from_link( info=ArchiveInfo(hash=hash), subdirectory=link.subdirectory_fragment, ) - - -def _link_from_direct_url(direct_url: DirectUrl) -> Link: - """Create a link from given direct URL construct. - - This function is designed specifically for ``link_matches_direct_url``, and - does NOT losslessly reconstruct the original link that produced the - DirectUrl. Namely: - - * The auth part is ignored (since it does not affect link equivalency). - * Only "subdirectory" and hash fragment parts are considered, and the - ordering of the kept parts are not considered (since only their values - affect link equivalency). - - .. seealso:: ``pip._internal.models.link.links_equivalent()`` - """ - url = direct_url.url - hash_frag: Optional[str] = None - - direct_url_info = direct_url.info - if isinstance(direct_url_info, VcsInfo): - url = f"{direct_url_info.vcs}+{url}@{direct_url_info.commit_id}" - elif isinstance(direct_url_info, ArchiveInfo): - hash_frag = direct_url_info.hash - elif not isinstance(direct_url_info, DirInfo): - raise ValueError(f"Unsupported direct URL format: {direct_url_info!r}") - - fragment_parts: List[str] = [] - if direct_url.subdirectory is not None: - fragment_parts.append(f"subdirectory={direct_url.subdirectory}") - if hash_frag: - fragment_parts.append(hash_frag) - if fragment_parts: - fragment = "&".join(fragment_parts) - url = f"{url}#{fragment}" - - return Link(url) - - -def link_matches_direct_url(link: Link, direct_url: DirectUrl) -> bool: - return links_equivalent(link, _link_from_direct_url(direct_url)) diff --git a/tests/unit/test_direct_url.py b/tests/unit/test_direct_url.py index c81e5129253..ab6aee79623 100644 --- a/tests/unit/test_direct_url.py +++ b/tests/unit/test_direct_url.py @@ -129,3 +129,79 @@ def _redact_archive(url: str) -> str: == "https://${PIP_TOKEN}@g.c/u/p.git" ) assert _redact_git("ssh://git@g.c/u/p.git") == "ssh://git@g.c/u/p.git" + + +@pytest.mark.parametrize( + "direct_url, other_direct_url, expected", + [ + ( + DirectUrl(url="file:///some/dir", info=DirInfo(editable=False)), + DirectUrl(url="file:///some/dir", info=DirInfo(editable=False)), + True, + ), + ( + DirectUrl(url="file:///some/dir", info=DirInfo(editable=False)), + DirectUrl(url="file:///some/other/dir", info=DirInfo(editable=False)), + False, + ), + ( + DirectUrl(url="file:///some/dir", info=DirInfo(editable=True)), + DirectUrl(url="file:///some/dir", info=DirInfo(editable=False)), + False, + ), + ( + DirectUrl(url="file:///some/dir/a.tgz", info=ArchiveInfo()), + DirectUrl(url="file:///some/dir/a.tgz", info=ArchiveInfo(hash="abcd")), + False, + ), + ( + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abcd"), + ), + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abcd"), + ), + True, + ), + ( + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abcd"), + ), + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="v1", commit_id="abcd"), + ), + True, + ), + ( + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abcd"), + ), + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abce"), + ), + False, + ), + ( + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abcd"), + subdirectory="subdir", + ), + DirectUrl( + url="https://g.c/u/r", + info=VcsInfo(vcs="git", requested_revision="main", commit_id="abcd"), + ), + False, + ), + ], +) +def test_direct_url_equivalent( + direct_url: DirectUrl, other_direct_url: DirectUrl, expected: bool +) -> None: + assert direct_url.equivalent(other_direct_url) is expected diff --git a/tests/unit/test_direct_url_helpers.py b/tests/unit/test_direct_url_helpers.py index 5a68f6a7640..8d94aeb50b6 100644 --- a/tests/unit/test_direct_url_helpers.py +++ b/tests/unit/test_direct_url_helpers.py @@ -1,14 +1,11 @@ from functools import partial from unittest import mock -import pytest - from pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo from pip._internal.models.link import Link from pip._internal.utils.direct_url_helpers import ( direct_url_as_pep440_direct_reference, direct_url_from_link, - link_matches_direct_url, ) from pip._internal.utils.urls import path_to_url from tests.lib import PipTestEnvironment @@ -173,30 +170,3 @@ def test_from_link_hide_user_password() -> None: link_is_in_wheel_cache=True, ) assert direct_url.to_dict()["url"] == "ssh://git@g.c/u/p.git" - - -@pytest.mark.parametrize( - "link, direct_url", - [ - pytest.param( - Link("git+https://user:password@g.c/u/p.git@commit_id"), - DirectUrl( - "https://user:password@g.c/u/p.git", - VcsInfo("git", "commit_id", requested_revision="revision"), - ), - id="vcs", - ), - pytest.param( - Link("https://g.c/archive.tgz#hash=12345"), - DirectUrl("https://g.c/archive.tgz", ArchiveInfo("hash=12345")), - id="archive", - ), - pytest.param( - Link("file:///home/user/project"), - DirectUrl("file:///home/user/project", DirInfo(editable=False)), - id="dir", - ), - ], -) -def test_link_matches_direct_url(link: Link, direct_url: DirectUrl) -> None: - assert link_matches_direct_url(link, direct_url) From feb290087b8120bea96865db62d5aba0b3c0d654 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Tue, 3 May 2022 15:44:29 -0600 Subject: [PATCH 13/16] Notes on direct URL equivalent logic --- src/pip/_internal/models/direct_url.py | 6 ++++++ src/pip/_internal/resolution/resolvelib/resolver.py | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py index d753fa3885b..7f08cf8bdfb 100644 --- a/src/pip/_internal/models/direct_url.py +++ b/src/pip/_internal/models/direct_url.py @@ -177,6 +177,12 @@ def __init__( self.subdirectory = subdirectory def equivalent(self, other: "DirectUrl") -> bool: + """Whether two direct URL objects are equivalent. + + This is different from ``__eq__`` in that two non-equal infos can be + "logically the same", e.g. two different Git branches cab be equivalent + if they resolve to the same commit. + """ return ( self.url == other.url and self.info.equivalent(other.info) diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index 2019ae695b9..44a86e4be5b 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -143,9 +143,14 @@ def _get_ireq( # Now we know both the installed distribution and incoming candidate # are based on direct URLs, and neither are editable. Don't reinstall - # if the direct URLs match. + # if the direct URLs match. Note that there's a special case for VCS: a + # "unresolved" reference (e.g. branch) needs to be fully resolved for + # comparison, so an updated remote branch can trigger reinstallation. + # This is handled by the 'equivalent' implementation. cand_direct_url = direct_url_from_link( - cand_link, ireq.source_dir, ireq.original_link_is_in_wheel_cache + cand_link, + ireq.source_dir, + ireq.original_link_is_in_wheel_cache, ) if cand_direct_url.equivalent(dist_direct_url): return None From 28885d1e9c657f1ddae6a9649d87fb2424dc9837 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Fri, 11 Nov 2022 07:42:05 +0800 Subject: [PATCH 14/16] Use pathlib --- tests/functional/test_install_direct_url.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/functional/test_install_direct_url.py b/tests/functional/test_install_direct_url.py index e8eefffec84..106ebf39394 100644 --- a/tests/functional/test_install_direct_url.py +++ b/tests/functional/test_install_direct_url.py @@ -1,9 +1,10 @@ +import pathlib + import pytest from pip._internal.models.direct_url import VcsInfo from tests.lib import PipTestEnvironment, TestData, _create_test_package from tests.lib.direct_url import get_created_direct_url -from tests.lib.path import Path @pytest.mark.usefixtures("with_wheel") @@ -80,15 +81,15 @@ def test_reinstall_vcs_does_not_modify(script: PipTestEnvironment) -> None: @pytest.mark.network @pytest.mark.usefixtures("with_wheel") def test_reinstall_cached_vcs_does_modify( - script: PipTestEnvironment, tmpdir: Path + script: PipTestEnvironment, tmp_path: pathlib.Path ) -> None: # Populate the wheel cache. script.pip( "wheel", "--cache-dir", - tmpdir / "cache", + tmp_path.joinpath("cache").as_posix(), "--wheel-dir", - tmpdir / "wheelhouse", + tmp_path.joinpath("wheelhouse").as_posix(), "pip-test-package @ git+https://github.com/pypa/pip-test-package" "@5547fa909e83df8bd743d3978d6667497983a4b7", ) @@ -96,7 +97,7 @@ def test_reinstall_cached_vcs_does_modify( script.pip( "install", "--cache-dir", - tmpdir / "cache", + tmp_path.joinpath("cache").as_posix(), "pip-test-package @ git+https://github.com/pypa/pip-test-package@0.1.1", ) # Install the same version but from a different commit for which we have the wheel @@ -104,7 +105,7 @@ def test_reinstall_cached_vcs_does_modify( result = script.pip( "install", "--cache-dir", - tmpdir / "cache", + tmp_path.joinpath("cache").as_posix(), "pip-test-package @ git+https://github.com/pypa/pip-test-package" "@5547fa909e83df8bd743d3978d6667497983a4b7", ) From 32824c8e0c6f874c59c7f85548d8b10be67cc68f Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Fri, 11 Nov 2022 17:16:08 +0800 Subject: [PATCH 15/16] Improve handling of --upgrade and --editable Now --upgrade can trigger a non-URL requirement to upgrade an existing URL-installed distribution. Some logic is also tweaked so legacy editable installations (which lack direct_url.json) is also considered URL-specified and treated the same as non-editable direct URL and modern (PEP 660) editable installations. --- src/pip/_internal/metadata/base.py | 16 ++++++++++-- src/pip/_internal/models/direct_url.py | 5 ++-- .../resolution/resolvelib/resolver.py | 26 ++++++++++++------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/pip/_internal/metadata/base.py b/src/pip/_internal/metadata/base.py index cafb79fb3dc..2c03c16c869 100644 --- a/src/pip/_internal/metadata/base.py +++ b/src/pip/_internal/metadata/base.py @@ -295,8 +295,11 @@ def setuptools_filename(self) -> str: def direct_url(self) -> Optional[DirectUrl]: """Obtain a DirectUrl from this distribution. - Returns None if the distribution has no `direct_url.json` metadata, - or if `direct_url.json` is invalid. + Returns None if the distribution has no ``direct_url.json`` metadata, + or if the ``direct_url.json`` content is invalid. + + Note that this may return None for a legacy editable installation. See + also ``specified_by_url``. """ try: content = self.read_text(DIRECT_URL_METADATA_NAME) @@ -337,6 +340,15 @@ def requested(self) -> bool: def editable(self) -> bool: return bool(self.editable_project_location) + @property + def specified_by_url(self) -> bool: + """WHether the distribution was originally installed via a direct URL. + + This includes cases where the user used a path (since it is a shorthand + and internally treated very similarly as a URL). + """ + return self.direct_url is not None or self.editable + @property def local(self) -> bool: """If distribution is installed in the current virtual environment. diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py index 7f08cf8bdfb..3f87013b594 100644 --- a/src/pip/_internal/models/direct_url.py +++ b/src/pip/_internal/models/direct_url.py @@ -176,7 +176,7 @@ def __init__( self.info = info self.subdirectory = subdirectory - def equivalent(self, other: "DirectUrl") -> bool: + def equivalent(self, other: Optional["DirectUrl"]) -> bool: """Whether two direct URL objects are equivalent. This is different from ``__eq__`` in that two non-equal infos can be @@ -184,7 +184,8 @@ def equivalent(self, other: "DirectUrl") -> bool: if they resolve to the same commit. """ return ( - self.url == other.url + other is not None + and self.url == other.url and self.info.equivalent(other.info) and self.subdirectory == other.subdirectory ) diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index d4881fc5618..c1c617227c1 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -103,12 +103,21 @@ def _get_ireq( if cand_link is None: return None + # Whether --upgrade is specified. + upgrade_mode = self.upgrade_strategy != "to-satisfy-only" + # The incoming candidate was produced only from version requirements. - # Reinstall if the installed distribution's version does not match. + # Reinstall only if... if not direct_url_requested: - if installed_dist.version == candidate.version: - return None - return ireq + # The currently installed distribution does not satisfy the + # requested version specification. + if installed_dist.version != candidate.version: + return ireq + # The currently installed distribution was from a direct URL, and + # an upgrade is requested. + if upgrade_mode and installed_dist.specified_by_url: + return ireq + return None # At this point, the incoming candidate was produced from a direct URL. # Determine whether to upgrade based on flags and whether the installed @@ -119,15 +128,12 @@ def _get_ireq( return ireq # Reinstall if --upgrade is specified. - if self.upgrade_strategy != "to-satisfy-only": + if upgrade_mode: return ireq - # The PEP 610 direct URL representation of the installed distribution. - dist_direct_url = installed_dist.direct_url - # The incoming candidate was produced from a direct URL, but the # currently installed distribution was not. Always reinstall to be sure. - if dist_direct_url is None: + if not installed_dist.specified_by_url: return ireq # Editable candidate always triggers reinstallation. @@ -150,7 +156,7 @@ def _get_ireq( ireq.source_dir, ireq.original_link_is_in_wheel_cache, ) - if cand_direct_url.equivalent(dist_direct_url): + if cand_direct_url.equivalent(installed_dist.direct_url): return None return ireq From 9eabee48004785dc2d3fd746c894487f42d10a5e Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Fri, 11 Nov 2022 17:30:19 +0800 Subject: [PATCH 16/16] Clean up pathlib references --- tests/lib/wheel.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/lib/wheel.py b/tests/lib/wheel.py index 0da64b9d8ab..f2ddfd3b7e1 100644 --- a/tests/lib/wheel.py +++ b/tests/lib/wheel.py @@ -2,7 +2,6 @@ """ import csv import itertools -import pathlib from base64 import urlsafe_b64encode from collections import namedtuple from copy import deepcopy @@ -253,23 +252,23 @@ def __init__(self, name: str, files: Iterable[File]) -> None: self._name = name self._files = files - def save_to_dir(self, path: Union[pathlib.Path, Path, str]) -> str: + def save_to_dir(self, path: Union[Path, str]) -> str: """Generate wheel file with correct name and save into the provided directory. :returns the wheel file path """ - p = pathlib.Path(path) / self._name + p = Path(path) / self._name p.write_bytes(self.as_bytes()) return str(p) - def save_to(self, path: Union[pathlib.Path, Path, str]) -> str: + def save_to(self, path: Union[Path, str]) -> str: """Generate wheel file, saving to the provided path. Any parent directories must already exist. :returns the wheel file path """ - path = pathlib.Path(path) + path = Path(path) path.write_bytes(self.as_bytes()) return str(path)