Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature filter keep_keys #8456

Merged
Merged
4 changes: 4 additions & 0 deletions .github/BOTMETA.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ files:
maintainers: cfiehe
$filters/lists_intersect.yml:
maintainers: cfiehe
$filters/keep_keys.py:
maintainers: vbotka
vbotka marked this conversation as resolved.
Show resolved Hide resolved
$filters/lists_mergeby.py:
maintainers: vbotka
$filters/lists_symmetric_difference.yml:
Expand Down Expand Up @@ -1419,6 +1421,8 @@ files:
maintainers: $team_suse
$plugin_utils/unsafe.py:
maintainers: felixfontein
$plugin_utils/keys_filter.py:
maintainers: vbotka
vbotka marked this conversation as resolved.
Show resolved Hide resolved
$tests/a_module.py:
maintainers: felixfontein
$tests/fqdn_valid.py:
Expand Down
140 changes: 140 additions & 0 deletions plugins/filter/keep_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2024 Vladimir Botka <vbotka@gmail.com>
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

DOCUMENTATION = '''
name: keep_keys
short_description: Keep specific keys from dictionaries in a list
version_added: "9.1.0"
author:
- Vladimir Botka (@vbotka)
- Felix Fontein (@felixfontein)
description: This filter keeps only specified keys from a provided list of dictionaries.
options:
_input:
description:
- A list of dictionaries.
- All keys must be strings.
type: list
elements: dictionary
required: true
target:
description:
- A key or key pattern to keep, or
- A list of keys or keys patterns to keep.
- The interpretation of O(target) depends on the option O(matching_parameter).
- Single item is required in O(target) for O(matching_parameter=regex).
vbotka marked this conversation as resolved.
Show resolved Hide resolved
type: raw
required: true
matching_parameter:
description: Specify the matching option of target keys.
type: str
default: equal
choices:
equal: Matches keys of exactly one of the O(target) items.
starts_with: Matches keys that start with one of the O(target) items.
ends_with: Matches keys that end with one of the O(target) items.
regex:
- Matches keys that match the regular expresion provided in O(target).
- In this case, O(target) must be a regex string or a list with single regex string.
'''

EXAMPLES = '''
l:
- {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo}
- {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar}

# 1) By default match keys that equal any of the items in the target.
t: [k0_x0, k1_x1]
r: "{{ l | keep_keys(target=t) }}"

# 2) Match keys that start with any of the items in the target.
t: [k0, k1]
r: "{{ l | keep_keys(target=t, matching_parameter='starts_with') }}"

# 3) Match keys that end with any of the items in target.
t: [x0, x1]
r: "{{ l | keep_keys(target=t, matching_parameter='ends_with') }}"

# 4) Match keys by the regex.
t: ['^.*[01]_x.*$']
r: "{{ l | keep_keys(target=t, matching_parameter='regex') }}"

# 5) Match keys by the regex.
t: '^.*[01]_x.*$'
r: "{{ l | keep_keys(target=t, matching_parameter='regex') }}"

# The results of above examples 1-5 are all the same.
r:
- {k0_x0: A0, k1_x1: B0}
- {k0_x0: A1, k1_x1: B1}

# 6) By default match keys that equal the target.
t: k0_x0
r: "{{ l | keep_keys(target=t) }}"

# 7) Match keys that start with the target.
t: k0
r: "{{ l | keep_keys(target=t, matching_parameter='starts_with') }}"

# 8) Match keys that end with the target.
t: x0
r: "{{ l | keep_keys(target=t, matching_parameter='ends_with') }}"

# 9) Match keys by the regex.
t: '^.*0_x.*$'
r: "{{ l | keep_keys(target=t, matching_parameter='regex') }}"

# The results of above examples 6-9 are all the same.
r:
- {k0_x0: A0}
- {k0_x0: A1}
'''

RETURN = '''
_value:
description: The list of dictionaries with selected keys.
type: list
elements: dictionary
'''

from ansible_collections.community.general.plugins.plugin_utils.keys_filter import (
_keys_filter_params,
_keys_filter_target_str)


def keep_keys(data, target=None, matching_parameter='equal'):
"""keep specific keys from dictionaries in a list"""

# test parameters
_keys_filter_params(data, target, matching_parameter)
# test and transform target
tt = _keys_filter_target_str(target, matching_parameter)

if matching_parameter == 'equal':
def keep_key(key):
return key in tt
elif matching_parameter == 'starts_with':
def keep_key(key):
return key.startswith(tt)
elif matching_parameter == 'ends_with':
def keep_key(key):
return key.endswith(tt)
elif matching_parameter == 'regex':
def keep_key(key):
return tt.match(key) is not None

return [dict((k, v) for k, v in d.items() if keep_key(k)) for d in data]


class FilterModule(object):

def filters(self):
return {
'keep_keys': keep_keys,
}
108 changes: 108 additions & 0 deletions plugins/plugin_utils/keys_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Copyright (c) 2024 Vladimir Botka <vbotka@gmail.com>
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import re

from ansible.errors import AnsibleFilterError
from ansible.module_utils.six import string_types
from ansible.module_utils.common._collections_compat import Mapping, Sequence


def _keys_filter_params(data, target, matching_parameter):
"""test parameters:
* data must be a list of dictionaries. All keys must be strings.
* target must be a non-empty sequence.
* matching_parameter is member of a list.
"""

mp = matching_parameter
ml = ['equal', 'starts_with', 'ends_with', 'regex']

if not isinstance(data, Sequence):
msg = "First argument must be a list. %s is %s"
raise AnsibleFilterError(msg % (data, type(data)))

