Skip to content
Open
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
2 changes: 1 addition & 1 deletion cassandra/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3290,7 +3290,7 @@ def get_schema_parser(connection, server_version, dse_version, timeout):
elif v >= Version('6.0.0'):
return SchemaParserDSE60(connection, timeout)

if version >= Version('4-a'):
if version >= Version('4.0-alpha'):
return SchemaParserV4(connection, timeout)
elif version >= Version('3.0.0'):
return SchemaParserV3(connection, timeout)
Expand Down
109 changes: 41 additions & 68 deletions cassandra/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,54 +1692,43 @@ def __repr__(self):
self.lower_bound, self.upper_bound, self.value
)

VERSION_REGEX = re.compile("(\\d+)\\.(\\d+)(\\.\\d+)?(\\.\\d+)?([~\\-]\\w[.\\w]*(?:-\\w[.\\w]*)*)?(\\+[.\\w]+)?")

@total_ordering
class Version(object):
"""
Internal minimalist class to compare versions.
A valid version is: <int>.<int>.<int>.<int or str>.

TODO: when python2 support is removed, use packaging.version.
"""

_version = None
major = None
minor = 0
patch = 0
build = 0
prerelease = 0

def __init__(self, version):
self._version = version
Comment on lines 1697 to 1701
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

The new Version implementation removed the class docstring entirely. Since cassandra.util.Version is used outside this module (e.g. in cassandra.metadata and unit tests), it should keep an up-to-date docstring describing the supported version formats and comparison semantics.

Copilot uses AI. Check for mistakes.
if '-' in version:
version_without_prerelease, self.prerelease = version.split('-', 1)
else:
version_without_prerelease = version
parts = list(reversed(version_without_prerelease.split('.')))
if len(parts) > 4:
prerelease_string = "-{}".format(self.prerelease) if self.prerelease else ""
log.warning("Unrecognized version: {}. Only 4 components plus prerelease are supported. "
"Assuming version as {}{}".format(version, '.'.join(parts[:-5:-1]), prerelease_string))

match = VERSION_REGEX.match(version)
if not match:
raise ValueError("Version string did not match expected format")
Comment on lines +1695 to +1705
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

VERSION_REGEX.match(version) is not anchored, so it can accept only a prefix of the string and silently ignore trailing characters (e.g. 3.55.1.build12 will match 3.55.1). If the intent is strict validation, use re.fullmatch (or ^...$) so malformed versions raise. If some legacy/lenient parsing is desired, it would be safer to make that behavior explicit (e.g., a separate fallback parse with a warning) rather than relying on partial regex matches.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

The ValueError raised on parse failure is generic ("Version string did not match expected format") and omits the offending input. Including the actual version string in the message would make troubleshooting much easier.

Suggested change
raise ValueError("Version string did not match expected format")
raise ValueError("Version string did not match expected format: {!r}".format(version))

Copilot uses AI. Check for mistakes.

self.major = int(match[1])
self.minor = int(match[2])

try:
self.major = int(parts.pop())
except ValueError as e:
raise ValueError(
"Couldn't parse version {}. Version should start with a number".format(version))\
.with_traceback(e.__traceback__)
self.patch = self._cleanup_int(match[3])
except:
self.patch = 0

try:
self.minor = int(parts.pop()) if parts else 0
self.patch = int(parts.pop()) if parts else 0
self.build = self._cleanup_int(match[4])
except:
self.build = 0

if parts: # we have a build version
build = parts.pop()
try:
self.build = int(build)
except ValueError:
self.build = build
except ValueError:
assumed_version = "{}.{}.{}.{}-{}".format(self.major, self.minor, self.patch, self.build, self.prerelease)
log.warning("Unrecognized version {}. Assuming version as {}".format(version, assumed_version))
try:
self.prerelease = self._cleanup_str(match[5])
except:
self.prerelease = 0
Comment on lines 1710 to +1723
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Avoid bare except: here. These blocks will also swallow unexpected exceptions and make debugging harder. Since _cleanup_int/_cleanup_str already handle None, the try/except may be unnecessary; otherwise, catch specific exceptions (e.g. ValueError, TypeError) and consider whether an invalid numeric component should raise vs. default to 0.

