From 972a262ffac8665f0da53879e565b37b3f1611b4 Mon Sep 17 00:00:00 2001 From: absurdfarce Date: Wed, 25 Feb 2026 16:19:04 -0600 Subject: [PATCH 1/5] Initial cut at a new version impl that should be more compatible with Cassandra versions --- cassandra/util.py | 64 ++++++++++++++------------------- tests/unit/test_util_types.py | 67 ++++++++++++++++++++--------------- 2 files changed, 64 insertions(+), 67 deletions(-) diff --git a/cassandra/util.py b/cassandra/util.py index f973912574..bc1862c5bd 100644 --- a/cassandra/util.py +++ b/cassandra/util.py @@ -1692,54 +1692,42 @@ 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: .... - - 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 - 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") + print(match.groups()) + + 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(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(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_prerelease(match[5]) + except: + self.prerelease = 0 + + def _cleanup(self, s): + return int(s[1:]) if s else 0 + + def _cleanup_prerelease(self, str): + return str[1:] if str else 0 def __hash__(self): return self._version diff --git a/tests/unit/test_util_types.py b/tests/unit/test_util_types.py index 779d416923..d328eda036 100644 --- a/tests/unit/test_util_types.py +++ b/tests/unit/test_util_types.py @@ -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) self.assertEqual(str_version, str(v)) self.assertEqual(v.major, expected_result[0]) @@ -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) + with self.assertRaises(ValueError): + Version(v) def test_version_compare(self): # just tests a bunch of versions @@ -251,29 +264,25 @@ 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')) @@ -281,10 +290,10 @@ def test_version_compare(self): 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')) 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')) From 9195f760f84c1934eb54d7fe4dc48ee49bd87a26 Mon Sep 17 00:00:00 2001 From: absurdfarce Date: Wed, 25 Feb 2026 16:35:51 -0600 Subject: [PATCH 2/5] Re-implement current functionality to be a bit more concise --- cassandra/util.py | 56 ++++++++++++++++++----------------------------- 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/cassandra/util.py b/cassandra/util.py index bc1862c5bd..e52d557252 100644 --- a/cassandra/util.py +++ b/cassandra/util.py @@ -1709,24 +1709,26 @@ def __init__(self, version): self.minor = int(match[2]) try: - self.patch = self._cleanup(match[3]) + self.patch = self._cleanup_int(match[3]) except: self.patch = 0 try: - self.build = self._cleanup(match[4]) + self.build = self._cleanup_int(match[4]) except: self.build = 0 try: - self.prerelease = self._cleanup_prerelease(match[5]) + self.prerelease = self._cleanup_str(match[5]) except: self.prerelease = 0 - def _cleanup(self, s): + # 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 - def _cleanup_prerelease(self, str): + # Trim off the leading '.' or '~' characters and just return the string directly + def _cleanup_str(self, str): return str[1:] if str else 0 def __hash__(self): @@ -1745,15 +1747,6 @@ 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 @@ -1761,32 +1754,25 @@ def __eq__(self, other): 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 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 From 99a95ed15d8313877393cb5b51ddf30aa865ec2b Mon Sep 17 00:00:00 2001 From: absurdfarce Date: Wed, 25 Feb 2026 18:17:52 -0600 Subject: [PATCH 3/5] Removing some debugging --- cassandra/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cassandra/util.py b/cassandra/util.py index e52d557252..7af8b1ec98 100644 --- a/cassandra/util.py +++ b/cassandra/util.py @@ -1703,7 +1703,6 @@ def __init__(self, version): match = VERSION_REGEX.match(version) if not match: raise ValueError("Version string did not match expected format") - print(match.groups()) self.major = int(match[1]) self.minor = int(match[2]) From d63ec96368a5628be8742c4f9451476bedcc1719 Mon Sep 17 00:00:00 2001 From: absurdfarce Date: Wed, 25 Feb 2026 23:22:12 -0600 Subject: [PATCH 4/5] Update metadata version check to do the right thing re: 4.0 versions (with new versioning scheme) --- cassandra/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 2c13f92e42..0b403bc324 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -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) From 21c19ac55421afcf39b59e83c571544277fecc38 Mon Sep 17 00:00:00 2001 From: absurdfarce Date: Wed, 25 Feb 2026 23:24:33 -0600 Subject: [PATCH 5/5] Test updates to validate change to cassandra.metadata --- tests/unit/test_util_types.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_util_types.py b/tests/unit/test_util_types.py index d328eda036..35d6147591 100644 --- a/tests/unit/test_util_types.py +++ b/tests/unit/test_util_types.py @@ -298,3 +298,9 @@ def test_version_compare(self): 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'))