diff --git a/tools/distribution/release.py b/tools/distribution/release.py index 25972e53a9..3fc2d3da3f 100755 --- a/tools/distribution/release.py +++ b/tools/distribution/release.py @@ -16,6 +16,7 @@ from src.git import clone_repo from src.assets.gh_asset import GHAsset from src.assets.podspec import CPPodspec +from src.semver import Version DD_SDK_IOS_REPO_SSH = 'git@github.com:DataDog/dd-sdk-ios.git' DD_SDK_IOS_REPO_NAME = 'dd-sdk-ios' @@ -83,6 +84,8 @@ f'- overwrite_github = {overwrite_github}\n' f'- dry_run = {dry_run}.') + print(f'🛠️ Git tag read to version: {Version.parse(git_tag)}') + publish_to_gh = not only_cocoapods publish_to_cp = not only_github diff --git a/tools/distribution/src/assets/gh_asset.py b/tools/distribution/src/assets/gh_asset.py index 26ac10ff70..091bce3858 100644 --- a/tools/distribution/src/assets/gh_asset.py +++ b/tools/distribution/src/assets/gh_asset.py @@ -11,15 +11,136 @@ import glob from tempfile import TemporaryDirectory, NamedTemporaryFile from src.utils import remember_cwd, shell, read_sdk_version +from src.directory_matcher import DirectoryMatcher +from src.semver import Version -SWIFT_CONTENT = [ - 'Datadog.xcframework', - 'DatadogObjc.xcframework', - 'DatadogCrashReporting.xcframework', - 'Kronos.xcframework', + +class XCFrameworkValidator: + name: str + + def should_be_included(self, in_version: Version) -> bool: + pass + + def validate(self, zip_directory: DirectoryMatcher): + pass + + +class DatadogXCFrameworkValidator(XCFrameworkValidator): + name = 'Datadog.xcframework' + + def should_be_included(self, in_version: Version): + return True # always expect `Datadog.xcframework` + + def validate(self, zip_directory: DirectoryMatcher): + zip_directory.get('Datadog.xcframework').assert_it_has_files([ + 'ios-arm64', + 'ios-arm64/BCSymbolMaps/*.bcsymbolmap', + 'ios-arm64/dSYMs/*.dSYM', + 'ios-arm64/**/arm64.swiftinterface', + 'ios-arm64/**/arm64-apple-ios.swiftinterface', + + 'ios-arm64_x86_64-simulator', + 'ios-arm64_x86_64-simulator/dSYMs/*.dSYM', + 'ios-arm64_x86_64-simulator/**/arm64.swiftinterface', + 'ios-arm64_x86_64-simulator/**/arm64-apple-ios-simulator.swiftinterface', + 'ios-arm64_x86_64-simulator/**/x86_64.swiftinterface', + 'ios-arm64_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface', + ]) + + +class DatadogObjcXCFrameworkValidator(XCFrameworkValidator): + name = 'DatadogObjc.xcframework' + + def should_be_included(self, in_version: Version): + return True # always expect `DatadogObjc.xcframework` + + def validate(self, zip_directory: DirectoryMatcher): + zip_directory.get('DatadogObjc.xcframework').assert_it_has_files([ + 'ios-arm64', + 'ios-arm64/BCSymbolMaps/*.bcsymbolmap', + 'ios-arm64/dSYMs/*.dSYM', + 'ios-arm64/**/arm64.swiftinterface', + 'ios-arm64/**/arm64-apple-ios.swiftinterface', + + 'ios-arm64_x86_64-simulator', + 'ios-arm64_x86_64-simulator/**/arm64.swiftinterface', + 'ios-arm64_x86_64-simulator/**/arm64-apple-ios-simulator.swiftinterface', + 'ios-arm64_x86_64-simulator/**/x86_64.swiftinterface', + 'ios-arm64_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface', + ]) + + +class DatadogCrashReportingXCFrameworkValidator(XCFrameworkValidator): + name = 'DatadogCrashReporting.xcframework' + + def should_be_included(self, in_version: Version): + min_version = Version.parse('1.7.0') # Datadog Crash Reporting.xcframework was introduced in `1.7.0` + return in_version.is_newer_than_or_equal(min_version) + + def validate(self, zip_directory: DirectoryMatcher): + zip_directory.get('DatadogCrashReporting.xcframework').assert_it_has_files([ + 'ios-arm64', + 'ios-arm64/BCSymbolMaps/*.bcsymbolmap', + 'ios-arm64/**/arm64.swiftinterface', + 'ios-arm64/**/arm64-apple-ios.swiftinterface', + + 'ios-arm64_x86_64-simulator', + 'ios-arm64_x86_64-simulator/dSYMs/*.dSYM', + 'ios-arm64_x86_64-simulator/**/x86_64.swiftinterface', + 'ios-arm64_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface', + ]) + + +class CrashReporterXCFrameworkValidator(XCFrameworkValidator): + name = 'CrashReporter.xcframework' + + def should_be_included(self, in_version: Version): + min_version = Version.parse('1.7.0') # Datadog Crash Reporting.xcframework was introduced in `1.7.0` + return in_version.is_newer_than_or_equal(min_version) + + def validate(self, zip_directory: DirectoryMatcher): + zip_directory.get('CrashReporter.xcframework').assert_it_has_files([ + 'ios-arm64_arm64e_armv7_armv7s', + 'ios-arm64_i386_x86_64-simulator', + ]) + + +class KronosXCFrameworkValidator(XCFrameworkValidator): + name = 'Kronos.xcframework' + + def should_be_included(self, in_version: Version): + min_version = Version.parse('1.5.0') # First version that depends on Kronos + max_version = Version.parse('1.8.99') # Last version that depends on Kronos + return in_version.is_newer_than_or_equal(min_version) and max_version.is_newer_than_or_equal(in_version) + + def validate(self, zip_directory: DirectoryMatcher): + zip_directory.get('Kronos.xcframework').assert_it_has_files([ + 'ios-arm64_armv7', + 'ios-arm64_armv7/BCSymbolMaps/*.bcsymbolmap', + 'ios-arm64_armv7/dSYMs/*.dSYM', + 'ios-arm64_armv7/**/arm.swiftinterface', + 'ios-arm64_armv7/**/arm64-apple-ios.swiftinterface', + 'ios-arm64_armv7/**/arm64.swiftinterface', + 'ios-arm64_armv7/**/armv7-apple-ios.swiftinterface', + 'ios-arm64_armv7/**/armv7.swiftinterface', + + 'ios-arm64_i386_x86_64-simulator', + 'ios-arm64_i386_x86_64-simulator/dSYMs/*.dSYM', + 'ios-arm64_i386_x86_64-simulator/**/arm64-apple-ios-simulator.swiftinterface', + 'ios-arm64_i386_x86_64-simulator/**/i386-apple-ios-simulator.swiftinterface', + 'ios-arm64_i386_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface', + 'ios-arm64_i386_x86_64-simulator/**/x86_64.swiftinterface', + ]) + + +xcframeworks_validators: [XCFrameworkValidator] = [ + DatadogXCFrameworkValidator(), + DatadogObjcXCFrameworkValidator(), + DatadogCrashReportingXCFrameworkValidator(), + CrashReporterXCFrameworkValidator(), + KronosXCFrameworkValidator(), ] -OBJC_CONTENT = ['CrashReporter.xcframework'] -EXPECTED_ZIP_CONTENT = SWIFT_CONTENT + OBJC_CONTENT + class GHAsset: """ @@ -34,7 +155,7 @@ def __init__(self): with NamedTemporaryFile(mode='w+', prefix='dd-gh-distro-', suffix='.xcconfig') as xcconfig: xcconfig.write('BUILD_LIBRARY_FOR_DISTRIBUTION = YES\n') - xcconfig.seek(0) # without this line, content isn't actually written + xcconfig.seek(0) # without this line, content isn't actually written os.environ['XCODE_XCCONFIG_FILE'] = xcconfig.name # Produce XCFrameworks with carthage: @@ -55,16 +176,7 @@ def __init__(self): def __repr__(self): return f'[GHAsset: path = {self.__path}]' - def __content_with_swiftinterface(self, dir: str) -> set: - # e.g: /TMP_DIR/X.xcframework/ios-arm64/X.framework/Modules/X.swiftmodule/arm64.swiftinterface - swiftinterfaces = glob.iglob(f'{dir}/*.xcframework/**/*.framework/Modules/*.swiftmodule/*.swiftinterface', recursive=True) - # e.g: X.xcframework/ios-arm64/X.framework/Modules/X.swiftmodule/arm64.swiftinterface - relative_paths = [abs_path.removeprefix(dir + '/') for abs_path in swiftinterfaces] - # e.g: X.xcframework - product_names = [rel_path[0:rel_path.find('/')] for rel_path in relative_paths] - return set(product_names) - - def validate(self, git_tag: str): + def validate(self, git_tag: str): # 1.5.0 """ Checks the `.zip` archive integrity with given `git_tag`. """ @@ -79,27 +191,28 @@ def validate(self, git_tag: str): # Inspect the content of zip archive: with TemporaryDirectory() as unzip_dir: shell(f'unzip -q {self.__path} -d {unzip_dir}') - actual_files = os.listdir(unzip_dir) - expected_files = EXPECTED_ZIP_CONTENT - actual_files.sort(), expected_files.sort() - - if set(actual_files) != set(expected_files): - raise Exception(f'The content of `.zip` archive is not correct: \n' - f' - actual {actual_files}\n' - f' - expected: {expected_files}') - - missing_swiftinterface_content = set(SWIFT_CONTENT).difference(self.__content_with_swiftinterface(unzip_dir)) - if missing_swiftinterface_content: - raise Exception(f'Frameworks missing .swiftinterface: \n {missing_swiftinterface_content} \n') - - print(f' → the content of `.zip` archive is correct: \n' - f' - actual: {actual_files}\n' - f' - expected: {expected_files}') - - print(f' → details on bundled `XCFrameworks`:') - for file_path in glob.iglob(f'{unzip_dir}/*.xcframework/*', recursive=True): + + print(f' → GH asset (zip) content:') + for file_path in glob.iglob(f'{unzip_dir}/**', recursive=True): print(f' - {file_path.removeprefix(unzip_dir)}') + dm = DirectoryMatcher(path=unzip_dir) + this_version = Version.parse(git_tag) + + print(f' → Validating each `XCFramework`:') + validated_count = 0 + for validator in xcframeworks_validators: + if validator.should_be_included(in_version=this_version): + validator.validate(zip_directory=dm) + print(f' → {validator.name} - OK') + validated_count += 1 + else: + print(f' → {validator.name} - SKIPPING for {this_version}') + + dm.assert_number_of_files(expected_count=validated_count) # assert there are no other files + + print(f' → the content of `.zip` archive is correct') + def publish(self, git_tag: str, overwrite_existing: bool, dry_run: bool): """ Uploads the `.zip` archive to GH Release for given `git_tag`. diff --git a/tools/distribution/src/directory_matcher.py b/tools/distribution/src/directory_matcher.py new file mode 100644 index 0000000000..0fbec2648e --- /dev/null +++ b/tools/distribution/src/directory_matcher.py @@ -0,0 +1,42 @@ +# ----------------------------------------------------------- +# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019-2020 Datadog, Inc. +# ----------------------------------------------------------- + +import os +import glob + + +class DirectoryMatcherException(Exception): + pass + + +class DirectoryMatcher: + path: str + + def __init__(self, path: str): + if os.path.exists(path): + self.path = path + else: + raise DirectoryMatcherException(f'Directory does not exist: {path}') + + def assert_number_of_files(self, expected_count: int): + actual_count = len(os.listdir(self.path)) + if expected_count != actual_count: + raise DirectoryMatcherException(f'Expected {expected_count} files in "{self.path}", but ' + f'found {actual_count} instead.') + + def assert_it_has_file(self, file_path: str): + search_path = os.path.join(self.path, file_path) + result = list(glob.iglob(search_path, recursive=True)) + + if not result: + raise DirectoryMatcherException(f'Expected "{self.path}" to include {file_path}, but it is missing.') + + def assert_it_has_files(self, file_paths: [str]): + for file_path in file_paths: + self.assert_it_has_file(file_path) + + def get(self, file: str) -> 'DirectoryMatcher': + return DirectoryMatcher(path=os.path.join(self.path, file)) diff --git a/tools/distribution/src/semver.py b/tools/distribution/src/semver.py new file mode 100644 index 0000000000..3608da2ccc --- /dev/null +++ b/tools/distribution/src/semver.py @@ -0,0 +1,112 @@ +# ----------------------------------------------------------- +# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019-2020 Datadog, Inc. +# ----------------------------------------------------------- + +# This file is a copied and enhanced version of `tools/nightly-unit-tests/src/semver.py` +# TODO: RUMM-1860 Share this code between both tools + +import re +from dataclasses import dataclass +from typing import Optional + +main_regex = r'([0-9]+)(\.[0-9]+)?(\.[0-9]+)?' # regex describing the main version component, e.g. '1.3.2' +pre_release_regex = r'-(alpha|beta|rc)([0-9]+)' # regex describing the pre-release version component, e.g. '-alpha3' + + +class VersionParsingException(Exception): + pass + + +@dataclass +class PreRelease: + identifier: str # 'alpha' | 'beta' | 'rc' + iteration: int # iteration of version within given identifier, e.g. 2 for 2nd 'beta' + + def __repr__(self): + return f'-{self.identifier}.{self.iteration}' + + def is_newer_than(self, other_version: 'PreRelease'): + grades = {'alpha': 1, 'beta': 2, 'rc': 3} + + if grades[self.identifier] > grades[other_version.identifier]: + return True + elif grades[self.identifier] == grades[other_version.identifier]: + if self.iteration > other_version.iteration: + return True + + return False + + +@dataclass +class Version: + major: int + minor: int + patch: int + pre_release: Optional[PreRelease] # optional pre-release version, e.g. '1.0.0-alpha3' has PreRelease('alpha', 3) + + def __repr__(self): + pre_release_repr = '' if not self.pre_release else f'{self.pre_release}' + if self.patch == 0: + return f'{self.major}.{self.minor}{pre_release_repr}' + else: + return f'{self.major}.{self.minor}.{self.patch}{pre_release_repr}' + + @staticmethod + def parse(string: str): + """ + Reads `Version` from string like '1.5' or '1.3.1-beta3', where '1.5' and 1.3.1' are main + version components and '-beta3' is a pre-release component. + """ + regex = re.compile(f'^(?P{main_regex})(?P{pre_release_regex})?$') + match = re.match(regex, string) + + if not match: + raise VersionParsingException(f'Invalid version string: {string} - not matching `{regex}`') + + if m_string := match.groupdict().get('m'): + pr = None + + if pr_string := match.groupdict().get('pr'): + if pr_match := re.match(re.compile(pre_release_regex), pr_string): + pr = PreRelease( + identifier=pr_match[1], + iteration=int(pr_match[2]) + ) + else: + raise VersionParsingException(f'Invalid pre-release version string: {pr_string}') + + if m_match := re.match(re.compile(main_regex), m_string): + return Version( + major=0 if not m_match[1] else int(m_match[1]), + minor=0 if not m_match[2] else int(m_match[2][1:]), + patch=0 if not m_match[3] else int(m_match[3][1:]), + pre_release=pr + ) + else: + raise VersionParsingException(f'Invalid main version string: {m_string}') + else: + raise VersionParsingException(f'Invalid version string: {string}') + + def is_newer_than(self, other_version: 'Version'): + if self.major > other_version.major: + return True + elif self.major == other_version.major: + if self.minor > other_version.minor: + return True + elif self.minor == other_version.minor: + if self.patch > other_version.patch: + return True + elif self.patch == other_version.patch: + if self.pre_release and other_version.pre_release: + return self.pre_release.is_newer_than(other_version.pre_release) + elif self.pre_release and not other_version.pre_release: + return False + elif not self.pre_release and other_version.pre_release: + return True + + return False + + def is_newer_than_or_equal(self, other_version: 'Version'): + return self.is_newer_than(other_version=other_version) or self == other_version diff --git a/tools/distribution/tests/test_directory_matcher.py b/tools/distribution/tests/test_directory_matcher.py new file mode 100644 index 0000000000..e9b8f39c5d --- /dev/null +++ b/tools/distribution/tests/test_directory_matcher.py @@ -0,0 +1,93 @@ +# ----------------------------------------------------------- +# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019-2020 Datadog, Inc. +# ----------------------------------------------------------- + + +import unittest +import os +from tempfile import TemporaryDirectory +from src.directory_matcher import DirectoryMatcher, DirectoryMatcherException + + +class DirectoryMatcherTestCase(unittest.TestCase): + def test_initializing(self): + with TemporaryDirectory() as tmp_dir: + self.assertEqual(DirectoryMatcher(path=tmp_dir).path, tmp_dir) + + with self.assertRaises(DirectoryMatcherException): + _ = DirectoryMatcher(path=f'{tmp_dir}/non-existing-path') + + def test_number_of_files(self): + with TemporaryDirectory() as tmp_dir: + dm = DirectoryMatcher(path=tmp_dir) + dm.assert_number_of_files(expected_count=0) + + os.mkdir(os.path.join(tmp_dir, '1')) + os.mkdir(os.path.join(tmp_dir, '2')) + dm.assert_number_of_files(expected_count=2) + + with self.assertRaises(DirectoryMatcherException): + dm.assert_number_of_files(expected_count=4) + + def test_has_file(self): + with TemporaryDirectory() as tmp_dir: + dm = DirectoryMatcher(path=tmp_dir) + os.makedirs(os.path.join(tmp_dir, '1/1A/1AA.xyz')) + os.makedirs(os.path.join(tmp_dir, '1/1A/1AB.xyz')) + os.makedirs(os.path.join(tmp_dir, '2/2A/2AA/foo.xyz')) + os.makedirs(os.path.join(tmp_dir, '2/2A/2AB/foo.xyz')) + + dm.assert_it_has_file('1') + dm.assert_it_has_file('1/1A/1AA.xyz') + dm.assert_it_has_file('1/1A/1AB.xyz') + dm.assert_it_has_file('**/1AA.xyz') + dm.assert_it_has_file('**/1AB.xyz') + dm.assert_it_has_file('**/*.xyz') + dm.assert_it_has_file('**/2A/**/*.xyz') + dm.assert_it_has_file('2') + + with self.assertRaises(DirectoryMatcherException): + dm.assert_it_has_file('foo') + + with self.assertRaises(DirectoryMatcherException): + dm.assert_it_has_file('1A') + + def test_has_files(self): + with TemporaryDirectory() as tmp_dir: + dm = DirectoryMatcher(path=tmp_dir) + os.makedirs(os.path.join(tmp_dir, '1/1A/1AA.xyz')) + os.makedirs(os.path.join(tmp_dir, '1/1A/1AB.xyz')) + os.makedirs(os.path.join(tmp_dir, '2/2A/2AA/foo.xyz')) + os.makedirs(os.path.join(tmp_dir, '2/2A/2AB/foo.xyz')) + + dm.assert_it_has_files([ + '1', + '1/1A/1AA.xyz', + '1/1A/1AB.xyz', + '**/1AA.xyz', + '**/1AB.xyz', + '**/*.xyz', + '**/2A/**/*.xyz', + '2', + ]) + + with self.assertRaises(DirectoryMatcherException): + dm.assert_it_has_files(file_paths=['foo', 'bar']) + + with self.assertRaises(DirectoryMatcherException): + dm.assert_it_has_files(file_paths=['2', '**/foo']) + + def test_get_submatcher(self): + with TemporaryDirectory() as tmp_dir: + os.makedirs(os.path.join(tmp_dir, '1/1A')) + + dm = DirectoryMatcher(path=tmp_dir) + dm.assert_it_has_file('1') + + dm = dm.get('1') + dm.assert_it_has_file('1A') + + with self.assertRaises(DirectoryMatcherException): + dm.assert_it_has_file('1') diff --git a/tools/distribution/tests/test_semver.py b/tools/distribution/tests/test_semver.py new file mode 100644 index 0000000000..0144ef0a80 --- /dev/null +++ b/tools/distribution/tests/test_semver.py @@ -0,0 +1,68 @@ +# ----------------------------------------------------------- +# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019-2020 Datadog, Inc. +# ----------------------------------------------------------- + +# This file is a copied and enhanced version of `tools/nightly-unit-tests/tests/test_semver.py` +# TODO: RUMM-1860 Share this code between both tools + +import unittest +from src.semver import Version, PreRelease, VersionParsingException + + +class VersionTestCase(unittest.TestCase): + def test_parsing(self): + self.assertEqual(Version.parse('10.0.3'), Version(major=10, minor=0, patch=3, pre_release=None)) + self.assertEqual(Version.parse('11.4'), Version(major=11, minor=4, patch=0, pre_release=None)) + self.assertEqual(Version.parse('12'), Version(major=12, minor=0, patch=0, pre_release=None)) + self.assertEqual( + Version.parse('10.0.3-alpha3'), + Version(major=10, minor=0, patch=3, pre_release=PreRelease(identifier='alpha', iteration=3)) + ) + self.assertEqual( + Version.parse('10.0.3-rc2'), + Version(major=10, minor=0, patch=3, pre_release=PreRelease(identifier='rc', iteration=2)) + ) + self.assertEqual( + Version.parse('10.0.3-beta12'), + Version(major=10, minor=0, patch=3, pre_release=PreRelease(identifier='beta', iteration=12)) + ) + + def test_invalid_parsing(self): + with self.assertRaises(VersionParsingException): + Version.parse('') + with self.assertRaises(VersionParsingException): + Version.parse('1.-2') + with self.assertRaises(VersionParsingException): + Version.parse('1x1x3') + with self.assertRaises(VersionParsingException): + Version.parse('1.1.0-unknown12') + + def test_comparing(self): + self.assertTrue(Version.parse('14.0.0').is_newer_than(Version.parse('13.1.2'))) + self.assertTrue(Version.parse('14.1.1').is_newer_than(Version.parse('14.1.0'))) + self.assertTrue(Version.parse('14.2.3').is_newer_than(Version.parse('14.2.2'))) + self.assertFalse(Version.parse('14.0.3').is_newer_than(Version.parse('15.0.2'))) + self.assertFalse(Version.parse('14.0.3').is_newer_than(Version.parse('14.1.0'))) + self.assertFalse(Version.parse('14.0.3').is_newer_than(Version.parse('14.0.4'))) + self.assertFalse(Version.parse('14.0.3').is_newer_than(Version.parse('14.0.3'))) + self.assertTrue(Version.parse('14.0.3').is_newer_than_or_equal(Version.parse('14.0.3'))) + self.assertFalse(Version.parse('14.0.2').is_newer_than_or_equal(Version.parse('14.0.3'))) + + self.assertTrue(Version.parse('14.0.0').is_newer_than(Version.parse('14.0.0-alpha1'))) + self.assertTrue(Version.parse('14.0.0').is_newer_than(Version.parse('14.0.0-alpha2'))) + self.assertTrue(Version.parse('14.0.0').is_newer_than(Version.parse('14.0.0-beta3'))) + self.assertTrue(Version.parse('14.0.0').is_newer_than(Version.parse('14.0.0-rc4'))) + self.assertTrue(Version.parse('1.0.2').is_newer_than(Version.parse('1.0.0-rc3'))) + self.assertTrue(Version.parse('1.2').is_newer_than(Version.parse('1.2-rc1'))) + self.assertTrue(Version.parse('1.2-beta1').is_newer_than(Version.parse('1.2-alpha2'))) + self.assertTrue(Version.parse('1.2-rc3').is_newer_than(Version.parse('1.2-rc2'))) + self.assertTrue(Version.parse('1.2-rc2').is_newer_than(Version.parse('1.2-beta4'))) + self.assertTrue(Version.parse('14.0.3-alpha2').is_newer_than_or_equal(Version.parse('14.0.3-alpha2'))) + self.assertFalse(Version.parse('14.0.2-alpha3').is_newer_than_or_equal(Version.parse('14.0.2-rc2'))) + + self.assertTrue(Version.parse('1.8.1').is_newer_than(Version.parse('1.8'))) + self.assertTrue(Version.parse('1.8.1-beta1').is_newer_than(Version.parse('1.8'))) + self.assertTrue(Version.parse('1.9.0').is_newer_than(Version.parse('1.8'))) + self.assertTrue(Version.parse('1.80.0').is_newer_than(Version.parse('1.8')))