From 81f4d6af70a0c9f8f063bd37de0508c0dbe50462 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 12:58:30 +0100 Subject: [PATCH 1/4] Add `value_to_enum_name` filter This Jinja2 filter aims to convert a value into a name when it is part of an enum. This is useful when the name of an enum member has more meaning, e.g. in a string which is exposed to users. An optional `enum_path` parameter can be specified. When using a valid import string for an enum class, that class will be imported to look for the value within the enum members. This mean that this filter can be used with any enum classes, including the ones found in the Infrahub server code. --- infrahub_sdk/template/filters.py | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index 1d082b39..d71f8ce8 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -1,4 +1,7 @@ from dataclasses import dataclass +from enum import Enum +from importlib import import_module +from typing import Any @dataclass @@ -8,6 +11,42 @@ class FilterDefinition: source: str +def value_to_enum_name(value: Any, enum_path: str | None = None) -> str: + """Convert a value to its enum member name using the specified enum class. + + This filter takes a raw value and converts it to the corresponding enum member name by dynamically importing the + enum class. + + For example, `{{ decision__value | value_to_enum_name("infrahub.permissions.constants.PermissionDecisionFlag") }}` + will return: `"ALLOW_ALL"` for value `6`. + """ + if isinstance(value, Enum): + return value.name + + if not enum_path: + return str(value) + + try: + module_path, class_name = enum_path.rsplit(".", 1) + module = import_module(module_path) + enum_type = getattr(module, class_name) + except (ValueError, ImportError, AttributeError): + return str(value) + + # Verify that we have a class and that this class is a valid Enum + if not (isinstance(enum_type, type) and issubclass(enum_type, Enum)): + return str(value) + + try: + enum_member = enum_type(value) + if enum_member.name is not None: + return enum_member.name + except (ValueError, TypeError): + pass + + return str(value) + + BUILTIN_FILTERS = [ FilterDefinition(name="abs", trusted=True, source="jinja2"), FilterDefinition(name="attr", trusted=False, source="jinja2"), From 326c32f68b8b9d5c5984431c4413d7ae3fc29cae Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 12:59:09 +0100 Subject: [PATCH 2/4] Expose the filter to the templating environment --- infrahub_sdk/template/__init__.py | 6 +++++- infrahub_sdk/template/filters.py | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/template/__init__.py b/infrahub_sdk/template/__init__.py index 910ec216..53092157 100644 --- a/infrahub_sdk/template/__init__.py +++ b/infrahub_sdk/template/__init__.py @@ -19,7 +19,7 @@ JinjaTemplateSyntaxError, JinjaTemplateUndefinedError, ) -from .filters import AVAILABLE_FILTERS +from .filters import AVAILABLE_FILTERS, INFRAHUB_FILTERS from .models import UndefinedJinja2Error netutils_filters = jinja2_convenience_function() @@ -155,6 +155,10 @@ def _set_filters(self, env: jinja2.Environment) -> None: env.filters.update( {name: jinja_filter for name, jinja_filter in netutils_filters.items() if name in self._available_filters} ) + # Add filters from our own SDK + env.filters.update( + {name: jinja_filter for name, jinja_filter in INFRAHUB_FILTERS.items() if name in self._available_filters} + ) # Add user supplied filters env.filters.update(self._filters) diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index d71f8ce8..cc70b514 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -47,6 +47,12 @@ def value_to_enum_name(value: Any, enum_path: str | None = None) -> str: return str(value) +INFRAHUB_FILTERS = {"value_to_enum_name": value_to_enum_name} +INFRAHUB_FILTER_DEFINITIONS = [ + FilterDefinition(name=name, trusted=True, source="infrahub-sdk-python") for name in sorted(INFRAHUB_FILTERS.keys()) +] + + BUILTIN_FILTERS = [ FilterDefinition(name="abs", trusted=True, source="jinja2"), FilterDefinition(name="attr", trusted=False, source="jinja2"), @@ -187,4 +193,4 @@ def value_to_enum_name(value: Any, enum_path: str | None = None) -> str: ] -AVAILABLE_FILTERS = BUILTIN_FILTERS + NETUTILS_FILTERS +AVAILABLE_FILTERS = BUILTIN_FILTERS + NETUTILS_FILTERS + INFRAHUB_FILTER_DEFINITIONS From a59f7ec74aa4f7fee81a418907799b400ed12a9a Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 13:00:08 +0100 Subject: [PATCH 3/4] Add tests to validate the filter behaviour --- tests/unit/sdk/test_template.py | 109 ++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/unit/sdk/test_template.py b/tests/unit/sdk/test_template.py index 2554dc46..07db53de 100644 --- a/tests/unit/sdk/test_template.py +++ b/tests/unit/sdk/test_template.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from enum import IntEnum from pathlib import Path from typing import Any @@ -16,8 +17,10 @@ ) from infrahub_sdk.template.filters import ( BUILTIN_FILTERS, + INFRAHUB_FILTER_DEFINITIONS, NETUTILS_FILTERS, FilterDefinition, + value_to_enum_name, ) from infrahub_sdk.template.models import UndefinedJinja2Error @@ -310,3 +313,109 @@ def _compare_errors(expected: JinjaTemplateError, received: JinjaTemplateError) else: raise Exception("This should never happen") + + +class SampleIntEnum(IntEnum): + DENY = 1 + ALLOW_DEFAULT = 2 + ALLOW_OTHER = 4 + ALLOW_ALL = 6 + + +TEST_ENUM_PATH = "tests.unit.sdk.test_template.SampleIntEnum" + + +def test_validate_infrahub_filter_sorting() -> None: + """Test to validate that infrahub-sdk-python filter names are in alphabetical order.""" + names = [filter_definition.name for filter_definition in INFRAHUB_FILTER_DEFINITIONS] + assert names == sorted(names) + + +def test_value_to_enum_name_with_full_import_path() -> None: + for enum_entry in SampleIntEnum: + assert value_to_enum_name(enum_entry.value, TEST_ENUM_PATH) == enum_entry.name + + +def test_value_to_enum_name_with_enum_input() -> None: + assert value_to_enum_name(SampleIntEnum.DENY, TEST_ENUM_PATH) == "DENY" + assert value_to_enum_name(SampleIntEnum.ALLOW_ALL, TEST_ENUM_PATH) == "ALLOW_ALL" + + +def test_value_to_enum_name_without_enum_path() -> None: + assert value_to_enum_name(6) == "6" + assert value_to_enum_name("test") == "test" + + +def test_value_to_enum_name_with_invalid_module() -> None: + assert value_to_enum_name(6, "nonexistent.module.EnumClass") == "6" + + +def test_value_to_enum_name_with_invalid_class() -> None: + assert value_to_enum_name(6, "enum.NonExistentEnum") == "6" + + +def test_value_to_enum_name_with_non_enum_class() -> None: + assert value_to_enum_name(6, "dataclasses.dataclass") == "6" + + +def test_value_to_enum_name_with_invalid_value() -> None: + assert value_to_enum_name("invalid", TEST_ENUM_PATH) == "invalid" + + +def test_value_to_enum_name_with_zero() -> None: + assert value_to_enum_name(0, TEST_ENUM_PATH) == "0" + + +def test_value_to_enum_name_with_invalid_path_format() -> None: + assert value_to_enum_name(6, "NoDotInPath") == "6" + + +VALUE_TO_ENUM_NAME_FILTER_TEST_CASES = [ + JinjaTestCase( + name="value-to-enum-name-with-full-path-deny", + template="{{ decision | value_to_enum_name('" + TEST_ENUM_PATH + "') }}", + variables={"decision": 1}, + expected="DENY", + expected_variables=["decision"], + ), + JinjaTestCase( + name="value-to-enum-name-with-full-path-allow-all", + template="{{ decision | value_to_enum_name('" + TEST_ENUM_PATH + "') }}", + variables={"decision": 6}, + expected="ALLOW_ALL", + expected_variables=["decision"], + ), + JinjaTestCase( + name="value-to-enum-name-global-permission-format", + template="global:{{ action }}:{{ decision | value_to_enum_name('" + TEST_ENUM_PATH + "') | lower }}", + variables={"action": "manage_accounts", "decision": 6}, + expected="global:manage_accounts:allow_all", + expected_variables=["action", "decision"], + ), + JinjaTestCase( + name="value-to-enum-name-object-permission-format", + template="object:{{ ns }}:{{ nm }}:{{ action }}:{{ decision | value_to_enum_name('" + + TEST_ENUM_PATH + + "') | lower }}", + variables={"ns": "Infra", "nm": "Device", "action": "view", "decision": 2}, + expected="object:Infra:Device:view:allow_default", + expected_variables=["action", "decision", "nm", "ns"], + ), + JinjaTestCase( + name="value-to-enum-name-with-invalid-path", + template="{{ decision | value_to_enum_name('invalid.path.Enum') }}", + variables={"decision": 6}, + expected="6", + expected_variables=["decision"], + ), +] + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in VALUE_TO_ENUM_NAME_FILTER_TEST_CASES], +) +async def test_value_to_enum_name_filter_in_templates(test_case: JinjaTestCase) -> None: + jinja = Jinja2Template(template=test_case.template) + assert test_case.expected == await jinja.render(variables=test_case.variables) + assert test_case.expected_variables == jinja.get_variables() From 5ebde431b95872db0777d7e5729fafecd3dca824 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 17:02:06 +0100 Subject: [PATCH 4/4] Fix when using different enum than original --- infrahub_sdk/template/filters.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index cc70b514..371ca16f 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -17,34 +17,36 @@ def value_to_enum_name(value: Any, enum_path: str | None = None) -> str: This filter takes a raw value and converts it to the corresponding enum member name by dynamically importing the enum class. - For example, `{{ decision__value | value_to_enum_name("infrahub.permissions.constants.PermissionDecisionFlag") }}` + For example, `{{ decision__value | value_to_enum_name("infrahub.core.constants.PermissionDecision") }}` will return: `"ALLOW_ALL"` for value `6`. """ - if isinstance(value, Enum): + if isinstance(value, Enum) and not enum_path: return value.name + raw_value = value.value if isinstance(value, Enum) else value + if not enum_path: - return str(value) + return str(raw_value) try: module_path, class_name = enum_path.rsplit(".", 1) module = import_module(module_path) enum_type = getattr(module, class_name) except (ValueError, ImportError, AttributeError): - return str(value) + return str(raw_value) # Verify that we have a class and that this class is a valid Enum if not (isinstance(enum_type, type) and issubclass(enum_type, Enum)): - return str(value) + return str(raw_value) try: - enum_member = enum_type(value) + enum_member = enum_type(raw_value) if enum_member.name is not None: return enum_member.name except (ValueError, TypeError): pass - return str(value) + return str(raw_value) INFRAHUB_FILTERS = {"value_to_enum_name": value_to_enum_name}