From 89b29e568d5f45498b31b51496ef6d59995a2f0d Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 12:58:30 +0100 Subject: [PATCH 1/7] 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 4213d1a610999e67505f554d4eb513be065c8be4 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 12:59:09 +0100 Subject: [PATCH 2/7] 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 57716c930dab6c6961bb37ebf22729d12cd95d30 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 13:00:08 +0100 Subject: [PATCH 3/7] 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 9a8914549bbe68c904af4f6e23d74a2fc51184ba Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Tue, 30 Dec 2025 17:02:06 +0100 Subject: [PATCH 4/7] 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} From 68fbe7ba62c6b4b6a1853b260b18ee0cc6673d59 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Fri, 2 Jan 2026 16:54:53 +0100 Subject: [PATCH 5/7] Raise error in case of lookup failure It can be that we fail to import the given module, the provided path is not a valid enum, or the enum does not contain the value we are looking for. We do this to avoid masking a potential user error. --- infrahub_sdk/template/filters.py | 12 +++-- tests/unit/sdk/test_template.py | 87 +++++++++++++++++--------------- 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index 371ca16f..5fede608 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -32,19 +32,21 @@ def value_to_enum_name(value: Any, enum_path: str | None = None) -> str: 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(raw_value) + except (ValueError, ImportError, AttributeError) as exc: + msg = f"Failed to resolve enum '{enum_path}': {exc}" + raise ValueError(msg) from exc # 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(raw_value) + raise ValueError(f"Resolved type '{enum_path}' is not a valid Enum") try: enum_member = enum_type(raw_value) if enum_member.name is not None: return enum_member.name - except (ValueError, TypeError): - pass + except (ValueError, TypeError) as exc: + msg = f"Value '{raw_value}' not found in enum '{enum_path}': {exc}" + raise ValueError(msg) from exc return str(raw_value) diff --git a/tests/unit/sdk/test_template.py b/tests/unit/sdk/test_template.py index 07db53de..e6f2d730 100644 --- a/tests/unit/sdk/test_template.py +++ b/tests/unit/sdk/test_template.py @@ -331,43 +331,47 @@ def test_validate_infrahub_filter_sorting() -> None: 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" +@pytest.mark.parametrize( + ("value", "enum_path", "expected"), + [ + pytest.param(1, TEST_ENUM_PATH, "DENY", id="int-value-deny"), + pytest.param(6, TEST_ENUM_PATH, "ALLOW_ALL", id="int-value-allow-all"), + pytest.param(SampleIntEnum.DENY, TEST_ENUM_PATH, "DENY", id="enum-input-deny"), + pytest.param(SampleIntEnum.ALLOW_ALL, TEST_ENUM_PATH, "ALLOW_ALL", id="enum-input-allow-all"), + pytest.param(6, None, "6", id="no-enum-path-int"), + pytest.param("test", None, "test", id="no-enum-path-str"), + ], +) +def test_value_to_enum_name_success(value: int | str | SampleIntEnum, enum_path: str | None, expected: str) -> None: + assert value_to_enum_name(value, enum_path) == expected -def test_value_to_enum_name_with_invalid_path_format() -> None: - assert value_to_enum_name(6, "NoDotInPath") == "6" +@pytest.mark.parametrize( + ("value", "enum_path", "error_pattern"), + [ + pytest.param( + 6, + "nonexistent.module.EnumClass", + r"Failed to resolve enum 'nonexistent\.module\.EnumClass'", + id="invalid-module", + ), + pytest.param(6, "enum.NonExistentEnum", r"Failed to resolve enum 'enum\.NonExistentEnum'", id="invalid-class"), + pytest.param( + "invalid", TEST_ENUM_PATH, f"Value 'invalid' not found in enum '{TEST_ENUM_PATH}'", id="invalid-value" + ), + pytest.param(0, TEST_ENUM_PATH, f"Value '0' not found in enum '{TEST_ENUM_PATH}'", id="zero-not-in-enum"), + pytest.param(6, "NoDotInPath", "Failed to resolve enum 'NoDotInPath'", id="invalid-path-format"), + pytest.param( + 6, + "dataclasses.dataclass", + r"Resolved type 'dataclasses\.dataclass' is not a valid Enum", + id="non-enum-class", + ), + ], +) +def test_value_to_enum_name_errors(value: int | str, enum_path: str, error_pattern: str) -> None: + with pytest.raises(ValueError, match=error_pattern): + value_to_enum_name(value, enum_path) VALUE_TO_ENUM_NAME_FILTER_TEST_CASES = [ @@ -401,13 +405,6 @@ def test_value_to_enum_name_with_invalid_path_format() -> None: 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"], - ), ] @@ -419,3 +416,9 @@ async def test_value_to_enum_name_filter_in_templates(test_case: JinjaTestCase) 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() + + +async def test_value_to_enum_name_filter_in_templates_with_invalid_path() -> None: + jinja = Jinja2Template(template="{{ decision | value_to_enum_name('invalid.path.Enum') }}") + with pytest.raises(JinjaTemplateError): + await jinja.render(variables={"decision": 6}) From 9edaf7a6c359e03c5f25d67b87e0aab542347234 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Mon, 5 Jan 2026 11:08:31 +0100 Subject: [PATCH 6/7] Fix unreachable code path --- infrahub_sdk/template/filters.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index 5fede608..121b8592 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -41,15 +41,11 @@ def value_to_enum_name(value: Any, enum_path: str | None = None) -> str: raise ValueError(f"Resolved type '{enum_path}' is not a valid Enum") try: - enum_member = enum_type(raw_value) - if enum_member.name is not None: - return enum_member.name + return enum_type(raw_value).name except (ValueError, TypeError) as exc: msg = f"Value '{raw_value}' not found in enum '{enum_path}': {exc}" raise ValueError(msg) from exc - return str(raw_value) - INFRAHUB_FILTERS = {"value_to_enum_name": value_to_enum_name} INFRAHUB_FILTER_DEFINITIONS = [ From 97dbf0eba1d454ea37a550436e5d05f8a1013454 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Mon, 5 Jan 2026 14:25:58 +0100 Subject: [PATCH 7/7] Add more details to the docstring --- infrahub_sdk/template/filters.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index 121b8592..00810e96 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -15,10 +15,12 @@ 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. + enum class if provided. The enum class can be any valid enum as long as its full import path is given. - For example, `{{ decision__value | value_to_enum_name("infrahub.core.constants.PermissionDecision") }}` - will return: `"ALLOW_ALL"` for value `6`. + If the value is already an instance of `Enum` and no `enum_path` is provided, it simply returns the name of the + enum member which is equivalent to using `{{ value.name }}` in a Jinja2 template. + + Usage example: `{{ decision__value | value_to_enum_name("infrahub.core.constants.PermissionDecision") }}` will return `"ALLOW_ALL"` for value `6`. """ if isinstance(value, Enum) and not enum_path: return value.name