Backward-compatible None handling in template concat and argspec str (#85652)

* templating coerces None to empty string on multi-node result

* avoid simple cases of embedded `None` in multi-node string concatenated template results ala <=2.18
* single-node template results preserve NoneType

* add None->empty str equivalency to argspec validation

* fix integration tests
* remove conversion error message check from apt_repository test
* remove error message check on `None` value for required str argspec in roles_arg_spec test (now logically-equivalent to empty string)

* explanatory comment for None->empty str coalesce
This commit is contained in:
Matt Davis 2025-08-13 15:05:01 -07:00 committed by GitHub
parent 76748b8478
commit e3c9908679
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 21 additions and 27 deletions

View File

@ -0,0 +1,3 @@
bugfixes:
- templating - Multi-node template results coerce embedded ``None`` nodes to empty string (instead of rendering literal ``None`` to the output).
- argspec validation - The ``str`` argspec type treats ``None`` values as empty string for better consistency with pre-2.19 templating conversions.

View File

@ -753,7 +753,7 @@ class AnsibleEnvironment(SandboxedEnvironment):
except MarkerError as ex: except MarkerError as ex:
return ex.source # return the first Marker encountered return ex.source # return the first Marker encountered
return ''.join([to_text(v) for v in node_list]) return ''.join([to_text(v) for v in node_list if v is not None]) # skip concat on `None`-valued nodes to avoid literal "None" in template results
@staticmethod @staticmethod
def _access_const(const_template: t.LiteralString) -> t.Any: def _access_const(const_template: t.LiteralString) -> t.Any:

View File

@ -374,7 +374,10 @@ def check_type_str(value, allow_conversion=True, param=None, prefix=''):
if isinstance(value, str): if isinstance(value, str):
return value return value
if allow_conversion and value is not None: if value is None:
return '' # approximate pre-2.19 templating None->empty str equivalency here for backward compatibility
if allow_conversion:
return to_native(value, errors='surrogate_or_strict') return to_native(value, errors='surrogate_or_strict')
msg = "'{0!r}' is not a string and conversion is not allowed".format(value) msg = "'{0!r}' is not a string and conversion is not allowed".format(value)

View File

@ -301,7 +301,7 @@
- assert: - assert:
that: that:
- result is failed - result is failed
- result.msg.startswith("argument 'repo' is of type NoneType and we were unable to convert to str") - result.msg == 'Please set argument \'repo\' to a non-empty value'
- name: Test apt_repository with an empty value for repo - name: Test apt_repository with an empty value for repo
apt_repository: apt_repository:

View File

@ -188,29 +188,6 @@
c_list: [] c_list: []
c_raw: ~ c_raw: ~
tasks: tasks:
- name: test type coercion fails on None for required str
block:
- name: "Test import_role of role C (missing a_str)"
import_role:
name: c
vars:
a_str: ~
- fail:
msg: "Should not get here"
rescue:
- debug:
var: ansible_failed_result
- name: "Validate import_role failure"
assert:
that:
# NOTE: a bug here that prevents us from getting ansible_failed_task
- ansible_failed_result.argument_errors == [error]
- ansible_failed_result.argument_spec_data == a_main_spec
vars:
error: >-
argument 'a_str' is of type NoneType and we were unable to convert to str:
'None' is not a string and conversion is not allowed
- name: test type coercion fails on None for required int - name: test type coercion fails on None for required int
block: block:
- name: "Test import_role of role C (missing c_int)" - name: "Test import_role of role C (missing c_int)"

View File

@ -1080,6 +1080,16 @@ def test_marker_from_test_plugin() -> None:
assert TemplateEngine(variables=dict(something=TRUST.tag("{{ nope }}"))).template(TRUST.tag("{{ (something is eq {}) is undefined }}")) assert TemplateEngine(variables=dict(something=TRUST.tag("{{ nope }}"))).template(TRUST.tag("{{ (something is eq {}) is undefined }}"))
@pytest.mark.parametrize("template,expected", (
("{{ none }}", None), # concat sees one node, NoneType result is preserved
("{% if False %}{% endif %}", None), # concat sees one node, NoneType result is preserved
("{{''}}{% if False %}{% endif %}", ""), # multiple blocks with an embedded None result, concat is in play, the result is an empty string
("hey {{ none }}", "hey "), # composite template, the result is an empty string
))
def test_none_concat(template: str, expected: object) -> None:
assert TemplateEngine().template(TRUST.tag(template)) == expected
def test_filter_generator() -> None: def test_filter_generator() -> None:
"""Verify that filters which return a generator are converted to a list while under the filter's JinjaCallContext.""" """Verify that filters which return a generator are converted to a list while under the filter's JinjaCallContext."""
variables = dict( variables = dict(

View File

@ -12,6 +12,7 @@ from ansible.module_utils.common.validation import check_type_str, _check_type_s
TEST_CASES = ( TEST_CASES = (
('string', 'string'), ('string', 'string'),
(None, '',), # 2.19+ relaxed restriction on None<->empty for backward compatibility
(100, '100'), (100, '100'),
(1.5, '1.5'), (1.5, '1.5'),
({'k1': 'v1'}, "{'k1': 'v1'}"), ({'k1': 'v1'}, "{'k1': 'v1'}"),
@ -25,7 +26,7 @@ def test_check_type_str(value, expected):
assert expected == check_type_str(value) assert expected == check_type_str(value)
@pytest.mark.parametrize('value, expected', TEST_CASES[1:]) @pytest.mark.parametrize('value, expected', TEST_CASES[2:])
def test_check_type_str_no_conversion(value, expected): def test_check_type_str_no_conversion(value, expected):
with pytest.raises(TypeError) as e: with pytest.raises(TypeError) as e:
_check_type_str_no_conversion(value) _check_type_str_no_conversion(value)