for elem in data:
if not isinstance(elem, Mapping):
msg = "The data items must be dictionaries. %s is %s"
raise AnsibleFilterError(msg % (elem, type(elem)))

for elem in data:
if not all(isinstance(item, string_types) for item in elem.keys()):
msg = "All keys must be strings. keys: %s"
raise AnsibleFilterError(msg % elem.keys())

if not isinstance(target, Sequence):
msg = ("The target must be a list. It can be a string if matching_parameter is regex."
" target is %s.")
raise AnsibleFilterError(msg % target)

if len(target) == 0:
msg = ("The target can't be empty.")
raise AnsibleFilterError(msg)

if mp not in ml:
msg = ("The matching_parameter must be one of %s. matching_parameter is %s")
raise AnsibleFilterError(msg % (ml, mp))

return


def _keys_filter_target_str(target, matching_parameter):
"""test:
* target is a list of strings, or
* target is a string or list with single string if matching_parameter=regex
convert and return:
* tuple of unique target items, or
* compiled regex if matching_parameter=regex
"""

if isinstance(target, list):
for elem in target:
if not isinstance(elem, string_types):
msg = "The target items must be strings. %s is %s"
raise AnsibleFilterError(msg % (elem, type(elem)))

if matching_parameter == 'regex':
if isinstance(target, string_types):
r = target
else:
if len(target) > 1:
msg = ("Single item is required in the target list if matching_parameter is regex.")
raise AnsibleFilterError(msg)
else:
r = target[0]
try:
tt = re.compile(r)
except re.error:
msg = ("The target must be a valid regex if matching_parameter is regex."
" target is %s")
raise AnsibleFilterError(msg % r)
elif isinstance(target, string_types):
tt = (target, )
else:
tt = tuple(target)
vbotka marked this conversation as resolved.
Show resolved Hide resolved

return tt


def _keys_filter_target_dict(target, matching_parameter):
"""test:
* target is a list of dictionaries
* ...
"""

# TODO: Complete and use this in filter replace_keys

if isinstance(target, list):
for elem in target:
if not isinstance(elem, Mapping):
msg = "The target items must be dictionary. %s is %s"
raise AnsibleFilterError(msg % (elem, type(elem)))

return
5 changes: 5 additions & 0 deletions tests/integration/targets/filter_keep_keys/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

azp/posix/2
79 changes: 79 additions & 0 deletions tests/integration/targets/filter_keep_keys/tasks/keep_keys.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

- name: Debug ansible_version
ansible.builtin.debug:
var: ansible_version
when: not quite_test | d(true) | bool
tags: ansible_version

- name: Test keep keys equal (default)
ansible.builtin.assert:
that:
- (rr | difference(result1) | length) == 0
success_msg: |
[OK] result:
{{ rr | to_yaml }}
fail_msg: |
[ERR] result:
{{ rr | to_yaml }}
quiet: "{{ quiet_test | d(true) | bool }}"
vars:
rr: "{{ list1 | community.general.keep_keys(target=tt) }}"
tt: [k0_x0, k1_x1]
tags: equal_default

- name: Test keep keys regex string
ansible.builtin.assert:
that:
- (rr | difference(result1) | length) == 0
success_msg: |
[OK] result:
{{ rr | to_yaml }}
fail_msg: |
[ERR] result:
{{ rr | to_yaml }}
quiet: "{{ quiet_test | d(true) | bool }}"
vars:
rr: "{{ list1 | community.general.keep_keys(target=tt, matching_parameter=mp) }}"
mp: regex
tt: '^.*[01]_x.*$'
tags: regex_string

- name: Test keep keys targets1
ansible.builtin.assert:
that:
- (rr | difference(result1) | length) == 0
success_msg: |
[OK] result:
{{ rr | to_yaml }}
fail_msg: |
[ERR] result:
{{ rr | to_yaml }}
quiet: "{{ quiet_test | d(true) | bool }}"
loop: "{{ targets1 }}"
loop_control:
label: "{{ item.mp }}: {{ item.tt }}"
vars:
rr: "{{ list1 | community.general.keep_keys(target=item.tt, matching_parameter=item.mp) }}"
tags: targets1

- name: Test keep keys targets2
ansible.builtin.assert:
that:
- (rr | difference(result2) | length) == 0
success_msg: |
[OK] result:
{{ rr | to_yaml }}
fail_msg: |
[ERR] result:
{{ rr | to_yaml }}
quiet: "{{ quiet_test | d(true) | bool }}"
loop: "{{ targets2 }}"
loop_control:
label: "{{ item.mp }}: {{ item.tt }}"
vars:
rr: "{{ list2 | community.general.keep_keys(target=item.tt, matching_parameter=item.mp) }}"
tags: targets2
7 changes: 7 additions & 0 deletions tests/integration/targets/filter_keep_keys/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

- name: Test keep_keys
import_tasks: keep_keys.yml
33 changes: 33 additions & 0 deletions tests/integration/targets/filter_keep_keys/vars/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

targets1:
- {mp: equal, tt: [k0_x0, k1_x1]}
- {mp: starts_with, tt: [k0, k1]}
- {mp: ends_with, tt: [x0, x1]}
- {mp: regex, tt: ['^.*[01]_x.*$']}
- {mp: regex, tt: '^.*[01]_x.*$'}

list1:
- {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo}
- {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar}

result1:
- {k0_x0: A0, k1_x1: B0}
- {k0_x0: A1, k1_x1: B1}

targets2:
- {mp: equal, tt: k0_x0}
- {mp: starts_with, tt: k0}
- {mp: ends_with, tt: x0}
- {mp: regex, tt: '^.*0_x.*$'}

list2:
- {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo}
- {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar}

result2:
- {k0_x0: A0}
- {k0_x0: A1}
Loading