diff --git a/src/corelibs/var_handling/enum_base.py b/src/corelibs/var_handling/enum_base.py new file mode 100644 index 0000000..fa2b967 --- /dev/null +++ b/src/corelibs/var_handling/enum_base.py @@ -0,0 +1,75 @@ +""" +Enum base classes +""" + +from enum import Enum +from typing import Any + + +class EnumBase(Enum): + """ + base for enum + lookup_any and from_any will return "EnumBase" and the sub class name + run the return again to "from_any" to get a clean value, or cast it + """ + + @classmethod + def lookup_key(cls, enum_key: str): + """Lookup from key side (must be string)""" + # if there is a ":", then this is legacy, replace with ___ + if ":" in enum_key: + enum_key = enum_key.replace(':', '___') + try: + return cls[enum_key.upper()] + except KeyError as e: + raise ValueError(f"Invalid key: {enum_key}") from e + except AttributeError as e: + raise ValueError(f"Invalid key: {enum_key}") from e + + @classmethod + def lookup_value(cls, enum_value: Any): + """Lookup through value side""" + try: + return cls(enum_value) + except ValueError as e: + raise ValueError(f"Invalid value: {enum_value}") from e + + @classmethod + def from_any(cls, enum_any: Any): + """ + This only works in the following order + -> class itself, as is + -> str, assume key lookup + -> if failed try other + + Arguments: + enum_any {Any} -- _description_ + + Returns: + _type_ -- _description_ + """ + if isinstance(enum_any, cls): + return enum_any + # try key first if it is string + # if failed try value + if isinstance(enum_any, str): + try: + return cls.lookup_key(enum_any) + except (ValueError, AttributeError): + try: + return cls.lookup_value(enum_any) + except ValueError as e: + raise ValueError(f"Could not find as key or value: {enum_any}") from e + return cls.lookup_value(enum_any) + + def to_value(self) -> Any: + """Convert to value""" + return self.value + + def to_lower_case(self) -> str: + """return lower case""" + return self.name.lower() + + def __str__(self) -> str: + """return [Enum].NAME like it was called with .name""" + return self.name diff --git a/test-run/var_handling/enum_test.py b/test-run/var_handling/enum_test.py new file mode 100644 index 0000000..33e3ab0 --- /dev/null +++ b/test-run/var_handling/enum_test.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +""" +Enum handling +""" + +from corelibs.var_handling.enum_base import EnumBase + + +class TestBlock(EnumBase): + """Test block enum""" + BLOCK_A = "block_a" + HAS_NUM = 5 + + +def main() -> None: + """ + Comment + """ + + print(f"BLOCK A: {TestBlock.from_any('BLOCK_A')}") + print(f"HAS NUM: {TestBlock.from_any(5)}") + print(f"DIRECT BLOCK: {TestBlock.BLOCK_A.name} -> {TestBlock.BLOCK_A.value}") + + +if __name__ == "__main__": + main() + +# __END__ diff --git a/tests/unit/var_handling/__init__.py b/tests/unit/var_handling/__init__.py new file mode 100644 index 0000000..1bfbaf1 --- /dev/null +++ b/tests/unit/var_handling/__init__.py @@ -0,0 +1,3 @@ +""" +var_handling tests +""" diff --git a/tests/unit/var_handling/test_enum_base.py b/tests/unit/var_handling/test_enum_base.py new file mode 100644 index 0000000..5ec1dcf --- /dev/null +++ b/tests/unit/var_handling/test_enum_base.py @@ -0,0 +1,546 @@ +""" +var_handling.enum_base tests +""" + +from typing import Any +import pytest +from corelibs.var_handling.enum_base import EnumBase + + +class SampleBlock(EnumBase): + """Sample block enum for testing purposes""" + BLOCK_A = "block_a" + BLOCK_B = "block_b" + HAS_NUM = 5 + HAS_FLOAT = 3.14 + LEGACY_KEY = "legacy_value" + + +class SimpleEnum(EnumBase): + """Simple enum with string values""" + OPTION_ONE = "one" + OPTION_TWO = "two" + OPTION_THREE = "three" + + +class NumericEnum(EnumBase): + """Enum with only numeric values""" + FIRST = 1 + SECOND = 2 + THIRD = 3 + + +class TestEnumBaseLookupKey: + """Test cases for lookup_key class method""" + + def test_lookup_key_valid_uppercase(self): + """Test lookup_key with valid uppercase key""" + result = SampleBlock.lookup_key("BLOCK_A") + assert result == SampleBlock.BLOCK_A + assert result.name == "BLOCK_A" + assert result.value == "block_a" + + def test_lookup_key_valid_lowercase(self): + """Test lookup_key with valid lowercase key (should convert to uppercase)""" + result = SampleBlock.lookup_key("block_a") + assert result == SampleBlock.BLOCK_A + assert result.name == "BLOCK_A" + + def test_lookup_key_valid_mixed_case(self): + """Test lookup_key with mixed case key""" + result = SampleBlock.lookup_key("BlOcK_a") + assert result == SampleBlock.BLOCK_A + assert result.name == "BLOCK_A" + + def test_lookup_key_with_numeric_enum(self): + """Test lookup_key with numeric enum member""" + result = SampleBlock.lookup_key("HAS_NUM") + assert result == SampleBlock.HAS_NUM + assert result.value == 5 + + def test_lookup_key_legacy_colon_replacement(self): + """Test lookup_key with legacy colon format (converts : to ___)""" + # This assumes the enum has a key that might be accessed with legacy format + # Should convert : to ___ and look up LEGACY___KEY + # Since we don't have this key, we test the behavior with a valid conversion + # Let's test with a known key that would work + with pytest.raises(ValueError, match="Invalid key"): + SampleBlock.lookup_key("BLOCK:A") # Should fail as BLOCK___A doesn't exist + + def test_lookup_key_invalid_key(self): + """Test lookup_key with invalid key""" + with pytest.raises(ValueError, match="Invalid key: NONEXISTENT"): + SampleBlock.lookup_key("NONEXISTENT") + + def test_lookup_key_empty_string(self): + """Test lookup_key with empty string""" + with pytest.raises(ValueError, match="Invalid key"): + SampleBlock.lookup_key("") + + def test_lookup_key_with_special_characters(self): + """Test lookup_key with special characters that might cause AttributeError""" + with pytest.raises(ValueError, match="Invalid key"): + SampleBlock.lookup_key("@#$%") + + def test_lookup_key_numeric_string(self): + """Test lookup_key with numeric string that isn't a key""" + with pytest.raises(ValueError, match="Invalid key"): + SampleBlock.lookup_key("123") + + +class TestEnumBaseLookupValue: + """Test cases for lookup_value class method""" + + def test_lookup_value_valid_string(self): + """Test lookup_value with valid string value""" + result = SampleBlock.lookup_value("block_a") + assert result == SampleBlock.BLOCK_A + assert result.name == "BLOCK_A" + assert result.value == "block_a" + + def test_lookup_value_valid_integer(self): + """Test lookup_value with valid integer value""" + result = SampleBlock.lookup_value(5) + assert result == SampleBlock.HAS_NUM + assert result.name == "HAS_NUM" + assert result.value == 5 + + def test_lookup_value_valid_float(self): + """Test lookup_value with valid float value""" + result = SampleBlock.lookup_value(3.14) + assert result == SampleBlock.HAS_FLOAT + assert result.name == "HAS_FLOAT" + assert result.value == 3.14 + + def test_lookup_value_invalid_string(self): + """Test lookup_value with invalid string value""" + with pytest.raises(ValueError, match="Invalid value: nonexistent"): + SampleBlock.lookup_value("nonexistent") + + def test_lookup_value_invalid_integer(self): + """Test lookup_value with invalid integer value""" + with pytest.raises(ValueError, match="Invalid value: 999"): + SampleBlock.lookup_value(999) + + def test_lookup_value_case_sensitive(self): + """Test that lookup_value is case-sensitive for string values""" + with pytest.raises(ValueError, match="Invalid value"): + SampleBlock.lookup_value("BLOCK_A") # Value is "block_a", not "BLOCK_A" + + +class TestEnumBaseFromAny: + """Test cases for from_any class method""" + + def test_from_any_with_enum_instance(self): + """Test from_any with an enum instance (should return as-is)""" + enum_instance = SampleBlock.BLOCK_A + result = SampleBlock.from_any(enum_instance) + assert result is enum_instance + assert result == SampleBlock.BLOCK_A + + def test_from_any_with_string_as_key(self): + """Test from_any with string that matches a key""" + result = SampleBlock.from_any("BLOCK_A") + assert result == SampleBlock.BLOCK_A + assert result.name == "BLOCK_A" + assert result.value == "block_a" + + def test_from_any_with_string_as_key_lowercase(self): + """Test from_any with lowercase string key""" + result = SampleBlock.from_any("block_a") + # Should first try as key (convert to uppercase and find BLOCK_A) + assert result == SampleBlock.BLOCK_A + + def test_from_any_with_string_as_value(self): + """Test from_any with string that only matches a value""" + # Use a value that isn't also a valid key + result = SampleBlock.from_any("block_b") + # Should try key first (fail), then value (succeed) + assert result == SampleBlock.BLOCK_B + assert result.value == "block_b" + + def test_from_any_with_integer(self): + """Test from_any with integer value""" + result = SampleBlock.from_any(5) + assert result == SampleBlock.HAS_NUM + assert result.value == 5 + + def test_from_any_with_float(self): + """Test from_any with float value""" + result = SampleBlock.from_any(3.14) + assert result == SampleBlock.HAS_FLOAT + assert result.value == 3.14 + + def test_from_any_with_invalid_string(self): + """Test from_any with string that doesn't match key or value""" + with pytest.raises(ValueError, match="Could not find as key or value: invalid_string"): + SampleBlock.from_any("invalid_string") + + def test_from_any_with_invalid_integer(self): + """Test from_any with integer that doesn't match any value""" + with pytest.raises(ValueError, match="Invalid value: 999"): + SampleBlock.from_any(999) + + def test_from_any_string_key_priority(self): + """Test that from_any tries key lookup before value for strings""" + # Create an enum where a value matches another key + class AmbiguousEnum(EnumBase): + KEY_A = "key_b" # Value is the name of another key + KEY_B = "value_b" + + # When we look up "KEY_B", it should find it as a key, not as value "key_b" + result = AmbiguousEnum.from_any("KEY_B") + assert result == AmbiguousEnum.KEY_B + assert result.value == "value_b" + + +class TestEnumBaseToValue: + """Test cases for to_value instance method""" + + def test_to_value_string_value(self): + """Test to_value with string enum value""" + result = SampleBlock.BLOCK_A.to_value() + assert result == "block_a" + assert isinstance(result, str) + + def test_to_value_integer_value(self): + """Test to_value with integer enum value""" + result = SampleBlock.HAS_NUM.to_value() + assert result == 5 + assert isinstance(result, int) + + def test_to_value_float_value(self): + """Test to_value with float enum value""" + result = SampleBlock.HAS_FLOAT.to_value() + assert result == 3.14 + assert isinstance(result, float) + + def test_to_value_equals_value_attribute(self): + """Test that to_value returns the same as .value""" + enum_instance = SampleBlock.BLOCK_A + assert enum_instance.to_value() == enum_instance.value + + +class TestEnumBaseToLowerCase: + """Test cases for to_lower_case instance method""" + + def test_to_lower_case_uppercase_name(self): + """Test to_lower_case with uppercase enum name""" + result = SampleBlock.BLOCK_A.to_lower_case() + assert result == "block_a" + assert isinstance(result, str) + + def test_to_lower_case_mixed_name(self): + """Test to_lower_case with name containing underscores""" + result = SampleBlock.HAS_NUM.to_lower_case() + assert result == "has_num" + + def test_to_lower_case_consistency(self): + """Test that to_lower_case always returns lowercase""" + for member in SampleBlock: + result = member.to_lower_case() + assert result == result.lower() + assert result == member.name.lower() + + +class TestEnumBaseStrMethod: + """Test cases for __str__ magic method""" + + def test_str_returns_name(self): + """Test that str() returns the enum name""" + result = str(SampleBlock.BLOCK_A) + assert result == "BLOCK_A" + assert result == SampleBlock.BLOCK_A.name + + def test_str_all_members(self): + """Test str() for all enum members""" + for member in SampleBlock: + result = str(member) + assert result == member.name + assert isinstance(result, str) + + def test_str_in_formatting(self): + """Test that str works in string formatting""" + formatted = f"Enum: {SampleBlock.BLOCK_A}" + assert formatted == "Enum: BLOCK_A" + + def test_str_vs_repr(self): + """Test difference between str and repr""" + enum_instance = SampleBlock.BLOCK_A + str_result = str(enum_instance) + repr_result = repr(enum_instance) + + assert str_result == "BLOCK_A" + # repr should include class name + assert "SampleBlock" in repr_result + + +# Parametrized tests for comprehensive coverage +class TestParametrized: + """Parametrized tests for better coverage""" + + @pytest.mark.parametrize("key,expected_member", [ + ("BLOCK_A", SampleBlock.BLOCK_A), + ("block_a", SampleBlock.BLOCK_A), + ("BLOCK_B", SampleBlock.BLOCK_B), + ("HAS_NUM", SampleBlock.HAS_NUM), + ("has_num", SampleBlock.HAS_NUM), + ("HAS_FLOAT", SampleBlock.HAS_FLOAT), + ]) + def test_lookup_key_parametrized(self, key: str, expected_member: EnumBase): + """Test lookup_key with various valid keys""" + result = SampleBlock.lookup_key(key) + assert result == expected_member + + @pytest.mark.parametrize("value,expected_member", [ + ("block_a", SampleBlock.BLOCK_A), + ("block_b", SampleBlock.BLOCK_B), + (5, SampleBlock.HAS_NUM), + (3.14, SampleBlock.HAS_FLOAT), + ("legacy_value", SampleBlock.LEGACY_KEY), + ]) + def test_lookup_value_parametrized(self, value: Any, expected_member: EnumBase): + """Test lookup_value with various valid values""" + result = SampleBlock.lookup_value(value) + assert result == expected_member + + @pytest.mark.parametrize("input_any,expected_member", [ + ("BLOCK_A", SampleBlock.BLOCK_A), + ("block_a", SampleBlock.BLOCK_A), + ("block_b", SampleBlock.BLOCK_B), + (5, SampleBlock.HAS_NUM), + (3.14, SampleBlock.HAS_FLOAT), + (SampleBlock.BLOCK_A, SampleBlock.BLOCK_A), # Pass enum instance + ]) + def test_from_any_parametrized(self, input_any: Any, expected_member: EnumBase): + """Test from_any with various valid inputs""" + result = SampleBlock.from_any(input_any) + assert result == expected_member + + @pytest.mark.parametrize("invalid_key", [ + "NONEXISTENT", + "invalid", + "123", + "", + "BLOCK_C", + ]) + def test_lookup_key_invalid_parametrized(self, invalid_key: str): + """Test lookup_key with various invalid keys""" + with pytest.raises(ValueError, match="Invalid key"): + SampleBlock.lookup_key(invalid_key) + + @pytest.mark.parametrize("invalid_value", [ + "nonexistent", + 999, + -1, + 0.0, + "BLOCK_A", # This is a key name, not a value + ]) + def test_lookup_value_invalid_parametrized(self, invalid_value: Any): + """Test lookup_value with various invalid values""" + with pytest.raises(ValueError, match="Invalid value"): + SampleBlock.lookup_value(invalid_value) + + +# Edge cases and special scenarios +class TestEdgeCases: + """Test edge cases and special scenarios""" + + def test_enum_with_single_member(self): + """Test EnumBase with only one member""" + class SingleEnum(EnumBase): + ONLY_ONE = "single" + + result = SingleEnum.from_any("ONLY_ONE") + assert result == SingleEnum.ONLY_ONE + assert result.to_value() == "single" + + def test_enum_iteration(self): + """Test iterating over enum members""" + members = list(SampleBlock) + assert len(members) == 5 + assert SampleBlock.BLOCK_A in members + assert SampleBlock.BLOCK_B in members + assert SampleBlock.HAS_NUM in members + + def test_enum_membership(self): + """Test checking membership in enum""" + assert SampleBlock.BLOCK_A in SampleBlock + assert SampleBlock.HAS_NUM in SampleBlock + + def test_enum_comparison(self): + """Test comparing enum members""" + assert SampleBlock.BLOCK_A == SampleBlock.BLOCK_A + assert SampleBlock.BLOCK_A != SampleBlock.BLOCK_B + assert SampleBlock.from_any("BLOCK_A") == SampleBlock.BLOCK_A + + def test_enum_identity(self): + """Test enum member identity""" + member1 = SampleBlock.BLOCK_A + member2 = SampleBlock.lookup_key("BLOCK_A") + member3 = SampleBlock.from_any("BLOCK_A") + + assert member1 is member2 + assert member1 is member3 + assert member2 is member3 + + def test_different_enum_classes(self): + """Test that different enum classes are distinct""" + # Even if they have same keys/values, they're different + class OtherEnum(EnumBase): + BLOCK_A = "block_a" + + result1 = SampleBlock.from_any("BLOCK_A") + result2 = OtherEnum.from_any("BLOCK_A") + + assert result1 != result2 + assert not isinstance(result1, type(result2)) + + def test_numeric_enum_operations(self): + """Test operations specific to numeric enums""" + assert NumericEnum.FIRST.to_value() == 1 + assert NumericEnum.SECOND.to_value() == 2 + assert NumericEnum.THIRD.to_value() == 3 + + # Test from_any with integers + assert NumericEnum.from_any(1) == NumericEnum.FIRST + assert NumericEnum.from_any(2) == NumericEnum.SECOND + + def test_mixed_value_types_in_same_enum(self): + """Test enum with mixed value types""" + # SampleBlock already has mixed types (strings, int, float) + assert isinstance(SampleBlock.BLOCK_A.to_value(), str) + assert isinstance(SampleBlock.HAS_NUM.to_value(), int) + assert isinstance(SampleBlock.HAS_FLOAT.to_value(), float) + + def test_from_any_chained_calls(self): + """Test that from_any can be chained (idempotent)""" + result1 = SampleBlock.from_any("BLOCK_A") + result2 = SampleBlock.from_any(result1) + result3 = SampleBlock.from_any(result2) + + assert result1 == result2 == result3 + assert result1 is result2 is result3 + + +# Integration tests +class TestIntegration: + """Integration tests combining multiple methods""" + + def test_round_trip_key_lookup(self): + """Test round-trip from key to enum and back""" + original_key = "BLOCK_A" + enum_member = SampleBlock.lookup_key(original_key) + result_name = str(enum_member) + + assert result_name == original_key + + def test_round_trip_value_lookup(self): + """Test round-trip from value to enum and back""" + original_value = "block_a" + enum_member = SampleBlock.lookup_value(original_value) + result_value = enum_member.to_value() + + assert result_value == original_value + + def test_from_any_workflow(self): + """Test realistic workflow using from_any""" + # Simulate receiving various types of input + inputs = [ + "BLOCK_A", # Key as string + "block_b", # Value as string + 5, # Numeric value + SampleBlock.HAS_FLOAT, # Already an enum + ] + + expected = [ + SampleBlock.BLOCK_A, + SampleBlock.BLOCK_B, + SampleBlock.HAS_NUM, + SampleBlock.HAS_FLOAT, + ] + + for input_val, expected_val in zip(inputs, expected): + result = SampleBlock.from_any(input_val) + assert result == expected_val + + def test_enum_in_dictionary(self): + """Test using enum as dictionary key""" + enum_dict = { + SampleBlock.BLOCK_A: "Value A", + SampleBlock.BLOCK_B: "Value B", + SampleBlock.HAS_NUM: "Value Num", + } + + assert enum_dict[SampleBlock.BLOCK_A] == "Value A" + block_b = SampleBlock.from_any("BLOCK_B") + assert isinstance(block_b, SampleBlock) + assert enum_dict[block_b] == "Value B" + + def test_enum_in_set(self): + """Test using enum in a set""" + enum_set = {SampleBlock.BLOCK_A, SampleBlock.BLOCK_B, SampleBlock.BLOCK_A} + + assert len(enum_set) == 2 # BLOCK_A should be deduplicated + assert SampleBlock.BLOCK_A in enum_set + assert SampleBlock.from_any("BLOCK_B") in enum_set + + +# Real-world usage scenarios +class TestRealWorldScenarios: + """Test real-world usage scenarios from enum_test.py""" + + def test_original_enum_test_scenario(self): + """Test the scenario from the original enum_test.py""" + # BLOCK A: {SampleBlock.from_any('BLOCK_A')} + result_a = SampleBlock.from_any('BLOCK_A') + assert result_a == SampleBlock.BLOCK_A + assert str(result_a) == "BLOCK_A" + + # HAS NUM: {SampleBlock.from_any(5)} + result_num = SampleBlock.from_any(5) + assert result_num == SampleBlock.HAS_NUM + assert result_num.to_value() == 5 + + # DIRECT BLOCK: {SampleBlock.BLOCK_A.name} -> {SampleBlock.BLOCK_A.value} + assert SampleBlock.BLOCK_A.name == "BLOCK_A" + assert SampleBlock.BLOCK_A.value == "block_a" + + def test_config_value_parsing(self): + """Test parsing values from configuration (common use case)""" + # Simulate config values that might come as strings + config_values = ["OPTION_ONE", "option_two", "OPTION_THREE"] + + results = [SimpleEnum.from_any(val) for val in config_values] + + assert results[0] == SimpleEnum.OPTION_ONE + assert results[1] == SimpleEnum.OPTION_TWO + assert results[2] == SimpleEnum.OPTION_THREE + + def test_api_response_mapping(self): + """Test mapping API response values to enum""" + # Simulate API returning numeric codes + api_codes = [1, 2, 3] + + results = [NumericEnum.from_any(code) for code in api_codes] + + assert results[0] == NumericEnum.FIRST + assert results[1] == NumericEnum.SECOND + assert results[2] == NumericEnum.THIRD + + def test_validation_with_error_handling(self): + """Test validation with proper error handling""" + valid_input = "BLOCK_A" + invalid_input = "INVALID" + + # Valid input should work + result = SampleBlock.from_any(valid_input) + assert result == SampleBlock.BLOCK_A + + # Invalid input should raise ValueError + try: + SampleBlock.from_any(invalid_input) + assert False, "Should have raised ValueError" + except ValueError as e: + assert "Could not find as key or value" in str(e) + assert "INVALID" in str(e)