Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Changelog
*unreleased*
~~~~~~~~~~~~

No unreleased changes.
Fixes:

* Restore ``Version._version`` as a compatibility shim with a ``DeprecationWarning`` (:pull:`1062`)

26.0rc3 - 2026-01-15
~~~~~~~~~~~~~~~~~~~~
Expand Down
63 changes: 62 additions & 1 deletion src/packaging/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,46 @@
import re
import sys
import typing
from typing import Any, Callable, Literal, SupportsInt, Tuple, TypedDict, Union
from typing import (
Any,
Callable,
Literal,
NamedTuple,
SupportsInt,
Tuple,
TypedDict,
Union,
)

from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType

if typing.TYPE_CHECKING:
from typing_extensions import Self, Unpack

if sys.version_info >= (3, 13): # pragma: no cover
from warnings import deprecated as _deprecated
elif typing.TYPE_CHECKING:
from typing_extensions import deprecated as _deprecated
else: # pragma: no cover
import functools
import warnings

def _deprecated(message: str) -> object:
def decorator(func: object) -> object:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> object:
warnings.warn(
message,
category=DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)

return wrapper

return decorator


_LETTER_NORMALIZATION = {
"alpha": "a",
"beta": "b",
Expand Down Expand Up @@ -264,6 +297,16 @@ def _validate_local(value: object, /) -> LocalType | None:
raise InvalidVersion(msg)


# Backward compatibility for internals before 26.0. Do not use.
class _Version(NamedTuple):
epoch: int
release: tuple[int, ...]
dev: tuple[str, int] | None
pre: tuple[str, int] | None
post: tuple[str, int] | None
local: LocalType | None


class Version(_BaseVersion):
"""This class abstracts handling of a project's versions.

Expand Down Expand Up @@ -374,6 +417,24 @@ def _key(self) -> CmpKey:
)
return self._key_cache

@property
@_deprecated("Version._version is private and will be removed soon")
def _version(self) -> _Version:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need three deprecates? Won't it trigger twice now when you access Version._version?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted type checkers to complain when _Version is accessed directly or _version is used. Yes, I think you do get two runtime warnings, though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can keep the double warning from occurring:

diff --git a/src/packaging/version.py b/src/packaging/version.py
index 4ddd897..5891335 100644
--- a/src/packaging/version.py
+++ b/src/packaging/version.py
@@ -12,6 +12,7 @@ from __future__ import annotations
 import re
 import sys
 import typing
+import warnings
 from typing import (
     Any,
     Callable,
@@ -34,7 +35,6 @@ elif typing.TYPE_CHECKING:
     from typing_extensions import deprecated
 else:  # pragma: no cover
     import functools
-    import warnings

     def deprecated(message: str) -> object:
         def decorator(func: object) -> object:
@@ -421,9 +421,11 @@ class Version(_BaseVersion):
     @property
     @deprecated("Version._version is private and will be removed soon")
     def _version(self) -> _Version:
-        return _Version(
-            self._epoch, self._release, self._dev, self._pre, self._post, self._local
-        )
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", DeprecationWarning)
+            return _Version(
+                self._epoch, self._release, self._dev, self._pre, self._post, self._local
+            )

     @_version.setter
     @deprecated("Version._version is private and will be removed soon")
diff --git a/tests/test_version.py b/tests/test_version.py
index efbcfc0..344a7fa 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -1022,7 +1022,7 @@ def reset_version_parts(version: Version, **kwargs: typing.Any) -> None:  # noqa
 # These will be deprecated in 26.1, and removed in the future
 def test_deprecated__version() -> None:
     v = Version("1.2.3")
-    with pytest.warns(DeprecationWarning, match="is private"):
+    with pytest.warns(DeprecationWarning, match="_version is private"):
         assert v._version.release == (1, 2, 3)

But hatchling's usage will still trigger twice because it is also creating a _Version object directly. And the above patch is not great for threaded code.

If we had #1011 I could just not list _Version in __all__, which would make type checkers not see it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I was thinking this was just for runtime warnings, I hadn't considered type checkers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll revert adding @deprecated to _Version for now, I think the __all__ approach later will be better.

@notatallshaw notatallshaw Jan 15, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we had #1011 I could just not list _Version in all, which would make type checkers not see it.

Yeah, if that causes type checkers to error I would rather do that than causing warnings multiple times, it's not really deprecated anyway, as it was never public.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'm not positive type checkers have implemented this yet, but the idea was they could, and that's why it's in typing_extensions)

#1011 gives us an actual public interface that's not just underscores. Type checkers respect __all__.

return _Version(
self._epoch, self._release, self._dev, self._pre, self._post, self._local
)

@_version.setter
@_deprecated("Version._version is private and will be removed soon")
def _version(self, value: _Version) -> None:
self._epoch = value.epoch
self._release = value.release
self._dev = value.dev
self._pre = value.pre
self._post = value.post
self._local = value.local
self._key_cache = None

def __repr__(self) -> str:
"""A representation of the Version that shows all internal state.

Expand Down
34 changes: 34 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,3 +997,37 @@ def test_replace_invalid_local_string(self) -> None:
InvalidVersion, match="local must be a valid version string"
):
replace(v, local="+abc")


# Taken from hatchling 1.28
def reset_version_parts(version: Version, **kwargs: typing.Any) -> None: # noqa: ANN401
# https://github.com/pypa/packaging/blob/20.9/packaging/version.py#L301-L310
internal_version = version._version
parts: dict[str, typing.Any] = {}
ordered_part_names = ("epoch", "release", "pre", "post", "dev", "local")

reset = False
for part_name in ordered_part_names:
if reset:
parts[part_name] = kwargs.get(part_name)
elif part_name in kwargs:
parts[part_name] = kwargs[part_name]
reset = True
else:
parts[part_name] = getattr(internal_version, part_name)

version._version = type(internal_version)(**parts)


# These will be deprecated in 26.1, and removed in the future
def test_deprecated__version() -> None:
v = Version("1.2.3")
with pytest.warns(DeprecationWarning, match="is private"):
assert v._version.release == (1, 2, 3)


def test_hatchling_usage__version() -> None:
v = Version("2.3.4")
with pytest.warns(DeprecationWarning, match="is private"):
reset_version_parts(v, post=("post", 1))
assert v == Version("2.3.4.post1")
Loading