Copilot uses AI. Check for mistakes.

# Trim off the leading '.' characters and convert the discovered value to an integer
def _cleanup_int(self, s):
return int(s[1:]) if s else 0

# Trim off the leading '.' or '~' characters and just return the string directly
def _cleanup_str(self, str):
return str[1:] if str else 0
Comment on lines +1730 to +1731
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

_cleanup_str uses a parameter named str, which shadows Python’s built-in str type. Rename the parameter to avoid shadowing.

Suggested change
def _cleanup_str(self, str):
return str[1:] if str else 0
def _cleanup_str(self, s):
return s[1:] if s else 0

Copilot uses AI. Check for mistakes.

def __hash__(self):
return self._version
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Version.__hash__ returns self._version (a str). In Python 3, __hash__ must return an int, and it also needs to be consistent with __eq__. As written, calling hash(Version(...)) will raise TypeError, and even if fixed to return a string hash, it would still violate the hash/eq contract for versions that compare equal but have different original strings (e.g. 4.0-SNAPSHOT vs 4.0.0-SNAPSHOT).

Suggested change
return self._version
# Hash based on the same components used for equality, to satisfy the hash/eq contract.
return hash((self.major, self.minor, self.patch, self.build, self.prerelease))

Copilot uses AI. Check for mistakes.
Expand All @@ -1757,48 +1746,32 @@ def __repr__(self):
def __str__(self):
return self._version

@staticmethod
def _compare_version_part(version, other_version, cmp):
if not (isinstance(version, int) and
isinstance(other_version, int)):
version = str(version)
other_version = str(other_version)

return cmp(version, other_version)

def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented

return (self.major == other.major and
self.minor == other.minor and
self.patch == other.patch and
self._compare_version_part(self.build, other.build, lambda s, o: s == o) and
self._compare_version_part(self.prerelease, other.prerelease, lambda s, o: s == o)
self.build == other.build and
self.prerelease == other.prerelease
)

def __gt__(self, other):
if not isinstance(other, Version):
return NotImplemented

is_major_ge = self.major >= other.major
is_minor_ge = self.minor >= other.minor
is_patch_ge = self.patch >= other.patch
is_build_gt = self._compare_version_part(self.build, other.build, lambda s, o: s > o)
is_build_ge = self._compare_version_part(self.build, other.build, lambda s, o: s >= o)

# By definition, a prerelease comes BEFORE the actual release, so if a version
# doesn't have a prerelease, it's automatically greater than anything that does
if self.prerelease and not other.prerelease:
is_prerelease_gt = False
if self.major != other.major:
return self.major > other.major
elif self.minor != other.minor:
return self.minor > other.minor
elif self.patch != other.patch:
return self.patch > other.patch
elif self.build != other.build:
return self.build > other.build
elif self.prerelease and not other.prerelease:
return False
Copy link
Contributor

@bschoening bschoening Mar 1, 2026

Choose a reason for hiding this comment

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

Python tuples can be compared directly, so something like this should work

def __gt__(self, other):
        # Compare as tuples
        return (self.major, self.minor, self.patch, self.build, not self.prerelease) <
               (other.major, other.minor, other.patch, other.build, not other.prerelease)

elif other.prerelease and not self.prerelease:
is_prerelease_gt = True
return True
else:
is_prerelease_gt = self._compare_version_part(self.prerelease, other.prerelease, lambda s, o: s > o) \

return (self.major > other.major or
(is_major_ge and self.minor > other.minor) or
(is_major_ge and is_minor_ge and self.patch > other.patch) or
(is_major_ge and is_minor_ge and is_patch_ge and is_build_gt) or
(is_major_ge and is_minor_ge and is_patch_ge and is_build_ge and is_prerelease_gt)
)
return self.prerelease > other.prerelease
73 changes: 44 additions & 29 deletions tests/unit/test_util_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,21 +209,25 @@ class VersionTests(unittest.TestCase):

