diff --git a/plugins/filter/cm_version.py b/plugins/filter/cm_version.py deleted file mode 100644 index 15c18f25..00000000 --- a/plugins/filter/cm_version.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024 Cloudera, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -DOCUMENTATION = """ - name: cm_version - author: Webster Mudge (@wmudge) - short_description: Parse a Cloudera Manager version string - description: - - Cloudera Manager version string parsing. - - Returns a dictionary of the version parts. - positional: _input - options: - _input: - description: A version string to parse. - type: dict - required: True -""" - -EXAMPLES = """ -- name: Parse a standard version string - ansible.builtin.set_fact: - standard: "{{ '1.2.3' | cm_version }}" - -- name: Parse a version plus build number - ansible.builtin.set_fact: - build: "{{ '1.2.3.4' | cm_version }}" - -- name: Parse a version plus build metadata string - ansible.builtin.set_fact: - build: "{{ '1.2.3+build7' | cm_version }}" - -- name: Parse a version plus prerelease and build string - ansible.builtin.set_fact: - full: "{{ '1.2.3-rc1+build7' | cm_version }}" -""" - -RETURN = """ -_value: - description: - - A dictionary of the version parts. - - If unable to parse the string, returns C(None). - type: dict - options: - major: - description: Major version - minor: - description: Minor version - patch: - description: Patch version - prerelease: - description: Prerelease version - returned: when supported - buildmetadata: - description: Build metadata version - returned: when supported -""" - -import re - -from ansible.errors import AnsibleFilterError - -CM_REGEX = re.compile( - "^(?P0|[1-9]\\d*)" - + "\\.(?P0|[1-9]\\d*)" - + "\\.(?P0|[1-9]\\d*)" - + "(?:-(?P(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" - + "(?:[\\+|\\.](?P[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", -) - - -def cm_version(version: str): - """ - Parse a Cloudera Manager version string into its parts. - """ - - try: - ver = re.fullmatch(CM_REGEX, version) - except Exception as e: - raise AnsibleFilterError(orig_exc=e) - - if ver is not None: - return ver.groupdict() - - -class FilterModule(object): - def filters(self): - filters = {"cm_version": cm_version} - - return filters diff --git a/plugins/filter/combine_onto.yml b/plugins/filter/combine_onto.yml new file mode 100644 index 00000000..afd925ae --- /dev/null +++ b/plugins/filter/combine_onto.yml @@ -0,0 +1,66 @@ +# Copyright 2025 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +DOCUMENTATION: + name: combine_onto + author: Webster Mudge (@wmudge) + short_description: combine two dictionaries + description: + - Create a dictionary (hash/associative array) as a result of merging existing dictionaries. + - This is the reverse of the C(ansible.builtin.combine) filter. + positional: _input, _dicts + options: + _input: + description: + - First dictionary to combine. + type: dict + required: true + _dicts: + description: + - The list of dictionaries to combine + type: list + elements: dict + required: true + recursive: + description: + - If V(True), merge elements recursively. + type: boolean + default: false + list_merge: + description: Behavior when encountering list elements. + type: str + default: replace + choices: + replace: overwrite older entries with newer ones + keep: discard newer entries + append: append newer entries to the older ones + prepend: insert newer entries in front of the older ones + append_rp: append newer entries to the older ones, overwrite duplicates + prepend_rp: insert newer entries in front of the older ones, discard duplicates + +EXAMPLES: | + # ab => {'a':1, 'b':2, 'c': 4} + ab: "{{ {'a':1, 'b':2} | cloudera.exe.combine_onto({'b':3, 'c':4}) }}" + + many: "{{ dict1 | cloudera.exe.combine_onto(dict2, dict3, dict4) }}" + + # defaults => {'a':{'b':3, 'c':4}, 'd': 5} + # customization => {'a':{'c':20}} + # final => {'a':{'b':3, 'c':20}, 'd': 5} + final: "{{ customization | cloudera.exe.combine_onto(defaults, recursive=true) }}" + +RETURN: + _value: + description: Resulting merge of supplied dictionaries. + type: dict diff --git a/plugins/filter/core_exe.py b/plugins/filter/core_exe.py index 7265947e..ed9926cc 100644 --- a/plugins/filter/core_exe.py +++ b/plugins/filter/core_exe.py @@ -19,67 +19,15 @@ __metaclass__ = type -DOCUMENTATION = """ -name: combine_onto -author: Webster Mudge (@wmudge) -short_description: combine two dictionaries -description: - - Create a dictionary (hash/associative array) as a result of merging existing dictionaries. - - This is the reverse of the C(ansible.builtin.combine) filter. -positional: _input, _dicts -options: - _input: - description: - - First dictionary to combine. - type: dict - required: True - _dicts: - description: - - The list of dictionaries to combine - type: list - elements: dict - required: True - recursive: - description: - - If V(True), merge elements recursively. - type: boolean - default: False - list_merge: - description: Behavior when encountering list elements. - type: str - default: replace - choices: - replace: overwrite older entries with newer ones - keep: discard newer entries - append: append newer entries to the older ones - prepend: insert newer entries in front of the older ones - append_rp: append newer entries to the older ones, overwrite duplicates - prepend_rp: insert newer entries in front of the older ones, discard duplicates -""" - -EXAMPLES = """ -# ab => {'a':1, 'b':2, 'c': 4} -ab: "{{ {'a':1, 'b':2} | cloudera.exe.combine_onto({'b':3, 'c':4}) }}" - -many: "{{ dict1 | cloudera.exe.combine_onto(dict2, dict3, dict4) }}" - -# defaults => {'a':{'b':3, 'c':4}, 'd': 5} -# customization => {'a':{'c':20}} -# final => {'a':{'b':3, 'c':20}, 'd': 5} -final: "{{ customization | cloudera.exe.combine_onto(defaults, recursive=true) }}" -""" - -RETURN = """ -_value: - description: Resulting merge of supplied dictionaries. - type: dict -""" - from ansible.errors import AnsibleFilterError from ansible.plugins.filter.core import flatten from ansible.template import recursive_check_defined from ansible.utils.vars import merge_hash +from ansible_collections.cloudera.exe.plugins.module_utils.cldr_version import ( + ClouderaVersion, +) + def combine_onto(*terms, **kwargs): """ @@ -112,10 +60,31 @@ def combine_onto(*terms, **kwargs): return result +def cldr_version(version: str): + """ + Parse a Cloudera version string into its parts. + """ + + try: + parsed_version = ClouderaVersion(version) + return dict( + major=parsed_version.major, + minor=parsed_version.minor, + patch=parsed_version.patch, + prerelease=parsed_version.prerelease, + buildmetadata=parsed_version.buildmetadata, + ) + except Exception as e: + raise AnsibleFilterError(orig_exc=e) + + class FilterModule(object): - """Derivatives of Ansible jinja2 filters""" + """Cloudera Ansible jinja2 filters""" def filters(self): - filters = {"combine_onto": combine_onto} + filters = { + "combine_onto": combine_onto, + "version": cldr_version, + } return filters diff --git a/plugins/filter/version.yml b/plugins/filter/version.yml new file mode 100644 index 00000000..369b9d51 --- /dev/null +++ b/plugins/filter/version.yml @@ -0,0 +1,72 @@ +# Copyright 2025 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +DOCUMENTATION: + name: version + author: Cloudera Labs + short_description: Parse a Cloudera Manager version string + description: + - Cloudera Manager version string parsing. + - Returns a dictionary of the version parts. + - Cloudera versioning is an extension of Semantic Versioning that uses the C(prerelease) segment to indicate service packs or extended patch releases. + - Moreover, this versioning scheme allow the C(prerelease) delimiter to use whitespace (C(' ')) and dots (C('.')) in addition to the semantic version's + dash (C('-')). + version_added: "3.0.0" + positional: _input + options: + _input: + description: A version string to parse. + type: dict + required: true + +EXAMPLES: | + - name: Parse a standard version string + ansible.builtin.set_fact: + standard: "{{ '1.2.3' | cloudera.exe.version }}" + + - name: Parse a version plus build number + ansible.builtin.set_fact: + build: "{{ '1.2.3.4' | cloudera.exe.version }}" + + - name: Parse a version plus service pack (as prerelease) + ansible.builtin.set_fact: + service_pack: "{{ '1.2.3 SP2' | cloudera.exe.version }}" + + - name: Parse a version plus build metadata string + ansible.builtin.set_fact: + build: "{{ '1.2.3+build7' | clouder.exe.version }}" + + - name: Parse a version plus prerelease and build string + ansible.builtin.set_fact: + full: "{{ '1.2.3-rc1+build7' | cloudera.exe.version }}" + +RETURN: + _value: + description: + - A dictionary of the version parts. + - If unable to parse the string, returns C(None). + type: dict + options: + major: + description: Major version + minor: + description: Minor version + patch: + description: Patch version + prerelease: + description: Prerelease/Service pack version + returned: when supported + buildmetadata: + description: Build metadata version + returned: when supported diff --git a/plugins/module_utils/cldr_version.py b/plugins/module_utils/cldr_version.py new file mode 100644 index 00000000..685ec485 --- /dev/null +++ b/plugins/module_utils/cldr_version.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +from ansible.module_utils.compat.version import LooseVersion, Version +from ansible.utils.version import _Alpha, _Numeric + + +CLDR_RE = re.compile( + r""" + ^ + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + (?: + [ \.\-]* + (?P + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* + ) + )? + (?: + \+ + (?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*) + )? + $ + """, + flags=re.X, +) + + +class ClouderaVersion(Version): + """Version comparison class that implements Cloudera versioning. + + Cloudera versioning is an extension of Semantic Versioning that uses the + ``prerelease`` segment as service packs or patch releases. + + Moreover, this versioning scheme allow the ``prerelease`` delimiter + to use whitespace (' ') and dots ('.') in addition to the semantic + version's dash ('-'). + + Based off of ``distutils.version.Version`` and ``ansible.utils.version``. + """ + + version_re = CLDR_RE + + def __init__(self, vstring=None): + self.vstring = vstring + self.major = None + self.minor = None + self.patch = None + self.prerelease = () + self.buildmetadata = () + + if vstring: + self.parse(vstring) + + def __repr__(self): + return "ClouderaVersion(%r)" % self.vstring + + @staticmethod + def from_loose_version(loose_version): + """This method is designed to take a ``LooseVersion`` + and attempt to construct a ``ClouderaVersion`` from it + + This is useful where you want to do simple version math + without requiring users to provide a compliant semver. + """ + if not isinstance(loose_version, LooseVersion): + raise ValueError("%r is not a LooseVersion" % loose_version) + + try: + version = loose_version.version[:] + except AttributeError: + raise ValueError("%r is not a LooseVersion" % loose_version) + + extra_idx = 3 + for marker in ("-", "+"): + try: + idx = version.index(marker) + except ValueError: + continue + else: + if idx < extra_idx: + extra_idx = idx + version = version[:extra_idx] + + if version and set(type(v) for v in version) != set((int,)): + raise ValueError("Non integer values in %r" % loose_version) + + # Extra is everything to the right of the core version + extra = re.search("[+-].+$", loose_version.vstring) + + version = version + [0] * (3 - len(version)) + return ClouderaVersion( + "%s%s" + % ( + ".".join(str(v) for v in version), + extra.group(0) if extra else "", + ), + ) + + def parse(self, vstring) -> None: + match = CLDR_RE.match(vstring) + if not match: + raise ValueError("invalid Cloudera version '%s'" % vstring) + + (major, minor, patch, prerelease, buildmetadata) = match.group(1, 2, 3, 4, 5) + self.vstring = vstring + self.major = int(major) + self.minor = int(minor) + self.patch = int(patch) + + self.prerelease = None + self.buildmetadata = None + + if prerelease: + self.prerelease = tuple( + _Numeric(x) if x.isdigit() else _Alpha(x) for x in prerelease.split(".") + ) + if buildmetadata: + self.buildmetadata = tuple( + _Numeric(x) if x.isdigit() else _Alpha(x) + for x in buildmetadata.split(".") + ) + + @property + def core(self) -> tuple[int | None, int | None, int | None]: + return self.major, self.minor, self.patch + + @property + def is_prerelease(self) -> bool: + return bool(self.prerelease) + + @property + def is_stable(self) -> bool: + # Major version zero (0.y.z) is for initial development. Anything MAY change at any time. + # The public API SHOULD NOT be considered stable. + # https://semver.org/#spec-item-4 + return not (self.major == 0 or self.is_prerelease) + + def _cmp(self, other) -> int: + if isinstance(other, str): + other = ClouderaVersion(other) + + if self.core != other.core: + # if the core version doesn't match + # prerelease and buildmetadata doesn't matter + if self.core < other.core: + return -1 + else: + return 1 + + if not any((self.prerelease, other.prerelease)): + return 0 + + if self.prerelease and not other.prerelease: + return 1 + elif not self.prerelease and other.prerelease: + return -1 + else: + if self.prerelease < other.prerelease: + return -1 + elif self.prerelease > other.prerelease: + return 1 + + # Build metadata MUST be ignored when determining version precedence + # https://semver.org/#spec-item-10 + # With the above in mind it is ignored here + + # If we have made it here, things should be equal + return 0 + + # The Py2 and Py3 implementations of distutils.version.Version + # are quite different, this makes the Py2 and Py3 implementations + # the same + def __eq__(self, other): + return self._cmp(other) == 0 + + def __ne__(self, other): + return not self.__eq__(other) + + def __lt__(self, other): + return self._cmp(other) < 0 + + def __le__(self, other): + return self._cmp(other) <= 0 + + def __gt__(self, other): + return self._cmp(other) > 0 + + def __ge__(self, other): + return self._cmp(other) >= 0 diff --git a/plugins/test/cldr.py b/plugins/test/cldr.py new file mode 100644 index 00000000..ba74457d --- /dev/null +++ b/plugins/test/cldr.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import operator as py_operator + +from ansible import errors +from ansible.module_utils.common.text.converters import to_native, to_text + +from ansible_collections.cloudera.exe.plugins.module_utils.cldr_version import ( + ClouderaVersion, +) + + +def cldr_version_compare(value, version, operator="eq"): + """Perform a Cloudera version comparison on a value""" + op_map = { + "==": "eq", + "=": "eq", + "eq": "eq", + "<": "lt", + "lt": "lt", + "<=": "le", + "le": "le", + ">": "gt", + "gt": "gt", + ">=": "ge", + "ge": "ge", + "!=": "ne", + "<>": "ne", + "ne": "ne", + } + + if operator in op_map: + operator = op_map[operator] + else: + raise errors.AnsibleFilterError( + "Invalid operator type (%s). Must be one of %s" + % (operator, ", ".join(map(repr, op_map))), + ) + + try: + method = getattr(py_operator, operator) + return method( + ClouderaVersion(to_text(value)), + ClouderaVersion(to_text(version)), + ) + except Exception as e: + raise errors.AnsibleFilterError("Version comparison failed: %s" % to_native(e)) + + +class TestModule(object): + """Cloudera jinja2 tests""" + + def tests(self): + return { + # failure testing + "version": cldr_version_compare, + } diff --git a/plugins/test/version.yml b/plugins/test/version.yml new file mode 100644 index 00000000..c2d62533 --- /dev/null +++ b/plugins/test/version.yml @@ -0,0 +1,64 @@ +# Copyright 2025 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +DOCUMENTATION: + name: version + author: Cloudera Labs + version_added: "3.0.0" + short_description: compare Cloudera version strings + description: + - Compare version strings using the Cloudera versioning scheme. + - Cloudera versioning is an extension of Semantic Versioning that uses the C(prerelease) segment to indicate service packs or extended patch releases. + - Moreover, this versioning scheme allow the C(prerelease) delimiter to use whitespace (C(' ')) and dots (C('.')) in addition to the semantic version's + dash (C('-')). + options: + _input: + description: Left hand version to compare + type: string + required: true + version: + description: Right hand version to compare + type: string + required: true + operator: + description: Comparison operator + type: string + required: false + choices: + - == + - "=" + - eq + - < + - lt + - <= + - le + - ">" + - gt + - ">=" + - ge + - "!=" + - <> + - ne + default: eq +EXAMPLES: | + - name: Cloudera version test examples + assert: + that: + - "'7.3.1' is version('8.0.0', 'lt')" + - "'7.1.9 SP3' is version('7.1.9', 'gt')" + - "'7.1.9 SP3' is version('7.1.9 SP2', 'gt')" +RETURN: + _value: + description: Returns V(True) or V(False) depending on the outcome of the comparison. + type: boolean diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index e69de29b..1a2bda13 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2025 Cloudera, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + + def __init__(self, kwargs): + super(AnsibleExitJson, self).__init__( + kwargs.get("msg", "General module success"), + ) + self.__dict__.update(kwargs) + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + + def __init__(self, kwargs): + super(AnsibleFailJson, self).__init__( + kwargs.get("msg", "General module failure"), + ) + self.__dict__.update(kwargs) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..3d7cf370 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +import pytest + +LOG = logging.getLogger(__name__) + +# Pytest fixtures diff --git a/tests/unit/plugins/conftest.py b/tests/unit/plugins/conftest.py new file mode 100644 index 00000000..036f1e2f --- /dev/null +++ b/tests/unit/plugins/conftest.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +import pytest + +from ansible_collections.cloudera.exe.plugins.filter.core_exe import ( + FilterModule as filters_exe, +) +from ansible_collections.cloudera.exe.plugins.test.cldr import TestModule as tests_exe + + +LOG = logging.getLogger(__name__) + +# Pytest fixtures + + +@pytest.fixture(scope="module") +def filter(): + def get_filter(filter_short_name: str): + return filters_exe().filters().get(filter_short_name) + + return get_filter + + +@pytest.fixture(scope="module") +def test(): + def get_test(test_short_name: str): + return tests_exe().tests().get(test_short_name) + + return get_test diff --git a/tests/unit/plugins/filter/test_core_exe.py b/tests/unit/plugins/filter/test_core_exe.py deleted file mode 100644 index 4c7d96ab..00000000 --- a/tests/unit/plugins/filter/test_core_exe.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright 2023 Cloudera, Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -import pytest -import unittest -from unittest.mock import patch, MagicMock - -from ansible_collections.cloudera.exe.plugins.filter import core_exe -from ansible.plugins.loader import filter_loader - - -class TestFilterModule(unittest.TestCase): - def setUp(self): - self.filter = filter_loader.get("cloudera.exe.core_exe") - - def test_combine_onto(self): - self.assertIn("combine_onto", self.filter.filters().keys()) - test_filter = self.filter.filters().get("combine_onto") - - # Source will combine ONTO the target, overriding the target - source_dict = {"foo": "bar", "gaz": "blaz", "nested": {"duz": "ferr"}} - target_dict = {"gaz": "blergh", "derr": "zaar", "nested": {"wuz": "gug"}} - - expected_results = { - "foo": "bar", - "gaz": "blaz", - "derr": "zaar", - "nested": {"duz": "ferr"}, - } - self.assertDictEqual(expected_results, test_filter([source_dict, target_dict])) - - expected_results_recursive = { - "foo": "bar", - "gaz": "blaz", - "derr": "zaar", - "nested": {"duz": "ferr", "wuz": "gug"}, - } - self.assertDictEqual( - expected_results_recursive, - test_filter([source_dict, target_dict], recursive=True), - ) diff --git a/tests/unit/plugins/filter/test_filter_combine_onto.py b/tests/unit/plugins/filter/test_filter_combine_onto.py new file mode 100644 index 00000000..0f113ecf --- /dev/null +++ b/tests/unit/plugins/filter/test_filter_combine_onto.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from unittest import TestCase + + +dataset = [ + ( + {"foo": "bar", "gaz": "blaz", "nested": {"duz": "ferr"}}, + {"gaz": "blergh", "derr": "zaar", "nested": {"wuz": "gug"}}, + False, + { + "foo": "bar", + "gaz": "blaz", + "derr": "zaar", + "nested": {"duz": "ferr"}, + }, + ), + ( + {"foo": "bar", "gaz": "blaz", "nested": {"duz": "ferr"}}, + {"gaz": "blergh", "derr": "zaar", "nested": {"wuz": "gug"}}, + True, + { + "foo": "bar", + "gaz": "blaz", + "derr": "zaar", + "nested": {"duz": "ferr", "wuz": "gug"}, + }, + ), +] + + +@pytest.mark.parametrize("source,target,recursive,expected", dataset) +def test_filter_version(filter, source, target, recursive, expected): + onto_filter = filter("combine_onto") + + actual = onto_filter([source, target], recursive=recursive) + + TestCase().assertDictEqual(expected, actual) diff --git a/tests/unit/plugins/filter/test_filter_version.py b/tests/unit/plugins/filter/test_filter_version.py new file mode 100644 index 00000000..eca2ebf8 --- /dev/null +++ b/tests/unit/plugins/filter/test_filter_version.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + + +dataset = [ + ("1.2.3", (1, 2, 3, None, None)), + ("1.2.3 SP1", (1, 2, 3, tuple(["SP1"]), None)), + ("1.2.3-SP1", (1, 2, 3, tuple(["SP1"]), None)), + ("1.2.3.SP1", (1, 2, 3, tuple(["SP1"]), None)), + ("1.2.3 SP1.400", (1, 2, 3, tuple(["SP1", 400]), None)), + ("1.2.3+Build", (1, 2, 3, None, tuple(["Build"]))), + ("1.2.3+Build.400", (1, 2, 3, None, tuple(["Build", 400]))), +] + + +@pytest.mark.parametrize("vstring,expected", dataset) +def test_filter_version(filter, vstring, expected): + version_filter = filter("version") + + actual = version_filter(vstring) + + assert actual.get("major") == expected[0] + assert actual.get("minor") == expected[1] + assert actual.get("patch") == expected[2] + assert actual.get("prerelease") == expected[3] + assert actual.get("buildmetadata") == expected[4] diff --git a/tests/unit/plugins/module_utils/test_cldr_version.py b/tests/unit/plugins/module_utils/test_cldr_version.py new file mode 100644 index 00000000..4764b990 --- /dev/null +++ b/tests/unit/plugins/module_utils/test_cldr_version.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_collections.cloudera.exe.plugins.module_utils.cldr_version import ( + ClouderaVersion, +) + +dataset = [ + # Core + ("1.2.3", (1, 2, 3, None, None)), + ("1.2", None), + ("1", None), + # Prerelease + ("1.2.3-rc1", (1, 2, 3, tuple(["rc1"]), None)), + ("1.2.3-rc1.foo", (1, 2, 3, tuple(["rc1", "foo"]), None)), + ("1.2.3-rc1.400", (1, 2, 3, tuple(["rc1", 400]), None)), + ("1.2.3-rc1-foo", (1, 2, 3, tuple(["rc1-foo"]), None)), + ("1.2.3 SP1", (1, 2, 3, tuple(["SP1"]), None)), + ("1.2.3 SP1.foo", (1, 2, 3, tuple(["SP1", "foo"]), None)), + ("1.2.3 SP1.400", (1, 2, 3, tuple(["SP1", 400]), None)), + ("1.2.3 SP1-foo", (1, 2, 3, tuple(["SP1-foo"]), None)), + ("1.2.3.100", (1, 2, 3, tuple([100]), None)), + ("1.2.3.100.400", (1, 2, 3, tuple([100, 400]), None)), + ("1.2.3.100-400", (1, 2, 3, tuple(["100-400"]), None)), + # Buildmeta + ("1.2.3+400", (1, 2, 3, None, tuple([400]))), + ("1.2.3+400.things", (1, 2, 3, None, tuple([400, "things"]))), + ("1.2.3+400-things", (1, 2, 3, None, tuple(["400-things"]))), + # Combined + ("1.2.3-rc1+400", (1, 2, 3, tuple(["rc1"]), tuple([400]))), + ("1.2.3-rc1.foo+400", (1, 2, 3, tuple(["rc1", "foo"]), tuple([400]))), + ( + "1.2.3-rc1.foo+400.things", + (1, 2, 3, tuple(["rc1", "foo"]), tuple([400, "things"])), + ), + # Invalid + ("1.2.3=boom", None), + ("1.2.3 boom=boom", None), + ("1.2.3.boom=boom", None), + ("1.2.3-boom=boom", None), + # ("1.2.3+boom=boom", None), +] + +comparisons = [ + # Core + ("1.2.3", "1.2.3", 0), + ("1.2.3", "1.2.4", -1), + ("1.2.4", "1.2.3", 1), + # Prerelease (i.e. service packs) + ("1.2.3 SP1", "1.2.3 SP1", 0), + ("1.2.3 SP1", "1.2.3 SP2", -1), + ("1.2.3 SP2", "1.2.3 SP1", 1), + # Ignored + ("1.2.3 SP1+100", "1.2.3 SP1+200", 0), + ("1.2.3 SP1+100", "1.2.4 SP1+100", -1), + ("1.2.4 SP1+100", "1.2.3 SP1+100", 1), + ("1.2.3 SP1+100", "1.2.3 SP1+200", 0), + ("1.2.3 SP1+100", "1.2.4 SP1+100", -1), + ("1.2.4 SP1+100", "1.2.3 SP1+100", 1), +] + + +@pytest.mark.parametrize("vstring,expected", dataset) +def test_parse(vstring, expected): + version = ClouderaVersion() + + if expected is None: + with pytest.raises(ValueError, match=vstring): + version.parse(vstring) + else: + version.parse(vstring) + + assert version.major == expected[0] + assert version.minor == expected[1] + assert version.patch == expected[2] + assert version.prerelease == expected[3] + assert version.buildmetadata == expected[4] + + assert version.core == (expected[0], expected[1], expected[2]) + + +@pytest.mark.parametrize("vstring,compare,expected", comparisons) +def test_comparisons(vstring, compare, expected): + version = ClouderaVersion(vstring) + + assert version._cmp(compare) == expected diff --git a/tests/unit/plugins/test/test_test_version.py b/tests/unit/plugins/test/test_test_version.py new file mode 100644 index 00000000..a67005d6 --- /dev/null +++ b/tests/unit/plugins/test/test_test_version.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + + +dataset = [ + # Equal + ("1.2.3", "1.2.3", "eq", True), + ("1.2.3", "1.2.4", "eq", False), + ("1.2.3", "1.2.2", "eq", False), + ("1.2.3 SP1", "1.2.3 SP1", "eq", True), + ("1.2.3 SP1", "1.2.3 SP2", "eq", False), + ("1.2.3 SP2", "1.2.3 SP1", "eq", False), + ("1.2.3 SP2", "1.2.2 SP2", "eq", False), + ("1.2.2 SP2", "1.2.3 SP2", "eq", False), + ("1.2.3+100", "1.2.3+100", "eq", True), # Buildmetadata is ignored + ("1.2.3+100", "1.2.3+200", "eq", True), + ("1.2.3+200", "1.2.3+100", "eq", True), + ("1.2.3+100", "1.2.4+100", "eq", False), + ("1.2.3+100", "1.2.2+100", "eq", False), + # Not equal + ("1.2.3", "1.2.3", "ne", False), + ("1.2.3", "1.2.4", "ne", True), + ("1.2.3", "1.2.2", "ne", True), + ("1.2.3 SP1", "1.2.3 SP1", "ne", False), + ("1.2.3 SP1", "1.2.3 SP2", "ne", True), + ("1.2.3 SP2", "1.2.3 SP1", "ne", True), + ("1.2.3 SP2", "1.2.2 SP2", "ne", True), + ("1.2.2 SP2", "1.2.3 SP2", "ne", True), + ("1.2.3+100", "1.2.3+100", "ne", False), # Buildmetadata is ignored + ("1.2.3+100", "1.2.3+200", "ne", False), + ("1.2.3+200", "1.2.3+100", "ne", False), + ("1.2.3+100", "1.2.4+100", "ne", True), + ("1.2.3+100", "1.2.2+100", "ne", True), + # Less than + ("1.2.3", "1.2.3", "lt", False), + ("1.2.3", "1.2.4", "lt", True), + ("1.2.3", "1.2.2", "lt", False), + ("1.2.3 SP1", "1.2.3 SP1", "lt", False), + ("1.2.3 SP1", "1.2.3 SP2", "lt", True), + ("1.2.3 SP2", "1.2.3 SP1", "lt", False), + ("1.2.3 SP2", "1.2.2 SP2", "lt", False), + ("1.2.2 SP2", "1.2.3 SP2", "lt", True), + ("1.2.3+100", "1.2.3+100", "lt", False), # Buildmetadata is ignored + ("1.2.3+100", "1.2.3+200", "lt", False), + ("1.2.3+200", "1.2.3+100", "lt", False), + ("1.2.3+100", "1.2.4+100", "lt", True), + ("1.2.3+100", "1.2.2+100", "lt", False), + # Less than or equal + ("1.2.3", "1.2.3", "le", True), + ("1.2.3", "1.2.4", "le", True), + ("1.2.3", "1.2.2", "le", False), + ("1.2.3 SP1", "1.2.3 SP1", "le", True), + ("1.2.3 SP1", "1.2.3 SP2", "le", True), + ("1.2.3 SP2", "1.2.3 SP1", "le", False), + ("1.2.3 SP2", "1.2.2 SP2", "le", False), + ("1.2.2 SP2", "1.2.3 SP2", "le", True), + ("1.2.3+100", "1.2.3+100", "le", True), # Buildmetadata is ignored + ("1.2.3+100", "1.2.3+200", "le", True), + ("1.2.3+200", "1.2.3+100", "le", True), + ("1.2.3+100", "1.2.4+100", "le", True), + ("1.2.3+100", "1.2.2+100", "le", False), + # Greater than + ("1.2.3", "1.2.3", "gt", False), + ("1.2.3", "1.2.4", "gt", False), + ("1.2.3", "1.2.2", "gt", True), + ("1.2.3 SP1", "1.2.3 SP1", "gt", False), + ("1.2.3 SP1", "1.2.3 SP2", "gt", False), + ("1.2.3 SP2", "1.2.3 SP1", "gt", True), + ("1.2.3 SP2", "1.2.2 SP2", "gt", True), + ("1.2.2 SP2", "1.2.3 SP2", "gt", False), + ("1.2.3+100", "1.2.3+100", "gt", False), # Buildmetadata is ignored + ("1.2.3+100", "1.2.3+200", "gt", False), + ("1.2.3+200", "1.2.3+100", "gt", False), + ("1.2.3+100", "1.2.4+100", "gt", False), + ("1.2.3+100", "1.2.2+100", "gt", True), + # Greater than or equal + ("1.2.3", "1.2.3", "ge", True), + ("1.2.3", "1.2.4", "ge", False), + ("1.2.3", "1.2.2", "ge", True), + ("1.2.3 SP1", "1.2.3 SP1", "ge", True), + ("1.2.3 SP1", "1.2.3 SP2", "ge", False), + ("1.2.3 SP2", "1.2.3 SP1", "ge", True), + ("1.2.3 SP2", "1.2.2 SP2", "ge", True), + ("1.2.2 SP2", "1.2.3 SP2", "ge", False), + ("1.2.3+100", "1.2.3+100", "ge", True), # Buildmetadata is ignored + ("1.2.3+100", "1.2.3+200", "ge", True), + ("1.2.3+200", "1.2.3+100", "ge", True), + ("1.2.3+100", "1.2.4+100", "ge", False), + ("1.2.3+100", "1.2.2+100", "ge", True), +] + + +@pytest.mark.parametrize("vstring,comparison,operator,expected", dataset) +def test_test_version(test, vstring, comparison, operator, expected): + version_test = test("version") + + assert version_test(vstring, comparison, operator) == expected