diff --git a/authentik/blueprints/tests/fixtures/tags.yaml b/authentik/blueprints/tests/fixtures/tags.yaml index b0e53727f0b0..65284054e1f4 100644 --- a/authentik/blueprints/tests/fixtures/tags.yaml +++ b/authentik/blueprints/tests/fixtures/tags.yaml @@ -121,6 +121,16 @@ entries: SEQ, !Format ["prefixed-items-%%s-%%s", !Index 0, !Value 0] ] + enumerate_nested_sequence_to_flat_sequence: !Enumerate [ + [!Context sequence, !Context sequence, scalar_value, [[deeply_nested]]], + FLAT_SEQ, + !Value 0 + ] + enumerate_sequence_to_uniq_sequence: !Enumerate [ + ["foo", "bar", "foo"], + UNIQ_SEQ, + !Value 0 + ] enumerate_sequence_to_mapping: !Enumerate [ !Context sequence, MAP, diff --git a/authentik/blueprints/tests/test_v1.py b/authentik/blueprints/tests/test_v1.py index 04cabe36cc3f..1c1e517f18f3 100644 --- a/authentik/blueprints/tests/test_v1.py +++ b/authentik/blueprints/tests/test_v1.py @@ -186,6 +186,18 @@ def test_import_yaml_tags(self): "prefixed-items-0-foo", "prefixed-items-1-bar", ], + "enumerate_nested_sequence_to_flat_sequence": [ + "foo", + "bar", + "foo", + "bar", + "scalar_value", + "deeply_nested", + ], + "enumerate_sequence_to_uniq_sequence": [ + "foo", + "bar", + ], "enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"}, "nested_complex_enumeration": { "0": { diff --git a/authentik/blueprints/v1/common.py b/authentik/blueprints/v1/common.py index b27929358cbd..b46c39ea72f1 100644 --- a/authentik/blueprints/v1/common.py +++ b/authentik/blueprints/v1/common.py @@ -15,6 +15,7 @@ from deepmerge import always_merger from django.apps import apps from django.db.models import Model, Q +from more_itertools import collapse, unique_everseen from rest_framework.exceptions import ValidationError from rest_framework.fields import Field from rest_framework.serializers import Serializer @@ -504,13 +505,16 @@ class Enumerate(YAMLTag, YAMLTagContext): iterable: YAMLTag | Iterable item_body: Any - output_body: Literal["SEQ", "MAP"] + output_body: Literal["SEQ", "FLAT_SEQ", "UNIQ_SEQ", "MAP"] _OUTPUT_BODIES = { - "SEQ": (list, lambda a, b: [*a, b]), + "SEQ": (list, lambda a, b: [*a, b], None), + "FLAT_SEQ": (list, lambda a, b: [*a, b], lambda x: list(collapse(x))), + "UNIQ_SEQ": (list, lambda a, b: [*a, b], lambda x: list(unique_everseen(x, key=tuple))), "MAP": ( dict, lambda a, b: always_merger.merge(a, {b[0]: b[1]} if isinstance(b, tuple | list) else b), + None, ), } @@ -550,7 +554,7 @@ def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: iterable = tuple(enumerate(iterable)) try: - output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()] + output_class, add_fn, post_process_fn = self._OUTPUT_BODIES[self.output_body.upper()] except KeyError as exc: raise EntryInvalidError.from_entry(exc, entry) from exc @@ -570,7 +574,7 @@ def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: finally: self.__current_context = tuple() - return result + return post_process_fn(result) if post_process_fn else result class EnumeratedItem(YAMLTag): diff --git a/pyproject.toml b/pyproject.toml index 37bd42efedea..e7d3a1449378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "kubernetes==35.0.0", "ldap3==2.9.1", "lxml==6.0.2", + "more-itertools>=10.8.0", "msgraph-sdk==1.55.0", "opencontainers==0.0.15", "packaging==26.0", diff --git a/uv.lock b/uv.lock index 2593321d6590..ddd54948718d 100644 --- a/uv.lock +++ b/uv.lock @@ -245,6 +245,7 @@ dependencies = [ { name = "kubernetes" }, { name = "ldap3" }, { name = "lxml" }, + { name = "more-itertools" }, { name = "msgraph-sdk" }, { name = "opencontainers" }, { name = "packaging" }, @@ -353,6 +354,7 @@ requires-dist = [ { name = "kubernetes", specifier = "==35.0.0" }, { name = "ldap3", specifier = "==2.9.1" }, { name = "lxml", specifier = "==6.0.2" }, + { name = "more-itertools", specifier = ">=10.8.0" }, { name = "msgraph-sdk", specifier = "==1.55.0" }, { name = "opencontainers", git = "https://github.com/vsoch/oci-python?rev=ceb4fcc090851717a3069d78e85ceb1e86c2740c" }, { name = "packaging", specifier = "==26.0" }, @@ -2320,6 +2322,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/e5/bd382327e7ddaaa091f1f323d760408213136f41940d074b2ffffd9a1127/microsoft_kiota_serialization_text-1.9.8-py3-none-any.whl", hash = "sha256:dd89ae49693623c0e1d8f07414aa7d71b34959a5253131b8b63d81920f08c6b1", size = 8883, upload-time = "2025-12-29T15:25:23.662Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "msal" version = "1.34.0" diff --git a/website/docs/customize/blueprints/v1/tags.mdx b/website/docs/customize/blueprints/v1/tags.mdx index 04a786b1cb3d..f00e845ddcae 100644 --- a/website/docs/customize/blueprints/v1/tags.mdx +++ b/website/docs/customize/blueprints/v1/tags.mdx @@ -208,9 +208,13 @@ This tag takes 3 arguments: ``` - **iterable**: Any Python iterable or custom tag that resolves to such iterable -- **output_object_type**: `SEQ` or `MAP`. Controls whether the returned YAML will be a mapping or a sequence. +- **output_object_type**: `SEQ`, `FLAT_SEQ`, `UNIQ_SEQ` or `MAP`. Controls whether the returned YAML will be a mapping or a sequence. With `FLAT_SEQ` the resulting list will also be flattened, while with `UNIQ_SEQ` duplicated entries will be removed - **single_item_yaml**: The YAML to use to create a single entry in the output object +:::info +`FLAT_SEQ` and `UNIQ_SEQ` require authentik 2026.05+ +::: + 2. `!Index` tag: :::info