Skip to content
Draft
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
13 changes: 13 additions & 0 deletions benchmarks/specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions benchmarks/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/packaging/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/packaging/pylock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
)
Expand Down
2 changes: 1 addition & 1 deletion src/packaging/specifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/packaging/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}"
Expand Down
53 changes: 51 additions & 2 deletions src/packaging/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Any,
Callable,
Literal,
MutableMapping,
NamedTuple,
SupportsInt,
Tuple,
Expand Down Expand Up @@ -111,15 +112,15 @@ 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')
<Version('1.0.dev1')>

: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):
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading