diff --git a/README.md b/README.md index 6c45a8d..58a82b8 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,15 @@ PEP440 is the schema used to describe the versions of Ansible. ### Filter plugins Name | Description --- | --- -ansible.utils.get_path|Get the value within a variable using a path. [See examples](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.get_path_lookup.rst) -ansible.utils.to_paths|Convert complex objects to paths. [See examples](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.to_paths_lookup.rst) +ansible.utils.get_path|Get value using path. [See examples](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.get_path_lookup.rst) +ansible.utils.index_of|Find items in a list. [See examples](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.netcommon.index_of_lookup.rst) +ansible.utils.to_paths|Convert objects to paths. [See examples](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.to_paths_lookup.rst) ### Lookup plugins Name | Description --- | --- [ansible.utils.get_path](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.get_path_lookup.rst)|Retrieve the value in a variable using a path +[ansible.utils.index_of](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.index_of_lookup.rst)|Find the indicies of items in a list matching some criteria [ansible.utils.to_paths](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.to_paths_lookup.rst)|Flatten a complex object into a dictionary of paths and values diff --git a/docs/ansible.utils.get_path_lookup.rst b/docs/ansible.utils.get_path_lookup.rst index 649e046..f2355e8 100644 --- a/docs/ansible.utils.get_path_lookup.rst +++ b/docs/ansible.utils.get_path_lookup.rst @@ -140,7 +140,7 @@ Examples path: b.c.d[0] # TASK [ansible.builtin.set_fact] ************************************* - # ok: [nxos101] => changed=false + # ok: [nxos101] => changed=false # ansible_facts: # as_filter: '0' # as_lookup: '0' @@ -157,7 +157,7 @@ Examples look_for: a.b.c.d[0] # TASK [Retrieve a value deep inside all of the host's vars] ********** - # ok: [nxos101] => changed=false + # ok: [nxos101] => changed=false # ansible_facts: # as_filter: '0' # as_lookup: '0' @@ -180,7 +180,7 @@ Examples value: "{{ vars|ansible.utils.get_path(item) }}" # TASK [Get the paths for the object] ********************************* - # ok: [nxos101] => changed=false + # ok: [nxos101] => changed=false # ansible_facts: # paths: # a.b.c.d[0]: 0 @@ -189,13 +189,13 @@ Examples # a.b.c.e[1]: false # TASK [Retrieve the value of each path from vars] ******************** - # ok: [nxos101] => (item=a.b.c.d[0]) => + # ok: [nxos101] => (item=a.b.c.d[0]) => # msg: The value of path a.b.c.d[0] in vars is 0 - # ok: [nxos101] => (item=a.b.c.d[1]) => + # ok: [nxos101] => (item=a.b.c.d[1]) => # msg: The value of path a.b.c.d[1] in vars is 1 - # ok: [nxos101] => (item=a.b.c.e[0]) => + # ok: [nxos101] => (item=a.b.c.e[0]) => # msg: The value of path a.b.c.e[0] in vars is True - # ok: [nxos101] => (item=a.b.c.e[1]) => + # ok: [nxos101] => (item=a.b.c.e[1]) => # msg: The value of path a.b.c.e[1] in vars is False @@ -217,9 +217,9 @@ Examples - by_name['Ethernet1/2'].description # TASK [Get the description of several interfaces] ******************** - # ok: [nxos101] => (item=by_name['Ethernet1/1'].description) => + # ok: [nxos101] => (item=by_name['Ethernet1/1'].description) => # msg: Configured by Ansible - # ok: [nxos101] => (item=by_name['Ethernet1/2'].description) => + # ok: [nxos101] => (item=by_name['Ethernet1/2'].description) => # msg: Configured by Ansible Network diff --git a/docs/ansible.utils.index_of_lookup.rst b/docs/ansible.utils.index_of_lookup.rst new file mode 100644 index 0000000..ada5bb4 --- /dev/null +++ b/docs/ansible.utils.index_of_lookup.rst @@ -0,0 +1,504 @@ +.. _ansible.utils.index_of_lookup: + + +********************** +ansible.utils.index_of +********************** + +**Find the indicies of items in a list matching some criteria** + + +Version added: 1.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- This lookup returns the indicies of items matching some criteria in a list +- When working with a list of dictionaries, the key to evaluate can be specified +- ``index_of`` is also available as a ``filter_plugin`` for convenience + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsConfigurationComments
+
+ _terms + +
+ - + / required +
+
+ + +
The values below provided in the order test, value, key.
+
+
+ data + +
+ list + / required +
+
+ + +
A list of items to enumerate and test against
+
+
+ fail_on_missing + +
+ boolean +
+
+
    Choices: +
  • no
  • +
  • yes
  • +
+
+ +
When provided a list of dictionaries, fail if the key is missing from one or more of the dictionaries
+
+
+ key + +
+ string +
+
+ + +
When the data provided is a list of dictionaries, run the test againt this dictionary key When using a key, the data must only contain dictionaries See fail_on_missing below to determine the behaviour when the key is missing from a dictionary in the data
+
+
+ test + +
+ string + / required +
+
+ + +
The name of the test to run against the list, a valid jinja2 test or ansible test plugin. Jinja2 includes the following tests http://jinja.palletsprojects.com/templates/#builtin-tests. An overview of tests included in ansible https://docs.ansible.com/ansible/latest/user_guide/playbooks_tests.html
+
+
+ value + +
+ raw +
+
+ + +
The value used to test each list item against Not required for simple tests (eg: true, false, even, odd) May be a string, boolean, number, regular expesion dict etc, depending on the test used
+
+
+ wantlist + +
+ boolean +
+
+
    Choices: +
  • no
  • +
  • yes
  • +
+
+ +
When only a single entry in the data is matched, that entries index is returned as an integer If set to True, the return value will always be a list, even if only a single entry is matched This can also be accomplised using query or q instead of lookup https://docs.ansible.com/ansible/latest/plugins/lookup.html
+
+
+ + + + +Examples +-------- + +.. code-block:: yaml+jinja + + #### Simple examples using a list of values + + - set_fact: + data: + - 1 + - 2 + - 3 + + - name: Find the index of 2, lookup or filter + set_fact: + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 2) }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 2) }}" + + # TASK [Find the index of 2, lookup or filter] ******************************* + # ok: [sw01] => changed=false + # ansible_facts: + # as_filter: '1' + # as_lookup: '1' + + - name: Any test can be negated using not or ! + set_fact: + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'not in', [1,2]) }}" + as_filter: "{{ data|ansible.utils.index_of('!in', [1,2]) }}" + + # TASK [Any test can be negated using not or !] ****************************** + # ok: [localhost] => changed=false + # ansible_facts: + # as_filter: '2' + # as_lookup: '2' + + - name: Find the index of 2, lookup or filter, ensure list is returned + set_fact: + as_query: "{{ query('ansible.utils.index_of', data, 'eq', 2) }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 2, wantlist=True) }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 2, wantlist=True) }}" + + # TASK [Find the index of 2, lookup or filter, ensure list is returned] ****** + # ok: [sw01] => changed=false + # ansible_facts: + # as_filter: + # - 1 + # as_lookup: + # - 1 + # as_query: + # - 1 + + - name: Find the index of 3 using the long format + set_fact: + as_query: "{{ query('ansible.utils.index_of', data=data, test='eq', value=value) }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data=data, test='eq',value =value, wantlist=True) }}" + as_filter: "{{ data|ansible.utils.index_of(test='eq', value=value, wantlist=True) }}" + vars: + value: 3 + + # TASK [Find the index of 3 using the long format] *************************** + # ok: [sw01] => changed=false + # ansible_facts: + # as_filter: + # - 2 + # as_lookup: + # - 2 + # as_query: + # - 2 + + - name: Find numbers greater than 1, using loop + debug: + msg: "{{ data[item] }} is {{ test }} than {{ value }}" + loop: "{{ data|ansible.utils.index_of(test, value) }}" + vars: + test: '>' + value: 1 + + # TASK [Find numbers great than 1, using loop] ******************************* + # ok: [sw01] => (item=1) => + # msg: 2 is > than 1 + # ok: [sw01] => (item=2) => + # msg: 3 is > than 1 + + - name: Find numbers greater than 1, using with + debug: + msg: "{{ data[item] }} is {{ params.test }} than {{ params.value }}" + with_ansible.utils.index_of: "{{ params }}" + vars: + params: + data: "{{ data }}" + test: '>' + value: 1 + + # TASK [Find numbers greater than 1, using with] ***************************** + # ok: [sw01] => (item=1) => + # msg: 2 is > than 1 + # ok: [sw01] => (item=2) => + # msg: 3 is > than 1 + + + + #### Working with lists of dictionaries + + - set_fact: + data: + - name: sw01.example.lan + type: switch + - name: rtr01.example.lan + type: router + - name: fw01.example.corp + type: firewall + - name: fw02.example.corp + type: firewall + + - name: Find the index of all firewalls using the type key + set_fact: + as_query: "{{ query('ansible.utils.index_of', data, 'eq', 'firewall', 'type') }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 'firewall', 'type') }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 'firewall', 'type') }}" + + # TASK [Find the index of all firewalls using the type key] ****************** + # ok: [sw01] => changed=false + # ansible_facts: + # as_filter: + # - 2 + # - 3 + # as_lookup: + # - 2 + # - 3 + # as_query: + # - 2 + # - 3 + + - name: Find the index of all firewalls, use in a loop, as a filter + debug: + msg: "The type of {{ device_type }} at index {{ item }} has name {{ data[item].name }}." + loop: "{{ data|ansible.utils.index_of('eq', device_type, 'type') }}" + vars: + device_type: firewall + + # TASK [Find the index of all firewalls, use in a loop] ********************** + # ok: [sw01] => (item=2) => + # msg: The type of firewall at index 2 has name fw01.example.corp + # ok: [sw01] => (item=3) => + # msg: The type of firewall at index 3 has name fw02.example.corp + + - name: Find the index of all devices with a .corp name, as a lookup + debug: + msg: "The device named {{ data[item].name }} is a {{ data[item].type }}" + loop: "{{ lookup('ansible.utils.index_of', data, 'regex', regex, 'name') }}" + vars: + regex: '\.corp$' # ends with .corp + + # TASK [Find the index of all devices with a .corp name, as a lookup] ********** + # ok: [sw01] => (item=2) => + # msg: The device named fw01.example.corp is a firewall + # ok: [sw01] => (item=3) => + # msg: The device named fw02.example.corp is a firewall + + + + #### Working with data from resource modules + + - name: Retrieve the current L3 interface configuration + cisco.nxos.nxos_l3_interfaces: + state: gathered + register: current_l3 + + # TASK [Retrieve the current L3 interface configuration] ********************* + # ok: [sw01] => changed=false + # gathered: + # - name: Ethernet1/1 + # - name: Ethernet1/2 + # <...> + # - name: Ethernet1/128 + # - ipv4: + # - address: 192.168.101.14/24 + # name: mgmt0 + + - name: Find the index of the interface and address with a 192.168.101.xx ip address + set_fact: + found: "{{ found + entry }}" + with_indexed_items: "{{ current_l3.gathered }}" + vars: + found: [] + ip: '192.168.101.' + address: "{{ item.1.ipv4|d([])|ansible.utils.index_of('search', ip, 'address', wantlist=True) }}" + entry: + - interface_idx: "{{ item.0 }}" + address_idxs: "{{ address }}" + when: address + + # TASK [debug] *************************************************************** + # ok: [sw01] => + # found: + # - address_idxs: + # - 0 + # interface_idx: '128' + + - name: Show all interfaces and their address + debug: + msg: "{{ interface.name }} has ip {{ address }}" + loop: "{{ found|subelements('address_idxs') }}" + vars: + interface: "{{ current_l3.gathered[item.0.interface_idx|int] }}" + address: "{{ interface.ipv4[item.1].address }}" + + # TASK [debug] *************************************************************** + # ok: [sw01] => (item=[{'interface_idx': '128', 'address_idx': [0]}, 0]) => + # msg: mgmt0 has ip 192.168.101.14/24 + + + + #### Working with complex structures + + - set_fact: + data: + interfaces: + interface: + - config: + description: configured by Ansible - 1 + enabled: True + loopback-mode: False + mtu: 1024 + name: loopback0000 + type: eth + name: loopback0000 + subinterfaces: + subinterface: + - config: + description: subinterface configured by Ansible - 1 + enabled: True + index: 5 + index: 5 + - config: + description: subinterface configured by Ansible - 2 + enabled: False + index: 2 + index: 2 + - config: + description: configured by Ansible - 2 + enabled: False + loopback-mode: False + mtu: 2048 + name: loopback1111 + type: virt + name: loopback1111 + subinterfaces: + subinterface: + - config: + description: subinterface configured by Ansible - 3 + enabled: True + index: 10 + index: 10 + - config: + description: subinterface configured by Ansible - 4 + enabled: False + index: 3 + index: 3 + + + - name: Find the description of loopback111, subinterface index 10 + debug: + msg: |- + {{ data.interfaces.interface[int_idx|int] + .subinterfaces.subinterface[subint_idx|int] + .config.description }} + vars: + # the values to search for + int_name: loopback1111 + sub_index: 10 + # retrieve the index in each nested list + int_idx: | + {{ data.interfaces.interface| + ansible.utils.index_of('eq', int_name, 'name') }} + subint_idx: | + {{ data.interfaces.interface[int_idx|int] + .subinterfaces.subinterface| + ansible.utils.index_of('eq', sub_index, 'index') }} + + # TASK [Find the description of loopback111, subinterface index 10] ************ + # ok: [sw01] => + # msg: subinterface configured by Ansible - 3 + + + +Return Values +------------- +Common return values are documented `here `_, the following are the fields unique to this lookup: + +.. raw:: html + + + + + + + + + + + + +
KeyReturnedDescription
+
+ _raw + +
+ - +
+
+
One or more zero-based indicies of the matching list items
+
See wantlist if a list is always required
+
+
+

