diff --git a/news/12316.bugfix.rst b/news/12316.bugfix.rst new file mode 100644 index 00000000000..525cea34766 --- /dev/null +++ b/news/12316.bugfix.rst @@ -0,0 +1 @@ +Significantly improve performance when installing hundreds of packages diff --git a/src/pip/_internal/metadata/importlib/_dists.py b/src/pip/_internal/metadata/importlib/_dists.py index 26370facf28..185f6b00cc1 100644 --- a/src/pip/_internal/metadata/importlib/_dists.py +++ b/src/pip/_internal/metadata/importlib/_dists.py @@ -102,6 +102,7 @@ def __init__( self._dist = dist self._info_location = info_location self._installed_location = installed_location + self._cached_canonical_name: Optional[NormalizedName] = None @classmethod def from_directory(cls, directory: str) -> BaseDistribution: @@ -169,8 +170,10 @@ def _get_dist_name_from_location(self) -> Optional[str]: @property def canonical_name(self) -> NormalizedName: - name = self._get_dist_name_from_location() or get_dist_name(self._dist) - return canonicalize_name(name) + if self._cached_canonical_name is None: + name = self._get_dist_name_from_location() or get_dist_name(self._dist) + self._cached_canonical_name = canonicalize_name(name) + return self._cached_canonical_name @property def version(self) -> DistributionVersion: diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py index 67737a5092f..0d2932b9986 100644 --- a/src/pip/_internal/resolution/resolvelib/candidates.py +++ b/src/pip/_internal/resolution/resolvelib/candidates.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, FrozenSet, Iterable, Optional, Tuple, Union, cast from pip._vendor.packaging.utils import NormalizedName, canonicalize_name -from pip._vendor.packaging.version import Version +from pip._vendor.packaging.version import parse_version from pip._internal.exceptions import ( HashError, @@ -271,7 +271,7 @@ def __init__( assert name == wheel_name, f"{name!r} != {wheel_name!r} for wheel" # Version may not be present for PEP 508 direct URLs if version is not None: - wheel_version = Version(wheel.version) + wheel_version = parse_version(wheel.version) assert version == wheel_version, "{!r} != {!r} for wheel {}".format( version, wheel_version, name ) @@ -349,6 +349,7 @@ def __init__( # TODO: Supply reason based on force_reinstall and upgrade_strategy. skip_reason = "already satisfied" factory.preparer.prepare_installed_requirement(self._ireq, skip_reason) + self._name: Optional[NormalizedName] = None def __str__(self) -> str: return str(self.dist) @@ -369,7 +370,9 @@ def __eq__(self, other: Any) -> bool: @property def project_name(self) -> NormalizedName: - return self.dist.canonical_name + if self._name is None: + self._name = self.dist.canonical_name + return self._name @property def name(self) -> str: @@ -565,7 +568,7 @@ def __init__(self, py_version_info: Optional[Tuple[int, ...]]) -> None: version_info = normalize_version_info(py_version_info) else: version_info = sys.version_info[:3] - self._version = Version(".".join(str(c) for c in version_info)) + self._version = parse_version(".".join(str(c) for c in version_info)) # We don't need to implement __eq__() and __ne__() since there is always # only one RequiresPythonCandidate in a resolution, i.e. the host Python. diff --git a/src/pip/_vendor/packaging/specifiers.py b/src/pip/_vendor/packaging/specifiers.py index 0e218a6f9f7..550e680147f 100644 --- a/src/pip/_vendor/packaging/specifiers.py +++ b/src/pip/_vendor/packaging/specifiers.py @@ -22,7 +22,7 @@ ) from .utils import canonicalize_version -from .version import LegacyVersion, Version, parse +from .version import LegacyVersion, Version, parse, parse_legacy_version, parse_version ParsedVersion = Union[Version, LegacyVersion] UnparsedVersion = Union[Version, LegacyVersion, str] @@ -143,7 +143,7 @@ def _get_operator(self, op: str) -> CallableOperator: def _coerce_version(self, version: UnparsedVersion) -> ParsedVersion: if not isinstance(version, (LegacyVersion, Version)): - version = parse(version) + return parse(version) return version @property @@ -180,7 +180,7 @@ def contains( # Determine if we should be supporting prereleases in this specifier # or not, if we do not support prereleases than we can short circuit # logic if this version is a prereleases. - if normalized_item.is_prerelease and not prereleases: + if not prereleases and normalized_item.is_prerelease: return False # Actually do the comparison to determine if this item is contained @@ -260,7 +260,7 @@ def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: def _coerce_version(self, version: UnparsedVersion) -> LegacyVersion: if not isinstance(version, LegacyVersion): - version = LegacyVersion(str(version)) + version = parse_legacy_version(str(version)) return version def _compare_equal(self, prospective: LegacyVersion, spec: str) -> bool: @@ -432,7 +432,7 @@ def _compare_equal(self, prospective: ParsedVersion, spec: str) -> bool: # We need special logic to handle prefix matching if spec.endswith(".*"): # In the case of prefix matching we want to ignore local segment. - prospective = Version(prospective.public) + prospective = parse_version(prospective.public) # Split the spec out by dots, and pretend that there is an implicit # dot in between a release segment and a pre-release segment. split_spec = _version_split(spec[:-2]) # Remove the trailing .* @@ -456,13 +456,13 @@ def _compare_equal(self, prospective: ParsedVersion, spec: str) -> bool: return padded_prospective == padded_spec else: # Convert our spec string into a Version - spec_version = Version(spec) + spec_version = parse_version(spec) # If the specifier does not have a local segment, then we want to # act as if the prospective version also does not have a local # segment. if not spec_version.local: - prospective = Version(prospective.public) + prospective = parse_version(prospective.public) return prospective == spec_version @@ -476,7 +476,7 @@ def _compare_less_than_equal(self, prospective: ParsedVersion, spec: str) -> boo # NB: Local version identifiers are NOT permitted in the version # specifier, so local version labels can be universally removed from # the prospective version. - return Version(prospective.public) <= Version(spec) + return parse_version(prospective.public) <= parse_version(spec) @_require_version_compare def _compare_greater_than_equal( @@ -486,14 +486,14 @@ def _compare_greater_than_equal( # NB: Local version identifiers are NOT permitted in the version # specifier, so local version labels can be universally removed from # the prospective version. - return Version(prospective.public) >= Version(spec) + return parse_version(prospective.public) >= parse_version(spec) @_require_version_compare def _compare_less_than(self, prospective: ParsedVersion, spec_str: str) -> bool: # Convert our spec to a Version instance, since we'll want to work with # it as a version. - spec = Version(spec_str) + spec = parse_version(spec_str) # Check to see if the prospective version is less than the spec # version. If it's not we can short circuit and just return False now @@ -506,7 +506,7 @@ def _compare_less_than(self, prospective: ParsedVersion, spec_str: str) -> bool: # versions for the version mentioned in the specifier (e.g. <3.1 should # not match 3.1.dev0, but should match 3.0.dev0). if not spec.is_prerelease and prospective.is_prerelease: - if Version(prospective.base_version) == Version(spec.base_version): + if parse_version(prospective.base_version) == parse_version(spec.base_version): return False # If we've gotten to here, it means that prospective version is both @@ -519,7 +519,7 @@ def _compare_greater_than(self, prospective: ParsedVersion, spec_str: str) -> bo # Convert our spec to a Version instance, since we'll want to work with # it as a version. - spec = Version(spec_str) + spec = parse_version(spec_str) # Check to see if the prospective version is greater than the spec # version. If it's not we can short circuit and just return False now @@ -532,13 +532,13 @@ def _compare_greater_than(self, prospective: ParsedVersion, spec_str: str) -> bo # post-release versions for the version mentioned in the specifier # (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0). if not spec.is_postrelease and prospective.is_postrelease: - if Version(prospective.base_version) == Version(spec.base_version): + if parse_version(prospective.base_version) == parse_version(spec.base_version): return False # Ensure that we do not allow a local version of the version mentioned # in the specifier, which is technically greater than, to match. if prospective.local is not None: - if Version(prospective.base_version) == Version(spec.base_version): + if parse_version(prospective.base_version) == parse_version(spec.base_version): return False # If we've gotten to here, it means that prospective version is both diff --git a/src/pip/_vendor/packaging/utils.py b/src/pip/_vendor/packaging/utils.py index bab11b80c60..55cbccf148c 100644 --- a/src/pip/_vendor/packaging/utils.py +++ b/src/pip/_vendor/packaging/utils.py @@ -2,11 +2,12 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +import functools import re from typing import FrozenSet, NewType, Tuple, Union, cast from .tags import Tag, parse_tag -from .version import InvalidVersion, Version +from .version import parse_version, InvalidVersion, Version BuildTag = Union[Tuple[()], Tuple[int, str]] NormalizedName = NewType("NormalizedName", str) @@ -29,12 +30,14 @@ class InvalidSdistFilename(ValueError): _build_tag_regex = re.compile(r"(\d+)(.*)") +@functools.lru_cache(maxsize=4096) def canonicalize_name(name: str) -> NormalizedName: # This is taken from PEP 503. value = _canonicalize_regex.sub("-", name).lower() return cast(NormalizedName, value) +@functools.lru_cache(maxsize=4096) def canonicalize_version(version: Union[Version, str]) -> str: """ This is very similar to Version.__str__, but has one subtle difference @@ -42,7 +45,7 @@ def canonicalize_version(version: Union[Version, str]) -> str: """ if isinstance(version, str): try: - parsed = Version(version) + parsed = parse_version(version) except InvalidVersion: # Legacy versions cannot be normalized return version diff --git a/src/pip/_vendor/packaging/version.py b/src/pip/_vendor/packaging/version.py index de9a09a4ed3..bd79e1ad39e 100644 --- a/src/pip/_vendor/packaging/version.py +++ b/src/pip/_vendor/packaging/version.py @@ -3,6 +3,7 @@ # for complete details. import collections +import functools import itertools import re import warnings @@ -10,7 +11,7 @@ from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType -__all__ = ["parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN"] +__all__ = ["parse", "parse_legacy_version", "parse_version", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN"] InfiniteTypes = Union[InfinityType, NegativeInfinityType] PrePostDevType = Union[InfiniteTypes, Tuple[str, int]] @@ -38,7 +39,6 @@ "_Version", ["epoch", "release", "dev", "pre", "post", "local"] ) - def parse(version: str) -> Union["LegacyVersion", "Version"]: """ Parse the given version string and return either a :class:`Version` object @@ -46,11 +46,26 @@ def parse(version: str) -> Union["LegacyVersion", "Version"]: a valid PEP 440 version or a legacy version. """ try: - return Version(version) + return parse_version(version) except InvalidVersion: - return LegacyVersion(version) + return parse_legacy_version(version) + + +@functools.lru_cache(maxsize=4096) +def parse_legacy_version(version: str) -> "LegacyVersion": + """ + Parse the given version string and return a :class:`LegacyVersion` object. + """ + return LegacyVersion(version) +@functools.lru_cache(maxsize=4096) +def parse_version(version: str) -> "Version": + """ + Parse the given version string and return a :class:`Version` object. + """ + return Version(version) + class InvalidVersion(ValueError): """ An invalid version was found, users should refer to PEP 440. @@ -287,10 +302,19 @@ def __init__(self, version: str) -> None: self._version.local, ) + # Pre-compute the version string since + # its almost always called anyways and + # we want to cache it to avoid the cost + # of re-computing it. + self._str = self._version_string() + def __repr__(self) -> str: return f"" def __str__(self) -> str: + return self._str + + def _version_string(self) -> str: parts = [] # Epoch @@ -367,15 +391,15 @@ def base_version(self) -> str: @property def is_prerelease(self) -> bool: - return self.dev is not None or self.pre is not None + return self._version.dev is not None or self._version.pre is not None @property def is_postrelease(self) -> bool: - return self.post is not None + return self._version.post is not None @property def is_devrelease(self) -> bool: - return self.dev is not None + return self._version.dev is not None @property def major(self) -> int: