Browse code

Make filter type errors 'loop friendly' (#70417) (#70575)

- ensure we preserve the typeerror part of the exception so loop defereed error handling
can postpone those caused by undefined variables until the when check is done.
- fix tests to comply with the 'new normal'

- human_to_bytes and others can issue TypeError not only on 'non string'
but also bad string that is not convertable.

Co-authored-by: Sloane Hertel <shertel@redhat.com>

Co-authored-by: Sloane Hertel <shertel@redhat.com>
(cherry picked from commit cf89ca8a03a8a84302ad27cb1fc7aa9120b743ca)

Brian Coca authored on 2020/07/18 06:39:50
Showing 6 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,2 @@
0
+bugfixes:
1
+  - Allow TypeErrors on Undefined variables in filters to be handled or deferred when processing for loops.
... ...
@@ -312,3 +312,8 @@ class AnsibleActionFail(AnsibleAction):
312 312
 class _AnsibleActionDone(AnsibleAction):
313 313
     ''' an action runtime early exit'''
314 314
     pass
315
+
316
+
317
+class AnsibleFilterTypeError(AnsibleTemplateError, TypeError):
318
+    ''' a Jinja filter templating failure due to bad type'''
319
+    pass
... ...
@@ -40,7 +40,7 @@ from random import Random, SystemRandom, shuffle
40 40
 
41 41
 from jinja2.filters import environmentfilter, do_groupby as _do_groupby
42 42
 
43
-from ansible.errors import AnsibleError, AnsibleFilterError
43
+from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleFilterTypeError
44 44
 from ansible.module_utils.six import iteritems, string_types, integer_types, reraise
45 45
 from ansible.module_utils.six.moves import reduce, shlex_quote
46 46
 from ansible.module_utils._text import to_bytes, to_native, to_text
... ...
@@ -490,7 +490,7 @@ def subelements(obj, subelements, skip_missing=False):
490 490
     elif isinstance(subelements, string_types):
491 491
         subelement_list = subelements.split('.')
492 492
     else:
493
-        raise AnsibleFilterError('subelements must be a list or a string')
493
+        raise AnsibleFilterTypeError('subelements must be a list or a string')
494 494
 
495 495
     results = []
496 496
 
... ...
@@ -505,9 +505,9 @@ def subelements(obj, subelements, skip_missing=False):
505 505
                     break
506 506
                 raise AnsibleFilterError("could not find %r key in iterated item %r" % (subelement, values))
507 507
             except TypeError:
508
-                raise AnsibleFilterError("the key %s should point to a dictionary, got '%s'" % (subelement, values))
508
+                raise AnsibleFilterTypeError("the key %s should point to a dictionary, got '%s'" % (subelement, values))
509 509
         if not isinstance(values, list):
510
-            raise AnsibleFilterError("the key %r should point to a list, got %r" % (subelement, values))
510
+            raise AnsibleFilterTypeError("the key %r should point to a list, got %r" % (subelement, values))
511 511
 
512 512
         for value in values:
513 513
             results.append((element, value))
... ...
@@ -520,7 +520,7 @@ def dict_to_list_of_dict_key_value_elements(mydict, key_name='key', value_name='
520 520
         with each having a 'key' and 'value' keys that correspond to the keys and values of the original '''
521 521
 
522 522
     if not isinstance(mydict, Mapping):
523
-        raise AnsibleFilterError("dict2items requires a dictionary, got %s instead." % type(mydict))
523
+        raise AnsibleFilterTypeError("dict2items requires a dictionary, got %s instead." % type(mydict))
524 524
 
525 525
     ret = []
526 526
     for key in mydict:
... ...
@@ -533,7 +533,7 @@ def list_of_dict_key_value_elements_to_dict(mylist, key_name='key', value_name='
533 533
         effectively as the reverse of dict2items '''
534 534
 
535 535
     if not is_sequence(mylist):
536
-        raise AnsibleFilterError("items2dict requires a list, got %s instead." % type(mylist))
536
+        raise AnsibleFilterTypeError("items2dict requires a list, got %s instead." % type(mylist))
537 537
 
538 538
     return dict((item[key_name], item[value_name]) for item in mylist)
539 539
 
... ...
@@ -28,7 +28,7 @@ import math
28 28
 
29 29
 from jinja2.filters import environmentfilter
30 30
 
31
-from ansible.errors import AnsibleFilterError
31
+from ansible.errors import AnsibleFilterError, AnsibleFilterTypeError
32 32
 from ansible.module_utils.common.text import formatters
33 33
 from ansible.module_utils.six import binary_type, text_type
34 34
 from ansible.module_utils.six.moves import zip, zip_longest
... ...
@@ -140,14 +140,14 @@ def logarithm(x, base=math.e):
140 140
         else:
141 141
             return math.log(x, base)
142 142
     except TypeError as e:
143
-        raise AnsibleFilterError('log() can only be used on numbers: %s' % to_native(e))
143
+        raise AnsibleFilterTypeError('log() can only be used on numbers: %s' % to_native(e))
144 144
 
145 145
 
146 146
 def power(x, y):
147 147
     try:
148 148
         return math.pow(x, y)
149 149
     except TypeError as e:
150
-        raise AnsibleFilterError('pow() can only be used on numbers: %s' % to_native(e))
150
+        raise AnsibleFilterTypeError('pow() can only be used on numbers: %s' % to_native(e))
151 151
 
152 152
 
153 153
 def inversepower(x, base=2):
... ...
@@ -157,13 +157,15 @@ def inversepower(x, base=2):
157 157
         else:
158 158
             return math.pow(x, 1.0 / float(base))
159 159
     except (ValueError, TypeError) as e:
160
-        raise AnsibleFilterError('root() can only be used on numbers: %s' % to_native(e))
160
+        raise AnsibleFilterTypeError('root() can only be used on numbers: %s' % to_native(e))
161 161
 
162 162
 
163 163
 def human_readable(size, isbits=False, unit=None):
164 164
     ''' Return a human readable string '''
165 165
     try:
166 166
         return formatters.bytes_to_human(size, isbits, unit)
167
+    except TypeError as e:
168
+        raise AnsibleFilterTypeError("human_readable() failed on bad input: %s" % to_native(e))
167 169
     except Exception:
168 170
         raise AnsibleFilterError("human_readable() can't interpret following string: %s" % size)
169 171
 
... ...
@@ -172,6 +174,8 @@ def human_to_bytes(size, default_unit=None, isbits=False):
172 172
     ''' Return bytes count from a human readable string '''
173 173
     try:
174 174
         return formatters.human_to_bytes(size, default_unit, isbits)
175
+    except TypeError as e:
176
+        raise AnsibleFilterTypeError("human_to_bytes() failed on bad input: %s" % to_native(e))
175 177
     except Exception:
176 178
         raise AnsibleFilterError("human_to_bytes() can't interpret following string: %s" % size)
177 179
 
... ...
@@ -195,16 +199,18 @@ def rekey_on_member(data, key, duplicates='error'):
195 195
     elif isinstance(data, Iterable) and not isinstance(data, (text_type, binary_type)):
196 196
         iterate_over = data
197 197
     else:
198
-        raise AnsibleFilterError("Type is not a valid list, set, or dict")
198
+        raise AnsibleFilterTypeError("Type is not a valid list, set, or dict")
199 199
 
200 200
     for item in iterate_over:
201 201
         if not isinstance(item, Mapping):
202
-            raise AnsibleFilterError("List item is not a valid dict")
202
+            raise AnsibleFilterTypeError("List item is not a valid dict")
203 203
 
204 204
         try:
205 205
             key_elem = item[key]
206 206
         except KeyError:
207 207
             raise AnsibleFilterError("Key {0} was not found".format(key))
208
+        except TypeError as e:
209
+            raise AnsibleFilterTypeError(to_native(e))
208 210
         except Exception as e:
209 211
             raise AnsibleFilterError(to_native(e))
210 212
 
211 213
new file mode 100644
... ...
@@ -0,0 +1,29 @@
0
+- hosts: localhost
1
+  gather_facts: false
2
+  tasks:
3
+    - debug: msg={{item}}
4
+      with_dict: '{{myundef}}'
5
+      when:
6
+       - myundef is defined
7
+      register: shouldskip
8
+
9
+    - name: check if skipped
10
+      assert:
11
+        that:
12
+          - shouldskip is skipped
13
+
14
+    - debug: msg={{item}}
15
+      loop: '{{myundef|dict2items}}'
16
+      when:
17
+       - myundef is defined
18
+
19
+    - debug: msg={{item}}
20
+      with_dict: '{{myundef}}'
21
+      register: notskipped
22
+      ignore_errors: true
23
+
24
+    - name: check it failed
25
+      assert:
26
+        that:
27
+          - notskipped is not skipped
28
+          - notskipped is failed
... ...
@@ -9,7 +9,7 @@ import pytest
9 9
 from jinja2 import Environment
10 10
 
11 11
 import ansible.plugins.filter.mathstuff as ms
12
-from ansible.errors import AnsibleFilterError
12
+from ansible.errors import AnsibleFilterError, AnsibleFilterTypeError
13 13
 
14 14
 
15 15
 UNIQUE_DATA = (([1, 3, 4, 2], sorted([1, 2, 3, 4])),
... ...
@@ -79,9 +79,9 @@ class TestMax:
79 79
 class TestLogarithm:
80 80
     def test_log_non_number(self):
81 81
         # Message changed in python3.6
82
-        with pytest.raises(AnsibleFilterError, match='log\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
82
+        with pytest.raises(AnsibleFilterTypeError, match='log\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
83 83
             ms.logarithm('a')
84
-        with pytest.raises(AnsibleFilterError, match='log\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
84
+        with pytest.raises(AnsibleFilterTypeError, match='log\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
85 85
             ms.logarithm(10, base='a')
86 86
 
87 87
     def test_log_ten(self):
... ...
@@ -98,10 +98,10 @@ class TestLogarithm:
98 98
 class TestPower:
99 99
     def test_power_non_number(self):
100 100
         # Message changed in python3.6
101
-        with pytest.raises(AnsibleFilterError, match='pow\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
101
+        with pytest.raises(AnsibleFilterTypeError, match='pow\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
102 102
             ms.power('a', 10)
103 103
 
104
-        with pytest.raises(AnsibleFilterError, match='pow\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
104
+        with pytest.raises(AnsibleFilterTypeError, match='pow\\(\\) can only be used on numbers: (a float is required|must be real number, not str)'):
105 105
             ms.power(10, 'a')
106 106
 
107 107
     def test_power_squared(self):
... ...
@@ -114,13 +114,13 @@ class TestPower:
114 114
 class TestInversePower:
115 115
     def test_root_non_number(self):
116 116
         # Messages differed in python-2.6, python-2.7-3.5, and python-3.6+
117
-        with pytest.raises(AnsibleFilterError, match="root\\(\\) can only be used on numbers:"
117
+        with pytest.raises(AnsibleFilterTypeError, match="root\\(\\) can only be used on numbers:"
118 118
                            " (invalid literal for float\\(\\): a"
119 119
                            "|could not convert string to float: a"
120 120
                            "|could not convert string to float: 'a')"):
121 121
             ms.inversepower(10, 'a')
122 122
 
123
-        with pytest.raises(AnsibleFilterError, match="root\\(\\) can only be used on numbers: (a float is required|must be real number, not str)"):
123
+        with pytest.raises(AnsibleFilterTypeError, match="root\\(\\) can only be used on numbers: (a float is required|must be real number, not str)"):
124 124
             ms.inversepower('a', 10)
125 125
 
126 126
     def test_square_root(self):
... ...
@@ -145,27 +145,27 @@ class TestRekeyOnMember():
145 145
     # (Input data structure, member to rekey on, expected error message)
146 146
     INVALID_ENTRIES = (
147 147
         # Fail when key is not found
148
-        ([{"proto": "eigrp", "state": "enabled"}], 'invalid_key', "Key invalid_key was not found"),
149
-        ({"eigrp": {"proto": "eigrp", "state": "enabled"}}, 'invalid_key', "Key invalid_key was not found"),
148
+        (AnsibleFilterError, [{"proto": "eigrp", "state": "enabled"}], 'invalid_key', "Key invalid_key was not found"),
149
+        (AnsibleFilterError, {"eigrp": {"proto": "eigrp", "state": "enabled"}}, 'invalid_key', "Key invalid_key was not found"),
150 150
         # Fail when key is duplicated
151
-        ([{"proto": "eigrp"}, {"proto": "ospf"}, {"proto": "ospf"}],
151
+        (AnsibleFilterError, [{"proto": "eigrp"}, {"proto": "ospf"}, {"proto": "ospf"}],
152 152
          'proto', 'Key ospf is not unique, cannot correctly turn into dict'),
153 153
         # Fail when value is not a dict
154
-        (["string"], 'proto', "List item is not a valid dict"),
155
-        ([123], 'proto', "List item is not a valid dict"),
156
-        ([[{'proto': 1}]], 'proto', "List item is not a valid dict"),
154
+        (AnsibleFilterTypeError, ["string"], 'proto', "List item is not a valid dict"),
155
+        (AnsibleFilterTypeError, [123], 'proto', "List item is not a valid dict"),
156
+        (AnsibleFilterTypeError, [[{'proto': 1}]], 'proto', "List item is not a valid dict"),
157 157
         # Fail when we do not send a dict or list
158
-        ("string", 'proto', "Type is not a valid list, set, or dict"),
159
-        (123, 'proto', "Type is not a valid list, set, or dict"),
158
+        (AnsibleFilterTypeError, "string", 'proto', "Type is not a valid list, set, or dict"),
159
+        (AnsibleFilterTypeError, 123, 'proto', "Type is not a valid list, set, or dict"),
160 160
     )
161 161
 
162 162
     @pytest.mark.parametrize("list_original, key, expected", VALID_ENTRIES)
163 163
     def test_rekey_on_member_success(self, list_original, key, expected):
164 164
         assert ms.rekey_on_member(list_original, key) == expected
165 165
 
166
-    @pytest.mark.parametrize("list_original, key, expected", INVALID_ENTRIES)
167
-    def test_fail_rekey_on_member(self, list_original, key, expected):
168
-        with pytest.raises(AnsibleFilterError) as err:
166
+    @pytest.mark.parametrize("expected_exception_type, list_original, key, expected", INVALID_ENTRIES)
167
+    def test_fail_rekey_on_member(self, expected_exception_type, list_original, key, expected):
168
+        with pytest.raises(expected_exception_type) as err:
169 169
             ms.rekey_on_member(list_original, key)
170 170
 
171 171
         assert err.value.message == expected