Skip to content

Commit a449564

Browse files
SG-40319 SG-41241 new normalize_version_format() function and packaging.version bundled in tank_vendor (#1066)
1 parent 6c0fe83 commit a449564

14 files changed

Lines changed: 859 additions & 148 deletions

File tree

python/tank/util/version.py

Lines changed: 53 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,41 @@
77
# By accessing, using, copying or modifying this work you indicate your
88
# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights
99
# not expressly granted therein are reserved by Shotgun Software Inc.
10-
import warnings
1110
import contextlib
12-
import sys
13-
14-
LooseVersion = None
15-
try:
16-
import packaging.version
17-
except ModuleNotFoundError:
18-
try:
19-
# Try importing from setuptools.
20-
# If it fails, then we can't do much at the moment
21-
# The DCC should have either setuptools or packaging installed.
22-
from setuptools._distutils.version import LooseVersion
23-
except ModuleNotFoundError:
24-
try:
25-
# DCCs with older versions of Python 3.12
26-
from distutils.version import LooseVersion
27-
except ModuleNotFoundError:
28-
pass
11+
import warnings
12+
13+
from tank_vendor.packaging.version import parse as version_parse
2914

30-
from . import sgre as re
3115
from .. import LogManager
3216
from ..errors import TankError
33-
17+
from . import sgre as re
3418

3519
logger = LogManager.get_logger(__name__)
3620
GITHUB_HASH_RE = re.compile("^[0-9a-fA-F]{7,40}$")
3721

22+
# Normalize non-standard version formats
23+
# into PEP 440–compliant forms ("1.2.3") to ensure compatibility with
24+
# Python’s version parsing utilities (e.g., packaging.version.parse).
25+
# Reference: https://peps.python.org/pep-0440/
26+
_VERSION_PATTERNS = [
27+
( # Extract version from software names: "Software Name 21.0" -> "21.0"
28+
re.compile(r"^[a-zA-Z\s]+(\d+(?:\.\d+)*(?:v\d+(?:\.\d+)*)?)$"),
29+
r"\1",
30+
),
31+
( # Dot-v format: "6.3v6" -> "6.3.6"
32+
re.compile(r"^(\d+)\.(\d+)v(\d+)$"),
33+
r"\1.\2.\3",
34+
),
35+
( # Simple v format: "2019v0.1" -> "2019.0.1"
36+
re.compile(r"^(\d+)v(\d+(?:\.\d+)*)$"),
37+
r"\1.\2",
38+
),
39+
( # Service pack with/without dot: "2017.2sp1" or "2017.2.sp1" -> "2017.2.post1"
40+
re.compile(r"^(\d+(?:\.\d+)*)\.?(sp|hotfix|hf)(\d+)$"),
41+
r"\1.post\3",
42+
),
43+
]
44+
3845

3946
def is_version_head(version):
4047
"""
@@ -150,28 +157,29 @@ def suppress_known_deprecation():
150157
yield ctx
151158

152159

153-
def version_parse(version_string):
160+
def normalize_version_format(version: str) -> str:
154161
"""
155-
Parse a version string into a Version object. We also support LooseVersion
156-
for compatibility with older versions of Python.
162+
Normalize version strings by applying common format transformations.
163+
164+
This function exists because packaging.version.parse() follows PEP 440
165+
and cannot handle non-standard version formats like "v1.2.3" or "6.3v6",
166+
which are commonly found in various software tools and DCCs but don't
167+
conform to the PEP 440 specification.
157168
158-
:param str version_string: The version string to parse.
169+
Transformations applied:
170+
- Extract version numbers from software names: "Software Name 21.0" -> "21.0"
171+
- Convert dot-v format: "6.3v6" -> "6.3.6"
172+
- Convert simple v format: "2019v0.1" -> "2019.0.1"
173+
- Convert service pack formats: "2017.2sp1" -> "2017.2.post1", "2017.2.sp1" -> "2017.2.post1"
159174
160-
:rtype: packaging.version.Version, LooseVersion or str as fallback.
175+
:param str version: Version string to normalize
176+
:return str: Normalized version string compatible with PEP 440
161177
"""
162-
if "packaging" in sys.modules:
163-
try:
164-
return packaging.version.parse(version_string)
165-
except packaging.version.InvalidVersion:
166-
# Version cannot be parsed with packaging.version (SG-40480)
167-
pass
168178

169-
if LooseVersion:
170-
with suppress_known_deprecation():
171-
return LooseVersion(version_string)
179+
for compiled_pattern, replacement in _VERSION_PATTERNS:
180+
version = compiled_pattern.sub(replacement, version)
172181

173-
# Fallback to string comparison
174-
return version_string
182+
return version
175183

176184

177185
def _compare_versions(a, b):
@@ -205,79 +213,12 @@ def _compare_versions(a, b):
205213
# comparing against HEAD - our version is always old
206214
return False
207215

208-
if a.startswith("v"):
209-
a = a[1:]
210-
if b.startswith("v"):
211-
b = b[1:]
212-
213-
if "packaging" in sys.modules:
214-
version_a = version_parse(a)
215-
version_b = version_parse(b)
216-
if isinstance(version_a, str) or isinstance(version_b, str):
217-
return a > b
218-
219-
return version_a > version_b
220-
221-
if LooseVersion:
222-
# In Python 3, LooseVersion comparisons between versions where a non-numeric
223-
# version component is compared to a numeric one fail. We'll work around this
224-
# as follows:
225-
# First, try to use LooseVersion for comparison. This should work in
226-
# most cases.
227-
try:
228-
with suppress_known_deprecation():
229-
# Supress `distutils Version classes are deprecated.` for Python 3.10
230-
version_a = LooseVersion(a).version
231-
version_b = LooseVersion(b).version
232-
233-
version_num_a = []
234-
version_num_b = []
235-
# taking only the integers of the version to make comparison
236-
for version in version_a:
237-
if isinstance(version, (int)):
238-
version_num_a.append(version)
239-
elif version == "-":
240-
break
241-
for version in version_b:
242-
if isinstance(version, (int)):
243-
version_num_b.append(version)
244-
elif version == "-":
245-
break
246-
247-
# Comparing equal number versions with with one of them with '-' appended, if a version
248-
# has '-' appended it's older than the same version with '-' at the end
249-
if version_num_a == version_num_b:
250-
if "-" in a and "-" not in b:
251-
return False # False, version a is older than b
252-
elif "-" in b and "-" not in a:
253-
return True # True, version a is older than b
254-
else:
255-
return LooseVersion(a) > LooseVersion(
256-
b
257-
) # If both has '-' compare '-rcx' versions
258-
else:
259-
return LooseVersion(a) > LooseVersion(
260-
b
261-
) # If they are different numeric versions
262-
except TypeError:
263-
version_expr = re.compile(r"^((?:\d+)(?:\.\d+)*)(.+)$")
264-
match_a = version_expr.match(a)
265-
match_b = version_expr.match(b)
266-
if match_a and match_b:
267-
# If we could get two numeric versions, generate LooseVersions for
268-
# them.
269-
ver_a = LooseVersion(match_a.group(1))
270-
ver_b = LooseVersion(match_b.group(1))
271-
if ver_a != ver_b:
272-
# If they're not identical, return based on this comparison
273-
return ver_a > ver_b
274-
else:
275-
# If the numeric versions do match, do a string comparsion for
276-
# the rest.
277-
return match_a.group(2) > match_b.group(2)
278-
elif match_a or match_b:
279-
# If only one had a numeric version, treat that as the newer version.
280-
return bool(match_a)
281-
282-
# In the case that both versions are non-numeric, do a string comparison.
283-
return a > b
216+
a = normalize_version_format(a)
217+
b = normalize_version_format(b)
218+
219+
# Use packaging.version (either system or vendored)
220+
# This is now guaranteed to be available
221+
version_a = version_parse(a)
222+
version_b = version_parse(b)
223+
224+
return version_a > version_b
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
__title__ = "packaging"
6+
__summary__ = "Core utilities for Python packages"
7+
__uri__ = "https://github.com/pypa/packaging"
8+
9+
__version__ = "25.0"
10+
11+
__author__ = "Donald Stufft and individual contributors"
12+
__email__ = "donald@stufft.io"
13+
14+
__license__ = "BSD-2-Clause or Apache-2.0"
15+
__copyright__ = f"2014 {__author__}"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
6+
class InfinityType:
7+
def __repr__(self) -> str:
8+
return "Infinity"
9+
10+
def __hash__(self) -> int:
11+
return hash(repr(self))
12+
13+
def __lt__(self, other: object) -> bool:
14+
return False
15+
16+
def __le__(self, other: object) -> bool:
17+
return False
18+
19+
def __eq__(self, other: object) -> bool:
20+
return isinstance(other, self.__class__)
21+
22+
def __gt__(self, other: object) -> bool:
23+
return True
24+
25+
def __ge__(self, other: object) -> bool:
26+
return True
27+
28+
def __neg__(self: object) -> "NegativeInfinityType":
29+
return NegativeInfinity
30+
31+
32+
Infinity = InfinityType()
33+
34+
35+
class NegativeInfinityType:
36+
def __repr__(self) -> str:
37+
return "-Infinity"
38+
39+
def __hash__(self) -> int:
40+
return hash(repr(self))
41+
42+
def __lt__(self, other: object) -> bool:
43+
return True
44+
45+
def __le__(self, other: object) -> bool:
46+
return True
47+
48+
def __eq__(self, other: object) -> bool:
49+
return isinstance(other, self.__class__)
50+
51+
def __gt__(self, other: object) -> bool:
52+
return False
53+
54+
def __ge__(self, other: object) -> bool:
55+
return False
56+
57+
def __neg__(self: object) -> InfinityType:
58+
return Infinity
59+
60+
61+
NegativeInfinity = NegativeInfinityType()

python/tank_vendor/packaging/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)