def test_version_parsing(self):
versions = [
('2.0.0', (2, 0, 0, 0, 0)),
('3.1.0', (3, 1, 0, 0, 0)),
('2.4.54', (2, 4, 54, 0, 0)),
('3.1.1.12', (3, 1, 1, 12, 0)),
('3.55.1.build12', (3, 55, 1, 'build12', 0)),
('3.55.1.20190429-TEST', (3, 55, 1, 20190429, 'TEST')),
('4.0-SNAPSHOT', (4, 0, 0, 0, 'SNAPSHOT')),
# Test cases here adapted from the Java driver cases
# (https://github.com/apache/cassandra-java-driver/blob/4.19.2/core/src/test/java/com/datastax/oss/driver/api/core/VersionTest.java)
('1.2.19', (1, 2, 19, 0, 0)),
('1.2', (1, 2, 0, 0, 0)),
('1.2-beta1-SNAPSHOT', (1, 2, 0, 0, 'beta1-SNAPSHOT')),
('1.2~beta1-SNAPSHOT', (1, 2, 0, 0, 'beta1-SNAPSHOT')),
('1.2.19.2-SNAPSHOT', (1, 2, 19, 2, 'SNAPSHOT')),

# We also include a few test cases from the former impl of this class, mainly to note differences in behaviours

# Note that prerelease tags are expected to start with a hyphen or tilde so the expected tag is
# lost in all cases below
('3.55.1.build12', (3, 55, 1, 0, 0)),
('1.0.5.4.3', (1, 0, 5, 4, 0)),
('1-SNAPSHOT', (1, 0, 0, 0, 'SNAPSHOT')),
('4.0.1.2.3.4.5-ABC-123-SNAP-TEST.blah', (4, 0, 1, 2, 'ABC-123-SNAP-TEST.blah')),
('2.1.hello', (2, 1, 0, 0, 0)),
('2.test.1', (2, 0, 0, 0, 0)),
('2.1.hello', (2, 1, 0, 0, 0))
]

for str_version, expected_result in versions:
print(str_version)
v = Version(str_version)
Comment on lines 229 to 231
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Remove the print(...) statements from this unit test. They add noise to test output and can slow down/obscure CI logs; the asserts already provide sufficient failure context.

Copilot uses AI. Check for mistakes.
self.assertEqual(str_version, str(v))
self.assertEqual(v.major, expected_result[0])
Expand All @@ -232,9 +236,18 @@ def test_version_parsing(self):
self.assertEqual(v.build, expected_result[3])
self.assertEqual(v.prerelease, expected_result[4])

# not supported version formats
with self.assertRaises(ValueError):
Version('test.1.0')
# Note that a few of these formats used to be supported when this class was based on the Python versioning scheme.
# This has been updated to more directly correspond to the Cassandra versioning scheme. See CASSPYTHON-10 for more
# detail.
unsupported_versions = [
"test.1.0",
'2.test.1'
]

for v in unsupported_versions:
print(v)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Remove the print(v) statement inside the unsupported-version loop; tests should not emit output in normal operation.

Suggested change
print(v)

Copilot uses AI. Check for mistakes.
with self.assertRaises(ValueError):
Version(v)

def test_version_compare(self):
# just tests a bunch of versions
Expand All @@ -251,41 +264,43 @@ def test_version_compare(self):

# patch wins
self.assertTrue(Version('2.3.1') > Version('2.3.0'))
self.assertTrue(Version('2.3.1') > Version('2.3.0.4post0'))
self.assertTrue(Version('2.3.1') > Version('2.3.0-4post0'))
self.assertTrue(Version('2.3.1') > Version('2.3.0.44'))

