From 9642a15d346930ab5cbb66304e43faa6456bb825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Keil?= Date: Sun, 26 Dec 2021 14:56:21 +0100 Subject: [PATCH] Add counter filter (#3921) * Add counter filter * move counter filter doc to existing chapter * Use existing typerror exception from Counter * Match counter filter example task name and output --- .github/BOTMETA.yml | 2 + .../3921-add-counter-filter-plugin.yml | 4 + docs/docsite/rst/filter_guide.rst | 78 +++++++++++++++++++ plugins/filter/counter.py | 36 +++++++++ .../targets/filter_counter/aliases | 2 + .../targets/filter_counter/tasks/main.yml | 37 +++++++++ 6 files changed, 159 insertions(+) create mode 100644 changelogs/fragments/3921-add-counter-filter-plugin.yml create mode 100644 plugins/filter/counter.py create mode 100644 tests/integration/targets/filter_counter/aliases create mode 100644 tests/integration/targets/filter_counter/tasks/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 62a07052da..bf99fdb75b 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -118,6 +118,8 @@ files: $doc_fragments/xenserver.py: maintainers: bvitnik labels: xenserver + $filters/counter.py: + maintainers: keilr $filters/dict.py: maintainers: felixfontein $filters/dict_kv.py: diff --git a/changelogs/fragments/3921-add-counter-filter-plugin.yml b/changelogs/fragments/3921-add-counter-filter-plugin.yml new file mode 100644 index 0000000000..894f390426 --- /dev/null +++ b/changelogs/fragments/3921-add-counter-filter-plugin.yml @@ -0,0 +1,4 @@ +--- +add plugin.filter: + - name: counter + description: Counts hashable elements in a sequence diff --git a/docs/docsite/rst/filter_guide.rst b/docs/docsite/rst/filter_guide.rst index dab8464439..18e4158d44 100644 --- a/docs/docsite/rst/filter_guide.rst +++ b/docs/docsite/rst/filter_guide.rst @@ -297,6 +297,84 @@ This produces: .. versionadded: 2.0.0 +Counting elements in a sequence +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``community.general.counter`` filter plugin allows you to count (hashable) elements in a sequence. Elements are returned as dictionary keys and their counts are stored as dictionary values. + +.. code-block:: yaml+jinja + + - name: Count character occurrences in a string + debug: + msg: "{{ 'abccbaabca' | community.general.counter }}" + + - name: Count items in a list + debug: + msg: "{{ ['car', 'car', 'bike', 'plane', 'bike'] | community.general.counter }}" + +This produces: + +.. code-block:: ansible-output + + TASK [Count character occurrences in a string] ******************************************** + ok: [localhost] => { + "msg": { + "a": 4, + "b": 3, + "c": 3 + } + } + + TASK [Count items in a list] ************************************************************** + ok: [localhost] => { + "msg": { + "bike": 2, + "car": 2, + "plane": 1 + } + } + +This plugin is useful for selecting resources based on current allocation: + +.. code-block:: yaml+jinja + + - name: Get ID of SCSI controller(s) with less than 4 disks attached and choose the one with the least disks + debug: + msg: >- + {{ + ( disks | dict2items | map(attribute='value.adapter') | list + | community.general.counter | dict2items + | rejectattr('value', '>=', 4) | sort(attribute='value') | first + ).key + }} + vars: + disks: + sda: + adapter: scsi_1 + sdb: + adapter: scsi_1 + sdc: + adapter: scsi_1 + sdd: + adapter: scsi_1 + sde: + adapter: scsi_2 + sdf: + adapter: scsi_3 + sdg: + adapter: scsi_3 + +This produces: + +.. code-block:: ansible-output + + TASK [Get ID of SCSI controller(s) with less than 4 disks attached and choose the one with the least disks] + ok: [localhost] => { + "msg": "scsi_2" + } + +.. versionadded:: 4.3.0 + Working with times ------------------ diff --git a/plugins/filter/counter.py b/plugins/filter/counter.py new file mode 100644 index 0000000000..ad957fce21 --- /dev/null +++ b/plugins/filter/counter.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Remy Keil +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.common._collections_compat import Sequence +from collections import Counter + + +def counter(sequence): + ''' Count elements in a sequence. Returns dict with count result. ''' + if not isinstance(sequence, Sequence): + raise AnsibleFilterError('Argument for community.general.counter must be a sequence (string or list). %s is %s' % + (sequence, type(sequence))) + + try: + result = dict(Counter(sequence)) + except TypeError as e: + raise AnsibleFilterError( + "community.general.counter needs a sequence with hashable elements (int, float or str) - %s" % (e) + ) + return result + + +class FilterModule(object): + ''' Ansible counter jinja2 filters ''' + + def filters(self): + filters = { + 'counter': counter, + } + + return filters diff --git a/tests/integration/targets/filter_counter/aliases b/tests/integration/targets/filter_counter/aliases new file mode 100644 index 0000000000..f04737b845 --- /dev/null +++ b/tests/integration/targets/filter_counter/aliases @@ -0,0 +1,2 @@ +shippable/posix/group2 +skip/python2.6 # filters are controller only, and we no longer support Python 2.6 on the controller diff --git a/tests/integration/targets/filter_counter/tasks/main.yml b/tests/integration/targets/filter_counter/tasks/main.yml new file mode 100644 index 0000000000..69f8bed3b0 --- /dev/null +++ b/tests/integration/targets/filter_counter/tasks/main.yml @@ -0,0 +1,37 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- name: test counter filter + assert: + that: + - "('abca' | community.general.counter) == {'a': 2, 'b': 1, 'c': 1}" + - "(['apple', 'pear', 'pear'] | community.general.counter) == {'apple': 1, 'pear': 2}" + - "([1, 2, 2, 3] | community.general.counter) == {1: 1, 2: 2, 3: 1}" + - "([1.11, 1.11, 1.12] | community.general.counter) == {1.11: 2, 1.12: 1}" + +- name: test fail argument not a sequence + debug: + msg: "{{ {'a': 'b'} | community.general.counter }}" + ignore_errors: yes + register: res + +- name: verify test fail argument not a sequence + assert: + that: + - res is failed + - res.msg is match('Argument for community.general.counter must be a sequence') + +- name: test fail element not hashable + debug: + msg: "{{ [{'a': 'b'}] | community.general.counter }}" + ignore_errors: yes + register: res + +- name: verify test fail element not hashable + assert: + that: + - res is failed + - res.msg is match('community.general.counter needs a sequence with hashable elements')