diff --git a/benchmarks/specifiers.py b/benchmarks/specifiers.py index 7e0cdccc4..074f8716a 100644 --- a/benchmarks/specifiers.py +++ b/benchmarks/specifiers.py @@ -21,6 +21,12 @@ def setup(self) -> None: with (DIR / "specs_sample.txt").open() as f: self.spec_strs = [s.strip() for s in f.readlines()] + # enable version caching if the feature is available + try: + Version.set_cache({}) + except AttributeError: + pass + # Build and warm versions self.single_version = Version("3.12") self.simple_versions = [Version(str(i / 10)) for i in range(1, 11)] @@ -55,6 +61,13 @@ def setup(self) -> None: for sp in self._warm_compatible._specs: sp.contains(self.complex_versions[0]) + def teardown(self) -> None: + # disable version caching if the feature is available (therefore was enabled) + try: + Version.set_cache(None) + except AttributeError: + pass + def _make_cold(self, spec: SpecifierSet) -> None: if hasattr(spec, "_canonicalized"): spec._canonicalized = False diff --git a/benchmarks/version.py b/benchmarks/version.py index 9c1fa0c6e..3320d2876 100644 --- a/benchmarks/version.py +++ b/benchmarks/version.py @@ -23,6 +23,7 @@ def setup(self) -> None: with (DIR / "version_sample.txt").open() as f: self.versions = [v.strip() for v in f.readlines()] self.valid_versions = [v for v in self.versions if valid_version(v)] + self.unique_versions = list(set(self.versions)) self.version_objects_cold = [Version(v) for v in self.valid_versions] self.version_objects_warm = [Version(v) for v in self.valid_versions] for v in self.version_objects_warm: @@ -36,6 +37,56 @@ def time_constructor(self) -> None: except InvalidVersion: # noqa: PERF203 pass + @add_attributes(pretty_name="cached Version constructor") + def time_cached(self) -> None: + """ + A duplicate of the time_constructor test, but with a dict cache enabled if the + set_cache method is available. + + If not, fall-back to just being a duplicate of the constructor test. + """ + # try to do the cached version + try: + Version.set_cache({}) + + for v in self.versions: + try: + Version.cached(v) + except InvalidVersion: # noqa: PERF203 + pass + + Version.set_cache(None) + + # when caching is not available as a feature, compare against plain init + except AttributeError: + for v in self.versions: + try: + Version(v) + except InvalidVersion: # noqa: PERF203 + pass + + @add_attributes(pretty_name="cached Version constructor (unique versions)") + def time_cached_unique(self) -> None: + # try to do the cached version + try: + Version.set_cache({}) + + for v in self.unique_versions: + try: + Version.cached(v) + except InvalidVersion: # noqa: PERF203 + pass + + Version.set_cache(None) + + # when caching is not available as a feature, compare against plain init + except AttributeError: + for v in self.unique_versions: + try: + Version(v) + except InvalidVersion: # noqa: PERF203 + pass + @add_attributes(pretty_name="Version hash") def time_hash(self) -> None: for v in self.valid_versions: diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index 967a932f9..4bc04387c 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -600,7 +600,7 @@ def _process_version(self, value: str) -> version_module.Version: if not value: raise self._invalid_metadata("{field} is a required field") try: - return version_module.parse(value) + return version_module.Version.cached(value) except version_module.InvalidVersion as exc: raise self._invalid_metadata( f"{value!r} is invalid for {{field}}", cause=exc diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 96aa35c6d..df0c34c3c 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -146,7 +146,12 @@ def _get_as( if (value := _get(d, expected_type, key)) is None: return None try: - return target_type(value) + if isinstance(target_type, type) and issubclass(target_type, Version): + return target_type.cached(value) # type: ignore[arg-type,return-value] + else: + # mypy incorrectly narrows to `object` here, rather than retaining + # target_type's original type + return target_type(value) # type: ignore[call-arg,return-value] except Exception as e: raise PylockValidationError(e, context=key) from e @@ -664,7 +669,7 @@ def _from_dict(cls, d: Mapping[str, Any]) -> Self: packages=_get_required_sequence_of_objects(d, Package, "packages"), tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract] ) - if not Version("1") <= pylock.lock_version < Version("2"): + if not Version.cached("1") <= pylock.lock_version < Version.cached("2"): raise PylockUnsupportedVersionError( f"pylock version {pylock.lock_version} is not supported" ) diff --git a/src/packaging/specifiers.py b/src/packaging/specifiers.py index 0d55cad5f..0211a37f6 100644 --- a/src/packaging/specifiers.py +++ b/src/packaging/specifiers.py @@ -40,7 +40,7 @@ def __dir__() -> list[str]: def _coerce_version(version: UnparsedVersion) -> Version | None: if not isinstance(version, Version): try: - version = Version(version) + version = Version.cached(version) except InvalidVersion: return None return version diff --git a/src/packaging/utils.py b/src/packaging/utils.py index 9d66be888..fa9f9ec26 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -148,7 +148,7 @@ def canonicalize_version( """ if isinstance(version, str): try: - version = Version(version) + version = Version.cached(version) except InvalidVersion: return str(version) return str(_TrimmedRelease(version) if strip_trailing_zero else version) @@ -270,7 +270,7 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: name = canonicalize_name(name_part) try: - version = Version(version_part) + version = Version.cached(version_part) except InvalidVersion as e: raise InvalidSdistFilename( f"Invalid sdist filename (invalid version): {filename!r}" diff --git a/src/packaging/version.py b/src/packaging/version.py index af000eea6..f48e3e70e 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -16,6 +16,7 @@ Any, Callable, Literal, + MutableMapping, NamedTuple, SupportsInt, Tuple, @@ -111,7 +112,7 @@ def normalize_pre(letter: str, /) -> str: def parse(version: str) -> Version: """Parse the given version string. - This is identical to the :class:`Version` constructor. + This is identical to the :meth:`Version.cached` constructor. >>> parse('1.0.dev1') @@ -119,7 +120,7 @@ def parse(version: str) -> Version: :param version: The version string to parse. :raises InvalidVersion: When the version string is not a valid version. """ - return Version(version) + return Version.cached(version) class InvalidVersion(ValueError): @@ -391,6 +392,8 @@ class Version(_BaseVersion): _hash_cache: int | None _key_cache: CmpKey | None + _construction_cache: MutableMapping[str, Self] | None = None + def __init__(self, version: str) -> None: """Initialize a Version object. @@ -435,6 +438,52 @@ def __init__(self, version: str) -> None: self._key_cache = None self._hash_cache = None + @classmethod + def set_cache(cls, cache: MutableMapping[str, Self] | None) -> None: + """ + Set the cache to use for ``Version.cached``. + + Set the cache to ``None`` to disable caching if it was previously enabled. + + Note that no locking is performed around cache accesses, as lock contention may + be more costly for multithreaded applications than occasionally duplicated work. + Callers may use locks around ``Version.cached`` construction or use + sophisticated types for ``cache``, depending on their needs. + + >>> from packaging.version import Version + >>> Version.set_cache({}) + >>> ver1 = Version.cached("1.0.1") + >>> ver2 = Version.cached("1.0.1") + >>> ver1 is ver2 + True + + :param cache: A mutable mapping (e.g., a ``dict`` instance) to use as a cache, + or ``None`` to disable caching. + """ + cls._construction_cache = cache + + @classmethod + def cached(cls, version: str) -> Self: + """ + A new or cached version, as parsed from the version string. + If the cache is not enabled, a new version is constructed and returned. + + The cache can be used to return cached objects (and therefore skip re-parsing) + when ``Version`` objects are instantiated from strings. + + Internally, ``packaging`` uses ``Version.cached()`` for all version parsing. + """ + construction_cache = cls._construction_cache + if construction_cache is None: + return cls(version) + + version_obj = construction_cache.get(version, None) + if version_obj is None: + version_obj = cls(version) + construction_cache[version] = version_obj + + return version_obj + @classmethod def from_parts( cls, diff --git a/tests/test_version.py b/tests/test_version.py index 3843f8fd5..03eaa609e 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1235,3 +1235,22 @@ def test_hatchling_usage__version() -> None: def test_from_parts(args: dict[str, typing.Any], string: str) -> None: v = Version.from_parts(**args) assert v == Version(string) + + +def test_version_construction_with_dict_cache_produces_identical_results( + request: pytest.FixtureRequest, +) -> None: + # at the end of the test, no matter what, ensure the cache is cleared + request.addfinalizer(lambda: Version.set_cache(None)) + + # enable caching + Version.set_cache({}) + x = Version.cached("1.0") + y = Version.cached("1.0") + assert x is y # caching worked! + + # disable caching + Version.set_cache(None) + x2 = Version.cached("1.0") + y2 = Version.cached("1.0") + assert x2 is not y2 # caching was not inappropriately used