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
111 changes: 44 additions & 67 deletions cassandra/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,57 +1692,50 @@ 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.
Representation of a Cassandra version. Mostly follows the implementation of the same logic in the Java driver;
see https://github.com/apache/cassandra-java-driver/blob/4.19.2/core/src/main/java/com/datastax/oss/driver/api/core/Version.java
"""

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

def __init__(self, version):
self._version = version
Comment on lines 1697 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.

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.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a fair point; I'll add an updated doc string back in.

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 {0} did not match expected format".format(version))

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 1714 to +1727
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.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm inclined to leave these in. The seq manipulation ops in _cleanup_int and _cleanup_str can throw and I don't want that to wreck the version parsing logic.


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

# Trim off the leading '.' or '~' characters and just return the string directly
def _cleanup_str(self, instr):
return instr[1:] if instr else 0

def __hash__(self):
return self._version
return hash((self.major, self.minor, self.patch, self.build, self.prerelease))

def __repr__(self):
version_string = "Version({0}, {1}, {2}".format(self.major, self.minor, self.patch)
Expand All @@ -1757,48 +1750,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)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I honestly didn't realize that would work @bschoening, thanks for mentioning that!

After looking at it for a bit, though, I'm kind of inclined to leave it as a sequence if/else strings. My only rationale is that it reads much more clearly to a casual reader. That might be a personal bias on my part because the tuple comparison syntax you reference was unfamiliar to me (and I'm open to reconsideration if that's the case) but the current version immediately conveys the intent behind the code on even a surface reading... which is probably beneficial.

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
96 changes: 56 additions & 40 deletions tests/unit/test_util_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,18 +209,13 @@ 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')),
('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)),
# 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')),
]

for str_version, expected_result in versions:
Expand All @@ -232,9 +227,17 @@ 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:
with self.assertRaises(ValueError):
Version(v)

def test_version_compare(self):
# just tests a bunch of versions
Expand All @@ -251,41 +254,54 @@ 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.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-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'))

equalTuples = [
(Version('4.0-SNAPSHOT'), Version('4.0-SNAPSHOT')),
(Version('4.0.0-SNAPSHOT'), Version('4.0-SNAPSHOT')),
(Version('4.0.0-SNAPSHOT'), Version('4.0.0-SNAPSHOT')),
(Version('4.0.0.5-SNAPSHOT'), Version('4.0.0.5-SNAPSHOT'))
]
for (a,b) in equalTuples:
self.assertEqual(a, b)
self.assertEqual(hash(a), hash(b))

leftgreaterTuples = [
(Version('4.0'), Version('4.0-SNAPSHOT')),
(Version('4.1-SNAPSHOT'), Version('4.0-SNAPSHOT')),
(Version('4.0.1-SNAPSHOT'), Version('4.0.0-SNAPSHOT')),
(Version('4.0.0.6-SNAPSHOT'), Version('4.0.0.5-SNAPSHOT')),
(Version('4.0-SNAPSHOT2'), Version('4.0-SNAPSHOT1')),
(Version('4.0-SNAPSHOT2'), Version('4.0.0-SNAPSHOT1')),
(Version('4.0.0-alpha1-SNAPSHOT'), Version('4.0.0-SNAPSHOT'))
]
for (a,b) in leftgreaterTuples:
self.assertGreater(a, b)
self.assertNotEqual(hash(a), hash(b))

# 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'))