diff --git a/changelogs/fragments/jinja2_nested_undefined.yaml b/changelogs/fragments/jinja2_nested_undefined.yaml new file mode 100644 index 00000000000..3994b3fa16d --- /dev/null +++ b/changelogs/fragments/jinja2_nested_undefined.yaml @@ -0,0 +1,2 @@ +minor_changes: + - jinja2 - accesses to attributes on an undefined value now return further undefined values rather than throwing an exception diff --git a/docs/docsite/rst/porting_guides/porting_guide_2.8.rst b/docs/docsite/rst/porting_guides/porting_guide_2.8.rst index 3494b6595f0..266343a5b2a 100644 --- a/docs/docsite/rst/porting_guides/porting_guide_2.8.rst +++ b/docs/docsite/rst/porting_guides/porting_guide_2.8.rst @@ -37,6 +37,24 @@ using an import, a task can notify any of the named tasks within the imported fi To achieve the results of notifying a single name but running mulitple handlers, utilize ``include_tasks``, or ``listen`` :ref:`handlers`. +Jinja Undefined values +---------------------- + +Beginning in version 2.8, attempting to access an attribute of an Undefined value in Jinja will return another Undefined value, rather than throwing an error immediately. This means that you can now simply use +a default with a value in a nested data structure when you don't know if the intermediate values are defined. + +In Ansible 2.8:: + + {{ foo.bar.baz | default('DEFAULT') }} + +In Ansible 2.7 and older:: + + {{ ((foo | default({})).bar | default({})).baz | default('DEFAULT') }} + + or + + {{ foo.bar.baz if (foo is defined and foo.bar is defined and foo.bar.baz is defined) else 'DEFAULT' }} + Command Line ============ diff --git a/lib/ansible/template/__init__.py b/lib/ansible/template/__init__.py index d5cb3792497..a99f330dbfc 100644 --- a/lib/ansible/template/__init__.py +++ b/lib/ansible/template/__init__.py @@ -191,6 +191,19 @@ def tests_as_filters_warning(name, func): return wrapper +class AnsibleUndefined(StrictUndefined): + ''' + A custom Undefined class, which returns further Undefined objects on access, + rather than throwing an exception. + ''' + def __getattr__(self, name): + # Return original Undefined object to preserve the first failure context + return self + + def __repr__(self): + return 'AnsibleUndefined' + + class AnsibleContext(Context): ''' A custom context, which intercepts resolve() calls and sets a flag @@ -285,7 +298,7 @@ class Templar: self.environment = AnsibleEnvironment( trim_blocks=True, - undefined=StrictUndefined, + undefined=AnsibleUndefined, extensions=self._get_extensions(), finalize=self._finalize, loader=FileSystemLoader(self._basedir), @@ -695,7 +708,7 @@ class Templar: if getattr(new_context, 'unsafe', False): res = wrap_var(res) except TypeError as te: - if 'StrictUndefined' in to_native(te): + if 'AnsibleUndefined' in to_native(te): errmsg = "Unable to look up a name or access an attribute in template string (%s).\n" % to_native(data) errmsg += "Make sure your variable name does not contain invalid characters like '-': %s" % to_native(te) raise AnsibleUndefinedVariable(errmsg) diff --git a/test/integration/targets/template/tasks/main.yml b/test/integration/targets/template/tasks/main.yml index 87e2e7d16fc..33e985da25a 100644 --- a/test/integration/targets/template/tasks/main.yml +++ b/test/integration/targets/template/tasks/main.yml @@ -642,5 +642,30 @@ register: encoding_1252_diff_result loop: '{{ template_encoding_1252_encodings }}' +- name: Check that nested undefined values return Undefined + vars: + dict_var: + bar: {} + list_var: + - foo: {} + assert: + that: + - dict_var is defined + - dict_var.bar is defined + - dict_var.bar.baz is not defined + - dict_var.bar.baz | default('DEFAULT') == 'DEFAULT' + - dict_var.bar.baz.abc is not defined + - dict_var.bar.baz.abc | default('DEFAULT') == 'DEFAULT' + - dict_var.baz is not defined + - dict_var.baz.abc is not defined + - dict_var.baz.abc | default('DEFAULT') == 'DEFAULT' + - list_var.0 is defined + - list_var.1 is not defined + - list_var.0.foo is defined + - list_var.0.foo.bar is not defined + - list_var.0.foo.bar | default('DEFAULT') == 'DEFAULT' + - list_var.1.foo is not defined + - list_var.1.foo | default('DEFAULT') == 'DEFAULT' + # aliases file requires root for template tests so this should be safe - include: backup_test.yml