+ + +Status +------ + + +Authors +~~~~~~~ + +- Bradley Thornton (@cidrblock) + + +.. hint:: + Configuration entries for each entry type have a low to high priority order. For example, a variable that is lower in the list will override a variable that is higher up. diff --git a/docs/ansible.utils.to_paths_lookup.rst b/docs/ansible.utils.to_paths_lookup.rst index 329769e..583f3ca 100644 --- a/docs/ansible.utils.to_paths_lookup.rst +++ b/docs/ansible.utils.to_paths_lookup.rst @@ -141,7 +141,7 @@ Examples # TASK [set_fact] ***************************************************** # task path: /home/brad/github/dotbracket/site.yaml:17 - # ok: [localhost] => changed=false + # ok: [localhost] => changed=false # ansible_facts: # as_filter: # b.c.d[0]: 0 @@ -160,7 +160,7 @@ Examples as_filter: "{{ a|ansible.utils.to_paths(prepend='a') }}" # TASK [Use prepend to add the initial variable name] ***************** - # ok: [nxos101] => changed=false + # ok: [nxos101] => changed=false # ansible_facts: # as_filter: # a.b.c.d[0]: 0 @@ -192,7 +192,7 @@ Examples flattened: "{{ result.json|ansible.utils.to_paths }}" # TASK [Flatten the complex object] ******************** - # ok: [nxos101] => changed=false + # ok: [nxos101] => changed=false # ansible_facts: # flattened: # interfaces.interface[0].config.enabled: 'true' diff --git a/plugins/filter/index_of.py b/plugins/filter/index_of.py new file mode 100644 index 0000000..7a5138d --- /dev/null +++ b/plugins/filter/index_of.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +""" +The index_of filter plugin +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.ansible.utils.plugins.module_utils.common.index_of import ( + index_of, +) +from jinja2.filters import environmentfilter + + +@environmentfilter +def _index_of(*args, **kwargs): + """Find items in a list. [See examples](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.netcommon.index_of_lookup.rst)""" + kwargs["tests"] = args[0].tests + args = args[1:] + return index_of(*args, **kwargs) + + +class FilterModule(object): + """ index_of """ + + def filters(self): + """a mapping of filter names to functions""" + return {"index_of": _index_of} diff --git a/plugins/lookup/index_of.py b/plugins/lookup/index_of.py new file mode 100644 index 0000000..486d331 --- /dev/null +++ b/plugins/lookup/index_of.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +""" +The index_of lookup plugin +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = """ + lookup: index_of + author: Bradley Thornton (@cidrblock) + version_added: "1.0" + short_description: Find the indicies of items in a list matching some criteria + description: + - This lookup returns the indicies of items matching some criteria in a list + - When working with a list of dictionaries, the key to evaluate can be specified + - C(index_of) is also available as a C(filter_plugin) for convenience + options: + _terms: + description: The values below provided in the order C(test), C(value), C(key). + required: True + data: + description: A list of items to enumerate and test against + type: list + required: True + test: + description: > + The name of the test to run against the list, a valid jinja2 test or ansible test plugin. + Jinja2 includes the following tests U(http://jinja.palletsprojects.com/templates/#builtin-tests). + An overview of tests included in ansible U(https://docs.ansible.com/ansible/latest/user_guide/playbooks_tests.html) + type: str + required: True + value: + description: > + The value used to test each list item against + Not required for simple tests (eg: C(true), C(false), C(even), C(odd)) + May be a C(string), C(boolean), C(number), C(regular expesion) C(dict) etc, depending on the C(test) used + type: raw + key: + description: > + When the data provided is a list of dictionaries, run the test againt this dictionary key + When using a C(key), the C(data) must only contain dictionaries + See C(fail_on_missing) below to determine the behaviour when the C(key) is missing from a dictionary in the C(data) + type: str + fail_on_missing: + description: When provided a list of dictionaries, fail if the key is missing from one or more of the dictionaries + type: bool + wantlist: + description: > + When only a single entry in the C(data) is matched, that entries index is returned as an integer + If set to C(True), the return value will always be a list, even if only a single entry is matched + This can also be accomplised using C(query) or C(q) instead of C(lookup) + U(https://docs.ansible.com/ansible/latest/plugins/lookup.html) + type: bool + + notes: +""" + +EXAMPLES = r""" + +#### Simple examples using a list of values + +- set_fact: + data: + - 1 + - 2 + - 3 + +- name: Find the index of 2, lookup or filter + set_fact: + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 2) }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 2) }}" + +# TASK [Find the index of 2, lookup or filter] ******************************* +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: '1' +# as_lookup: '1' + +- name: Any test can be negated using not or ! + set_fact: + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'not in', [1,2]) }}" + as_filter: "{{ data|ansible.utils.index_of('!in', [1,2]) }}" + +# TASK [Any test can be negated using not or !] ****************************** +# ok: [localhost] => changed=false +# ansible_facts: +# as_filter: '2' +# as_lookup: '2' + +- name: Find the index of 2, lookup or filter, ensure list is returned + set_fact: + as_query: "{{ query('ansible.utils.index_of', data, 'eq', 2) }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 2, wantlist=True) }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 2, wantlist=True) }}" + +# TASK [Find the index of 2, lookup or filter, ensure list is returned] ****** +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: +# - 1 +# as_lookup: +# - 1 +# as_query: +# - 1 + +- name: Find the index of 3 using the long format + set_fact: + as_query: "{{ query('ansible.utils.index_of', data=data, test='eq', value=value) }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data=data, test='eq',value =value, wantlist=True) }}" + as_filter: "{{ data|ansible.utils.index_of(test='eq', value=value, wantlist=True) }}" + vars: + value: 3 + +# TASK [Find the index of 3 using the long format] *************************** +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: +# - 2 +# as_lookup: +# - 2 +# as_query: +# - 2 + +- name: Find numbers greater than 1, using loop + debug: + msg: "{{ data[item] }} is {{ test }} than {{ value }}" + loop: "{{ data|ansible.utils.index_of(test, value) }}" + vars: + test: '>' + value: 1 + +# TASK [Find numbers great than 1, using loop] ******************************* +# ok: [sw01] => (item=1) => +# msg: 2 is > than 1 +# ok: [sw01] => (item=2) => +# msg: 3 is > than 1 + +- name: Find numbers greater than 1, using with + debug: + msg: "{{ data[item] }} is {{ params.test }} than {{ params.value }}" + with_ansible.utils.index_of: "{{ params }}" + vars: + params: + data: "{{ data }}" + test: '>' + value: 1 + +# TASK [Find numbers greater than 1, using with] ***************************** +# ok: [sw01] => (item=1) => +# msg: 2 is > than 1 +# ok: [sw01] => (item=2) => +# msg: 3 is > than 1 + + + +#### Working with lists of dictionaries + +- set_fact: + data: + - name: sw01.example.lan + type: switch + - name: rtr01.example.lan + type: router + - name: fw01.example.corp + type: firewall + - name: fw02.example.corp + type: firewall + +- name: Find the index of all firewalls using the type key + set_fact: + as_query: "{{ query('ansible.utils.index_of', data, 'eq', 'firewall', 'type') }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 'firewall', 'type') }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 'firewall', 'type') }}" + +# TASK [Find the index of all firewalls using the type key] ****************** +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: +# - 2 +# - 3 +# as_lookup: +# - 2 +# - 3 +# as_query: +# - 2 +# - 3 + +- name: Find the index of all firewalls, use in a loop, as a filter + debug: + msg: "The type of {{ device_type }} at index {{ item }} has name {{ data[item].name }}." + loop: "{{ data|ansible.utils.index_of('eq', device_type, 'type') }}" + vars: + device_type: firewall + +# TASK [Find the index of all firewalls, use in a loop] ********************** +# ok: [sw01] => (item=2) => +# msg: The type of firewall at index 2 has name fw01.example.corp +# ok: [sw01] => (item=3) => +# msg: The type of firewall at index 3 has name fw02.example.corp + +- name: Find the index of all devices with a .corp name, as a lookup + debug: + msg: "The device named {{ data[item].name }} is a {{ data[item].type }}" + loop: "{{ lookup('ansible.utils.index_of', data, 'regex', regex, 'name') }}" + vars: + regex: '\.corp$' # ends with .corp + +# TASK [Find the index of all devices with a .corp name, as a lookup] ********** +# ok: [sw01] => (item=2) => +# msg: The device named fw01.example.corp is a firewall +# ok: [sw01] => (item=3) => +# msg: The device named fw02.example.corp is a firewall + + + +#### Working with data from resource modules + +- name: Retrieve the current L3 interface configuration + cisco.nxos.nxos_l3_interfaces: + state: gathered + register: current_l3 + +# TASK [Retrieve the current L3 interface configuration] ********************* +# ok: [sw01] => changed=false +# gathered: +# - name: Ethernet1/1 +# - name: Ethernet1/2 +# <...> +# - name: Ethernet1/128 +# - ipv4: +# - address: 192.168.101.14/24 +# name: mgmt0 + +- name: Find the index of the interface and address with a 192.168.101.xx ip address + set_fact: + found: "{{ found + entry }}" + with_indexed_items: "{{ current_l3.gathered }}" + vars: + found: [] + ip: '192.168.101.' + address: "{{ item.1.ipv4|d([])|ansible.utils.index_of('search', ip, 'address', wantlist=True) }}" + entry: + - interface_idx: "{{ item.0 }}" + address_idxs: "{{ address }}" + when: address + +# TASK [debug] *************************************************************** +# ok: [sw01] => +# found: +# - address_idxs: +# - 0 +# interface_idx: '128' + +- name: Show all interfaces and their address + debug: + msg: "{{ interface.name }} has ip {{ address }}" + loop: "{{ found|subelements('address_idxs') }}" + vars: + interface: "{{ current_l3.gathered[item.0.interface_idx|int] }}" + address: "{{ interface.ipv4[item.1].address }}" + +# TASK [debug] *************************************************************** +# ok: [sw01] => (item=[{'interface_idx': '128', 'address_idx': [0]}, 0]) => +# msg: mgmt0 has ip 192.168.101.14/24 + + + +#### Working with complex structures + +- set_fact: + data: + interfaces: + interface: + - config: + description: configured by Ansible - 1 + enabled: True + loopback-mode: False + mtu: 1024 + name: loopback0000 + type: eth + name: loopback0000 + subinterfaces: + subinterface: + - config: + description: subinterface configured by Ansible - 1 + enabled: True + index: 5 + index: 5 + - config: + description: subinterface configured by Ansible - 2 + enabled: False + index: 2 + index: 2 + - config: + description: configured by Ansible - 2 + enabled: False + loopback-mode: False + mtu: 2048 + name: loopback1111 + type: virt + name: loopback1111 + subinterfaces: + subinterface: + - config: + description: subinterface configured by Ansible - 3 + enabled: True + index: 10 + index: 10 + - config: + description: subinterface configured by Ansible - 4 + enabled: False + index: 3 + index: 3 + + +- name: Find the description of loopback111, subinterface index 10 + debug: + msg: |- + {{ data.interfaces.interface[int_idx|int] + .subinterfaces.subinterface[subint_idx|int] + .config.description }} + vars: + # the values to search for + int_name: loopback1111 + sub_index: 10 + # retrieve the index in each nested list + int_idx: | + {{ data.interfaces.interface| + ansible.utils.index_of('eq', int_name, 'name') }} + subint_idx: | + {{ data.interfaces.interface[int_idx|int] + .subinterfaces.subinterface| + ansible.utils.index_of('eq', sub_index, 'index') }} + +# TASK [Find the description of loopback111, subinterface index 10] ************ +# ok: [sw01] => +# msg: subinterface configured by Ansible - 3 + + + +""" + +RETURN = """ + _raw: + description: + - One or more zero-based indicies of the matching list items + - See C(wantlist) if a list is always required +""" + +from ansible.plugins.lookup import LookupBase +from ansible_collections.ansible.utils.plugins.module_utils.common.index_of import ( + index_of, +) + + +class LookupModule(LookupBase): + def run(self, terms, variables, **kwargs): + kwargs["tests"] = self._templar.environment.tests + if isinstance(terms, dict): + terms.update(kwargs) + res = index_of(**terms) + else: + res = index_of(*terms, **kwargs) + if not isinstance(res, list): + return [res] + return res diff --git a/plugins/module_utils/common/index_of.py b/plugins/module_utils/common/index_of.py new file mode 100644 index 0000000..264bb45 --- /dev/null +++ b/plugins/module_utils/common/index_of.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +""" +The index_of plugin common code +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +from ansible.module_utils.common.collections import is_sequence +from ansible.module_utils.six import string_types, integer_types +from ansible.module_utils._text import to_native + +# Note, this file can only be used on the control node +# where ansible is installed +# limit imports to filter and lookup plugins +try: + from ansible.errors import AnsibleError +except ImportError: + pass + + +def _raise_error(msg): + """Raise an error message, prepend with filter name + + :param msg: The message + :type msg: str + :raises: AnsibleError + """ + error = "Error when using plugin 'index_of': {msg}".format(msg=msg) + raise AnsibleError(error) + + +def _list_to_and_str(lyst): + """Convert a list to a command delimited string + with the last entry being an and + + :param lyst: The list to turn into a str + :type lyst: list + :return: The nicely formatted string + :rtype: str + """ + res = "{most} and {last}".format(most=", ".join(lyst[:-1]), last=lyst[-1]) + return res + + +def _to_well_known_type(obj): + """Convert an ansible internal type to a well-known type + ie AnsibleUnicode => str + + :param obj: the obj to convert + :type obj: unknown + """ + return json.loads(json.dumps(obj)) + + +def _check_reqs(obj, wantlist): + """Check the args passed, ensure given a list + + :param obj: The object passed to the filter plugin + :type obj: unknown + """ + errors = [] + if not is_sequence(obj): + msg = "a list is required, was passed a '{type}'.".format( + type=type(_to_well_known_type(obj)).__name__ + ) + errors.append(msg) + if not isinstance(wantlist, bool): + msg = "'wantlist' is required to be a bool, was passed a '{type}'.".format( + type=type(_to_well_known_type(wantlist)).__name__ + ) + errors.append(msg) + if errors: + _raise_error(" ".join(errors)) + + +def _run_test(entry, test, right, tests): + """Run a test + + :param test: The test to run + :type test: a lambda from the qual_map + :param entry: The x for the lambda + :type entry: str int or bool + :param right: The y for the lamba + :type right: str int bool or list + :return: If the test passed + :rtype: book + """ + msg = ( + "Error encountered when testing value " + "'{entry}' (type={entry_type}) against " + "'{right}' (type={right_type}) with '{test}'. " + ).format( + entry=entry, + entry_type=type(_to_well_known_type(entry)).__name__, + right=right, + right_type=type(_to_well_known_type(entry)).__name__, + test=test, + ) + + if test.startswith("!"): + invert = True + test = test.lstrip("!") + if test == "=": + test = "==" + elif test.startswith("not "): + invert = True + test = test.lstrip("not ") + else: + invert = False + + if not isinstance(right, list) and test == "in": + right = [right] + + j2_test = tests.get(test) + if not j2_test: + msg = "{msg} Error was: the test '{test}' was not found.".format( + msg=msg, test=test + ) + _raise_error(msg) + else: + try: + if right is None: + result = j2_test(entry) + else: + result = j2_test(entry, right) + except Exception as exc: + msg = "{msg} Error was: {error}".format( + msg=msg, error=to_native(exc) + ) + _raise_error(msg) + + if invert: + result = not result + return result + + +def index_of( + data, + test, + value=None, + key=None, + wantlist=False, + fail_on_missing=False, + tests=None, +): + """Find the index or indices of entries in list of objects" + + :param data: The data passed in (data|index_of(...)) + :type data: unknown + :param test: the test to use + :type test: jinj2 test + :param value: The value to use for the test + :type value: unknown + :param key: The key to use when a list of dicts is passed + :type key: valid key type + :param want_list: always return a list, even if 1 index + :type want_list: bool + :param fail_on_missing: Should we fail if key not found? + :type fail_on_missing: bool + :param tests: The jinja tests from the current environment + :type tests: ansible.template.JinjaPluginIntercept + """ + _check_reqs(data, wantlist) + res = list() + if key is None: + for idx, entry in enumerate(data): + result = _run_test(entry, test, value, tests) + if result: + res.append(idx) + + elif isinstance(key, (string_types, integer_types, bool)): + + if not all(isinstance(entry, dict) for entry in data): + all_tipes = [ + type(_to_well_known_type(entry)).__name__ for entry in data + ] + msg = ( + "When a key name is provided, all list entries are required to " + "be dictionaries, got {str_tipes}" + ).format(str_tipes=_list_to_and_str(all_tipes)) + _raise_error(msg) + errors = [] + for idx, dyct in enumerate(data): + if key in dyct: + entry = dyct.get(key) + result = _run_test(entry, test, value, tests) + if result: + res.append(idx) + elif fail_on_missing: + msg = ( + "'{key}' was not found in '{dyct}' at [{index}]" + ).format(key=key, dyct=dyct, index=idx) + errors.append(msg) + if errors: + _raise_error( + ("{errors}. fail_on_missing={fom}").format( + errors=_list_to_and_str(errors), fom=str(fail_on_missing) + ) + ) + else: + msg = "Unknown key type, key ({key}) was a {type}. ".format( + key=key, type=type(_to_well_known_type(key)).__name__ + ) + _raise_error(msg) + if len(res) == 1 and not wantlist: + return res[0] + return res diff --git a/tests/integration/targets/index_of/tasks/examples.yaml b/tests/integration/targets/index_of/tasks/examples.yaml new file mode 100644 index 0000000..48e630e --- /dev/null +++ b/tests/integration/targets/index_of/tasks/examples.yaml @@ -0,0 +1,254 @@ +# These are the examples for the lookup plugin +# here for future use if needed +# Note: the cisco example has been commented out +# to eliminate a cross collection dependancy + +- set_fact: + data: + - 1 + - 2 + - 3 + +- name: Find the index of 2, lookup or filter + set_fact: + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 2) }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 2) }}" + +# TASK [Find the index of 2, lookup or filter] ******************************* +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: '1' +# as_lookup: '1' + +- name: Find the index of 2, lookup or filter, ensure list is returned + set_fact: + as_query: "{{ query('ansible.utils.index_of', data, 'eq', 2) }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 2, wantlist=True) }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 2, wantlist=True) }}" + +# TASK [Find the index of 2, lookup or filter, ensure list is returned] ****** +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: +# - 1 +# as_lookup: +# - 1 +# as_query: +# - 1 + +- name: Find the index of 3 using the long format + set_fact: + as_query: "{{ query('ansible.utils.index_of', data=data, test='eq', value=value) }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data=data, test='eq',value =value, wantlist=True) }}" + as_filter: "{{ data|ansible.utils.index_of(test='eq', value=value, wantlist=True) }}" + vars: + value: 3 + +# TASK [Find the index of 3 using the long format] *************************** +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: +# - 2 +# as_lookup: +# - 2 +# as_query: +# - 2 + +- name: Find numbers greater than 1, using loop + debug: + msg: "{{ data[item] }} is {{ test }} than {{ value }}" + loop: "{{ data|ansible.utils.index_of(test, value) }}" + vars: + test: '>' + value: 1 + +# TASK [Find numbers great than 1, using loop] ******************************* +# ok: [sw01] => (item=1) => +# msg: 2 is > than 1 +# ok: [sw01] => (item=2) => +# msg: 3 is > than 1 + +- name: Find numbers greater than 1, using with + debug: + msg: "{{ data[item] }} is {{ params.test }} than {{ params.value }}" + with_ansible.utils.index_of: "{{ params }}" + vars: + params: + data: "{{ data }}" + test: '>' + value: 1 + + +- set_fact: + data: + - name: sw01.example.lan + type: switch + - name: rtr01.example.lan + type: router + - name: fw01.example.corp + type: firewall + - name: fw02.example.corp + type: firewall + +- name: Find the index of all firewalls using the type key + set_fact: + as_query: "{{ query('ansible.utils.index_of', data, 'eq', 'firewall', 'type') }}" + as_lookup: "{{ lookup('ansible.utils.index_of', data, 'eq', 'firewall', 'type') }}" + as_filter: "{{ data|ansible.utils.index_of('eq', 'firewall', 'type') }}" + +# TASK [Find the index of all firewalls using the type key] ****************** +# ok: [sw01] => changed=false +# ansible_facts: +# as_filter: +# - 2 +# - 3 +# as_lookup: +# - 2 +# - 3 +# as_query: +# - 2 +# - 3 + +- name: Find the index of all firewalls, use in a loop, as a filter + debug: + msg: "The type of {{ device_type }} at index {{ item }} has name {{ data[item].name }}." + loop: "{{ data|ansible.utils.index_of('eq', device_type, 'type') }}" + vars: + device_type: firewall + +# TASK [Find the index of all firewalls, use in a loop] ********************** +# ok: [sw01] => (item=2) => +# msg: The type of firewall at index 2 has name fw01. +# ok: [sw01] => (item=3) => +# msg: The type of firewall at index 3 has name fw02. + + +- name: Find the index of all devices with a .corp name, as a lookup + debug: + msg: "The device named {{ data[item].name }} is a {{ data[item].type }}" + loop: "{{ lookup('ansible.utils.index_of', data, 'regex', regex, 'name') }}" + vars: + regex: '\.corp$' # ends with .corp + +# TASK [Find the index of all devices with a .corp name, as a lookup] ********** +# ok: [sw01] => (item=2) => +# msg: The device named fw01.example.corp is a firewall +# ok: [sw01] => (item=3) => +# msg: The device named fw02.example.corp is a firewall + +# - name: Retrieve the current L3 interface configuration +# cisco.nxos.nxos_l3_interfaces: +# state: gathered +# register: current_l3 + +# TASK [Retrieve the current L3 interface configuration] ********************* +# ok: [sw01] => changed=false +# gathered: +# - name: Ethernet1/1 +# - name: Ethernet1/2 +# <...> +# - name: Ethernet1/128 +# - ipv4: +# - address: 192.168.101.14/24 +# name: mgmt0 + +# - name: Find the index of the interface and address with a 192.168.101.xx ip address +# set_fact: +# found: "{{ found + entry }}" +# with_indexed_items: "{{ current_l3.gathered }}" +# vars: +# found: [] +# ip: '192.168.101.' +# address: "{{ item.1.ipv4|d([])|ansible.utils.index_of('search', ip, 'address', wantlist=True) }}" +# entry: +# - interface_idx: "{{ item.0 }}" +# address_idxs: "{{ address }}" +# when: address + +# TASK [debug] *************************************************************** +# ok: [sw01] => +# found: +# - address_idxs: +# - 0 +# interface_idx: '128' + +# - name: Show all interfaces and their address +# debug: +# msg: "{{ interface.name }} has ip {{ address }}" +# loop: "{{ found|subelements('address_idxs') }}" +# vars: +# interface: "{{ current_l3.gathered[item.0.interface_idx|int] }}" +# address: "{{ interface.ipv4[item.1].address }}" + +# TASK [debug] *************************************************************** +# ok: [sw01] => (item=[{'interface_idx': '128', 'address_idx': [0]}, 0]) => +# msg: mgmt0 has ip 192.168.101.14/24 + +- set_fact: + data: + interfaces: + interface: + - config: + description: configured by Ansible - 1 + enabled: True + loopback-mode: False + mtu: 1024 + name: loopback0000 + type: eth + name: loopback0000 + subinterfaces: + subinterface: + - config: + description: subinterface configured by Ansible - 1 + enabled: True + index: 5 + index: 5 + - config: + description: subinterface configured by Ansible - 2 + enabled: False + index: 2 + index: 2 + - config: + description: configured by Ansible - 2 + enabled: False + loopback-mode: False + mtu: 2048 + name: loopback1111 + type: virt + name: loopback1111 + subinterfaces: + subinterface: + - config: + description: subinterface configured by Ansible - 3 + enabled: True + index: 10 + index: 10 + - config: + description: subinterface configured by Ansible - 4 + enabled: False + index: 3 + index: 3 + + +- name: Find the description of loopback111, subinterface index 10 + debug: + msg: |- + {{ data.interfaces.interface[int_idx|int] + .subinterfaces.subinterface[subint_idx|int] + .config.description }} + vars: + # the values to search for + int_name: loopback1111 + sub_index: 10 + # retrieve the index in each nested list + int_idx: | + {{ data.interfaces.interface| + ansible.utils.index_of('eq', int_name, 'name') }} + subint_idx: | + {{ data.interfaces.interface[int_idx|int] + .subinterfaces.subinterface| + ansible.utils.index_of('eq', sub_index, 'index') }} + +# TASK [Find the description of loopback111, subinterface index 10] ************ +# ok: [sw01] => +# msg: subinterface configured by Ansible - 3 diff --git a/tests/integration/targets/index_of/tasks/main.yaml b/tests/integration/targets/index_of/tasks/main.yaml new file mode 100644 index 0000000..1c4c8db --- /dev/null +++ b/tests/integration/targets/index_of/tasks/main.yaml @@ -0,0 +1,2 @@ +- include: simple.yaml +- include: examples.yaml \ No newline at end of file diff --git a/tests/integration/targets/index_of/tasks/simple.yaml b/tests/integration/targets/index_of/tasks/simple.yaml new file mode 100644 index 0000000..6b7708b --- /dev/null +++ b/tests/integration/targets/index_of/tasks/simple.yaml @@ -0,0 +1,107 @@ +- set_fact: + complex: + a: + - True + - True + - False + - 5 + + b: + - b1: 1 + b2: 2 + - b1: 3 + b2: 4 + c: + c1: + - a + - b + - c + d: + - Abcd + - abcd + - B + - b + +- name: Some basic tests + assert: + that: "{{ item.test == item.result }}" + loop: + - test: "{{ complex.a|ansible.utils.index_of('eq', True) }}" + result: [0, 1] + - test: "{{ lookup('ansible.utils.index_of', complex.a, 'eq', True) }}" + result: [0, 1] + - test: "{{ complex.a|ansible.utils.index_of('in', [True, False]) }}" + result: [0, 1, 2] + - test: "{{ lookup('ansible.utils.index_of', complex.a, 'in', [True, False]) }}" + result: [0, 1, 2] + # These are commented out due to jinja < 2.11 w/ 2.9, 'integer' not avaialable + # can be enabled at a later date + # - test: "{{ complex.a|ansible.utils.index_of('integer') }}" + # result: "3" + # - test: "{{ lookup('ansible.utils.index_of', complex.a, 'integer') }}" + # result: "3" + + - test: "{{ complex.b|ansible.utils.index_of('==', 1, 'b1') }}" + result: "0" + - test: "{{ lookup('ansible.utils.index_of', complex.b, '==', 1, 'b1') }}" + result: "0" + + - test: "{{ complex.c.c1|ansible.utils.index_of('!=', 'c') }}" + result: [0, 1] + - test: "{{ lookup('ansible.utils.index_of', complex.c.c1, '!=', 'c') }}" + result: [0, 1] + + - test: "{{ complex.d|ansible.utils.index_of('match', '.*d$') }}" + result: [0, 1] + - test: "{{ lookup('ansible.utils.index_of', complex.d, 'match', '.*d$') }}" + result: [0, 1] + + +- set_fact: + complex: + a: + b: + c: + d: + - e0: 0 + e1: ansible + e2: True + - e0: 1 + e1: redhat + +- name: Find index in list of dictionaries + assert: + that: "{{ item.test == item.result }}" + loop: + - test: "{{ complex.a.b.c.d|ansible.utils.index_of('eq', 'ansible', 'e1') }}" + result: "0" + - test: "{{ lookup('ansible.utils.index_of', complex.a.b.c.d, 'eq', 'ansible', 'e1') }}" + result: "0" + - test: "{{ complex.a.b.c.d|ansible.utils.index_of('eq', 'ansible', 'e1', wantlist=True) }}" + result: [0] + - test: "{{ lookup('ansible.utils.index_of', complex.a.b.c.d, 'eq', 'ansible', 'e1', wantlist=True) }}" + result: [0] + +- name: Test a missing key in the list of dictionaries + assert: + that: "{{ item.test == item.result }}" + loop: + - test: "{{ complex.a.b.c.d|ansible.utils.index_of('eq', True, 'e2') }}" + result: "0" + - test: "{{ lookup('ansible.utils.index_of', complex.a.b.c.d, 'eq', True, 'e2') }}" + result: "0" + +- name: Test a missing key in the list of dictionaries, fail on missing + assert: + that: "{{ item.test == item.result }}" + loop: + - test: "{{ complex.a.b.c.d|ansible.utils.index_of('eq', True, 'e2', fail_on_missing=True) }}" + result: "0" + - test: "{{ lookup('ansible.utils.index_of', complex.a.b.c.d, 'eq', True, 'e2', fail_on_missing=True) }}" + result: "0" + ignore_errors: True + register: result + +- name: Ensure the previous test failed + assert: + that: "{{ result.failed and 'not found in' in result.msg }}" diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index cf4712f..ccf822d 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -1 +1,2 @@ plugins/module_utils/common/path.py pylint:ansible-bad-module-import # file's use is limited to filter and lookups on control node +plugins/module_utils/common/index_of.py pylint:ansible-bad-module-import # file's use is limited to filter and lookups on control node diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index cf4712f..ccf822d 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -1 +1,2 @@ plugins/module_utils/common/path.py pylint:ansible-bad-module-import # file's use is limited to filter and lookups on control node +plugins/module_utils/common/index_of.py pylint:ansible-bad-module-import # file's use is limited to filter and lookups on control node diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index cf4712f..ccf822d 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -1 +1,2 @@ plugins/module_utils/common/path.py pylint:ansible-bad-module-import # file's use is limited to filter and lookups on control node +plugins/module_utils/common/index_of.py pylint:ansible-bad-module-import # file's use is limited to filter and lookups on control node diff --git a/tests/unit/module_utils/test_index_of.py b/tests/unit/module_utils/test_index_of.py new file mode 100644 index 0000000..479bec5 --- /dev/null +++ b/tests/unit/module_utils/test_index_of.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Red Hat +# 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 + + +import unittest +from ansible_collections.ansible.utils.plugins.module_utils.common.index_of import ( + index_of, +) +from ansible.template import Templar + + +class TestIndexOfFilter(unittest.TestCase): + def setUp(self): + self._tests = Templar(loader=None).environment.tests + + def test_fail_no_qualfier(self): + obj, test, value = [1, 2], "@@", 1 + with self.assertRaises(Exception) as exc: + index_of(obj, test, value, tests=self._tests) + self.assertIn("the test '@@' was not found", str(exc.exception)) + obj, test, value, key = [{"a": 1}], "@@", 1, "a" + with self.assertRaises(Exception) as exc: + index_of(obj, test, value, key, tests=self._tests) + self.assertIn("the test '@@' was not found", str(exc.exception)) + + def test_fail_not_a_list(self): + obj, test, value = True, "==", 1 + with self.assertRaises(Exception) as exc: + index_of(obj, test, value, tests=self._tests) + self.assertIn( + "a list is required, was passed a 'bool'", str(exc.exception) + ) + + def test_fail_wantlist_not_a_bool(self): + obj, test, value = [1, 2], "==", 1 + with self.assertRaises(Exception) as exc: + index_of(obj, test, value, wantlist=42, tests=self._tests) + self.assertIn( + "'wantlist' is required to be a bool, was passed a 'int'", + str(exc.exception), + ) + + def test_fail_mixed_list(self): + obj, test, value, key = [{"a": "b"}, True, 1, "a"], "==", "b", "a" + with self.assertRaises(Exception) as exc: + index_of(obj, test, value, key, tests=self._tests) + self.assertIn("required to be dictionaries", str(exc.exception)) + + def test_fail_key_not_valid(self): + obj, test, value, key = [{"a": "b"}], "==", "b", [1, 2] + with self.assertRaises(Exception) as exc: + index_of(obj, test, value, key, tests=self._tests) + self.assertIn("Unknown key type", str(exc.exception)) + + def test_fail_on_missing(self): + obj, test, value, key = [{"a": True}, {"c": False}], "==", True, "a" + with self.assertRaises(Exception) as exc: + index_of( + obj, test, value, key, fail_on_missing=True, tests=self._tests + ) + self.assertIn("'a' was not found", str(exc.exception)) + + def test_just_test(self): + """Limit to jinja < 2.11 tests""" + objs = [ + # ([True], "true", 0), + # ([False], "not false", []), + # ([False, 5], "boolean", 0), + # ([0, False], "false", 1), + ([3, 4], "even", 1), + ([3, 3], "even", []), + ([3, 3, 3, 4], "odd", [0, 1, 2]), + # ([3.3, 3.4], "float", [0, 1]), + ] + for entry in objs: + obj, test, answer = entry + result = index_of(obj, test, tests=self._tests) + expected = answer + self.assertEqual(result, expected) + + def test_simple_lists(self): + objs = [ + ([1, 2, 3], "==", 2, 1), + (["a", "b", "c"], "eq", "c", 2), + ([True, False, 0, 1], "equalto", False, [1, 2]), + ([True, False, "0", "1"], "==", False, 1), + ([True, False, "", "1"], "==", False, 1), + ([True, False, "", "1"], "in", False, 1), + ([True, False, "", "1", "a"], "in", [False, "1"], [1, 3]), + ([1, 2, 3, "a", "b", "c"], "!=", "c", [0, 1, 2, 3, 4]), + ([1, 2, 3], "!<", 3, 2), + ] + for entry in objs: + obj, test, value, answer = entry + result = index_of(obj, test, value, tests=self._tests) + expected = answer + self.assertEqual(result, expected) + + def test_simple_dict(self): + objs = [ + ([{"a": 1}], "==", 1, "a", 0), + ([{"a": 1}], "==", 1, "b", []), + ([{"a": 1}], "==", 2, "a", []), + ( + [{"a": 1}, {"a": 1}, {"a": 1}, {"a": 2}], + "==", + 1, + "a", + [0, 1, 2], + ), + ( + [{"a": "abc"}, {"a": "def"}, {"a": "ghi"}, {"a": "jkl"}], + "ansible.builtin.match", + "^a", + "a", + 0, + ), + ( + [{"a": "abc"}, {"a": "def"}, {"a": "ghi"}, {"a": "jkl"}], + "ansible.builtin.search", + "e", + "a", + 1, + ), + ] + for entry in objs: + obj, test, value, key, answer = entry + result = index_of(obj, test, value, key, tests=self._tests) + self.assertEqual(result, answer) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt new file mode 100644 index 0000000..e69de29