# various
self.assertTrue(Version('2.3.0.1') > Version('2.3.0.0'))
self.assertTrue(Version('2.3.0.680') > Version('2.3.0.670'))
self.assertTrue(Version('2.3.0.681') > Version('2.3.0.680'))
self.assertTrue(Version('2.3.0.1build0') > Version('2.3.0.1')) # 4th part fallback to str cmp
self.assertTrue(Version('2.3.0.build0') > Version('2.3.0.1')) # 4th part fallback to str cmp
self.assertTrue(Version('2.3.0') < Version('2.3.0.build'))

self.assertTrue(Version('4-a') <= Version('4.0.0'))
self.assertTrue(Version('4-a') <= Version('4.0-alpha1'))
self.assertTrue(Version('4-a') <= Version('4.0-beta1'))
self.assertTrue(Version('4.0.0') >= Version('4.0.0'))
self.assertTrue(Version('4.0.0.421') >= Version('4.0.0'))
self.assertTrue(Version('4.0.1') >= Version('4.0.0'))

# If builds are equal then a prerelease always comes before
self.assertTrue(Version('2.3.0.1-SNAPSHOT') < Version('2.3.0.1'))

# If both have prereleases we fall back to a string compare
self.assertTrue(Version('2.3.0.1-SNAPSHOT') < Version('2.3.0.1-ZNAPSHOT'))

self.assertTrue(Version('2.3.0') == Version('2.3.0'))
self.assertTrue(Version('2.3.32') == Version('2.3.32'))
self.assertTrue(Version('2.3.32') == Version('2.3.32.0'))
self.assertTrue(Version('2.3.0.build') == Version('2.3.0.build'))
self.assertTrue(Version('2.3.0-SNAPSHOT') == Version('2.3.0-SNAPSHOT'))

self.assertTrue(Version('4') == Version('4.0.0'))
self.assertTrue(Version('4.0') == Version('4.0.0.0'))
self.assertTrue(Version('4.0') > Version('3.9.3'))

self.assertTrue(Version('4.0') > Version('4.0-SNAPSHOT'))
self.assertTrue(Version('4.0-SNAPSHOT') == Version('4.0-SNAPSHOT'))
self.assertTrue(Version('4.0.0-SNAPSHOT') == Version('4.0-SNAPSHOT'))
self.assertTrue(Version('4.0.0-SNAPSHOT') == Version('4.0.0-SNAPSHOT'))
self.assertTrue(Version('4.0.0.build5-SNAPSHOT') == Version('4.0.0.build5-SNAPSHOT'))
self.assertTrue(Version('4.0.0.5-SNAPSHOT') == Version('4.0.0.5-SNAPSHOT'))
Comment on lines 289 to +293
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Given the new normalization behavior (e.g. Version('4.0.0-SNAPSHOT') == Version('4.0-SNAPSHOT')), add a unit test that hash() works and is consistent with equality for equivalent versions. This would catch regressions where __hash__ raises or returns inconsistent values.

Copilot uses AI. Check for mistakes.
self.assertTrue(Version('4.1-SNAPSHOT') > Version('4.0-SNAPSHOT'))
self.assertTrue(Version('4.0.1-SNAPSHOT') > Version('4.0.0-SNAPSHOT'))
self.assertTrue(Version('4.0.0.build6-SNAPSHOT') > Version('4.0.0.build5-SNAPSHOT'))
self.assertTrue(Version('4.0.0.6-SNAPSHOT') > Version('4.0.0.5-SNAPSHOT'))
self.assertTrue(Version('4.0-SNAPSHOT2') > Version('4.0-SNAPSHOT1'))
self.assertTrue(Version('4.0-SNAPSHOT2') > Version('4.0.0-SNAPSHOT1'))

self.assertTrue(Version('4.0.0-alpha1-SNAPSHOT') > Version('4.0.0-SNAPSHOT'))

# Test the version limit for v4 schema parsing in cassandra.metadata to make sure
# all 4.0.x Cassandra servers are covered
self.assertTrue(Version('4.0-alpha') <= Version('4.0.0'))
self.assertTrue(Version('4.0-alpha') <= Version('4.0-alpha1'))
self.assertTrue(Version('4.0-alpha') <= Version('4.0-beta1'))