Version in base suite: 2.19.0~beta6-1 Base version: ansible-core_2.19.0~beta6-1 Target version: ansible-core_2.19.1-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/a/ansible-core/ansible-core_2.19.0~beta6-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/a/ansible-core/ansible-core_2.19.1-0+deb13u1.dsc PKG-INFO | 2 ansible_core.egg-info/PKG-INFO | 2 ansible_core.egg-info/SOURCES.txt | 44 changelogs/CHANGELOG-v2.19.rst | 324 +++---- changelogs/changelog.yaml | 257 +++++ debian/changelog | 10 debian/gbp.conf | 5 debian/tests/ansible-test-integration.py | 1 debian/watch | 2 lib/ansible/_internal/_ansiballz/_builder.py | 39 lib/ansible/_internal/_json/__init__.py | 51 - lib/ansible/_internal/_json/_profiles/_legacy.py | 2 lib/ansible/_internal/_templating/_engine.py | 10 lib/ansible/_internal/_templating/_jinja_bits.py | 64 + lib/ansible/_internal/_templating/_jinja_common.py | 2 lib/ansible/_internal/_templating/_jinja_plugins.py | 16 lib/ansible/_internal/_templating/_lazy_containers.py | 10 lib/ansible/_internal/_templating/_utils.py | 3 lib/ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/dump_object.py | 9 lib/ansible/cli/__init__.py | 4 lib/ansible/cli/_ssh_askpass.py | 67 - lib/ansible/cli/adhoc.py | 9 lib/ansible/cli/console.py | 4 lib/ansible/cli/doc.py | 4 lib/ansible/config/base.yml | 37 lib/ansible/config/manager.py | 53 - lib/ansible/executor/module_common.py | 15 lib/ansible/executor/powershell/psrp_put_file.ps1 | 2 lib/ansible/executor/task_executor.py | 11 lib/ansible/executor/task_queue_manager.py | 108 -- lib/ansible/executor/task_result.py | 2 lib/ansible/galaxy/api.py | 4 lib/ansible/galaxy/collection/concrete_artifact_manager.py | 4 lib/ansible/galaxy/dependency_resolution/providers.py | 6 lib/ansible/inventory/group.py | 7 lib/ansible/inventory/host.py | 7 lib/ansible/module_utils/_internal/_ansiballz/_extensions/_debugpy.py | 97 ++ lib/ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py | 6 lib/ansible/module_utils/_internal/_datatag/__init__.py | 7 lib/ansible/module_utils/_internal/_deprecator.py | 13 lib/ansible/module_utils/_internal/_traceback.py | 2 lib/ansible/module_utils/ansible_release.py | 2 lib/ansible/module_utils/basic.py | 42 lib/ansible/module_utils/common/validation.py | 5 lib/ansible/module_utils/common/yaml.py | 2 lib/ansible/module_utils/csharp/Ansible.Basic.cs | 2 lib/ansible/module_utils/csharp/Ansible.Privilege.cs | 4 lib/ansible/module_utils/facts/hardware/base.py | 2 lib/ansible/module_utils/facts/other/facter.py | 2 lib/ansible/module_utils/facts/system/distribution.py | 4 lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 | 2 lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 | 2 lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 | 2 lib/ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 | 2 lib/ansible/module_utils/urls.py | 2 lib/ansible/modules/apt.py | 12 lib/ansible/modules/assemble.py | 8 lib/ansible/modules/dnf.py | 86 - lib/ansible/modules/dnf5.py | 65 - lib/ansible/modules/expect.py | 10 lib/ansible/modules/hostname.py | 4 lib/ansible/modules/meta.py | 3 lib/ansible/modules/pip.py | 20 lib/ansible/modules/raw.py | 4 lib/ansible/modules/service_facts.py | 6 lib/ansible/modules/stat.py | 2 lib/ansible/modules/systemd.py | 2 lib/ansible/modules/systemd_service.py | 2 lib/ansible/modules/wait_for.py | 13 lib/ansible/parsing/mod_args.py | 58 - lib/ansible/parsing/vault/__init__.py | 6 lib/ansible/playbook/base.py | 2 lib/ansible/playbook/helpers.py | 3 lib/ansible/playbook/playbook_include.py | 80 - lib/ansible/playbook/role/__init__.py | 63 - lib/ansible/playbook/taggable.py | 3 lib/ansible/plugins/__init__.py | 28 lib/ansible/plugins/action/__init__.py | 4 lib/ansible/plugins/action/assemble.py | 3 lib/ansible/plugins/action/assert.py | 4 lib/ansible/plugins/action/script.py | 9 lib/ansible/plugins/action/template.py | 2 lib/ansible/plugins/callback/__init__.py | 171 +-- lib/ansible/plugins/callback/default.py | 3 lib/ansible/plugins/callback/junit.py | 6 lib/ansible/plugins/connection/ssh.py | 19 lib/ansible/plugins/filter/pow.yml | 2 lib/ansible/plugins/filter/root.yml | 2 lib/ansible/plugins/filter/strftime.yml | 6 lib/ansible/plugins/filter/to_json.yml | 12 lib/ansible/plugins/filter/to_nice_json.yml | 5 lib/ansible/plugins/filter/to_uuid.yml | 2 lib/ansible/plugins/inventory/script.py | 2 lib/ansible/plugins/loader.py | 5 lib/ansible/plugins/lookup/password.py | 10 lib/ansible/plugins/lookup/template.py | 7 lib/ansible/release.py | 2 lib/ansible/utils/display.py | 42 lib/ansible/utils/encrypt.py | 2 lib/ansible/utils/path.py | 2 lib/ansible/utils/vars.py | 8 lib/ansible/vars/manager.py | 9 lib/ansible/vars/reserved.py | 10 test/integration/targets/ansiballz_debugging/tasks/main.yml | 2 test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/ultimatequestion.yml | 2 test/integration/targets/ansible-doc/runme.sh | 3 test/integration/targets/ansible-test-debugging-env/aliases | 2 test/integration/targets/ansible-test-debugging-env/runme.sh | 4 test/integration/targets/ansible-test-debugging-inventory/aliases | 2 test/integration/targets/ansible-test-debugging-inventory/runme.sh | 4 test/integration/targets/ansible-test-debugging/aliases | 5 test/integration/targets/ansible-test-debugging/tasks/main.yml | 98 ++ test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/aliases | 2 test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/tasks/main.yml | 2 test/integration/targets/apt_repository/tasks/apt.yml | 2 test/integration/targets/assemble/tasks/main.yml | 36 test/integration/targets/callback-dispatch/aliases | 2 test/integration/targets/callback-dispatch/callback_plugins/legacy_warning_display.py | 116 ++ test/integration/targets/callback-dispatch/callback_plugins/missing_base_class.py | 6 test/integration/targets/callback-dispatch/callback_plugins/oops_always_enabled.py | 22 test/integration/targets/callback-dispatch/callback_plugins/v1_only_methods.py | 35 test/integration/targets/callback-dispatch/library/noisy.py | 14 test/integration/targets/callback-dispatch/one-task.yml | 4 test/integration/targets/callback-dispatch/runme.sh | 22 test/integration/targets/callback-dispatch/test_legacy_warning_display.yml | 20 test/integration/targets/callback-dispatch/test_v1_methods.yml | 9 test/integration/targets/callback-legacy-warnings/aliases | 2 test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py | 112 -- test/integration/targets/callback-legacy-warnings/library/noisy.py | 14 test/integration/targets/callback-legacy-warnings/runme.sh | 5 test/integration/targets/callback-legacy-warnings/test.yml | 20 test/integration/targets/config/lookup_plugins/broken.py | 57 + test/integration/targets/config/match_option_methods.yml | 37 test/integration/targets/config/runme.sh | 3 test/integration/targets/connection_ssh/test_ssh_askpass.yml | 37 test/integration/targets/data_tagging_controller/runme.sh | 2 test/integration/targets/dnf-latest/aliases | 4 test/integration/targets/dnf-latest/tasks/main.yml | 16 test/integration/targets/dnf-oldest/aliases | 4 test/integration/targets/dnf-oldest/tasks/main.yml | 13 test/integration/targets/dnf/tasks/dnf.yml | 7 test/integration/targets/dnf/tasks/main.yml | 9 test/integration/targets/dnf/tasks/repo.yml | 96 ++ test/integration/targets/failed_when/aliases | 1 test/integration/targets/failed_when/tasks/main.yml | 10 test/integration/targets/filter_core/tasks/main.yml | 48 - test/integration/targets/filter_core/tasks/to_json.yml | 28 test/integration/targets/filter_core/tasks/to_yaml.yml | 27 test/integration/targets/git/tasks/specific-revision.yml | 2 test/integration/targets/include_import/playbook/playbook_using_a_var.yml | 6 test/integration/targets/include_import/playbook/test_import_playbook.yml | 18 test/integration/targets/include_import_tasks_nested/tasks/main.yml | 2 test/integration/targets/include_import_tasks_nested/tests/main.yml | 1 test/integration/targets/lookup_ini/test_errors.yml | 37 test/integration/targets/lookup_template/tasks/ansible_managed.yml | 1 test/integration/targets/module-serialization-profiles/aliases | 1 test/integration/targets/module_utils_Ansible.ModuleUtils.CommandUtil/library/command_util_test.ps1 | 2 test/integration/targets/pause/test-pause.py | 10 test/integration/targets/prepare_http_tests/tasks/windows.yml | 2 test/integration/targets/protomatter/tasks/main.yml | 20 test/integration/targets/roles_arg_spec/test.yml | 23 test/integration/targets/script/files/exit_1.sh | 3 test/integration/targets/script/tasks/main.yml | 23 test/integration/targets/set_fact/set_fact.yml | 4 test/integration/targets/task-args/tasks/main.yml | 2 test/integration/targets/task-esoterica/action_plugins/echo.py | 8 test/integration/targets/task-esoterica/aliases | 4 test/integration/targets/task-esoterica/tasks/main.yml | 39 test/integration/targets/templating/tasks/plugin_errors.yml | 10 test/integration/targets/var_reserved/tasks/main.yml | 4 test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 | 2 test/integration/targets/win_app_control/test_manifest.yml | 2 test/lib/ansible_test/_internal/__init__.py | 5 test/lib/ansible_test/_internal/ansible_util.py | 2 test/lib/ansible_test/_internal/classification/python.py | 6 test/lib/ansible_test/_internal/cli/commands/__init__.py | 5 test/lib/ansible_test/_internal/cli/environments.py | 56 + test/lib/ansible_test/_internal/commands/coverage/__init__.py | 2 test/lib/ansible_test/_internal/commands/integration/__init__.py | 23 test/lib/ansible_test/_internal/commands/integration/cloud/httptester.py | 2 test/lib/ansible_test/_internal/commands/integration/coverage.py | 4 test/lib/ansible_test/_internal/commands/sanity/__init__.py | 4 test/lib/ansible_test/_internal/commands/sanity/integration_aliases.py | 11 test/lib/ansible_test/_internal/commands/shell/__init__.py | 114 ++ test/lib/ansible_test/_internal/commands/units/__init__.py | 5 test/lib/ansible_test/_internal/config.py | 34 test/lib/ansible_test/_internal/coverage_util.py | 53 - test/lib/ansible_test/_internal/debugging.py | 454 ++++++++++ test/lib/ansible_test/_internal/delegation.py | 34 test/lib/ansible_test/_internal/host_profiles.py | 203 ++++ test/lib/ansible_test/_internal/inventory.py | 4 test/lib/ansible_test/_internal/metadata.py | 63 + test/lib/ansible_test/_internal/processes.py | 80 + test/lib/ansible_test/_internal/python_requirements.py | 27 test/lib/ansible_test/_internal/target.py | 8 test/lib/ansible_test/_internal/util_common.py | 16 test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test.cfg | 1 test/lib/ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py | 3 test/lib/ansible_test/_util/target/injector/python.py | 8 test/lib/ansible_test/_util/target/setup/bootstrap.sh | 53 - test/sanity/code-smell/mypy/ansible-core.ini | 3 test/sanity/code-smell/mypy/ansible-test.ini | 6 test/units/_internal/_ansiballz/test_builder.py | 22 test/units/_internal/_json/test_json.py | 51 + test/units/_internal/templating/test_jinja_bits.py | 77 + test/units/_internal/templating/test_lazy_containers.py | 27 test/units/_internal/templating/test_templar.py | 49 + test/units/cli/test_adhoc.py | 2 test/units/config/test_manager.py | 28 test/units/executor/test_task_executor.py | 3 test/units/module_utils/_internal/test_traceback.py | 31 test/units/module_utils/basic/test_exit_json.py | 25 test/units/module_utils/common/test_utils.py | 17 test/units/module_utils/common/validation/test_check_type_str.py | 3 test/units/modules/conftest.py | 6 test/units/playbook/test_base.py | 13 test/units/plugins/callback/test_callback.py | 10 test/units/plugins/connection/test_paramiko_ssh.py | 16 test/units/plugins/lookup/test_password.py | 15 test/units/plugins/lookup/test_template.py | 31 test/units/template/test_template.py | 21 test/units/utils/test_display.py | 2 test/units/utils/test_vars.py | 33 223 files changed, 3931 insertions(+), 1258 deletions(-) gpgv: Signature made Thu Jun 12 22:27:21 2025 UTC gpgv: using RSA key D847C62510A9C2FF242CE02CD604A1C4823EE0F8 gpgv: Note: signatures using the SHA1 algorithm are rejected gpgv: WARNING: signing subkey D604A1C4823EE0F8 has an invalid cross-certification gpgv: Can't check signature: General error dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9rmk53a1/ansible-core_2.19.0~beta6-1.dsc: no acceptable signature found gpgv: Signature made Tue Aug 26 22:52:03 2025 UTC gpgv: using RSA key D847C62510A9C2FF242CE02CD604A1C4823EE0F8 gpgv: Note: signatures using the SHA1 algorithm are rejected gpgv: WARNING: signing subkey D604A1C4823EE0F8 has an invalid cross-certification gpgv: Can't check signature: General error dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9rmk53a1/ansible-core_2.19.1-0+deb13u1.dsc: no acceptable signature found diff -Nru ansible-core-2.19.0~beta6/PKG-INFO ansible-core-2.19.1/PKG-INFO --- ansible-core-2.19.0~beta6/PKG-INFO 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/PKG-INFO 2025-08-25 19:16:05.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: ansible-core -Version: 2.19.0b6 +Version: 2.19.1 Summary: Radically simple IT automation Author: Ansible Project Project-URL: Homepage, https://ansible.com/ diff -Nru ansible-core-2.19.0~beta6/ansible_core.egg-info/PKG-INFO ansible-core-2.19.1/ansible_core.egg-info/PKG-INFO --- ansible-core-2.19.0~beta6/ansible_core.egg-info/PKG-INFO 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/ansible_core.egg-info/PKG-INFO 2025-08-25 19:16:05.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: ansible-core -Version: 2.19.0b6 +Version: 2.19.1 Summary: Radically simple IT automation Author: Ansible Project Project-URL: Homepage, https://ansible.com/ diff -Nru ansible-core-2.19.0~beta6/ansible_core.egg-info/SOURCES.txt ansible-core-2.19.1/ansible_core.egg-info/SOURCES.txt --- ansible-core-2.19.0~beta6/ansible_core.egg-info/SOURCES.txt 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/ansible_core.egg-info/SOURCES.txt 2025-08-25 19:16:05.000000000 +0000 @@ -256,6 +256,7 @@ lib/ansible/module_utils/_internal/_ansiballz/_respawn_wrapper.py lib/ansible/module_utils/_internal/_ansiballz/_extensions/__init__.py lib/ansible/module_utils/_internal/_ansiballz/_extensions/_coverage.py +lib/ansible/module_utils/_internal/_ansiballz/_extensions/_debugpy.py lib/ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py lib/ansible/module_utils/_internal/_concurrent/__init__.py lib/ansible/module_utils/_internal/_concurrent/_daemon_threading.py @@ -1098,6 +1099,12 @@ test/integration/targets/ansible-test-coverage-windows/ansible_collections/ns/col/tests/integration/targets/win_collection/module_utils/Ansible.ModuleUtils.AdjacentPwshCoverage.psm1 test/integration/targets/ansible-test-coverage-windows/ansible_collections/ns/col/tests/integration/targets/win_collection/tasks/main.yml test/integration/targets/ansible-test-coverage/ansible_collections/ns/col/plugins/module_utils/test_util.py +test/integration/targets/ansible-test-debugging/aliases +test/integration/targets/ansible-test-debugging-env/aliases +test/integration/targets/ansible-test-debugging-env/runme.sh +test/integration/targets/ansible-test-debugging-inventory/aliases +test/integration/targets/ansible-test-debugging-inventory/runme.sh +test/integration/targets/ansible-test-debugging/tasks/main.yml test/integration/targets/ansible-test-docker/aliases test/integration/targets/ansible-test-docker/runme.sh test/integration/targets/ansible-test-docker/ansible_collections/ns/col/galaxy.yml @@ -1576,11 +1583,16 @@ test/integration/targets/cache-plugins/test_inventory_cache.yml test/integration/targets/cache-plugins/cache_plugins/dummy_cache.py test/integration/targets/cache-plugins/inventory_plugins/test_inventoryconfig.py -test/integration/targets/callback-legacy-warnings/aliases -test/integration/targets/callback-legacy-warnings/runme.sh -test/integration/targets/callback-legacy-warnings/test.yml -test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py -test/integration/targets/callback-legacy-warnings/library/noisy.py +test/integration/targets/callback-dispatch/aliases +test/integration/targets/callback-dispatch/one-task.yml +test/integration/targets/callback-dispatch/runme.sh +test/integration/targets/callback-dispatch/test_legacy_warning_display.yml +test/integration/targets/callback-dispatch/test_v1_methods.yml +test/integration/targets/callback-dispatch/callback_plugins/legacy_warning_display.py +test/integration/targets/callback-dispatch/callback_plugins/missing_base_class.py +test/integration/targets/callback-dispatch/callback_plugins/oops_always_enabled.py +test/integration/targets/callback-dispatch/callback_plugins/v1_only_methods.py +test/integration/targets/callback-dispatch/library/noisy.py test/integration/targets/callback_default/aliases test/integration/targets/callback_default/callback_default.out.check_markers_dry.stderr test/integration/targets/callback_default/callback_default.out.check_markers_dry.stdout @@ -1840,6 +1852,7 @@ test/integration/targets/conditionals/vars/main.yml test/integration/targets/config/aliases test/integration/targets/config/inline_comment_ansible.cfg +test/integration/targets/config/match_option_methods.yml test/integration/targets/config/runme.sh test/integration/targets/config/type_munging.cfg test/integration/targets/config/types.yml @@ -1849,6 +1862,7 @@ test/integration/targets/config/files/types.vars test/integration/targets/config/files/types_dump.txt test/integration/targets/config/lookup_plugins/bogus.py +test/integration/targets/config/lookup_plugins/broken.py test/integration/targets/config/lookup_plugins/casting.py test/integration/targets/config/lookup_plugins/casting_individual.py test/integration/targets/config/lookup_plugins/types.py @@ -2020,6 +2034,10 @@ test/integration/targets/display-newline/library/noisy.py test/integration/targets/display-newline/tasks/main.yml test/integration/targets/dnf/aliases +test/integration/targets/dnf-latest/aliases +test/integration/targets/dnf-latest/tasks/main.yml +test/integration/targets/dnf-oldest/aliases +test/integration/targets/dnf-oldest/tasks/main.yml test/integration/targets/dnf/filter_plugins/dnf_module_list.py test/integration/targets/dnf/meta/main.yml test/integration/targets/dnf/tasks/cacheonly.yml @@ -2123,6 +2141,8 @@ test/integration/targets/filter_core/files/fileglob/two.txt test/integration/targets/filter_core/host_vars/localhost test/integration/targets/filter_core/tasks/main.yml +test/integration/targets/filter_core/tasks/to_json.yml +test/integration/targets/filter_core/tasks/to_yaml.yml test/integration/targets/filter_core/templates/foo.j2 test/integration/targets/filter_core/vars/main.yml test/integration/targets/filter_encryption/aliases @@ -2467,6 +2487,7 @@ test/integration/targets/include_import/playbook/playbook3.yml test/integration/targets/include_import/playbook/playbook4.yml test/integration/targets/include_import/playbook/playbook_needing_vars.yml +test/integration/targets/include_import/playbook/playbook_using_a_var.yml test/integration/targets/include_import/playbook/test_import_playbook.yml test/integration/targets/include_import/playbook/test_import_playbook_tags.yml test/integration/targets/include_import/playbook/test_templated_filenames.yml @@ -2600,6 +2621,8 @@ test/integration/targets/include_import_tasks_nested/tasks/nested/nested_adjacent.yml test/integration/targets/include_import_tasks_nested/tasks/nested/nested_import.yml test/integration/targets/include_import_tasks_nested/tasks/nested/nested_include.yml +test/integration/targets/include_import_tasks_nested/tests/main.yml +test/integration/targets/include_import_tasks_nested/tests/tests_relative.yml test/integration/targets/include_parent_role_vars/aliases test/integration/targets/include_parent_role_vars/tasks/included_by_other_role.yml test/integration/targets/include_parent_role_vars/tasks/included_by_ourselves.yml @@ -3590,6 +3613,7 @@ test/integration/targets/run_modules/library/test.py test/integration/targets/script/aliases test/integration/targets/script/files/create_afile.sh +test/integration/targets/script/files/exit_1.sh test/integration/targets/script/files/no_shebang.py test/integration/targets/script/files/remove_afile.sh test/integration/targets/script/files/test.sh @@ -3829,6 +3853,9 @@ test/integration/targets/task-args/action_plugins/echo.py test/integration/targets/task-args/action_plugins/echo_raw.py test/integration/targets/task-args/tasks/main.yml +test/integration/targets/task-esoterica/aliases +test/integration/targets/task-esoterica/action_plugins/echo.py +test/integration/targets/task-esoterica/tasks/main.yml test/integration/targets/task-timeout/aliases test/integration/targets/task-timeout/tasks/main.yml test/integration/targets/task_ordering/aliases @@ -4397,6 +4424,7 @@ test/lib/ansible_test/_internal/core_ci.py test/lib/ansible_test/_internal/coverage_util.py test/lib/ansible_test/_internal/data.py +test/lib/ansible_test/_internal/debugging.py test/lib/ansible_test/_internal/delegation.py test/lib/ansible_test/_internal/diff.py test/lib/ansible_test/_internal/docker_util.py @@ -4413,6 +4441,7 @@ test/lib/ansible_test/_internal/locale_util.py test/lib/ansible_test/_internal/metadata.py test/lib/ansible_test/_internal/payload.py +test/lib/ansible_test/_internal/processes.py test/lib/ansible_test/_internal/provisioning.py test/lib/ansible_test/_internal/pypi_proxy.py test/lib/ansible_test/_internal/python_requirements.py @@ -4770,12 +4799,15 @@ test/units/_internal/__init__.py test/units/_internal/test_event_formatting.py test/units/_internal/test_locking.py +test/units/_internal/_ansiballz/__init__.py +test/units/_internal/_ansiballz/test_builder.py test/units/_internal/_datatag/__init__.py test/units/_internal/_datatag/test_tags.py test/units/_internal/_errors/test_alarm_timeout.py test/units/_internal/_errors/test_error_utils.py test/units/_internal/_errors/test_task_timeout.py test/units/_internal/_json/__init__.py +test/units/_internal/_json/test_json.py test/units/_internal/_json/test_legacy_encoder.py test/units/_internal/_yaml/__init__.py test/units/_internal/_yaml/test_dumper.py @@ -4943,6 +4975,7 @@ test/units/module_utils/test_text.py test/units/module_utils/_internal/__init__.py test/units/module_utils/_internal/test_deprecator.py +test/units/module_utils/_internal/test_traceback.py test/units/module_utils/_internal/_concurrent/__init__.py test/units/module_utils/_internal/_concurrent/test_daemon_threading.py test/units/module_utils/_internal/_concurrent/test_futures.py @@ -5302,6 +5335,7 @@ test/units/plugins/lookup/test_env.py test/units/plugins/lookup/test_ini.py test/units/plugins/lookup/test_password.py +test/units/plugins/lookup/test_template.py test/units/plugins/lookup/test_url.py test/units/plugins/shell/__init__.py test/units/plugins/shell/test_cmd.py diff -Nru ansible-core-2.19.0~beta6/changelogs/CHANGELOG-v2.19.rst ansible-core-2.19.1/changelogs/CHANGELOG-v2.19.rst --- ansible-core-2.19.0~beta6/changelogs/CHANGELOG-v2.19.rst 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/changelogs/CHANGELOG-v2.19.rst 2025-08-25 19:16:05.000000000 +0000 @@ -4,188 +4,52 @@ .. contents:: Topics -v2.19.0b6 -========= +v2.19.1 +======= Release Summary --------------- -| Release Date: 2025-06-11 +| Release Date: 2025-08-25 | `Porting Guide `__ Minor Changes ------------- -- ansiballz - Added an experimental AnsiballZ extension for remote debugging. -- ansiballz - Added support for AnsiballZ extensions. -- ansiballz - Moved AnsiballZ code coverage support into an extension. -- ansiballz - Refactored AnsiballZ and module respawn. -- template action and lookup plugin - The value of the ``ansible_managed`` variable (if set) will not be masked by the ``template`` action and lookup. Previously, the value calculated by the ``DEFAULT_MANAGED_STR`` configuration option always masked the variable value during plugin execution, preventing runtime customization. - -Bugfixes --------- - -- Fix templating ``tags`` on plays and roles. (https://github.com/ansible/ansible/issues/69903) -- ansible-doc will no longer ignore docs for modules without an extension (https://github.com/ansible/ansible/issues/85279). -- display - Fix hang caused by early post-fork writers to stdout/stderr (e.g., pydevd) encountering an unreleased fork lock. -- get_url - add a check to recognize incomplete data transfers. -- include_tasks - fix templating options when used as a handler (https://github.com/ansible/ansible/pull/85015). -- templating - Fixed cases where template expression blocks halted prematurely when a Jinja macro invocation returned an undefined value. -- templating - Jinja macros returned from a template expression can now be called from another template expression. - -v2.19.0b5 -========= - -Release Summary ---------------- - -| Release Date: 2025-06-03 -| `Porting Guide `__ - -Minor Changes -------------- - -- Improved SUSE distribution detection in distribution.py by parsing VARIANT_ID from /etc/os-release for identifying SLES_SAP and SL-Micro. Falls back to /etc/products.d/baseproduct symlink for older systems. -- Remove unnecessary shebang from the ``hostname`` module. -- Use ``importlib.metadata.version()`` to detect Jinja version as jinja2.__version__ is deprecated and will be removed in Jinja 3.3. -- ansible-doc - Return dynamic stub when reporting on Jinja filters and tests not explicitly documented in Ansible -- ansible-doc - Skip listing the internal ``ansible._protomatter`` plugins unless explicitly requested -- ansible-test - Add RHEL 10.0 as a remote platform for testing. -- apt_repository - remove Python 2 support -- csvfile lookup - remove Python 2 compat -- display - Add ``help_text`` and ``obj`` to ``Display.error_as_warning``. -- display - Replace Windows newlines (``\r\n``) in display output with Unix newlines (``\n``). This ensures proper display of strings sourced from Windows hosts in environments which treat ``\r`` as ``\n``, such as Azure Pipelines. -- facts - add "Linode" for Linux VM in virtual facts -- module_utils - Add ``AnsibleModule.error_as_warning``. -- module_utils - Add ``ansible.module_utils.common.warnings.error_as_warning``. -- module_utils - Add optional ``help_text`` argument to ``AnsibleModule.warn``. -- ssh agent - Added ``SSH_AGENT_EXECUTABLE`` config to allow override of ssh-agent. -- ssh connection plugin - Added ``verbosity`` config to decouple SSH debug output verbosity from Ansible verbosity. Previously, the Ansible verbosity value was always applied to the SSH client command-line, leading to excessively verbose output. Set the ``ANSIBLE_SSH_VERBOSITY`` envvar or ``ansible_ssh_verbosity`` Ansible variable to a positive integer to increase SSH client verbosity. -- task timeout - Specifying a timeout greater than 100,000,000 now results in an error. -- templating - Added ``_ANSIBLE_TEMPLAR_SANDBOX_MODE=allow_unsafe_attributes`` environment variable to disable Jinja template attribute sandbox. (https://github.com/ansible/ansible/issues/85202) -- windows - Added support for ``#AnsibleRequires -Wrapper`` to request a PowerShell module be run through the execution wrapper scripts without any module utils specified. -- windows - Added support for running signed modules and scripts with a Windows host protected by Windows App Control/WDAC. This is a tech preview and the interface may be subject to change. -- windows - Script modules will preserve UTF-8 encoding when executing the script. - -Deprecated Features -------------------- - -- The ``ShellModule.checksum`` method is now deprecated and will be removed in ansible-core 2.23. Use ``ActionBase._execute_remote_stat()`` instead. -- The ``ansible.module_utils.common.collections.count()`` function is deprecated and will be removed in ansible-core 2.23. Use ``collections.Counter()`` from the Python standard library instead. -- ``ansible.compat.importlib_resources`` is deprecated and will be removed in ansible-core 2.23. Use ``importlib.resources`` from the Python standard library instead. - -Bugfixes --------- - -- Core Jinja test plugins - Builtin test plugins now always return ``bool`` to avoid spurious deprecation warnings for some malformed inputs. -- ansible-test - Disabled the ``bad-super-call`` pylint rule due to false positives. -- ansible-test - Fix incorrect handling of options with optional args (e.g. ``--color``), when followed by other options which are omitted during arg filtering (e.g. ``--docker``). Previously it was possible for non-option arguments to be incorrectly omitted in these cases. (https://github.com/ansible/ansible/issues/85173) -- ansible-test - Improve type inference for pylint deprecated checks to accommodate some type annotations. -- async_status module - The ``started`` and ``finished`` return values are now ``True`` or ``False`` instead of ``1`` or ``0``. -- constructed inventory - Use the ``default_value`` or ``trailing_separator`` in a ``keyed_groups`` entry if the expression result of ``key`` is ``None`` and not just an empty string. -- dnf5 - handle all libdnf5 specific exceptions (https://github.com/ansible/ansible/issues/84634) -- error handling - Error details and tracebacks from connection and built-in action exceptions are preserved. Previously, much of the detail was lost or mixed into the error message. -- from_yaml_all filter - `None` and empty string inputs now always return an empty list. Previously, `None` was returned in Jinja native mode and empty list in classic mode. -- local connection plugin - The command-line used to create subprocesses is now always ``str`` to avoid issues with debuggers and profilers. -- ssh agent - Fixed several potential startup hangs for badly-behaved or overloaded ssh agents. -- task timeout - Specifying a negative task timeout now results in an error. - -v2.19.0b4 -========= - -Release Summary ---------------- - -| Release Date: 2025-05-12 -| `Porting Guide `__ - -Minor Changes -------------- - -- facts - add "CloudStack KVM Hypervisor" for Linux VM in virtual facts (https://github.com/ansible/ansible/issues/85089). -- modules - use ``AnsibleModule.warn`` instead of passing ``warnings`` to ``exit_json`` or ``fail_json`` which is deprecated. - -Bugfixes --------- - -- ansible-test - Updated the ``pylint`` sanity test to skip some deprecation validation checks when all arguments are dynamic. -- config - Preserve or apply Origin tag to values returned by config. -- config - Prevented fatal errors when ``MODULE_IGNORE_EXTS`` configuration was set. -- config - Templating failures on config defaults now issue a warning. Previously, failures silently returned an unrendered and untrusted template to the caller. -- config - ``ensure_type`` correctly propagates trust and other tags on returned values. -- config - ``ensure_type`` now converts mappings to ``dict`` when requested, instead of returning the mapping. -- config - ``ensure_type`` now converts sequences to ``list`` when requested, instead of returning the sequence. -- config - ``ensure_type`` now correctly errors when ``pathlist`` or ``pathspec`` types encounter non-string list items. -- config - ``ensure_type`` now reports an error when ``bytes`` are provided for any known ``value_type``. Previously, the behavior was undefined, but often resulted in an unhandled exception or incorrect return type. -- config - ``ensure_type`` with expected type ``int`` now properly converts ``True`` and ``False`` values to ``int``. Previously, these values were silently returned unmodified. -- convert_bool.boolean API conversion function - Unhashable values passed to ``boolean`` behave like other non-boolean convertible values, returning False or raising ``TypeError`` depending on the value of ``strict``. Previously, unhashable values always raised ``ValueError`` due to an invalid set membership check. -- dnf5 - when ``bugfix`` and/or ``security`` is specified, skip packages that do not have any such updates, even for new versions of libdnf5 where this functionality changed and it is considered failure -- plugin loader - Apply template trust to strings loaded from plugin configuration definitions and doc fragments. -- template action - Template files where the entire file's output renders as ``None`` are no longer emitted as the string "None", but instead render to an empty file as in previous releases. - -v2.19.0b3 -========= - -Release Summary ---------------- - -| Release Date: 2025-05-06 -| `Porting Guide `__ - -Minor Changes -------------- - -- ansible-config will now show internal, but not test configuration entries. This allows for debugging but still denoting the configurations as internal use only (_ prefix). -- ansible-test - Improved ``pylint`` checks for Ansible-specific deprecation functions. -- ansible-test - Use the ``-t`` option to set the stop timeout when stopping a container. This avoids use of the ``--time`` option which was deprecated in Docker v28.0. -- collection metadata - The collection loader now parses scalar values from ``meta/runtime.yml`` as strings. This avoids issues caused by unquoted values such as versions or dates being parsed as types other than strings. -- deprecation warnings - Deprecation warning APIs automatically capture the identity of the deprecating plugin. The ``collection_name`` argument is only required to correctly attribute deprecations that occur in module_utils or other non-plugin code. -- deprecation warnings - Improved deprecation messages to more clearly indicate the affected content, including plugin name when available. -- deprecations - Collection name strings not of the form ``ns.coll`` passed to deprecation API functions will result in an error. -- deprecations - Removed support for specifying deprecation dates as a ``datetime.date``, which was included in an earlier 2.19 pre-release. -- deprecations - Some argument names to ``deprecate_value`` for consistency with existing APIs. An earlier 2.19 pre-release included a ``removal_`` prefix on the ``date`` and ``version`` arguments. -- modules - The ``AnsibleModule.deprecate`` function no longer sends deprecation messages to the target host's logging system. - -Deprecated Features -------------------- - -- Passing a ``warnings` or ``deprecations`` key to ``exit_json`` or ``fail_json`` is deprecated. Use ``AnsibleModule.warn`` or ``AnsibleModule.deprecate`` instead. -- plugins - Accessing plugins with ``_``-prefixed filenames without the ``_`` prefix is deprecated. +- AnsibleModule - Add temporary internal monkeypatch-able hook to alter module result serialization by splitting serialization from ``_return_formatted`` into ``_record_module_result``. +- ansible-test - Improve formatting of generated coverage config file. +- ansible-test - Use OS packages to satisfy controller requirements on FreeBSD 13.5 during managed instance bootstrapping. +- encrypt - check datatype of salt_size in password_hash filter. +- service_facts - handle keyerror exceptions with warning. +- service_facts - warn user about missing service details instead of ignoring. Bugfixes -------- -- Ansible will now ensure predictable permissions on remote artifacts, until now it only ensured executable and relied on system masks for the rest. -- dnf5 - avoid generating excessive transaction entries in the dnf5 history (https://github.com/ansible/ansible/issues/85046) +- ansible-test - Always exclude the ``tests/output/`` directory from a collection's code coverage. (https://github.com/ansible/ansible/issues/84244) +- ansible-test - Limit package install retries during managed remote instance bootstrapping. +- ansible-test - Use a consistent coverage config for all collection testing. +- argspec validation - The ``str`` argspec type treats ``None`` values as empty string for better consistency with pre-2.19 templating conversions. +- conditionals - When displaying a broken conditional error or deprecation warning, the origin of the non-boolean result is included (if available), and the raw result is omitted. +- failed_when - When using ``failed_when`` to suppress an error, the ``exception`` key in the result is renamed to ``failed_when_suppressed_exception``. This prevents the error from being displayed by callbacks after being suppressed. (https://github.com/ansible/ansible/issues/85505) +- import_tasks - fix templating parent include arguments. +- plugins config, get_option_and_origin now correctly displays the value and origin of the option. +- template lookup - Skip finalization on the internal templating operation to allow markers to be returned and handled by, e.g. the ``default`` filter. Previously, finalization tripped markers, causing an exception to end processing of the current template pipeline. (https://github.com/ansible/ansible/issues/85674) +- templating - Avoid tripping markers within Jinja generated code. (https://github.com/ansible/ansible/issues/85674) +- templating - Ensure filter plugin result processing occurs under the correct call context. (https://github.com/ansible/ansible/issues/85585) +- templating - Fix slicing of tuples in templating (https://github.com/ansible/ansible/issues/85606). +- templating - Multi-node template results coerce embedded ``None`` nodes to empty string (instead of rendering literal ``None`` to the output). +- templating - Undefined marker values sourced from the Jinja ``getattr->getitem`` fallback are now accessed correctly, raising AnsibleUndefinedVariable for user plugins that do not understand markers. Previously, these values were erroneously returned to user plugin code that had not opted in to marker acceptance. +- tqm - use display.error_as_warning instead of display.warning_as_error. +- tqm - use display.error_as_warning instead of self.warning. -v2.19.0b2 -========= +v2.19.0 +======= Release Summary --------------- -| Release Date: 2025-04-24 -| `Porting Guide `__ - -Minor Changes -------------- - -- comment filter - Improve the error message shown when an invalid ``style`` argument is provided. - -Bugfixes --------- - -- Remove use of `required` parameter in `get_bin_path` which has been deprecated. -- ansible-doc - fix indentation for first line of descriptions of suboptions and sub-return values (https://github.com/ansible/ansible/pull/84690). -- ansible-doc - fix line wrapping for first line of description of options and return values (https://github.com/ansible/ansible/pull/84690). - -v2.19.0b1 -========= - -Release Summary ---------------- - -| Release Date: 2025-04-14 +| Release Date: 2025-07-21 | `Porting Guide `__ Major Changes @@ -201,25 +65,40 @@ ------------- - Added a -vvvvv log message indicating when a host fails to produce output within the timeout period. +- Added type annotations to the ``Role.__init__()`` method to enable type checking. (https://github.com/ansible/ansible/pull/85346) - AnsibleModule.uri - Add option ``multipart_encoding`` for ``form-multipart`` files in body to change default base64 encoding for files - INVENTORY_IGNORE_EXTS config, removed ``ini`` from the default list, inventory scripts using a corresponding .ini configuration are rare now and inventory.ini files are more common. Those that need to ignore the ini files for inventory scripts can still add it to configuration. +- Improved SUSE distribution detection in distribution.py by parsing VARIANT_ID from /etc/os-release for identifying SLES_SAP and SL-Micro. Falls back to /etc/products.d/baseproduct symlink for older systems. - Jinja plugins - Plugins can declare support for undefined values. - Jinja2 version 3.1.0 or later is now required on the controller. - Move ``follow_redirects`` parameter to module_utils so external modules can reuse it. - PlayIterator - do not return tasks from already executed roles so specific strategy plugins do not have to do the filtering of such tasks themselves +- Remove unnecessary shebang from the ``hostname`` module. - SSH Escalation-related -vvv log messages now include the associated host information. +- Use ``importlib.metadata.version()`` to detect Jinja version as jinja2.__version__ is deprecated and will be removed in Jinja 3.3. - Windows - Add support for Windows Server 2025 to Ansible and as an ``ansible-test`` remote target - https://github.com/ansible/ansible/issues/84229 - Windows - refactor the async implementation to better handle errors during bootstrapping and avoid WMI when possible. - ``ansible-galaxy collection install`` — the collection dependency resolver now prints out conflicts it hits during dependency resolution when it's taking too long and it ends up backtracking a lot. It also displays suggestions on how to help it compute the result more quickly. +- ansiballz - Added an experimental AnsiballZ extension for remote debugging. +- ansiballz - Added support for AnsiballZ extensions. +- ansiballz - Moved AnsiballZ code coverage support into an extension. +- ansiballz - Refactored AnsiballZ and module respawn. - ansible, ansible-console, ansible-pull - add --flush-cache option (https://github.com/ansible/ansible/issues/83749). +- ansible-config will now show internal, but not test configuration entries. This allows for debugging but still denoting the configurations as internal use only (_ prefix). +- ansible-doc - Return dynamic stub when reporting on Jinja filters and tests not explicitly documented in Ansible +- ansible-doc - Skip listing the internal ``ansible._protomatter`` plugins unless explicitly requested - ansible-galaxy - Add support for Keycloak service accounts - ansible-galaxy - support ``resolvelib >= 0.5.3, < 2.0.0`` (https://github.com/ansible/ansible/issues/84217). +- ansible-test - Add RHEL 10.0 as a remote platform for testing. - ansible-test - Added a macOS 15.3 remote VM, replacing 14.3. +- ansible-test - Added experimental support for remote debugging. +- ansible-test - Added support for setting static environment variables in integration tests using ``env/set/`` entries in the ``aliases`` file. For example, ``env/set/MY_KEY/MY_VALUE`` or ``env/set/MY_PATH//an/abs/path``. - ansible-test - Automatically retry HTTP GET/PUT/DELETE requests on exceptions. - ansible-test - Default to Python 3.13 in the ``base`` and ``default`` containers. - ansible-test - Disable the ``deprecated-`` prefixed ``pylint`` rules as their results vary by Python version. - ansible-test - Disable the ``pep8`` sanity test rules ``E701`` and ``E704`` to improve compatibility with ``black``. - ansible-test - Improve container runtime probe error handling. When unexpected probe output is encountered, an error with more useful debugging information is provided. +- ansible-test - Improved ``pylint`` checks for Ansible-specific deprecation functions. - ansible-test - Replace container Alpine 3.20 with 3.21. - ansible-test - Replace container Fedora 40 with 41. - ansible-test - Replace remote Alpine 3.20 with 3.21. @@ -228,32 +107,51 @@ - ansible-test - Replace remote FreeBSD 14.1 with 14.2. - ansible-test - Replace remote RHEL 9.4 with 9.5. - ansible-test - Show a more user-friendly error message when a ``runme.sh`` script is not executable. +- ansible-test - The ``shell`` command has been augmented to propagate remote debug configurations and other test-related settings when running on the controller. Use the ``--raw`` argument to bypass the additional environment configuration. - ansible-test - The ``yamllint`` sanity test now enforces string values for the ``!vault`` tag. - ansible-test - Update ``nios-test-container`` to version 7.0.0. - ansible-test - Update ``pylint`` sanity test to use version 3.3.1. -- ansible-test - Update distro containers to remove unnecessary pakages (apache2, subversion, ruby). +- ansible-test - Update distro containers to remove unnecessary packages (apache2, subversion, ruby). - ansible-test - Update sanity test requirements to latest available versions. - ansible-test - Update the HTTP test container. - ansible-test - Update the PyPI test container. - ansible-test - Update the ``base`` and ``default`` containers. - ansible-test - Update the utility container. - ansible-test - Use Python's ``urllib`` instead of ``curl`` for HTTP requests. +- ansible-test - Use the ``-t`` option to set the stop timeout when stopping a container. This avoids use of the ``--time`` option which was deprecated in Docker v28.0. - ansible-test - When detection of the current container network fails, a warning is now issued and execution continues. This simplifies usage in cases where the current container cannot be inspected, such as when running in GitHub Codespaces. - ansible-test acme test container - bump `version to 2.3.0 `__ to include newer versions of Pebble, dependencies, and runtimes. This adds support for ACME profiles, ``dns-account-01`` support, and some smaller improvements (https://github.com/ansible/ansible/pull/84547). +- apt - consider lock timeout while invoking apt-get command (https://github.com/ansible/ansible/issues/78658). - apt_key module - add notes to docs and errors to point at the CLI tool deprecation by Debian and alternatives +- apt_repository - remove Python 2 support - apt_repository module - add notes to errors to point at the CLI tool deprecation by Debian and alternatives +- assemble action added check_mode support - become plugins get new property 'pipelining' to show support or lack there of for the feature. - callback plugins - add has_option() to CallbackBase to match other functions overloaded from AnsiblePlugin - callback plugins - fix get_options() for CallbackBase +- collection metadata - The collection loader now parses scalar values from ``meta/runtime.yml`` as strings. This avoids issues caused by unquoted values such as versions or dates being parsed as types other than strings. +- comment filter - Improve the error message shown when an invalid ``style`` argument is provided. - copy - fix sanity test failures (https://github.com/ansible/ansible/pull/83643). - copy - parameter ``local_follow`` was incorrectly documented as having default value ``True`` (https://github.com/ansible/ansible/pull/83643). - cron - Provide additional error information while writing cron file (https://github.com/ansible/ansible/issues/83223). - csvfile - let the config system do the typecasting (https://github.com/ansible/ansible/pull/82263). +- csvfile lookup - remove Python 2 compat +- deprecation warnings - Deprecation warning APIs automatically capture the identity of the deprecating plugin. The ``collection_name`` argument is only required to correctly attribute deprecations that occur in module_utils or other non-plugin code. +- deprecation warnings - Improved deprecation messages to more clearly indicate the affected content, including plugin name when available. +- deprecations - Collection name strings not of the form ``ns.coll`` passed to deprecation API functions will result in an error. +- deprecations - Removed support for specifying deprecation dates as a ``datetime.date``, which was included in an earlier 2.19 pre-release. +- deprecations - Some argument names to ``deprecate_value`` for consistency with existing APIs. An earlier 2.19 pre-release included a ``removal_`` prefix on the ``date`` and ``version`` arguments. +- display - Add ``help_text`` and ``obj`` to ``Display.error_as_warning``. - display - Deduplication of warning and error messages considers the full content of the message (including source and traceback contexts, if enabled). This may result in fewer messages being omitted. +- display - Replace Windows newlines (``\r\n``) in display output with Unix newlines (``\n``). This ensures proper display of strings sourced from Windows hosts in environments which treat ``\r`` as ``\n``, such as Azure Pipelines. +- display - The ``formatted`` arg to ``warning`` has no effect. Warning wrapping is left to the consumer (e.g. terminal, browser). +- display - The ``wrap_text`` and ``stderr`` arguments to ``error`` have no effect. Errors are always sent to stderr and wrapping is left to the consumer (e.g. terminal, browser). - distribution - Added openSUSE MicroOS to Suse OS family (#84685). - dnf5, apt - add ``auto_install_module_deps`` option (https://github.com/ansible/ansible/issues/84206) - docs - add collection name in message from which the module is being deprecated (https://github.com/ansible/ansible/issues/84116). - env lookup - The error message generated for a missing environment variable when ``default`` is an undefined value (e.g. ``undef('something')``) will contain the hint from that undefined value, except when the undefined value is the default of ``undef()`` with no arguments. Previously, any existing undefined hint would be ignored. +- facts - add "CloudStack KVM Hypervisor" for Linux VM in virtual facts (https://github.com/ansible/ansible/issues/85089). +- facts - add "Linode" for Linux VM in virtual facts - file - enable file module to disable diff_mode (https://github.com/ansible/ansible/issues/80817). - file - make code more readable and simple. - filter - add support for URL-safe encoding and decoding in b64encode and b64decode (https://github.com/ansible/ansible/issues/84147). @@ -266,21 +164,34 @@ - local connection plugin - When a ``become`` plugin's ``prompt`` value is a non-string after the ``check_password_prompt`` callback has completed, no prompt stripping will occur on stderr. - lookup_template - add an option to trim blocks while templating (https://github.com/ansible/ansible/issues/75962). - module - set ipv4 and ipv6 rules simultaneously in iptables module (https://github.com/ansible/ansible/issues/84404). +- module_utils - Add ``AnsibleModule.error_as_warning``. - module_utils - Add ``NoReturn`` type annotations to functions which never return. +- module_utils - Add ``ansible.module_utils.common.warnings.error_as_warning``. +- module_utils - Add optional ``help_text`` argument to ``AnsibleModule.warn``. +- module_utils.basic.backup_local enforces check_mode now - modules - PowerShell modules can now receive ``datetime.date``, ``datetime.time`` and ``datetime.datetime`` values as ISO 8601 strings. - modules - PowerShell modules can now receive strings sourced from inline vault-encrypted strings. +- modules - The ``AnsibleModule.deprecate`` function no longer sends deprecation messages to the target host's logging system. - modules - Unhandled exceptions during Python module execution are now returned as structured data from the target. This allows the new traceback handling to be applied to exceptions raised on targets. +- modules - use ``AnsibleModule.warn`` instead of passing ``warnings`` to ``exit_json`` or ``fail_json`` which is deprecated. - pipelining logic has mostly moved to connection plugins so they can decide/override settings. - plugin error handling - When raising exceptions in an exception handler, be sure to use ``raise ... from`` as appropriate. This supersedes the use of the ``AnsibleError`` arg ``orig_exc`` to represent the cause. Specifying ``orig_exc`` as the cause is still permitted. Failure to use ``raise ... from`` when ``orig_exc`` is set will result in a warning. Additionally, if the two cause exceptions do not match, a warning will be issued. -- removed harcoding of su plugin as it now works with pipelining. +- removed hardcoding of su plugin as it now works with pipelining. - runtime-metadata sanity test - improve validation of ``action_groups`` (https://github.com/ansible/ansible/pull/83965). - service_facts module got freebsd support added. +- ssh agent - Added ``SSH_AGENT_EXECUTABLE`` config to allow override of ssh-agent. +- ssh connection plugin - Added ``verbosity`` config to decouple SSH debug output verbosity from Ansible verbosity. Previously, the Ansible verbosity value was always applied to the SSH client command-line, leading to excessively verbose output. Set the ``ANSIBLE_SSH_VERBOSITY`` envvar or ``ansible_ssh_verbosity`` Ansible variable to a positive integer to increase SSH client verbosity. - ssh connection plugin - Support ``SSH_ASKPASS`` mechanism to provide passwords, making it the default, but still offering an explicit choice to use ``sshpass`` (https://github.com/ansible/ansible/pull/83936) - ssh connection plugin now overrides pipelining when a tty is requested. - ssh-agent - ``ansible``, ``ansible-playbook`` and ``ansible-console`` are capable of spawning or reusing an ssh-agent, allowing plugins to interact with the ssh-agent. Additionally a pure python ssh-agent client has been added, enabling easy interaction with the agent. The ssh connection plugin contains new functionality via ``ansible_ssh_private_key`` and ``ansible_ssh_private_key_passphrase``, for loading an SSH private key into the agent from a variable. +- task timeout - Specifying a timeout greater than 100,000,000 now results in an error. +- template action and lookup plugin - The value of the ``ansible_managed`` variable (if set) will not be masked by the ``template`` action and lookup. Previously, the value calculated by the ``DEFAULT_MANAGED_STR`` configuration option always masked the variable value during plugin execution, preventing runtime customization. - templating - Access to an undefined variable from inside a lookup, filter, or test (which raises MarkerError) no longer ends processing of the current template. The triggering undefined value is returned as the result of the offending plugin invocation, and the template continues to execute. +- templating - Added ``_ANSIBLE_TEMPLAR_SANDBOX_MODE=allow_unsafe_attributes`` environment variable to disable Jinja template attribute sandbox. (https://github.com/ansible/ansible/issues/85202) - templating - Embedding ``range()`` values in containers such as lists will result in an error on use. Previously the value would be converted to a string representing the range parameters, such as ``range(0, 3)``. - templating - Handling of omitted values is now a first-class feature of the template engine, and is usable in all Ansible Jinja template contexts. Any template that resolves to ``omit`` is automatically removed from its parent container during templating. +- templating - Relaxed the Jinja sandbox to allow specific bitwise operations which have no filter equivalent. The allowed methods are ``__and__``, ``__lshift__``, ``__or__``, ``__rshift__``, ``__xor__``. +- templating - Switched from the Jinja immutable sandbox to the standard sandbox. This restores the ability to use mutation methods such as ``list.append`` and ``dict.update``. - templating - Template evaluation is lazier than in previous versions. Template expressions which resolve only portions of a data structure no longer result in the entire structure being templated. - templating - Templating errors now provide more information about both the location and context of the error, especially for deeply-nested and/or indirected templating scenarios. - templating - Unified ``omit`` behavior now requires that plugins calling ``Templar.template()`` handle cases where the entire template result is omitted, by catching the ``AnsibleValueOmittedError`` that is raised. Previously, this condition caused a randomly-generated string marker to appear in the template result. @@ -289,10 +200,15 @@ - troubleshooting - Tracebacks can be collected and displayed for most errors, warnings, and deprecation warnings (including those generated by modules). Tracebacks are no longer enabled with ``-vvv``; the behavior is directly configurable via the ``DISPLAY_TRACEBACK`` config option. Module tracebacks passed to ``fail_json`` via the ``exception`` kwarg will not be included in the task result unless error tracebacks are configured. - undef jinja function - The ``undef`` jinja function now raises an error if a non-string hint is given. Attempting to use an undefined hint also results in an error, ensuring incorrect use of the function can be distinguished from the function's normal behavior. - validate-modules sanity test - make sure that ``module`` and ``plugin`` ``seealso`` entries use FQCNs (https://github.com/ansible/ansible/pull/84325). +- variables - Removed restriction on usage of most Python keywords as Ansible variable names. +- variables - Warnings about reserved variable names now show context where the variable was defined. - vault - improved vault filter documentation by adding missing example content for dump_template_data.j2, refining examples for clarity, and ensuring variable consistency (https://github.com/ansible/ansible/issues/83583). - warnings - All warnings (including deprecation warnings) issued during a task's execution are now accessible via the ``warnings`` and ``deprecations`` keys on the task result. - when the ``dict`` lookup is given a non-dict argument, show the value of the argument and its type in the error message. -- windows - add hard minimum limit for PowerShell to 5.1. Ansible dropped support for older versions of PowerShell in the 2.16 release but this reqirement is now enforced at runtime. +- windows - Added support for ``#AnsibleRequires -Wrapper`` to request a PowerShell module be run through the execution wrapper scripts without any module utils specified. +- windows - Added support for running signed modules and scripts with a Windows host protected by Windows App Control/WDAC. This is a tech preview and the interface may be subject to change. +- windows - Script modules will preserve UTF-8 encoding when executing the script. +- windows - add hard minimum limit for PowerShell to 5.1. Ansible dropped support for older versions of PowerShell in the 2.16 release but this requirement is now enforced at runtime. - windows - refactor windows exec runner to improve efficiency and add better error reporting on failures. - winrm - Remove need for pexpect on macOS hosts when using ``kinit`` to retrieve the Kerberos TGT. By default the code will now only use the builtin ``subprocess`` library which should handle issues with select and a high fd count and also simplify the code. @@ -305,7 +221,6 @@ - first_found lookup - When specifying ``files`` or ``paths`` as a templated list containing undefined values, the undefined list elements will be discarded with a warning. Previously, the entire list would be discarded without any warning. - internals - The ``AnsibleLoader`` and ``AnsibleDumper`` classes for working with YAML are now factory functions and cannot be extended. - internals - The ``ansible.utils.native_jinja`` Python module has been removed. -- inventory - Invalid variable names provided by inventories result in an inventory parse failure. This behavior is now consistent with other variable name usages throughout Ansible. - lookup plugins - Lookup plugins called as `with_(lookup)` will no longer have the `_subdir` attribute set. - lookup plugins - ``terms`` will always be passed to ``run`` as the first positional arg, where previously it was sometimes passed as a keyword arg when using ``with_`` syntax. - loops - Omit placeholders no longer leak between loop item templating and task templating. Previously, ``omit`` placeholders could remain embedded in loop items after templating and be used as an ``omit`` for task templating. Now, values resolving to ``omit`` are dropped immediately when loop items are templated. To turn missing values into an ``omit`` for task templating, use ``| default(omit)``. This solution is backward-compatible with previous versions of ansible-core. @@ -331,7 +246,14 @@ ------------------- - CLI - The ``--inventory-file`` option alias is deprecated. Use the ``-i`` or ``--inventory`` option instead. -- Stategy Plugins - Use of strategy plugins not provided in ``ansible.builtin`` are deprecated and do not carry any backwards compatibility guarantees going forward. A future release will remove the ability to use external strategy plugins. No alternative for third party strategy plugins is currently planned. +- Jinja test plugins - Returning a non-boolean result from a Jinja test plugin is deprecated. +- Passing a ``warnings` or ``deprecations`` key to ``exit_json`` or ``fail_json`` is deprecated. Use ``AnsibleModule.warn`` or ``AnsibleModule.deprecate`` instead. +- Strategy Plugins - Use of strategy plugins not provided in ``ansible.builtin`` are deprecated and do not carry any backwards compatibility guarantees going forward. A future release will remove the ability to use external strategy plugins. No alternative for third party strategy plugins is currently planned. +- The ``ShellModule.checksum`` method is now deprecated and will be removed in ansible-core 2.23. Use ``ActionBase._execute_remote_stat()`` instead. +- The ``ansible.module_utils.common.collections.count()`` function is deprecated and will be removed in ansible-core 2.23. Use ``collections.Counter()`` from the Python standard library instead. +- YAML parsing - Usage of the YAML 1.1 ``!!omap`` and ``!!pairs`` tags is deprecated. Use standard mappings instead. +- YAML parsing - Usage of the undocumented ``!vault-encrypted`` YAML tag is deprecated. Use ``!vault`` instead. +- ``ansible.compat.importlib_resources`` is deprecated and will be removed in ansible-core 2.23. Use ``importlib.resources`` from the Python standard library instead. - ``ansible.module_utils.compat.datetime`` - The datetime compatibility shims are now deprecated. They are scheduled to be removed in ``ansible-core`` v2.21. This includes ``UTC``, ``utcfromtimestamp()`` and ``utcnow`` importable from said module (https://github.com/ansible/ansible/pull/81874). - bool filter - Support for coercing unrecognized input values (including None) has been deprecated. Consult the filter documentation for acceptable values, or consider use of the ``truthy`` and ``falsy`` tests. - cache plugins - The `ansible.plugins.cache.base` Python module is deprecated. Use `ansible.plugins.cache` instead. @@ -339,16 +261,30 @@ - callback plugins - The v1 callback API (callback methods not prefixed with `v2_`) is deprecated. Use `v2_` prefixed methods instead. - conditionals - Conditionals using Jinja templating delimiters (e.g., ``{{``, ``{%``) should be rewritten as expressions without delimiters, unless the entire conditional value is a single template that resolves to a trusted string expression. This is useful for dynamic indirection of conditional expressions, but is limited to trusted literal string expressions. - config - The ``ACTION_WARNINGS`` config has no effect. It previously disabled command warnings, which have since been removed. +- config - The ``DEFAULT_ALLOW_UNSAFE_LOOKUPS`` configuration option is deprecated and no longer has any effect. Ansible templating no longer encounters situations where use of lookup plugins is considered "unsafe". - config - The ``DEFAULT_JINJA2_NATIVE`` option has no effect. Jinja2 native mode is now the default and only option. - config - The ``DEFAULT_NULL_REPRESENTATION`` option has no effect. Null values are no longer automatically converted to another value during templating of single variable references. +- config - The ``DEFAULT_UNDEFINED_VAR_BEHAVIOR`` configuration option is deprecated and no longer has any effect. Attempting to use an undefined variable where undefined values are unexpected is now always an error. This behavior was enabled by default in previous versions, and disabling it yielded inconsistent results. +- config - The ``STRING_TYPE_FILTERS`` configuration option is deprecated and no longer has any effect. Since the template engine now always preserves native types, there is no longer a risk of unintended conversion from strings to native types. +- config - Using the ``DEFAULT_JINJA2_EXTENSIONS`` configuration option to enable Jinja2 extensions is deprecated. Previously, custom Jinja extensions were disabled by default, as they can destabilize the Ansible templating environment. Templates should only make use of filter, test and lookup plugins. +- config - Using the ``DEFAULT_MANAGED_STR`` configuration option to customize the value of the ``ansible_managed`` variable is deprecated. The ``ansible_managed`` variable can now be set the same as any other variable. - display - The ``Display.get_deprecation_message`` method has been deprecated. Call ``Display.deprecated`` to display a deprecation message, or call it with ``removed=True`` to raise an ``AnsibleError``. - file loading - Loading text files with ``DataLoader`` containing data that cannot be decoded under the expected encoding is deprecated. In most cases the encoding must be UTF-8, although some plugins allow choosing a different encoding. Previously, invalid data was silently wrapped in Unicode surrogate escape sequences, often resulting in later errors or other data corruption. - first_found lookup - Splitting of file paths on ``,;:`` is deprecated. Pass a list of paths instead. The ``split`` method on strings can be used to split variables into a list as needed. - interpreter discovery - The ``auto_legacy`` and ``auto_legacy_silent`` options for ``INTERPRETER_PYTHON`` are deprecated. Use ``auto`` or ``auto_silent`` options instead, as they have the same effect. +- inventory plugins - Setting invalid Ansible variable names in inventory plugins is deprecated. - oneline callback - The ``oneline`` callback and its associated ad-hoc CLI args (``-o``, ``--one-line``) are deprecated. - paramiko - The paramiko connection plugin has been deprecated with planned removal in 2.21. +- playbook - The ``timedout.frame`` task result value (injected when a task timeout occurs) is deprecated. Include ``error`` in the ``DISPLAY_TRACEBACK`` config value to capture a full Python traceback for timed out actions. +- playbook syntax - Specifying the task ``args`` keyword without a value is deprecated. +- playbook syntax - Using ``key=value`` args and the task ``args`` keyword on the same task is deprecated. +- playbook syntax - Using a mapping with the ``action`` keyword is deprecated. (https://github.com/ansible/ansible/issues/84101) - playbook variables - The ``play_hosts`` variable has been deprecated, use ``ansible_play_batch`` instead. - plugin error handling - The ``AnsibleError`` constructor arg ``suppress_extended_error`` is deprecated. Using ``suppress_extended_error=True`` has the same effect as ``show_content=False``. +- plugins - Accessing plugins with ``_``-prefixed filenames without the ``_`` prefix is deprecated. +- public API - The ``ansible.errors.AnsibleFilterTypeError`` exception type has been deprecated. Use ``AnsibleTypeError`` instead. +- public API - The ``ansible.errors._AnsibleActionDone`` exception type has been deprecated. Action plugins should return a task result dictionary in success cases instead of raising. +- public API - The ``ansible.module_utils.common.json.json_dump`` function is deprecated. Call Python stdlib ``json.dumps`` instead, with ``cls`` set to an Ansible profile encoder type from ``ansible.module_utils.common.json.get_encoder``. - template lookup - The jinja2_native option is no longer used in the Ansible Core code base. Jinja2 native mode is now the default and only option. - templating - Support for enabling Jinja2 extensions (not plugins) has been deprecated. - templating - The ``disable_lookups`` option has no effect, since plugins must be updated to apply trust before any templating can be performed. @@ -364,7 +300,7 @@ - modules - Modules returning non-UTF8 strings now result in an error. The ``MODULE_STRICT_UTF8_RESPONSE`` setting can be used to disable this check. - removed deprecated pycompat24 and compat.importlib. - selector - remove deprecated compat.selector related files (https://github.com/ansible/ansible/pull/84155). -- windows - removed common module functions ``ConvertFrom-AnsibleJson``, ``Format-AnsibleException`` from Windows modules as they are not used and add uneeded complexity to the code. +- windows - removed common module functions ``ConvertFrom-AnsibleJson``, ``Format-AnsibleException`` from Windows modules as they are not used and add unneeded complexity to the code. Security Fixes -------------- @@ -379,7 +315,9 @@ -------- - Ansible will now also warn when reserved keywords are set via a module (set_fact, include_vars, etc). +- Ansible will now ensure predictable permissions on remote artifacts, until now it only ensured executable and relied on system masks for the rest. - Ansible.Basic - Fix ``required_if`` check when the option value to check is unset or set to null. +- Core Jinja test plugins - Builtin test plugins now always return ``bool`` to avoid spurious deprecation warnings for some malformed inputs. - Correctly return ``False`` when using the ``filter`` and ``test`` Jinja tests on plugin names which are not filters or tests, respectively. (resolves issue https://github.com/ansible/ansible/issues/82084) - Do not run implicit ``flush_handlers`` meta tasks when the whole play is excluded from the run due to tags specified. - Errors now preserve stacked error messages even when YAML is involved. @@ -387,11 +325,14 @@ - Fix disabling SSL verification when installing collections and roles from git repositories. If ``--ignore-certs`` isn't provided, the value for the ``GALAXY_IGNORE_CERTS`` configuration option will be used (https://github.com/ansible/ansible/issues/83326). - Fix ipv6 pattern bug in lib/ansible/parsing/utils/addresses.py (https://github.com/ansible/ansible/issues/84237) - Fix returning 'unreachable' for the overall task result. This prevents false positives when a looped task has unignored unreachable items (https://github.com/ansible/ansible/issues/84019). +- Fix templating ``tags`` on plays and roles. (https://github.com/ansible/ansible/issues/69903) - Implicit ``meta: flush_handlers`` tasks now have a parent block to prevent potential tracebacks when calling methods like ``get_play()`` on them internally. - Improve performance on large inventories by reducing the number of implicit meta tasks. - Jinja plugins - Errors raised will always be derived from ``AnsibleTemplatePluginError``. - Optimize the way tasks from within ``include_tasks``/``include_role`` are inserted into the play. +- Remove use of `required` parameter in `get_bin_path` which has been deprecated. - Time out waiting on become is an unreachable error (https://github.com/ansible/ansible/issues/84468) +- Update automatic role argument spec validation to not use deprecated syntax (https://github.com/ansible/ansible/issues/85399). - Use consistent multiprocessing context for action write locks - Use the requested error message in the ansible.module_utils.facts.timeout timeout function instead of hardcoding one. - Windows - add support for running on system where WDAC is in audit mode with ``Dynamic Code Security`` enabled. @@ -401,46 +342,78 @@ - action plugins - Action plugins that raise unhandled exceptions no longer terminate playbook loops. Previously, exceptions raised by an action plugin caused abnormal loop termination and loss of loop iteration results. - ansible-config - format galaxy server configs while dumping in JSON format (https://github.com/ansible/ansible/issues/84840). - ansible-doc - If none of the files in files exists, path will be undefined and a direct reference will throw an UnboundLocalError (https://github.com/ansible/ansible/pull/84464). +- ansible-doc - fix indentation for first line of descriptions of suboptions and sub-return values (https://github.com/ansible/ansible/pull/84690). +- ansible-doc - fix line wrapping for first line of description of options and return values (https://github.com/ansible/ansible/pull/84690). +- ansible-doc will no longer ignore docs for modules without an extension (https://github.com/ansible/ansible/issues/85279). - ansible-galaxy - Small adjustments to URL building for ``download_url`` and relative redirects. - ansible-pull change detection will now work independently of callback or result format settings. +- ansible-test - Disabled the ``bad-super-call`` pylint rule due to false positives. - ansible-test - Enable the ``sys.unraisablehook`` work-around for the ``pylint`` sanity test on Python 3.11. Previously the work-around was only enabled for Python 3.12 and later. However, the same issue has been discovered on Python 3.11. - ansible-test - Ensure CA certificates are installed on managed FreeBSD instances. +- ansible-test - Fix Python relative import resolution from ``__init__.py`` files when using change detection. +- ansible-test - Fix incorrect handling of options with optional args (e.g. ``--color``), when followed by other options which are omitted during arg filtering (e.g. ``--docker``). Previously it was possible for non-option arguments to be incorrectly omitted in these cases. (https://github.com/ansible/ansible/issues/85173) - ansible-test - Fix support for PowerShell module_util imports with the ``-Optional`` flag. - ansible-test - Fix support for detecting PowerShell modules importing module utils with the newer ``#AnsibleRequires`` format. - ansible-test - Fix traceback that occurs after an interactive command fails. - ansible-test - Fix up coverage reporting to properly translate the temporary path of integration test modules to the expected static test module path. - ansible-test - Fixed traceback when handling certain YAML errors in the ``yamllint`` sanity test. +- ansible-test - Improve type inference for pylint deprecated checks to accommodate some type annotations. - ansible-test - Managed macOS instances now use the ``sudo_chdir`` option for the ``sudo`` become plugin to avoid permission errors when dropping privileges. +- ansible-test - Updated the ``pylint`` sanity test to skip some deprecation validation checks when all arguments are dynamic. - ansible-vault will now correctly handle `--prompt`, previously it would issue an error about stdin if no 2nd argument was passed - ansible_uptime_second - added ansible_uptime_seconds fact support for AIX (https://github.com/ansible/ansible/pull/84321). - apt_key module - prevent tests from running when apt-key was removed +- async_status module - The ``started`` and ``finished`` return values are now ``True`` or ``False`` instead of ``1`` or ``0``. - base.yml - deprecated libvirt_lxc_noseclabel config. - build - Pin ``wheel`` in ``pyproject.toml`` to ensure compatibility with supported ``setuptools`` versions. +- callback plugins - A more descriptive error is now raised if the stdout callback plugin cannot be loaded. +- callback plugins - Callback plugins that do not extend ``ansible.plugins.callback.CallbackBase`` will fail to load with a warning. If the plugin is used as the stdout callback plugin, this will also be a fatal error. +- callback plugins - Removed unused methods - runner_on_no_hosts, playbook_on_setup, playbook_on_import_for_host, playbook_on_not_import_for_host, v2_playbook_on_cleanup_task_start, v2_playbook_on_import_for_host, v2_playbook_on_not_import_for_host. +- callback plugins - The stdout callback plugin is no longer called twice if it is also in the list of additional callback plugins. +- config - Preserve or apply Origin tag to values returned by config. +- config - Prevented fatal errors when ``MODULE_IGNORE_EXTS`` configuration was set. +- config - Templating failures on config defaults now issue a warning. Previously, failures silently returned an unrendered and untrusted template to the caller. +- config - ``ensure_type`` correctly propagates trust and other tags on returned values. +- config - ``ensure_type`` now converts mappings to ``dict`` when requested, instead of returning the mapping. +- config - ``ensure_type`` now converts sequences to ``list`` when requested, instead of returning the sequence. +- config - ``ensure_type`` now correctly errors when ``pathlist`` or ``pathspec`` types encounter non-string list items. +- config - ``ensure_type`` now reports an error when ``bytes`` are provided for any known ``value_type``. Previously, the behavior was undefined, but often resulted in an unhandled exception or incorrect return type. +- config - ``ensure_type`` with expected type ``int`` now properly converts ``True`` and ``False`` values to ``int``. Previously, these values were silently returned unmodified. - config - various fixes to config lookup plugin (https://github.com/ansible/ansible/pull/84398). +- constructed inventory - Use the ``default_value`` or ``trailing_separator`` in a ``keyed_groups`` entry if the expression result of ``key`` is ``None`` and not just an empty string. +- convert_bool.boolean API conversion function - Unhashable values passed to ``boolean`` behave like other non-boolean convertible values, returning False or raising ``TypeError`` depending on the value of ``strict``. Previously, unhashable values always raised ``ValueError`` due to an invalid set membership check. - copy - refactor copy module for simplicity. - copy action now prevents user from setting internal options. - debconf - set empty password values (https://github.com/ansible/ansible/issues/83214). - debug - hide loop vars in debug var display (https://github.com/ansible/ansible/issues/65856). - default callback - Error context is now shown for failing tasks that use the ``debug`` action. +- display - Fix hang caused by early post-fork writers to stdout/stderr (e.g., pydevd) encountering an unreleased fork lock. - display - The ``Display.deprecated`` method once again properly handles the ``removed=True`` argument (https://github.com/ansible/ansible/issues/82358). - distro - add support for Linux Mint Debian Edition (LMDE) (https://github.com/ansible/ansible/issues/84934). - distro - detect Debian as os_family for LMDE 6 (https://github.com/ansible/ansible/issues/84934). - dnf5 - Handle forwarded exceptions from dnf5-5.2.13 where a generic ``RuntimeError`` was previously raised +- dnf5 - avoid generating excessive transaction entries in the dnf5 history (https://github.com/ansible/ansible/issues/85046) - dnf5 - fix ``is_installed`` check for packages that are not installed but listed as provided by an installed package (https://github.com/ansible/ansible/issues/84578) - dnf5 - fix installing a package using ``state=latest`` when a binary of the same name as the package is already installed (https://github.com/ansible/ansible/issues/84259) - dnf5 - fix traceback when ``enable_plugins``/``disable_plugins`` is used on ``python3-libdnf5`` versions that do not support this functionality +- dnf5 - handle all libdnf5 specific exceptions (https://github.com/ansible/ansible/issues/84634) - dnf5 - libdnf5 - use ``conf.pkg_gpgcheck`` instead of deprecated ``conf.gpgcheck`` which is used only as a fallback - dnf5 - matching on a binary can be achieved only by specifying a full path (https://github.com/ansible/ansible/issues/84334) +- dnf5 - when ``bugfix`` and/or ``security`` is specified, skip packages that do not have any such updates, even for new versions of libdnf5 where this functionality changed and it is considered failure +- error handling - Error details and tracebacks from connection and built-in action exceptions are preserved. Previously, much of the detail was lost or mixed into the error message. - facts - gather pagesize and calculate respective values depending upon architecture (https://github.com/ansible/ansible/issues/84773). - facts - skip if distribution file path is directory, instead of raising error (https://github.com/ansible/ansible/issues/84006). - find - skip ENOENT error code while recursively enumerating files. find module will now be tolerant to race conditions that remove files or directories from the target it is currently inspecting. (https://github.com/ansible/ansible/issues/84873). - first_found lookup - Corrected return value documentation to reflect None (not empty string) for no files found. +- from_yaml_all filter - `None` and empty string inputs now always return an empty list. Previously, `None` was returned in Jinja native mode and empty list in classic mode. - gather_facts action now defaults to `ansible.legacy.setup` if `smart` was set, no network OS was found and no other alias for `setup` was present. - gather_facts action will now issues errors and warnings as appropriate if a network OS is detected but no facts modules are defined for it. - gather_facts action, will now add setup when 'smart' appears with other modules in the FACTS_MODULES setting (#84750). +- get_url - add a check to recognize incomplete data transfers. - get_url - add support for BSD-style checksum digest file (https://github.com/ansible/ansible/issues/84476). - get_url - fix honoring ``filename`` from the ``content-disposition`` header even when the type is ``inline`` (https://github.com/ansible/ansible/issues/83690) - host_group_vars - fixed defining the 'key' variable if the get_vars method is called with cache=False (https://github.com/ansible/ansible/issues/84384) +- include_tasks - fix templating options when used as a handler (https://github.com/ansible/ansible/pull/85015). - include_vars - fix including previously undefined hash variables with hash_behaviour merge (https://github.com/ansible/ansible/issues/84295). - iptables - Allows the wait parameter to be used with iptables chain creation (https://github.com/ansible/ansible/issues/84490) - linear strategy - fix executing ``end_role`` meta tasks for each host, instead of handling these as implicit run_once tasks (https://github.com/ansible/ansible/issues/84660). @@ -455,11 +428,15 @@ - local connection plugin - Fixed long timeout/hang for ``become`` plugins that repeat their prompt on failure (e.g., ``sudo``, some ``su`` implementations). - local connection plugin - Fixed silent ignore of ``become`` failures and loss of task output when data arrived concurrently on stdout and stderr during ``become`` operation validation. - local connection plugin - Fixed task output header truncation when post-become data arrived before ``become`` operation validation had completed. +- local connection plugin - The command-line used to create subprocesses is now always ``str`` to avoid issues with debuggers and profilers. - lookup plugins - The ``terms`` arg to the ``run`` method is now always a list. Previously, there were cases where a non-list could be received. - module arg templating - When using a templated raw task arg and a templated ``args`` keyword, args are now merged. Previously use of templated raw task args silently ignored all values from the templated ``args`` keyword. - module defaults - Module defaults are no longer templated unless they are used by a task that does not override them. Previously, all module defaults for all modules were templated for every task. - module respawn - limit to supported Python versions - package_facts module when using 'auto' will return the first package manager found that provides an output, instead of just the first one, as this can be foreign and not have any packages. +- password lookup - fix acquiring the lock when human-readable FileExistsError error message is not English. +- plugin loader - A warning is now emitted for any plugin which fails to load due to a missing base class. +- plugin loader - Apply template trust to strings loaded from plugin configuration definitions and doc fragments. - psrp - Improve stderr parsing when running raw commands that emit error records or stderr lines. - regex_search filter - Corrected return value documentation to reflect None (not empty string) for no match. - respawn - use copy of env variables to update existing PYTHONPATH value (https://github.com/ansible/ansible/issues/84954). @@ -469,12 +446,18 @@ - ssh - Improve the logic for parsing CLIXML data in stderr when working with Windows host. This fixes issues when the raw stderr contains invalid UTF-8 byte sequences and improves embedded CLIXML sequences. - ssh - Raise exception when sshpass returns error code (https://github.com/ansible/ansible/issues/58133). - ssh - connection options were incorrectly templated during ``reset_connection`` tasks (https://github.com/ansible/ansible/pull/84238). +- ssh agent - Fixed several potential startup hangs for badly-behaved or overloaded ssh agents. +- ssh connection plugin - Allow only one password prompt attempt when utilizing ``SSH_ASKPASS`` (https://github.com/ansible/ansible/issues/85359) - stability - Fixed silent process failure on unhandled IOError/OSError under ``linear`` strategy. - su become plugin - Ensure generated regex from ``prompt_l10n`` config values is properly escaped. - su become plugin - Ensure that password prompts are correctly detected in the presence of leading output. Previously, this case resulted in a timeout or hang. - su become plugin - Ensure that trailing colon is expected on all ``prompt_l10n`` config values. - sudo become plugin - The `sudo_chdir` config option allows the current directory to be set to the specified value before executing sudo to avoid permission errors when dropping privileges. - sunos - remove hard coding of virtinfo command in facts gathering code (https://github.com/ansible/ansible/pull/84357). +- task timeout - Specifying a negative task timeout now results in an error. +- template action - Template files where the entire file's output renders as ``None`` are no longer emitted as the string "None", but instead render to an empty file as in previous releases. +- templating - Fixed cases where template expression blocks halted prematurely when a Jinja macro invocation returned an undefined value. +- templating - Jinja macros returned from a template expression can now be called from another template expression. - to_yaml/to_nice_yaml filters - Eliminated possibility of keyword arg collisions with internally-set defaults. - unarchive - Clamp timestamps from beyond y2038 to representible values when unpacking zip files on platforms that use 32-bit time_t (e.g. Debian i386). - uri - Form location correctly when the server returns a relative redirect (https://github.com/ansible/ansible/issues/84540) @@ -486,5 +469,6 @@ - user - Use higher precedence HOME_MODE as UMASK for path provided (https://github.com/ansible/ansible/pull/84482). - user action will now require O(force) to overwrite the public part of an ssh key when generating ssh keys, as was already the case for the private part. - user module now avoids changing ownership of files symlinked in provided home dir skeleton +- variables - Added Jinja scalar singletons (``true``, ``false``, ``none``) to invalid Ansible variable name detection. Previously, variables with these names could be assigned without error, but could not be resolved. - vars lookup - The ``default`` substitution only applies when trying to look up a variable which is not defined. If the variable is defined, but templates to an undefined value, the ``default`` substitution will not apply. Use the ``default`` filter to coerce those values instead. - wait_for_connection - a warning was displayed if any hosts used a local connection (https://github.com/ansible/ansible/issues/84419) diff -Nru ansible-core-2.19.0~beta6/changelogs/changelog.yaml ansible-core-2.19.1/changelogs/changelog.yaml --- ansible-core-2.19.0~beta6/changelogs/changelog.yaml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/changelogs/changelog.yaml 2025-08-25 19:16:05.000000000 +0000 @@ -1,5 +1,16 @@ ancestor: 2.18.0 releases: + 2.19.0: + changes: + release_summary: '| Release Date: 2025-07-21 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.0_summary.yaml + release_date: '2025-07-21' 2.19.0b1: changes: breaking_changes: @@ -23,9 +34,6 @@ - internals - The ``AnsibleLoader`` and ``AnsibleDumper`` classes for working with YAML are now factory functions and cannot be extended. - internals - The ``ansible.utils.native_jinja`` Python module has been removed. - - inventory - Invalid variable names provided by inventories result in an inventory - parse failure. This behavior is now consistent with other variable name usages - throughout Ansible. - lookup plugins - Lookup plugins called as `with_(lookup)` will no longer have the `_subdir` attribute set. - lookup plugins - ``terms`` will always be passed to ``run`` as the first positional @@ -302,7 +310,7 @@ deprecated_features: - CLI - The ``--inventory-file`` option alias is deprecated. Use the ``-i`` or ``--inventory`` option instead. - - Stategy Plugins - Use of strategy plugins not provided in ``ansible.builtin`` + - Strategy Plugins - Use of strategy plugins not provided in ``ansible.builtin`` are deprecated and do not carry any backwards compatibility guarantees going forward. A future release will remove the ability to use external strategy plugins. No alternative for third party strategy plugins is currently planned. @@ -432,7 +440,7 @@ the ``!vault`` tag. - ansible-test - Update ``nios-test-container`` to version 7.0.0. - ansible-test - Update ``pylint`` sanity test to use version 3.3.1. - - ansible-test - Update distro containers to remove unnecessary pakages (apache2, + - ansible-test - Update distro containers to remove unnecessary packages (apache2, subversion, ruby). - ansible-test - Update sanity test requirements to latest available versions. - ansible-test - Update the HTTP test container. @@ -512,7 +520,7 @@ as the cause is still permitted. Failure to use ``raise ... from`` when ``orig_exc`` is set will result in a warning. Additionally, if the two cause exceptions do not match, a warning will be issued. - - removed harcoding of su plugin as it now works with pipelining. + - removed hardcoding of su plugin as it now works with pipelining. - runtime-metadata sanity test - improve validation of ``action_groups`` (https://github.com/ansible/ansible/pull/83965). - service_facts module got freebsd support added. - ssh connection plugin - Support ``SSH_ASKPASS`` mechanism to provide passwords, @@ -571,8 +579,8 @@ - when the ``dict`` lookup is given a non-dict argument, show the value of the argument and its type in the error message. - windows - add hard minimum limit for PowerShell to 5.1. Ansible dropped support - for older versions of PowerShell in the 2.16 release but this reqirement is - now enforced at runtime. + for older versions of PowerShell in the 2.16 release but this requirement + is now enforced at runtime. - windows - refactor windows exec runner to improve efficiency and add better error reporting on failures. - winrm - Remove need for pexpect on macOS hosts when using ``kinit`` to retrieve @@ -594,7 +602,7 @@ - removed deprecated pycompat24 and compat.importlib. - selector - remove deprecated compat.selector related files (https://github.com/ansible/ansible/pull/84155). - windows - removed common module functions ``ConvertFrom-AnsibleJson``, ``Format-AnsibleException`` - from Windows modules as they are not used and add uneeded complexity to the + from Windows modules as they are not used and add unneeded complexity to the code. security_fixes: - include_vars action - Ensure that result masking is correctly requested when @@ -1041,3 +1049,234 @@ - template-tags-on-play-roles.yml - unmask_ansible_managed.yml release_date: '2025-06-11' + 2.19.0b7: + changes: + bugfixes: + - ansible-test - Fix Python relative import resolution from ``__init__.py`` + files when using change detection. + - callback plugins - A more descriptive error is now raised if the stdout callback + plugin cannot be loaded. + - callback plugins - Callback plugins that do not extend ``ansible.plugins.callback.CallbackBase`` + will fail to load with a warning. If the plugin is used as the stdout callback + plugin, this will also be a fatal error. + - callback plugins - Removed unused methods - runner_on_no_hosts, playbook_on_setup, + playbook_on_import_for_host, playbook_on_not_import_for_host, v2_playbook_on_cleanup_task_start, + v2_playbook_on_import_for_host, v2_playbook_on_not_import_for_host. + - callback plugins - The stdout callback plugin is no longer called twice if + it is also in the list of additional callback plugins. + - password lookup - fix acquiring the lock when human-readable FileExistsError + error message is not English. + - plugin loader - A warning is now emitted for any plugin which fails to load + due to a missing base class. + - variables - Added Jinja scalar singletons (``true``, ``false``, ``none``) + to invalid Ansible variable name detection. Previously, variables with these + names could be assigned without error, but could not be resolved. + deprecated_features: + - inventory plugins - Setting invalid Ansible variable names in inventory plugins + is deprecated. + - playbook syntax - Specifying the task ``args`` keyword without a value is + deprecated. + - playbook syntax - Using ``key=value`` args and the task ``args`` keyword on + the same task is deprecated. + - playbook syntax - Using a mapping with the ``action`` keyword is deprecated. + (https://github.com/ansible/ansible/issues/84101) + minor_changes: + - Added type annotations to the ``Role.__init__()`` method to enable type checking. + (https://github.com/ansible/ansible/pull/85346) + - ansible-test - Added experimental support for remote debugging. + - ansible-test - Added support for setting static environment variables in integration + tests using ``env/set/`` entries in the ``aliases`` file. For example, ``env/set/MY_KEY/MY_VALUE`` + or ``env/set/MY_PATH//an/abs/path``. + - ansible-test - The ``shell`` command has been augmented to propagate remote + debug configurations and other test-related settings when running on the controller. + Use the ``--raw`` argument to bypass the additional environment configuration. + - apt - consider lock timeout while invoking apt-get command (https://github.com/ansible/ansible/issues/78658). + - assemble action added check_mode support + - display - The ``formatted`` arg to ``warning`` has no effect. Warning wrapping + is left to the consumer (e.g. terminal, browser). + - display - The ``wrap_text`` and ``stderr`` arguments to ``error`` have no + effect. Errors are always sent to stderr and wrapping is left to the consumer + (e.g. terminal, browser). + - module_utils.basic.backup_local enforces check_mode now + - variables - Removed restriction on usage of most Python keywords as Ansible + variable names. + - variables - Warnings about reserved variable names now show context where + the variable was defined. + release_summary: '| Release Date: 2025-06-24 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.0b7_summary.yaml + - add_type_checking_to_role_init.yml + - ansible-test-change-detection-fix.yml + - ansible-test-debugging.yml + - ansible-test-env-set.yml + - apt_timeout.yml + - assemble_check_mode.yml + - callback_base.yml + - display_args.yml + - fix-lookup-password-lock-acquisition.yml + - task_esoterica_deprecation.yml + - variable_names.yml + - warn-on-reserved.yml + release_date: '2025-06-24' + 2.19.0rc1: + changes: + bugfixes: + - Update automatic role argument spec validation to not use deprecated syntax + (https://github.com/ansible/ansible/issues/85399). + - ssh connection plugin - Allow only one password prompt attempt when utilizing + ``SSH_ASKPASS`` (https://github.com/ansible/ansible/issues/85359) + minor_changes: + - templating - Relaxed the Jinja sandbox to allow specific bitwise operations + which have no filter equivalent. The allowed methods are ``__and__``, ``__lshift__``, + ``__or__``, ``__rshift__``, ``__xor__``. + - templating - Switched from the Jinja immutable sandbox to the standard sandbox. + This restores the ability to use mutation methods such as ``list.append`` + and ``dict.update``. + release_summary: '| Release Date: 2025-06-30 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.0rc1_summary.yaml + - 85359-askpass-incorrect-password-retries.yml + - fix-auto-role-spec-validation-deprecation.yml + - template-sandbox.yml + release_date: '2025-06-30' + 2.19.0rc2: + changes: + deprecated_features: + - Jinja test plugins - Returning a non-boolean result from a Jinja test plugin + is deprecated. + - YAML parsing - Usage of the YAML 1.1 ``!!omap`` and ``!!pairs`` tags is deprecated. + Use standard mappings instead. + - YAML parsing - Usage of the undocumented ``!vault-encrypted`` YAML tag is + deprecated. Use ``!vault`` instead. + - config - The ``DEFAULT_ALLOW_UNSAFE_LOOKUPS`` configuration option is deprecated + and no longer has any effect. Ansible templating no longer encounters situations + where use of lookup plugins is considered "unsafe". + - config - The ``DEFAULT_UNDEFINED_VAR_BEHAVIOR`` configuration option is deprecated + and no longer has any effect. Attempting to use an undefined variable where + undefined values are unexpected is now always an error. This behavior was + enabled by default in previous versions, and disabling it yielded inconsistent + results. + - config - The ``STRING_TYPE_FILTERS`` configuration option is deprecated and + no longer has any effect. Since the template engine now always preserves native + types, there is no longer a risk of unintended conversion from strings to + native types. + - config - Using the ``DEFAULT_JINJA2_EXTENSIONS`` configuration option to enable + Jinja2 extensions is deprecated. Previously, custom Jinja extensions were + disabled by default, as they can destabilize the Ansible templating environment. + Templates should only make use of filter, test and lookup plugins. + - config - Using the ``DEFAULT_MANAGED_STR`` configuration option to customize + the value of the ``ansible_managed`` variable is deprecated. The ``ansible_managed`` + variable can now be set the same as any other variable. + - playbook - The ``timedout.frame`` task result value (injected when a task + timeout occurs) is deprecated. Include ``error`` in the ``DISPLAY_TRACEBACK`` + config value to capture a full Python traceback for timed out actions. + - public API - The ``ansible.errors.AnsibleFilterTypeError`` exception type + has been deprecated. Use ``AnsibleTypeError`` instead. + - public API - The ``ansible.errors._AnsibleActionDone`` exception type has + been deprecated. Action plugins should return a task result dictionary in + success cases instead of raising. + - public API - The ``ansible.module_utils.common.json.json_dump`` function is + deprecated. Call Python stdlib ``json.dumps`` instead, with ``cls`` set to + an Ansible profile encoder type from ``ansible.module_utils.common.json.get_encoder``. + release_summary: '| Release Date: 2025-07-08 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.0rc2_summary.yaml + - 219_catchall.yml + release_date: '2025-07-08' + 2.19.1: + changes: + release_summary: '| Release Date: 2025-08-25 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.1_summary.yaml + release_date: '2025-08-25' + 2.19.1rc1: + changes: + bugfixes: + - ansible-test - Always exclude the ``tests/output/`` directory from a collection's + code coverage. (https://github.com/ansible/ansible/issues/84244) + - ansible-test - Limit package install retries during managed remote instance + bootstrapping. + - ansible-test - Use a consistent coverage config for all collection testing. + - argspec validation - The ``str`` argspec type treats ``None`` values as empty + string for better consistency with pre-2.19 templating conversions. + - conditionals - When displaying a broken conditional error or deprecation warning, + the origin of the non-boolean result is included (if available), and the raw + result is omitted. + - failed_when - When using ``failed_when`` to suppress an error, the ``exception`` + key in the result is renamed to ``failed_when_suppressed_exception``. This + prevents the error from being displayed by callbacks after being suppressed. + (https://github.com/ansible/ansible/issues/85505) + - import_tasks - fix templating parent include arguments. + - plugins config, get_option_and_origin now correctly displays the value and + origin of the option. + - template lookup - Skip finalization on the internal templating operation to + allow markers to be returned and handled by, e.g. the ``default`` filter. + Previously, finalization tripped markers, causing an exception to end processing + of the current template pipeline. (https://github.com/ansible/ansible/issues/85674) + - templating - Avoid tripping markers within Jinja generated code. (https://github.com/ansible/ansible/issues/85674) + - templating - Ensure filter plugin result processing occurs under the correct + call context. (https://github.com/ansible/ansible/issues/85585) + - templating - Fix slicing of tuples in templating (https://github.com/ansible/ansible/issues/85606). + - templating - Multi-node template results coerce embedded ``None`` nodes to + empty string (instead of rendering literal ``None`` to the output). + - templating - Undefined marker values sourced from the Jinja ``getattr->getitem`` + fallback are now accessed correctly, raising AnsibleUndefinedVariable for + user plugins that do not understand markers. Previously, these values were + erroneously returned to user plugin code that had not opted in to marker acceptance. + - tqm - use display.error_as_warning instead of display.warning_as_error. + - tqm - use display.error_as_warning instead of self.warning. + minor_changes: + - AnsibleModule - Add temporary internal monkeypatch-able hook to alter module + result serialization by splitting serialization from ``_return_formatted`` + into ``_record_module_result``. + - ansible-test - Improve formatting of generated coverage config file. + - ansible-test - Use OS packages to satisfy controller requirements on FreeBSD + 13.5 during managed instance bootstrapping. + - encrypt - check datatype of salt_size in password_hash filter. + - service_facts - handle keyerror exceptions with warning. + - service_facts - warn user about missing service details instead of ignoring. + release_summary: '| Release Date: 2025-08-18 + + | `Porting Guide `__ + + ' + codename: What Is and What Should Never Be + fragments: + - 2.19.1rc1_summary.yaml + - 85599-fix-templating-import_tasks-parent-include.yml + - ansible-test-bootstrap-retry.yml + - ansible-test-coverage-config.yml + - ansible-test-freebsd-bootstrap.yml + - concat_coerce_none_to_empty.yml + - elide_broken_conditional_result.yml + - failed-when-exception.yml + - getattr_marker_access.yml + - module_direct_exec.yml + - openrc.yml + - password_hash_encrypt.yml + - plugins_fix_origin.yml + - template-tuple-fix.yml + - template_lookup_skip_finalize.yml + - templating-filter-generators.yml + - tqm.yml + release_date: '2025-08-18' diff -Nru ansible-core-2.19.0~beta6/debian/changelog ansible-core-2.19.1/debian/changelog --- ansible-core-2.19.0~beta6/debian/changelog 2025-06-12 21:55:52.000000000 +0000 +++ ansible-core-2.19.1/debian/changelog 2025-08-26 20:44:31.000000000 +0000 @@ -1,3 +1,13 @@ +ansible-core (2.19.1-0+deb13u1) trixie; urgency=medium + + * New upstream bugfix release 2.19.1 + * Update debian/gbp.conf to track trixie branches + * Update watch file to follow ansible-core 2.19.x in trixie + * Change gbp upstream tag as long as upstream version in trixie and sid match + * Skip ansible-test-debugging integration test (requires running from source) + + -- Lee Garrett Tue, 26 Aug 2025 22:44:31 +0200 + ansible-core (2.19.0~beta6-1) unstable; urgency=medium * New upstream version 2.19.0~beta6 diff -Nru ansible-core-2.19.0~beta6/debian/gbp.conf ansible-core-2.19.1/debian/gbp.conf --- ansible-core-2.19.0~beta6/debian/gbp.conf 2025-05-29 08:11:43.000000000 +0000 +++ ansible-core-2.19.1/debian/gbp.conf 2025-08-26 20:27:58.000000000 +0000 @@ -1,10 +1,11 @@ # Configuration for git-buildpackage and affiliated tools [DEFAULT] -debian-branch = debian/latest +debian-branch = debian/trixie pristine-tar = True sign-tags = True -upstream-branch = upstream/latest +upstream-branch = upstream/trixie +upstream-tag = upstream/%(version)s_trixie [import-orig] merge-mode = replace diff -Nru ansible-core-2.19.0~beta6/debian/tests/ansible-test-integration.py ansible-core-2.19.1/debian/tests/ansible-test-integration.py --- ansible-core-2.19.0~beta6/debian/tests/ansible-test-integration.py 2025-06-12 21:55:07.000000000 +0000 +++ ansible-core-2.19.1/debian/tests/ansible-test-integration.py 2025-08-26 20:34:56.000000000 +0000 @@ -182,6 +182,7 @@ 'ansible-galaxy-role': 'dict object has no attribute lnk_source', ## needs upstream fix? 'ansible-test-docker': "pwsh doesn't exist in Debian yet", 'ansible-test': 'installs and runs python libs from remote', + 'ansible-test-debugging': 'requires running built binary instead of package binary', 'ansible-test-installed': 'checks only valid if source tree has bin/ directory', 'ansible-test-sanity': 'checks are only valid for the source tree', 'ansible-test-units-forked': '?????', diff -Nru ansible-core-2.19.0~beta6/debian/watch ansible-core-2.19.1/debian/watch --- ansible-core-2.19.0~beta6/debian/watch 2025-06-04 13:54:53.000000000 +0000 +++ ansible-core-2.19.1/debian/watch 2025-08-26 20:05:27.000000000 +0000 @@ -4,4 +4,4 @@ repacksuffix=+dfsg, \ uversionmangle=s/((?:a|b|rc)[0-9]+)$/~$1/;s/a/alpha/;s/b/beta/, \ " \ -https://pypi.debian.net/ansible-core/ ansible[-_]core-([0-9]+\.[0-9]+\.[0-9brc]+).tar.gz +https://pypi.debian.net/ansible-core/ ansible[-_]core-(2\.19\.[0-9]+).tar.gz diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_ansiballz/_builder.py ansible-core-2.19.1/lib/ansible/_internal/_ansiballz/_builder.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_ansiballz/_builder.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_ansiballz/_builder.py 2025-08-25 19:16:05.000000000 +0000 @@ -6,7 +6,7 @@ import typing as t from ansible.module_utils._internal._ansiballz import _extensions -from ansible.module_utils._internal._ansiballz._extensions import _pydevd, _coverage +from ansible.module_utils._internal._ansiballz._extensions import _debugpy, _pydevd, _coverage from ansible.constants import config _T = t.TypeVar('_T') @@ -17,15 +17,18 @@ def __init__( self, - debugger: _pydevd.Options | None = None, + pydevd: _pydevd.Options | None = None, + debugpy: _debugpy.Options | None = None, coverage: _coverage.Options | None = None, ) -> None: options = dict( - _pydevd=debugger, + _pydevd=pydevd, + _debugpy=debugpy, _coverage=coverage, ) - self._debugger = debugger + self._pydevd = pydevd + self._debugpy = debugpy self._coverage = coverage self._extension_names = tuple(name for name, option in options.items() if option) self._module_names = tuple(f'{_extensions.__name__}.{name}' for name in self._extension_names) @@ -35,7 +38,7 @@ @property def debugger_enabled(self) -> bool: """Returns True if the debugger extension is enabled, otherwise False.""" - return bool(self._debugger) + return bool(self._pydevd or self._debugpy) @property def extension_names(self) -> tuple[str, ...]: @@ -51,10 +54,16 @@ """Return the configured extensions and their options.""" extension_options: dict[str, t.Any] = {} - if self._debugger: + if self._debugpy: + extension_options['_debugpy'] = dataclasses.replace( + self._debugpy, + source_mapping=self._get_source_mapping(self._debugpy.source_mapping), + ) + + if self._pydevd: extension_options['_pydevd'] = dataclasses.replace( - self._debugger, - source_mapping=self._get_source_mapping(), + self._pydevd, + source_mapping=self._get_source_mapping(self._pydevd.source_mapping), ) if self._coverage: @@ -64,18 +73,19 @@ return extensions - def _get_source_mapping(self) -> dict[str, str]: + def _get_source_mapping(self, debugger_mapping: dict[str, str]) -> dict[str, str]: """Get the source mapping, adjusting the source root as needed.""" - if self._debugger.source_mapping: - source_mapping = {self._translate_path(key): value for key, value in self.source_mapping.items()} + if debugger_mapping: + source_mapping = {self._translate_path(key, debugger_mapping): value for key, value in self.source_mapping.items()} else: source_mapping = self.source_mapping return source_mapping - def _translate_path(self, path: str) -> str: + @staticmethod + def _translate_path(path: str, debugger_mapping: dict[str, str]) -> str: """Translate a local path to a foreign path.""" - for replace, match in self._debugger.source_mapping.items(): + for replace, match in debugger_mapping.items(): if path.startswith(match): return replace + path[len(match) :] @@ -85,7 +95,8 @@ def create(cls, task_vars: dict[str, object]) -> t.Self: """Create an instance using the provided task vars.""" return cls( - debugger=cls._get_options('_ANSIBALLZ_DEBUGGER_CONFIG', _pydevd.Options, task_vars), + pydevd=cls._get_options('_ANSIBALLZ_PYDEVD_CONFIG', _pydevd.Options, task_vars), + debugpy=cls._get_options('_ANSIBALLZ_DEBUGPY_CONFIG', _debugpy.Options, task_vars), coverage=cls._get_options('_ANSIBALLZ_COVERAGE_CONFIG', _coverage.Options, task_vars), ) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_json/__init__.py ansible-core-2.19.1/lib/ansible/_internal/_json/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_json/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_json/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -81,6 +81,7 @@ convert_custom_scalars: bool = False, convert_to_native_values: bool = False, apply_transforms: bool = False, + visit_keys: bool = False, encrypted_string_behavior: EncryptedStringBehavior = EncryptedStringBehavior.DECRYPT, ): super().__init__() # supports StateTrackingMixIn @@ -92,6 +93,7 @@ self.convert_custom_scalars = convert_custom_scalars self.convert_to_native_values = convert_to_native_values self.apply_transforms = apply_transforms + self.visit_keys = visit_keys self.encrypted_string_behavior = encrypted_string_behavior if apply_transforms: @@ -134,47 +136,55 @@ return result + def _visit_key(self, key: t.Any) -> t.Any: + """Internal implementation to recursively visit a key if visit_keys is enabled.""" + if not self.visit_keys: + return key + + return self._visit(None, key) # key=None prevents state tracking from seeing the key as value + def _visit(self, key: t.Any, value: _T) -> _T: """Internal implementation to recursively visit a data structure's contents.""" self._current = key # supports StateTrackingMixIn - value_type = type(value) + value_type: type = type(value) - if self.apply_transforms and value_type in _transform._type_transform_mapping: + # handle EncryptedString conversion before more generic transformation and native conversions + if value_type is EncryptedString: # pylint: disable=unidiomatic-typecheck + match self.encrypted_string_behavior: + case EncryptedStringBehavior.DECRYPT: + value = str(value) # type: ignore[assignment] + value_type = str + case EncryptedStringBehavior.REDACT: + value = "" # type: ignore[assignment] + value_type = str + case EncryptedStringBehavior.FAIL: + raise AnsibleVariableTypeError.from_value(obj=value) + elif self.apply_transforms and value_type in _transform._type_transform_mapping: value = self._template_engine.transform(value) value_type = type(value) - # DTFIX3: need to handle native copy for keys too if self.convert_to_native_values and isinstance(value, _datatag.AnsibleTaggedObject): value = value._native_copy() value_type = type(value) result: _T - # DTFIX3: the visitor is ignoring dict/mapping keys except for debugging and schema-aware checking, it should be doing type checks on keys - # keep in mind the allowed types for keys is a more restrictive set than for values (str and tagged str only, not EncryptedString) - # DTFIX5: some type lists being consulted (the ones from datatag) are probably too permissive, and perhaps should not be dynamic + # DTFIX-FUTURE: Visitor generally ignores dict/mapping keys by default except for debugging and schema-aware checking. + # It could be checking keys destined for variable storage to apply more strict rules about key shape and type. if (result := self._early_visit(value, value_type)) is not _sentinel: pass # DTFIX7: de-duplicate and optimize; extract inline generator expressions and fallback function or mapping for native type calculation? elif value_type in _ANSIBLE_ALLOWED_MAPPING_VAR_TYPES: # check mappings first, because they're also collections with self: # supports StateTrackingMixIn - result = AnsibleTagHelper.tag_copy(value, ((k, self._visit(k, v)) for k, v in value.items()), value_type=value_type) + result = AnsibleTagHelper.tag_copy(value, ((self._visit_key(k), self._visit(k, v)) for k, v in value.items()), value_type=value_type) elif value_type in _ANSIBLE_ALLOWED_NON_SCALAR_COLLECTION_VAR_TYPES: with self: # supports StateTrackingMixIn result = AnsibleTagHelper.tag_copy(value, (self._visit(k, v) for k, v in enumerate(t.cast(t.Iterable, value))), value_type=value_type) - elif self.encrypted_string_behavior != EncryptedStringBehavior.FAIL and isinstance(value, EncryptedString): - match self.encrypted_string_behavior: - case EncryptedStringBehavior.REDACT: - result = "" # type: ignore[assignment] - case EncryptedStringBehavior.PRESERVE: - result = value # type: ignore[assignment] - case EncryptedStringBehavior.DECRYPT: - result = str(value) # type: ignore[assignment] elif self.convert_mapping_to_dict and _internal.is_intermediate_mapping(value): with self: # supports StateTrackingMixIn - result = {k: self._visit(k, v) for k, v in value.items()} # type: ignore[assignment] + result = {self._visit_key(k): self._visit(k, v) for k, v in value.items()} # type: ignore[assignment] elif self.convert_sequence_to_list and _internal.is_intermediate_iterable(value): with self: # supports StateTrackingMixIn result = [self._visit(k, v) for k, v in enumerate(t.cast(t.Iterable, value))] # type: ignore[assignment] @@ -184,12 +194,13 @@ result = float(value) # type: ignore[assignment] elif self.convert_custom_scalars and isinstance(value, int) and not isinstance(value, bool): result = int(value) # type: ignore[assignment] - else: - if value_type not in _ANSIBLE_ALLOWED_VAR_TYPES: - raise AnsibleVariableTypeError.from_value(obj=value) - + elif value_type in _ANSIBLE_ALLOWED_VAR_TYPES: # supported scalar type that requires no special handling, just return as-is result = value + elif self.encrypted_string_behavior is EncryptedStringBehavior.PRESERVE and isinstance(value, EncryptedString): + result = value # type: ignore[assignment] + else: + raise AnsibleVariableTypeError.from_value(obj=value) if self.origin and not Origin.is_tagged_on(result): # apply shared instance default origin tag diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_json/_profiles/_legacy.py ansible-core-2.19.1/lib/ansible/_internal/_json/_profiles/_legacy.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_json/_profiles/_legacy.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_json/_profiles/_legacy.py 2025-08-25 19:16:05.000000000 +0000 @@ -1,6 +1,6 @@ """ Backwards compatibility profile for serialization other than inventory (which should use inventory_legacy for backward-compatible trust behavior). -Behavior is equivalent to pre 2.18 `AnsibleJSONEncoder` with vault_to_text=True. +Behavior is equivalent to pre 2.19 `AnsibleJSONEncoder` with vault_to_text=True. """ from __future__ import annotations as _annotations diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_engine.py ansible-core-2.19.1/lib/ansible/_internal/_templating/_engine.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_engine.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_templating/_engine.py 2025-08-25 19:16:05.000000000 +0000 @@ -6,7 +6,6 @@ import copy import dataclasses import enum -import textwrap import typing as t import collections.abc as c import re @@ -44,7 +43,7 @@ _finalize_template_result, FinalizeMode, ) -from ._jinja_common import _TemplateConfig, MarkerError, ExceptionMarker +from ._jinja_common import _TemplateConfig, MarkerError, ExceptionMarker, JinjaCallContext from ._lazy_containers import _AnsibleLazyTemplateMixin from ._marker_behaviors import MarkerBehavior, FAIL_ON_UNDEFINED from ._transform import _type_transform_mapping @@ -260,6 +259,7 @@ with ( TemplateContext(template_value=variable, templar=self, options=options, stop_on_template=stop_on_template) as ctx, DeprecatedAccessAuditContext.when(ctx.is_top_level), + JinjaCallContext(accept_lazy_markers=True), # let default Jinja marker behavior apply, since we're descending into a new template ): try: if not value_is_str: @@ -559,9 +559,11 @@ bool_result = bool(result) + result_origin = Origin.get_tag(result) or Origin.UNKNOWN + msg = ( - f'Conditional result was {textwrap.shorten(str(result), width=40)!r} of type {native_type_name(result)!r}, ' - f'which evaluates to {bool_result}. Conditionals must have a boolean result.' + f'Conditional result ({bool_result}) was derived from value of type {native_type_name(result)!r} at {str(result_origin)!r}. ' + 'Conditionals must have a boolean result.' ) if _TemplateConfig.allow_broken_conditionals: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_jinja_bits.py ansible-core-2.19.1/lib/ansible/_internal/_templating/_jinja_bits.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_jinja_bits.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_templating/_jinja_bits.py 2025-08-25 19:16:05.000000000 +0000 @@ -20,7 +20,7 @@ from jinja2.nativetypes import NativeCodeGenerator from jinja2.nodes import Const, EvalContext from jinja2.runtime import Context, Macro -from jinja2.sandbox import ImmutableSandboxedEnvironment +from jinja2.sandbox import SandboxedEnvironment from jinja2.utils import missing, LRUCache from ansible.utils.display import Display @@ -78,6 +78,21 @@ To include this literal value at the start of a string, a space or other character must precede it. """ +JINJA_KEYWORDS = frozenset( + { + # scalar singletons (see jinja2.nodes.Name.can_assign) + 'true', + 'false', + 'none', + 'True', + 'False', + 'None', + # other + 'not', # unary operator always applicable to names + } +) +"""Names which have special meaning to Jinja and cannot be resolved as variable names.""" + display = Display() @@ -503,9 +518,6 @@ return exception_to_raise -# DTFIX3: implement CapturedExceptionMarker deferral support on call (and lookup), filter/test plugins, etc. -# also update the protomatter integration test once this is done (the test was written differently since this wasn't done yet) - _BUILTIN_FILTER_ALIASES: dict[str, str] = {} _BUILTIN_TEST_ALIASES: dict[str, str] = { '!=': 'ne', @@ -520,7 +532,7 @@ _BUILTIN_TESTS = test_loader._wrap_funcs(t.cast(dict[str, t.Callable], defaults.DEFAULT_TESTS), _BUILTIN_TEST_ALIASES) -class AnsibleEnvironment(ImmutableSandboxedEnvironment): +class AnsibleEnvironment(SandboxedEnvironment): """ Our custom environment, which simply allows us to override the class-level values for the Template and Context classes used by jinja2 internally. @@ -531,6 +543,21 @@ code_generator_class = AnsibleCodeGenerator intercepted_binops = frozenset(('eq',)) + _allowed_unsafe_attributes: dict[str, type | tuple[type, ...]] = dict( + # Allow bitwise operations on int until bitwise filters are available. + # see: https://github.com/ansible/ansible/issues/85204 + __and__=int, + __lshift__=int, + __or__=int, + __rshift__=int, + __xor__=int, + ) + """ + Attributes which are considered unsafe by `is_safe_attribute`, which should be allowed when used on specific types. + The attributes allowed here are intended only for backward compatibility with existing use cases. + They should be exposed as filters in a future release and eventually deprecated. + """ + _lexer_cache = LRUCache(50) # DTFIX-FUTURE: bikeshed a name/mechanism to control template debugging @@ -594,6 +621,9 @@ if _TemplateConfig.sandbox_mode == _SandboxMode.ALLOW_UNSAFE_ATTRIBUTES: return True + if (type_or_tuple := self._allowed_unsafe_attributes.get(attr)) and isinstance(obj, type_or_tuple): + return True + return super().is_safe_attribute(obj, attr, value) @property @@ -781,7 +811,7 @@ try: value = obj[attribute] except (TypeError, LookupError): - return self.undefined(obj=obj, name=attribute) if is_safe else self.unsafe_undefined(obj, attribute) + value = self.undefined(obj=obj, name=attribute) if is_safe else self.unsafe_undefined(obj, attribute) AnsibleAccessContext.current().access(value) @@ -794,18 +824,18 @@ *args: t.Any, **kwargs: t.Any, ) -> t.Any: - if _DirectCall.is_marked(__obj): - # Both `_lookup` and `_query` handle arg proxying and `Marker` args internally. - # Performing either before calling them will interfere with that processing. - return super().call(__context, __obj, *args, **kwargs) + try: + if _DirectCall.is_marked(__obj): + # Both `_lookup` and `_query` handle arg proxying and `Marker` args internally. + # Performing either before calling them will interfere with that processing. + return super().call(__context, __obj, *args, **kwargs) - # Jinja's generated macro code handles Markers, so pre-emptive raise on Marker args and lazy retrieval should be disabled for the macro invocation. - is_macro = isinstance(__obj, Macro) + # Jinja's generated macro code handles Markers, so preemptive raise on Marker args and lazy retrieval should be disabled for the macro invocation. + is_macro = isinstance(__obj, Macro) - if not is_macro and (first_marker := get_first_marker_arg(args, kwargs)) is not None: - return first_marker + if not is_macro and (first_marker := get_first_marker_arg(args, kwargs)) is not None: + return first_marker - try: with JinjaCallContext(accept_lazy_markers=is_macro): call_res = super().call(__context, __obj, *lazify_container_args(args), **lazify_container_kwargs(kwargs)) @@ -819,6 +849,8 @@ except MarkerError as ex: return ex.source + except Exception as ex: + return CapturedExceptionMarker(ex) AnsibleTemplate.environment_class = AnsibleEnvironment @@ -859,6 +891,8 @@ else: if type(node) is TemplateModule: # pylint: disable=unidiomatic-typecheck yield from _flatten_nodes(node._body_stream) + elif node is None: + continue # avoid yielding `None`-valued nodes to avoid literal "None" in stringified template results else: yield node diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_jinja_common.py ansible-core-2.19.1/lib/ansible/_internal/_templating/_jinja_common.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_jinja_common.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_templating/_jinja_common.py 2025-08-25 19:16:05.000000000 +0000 @@ -96,7 +96,7 @@ return AnsibleUndefinedVariable(self._undefined_message, obj=self._marker_template_source) def _as_message(self) -> str: - """Return the error message to show when this marker must be represented as a string, such as for subsitutions or warnings.""" + """Return the error message to show when this marker must be represented as a string, such as for substitutions or warnings.""" return self._undefined_message def _fail_with_undefined_error(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_jinja_plugins.py ansible-core-2.19.1/lib/ansible/_internal/_templating/_jinja_plugins.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_jinja_plugins.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_templating/_jinja_plugins.py 2025-08-25 19:16:05.000000000 +0000 @@ -23,7 +23,7 @@ from ._datatag import _JinjaConstTemplate from ._errors import AnsibleTemplatePluginRuntimeError, AnsibleTemplatePluginLoadError, AnsibleTemplatePluginNotFoundError -from ._jinja_common import MarkerError, _TemplateConfig, get_first_marker_arg, Marker, JinjaCallContext +from ._jinja_common import MarkerError, _TemplateConfig, get_first_marker_arg, Marker, JinjaCallContext, CapturedExceptionMarker from ._lazy_containers import lazify_container_kwargs, lazify_container_args, lazify_container, _AnsibleLazyTemplateMixin from ._utils import LazyOptions, TemplateContext @@ -115,11 +115,20 @@ try: with JinjaCallContext(accept_lazy_markers=instance.accept_lazy_markers): - return instance.j2_function(*lazify_container_args(args), **lazify_container_kwargs(kwargs)) + result = instance.j2_function(*lazify_container_args(args), **lazify_container_kwargs(kwargs)) + + if instance.plugin_type == 'filter': + # ensure list conversion occurs under the call context + result = _wrap_plugin_output(result) + + return result except MarkerError as ex: return ex.source except Exception as ex: - raise AnsibleTemplatePluginRuntimeError(instance.plugin_type, instance.ansible_name) from ex # DTFIX-FUTURE: which name to use? use plugin info? + try: + raise AnsibleTemplatePluginRuntimeError(instance.plugin_type, instance.ansible_name) from ex # DTFIX-FUTURE: which name to use? PluginInfo? + except AnsibleTemplatePluginRuntimeError as captured: + return CapturedExceptionMarker(captured) def _wrap_test(self, instance: AnsibleJinja2Plugin) -> t.Callable: """Intercept point for all test plugins to ensure that args are properly templated/lazified.""" @@ -153,7 +162,6 @@ @functools.wraps(instance.j2_function) def wrapper(*args, **kwargs) -> t.Any: result = self._invoke_plugin(instance, *args, **kwargs) - result = _wrap_plugin_output(result) return result diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_lazy_containers.py ansible-core-2.19.1/lib/ansible/_internal/_templating/_lazy_containers.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_lazy_containers.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_templating/_lazy_containers.py 2025-08-25 19:16:05.000000000 +0000 @@ -229,8 +229,6 @@ __slots__ = _AnsibleLazyTemplateMixin._SLOTS def __init__(self, contents: t.Iterable | _LazyValueSource, /, **kwargs) -> None: - _AnsibleLazyTemplateMixin.__init__(self, contents) - if isinstance(contents, _AnsibleLazyTemplateDict): super().__init__(dict.items(contents), **kwargs) elif isinstance(contents, _LazyValueSource): @@ -238,6 +236,8 @@ else: raise UnsupportedConstructionMethodError() + _AnsibleLazyTemplateMixin.__init__(self, contents) + def get(self, key: t.Any, default: t.Any = None) -> t.Any: if (value := super().get(key, _NoKeySentinel)) is _NoKeySentinel: return default @@ -372,8 +372,6 @@ __slots__ = _AnsibleLazyTemplateMixin._SLOTS def __init__(self, contents: t.Iterable | _LazyValueSource, /) -> None: - _AnsibleLazyTemplateMixin.__init__(self, contents) - if isinstance(contents, _AnsibleLazyTemplateList): super().__init__(list.__iter__(contents)) elif isinstance(contents, _LazyValueSource): @@ -381,6 +379,8 @@ else: raise UnsupportedConstructionMethodError() + _AnsibleLazyTemplateMixin.__init__(self, contents) + def __getitem__(self, key: t.SupportsIndex | slice, /) -> t.Any: if type(key) is slice: # pylint: disable=unidiomatic-typecheck return _AnsibleLazyTemplateList(_LazyValueSource(source=super().__getitem__(key), templar=self._templar, lazy_options=self._lazy_options)) @@ -567,7 +567,7 @@ def __getitem__(self, key: t.SupportsIndex | slice, /) -> t.Any: if type(key) is slice: # pylint: disable=unidiomatic-typecheck - return _AnsibleLazyAccessTuple(super().__getitem__(key)) + return _AnsibleLazyAccessTuple(_LazyValueSource(source=super().__getitem__(key), templar=self._templar, lazy_options=self._lazy_options)) value = super().__getitem__(key) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_utils.py ansible-core-2.19.1/lib/ansible/_internal/_templating/_utils.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/_templating/_utils.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/_templating/_utils.py 2025-08-25 19:16:05.000000000 +0000 @@ -99,9 +99,10 @@ _datatag._untaggable_types.add(_OmitType) -# DTFIX5: review these type sets to ensure they're not overly permissive/dynamic IGNORE_SCALAR_VAR_TYPES = {value for value in _datatag._ANSIBLE_ALLOWED_SCALAR_VAR_TYPES if not issubclass(value, str)} +"""Scalar variable types that short-circuit bypass templating.""" PASS_THROUGH_SCALAR_VAR_TYPES = _datatag._ANSIBLE_ALLOWED_SCALAR_VAR_TYPES | { _OmitType, # allow pass through of omit for later handling after top-level finalize completes } +"""Scalar variable types which are allowed to appear in finalized template results.""" diff -Nru ansible-core-2.19.0~beta6/lib/ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/dump_object.py ansible-core-2.19.1/lib/ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/dump_object.py --- ansible-core-2.19.0~beta6/lib/ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/dump_object.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/dump_object.py 2025-08-25 19:16:05.000000000 +0000 @@ -3,12 +3,21 @@ import dataclasses import typing as t +from ansible.template import accept_args_markers +from ansible._internal._templating._jinja_common import ExceptionMarker + +@accept_args_markers def dump_object(value: t.Any) -> object: """Internal filter to convert objects not supported by JSON to types which are.""" if dataclasses.is_dataclass(value): return dataclasses.asdict(value) # type: ignore[arg-type] + if isinstance(value, ExceptionMarker): + return dict( + exception=value._as_exception(), + ) + return value diff -Nru ansible-core-2.19.0~beta6/lib/ansible/cli/__init__.py ansible-core-2.19.1/lib/ansible/cli/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/cli/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/cli/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -212,9 +212,9 @@ # used by --vault-id and --vault-password-file vault_ids.append(id_slug) - # if an action needs an encrypt password (create_new_password=True) and we dont + # if an action needs an encrypt password (create_new_password=True) and we don't # have other secrets setup, then automatically add a password prompt as well. - # prompts cant/shouldnt work without a tty, so dont add prompt secrets + # prompts can't/shouldn't work without a tty, so don't add prompt secrets if ask_vault_pass or (not vault_ids and auto_prompt): id_slug = u'%s@%s' % (C.DEFAULT_VAULT_IDENTITY, u'prompt_ask_vault_pass') diff -Nru ansible-core-2.19.0~beta6/lib/ansible/cli/_ssh_askpass.py ansible-core-2.19.1/lib/ansible/cli/_ssh_askpass.py --- ansible-core-2.19.0~beta6/lib/ansible/cli/_ssh_askpass.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/cli/_ssh_askpass.py 2025-08-25 19:16:05.000000000 +0000 @@ -3,45 +3,52 @@ from __future__ import annotations import json +import multiprocessing.resource_tracker import os import re import sys import typing as t -from multiprocessing.shared_memory import SharedMemory -HOST_KEY_RE = re.compile( - r'(The authenticity of host |differs from the key for the IP address)', -) +from multiprocessing.shared_memory import SharedMemory def main() -> t.Never: - try: - if HOST_KEY_RE.search(sys.argv[1]): - sys.stdout.buffer.write(b'no') - sys.stdout.flush() - sys.exit(0) - except IndexError: - pass - - kwargs: dict[str, bool] = {} - if sys.version_info[:2] >= (3, 13): - # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' - kwargs['track'] = False - try: - shm = SharedMemory(name=os.environ['_ANSIBLE_SSH_ASKPASS_SHM'], **kwargs) - except FileNotFoundError: - # We must be running after the ansible fork is shutting down - sys.exit(1) + if len(sys.argv) > 1: + exit_code = 0 if handle_prompt(sys.argv[1]) else 1 + else: + exit_code = 1 + + sys.exit(exit_code) + + +def handle_prompt(prompt: str) -> bool: + if re.search(r'(The authenticity of host |differs from the key for the IP address)', prompt): + sys.stdout.write('no') + sys.stdout.flush() + return True + + # deprecated: description='Python 3.13 and later support track' python_version='3.12' + can_track = sys.version_info[:2] >= (3, 13) + kwargs = dict(track=False) if can_track else {} + + # This SharedMemory instance is intentionally not closed or unlinked. + # Closing will occur naturally in the SharedMemory finalizer. + # Unlinking is the responsibility of the process which created it. + shm = SharedMemory(name=os.environ['_ANSIBLE_SSH_ASKPASS_SHM'], **kwargs) + + if not can_track: + # When track=False is not available, we must unregister explicitly, since it otherwise only occurs during unlink. + # This avoids resource tracker noise on stderr during process exit. + multiprocessing.resource_tracker.unregister(shm._name, 'shared_memory') + cfg = json.loads(shm.buf.tobytes().rstrip(b'\x00')) - try: - if cfg['prompt'] not in sys.argv[1]: - sys.exit(1) - except IndexError: - sys.exit(1) + if cfg['prompt'] not in prompt: + return False - sys.stdout.buffer.write(cfg['password'].encode('utf-8')) + # Report the password provided by the SharedMemory instance. + # The contents are left untouched after consumption to allow subsequent attempts to succeed. + # This can occur when multiple password prompting methods are enabled, such as password and keyboard-interactive, which is the default on macOS. + sys.stdout.write(cfg['password']) sys.stdout.flush() - shm.buf[:] = b'\x00' * shm.size - shm.close() - sys.exit(0) + return True diff -Nru ansible-core-2.19.0~beta6/lib/ansible/cli/adhoc.py ansible-core-2.19.1/lib/ansible/cli/adhoc.py --- ansible-core-2.19.0~beta6/lib/ansible/cli/adhoc.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/cli/adhoc.py 2025-08-25 19:16:05.000000000 +0000 @@ -88,8 +88,11 @@ if not module_args: module_args = parse_kv(module_args_raw, check_raw=check_raw) - mytask = {'action': {'module': context.CLIARGS['module_name'], 'args': module_args}, - 'timeout': context.CLIARGS['task_timeout']} + mytask = dict( + action=context.CLIARGS['module_name'], + args=module_args, + timeout=context.CLIARGS['task_timeout'], + ) mytask = Origin(description=f'').tag(mytask) @@ -184,7 +187,7 @@ variable_manager=variable_manager, loader=loader, passwords=passwords, - stdout_callback=cb, + stdout_callback_name=cb, run_additional_callbacks=C.DEFAULT_LOAD_CALLBACK_PLUGINS, run_tree=run_tree, forks=context.CLIARGS['forks'], diff -Nru ansible-core-2.19.0~beta6/lib/ansible/cli/console.py ansible-core-2.19.1/lib/ansible/cli/console.py --- ansible-core-2.19.0~beta6/lib/ansible/cli/console.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/cli/console.py 2025-08-25 19:16:05.000000000 +0000 @@ -194,7 +194,7 @@ result = None try: check_raw = module in C._ACTION_ALLOWS_RAW_ARGS - task = dict(action=dict(module=module, args=parse_kv(module_args, check_raw=check_raw)), timeout=self.task_timeout) + task = dict(action=module, args=parse_kv(module_args, check_raw=check_raw), timeout=self.task_timeout) play_ds = dict( name="Ansible Shell", hosts=self.cwd, @@ -222,7 +222,7 @@ variable_manager=self.variable_manager, loader=self.loader, passwords=self.passwords, - stdout_callback=cb, + stdout_callback_name=cb, run_additional_callbacks=C.DEFAULT_LOAD_CALLBACK_PLUGINS, run_tree=False, forks=self.forks, diff -Nru ansible-core-2.19.0~beta6/lib/ansible/cli/doc.py ansible-core-2.19.1/lib/ansible/cli/doc.py --- ansible-core-2.19.0~beta6/lib/ansible/cli/doc.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/cli/doc.py 2025-08-25 19:16:05.000000000 +0000 @@ -1309,7 +1309,7 @@ if ignore in item: del item[ignore] - # reformat cli optoins + # reformat cli options if 'cli' in opt and opt['cli']: conf['cli'] = [] for cli in opt['cli']: @@ -1440,7 +1440,7 @@ pad = display.columns * 0.20 limit = max(display.columns - int(pad), 70) - text.append("> %s %s (%s)" % (plugin_type.upper(), _format(doc.pop('plugin_name'), 'bold'), doc.pop('filename'))) + text.append("> %s %s (%s)" % (plugin_type.upper(), _format(doc.pop('plugin_name'), 'bold'), doc.pop('filename') or 'Jinja2')) if isinstance(doc['description'], list): descs = doc.pop('description') diff -Nru ansible-core-2.19.0~beta6/lib/ansible/config/base.yml ansible-core-2.19.1/lib/ansible/config/base.yml --- ansible-core-2.19.0~beta6/lib/ansible/config/base.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/config/base.yml 2025-08-25 19:16:05.000000000 +0000 @@ -11,16 +11,26 @@ vars: - {name: _ansible_ansiballz_coverage_config} version_added: '2.19' -_ANSIBALLZ_DEBUGGER_CONFIG: - name: Configure the AnsiballZ remote debugging extension +_ANSIBALLZ_DEBUGPY_CONFIG: + name: Configure the AnsiballZ remote debugging extension for debugpy description: - - Enables and configures the AnsiballZ remote debugging extension. + - Enables and configures the AnsiballZ remote debugging extension for debugpy. - This is for internal use only. env: - - {name: _ANSIBLE_ANSIBALLZ_DEBUGGER_CONFIG} + - {name: _ANSIBLE_ANSIBALLZ_DEBUGPY_CONFIG} vars: - - {name: _ansible_ansiballz_debugger_config} - version_added: '2.19' + - {name: _ansible_ansiballz_debugpy_config} + version_added: '2.20' +_ANSIBALLZ_PYDEVD_CONFIG: + name: Configure the AnsiballZ remote debugging extension for pydevd + description: + - Enables and configures the AnsiballZ remote debugging extension for pydevd. + - This is for internal use only. + env: + - {name: _ANSIBLE_ANSIBALLZ_PYDEVD_CONFIG} + vars: + - {name: _ansible_ansiballz_pydevd_config} + version_added: '2.20' _ANSIBLE_CONNECTION_PATH: env: - name: _ANSIBLE_CONNECTION_PATH @@ -41,6 +51,15 @@ ignore: just continue silently env: [ { name: _ANSIBLE_CALLBACK_DISPATCH_ERROR_BEHAVIOR } ] version_added: '2.19' +_MODULE_METADATA: + name: Enable experimental module metadata + description: + - Enables experimental module-level metadata controls for serialization profile selection. + - This is for internal use only. + type: boolean + default: false + env: [ { name: _ANSIBLE_MODULE_METADATA } ] + version_added: '2.19' ALLOW_BROKEN_CONDITIONALS: # This config option will be deprecated once it no longer has any effect (2.23). name: Allow broken conditionals @@ -2176,12 +2195,6 @@ vars: - {name: ansible_win_async_startup_timeout} version_added: '2.10' -WRAP_STDERR: - description: Control line-wrapping behavior on console warnings and errors from default output callbacks (eases pattern-based output testing) - env: [{name: ANSIBLE_WRAP_STDERR}] - default: false - type: bool - version_added: "2.19" YAML_FILENAME_EXTENSIONS: name: Valid YAML extensions default: [".yml", ".yaml", ".json"] diff -Nru ansible-core-2.19.0~beta6/lib/ansible/config/manager.py ansible-core-2.19.1/lib/ansible/config/manager.py --- ansible-core-2.19.0~beta6/lib/ansible/config/manager.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/config/manager.py 2025-08-25 19:16:05.000000000 +0000 @@ -6,6 +6,7 @@ import atexit import decimal import configparser +import functools import os import os.path import sys @@ -248,18 +249,6 @@ return ftype -# FIXME: can move to module_utils for use for ini plugins also? -def get_ini_config_value(p, entry): - """ returns the value of last ini entry found """ - value = None - if p is not None: - try: - value = p.get(entry.get('section', 'defaults'), entry.get('key', ''), raw=True) - except Exception: # FIXME: actually report issues here - pass - return value - - def find_ini_config_file(warnings=None): """ Load INI Config File order(first found is used): ENV, CWD, HOME, /etc/ansible """ # FIXME: eventually deprecate ini configs @@ -345,6 +334,7 @@ _errors: list[tuple[str, Exception]] def __init__(self, conf_file=None, defs_file=None): + self._get_ini_config_value = functools.cache(self._get_ini_config_value) self._base_defs = {} self._plugins = {} @@ -460,13 +450,17 @@ pass def get_plugin_options(self, plugin_type, name, keys=None, variables=None, direct=None): + options, dummy = self.get_plugin_options_and_origins(plugin_type, name, keys=keys, variables=variables, direct=direct) + return options + def get_plugin_options_and_origins(self, plugin_type, name, keys=None, variables=None, direct=None): options = {} + origins = {} defs = self.get_configuration_definitions(plugin_type=plugin_type, name=name) for option in defs: - options[option] = self.get_config_value(option, plugin_type=plugin_type, plugin_name=name, keys=keys, variables=variables, direct=direct) - - return options + options[option], origins[option] = self.get_config_value_and_origin(option, plugin_type=plugin_type, plugin_name=name, keys=keys, + variables=variables, direct=direct) + return options, origins def get_plugin_vars(self, plugin_type, name): @@ -628,6 +622,7 @@ # env vars are next precedence if value is None and defs[config].get('env'): value, origin = self._loop_entries(os.environ, defs[config]['env']) + value = _tags.TrustedAsTemplate().tag(value) origin = 'env: %s' % origin # try config file entries next, if we have one @@ -642,7 +637,7 @@ for entry in defs[config][ftype]: # load from config if ftype == 'ini': - temp_value = get_ini_config_value(self._parsers[cfile], entry) + temp_value = self._get_ini_config_value(cfile, entry.get('section', 'defaults'), entry['key']) elif ftype == 'yaml': raise AnsibleError('YAML configuration type has not been implemented yet') else: @@ -724,6 +719,32 @@ self._plugins[plugin_type][name] = defs + def _get_ini_config_value(self, config_file: str, section: str, option: str) -> t.Any: + """ + Fetch `option` from the specified `section`. + Returns `None` if the specified `section` or `option` are not present. + Origin and TrustedAsTemplate tags are applied to returned values. + + CAUTION: Although INI sourced configuration values are trusted for templating, that does not automatically mean they will be templated. + It is up to the code consuming configuration values to apply templating if required. + """ + parser = self._parsers[config_file] + value = parser.get(section, option, raw=True, fallback=None) + + if value is not None: + value = self._apply_tags(value, section, option) + + return value + + def _apply_tags(self, value: str, section: str, option: str) -> t.Any: + """Apply origin and trust to the given `value` sourced from the stated `section` and `option`.""" + description = f'section {section!r} option {option!r}' + origin = _tags.Origin(path=self._config_file, description=description) + tags = [origin, _tags.TrustedAsTemplate()] + value = AnsibleTagHelper.tag(value, tags) + + return value + @staticmethod def get_deprecated_msg_from_config(dep_docs, include_removal=False, collection_name=None): diff -Nru ansible-core-2.19.0~beta6/lib/ansible/executor/module_common.py ansible-core-2.19.1/lib/ansible/executor/module_common.py --- ansible-core-2.19.0~beta6/lib/ansible/executor/module_common.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/executor/module_common.py 2025-08-25 19:16:05.000000000 +0000 @@ -364,7 +364,7 @@ options=TemplateOptions(value_for_omit=None)) if not interpreter_out: - # nothing matched(None) or in case someone configures empty string or empty intepreter + # nothing matched(None) or in case someone configures empty string or empty interpreter interpreter_out = interpreter # set shebang @@ -659,9 +659,14 @@ 1: ModuleMetadataV1, } +_DEFAULT_LEGACY_METADATA = ModuleMetadataV1(serialization_profile='legacy') + def _get_module_metadata(module: ast.Module) -> ModuleMetadata: - # DTFIX2: while module metadata works, this feature isn't fully baked and should be turned off before release + # experimental module metadata; off by default + if not C.config.get_config_value('_MODULE_METADATA'): + return _DEFAULT_LEGACY_METADATA + metadata_nodes: list[ast.Assign] = [] for node in module.body: @@ -674,9 +679,7 @@ metadata_nodes.append(node) if not metadata_nodes: - return ModuleMetadataV1( - serialization_profile='legacy', - ) + return _DEFAULT_LEGACY_METADATA if len(metadata_nodes) > 1: raise ValueError('Module METADATA must defined only once.') @@ -951,7 +954,7 @@ class _CachedModule: """Cached Python module created by AnsiballZ.""" - # DTFIX5: secure this (locked down pickle, don't use pickle, etc.) + # FIXME: switch this to use a locked down pickle config or don't use pickle- easy to mess up and reach objects that shouldn't be pickled zip_data: bytes metadata: ModuleMetadata diff -Nru ansible-core-2.19.0~beta6/lib/ansible/executor/powershell/psrp_put_file.ps1 ansible-core-2.19.1/lib/ansible/executor/powershell/psrp_put_file.ps1 --- ansible-core-2.19.0~beta6/lib/ansible/executor/powershell/psrp_put_file.ps1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/executor/powershell/psrp_put_file.ps1 2025-08-25 19:16:05.000000000 +0000 @@ -102,7 +102,7 @@ Set-Property 'MaximumAllowedMemory' $null } catch { - # Satify pslint, we purposefully ignore this error as it is not critical it works. + # Satisfy pslint, we purposefully ignore this error as it is not critical it works. $null = $null } } diff -Nru ansible-core-2.19.0~beta6/lib/ansible/executor/task_executor.py ansible-core-2.19.1/lib/ansible/executor/task_executor.py --- ansible-core-2.19.0~beta6/lib/ansible/executor/task_executor.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/executor/task_executor.py 2025-08-25 19:16:05.000000000 +0000 @@ -712,7 +712,10 @@ condname = 'failed' if self._task.failed_when: - result['failed_when_result'] = result['failed'] = self._task._resolve_conditional(self._task.failed_when, vars_copy) + is_failed = result['failed_when_result'] = result['failed'] = self._task._resolve_conditional(self._task.failed_when, vars_copy) + + if not is_failed and (suppressed_exception := result.pop('exception', None)): + result['failed_when_suppressed_exception'] = suppressed_exception except AnsibleError as e: result['failed'] = True @@ -872,7 +875,7 @@ async_result = async_handler.run(task_vars=task_vars) # We do not bail out of the loop in cases where the failure # is associated with a parsing error. The async_runner can - # have issues which result in a half-written/unparseable result + # have issues which result in a half-written/unparsable result # file on disk, which manifests to the user as a timeout happening # before it's time to timeout. if (async_result.get('finished', False) or @@ -910,7 +913,7 @@ if async_result.get('_ansible_parsed'): return dict(failed=True, msg="async task did not complete within the requested time - %ss" % self._task.async_val, async_result=async_result) else: - return dict(failed=True, msg="async task produced unparseable results", async_result=async_result) + return dict(failed=True, msg="async task produced unparsable results", async_result=async_result) else: # If the async task finished, automatically cleanup the temporary # status file left behind. @@ -1129,7 +1132,7 @@ # let action plugin override module, fallback to 'normal' action plugin otherwise elif self._shared_loader_obj.action_loader.has_plugin(self._task.action, collection_list=collections): handler_name = self._task.action - elif all((module_prefix in C.NETWORK_GROUP_MODULES, self._shared_loader_obj.action_loader.has_plugin(network_action, collection_list=collections))): + elif module_prefix in C.NETWORK_GROUP_MODULES and self._shared_loader_obj.action_loader.has_plugin(network_action, collection_list=collections): handler_name = network_action display.vvvv("Using network group action {handler} for {action}".format(handler=handler_name, action=self._task.action), diff -Nru ansible-core-2.19.0~beta6/lib/ansible/executor/task_queue_manager.py ansible-core-2.19.1/lib/ansible/executor/task_queue_manager.py --- ansible-core-2.19.0~beta6/lib/ansible/executor/task_queue_manager.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/executor/task_queue_manager.py 2025-08-25 19:16:05.000000000 +0000 @@ -34,7 +34,6 @@ from ansible.executor.stats import AggregateStats from ansible.executor.task_result import _RawTaskResult, _WireTaskResult from ansible.inventory.data import InventoryData -from ansible.module_utils.six import string_types from ansible.module_utils.common.text.converters import to_native from ansible.parsing.dataloader import DataLoader from ansible.playbook.play_context import PlayContext @@ -139,7 +138,7 @@ variable_manager: VariableManager, loader: DataLoader, passwords: dict[str, str | None], - stdout_callback: str | None = None, + stdout_callback_name: str | None = None, run_additional_callbacks: bool = True, run_tree: bool = False, forks: int | None = None, @@ -149,12 +148,11 @@ self._loader = loader self._stats = AggregateStats() self.passwords = passwords - self._stdout_callback: str | None | CallbackBase = stdout_callback + self._stdout_callback_name: str | None = stdout_callback_name or C.DEFAULT_STDOUT_CALLBACK self._run_additional_callbacks = run_additional_callbacks self._run_tree = run_tree self._forks = forks or 5 - self._callbacks_loaded = False self._callback_plugins: list[CallbackBase] = [] self._start_at_done = False @@ -181,7 +179,7 @@ for fd in (STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO): os.set_inheritable(fd, False) except Exception as ex: - self.warning(f"failed to set stdio as non inheritable: {ex}") + display.error_as_warning("failed to set stdio as non inheritable", exception=ex) self._callback_lock = threading.Lock() @@ -199,44 +197,40 @@ only one such callback plugin will be loaded. """ - if self._callbacks_loaded: + if self._callback_plugins: return - stdout_callback_loaded = False - if self._stdout_callback is None: - self._stdout_callback = C.DEFAULT_STDOUT_CALLBACK - - if isinstance(self._stdout_callback, CallbackBase): - stdout_callback_loaded = True - elif isinstance(self._stdout_callback, string_types): - if self._stdout_callback not in callback_loader: - raise AnsibleError("Invalid callback for stdout specified: %s" % self._stdout_callback) - else: - self._stdout_callback = callback_loader.get(self._stdout_callback) - self._stdout_callback.set_options() - stdout_callback_loaded = True - else: - raise AnsibleError("callback must be an instance of CallbackBase or the name of a callback plugin") + if not self._stdout_callback_name: + raise AnsibleError("No stdout callback name provided.") + + stdout_callback = callback_loader.get(self._stdout_callback_name) + + if not stdout_callback: + raise AnsibleError(f"Could not load {self._stdout_callback_name!r} callback plugin.") + + stdout_callback._init_callback_methods() + stdout_callback.set_options() + + self._callback_plugins.append(stdout_callback) # get all configured loadable callbacks (adjacent, builtin) - callback_list = list(callback_loader.all(class_only=True)) + plugin_types = {plugin_type.ansible_name: plugin_type for plugin_type in callback_loader.all(class_only=True)} # add enabled callbacks that refer to collections, which might not appear in normal listing for c in C.CALLBACKS_ENABLED: # load all, as collection ones might be using short/redirected names and not a fqcn plugin = callback_loader.get(c, class_only=True) - # TODO: check if this skip is redundant, loader should handle bad file/plugin cases already if plugin: # avoids incorrect and dupes possible due to collections - if plugin not in callback_list: - callback_list.append(plugin) + plugin_types.setdefault(plugin.ansible_name, plugin) else: display.warning("Skipping callback plugin '%s', unable to load" % c) - # for each callback in the list see if we should add it to 'active callbacks' used in the play - for callback_plugin in callback_list: + plugin_types.pop(stdout_callback.ansible_name, None) + # for each callback in the list see if we should add it to 'active callbacks' used in the play + for callback_plugin in plugin_types.values(): callback_type = getattr(callback_plugin, 'CALLBACK_TYPE', '') callback_needs_enabled = getattr(callback_plugin, 'CALLBACK_NEEDS_ENABLED', getattr(callback_plugin, 'CALLBACK_NEEDS_WHITELIST', False)) @@ -252,10 +246,8 @@ display.vvvvv("Attempting to use '%s' callback." % (callback_name)) if callback_type == 'stdout': # we only allow one callback of type 'stdout' to be loaded, - if callback_name != self._stdout_callback or stdout_callback_loaded: - display.vv("Skipping callback '%s', as we already have a stdout callback." % (callback_name)) - continue - stdout_callback_loaded = True + display.vv("Skipping callback '%s', as we already have a stdout callback." % (callback_name)) + continue elif callback_name == 'tree' and self._run_tree: # TODO: remove special case for tree, which is an adhoc cli option --tree pass @@ -270,21 +262,16 @@ # avoid bad plugin not returning an object, only needed cause we do class_only load and bypass loader checks, # really a bug in the plugin itself which we ignore as callback errors are not supposed to be fatal. if callback_obj: - # skip initializing if we already did the work for the same plugin (even with diff names) - if callback_obj not in self._callback_plugins: - callback_obj.set_options() - self._callback_plugins.append(callback_obj) - else: - display.vv("Skipping callback '%s', already loaded as '%s'." % (callback_plugin, callback_name)) + callback_obj._init_callback_methods() + callback_obj.set_options() + self._callback_plugins.append(callback_obj) else: display.warning("Skipping callback '%s', as it does not create a valid plugin instance." % callback_name) continue - except Exception as e: - display.warning("Skipping callback '%s', unable to load due to: %s" % (callback_name, to_native(e))) + except Exception as ex: + display.error_as_warning(f"Failed to load callback plugin {callback_name!r}.", exception=ex) continue - self._callbacks_loaded = True - def run(self, play): """ Iterates over the roles/tasks in a play, using the given (or default) @@ -294,8 +281,7 @@ are done with the current task). """ - if not self._callbacks_loaded: - self.load_callbacks() + self.load_callbacks() all_vars = self._variable_manager.get_vars(play=play) templar = TemplateEngine(loader=self._loader, variables=all_vars) @@ -311,13 +297,9 @@ ) play_context = PlayContext(new_play, self.passwords, self._connection_lockfile.fileno()) - if (self._stdout_callback and - hasattr(self._stdout_callback, 'set_play_context')): - self._stdout_callback.set_play_context(play_context) for callback_plugin in self._callback_plugins: - if hasattr(callback_plugin, 'set_play_context'): - callback_plugin.set_play_context(play_context) + callback_plugin.set_play_context(play_context) self.send_callback('v2_playbook_on_play_start', new_play) @@ -437,7 +419,7 @@ @lock_decorator(attr='_callback_lock') def send_callback(self, method_name, *args, **kwargs): # We always send events to stdout callback first, rest should follow config order - for callback_plugin in [self._stdout_callback] + self._callback_plugins: + for callback_plugin in self._callback_plugins: # a plugin that set self.disabled to True will not be called # see osx_say.py example for such a plugin if callback_plugin.disabled: @@ -448,31 +430,13 @@ if not callback_plugin.wants_implicit_tasks and (task_arg := self._first_arg_of_type(Task, args)) and task_arg.implicit: continue - # try to find v2 method, fallback to v1 method, ignore callback if no method found methods = [] - for possible in [method_name, 'v2_on_any']: - method = getattr(callback_plugin, possible, None) - - if method is None: - method = getattr(callback_plugin, possible.removeprefix('v2_'), None) - - if method is not None: - display.deprecated( - msg='The v1 callback API is deprecated.', - version='2.23', - help_text='Use `v2_` prefixed callback methods instead.', - ) - - if method is not None and not getattr(method, '_base_impl', False): # don't bother dispatching to the base impls - if possible == 'v2_on_any': - display.deprecated( - msg='The `v2_on_any` callback method is deprecated.', - version='2.23', - help_text='Use event-specific callback methods instead.', - ) + if method_name in callback_plugin._implemented_callback_methods: + methods.append(getattr(callback_plugin, method_name)) - methods.append(method) + if 'v2_on_any' in callback_plugin._implemented_callback_methods: + methods.append(getattr(callback_plugin, 'v2_on_any')) for method in methods: # send clean copies @@ -498,4 +462,4 @@ except Exception as ex: raise AnsibleCallbackError(f"Callback dispatch {method_name!r} failed for plugin {callback_plugin._load_name!r}.") from ex - callback_plugin._current_task_result = None + callback_plugin._current_task_result = None # clear temporary instance storage hack diff -Nru ansible-core-2.19.0~beta6/lib/ansible/executor/task_result.py ansible-core-2.19.1/lib/ansible/executor/task_result.py --- ansible-core-2.19.0~beta6/lib/ansible/executor/task_result.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/executor/task_result.py 2025-08-25 19:16:05.000000000 +0000 @@ -27,7 +27,7 @@ CLEAN_EXCEPTIONS = ( '_ansible_verbose_always', # for debug and other actions, to always expand data (pretty jsonification) '_ansible_item_label', # to know actual 'item' variable - '_ansible_no_log', # jic we didnt clean up well enough, DON'T LOG + '_ansible_no_log', # jic we didn't clean up well enough, DON'T LOG '_ansible_verbose_override', # controls display of ansible_facts, gathering would be very noise with -v otherwise ) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/galaxy/api.py ansible-core-2.19.1/lib/ansible/galaxy/api.py --- ansible-core-2.19.0~beta6/lib/ansible/galaxy/api.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/galaxy/api.py 2025-08-25 19:16:05.000000000 +0000 @@ -92,7 +92,7 @@ try: data = self._call_galaxy(n_url, method='GET', error_context_msg=error_context_msg, cache=True) except (AnsibleError, GalaxyError, ValueError, KeyError) as err: - # Either the URL doesnt exist, or other error. Or the URL exists, but isn't a galaxy API + # Either the URL doesn't exist, or other error. Or the URL exists, but isn't a galaxy API # root (not JSON, no 'available_versions') so try appending '/api/' if n_url.endswith('/api') or n_url.endswith('/api/'): raise @@ -877,7 +877,7 @@ except GalaxyError as err: if err.http_code != 404: raise - # v3 doesn't raise a 404 so we need to mimick the empty response from APIs that do. + # v3 doesn't raise a 404 so we need to mimic the empty response from APIs that do. return [] if 'data' in data: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/galaxy/collection/concrete_artifact_manager.py ansible-core-2.19.1/lib/ansible/galaxy/collection/concrete_artifact_manager.py --- ansible-core-2.19.0~beta6/lib/ansible/galaxy/collection/concrete_artifact_manager.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/galaxy/collection/concrete_artifact_manager.py 2025-08-25 19:16:05.000000000 +0000 @@ -449,9 +449,9 @@ except subprocess.CalledProcessError as proc_err: raise AnsibleError( # should probably be LookupError 'Failed to switch a cloned Git repo `{repo_url!s}` ' - 'to the requested revision `{commitish!s}`.'. + 'to the requested revision `{revision!s}`.'. format( - commitish=to_native(version), + revision=to_native(version), repo_url=to_native(git_url), ), ) from proc_err diff -Nru ansible-core-2.19.0~beta6/lib/ansible/galaxy/dependency_resolution/providers.py ansible-core-2.19.1/lib/ansible/galaxy/dependency_resolution/providers.py --- ansible-core-2.19.0~beta6/lib/ansible/galaxy/dependency_resolution/providers.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/galaxy/dependency_resolution/providers.py 2025-08-25 19:16:05.000000000 +0000 @@ -148,7 +148,7 @@ :param resolutions: Mapping of identifier, candidate pairs. - :param candidates: Possible candidates for the identifer. + :param candidates: Possible candidates for the identifier. Mapping of identifier, list of candidate pairs. :param information: Requirement information of each package. @@ -158,7 +158,7 @@ :param backtrack_causes: Sequence of requirement information that were the requirements that caused the resolver to most recently backtrack. - The preference could depend on a various of issues, including + The preference could depend on various of issues, including (not necessarily in this order): * Is this package pinned in the current resolution result? @@ -404,7 +404,7 @@ :param requirement: A requirement that produced the `candidate`. - :param candidate: A pinned candidate supposedly matchine the \ + :param candidate: A pinned candidate supposedly matching the \ `requirement` specifier. It is guaranteed to \ have been generated from the `requirement`. diff -Nru ansible-core-2.19.0~beta6/lib/ansible/inventory/group.py ansible-core-2.19.1/lib/ansible/inventory/group.py --- ansible-core-2.19.0~beta6/lib/ansible/inventory/group.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/inventory/group.py 2025-08-25 19:16:05.000000000 +0000 @@ -26,7 +26,7 @@ from ansible.errors import AnsibleError from ansible.module_utils.common.text.converters import to_native, to_text from ansible.utils.display import Display -from ansible.utils.vars import combine_vars +from ansible.utils.vars import combine_vars, validate_variable_name from . import helpers # this is left as a module import to facilitate easier unit test patching @@ -221,6 +221,11 @@ def set_variable(self, key: str, value: t.Any) -> None: key = helpers.remove_trust(key) + try: + validate_variable_name(key) + except AnsibleError as ex: + Display().deprecated(msg=f'Accepting inventory variable with invalid name {key!r}.', version='2.23', help_text=ex._help_text, obj=ex.obj) + if key == 'ansible_group_priority': self.set_priority(int(value)) else: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/inventory/host.py ansible-core-2.19.1/lib/ansible/inventory/host.py --- ansible-core-2.19.0~beta6/lib/ansible/inventory/host.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/inventory/host.py 2025-08-25 19:16:05.000000000 +0000 @@ -22,8 +22,10 @@ from collections.abc import Mapping, MutableMapping +from ansible.errors import AnsibleError from ansible.inventory.group import Group, InventoryObjectType from ansible.parsing.utils.addresses import patterns +from ansible.utils.display import Display from ansible.utils.vars import combine_vars, get_unique_id, validate_variable_name from . import helpers # this is left as a module import to facilitate easier unit test patching @@ -117,7 +119,10 @@ def set_variable(self, key: str, value: t.Any) -> None: key = helpers.remove_trust(key) - validate_variable_name(key) + try: + validate_variable_name(key) + except AnsibleError as ex: + Display().deprecated(msg=f'Accepting inventory variable with invalid name {key!r}.', version='2.23', help_text=ex._help_text, obj=ex.obj) if key in self.vars and isinstance(self.vars[key], MutableMapping) and isinstance(value, Mapping): self.vars = combine_vars(self.vars, {key: value}) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_debugpy.py ansible-core-2.19.1/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_debugpy.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_debugpy.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_debugpy.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,97 @@ +""" +Remote debugging support for AnsiballZ modules with debugpy. + +To use with VS Code: + +1) Choose an available port for VS Code to listen on (e.g. 5678). +2) Ensure `debugpy` is installed for the interpreter(s) which will run the code being debugged. +3) Create the following launch.json configuration + + { + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debug Server", + "type": "debugpy", + "request": "attach", + "listen": { + "host": "localhost", + "port": 5678, + }, + }, + { + "name": "ansible-playbook main.yml", + "type": "debugpy", + "request": "launch", + "module": "ansible", + "args": [ + "playbook", + "main.yml" + ], + "env": { + "_ANSIBLE_ANSIBALLZ_DEBUGPY_CONFIG": "{\"host\": \"localhost\", \"port\": 5678}" + }, + "console": "integratedTerminal", + } + ], + "compounds": [ + { + "name": "Test Module Debugging", + "configurations": [ + "Python Debug Server", + "ansible-playbook main.yml" + ], + "stopAll": true + } + ] + } + +4) Set any desired breakpoints. +5) Configure the Run and Debug view to use the "Test Module Debugging" compound configuration. +6) Press F5 to start debugging. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import pathlib + +import typing as t + + +@dataclasses.dataclass(frozen=True) +class Options: + """Debugger options for debugpy.""" + + host: str = 'localhost' + """The host to connect to for remote debugging.""" + port: int = 5678 + """The port to connect to for remote debugging.""" + connect: dict[str, object] = dataclasses.field(default_factory=dict) + """The options to pass to the `debugpy.connect` method.""" + source_mapping: dict[str, str] = dataclasses.field(default_factory=dict) + """ + A mapping of source paths to provide to debugpy. + This setting is used internally by AnsiballZ and is not required unless Ansible CLI commands are run from a different system than your IDE. + In that scenario, use this setting instead of configuring source mapping in your IDE. + The key is a path known to the IDE. + The value is the same path as known to the Ansible CLI. + Both file paths and directories are supported. + """ + + +def run(args: dict[str, t.Any]) -> None: # pragma: nocover + """Enable remote debugging.""" + import debugpy + + options = Options(**args) + temp_dir = pathlib.Path(__file__).parent.parent.parent.parent.parent.parent + path_mapping = [[key, str(temp_dir / value)] for key, value in options.source_mapping.items()] + + os.environ['PATHS_FROM_ECLIPSE_TO_PYTHON'] = json.dumps(path_mapping) + + debugpy.connect((options.host, options.port), **options.connect) + + pass # A convenient place to put a breakpoint diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py ansible-core-2.19.1/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/_internal/_ansiballz/_extensions/_pydevd.py 2025-08-25 19:16:05.000000000 +0000 @@ -7,14 +7,12 @@ 2) Create a Python Debug Server using that port. 3) Start the Python Debug Server. 4) Ensure the correct version of `pydevd-pycharm` is installed for the interpreter(s) which will run the code being debugged. -5) Configure Ansible with the `_ANSIBALLZ_DEBUGGER_CONFIG` option. +5) Configure Ansible with the `_ANSIBALLZ_PYDEVD_CONFIG` option. See `Options` below for the structure of the debugger configuration. Example configuration using an environment variable: - export _ANSIBLE_ANSIBALLZ_DEBUGGER_CONFIG='{"module": "pydevd_pycharm", "settrace": {"host": "localhost", "port": 5678, "suspend": false}}' + export _ANSIBLE_ANSIBALLZ_PYDEVD_CONFIG='{"module": "pydevd_pycharm", "settrace": {"host": "localhost", "port": 5678, "suspend": false}}' 6) Set any desired breakpoints. 7) Run Ansible commands. - -A similar process should work for other pydevd based debuggers, such as Visual Studio Code, but they have not been tested. """ from __future__ import annotations diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_datatag/__init__.py ansible-core-2.19.1/lib/ansible/module_utils/_internal/_datatag/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_datatag/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/_internal/_datatag/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -949,8 +949,13 @@ # noinspection PyProtectedMember _ANSIBLE_ALLOWED_VAR_TYPES = frozenset({type(None), bool}) | set(AnsibleTaggedObject._tagged_type_map) | set(AnsibleTaggedObject._tagged_type_map.values()) -"""These are the only types supported by Ansible's variable storage. Subclasses are not permitted.""" +"""These are the exact types supported by Ansible's variable storage.""" _ANSIBLE_ALLOWED_NON_SCALAR_COLLECTION_VAR_TYPES = frozenset(item for item in _ANSIBLE_ALLOWED_VAR_TYPES if is_non_scalar_collection_type(item)) +"""These are the exact non-scalar collection types supported by Ansible's variable storage.""" + _ANSIBLE_ALLOWED_MAPPING_VAR_TYPES = frozenset(item for item in _ANSIBLE_ALLOWED_VAR_TYPES if issubclass(item, c.Mapping)) +"""These are the exact mapping types supported by Ansible's variable storage.""" + _ANSIBLE_ALLOWED_SCALAR_VAR_TYPES = _ANSIBLE_ALLOWED_VAR_TYPES - _ANSIBLE_ALLOWED_NON_SCALAR_COLLECTION_VAR_TYPES +"""These are the exact scalar types supported by Ansible's variable storage.""" diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_deprecator.py ansible-core-2.19.1/lib/ansible/module_utils/_internal/_deprecator.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_deprecator.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/_internal/_deprecator.py 2025-08-25 19:16:05.000000000 +0000 @@ -38,11 +38,16 @@ _skip_stackwalk = True if frame_info := _stack.caller_frame(): - return _path_as_core_plugininfo(frame_info.filename) or _path_as_collection_plugininfo(frame_info.filename) + return _path_as_plugininfo(frame_info.filename) return None # pragma: nocover +def _path_as_plugininfo(path: str) -> _messages.PluginInfo | None: + """Return a `PluginInfo` instance if the provided `path` refers to a plugin.""" + return _path_as_core_plugininfo(path) or _path_as_collection_plugininfo(path) + + def _path_as_core_plugininfo(path: str) -> _messages.PluginInfo | None: """Return a `PluginInfo` instance if the provided `path` refers to a core plugin.""" try: @@ -62,6 +67,10 @@ # Callers in this case need to identify the deprecating plugin name, otherwise only ansible-core will be reported. # Reporting ansible-core is never wrong, it just may be missing an additional detail (plugin name) in the "on behalf of" case. return ANSIBLE_CORE_DEPRECATOR + + if plugin_name == '__init__': + # The plugin type is known, but the caller isn't a specific plugin -- instead, it's core plugin infrastructure (the base class). + return _messages.PluginInfo(resolved_name=namespace, type=plugin_type) elif match := re.match(r'modules/(?P\w+)', relpath): # AnsiballZ Python package for core modules plugin_name = match.group("module_name") @@ -101,6 +110,8 @@ name = '.'.join((match.group('ns'), match.group('coll'), match.group('plugin_name'))) + # DTFIX-FUTURE: deprecations from __init__ will be incorrectly attributed to a plugin of that name + return _messages.PluginInfo(resolved_name=name, type=plugin_type) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_traceback.py ansible-core-2.19.1/lib/ansible/module_utils/_internal/_traceback.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/_internal/_traceback.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/_internal/_traceback.py 2025-08-25 19:16:05.000000000 +0000 @@ -80,7 +80,7 @@ from ..basic import _PARSED_MODULE_ARGS _module_tracebacks_enabled_events = frozenset( - TracebackEvent[value.upper()] for value in _PARSED_MODULE_ARGS.get('_ansible_tracebacks_for') + TracebackEvent[value.upper()] for value in _PARSED_MODULE_ARGS.get('_ansible_tracebacks_for', []) ) # type: ignore[union-attr] except BaseException: return True # if things failed early enough that we can't figure this out, assume we want a traceback for troubleshooting diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/ansible_release.py ansible-core-2.19.1/lib/ansible/module_utils/ansible_release.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/ansible_release.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/ansible_release.py 2025-08-25 19:16:05.000000000 +0000 @@ -17,6 +17,6 @@ from __future__ import annotations -__version__ = '2.19.0b6' +__version__ = '2.19.1' __author__ = 'Ansible, Inc.' __codename__ = "What Is and What Should Never Be" diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/basic.py ansible-core-2.19.1/lib/ansible/module_utils/basic.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/basic.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/basic.py 2025-08-25 19:16:05.000000000 +0000 @@ -1512,11 +1512,19 @@ # strip no_log collisions kwargs = remove_values(kwargs, self.no_log_values) - # return preserved + # graft preserved values back on kwargs.update(preserved) + self._record_module_result(kwargs) + + def _record_module_result(self, o: dict[str, t.Any]) -> None: + """ + Temporary internal hook to enable modification/bypass of module result serialization. + + Monkeypatched by ansible.netcommon for direct in-worker module execution. + """ encoder = _json.get_module_encoder(_ANSIBLE_PROFILE, _json.Direction.MODULE_TO_CONTROLLER) - print('\n%s' % json.dumps(kwargs, cls=encoder)) + print('\n%s' % json.dumps(o, cls=encoder)) def exit_json(self, **kwargs) -> t.NoReturn: """ return from the module, without error """ @@ -1657,10 +1665,11 @@ ext = time.strftime("%Y-%m-%d@%H:%M:%S~", time.localtime(time.time())) backupdest = '%s.%s.%s' % (fn, os.getpid(), ext) - try: - self.preserved_copy(fn, backupdest) - except (shutil.Error, OSError) as ex: - raise Exception(f'Could not make backup of {fn!r} to {backupdest!r}.') from ex + if not self.check_mode: + try: + self.preserved_copy(fn, backupdest) + except (shutil.Error, IOError) as ex: + raise Exception(f'Could not make backup of {fn!r} to {backupdest!r}.') from ex return backupdest @@ -1899,18 +1908,18 @@ the execution to hang (especially if no input data is specified) :kw environ_update: dictionary to *update* environ variables with :kw umask: Umask to be used when running the command. Default None - :kw encoding: Since we return native strings, on python3 we need to + :kw encoding: Since we return strings, we need to know the encoding to use to transform from bytes to text. If you want to always get bytes back, use encoding=None. The default is "utf-8". This does not affect transformation of strings given as args. - :kw errors: Since we return native strings, on python3 we need to + :kw errors: Since we return strings, we need to transform stdout and stderr from bytes to text. If the bytes are undecodable in the ``encoding`` specified, then use this error handler to deal with them. The default is ``surrogate_or_strict`` which means that the bytes will be decoded using the surrogateescape error handler if available (available on all - python3 versions we support) otherwise a UnicodeError traceback + Python versions we support) otherwise a UnicodeError traceback will be raised. This does not affect transformations of strings given as args. :kw expand_user_and_vars: When ``use_unsafe_shell=False`` this argument @@ -1918,10 +1927,8 @@ are expanded before running the command. When ``True`` a string such as ``$SHELL`` will be expanded regardless of escaping. When ``False`` and ``use_unsafe_shell=False`` no path or variable expansion will be done. - :kw pass_fds: When running on Python 3 this argument - dictates which file descriptors should be passed - to an underlying ``Popen`` constructor. On Python 2, this will - set ``close_fds`` to False. + :kw pass_fds: This argument dictates which file descriptors should be passed + to an underlying ``Popen`` constructor. :kw before_communicate_callback: This function will be called after ``Popen`` object will be created but before communicating to the process. @@ -1932,11 +1939,10 @@ :kw handle_exceptions: This flag indicates whether an exception will be handled inline and issue a failed_json or if the caller should handle it. - :returns: A 3-tuple of return code (integer), stdout (native string), - and stderr (native string). On python2, stdout and stderr are both - byte strings. On python3, stdout and stderr are text strings converted - according to the encoding and errors parameters. If you want byte - strings on python3, use encoding=None to turn decoding to text off. + :returns: A 3-tuple of return code (int), stdout (str), and stderr (str). + stdout and stderr are text strings converted according to the encoding + and errors parameters. If you want byte strings, use encoding=None + to turn decoding to text off. """ # used by clean args later on self._clean = None diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/common/validation.py ansible-core-2.19.1/lib/ansible/module_utils/common/validation.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/common/validation.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/common/validation.py 2025-08-25 19:16:05.000000000 +0000 @@ -376,7 +376,10 @@ if isinstance(value, string_types): 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') msg = "'{0!r}' is not a string and conversion is not allowed".format(value) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/common/yaml.py ansible-core-2.19.1/lib/ansible/module_utils/common/yaml.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/common/yaml.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/common/yaml.py 2025-08-25 19:16:05.000000000 +0000 @@ -26,7 +26,7 @@ # DTFIX-FUTURE: refactor this to share the implementation with the controller version # use an abstract base class, with __init_subclass__ for representer registration, and instance methods for overridable representers -# then tests can be consolidated intead of having two nearly identical copies +# then tests can be consolidated instead of having two nearly identical copies if HAS_YAML: try: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/csharp/Ansible.Basic.cs ansible-core-2.19.1/lib/ansible/module_utils/csharp/Ansible.Basic.cs --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/csharp/Ansible.Basic.cs 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/csharp/Ansible.Basic.cs 2025-08-25 19:16:05.000000000 +0000 @@ -1696,7 +1696,7 @@ if ((attr & FileAttributes.ReadOnly) != 0) { // Windows does not allow files set with ReadOnly to be - // deleted. Pre-emptively unset the attribute. + // deleted. Preemptively unset the attribute. // FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE is quite new, // look at using that flag with POSIX delete once Server 2019 // is the baseline. diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/csharp/Ansible.Privilege.cs ansible-core-2.19.1/lib/ansible/module_utils/csharp/Ansible.Privilege.cs --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/csharp/Ansible.Privilege.cs 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/csharp/Ansible.Privilege.cs 2025-08-25 19:16:05.000000000 +0000 @@ -228,7 +228,7 @@ } /// - /// Get's the status of all the privileges on the token specified + /// Gets the status of all the privileges on the token specified /// /// The process token to get the privilege status on /// Dictionary where the key is the privilege constant and the value is the PrivilegeAttributes flags @@ -342,7 +342,7 @@ // Need to manually marshal the bytes requires for newState as the constant size // of LUID_AND_ATTRIBUTES is set to 1 and can't be overridden at runtime, TOKEN_PRIVILEGES // always contains at least 1 entry so we need to calculate the extra size if there are - // nore than 1 LUID_AND_ATTRIBUTES entry + // more than 1 LUID_AND_ATTRIBUTES entry int tokenPrivilegesSize = Marshal.SizeOf(typeof(NativeHelpers.TOKEN_PRIVILEGES)); int luidAttrSize = 0; if (newState.Length > 1) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/facts/hardware/base.py ansible-core-2.19.1/lib/ansible/module_utils/facts/hardware/base.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/facts/hardware/base.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/facts/hardware/base.py 2025-08-25 19:16:05.000000000 +0000 @@ -49,7 +49,7 @@ _fact_ids = set(['processor', 'processor_cores', 'processor_count', - # TODO: mounts isnt exactly hardware + # TODO: mounts isn't exactly hardware 'mounts', 'devices']) # type: t.Set[str] _fact_class = Hardware diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/facts/other/facter.py ansible-core-2.19.1/lib/ansible/module_utils/facts/other/facter.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/facts/other/facter.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/facts/other/facter.py 2025-08-25 19:16:05.000000000 +0000 @@ -61,7 +61,7 @@ return out def collect(self, module=None, collected_facts=None): - # Note that this mirrors previous facter behavior, where there isnt + # Note that this mirrors previous facter behavior, where there isn't # a 'ansible_facter' key in the main fact dict, but instead, 'facter_whatever' # items are added to the main dict. facter_dict = {} diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/facts/system/distribution.py ansible-core-2.19.1/lib/ansible/module_utils/facts/system/distribution.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/facts/system/distribution.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/facts/system/distribution.py 2025-08-25 19:16:05.000000000 +0000 @@ -100,7 +100,7 @@ return get_file_content(path) def _get_dist_file_content(self, path, allow_empty=False): - # cant find that dist file or it is incorrectly empty + # can't find that dist file, or it is incorrectly empty if not _file_exists(path, allow_empty=allow_empty): return False, None @@ -585,7 +585,7 @@ distribution_facts.update(dist_file_facts) distro = distribution_facts['distribution'] - # look for a os family alias for the 'distribution', if there isnt one, use 'distribution' + # look for an os family alias for the 'distribution', if there isn't one, use 'distribution' distribution_facts['os_family'] = self.OS_FAMILY.get(distro, None) or distro return distribution_facts diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 2025-08-25 19:16:05.000000000 +0000 @@ -5,7 +5,7 @@ <# .SYNOPSIS Compiles one or more C# scripts similar to Add-Type. This exposes - more configuration options that are useable within Ansible and it + more configuration options that are usable within Ansible and it also allows multiple C# sources to be compiled together. .PARAMETER References diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 2025-08-25 19:16:05.000000000 +0000 @@ -21,7 +21,7 @@ return $string } -# used by Convert-DictToSnakeCase to covert list entries from camelCase +# used by Convert-DictToSnakeCase to convert list entries from camelCase # to snake_case Function Convert-ListToSnakeCase($list) { $snake_list = [System.Collections.ArrayList]@() diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 2025-08-25 19:16:05.000000000 +0000 @@ -6,7 +6,7 @@ Function Get-ExecutablePath { <# .SYNOPSIS - Get's the full path to an executable, will search the directory specified or ones in the PATH env var. + Gets the full path to an executable, will search the directory specified or ones in the PATH env var. .PARAMETER executable [String]The executable to search for. diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 2025-08-25 19:16:05.000000000 +0000 @@ -17,7 +17,7 @@ The protocol method to use, if omitted, will use the default value for the URI protocol specified. .PARAMETER FollowRedirects - Whether to follow redirect reponses. This is only valid when using a HTTP URI. + Whether to follow redirect responses. This is only valid when using a HTTP URI. all - Will follow all redirects none - Will follow no redirects safe - Will only follow redirects when GET or HEAD is used as the Method diff -Nru ansible-core-2.19.0~beta6/lib/ansible/module_utils/urls.py ansible-core-2.19.1/lib/ansible/module_utils/urls.py --- ansible-core-2.19.0~beta6/lib/ansible/module_utils/urls.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/module_utils/urls.py 2025-08-25 19:16:05.000000000 +0000 @@ -1155,7 +1155,7 @@ def url_redirect_argument_spec(): """ - Creates an addition arugment spec to `url_argument_spec` + Creates an addition argument spec to `url_argument_spec` for `follow_redirects` argument """ return dict( diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/apt.py ansible-core-2.19.1/lib/ansible/modules/apt.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/apt.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/apt.py 2025-08-25 19:16:05.000000000 +0000 @@ -855,6 +855,7 @@ allow_downgrade, allow_change_held_packages, dpkg_options, + lock_timeout, ): changed = False deps_to_install = [] @@ -903,13 +904,14 @@ # install the deps through apt retvals = {} if deps_to_install: + install_dpkg_options = f"{expand_dpkg_options(dpkg_options)} -o DPkg::Lock::Timeout={lock_timeout}" (success, retvals) = install(m=m, pkgspec=deps_to_install, cache=cache, install_recommends=install_recommends, fail_on_autoremove=fail_on_autoremove, allow_unauthenticated=allow_unauthenticated, allow_downgrade=allow_downgrade, allow_change_held_packages=allow_change_held_packages, - dpkg_options=expand_dpkg_options(dpkg_options)) + dpkg_options=install_dpkg_options) if not success: m.fail_json(**retvals) changed = retvals.get('changed', False) @@ -1269,7 +1271,7 @@ p = module.params install_recommends = p['install_recommends'] - dpkg_options = expand_dpkg_options(p['dpkg_options']) + dpkg_options = f"{expand_dpkg_options(p['dpkg_options'])} -o DPkg::Lock::Timeout={p['lock_timeout']}" if not HAS_PYTHON_APT: # This interpreter can't see the apt Python library- we'll do the following to try and fix that: @@ -1470,7 +1472,11 @@ allow_unauthenticated=allow_unauthenticated, allow_change_held_packages=allow_change_held_packages, allow_downgrade=allow_downgrade, - force=force_yes, fail_on_autoremove=fail_on_autoremove, dpkg_options=p['dpkg_options']) + force=force_yes, + fail_on_autoremove=fail_on_autoremove, + dpkg_options=p['dpkg_options'], + lock_timeout=p['lock_timeout'] + ) unfiltered_packages = p['package'] or () packages = [package.strip() for package in unfiltered_packages if package != '*'] diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/assemble.py ansible-core-2.19.1/lib/ansible/modules/assemble.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/assemble.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/assemble.py 2025-08-25 19:16:05.000000000 +0000 @@ -80,7 +80,7 @@ bypass_host_loop: support: none check_mode: - support: none + support: full diff_mode: support: full platform: @@ -212,6 +212,7 @@ decrypt=dict(type='bool', default=True), ), add_file_common_args=True, + supports_check_mode=True, ) changed = False @@ -266,12 +267,13 @@ if backup and dest_hash is not None: result['backup_file'] = module.backup_local(dest) - module.atomic_move(path, dest, unsafe_writes=module.params['unsafe_writes']) + if not module.check_mode: + module.atomic_move(path, dest, unsafe_writes=module.params['unsafe_writes']) changed = True cleanup(module, path, result) - # handle file permissions + # handle file permissions (check mode aware) file_args = module.load_file_common_arguments(module.params) result['changed'] = module.set_fs_attributes_if_different(file_args, changed) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/dnf.py ansible-core-2.19.1/lib/ansible/modules/dnf.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/dnf.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/dnf.py 2025-08-25 19:16:05.000000000 +0000 @@ -208,6 +208,8 @@ packages to install (because dependencies between the downgraded package and others can cause changes to the packages which were in the earlier transaction). + - Since this feature is not provided by C(dnf) itself but by M(ansible.builtin.dnf) module, + using this in combination with wildcard characters in O(name) may result in an unexpected results. type: bool default: "no" version_added: "2.7" @@ -708,72 +710,56 @@ self.module.exit_json(msg="", results=results) def _is_installed(self, pkg): - installed_query = dnf.subject.Subject(pkg).get_best_query(sack=self.base.sack).installed() - if dnf.util.is_glob_pattern(pkg): - available_query = dnf.subject.Subject(pkg).get_best_query(sack=self.base.sack).available() - return not ( - {p.name for p in available_query} - {p.name for p in installed_query} - ) - else: - return bool(installed_query) + return bool(dnf.subject.Subject(pkg).get_best_query(sack=self.base.sack).installed()) def _is_newer_version_installed(self, pkg_spec): + # expects a versioned package spec try: if isinstance(pkg_spec, dnf.package.Package): installed = sorted(self.base.sack.query().installed().filter(name=pkg_spec.name, arch=pkg_spec.arch))[-1] return installed.evr_gt(pkg_spec) else: - available = dnf.subject.Subject(pkg_spec).get_best_query(sack=self.base.sack).available() - installed = self.base.sack.query().installed().filter(name=available[0].name) - for arch in sorted(set(p.arch for p in installed)): # select only from already-installed arches for this case - installed_pkg = sorted(installed.filter(arch=arch))[-1] - try: - available_pkg = sorted(available.filter(arch=arch))[-1] - except IndexError: - continue # nothing currently available for this arch; keep going - if installed_pkg.evr_gt(available_pkg): - return True - return False + solution = dnf.subject.Subject(pkg_spec).get_best_solution(self.base.sack) + q = solution["query"] + if not q or not solution['nevra'] or solution['nevra'].has_just_name(): + return False + installed = self.base.sack.query().installed().filter(name=solution['nevra'].name) + if not installed: + return False + return installed[0].evr_gt(q[0]) except IndexError: return False def _mark_package_install(self, pkg_spec, upgrade=False): """Mark the package for install.""" - is_newer_version_installed = self._is_newer_version_installed(pkg_spec) - is_installed = self._is_installed(pkg_spec) msg = '' try: - if is_newer_version_installed: + if dnf.util.is_glob_pattern(pkg_spec): + # Special case for package specs that contain glob characters. + # For these we skip `is_installed` and `is_newer_version_installed` tests that allow for the + # allow_downgrade feature and pass the package specs to dnf. + # Since allow_downgrade is not available in dnf and while it is relatively easy to implement it for + # package specs that evaluate to a single package, trying to mimic what would the dnf machinery do + # for glob package specs and then filtering those for allow_downgrade appears to always + # result in naive/inferior solution. + # NOTE this has historically never worked even before https://github.com/ansible/ansible/pull/82725 + # where our (buggy) custom code ignored wildcards for the installed checks. + # TODO reasearch how feasible it is to implement the above + if upgrade: + # for upgrade we pass the spec to both upgrade and install, to satisfy both available and installed + # packages evaluated from the glob spec + try: + self.base.upgrade(pkg_spec) + except dnf.exceptions.PackagesNotInstalledError: + pass + self.base.install(pkg_spec, strict=self.base.conf.strict) + elif self._is_newer_version_installed(pkg_spec): if self.allow_downgrade: - # dnf only does allow_downgrade, we have to handle this ourselves - # because it allows a possibility for non-idempotent transactions - # on a system's package set (pending the yum repo has many old - # NVRs indexed) - if upgrade: - if is_installed: # Case 1 - # TODO: Is this case reachable? - # - # _is_installed() demands a name (*not* NVR) or else is always False - # (wildcards are treated literally). - # - # Meanwhile, _is_newer_version_installed() demands something versioned - # or else is always false. - # - # I fail to see how they can both be true at the same time for any - # given pkg_spec. -re - self.base.upgrade(pkg_spec) - else: # Case 2 - self.base.install(pkg_spec, strict=self.base.conf.strict) - else: # Case 3 - self.base.install(pkg_spec, strict=self.base.conf.strict) - else: # Case 4, Nothing to do, report back - pass - elif is_installed: # A potentially older (or same) version is installed - if upgrade: # Case 5 + self.base.install(pkg_spec, strict=self.base.conf.strict) + elif self._is_installed(pkg_spec): + if upgrade: self.base.upgrade(pkg_spec) - else: # Case 6, Nothing to do, report back - pass - else: # Case 7, The package is not installed, simply install it + else: self.base.install(pkg_spec, strict=self.base.conf.strict) except dnf.exceptions.MarkingError as e: msg = "No package {0} available.".format(pkg_spec) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/dnf5.py ansible-core-2.19.1/lib/ansible/modules/dnf5.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/dnf5.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/dnf5.py 2025-08-25 19:16:05.000000000 +0000 @@ -178,6 +178,8 @@ packages to install (because dependencies between the downgraded package and others can cause changes to the packages which were in the earlier transaction). + - Since this feature is not provided by C(dnf5) itself but by M(ansible.builtin.dnf5) module, + using this in combination with wildcard characters in O(name) may result in an unexpected results. type: bool default: "no" install_repoquery: @@ -368,7 +370,7 @@ LIBDNF5_ERRORS = RuntimeError -def is_installed(base, spec): +def get_resolve_spec_settings(): settings = libdnf5.base.ResolveSpecSettings() try: settings.set_group_with_name(True) @@ -394,47 +396,34 @@ settings.group_with_name = True settings.with_binaries = False settings.with_provides = False + return settings + + +def is_installed(base, spec): + settings = get_resolve_spec_settings() installed_query = libdnf5.rpm.PackageQuery(base) installed_query.filter_installed() match, nevra = installed_query.resolve_pkg_spec(spec, settings, True) - - # FIXME use `is_glob_pattern` function when available: - # https://github.com/rpm-software-management/dnf5/issues/1563 - glob_patterns = set("*[?") - if any(set(char) & glob_patterns for char in spec): - available_query = libdnf5.rpm.PackageQuery(base) - available_query.filter_available() - available_query.resolve_pkg_spec(spec, settings, True) - - return not ( - {p.get_name() for p in available_query} - {p.get_name() for p in installed_query} - ) - else: - return match + return match def is_newer_version_installed(base, spec): - # FIXME investigate whether this function can be replaced by dnf5's allow_downgrade option + # expects a versioned package spec if "/" in spec: spec = spec.split("/")[-1] if spec.endswith(".rpm"): spec = spec[:-4] - try: - spec_nevra = next(iter(libdnf5.rpm.Nevra.parse(spec))) - except LIBDNF5_ERRORS: - return False - except StopIteration: - return False - - spec_version = spec_nevra.get_version() - if not spec_version: + settings = get_resolve_spec_settings() + match, spec_nevra = libdnf5.rpm.PackageQuery(base).resolve_pkg_spec(spec, settings, True) + if not match or spec_nevra.has_just_name(): return False + spec_name = spec_nevra.get_name() installed = libdnf5.rpm.PackageQuery(base) installed.filter_installed() - installed.filter_name([spec_nevra.get_name()]) + installed.filter_name([spec_name]) installed.filter_latest_evr() try: installed_package = list(installed)[-1] @@ -442,8 +431,8 @@ return False target = libdnf5.rpm.PackageQuery(base) - target.filter_name([spec_nevra.get_name()]) - target.filter_version([spec_version]) + target.filter_name([spec_name]) + target.filter_version([spec_nevra.get_version()]) spec_release = spec_nevra.get_release() if spec_release: target.filter_release([spec_release]) @@ -725,8 +714,26 @@ goal.add_rpm_upgrade(settings) elif self.state in {"installed", "present", "latest"}: upgrade = self.state == "latest" + # FIXME use `is_glob_pattern` function when available: + # https://github.com/rpm-software-management/dnf5/issues/1563 + glob_patterns = set("*[?") for spec in self.names: - if is_newer_version_installed(base, spec): + if any(set(char) & glob_patterns for char in spec): + # Special case for package specs that contain glob characters. + # For these we skip `is_installed` and `is_newer_version_installed` tests that allow for the + # allow_downgrade feature and pass the package specs to dnf. + # Since allow_downgrade is not available in dnf and while it is relatively easy to implement it for + # package specs that evaluate to a single package, trying to mimic what would the dnf machinery do + # for glob package specs and then filtering those for allow_downgrade appears to always + # result in naive/inferior solution. + # TODO reasearch how feasible it is to implement the above + if upgrade: + # for upgrade we pass the spec to both upgrade and install, to satisfy both available and installed + # packages evaluated from the glob spec + goal.add_upgrade(spec, settings) + if not self.update_only: + goal.add_install(spec, settings) + elif is_newer_version_installed(base, spec): if self.allow_downgrade: goal.add_install(spec, settings) elif is_installed(base, spec): diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/expect.py ansible-core-2.19.1/lib/ansible/modules/expect.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/expect.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/expect.py 2025-08-25 19:16:05.000000000 +0000 @@ -218,7 +218,7 @@ rc=0 ) - startd = datetime.datetime.now() + start_date = datetime.datetime.now() try: try: @@ -246,8 +246,8 @@ except pexpect.ExceptionPexpect as e: module.fail_json(msg='%s' % to_native(e)) - endd = datetime.datetime.now() - delta = endd - startd + end_date = datetime.datetime.now() + delta = end_date - start_date if b_out is None: b_out = b'' @@ -256,8 +256,8 @@ cmd=args, stdout=to_native(b_out).rstrip('\r\n'), rc=rc, - start=str(startd), - end=str(endd), + start=str(start_date), + end=str(end_date), delta=str(delta), changed=True, ) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/hostname.py ansible-core-2.19.1/lib/ansible/modules/hostname.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/hostname.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/hostname.py 2025-08-25 19:16:05.000000000 +0000 @@ -608,8 +608,8 @@ self.use = module.params['use'] if self.use is not None: - strat = globals()['%sStrategy' % STRATS[self.use]] - self.strategy = strat(module) + strategy = globals()['%sStrategy' % STRATS[self.use]] + self.strategy = strategy(module) elif platform.system() == 'Linux' and ServiceMgrFactCollector.is_systemd_managed(module): # This is Linux and systemd is active self.strategy = SystemdStrategy(module) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/meta.py ansible-core-2.19.1/lib/ansible/modules/meta.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/meta.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/meta.py 2025-08-25 19:16:05.000000000 +0000 @@ -33,6 +33,7 @@ - V(clear_facts) (added in Ansible 2.1) causes the gathered facts for the hosts specified in the play's list of hosts to be cleared, including the fact cache. - V(clear_host_errors) (added in Ansible 2.1) clears the failed state (if any) from hosts specified in the play's list of hosts. + This will make them available for targetting in subsequent plays, but not continue execution in the current play. - V(end_play) (added in Ansible 2.2) causes the play to end without failing the host(s). Note that this affects all hosts. - V(reset_connection) (added in Ansible 2.3) interrupts a persistent connection (i.e. ssh + control persist) - V(end_host) (added in Ansible 2.8) is a per-host variation of V(end_play). Causes the play to end for the current host without failing it. @@ -108,7 +109,7 @@ - name: Clear gathered facts from all currently targeted hosts ansible.builtin.meta: clear_facts -# Example showing how to continue using a failed target +# Example showing how to continue using a failed target, for the next play - name: Bring host back to play after failure ansible.builtin.copy: src: file diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/pip.py ansible-core-2.19.1/lib/ansible/modules/pip.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/pip.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/pip.py 2025-08-25 19:16:05.000000000 +0000 @@ -60,7 +60,7 @@ virtualenv_python: description: - The Python executable used for creating the virtual environment. - For example V(python3.12), V(python2.7). When not specified, the + For example V(python3.13). When not specified, the Python version used to run the ansible module is used. This parameter should not be used when O(virtualenv_command) is using V(pyvenv) or the C(-m venv) module. @@ -93,8 +93,8 @@ description: - The explicit executable or pathname for the C(pip) executable, if different from the Ansible Python interpreter. For - example V(pip3.3), if there are both Python 2.7 and 3.3 installations - in the system and you want to run pip for the Python 3.3 installation. + example V(pip3.13), if there are multiple Python installations + in the system and you want to run pip for the Python 3.13 installation. - Mutually exclusive with O(virtualenv) (added in 2.1). - Does not affect the Ansible Python interpreter. - The C(setuptools) package must be installed for both the Ansible Python interpreter @@ -134,7 +134,7 @@ the virtualenv needs to be created. - Although it executes using the Ansible Python interpreter, the pip module shells out to run the actual pip command, so it can use any pip version you specify with O(executable). - By default, it uses the pip version for the Ansible Python interpreter. For example, pip3 on python 3, and pip2 or pip on python 2. + By default, it uses the pip version for the Ansible Python interpreter. - The interpreter used by Ansible (see R(ansible_python_interpreter, ansible_python_interpreter)) requires the setuptools package, regardless of the version of pip set with @@ -197,11 +197,11 @@ virtualenv: /my_app/venv virtualenv_site_packages: yes -- name: Install bottle into the specified (virtualenv), using Python 2.7 +- name: Install bottle into the specified (virtualenv), using Python 3.13 ansible.builtin.pip: name: bottle virtualenv: /my_app/venv - virtualenv_command: virtualenv-2.7 + virtualenv_command: virtualenv-3.13 - name: Install bottle within a user home directory ansible.builtin.pip: @@ -227,10 +227,10 @@ requirements: /my_app/requirements.txt extra_args: "--no-index --find-links=file:///my_downloaded_packages_dir" -- name: Install bottle for Python 3.3 specifically, using the 'pip3.3' executable +- name: Install bottle for Python 3.13 specifically, using the 'pip3.13' executable ansible.builtin.pip: name: bottle - executable: pip3.3 + executable: pip3.13 - name: Install bottle, forcing reinstallation if it's already installed ansible.builtin.pip: @@ -460,9 +460,7 @@ candidate_pip_basenames = (executable,) elif executable is None and env is None and _have_pip_module(): # If no executable or virtualenv were specified, use the pip module for the current Python interpreter if available. - # Use of `__main__` is required to support Python 2.6 since support for executing packages with `runpy` was added in Python 2.7. - # Without it Python 2.6 gives the following error: pip is a package and cannot be directly executed - pip = [sys.executable, '-m', 'pip.__main__'] + pip = [sys.executable, '-m', 'pip'] if pip is None: if env is None: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/raw.py ansible-core-2.19.1/lib/ansible/modules/raw.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/raw.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/raw.py 2025-08-25 19:16:05.000000000 +0000 @@ -73,8 +73,8 @@ """ EXAMPLES = r""" -- name: Bootstrap a host without python2 installed - ansible.builtin.raw: dnf install -y python2 python2-dnf libselinux-python +- name: Bootstrap a host without Python installed + ansible.builtin.raw: dnf install -y python3 python3-libdnf - name: Run a command that uses non-posix shell-isms (in this example /bin/sh doesn't handle redirection and wildcards together but bash does) ansible.builtin.raw: cat < /tmp/*txt diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/service_facts.py ansible-core-2.19.1/lib/ansible/modules/service_facts.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/service_facts.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/service_facts.py 2025-08-25 19:16:05.000000000 +0000 @@ -232,7 +232,11 @@ if service_name == "*": continue service_state = line_data[1] - service_runlevels = all_services_runlevels[service_name] + try: + service_runlevels = all_services_runlevels[service_name] + except KeyError: + self.module.warn(f"Service {service_name} not found in the service list") + continue service_data = {"name": service_name, "runlevels": service_runlevels, "state": service_state, "source": "openrc"} services[service_name] = service_data diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/stat.py ansible-core-2.19.1/lib/ansible/modules/stat.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/stat.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/stat.py 2025-08-25 19:16:05.000000000 +0000 @@ -408,7 +408,7 @@ ('st_blksize', 'block_size'), ('st_rdev', 'device_type'), ('st_flags', 'flags'), - # Some Berkley based + # Some Berkeley based ('st_gen', 'generation'), ('st_birthtime', 'birthtime'), # RISCOS diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/systemd.py ansible-core-2.19.1/lib/ansible/modules/systemd.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/systemd.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/systemd.py 2025-08-25 19:16:05.000000000 +0000 @@ -34,7 +34,7 @@ choices: [ reloaded, restarted, started, stopped ] enabled: description: - - Whether the unit should start on boot. At least one of O(state) and O(enabled) are required. + - Whether the unit should start on boot. At least one of O(state) or O(enabled) are required. - If set, requires O(name). type: bool force: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/systemd_service.py ansible-core-2.19.1/lib/ansible/modules/systemd_service.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/systemd_service.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/systemd_service.py 2025-08-25 19:16:05.000000000 +0000 @@ -34,7 +34,7 @@ choices: [ reloaded, restarted, started, stopped ] enabled: description: - - Whether the unit should start on boot. At least one of O(state) and O(enabled) are required. + - Whether the unit should start on boot. At least one of O(state) or O(enabled) are required. - If set, requires O(name). type: bool force: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/modules/wait_for.py ansible-core-2.19.1/lib/ansible/modules/wait_for.py --- ansible-core-2.19.0~beta6/lib/ansible/modules/wait_for.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/modules/wait_for.py 2025-08-25 19:16:05.000000000 +0000 @@ -76,6 +76,8 @@ description: - Can be used to match a string in either a file or a socket connection. - Defaults to a multiline regex. + - When inspecting a system log file and a static string, remember that Ansible by default logs its own actions there; + see the notes and examples for information. type: str version_added: "1.4" exclude_hosts: @@ -105,13 +107,13 @@ platform: platforms: posix notes: - - The ability to use search_regex with a port connection was added in Ansible 1.7. - - Prior to Ansible 2.4, testing for the absence of a directory or UNIX socket did not work correctly. - - Prior to Ansible 2.4, testing for the presence of a file did not work correctly if the remote user did not have read access to that file. - Under some circumstances when using mandatory access control, a path may always be treated as being absent even if it exists, but can't be modified or created by the remote user either. - When waiting for a path, symbolic links will be followed. Many other modules that manipulate files do not follow symbolic links, so operations on the path using other modules may not work exactly as expected. + - When searching a static string within a system log file, it is important to account for potential self-matching against log entries + generated by the Ansible modules. To prevent this, add a regular expression construct into the search string. For example, to match + a literal string 'this thing', one could use a regular expression like 'this t[h]ing'. seealso: - module: ansible.builtin.wait_for_connection - module: ansible.windows.win_wait_for @@ -156,6 +158,11 @@ path: /tmp/foo search_regex: completed +- name: Wait until the string "tomcat up" is in syslog, use regex character set to avoid self match + ansible.builtin.wait_for: + path: /var/log/syslog + search_regex: 'tomcat [u]p' + - name: Wait until regex pattern matches in the file /tmp/foo and print the matched group ansible.builtin.wait_for: path: /tmp/foo diff -Nru ansible-core-2.19.0~beta6/lib/ansible/parsing/mod_args.py ansible-core-2.19.1/lib/ansible/parsing/mod_args.py --- ansible-core-2.19.0~beta6/lib/ansible/parsing/mod_args.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/parsing/mod_args.py 2025-08-25 19:16:05.000000000 +0000 @@ -20,17 +20,17 @@ import ansible.constants as C from ansible.errors import AnsibleParserError, AnsibleError, AnsibleAssertionError from ansible.module_utils._internal._datatag import AnsibleTagHelper -from ansible.module_utils.six import string_types from ansible.module_utils.common.sentinel import Sentinel from ansible.module_utils.common.text.converters import to_text from ansible.parsing.splitter import parse_kv, split_args from ansible.parsing.vault import EncryptedString from ansible.plugins.loader import module_loader, action_loader -from ansible._internal._templating._engine import TemplateEngine +from ansible._internal._templating import _jinja_bits +from ansible.utils.display import Display from ansible.utils.fqcn import add_internal_fqcns -# modules formated for user msg +# modules formatted for user msg _BUILTIN_RAW_PARAM_MODULES_SIMPLE = set([ 'include_vars', 'include_tasks', @@ -152,38 +152,43 @@ arguments can be fuzzy. Deal with all the forms. """ - additional_args = {} if additional_args is None else additional_args - # final args are the ones we'll eventually return, so first update # them with any additional args specified, which have lower priority # than those which may be parsed/normalized next final_args = dict() - if additional_args: - if isinstance(additional_args, (str, EncryptedString)): - # DTFIX5: should this be is_possibly_template? - if TemplateEngine().is_template(additional_args): - final_args['_variable_params'] = additional_args - else: - raise AnsibleParserError("Complex args containing variables cannot use bare variables (without Jinja2 delimiters), " - "and must use the full variable style ('{{var_name}}')") + + if additional_args is not Sentinel: + if isinstance(additional_args, str) and _jinja_bits.is_possibly_all_template(additional_args): + final_args['_variable_params'] = additional_args elif isinstance(additional_args, dict): final_args.update(additional_args) + elif additional_args is None: + Display().deprecated( + msg="Ignoring empty task `args` keyword.", + version="2.23", + help_text='A mapping or template which resolves to a mapping is required.', + obj=self._task_ds, + ) else: - raise AnsibleParserError('Complex args must be a dictionary or variable string ("{{var}}").') + raise AnsibleParserError( + message='The value of the task `args` keyword is invalid.', + help_text='A mapping or template which resolves to a mapping is required.', + obj=additional_args, + ) # how we normalize depends if we figured out what the module name is # yet. If we have already figured it out, it's a 'new style' invocation. # otherwise, it's not if action is not None: - args = self._normalize_new_style_args(thing, action) + args = self._normalize_new_style_args(thing, action, additional_args) else: (action, args) = self._normalize_old_style_args(thing) # this can occasionally happen, simplify if args and 'args' in args: tmp_args = args.pop('args') - if isinstance(tmp_args, string_types): + if isinstance(tmp_args, str): tmp_args = parse_kv(tmp_args) args.update(tmp_args) @@ -206,7 +211,7 @@ return (action, final_args) - def _normalize_new_style_args(self, thing, action): + def _normalize_new_style_args(self, thing, action, additional_args): """ deals with fuzziness in new style module invocations accepting key=value pairs and dictionaries, and returns @@ -222,11 +227,23 @@ if isinstance(thing, dict): # form is like: { xyz: { x: 2, y: 3 } } args = thing - elif isinstance(thing, string_types): + elif isinstance(thing, str): # form is like: copy: src=a dest=b check_raw = action in FREEFORM_ACTIONS args = parse_kv(thing, check_raw=check_raw) + args_keys = set(args) - {'_raw_params'} + + if args_keys and additional_args is not Sentinel: + kv_args = ', '.join(repr(arg) for arg in sorted(args_keys)) + + Display().deprecated( + msg=f"Merging legacy k=v args ({kv_args}) into task args.", + help_text="Include all task args in the task `args` mapping.", + version="2.23", + obj=thing, + ) elif isinstance(thing, EncryptedString): + # k=v parsing intentionally omitted args = dict(_raw_params=thing) elif thing is None: # this can happen with modules which take no params, like ping: @@ -253,6 +270,7 @@ if isinstance(thing, dict): # form is like: action: { module: 'copy', src: 'a', dest: 'b' } + Display().deprecated("Using a mapping for `action` is deprecated.", version='2.23', help_text='Use a string value for `action`.', obj=thing) thing = thing.copy() if 'module' in thing: action, module_args = self._split_module_string(thing['module']) @@ -261,7 +279,7 @@ args.update(parse_kv(module_args, check_raw=check_raw)) del args['module'] - elif isinstance(thing, string_types): + elif isinstance(thing, str): # form is like: action: copy src=a dest=b (action, args) = self._split_module_string(thing) check_raw = action in FREEFORM_ACTIONS @@ -287,7 +305,7 @@ # This is the standard YAML form for command-type modules. We grab # the args and pass them in as additional arguments, which can/will # be overwritten via dict updates from the other arg sources below - additional_args = self._task_ds.get('args', dict()) + additional_args = self._task_ds.get('args', Sentinel) # We can have one of action, local_action, or module specified # action diff -Nru ansible-core-2.19.0~beta6/lib/ansible/parsing/vault/__init__.py ansible-core-2.19.1/lib/ansible/parsing/vault/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/parsing/vault/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/parsing/vault/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -570,8 +570,8 @@ return match_encrypt_vault_id_secret(secrets, encrypt_vault_id=encrypt_vault_id) - # Find the best/first secret from secrets since we didnt specify otherwise - # ie, consider all of the available secrets as matches + # Find the best/first secret from secrets since we didn't specify otherwise + # ie, consider all the available secrets as matches _vault_id_matchers = [_vault_id for _vault_id, dummy in secrets] best_secret = match_best_secret(secrets, _vault_id_matchers) @@ -1413,7 +1413,7 @@ 'ljust', 'lower', 'lstrip', - 'maketrans', # static, but implemented for simplicty/consistency + 'maketrans', # static, but implemented for simplicity/consistency 'partition', 'removeprefix', 'removesuffix', diff -Nru ansible-core-2.19.0~beta6/lib/ansible/playbook/base.py ansible-core-2.19.1/lib/ansible/playbook/base.py --- ansible-core-2.19.0~beta6/lib/ansible/playbook/base.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/playbook/base.py 2025-08-25 19:16:05.000000000 +0000 @@ -221,8 +221,6 @@ def validate(self, all_vars=None): """ validation that is done at parse time, not load time """ - all_vars = {} if all_vars is None else all_vars - if not self._validated: # walk all fields in the object for (name, attribute) in self.fattributes.items(): diff -Nru ansible-core-2.19.0~beta6/lib/ansible/playbook/helpers.py ansible-core-2.19.1/lib/ansible/playbook/helpers.py --- ansible-core-2.19.0~beta6/lib/ansible/playbook/helpers.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/playbook/helpers.py 2025-08-25 19:16:05.000000000 +0000 @@ -122,7 +122,7 @@ except AnsibleParserError as ex: # if the raises exception was created with obj=ds args, then it includes the detail # so we dont need to add it so we can just re raise. - if ex.obj: + if ex.obj is not None: raise # But if it wasn't, we can add the yaml object now to get more detail # DTFIX-FUTURE: this *should* be unnecessary- check code coverage. @@ -169,6 +169,7 @@ if not isinstance(parent_include, TaskInclude): parent_include = parent_include._parent continue + parent_include.post_validate(templar=templar) parent_include_dir = os.path.dirname(parent_include.args.get('_raw_params')) if cumulative_path is None: cumulative_path = parent_include_dir diff -Nru ansible-core-2.19.0~beta6/lib/ansible/playbook/playbook_include.py ansible-core-2.19.1/lib/ansible/playbook/playbook_include.py --- ansible-core-2.19.0~beta6/lib/ansible/playbook/playbook_include.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/playbook/playbook_include.py 2025-08-25 19:16:05.000000000 +0000 @@ -19,12 +19,7 @@ import os -import ansible.constants as C -from ansible.errors import AnsibleParserError, AnsibleAssertionError from ansible.module_utils.common.text.converters import to_bytes -from ansible.module_utils._internal._datatag import AnsibleTagHelper -from ansible.module_utils.six import string_types -from ansible.parsing.splitter import split_args from ansible.playbook.attribute import NonInheritableFieldAttribute from ansible.playbook.base import Base from ansible.playbook.conditional import Conditional @@ -32,15 +27,27 @@ from ansible.utils.collection_loader import AnsibleCollectionConfig from ansible.utils.collection_loader._collection_finder import _get_collection_name_from_path, _get_collection_playbook_path from ansible._internal._templating._engine import TemplateEngine -from ansible.utils.display import Display - -display = Display() +from ansible.errors import AnsibleError +from ansible import constants as C class PlaybookInclude(Base, Conditional, Taggable): - import_playbook = NonInheritableFieldAttribute(isa='string') - vars_val = NonInheritableFieldAttribute(isa='dict', default=dict, alias='vars') + import_playbook = NonInheritableFieldAttribute(isa='string', required=True) + + _post_validate_object = True # manually post_validate to get free arg validation/coercion + + def preprocess_data(self, ds): + keys = {action for action in C._ACTION_IMPORT_PLAYBOOK if action in ds} + + if len(keys) != 1: + raise AnsibleError(f'Found conflicting import_playbook actions: {", ".join(sorted(keys))}') + + key = next(iter(keys)) + + ds['import_playbook'] = ds.pop(key) + + return ds @staticmethod def load(data, basedir, variable_manager=None, loader=None): @@ -62,18 +69,22 @@ new_obj = super(PlaybookInclude, self).load_data(ds, variable_manager, loader) all_vars = self.vars.copy() + if variable_manager: all_vars |= variable_manager.get_vars() templar = TemplateEngine(loader=loader, variables=all_vars) + new_obj.post_validate(templar) + # then we use the object to load a Playbook pb = Playbook(loader=loader) - file_name = templar.template(new_obj.import_playbook) + file_name = new_obj.import_playbook # check for FQCN resource = _get_collection_playbook_path(file_name) + if resource is not None: playbook = resource[1] playbook_collection = resource[2] @@ -92,6 +103,7 @@ else: # it is NOT a collection playbook, setup adjacent paths AnsibleCollectionConfig.playbook_paths.append(os.path.dirname(os.path.abspath(to_bytes(playbook, errors='surrogate_or_strict')))) + # broken, see: https://github.com/ansible/ansible/issues/85357 pb._load_playbook_data(file_name=playbook, variable_manager=variable_manager, vars=self.vars.copy()) @@ -120,49 +132,3 @@ task_block._when = new_obj.when[:] + task_block.when[:] return pb - - def preprocess_data(self, ds): - """ - Reorganizes the data for a PlaybookInclude datastructure to line - up with what we expect the proper attributes to be - """ - - if not isinstance(ds, dict): - raise AnsibleAssertionError('ds (%s) should be a dict but was a %s' % (ds, type(ds))) - - # the new, cleaned datastructure, which will have legacy items reduced to a standard structure suitable for the - # attributes of the task class; copy any tagged data to preserve things like origin - new_ds = AnsibleTagHelper.tag_copy(ds, {}) - - for (k, v) in ds.items(): - if k in C._ACTION_IMPORT_PLAYBOOK: - self._preprocess_import(ds, new_ds, k, v) - else: - # some basic error checking, to make sure vars are properly - # formatted and do not conflict with k=v parameters - if k == 'vars': - if 'vars' in new_ds: - raise AnsibleParserError("import_playbook parameters cannot be mixed with 'vars' entries for import statements", obj=ds) - elif not isinstance(v, dict): - raise AnsibleParserError("vars for import_playbook statements must be specified as a dictionary", obj=ds) - new_ds[k] = v - - return super(PlaybookInclude, self).preprocess_data(new_ds) - - def _preprocess_import(self, ds, new_ds, k, v): - """ - Splits the playbook import line up into filename and parameters - """ - if v is None: - raise AnsibleParserError("playbook import parameter is missing", obj=ds) - elif not isinstance(v, string_types): - raise AnsibleParserError("playbook import parameter must be a string indicating a file path, got %s instead" % type(v), obj=ds) - - # The import_playbook line must include at least one item, which is the filename - # to import. Anything after that should be regarded as a parameter to the import - items = split_args(v) - if len(items) == 0: - raise AnsibleParserError("import_playbook statements must specify the file name to import", obj=ds) - - # DTFIX3: investigate this as a possible "problematic strip" - new_ds['import_playbook'] = AnsibleTagHelper.tag_copy(v, items[0].strip()) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/playbook/role/__init__.py ansible-core-2.19.1/lib/ansible/playbook/role/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/playbook/role/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/playbook/role/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -18,6 +18,7 @@ from __future__ import annotations import os +import typing as _t from collections.abc import Container, Mapping, Set, Sequence from types import MappingProxyType @@ -39,6 +40,16 @@ from ansible.utils.path import is_subpath from ansible.utils.vars import combine_vars +# NOTE: This import is only needed for the type-checking in __init__. While there's an alternative +# available by using forward references this seems not to work well with commonly used IDEs. +# Therefore the TYPE_CHECKING hack seems to be a more universal approach, even if not being very elegant. +# References: +# * https://stackoverflow.com/q/39740632/199513 +# * https://peps.python.org/pep-0484/#forward-references +if _t.TYPE_CHECKING: + from ansible.playbook.block import Block + from ansible.playbook.play import Play + __all__ = ['Role', 'hash_params'] # TODO: this should be a utility function, but can't be a member of @@ -97,13 +108,19 @@ class Role(Base, Conditional, Taggable, CollectionSearch, Delegatable): - def __init__(self, play=None, from_files=None, from_include=False, validate=True, public=None, static=True): - self._role_name = None - self._role_path = None - self._role_collection = None - self._role_params = dict() + def __init__(self, + play: Play = None, + from_files: dict[str, list[str]] = None, + from_include: bool = False, + validate: bool = True, + public: bool = None, + static: bool = True) -> None: + self._role_name: str = None + self._role_path: str = None + self._role_collection: str = None + self._role_params: dict[str, dict[str, str]] = dict() self._loader = None - self.static = static + self.static: bool = static # includes (static=false) default to private, while imports (static=true) default to public # but both can be overridden by global config if set @@ -116,26 +133,26 @@ else: self.public = public - self._metadata = RoleMetadata() - self._play = play - self._parents = [] - self._dependencies = [] - self._all_dependencies = None - self._task_blocks = [] - self._handler_blocks = [] - self._compiled_handler_blocks = None - self._default_vars = dict() - self._role_vars = dict() - self._had_task_run = dict() - self._completed = dict() - self._should_validate = validate + self._metadata: RoleMetadata = RoleMetadata() + self._play: Play = play + self._parents: list[Role] = [] + self._dependencies: list[Role] = [] + self._all_dependencies: list[Role] | None = None + self._task_blocks: list[Block] = [] + self._handler_blocks: list[Block] = [] + self._compiled_handler_blocks: list[Block] | None = None + self._default_vars: dict[str, str] | None = dict() + self._role_vars: dict[str, str] | None = dict() + self._had_task_run: dict[str, bool] = dict() + self._completed: dict[str, bool] = dict() + self._should_validate: bool = validate if from_files is None: from_files = {} - self._from_files = from_files + self._from_files: dict[str, list[str]] = from_files # Indicates whether this role was included via include/import_role - self.from_include = from_include + self.from_include: bool = from_include self._hash = None @@ -357,8 +374,8 @@ task_name = task_name + ' - ' + argument_spec['short_description'] return { - 'action': { - 'module': 'ansible.builtin.validate_argument_spec', + 'action': 'ansible.builtin.validate_argument_spec', + 'args': { # Pass only the 'options' portion of the arg spec to the module. 'argument_spec': argument_spec.get('options', {}), 'provided_arguments': self._role_params, diff -Nru ansible-core-2.19.0~beta6/lib/ansible/playbook/taggable.py ansible-core-2.19.1/lib/ansible/playbook/taggable.py --- ansible-core-2.19.0~beta6/lib/ansible/playbook/taggable.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/playbook/taggable.py 2025-08-25 19:16:05.000000000 +0000 @@ -20,7 +20,6 @@ import typing as t from ansible.errors import AnsibleError -from ansible.module_utils.six import string_types from ansible.module_utils.common.sentinel import Sentinel from ansible.module_utils._internal._datatag import AnsibleTagHelper from ansible.playbook.attribute import FieldAttribute @@ -40,7 +39,7 @@ class Taggable: untagged = frozenset(['untagged']) - tags = FieldAttribute(isa='list', default=list, listof=(string_types, int), extend=True) + tags = FieldAttribute(isa='list', default=list, listof=(str, int), extend=True) def _load_tags(self, attr, ds): if isinstance(ds, list): diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/__init__.py ansible-core-2.19.1/lib/ansible/plugins/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -79,6 +79,7 @@ def __init__(self): self._options = {} + self._origins = {} self._defs = None @property @@ -98,11 +99,16 @@ return bool(possible_fqcns.intersection(set(self.ansible_aliases))) def get_option_and_origin(self, option, hostvars=None): - try: - option_value, origin = C.config.get_config_value_and_origin(option, plugin_type=self.plugin_type, plugin_name=self._load_name, variables=hostvars) - except AnsibleError as e: - raise KeyError(str(e)) - return option_value, origin + if option not in self._options: + try: + # some plugins don't use set_option(s) and cannot use direct settings, so this populates the local copy for them + self._options[option], self._origins[option] = C.config.get_config_value_and_origin(option, plugin_type=self.plugin_type, + plugin_name=self._load_name, variables=hostvars) + except AnsibleError as e: + # callers expect key error on missing + raise KeyError() from e + + return self._options[option], self._origins[option] @functools.cached_property def __plugin_info(self): @@ -113,11 +119,10 @@ return _plugin_info.get_plugin_info(self) def get_option(self, option, hostvars=None): - if option not in self._options: - option_value, dummy = self.get_option_and_origin(option, hostvars=hostvars) - self.set_option(option, option_value) - return self._options.get(option) + # let it populate _options + self.get_option_and_origin(option, hostvars=hostvars) + return self._options[option] def get_options(self, hostvars=None): options = {} @@ -127,6 +132,7 @@ def set_option(self, option, value): self._options[option] = C.config.get_config_value(option, plugin_type=self.plugin_type, plugin_name=self._load_name, direct={option: value}) + self._origins[option] = 'Direct' _display._report_config_warnings(self.__plugin_info) def set_options(self, task_keys=None, var_options=None, direct=None): @@ -137,12 +143,14 @@ :arg var_options: Dict with either 'connection variables' :arg direct: Dict with 'direct assignment' """ - self._options = C.config.get_plugin_options(self.plugin_type, self._load_name, keys=task_keys, variables=var_options, direct=direct) + self._options, self._origins = C.config.get_plugin_options_and_origins(self.plugin_type, self._load_name, keys=task_keys, + variables=var_options, direct=direct) # allow extras/wildcards from vars that are not directly consumed in configuration # this is needed to support things like winrm that can have extended protocol options we don't directly handle if self.allow_extras and var_options and '_extras' in var_options: # these are largely unvalidated passthroughs, either plugin or underlying API will validate + # TODO: deprecate and remove, most plugins that needed this don't use this facility anymore self._options['_extras'] = var_options['_extras'] _display._report_config_warnings(self.__plugin_info) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/action/__init__.py ansible-core-2.19.1/lib/ansible/plugins/action/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/action/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/action/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -103,7 +103,7 @@ self._display = display @abstractmethod - def run(self, tmp=None, task_vars=None): + def run(self, tmp: str | None = None, task_vars: dict[str, t.Any] | None = None) -> dict[str, t.Any]: """ Action Plugins should implement this method to perform their tasks. Everything else in this base class is a helper method for the action plugin to do that. @@ -120,7 +120,7 @@ * Module parameters. These are stored in self._task.args """ # does not default to {'changed': False, 'failed': False}, as it used to break async - result = {} + result: dict[str, t.Any] = {} if tmp is not None: display.warning('ActionModule.run() no longer honors the tmp parameter. Action' diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/action/assemble.py ansible-core-2.19.1/lib/ansible/plugins/action/assemble.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/action/assemble.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/action/assemble.py 2025-08-25 19:16:05.000000000 +0000 @@ -81,9 +81,10 @@ def run(self, tmp=None, task_vars=None): - self._supports_check_mode = False + self._supports_check_mode = True super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect if task_vars is None: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/action/assert.py ansible-core-2.19.1/lib/ansible/plugins/action/assert.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/action/assert.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/action/assert.py 2025-08-25 19:16:05.000000000 +0000 @@ -72,12 +72,12 @@ fail_msg = new_module_args['fail_msg'] success_msg = new_module_args['success_msg'] quiet = new_module_args['quiet'] - thats = new_module_args['that'] + that_list = new_module_args['that'] if not quiet: result['_ansible_verbose_always'] = True - for that in thats: + for that in that_list: test_result = self._templar.evaluate_conditional(conditional=that) if not test_result: result['failed'] = True diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/action/script.py ansible-core-2.19.1/lib/ansible/plugins/action/script.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/action/script.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/action/script.py 2025-08-25 19:16:05.000000000 +0000 @@ -20,6 +20,7 @@ import pathlib import re import shlex +import typing as _t from ansible.errors import AnsibleError, AnsibleActionFail, AnsibleActionSkip from ansible.executor.powershell import module_manifest as ps_manifest @@ -35,7 +36,7 @@ # after chopping off a potential drive letter. windows_absolute_path_detection = re.compile(r'^(?:[a-zA-Z]\:)?(\\|\/)') - def run(self, tmp=None, task_vars=None): + def run(self, tmp: str | None = None, task_vars: dict[str, _t.Any] | None = None) -> dict[str, _t.Any]: """ handler for file transfer operations """ if task_vars is None: task_vars = dict() @@ -130,7 +131,7 @@ self._fixup_perms2((self._connection._shell.tmpdir, tmp_src), execute=True) # add preparation steps to one ssh roundtrip executing the script - env_dict = dict() + env_dict: dict[str, _t.Any] = {} env_string = self._compute_environment_string(env_dict) if executable: @@ -164,10 +165,10 @@ script_cmd = self._connection._shell.build_module_command(env_string='', shebang='#!powershell', cmd='') # now we execute script, always assume changed. - result = dict(self._low_level_execute_command(cmd=script_cmd, in_data=exec_data, sudoable=True, chdir=chdir), changed=True) + result: dict[str, object] = dict(self._low_level_execute_command(cmd=script_cmd, in_data=exec_data, sudoable=True, chdir=chdir), changed=True) if 'rc' in result and result['rc'] != 0: - raise AnsibleActionFail('non-zero return code', result=result) + result.update(msg='non-zero return code', failed=True) return result finally: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/action/template.py ansible-core-2.19.1/lib/ansible/plugins/action/template.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/action/template.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/action/template.py 2025-08-25 19:16:05.000000000 +0000 @@ -44,7 +44,7 @@ del tmp # tmp no longer has any effect # Options type validation - # stings + # strings for s_type in ('src', 'dest', 'state', 'newline_sequence', 'variable_start_string', 'variable_end_string', 'block_start_string', 'block_end_string', 'comment_start_string', 'comment_end_string'): if s_type in self._task.args: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/callback/__init__.py ansible-core-2.19.1/lib/ansible/plugins/callback/__init__.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/callback/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/callback/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -19,6 +19,7 @@ import difflib import functools +import inspect import json import re import sys @@ -39,6 +40,7 @@ from ansible.vars.clean import strip_internal_keys, module_response_deepcopy from ansible.module_utils._internal._json._profiles import _fallback_to_str from ansible._internal._templating import _engine +from ansible.module_utils._internal import _deprecator import yaml @@ -61,16 +63,6 @@ _T_callable = t.TypeVar("_T_callable", bound=t.Callable) -def _callback_base_impl(wrapped: _T_callable) -> _T_callable: - """ - Decorator for the no-op methods on the `CallbackBase` base class. - Used to avoid unnecessary dispatch overhead to no-op base callback methods. - """ - wrapped._base_impl = True - - return wrapped - - class _AnsibleCallbackDumper(_dumper.AnsibleDumper): def __init__(self, *args, lossy: bool = False, **kwargs): super().__init__(*args, **kwargs) @@ -154,6 +146,9 @@ custom actions. """ + _implemented_callback_methods: frozenset[str] = frozenset() + """Set of callback methods overridden by each subclass; used by TQM to bypass callback dispatch on no-op methods.""" + def __init__(self, display: Display | None = None, options: dict[str, t.Any] | None = None) -> None: super().__init__() @@ -189,12 +184,58 @@ # helper for callbacks, so they don't all have to include deepcopy _copy_result = deepcopy + def _init_callback_methods(self) -> None: + """Record analysis of callback methods on each callback instance for dispatch optimization and deprecation warnings.""" + implemented_callback_methods: set[str] = set() + deprecated_v1_method_overrides: set[str] = set() + plugin_file = sys.modules[type(self).__module__].__file__ + + if plugin_info := _deprecator._path_as_plugininfo(plugin_file): + plugin_name = plugin_info.resolved_name + else: + plugin_name = plugin_file + + for base_v2_method, base_v1_method in CallbackBase._v2_v1_method_map.items(): + method_name = None + + if not inspect.ismethod(method := getattr(self, (v2_method_name := base_v2_method.__name__))) or method.__func__ is not base_v2_method: + implemented_callback_methods.add(v2_method_name) # v2 method directly implemented by subclass + method_name = v2_method_name + elif base_v1_method is None: + pass # no corresponding v1 method + elif not inspect.ismethod(method := getattr(self, (v1_method_name := base_v1_method.__name__))) or method.__func__ is not base_v1_method: + implemented_callback_methods.add(v2_method_name) # v1 method directly implemented by subclass + deprecated_v1_method_overrides.add(v1_method_name) + method_name = v1_method_name + + if method_name and v2_method_name == 'v2_on_any': + deprecated_v1_method_overrides.discard(method_name) # avoid including v1 on_any in the v1 deprecation below + + global_display.deprecated( + msg=f'The {plugin_name!r} callback plugin implements deprecated method {method_name!r}.', + version='2.23', + help_text='Use event-specific callback methods instead.', + ) + + self._implemented_callback_methods = frozenset(implemented_callback_methods) + + if deprecated_v1_method_overrides: + global_display.deprecated( + msg=f'The {plugin_name!r} callback plugin implements the following deprecated method(s): {", ".join(sorted(deprecated_v1_method_overrides))}', + version='2.23', + help_text='Implement the `v2_*` equivalent callback method(s) instead.', + ) + def set_option(self, k, v): self._plugin_options[k] = C.config.get_config_value(k, plugin_type=self.plugin_type, plugin_name=self._load_name, direct={k: v}) + self._origins[k] = 'direct' def get_option(self, k, hostvars=None): return self._plugin_options[k] + def get_option_and_origin(self, k, hostvars=None): + return self._plugin_options[k], self._origins[k] + def has_option(self, option): return (option in self._plugin_options) @@ -204,7 +245,8 @@ """ # load from config - self._plugin_options = C.config.get_plugin_options(self.plugin_type, self._load_name, keys=task_keys, variables=var_options, direct=direct) + self._plugin_options, self._origins = C.config.get_plugin_options_and_origins(self.plugin_type, self._load_name, + keys=task_keys, variables=var_options, direct=direct) @staticmethod def host_label(result: CallbackTaskResult) -> str: @@ -316,8 +358,7 @@ if res.pop('warnings', None) and self._current_task_result and (warnings := self._current_task_result.warnings): # display warnings from the current task result if `warnings` was not removed from `result` (or made falsey) for warning in warnings: - # DTFIX3: what to do about propagating wrap_text from the original display.warning call? - self._display._warning(warning, wrap_text=False) + self._display._warning(warning) if res.pop('deprecations', None) and self._current_task_result and (deprecations := self._current_task_result.deprecations): # display deprecations from the current task result if `deprecations` was not removed from `result` (or made falsey) @@ -327,7 +368,7 @@ def _handle_exception(self, result: _c.MutableMapping[str, t.Any], use_stderr: bool = False) -> None: if result.pop('exception', None) and self._current_task_result and (exception := self._current_task_result.exception): # display exception from the current task result if `exception` was not removed from `result` (or made falsey) - self._display._error(exception, wrap_text=False, stderr=use_stderr) + self._display._error(exception, stderr=use_stderr) def _handle_warnings_and_exception(self, result: CallbackTaskResult) -> None: """Standardized handling of warnings/deprecations and exceptions from a task/item result.""" @@ -471,96 +512,61 @@ def set_play_context(self, play_context): pass - @_callback_base_impl def on_any(self, *args, **kwargs): pass - @_callback_base_impl def runner_on_failed(self, host, res, ignore_errors=False): pass - @_callback_base_impl def runner_on_ok(self, host, res): pass - @_callback_base_impl def runner_on_skipped(self, host, item=None): pass - @_callback_base_impl def runner_on_unreachable(self, host, res): pass - @_callback_base_impl - def runner_on_no_hosts(self): - pass - - @_callback_base_impl def runner_on_async_poll(self, host, res, jid, clock): pass - @_callback_base_impl def runner_on_async_ok(self, host, res, jid): pass - @_callback_base_impl def runner_on_async_failed(self, host, res, jid): pass - @_callback_base_impl def playbook_on_start(self): pass - @_callback_base_impl def playbook_on_notify(self, host, handler): pass - @_callback_base_impl def playbook_on_no_hosts_matched(self): pass - @_callback_base_impl def playbook_on_no_hosts_remaining(self): pass - @_callback_base_impl def playbook_on_task_start(self, name, is_conditional): pass - @_callback_base_impl def playbook_on_vars_prompt(self, varname, private=True, prompt=None, encrypt=None, confirm=False, salt_size=None, salt=None, default=None, unsafe=None): pass - @_callback_base_impl - def playbook_on_setup(self): - pass - - @_callback_base_impl - def playbook_on_import_for_host(self, host, imported_file): - pass - - @_callback_base_impl - def playbook_on_not_import_for_host(self, host, missing_file): - pass - - @_callback_base_impl def playbook_on_play_start(self, name): pass - @_callback_base_impl def playbook_on_stats(self, stats): pass - @_callback_base_impl def on_file_diff(self, host, diff): pass # V2 METHODS, by default they call v1 counterparts if possible - @_callback_base_impl def v2_on_any(self, *args, **kwargs): self.on_any(args, kwargs) - @_callback_base_impl def v2_runner_on_failed(self, result: CallbackTaskResult, ignore_errors: bool = False) -> None: """Process results of a failed task. @@ -583,7 +589,6 @@ host = result.host.get_name() self.runner_on_failed(host, result.result, ignore_errors) - @_callback_base_impl def v2_runner_on_ok(self, result: CallbackTaskResult) -> None: """Process results of a successful task. @@ -596,7 +601,6 @@ host = result.host.get_name() self.runner_on_ok(host, result.result) - @_callback_base_impl def v2_runner_on_skipped(self, result: CallbackTaskResult) -> None: """Process results of a skipped task. @@ -610,7 +614,6 @@ host = result.host.get_name() self.runner_on_skipped(host, self._get_item_label(getattr(result.result, 'results', {}))) - @_callback_base_impl def v2_runner_on_unreachable(self, result: CallbackTaskResult) -> None: """Process results of a task if a target node is unreachable. @@ -623,7 +626,6 @@ host = result.host.get_name() self.runner_on_unreachable(host, result.result) - @_callback_base_impl def v2_runner_on_async_poll(self, result: CallbackTaskResult) -> None: """Get details about an unfinished task running in async mode. @@ -642,7 +644,6 @@ clock = 0 self.runner_on_async_poll(host, result.result, jid, clock) - @_callback_base_impl def v2_runner_on_async_ok(self, result: CallbackTaskResult) -> None: """Process results of a successful task that ran in async mode. @@ -656,7 +657,6 @@ jid = result.result.get('ansible_job_id') self.runner_on_async_ok(host, result.result, jid) - @_callback_base_impl def v2_runner_on_async_failed(self, result: CallbackTaskResult) -> None: host = result.host.get_name() # Attempt to get the async job ID. If the job does not finish before the @@ -666,89 +666,84 @@ jid = result.result['async_result'].get('ansible_job_id') self.runner_on_async_failed(host, result.result, jid) - @_callback_base_impl def v2_playbook_on_start(self, playbook): self.playbook_on_start() - @_callback_base_impl def v2_playbook_on_notify(self, handler, host): self.playbook_on_notify(host, handler) - @_callback_base_impl def v2_playbook_on_no_hosts_matched(self): self.playbook_on_no_hosts_matched() - @_callback_base_impl def v2_playbook_on_no_hosts_remaining(self): self.playbook_on_no_hosts_remaining() - @_callback_base_impl def v2_playbook_on_task_start(self, task, is_conditional): self.playbook_on_task_start(task.name, is_conditional) - # FIXME: not called - @_callback_base_impl - def v2_playbook_on_cleanup_task_start(self, task): - pass # no v1 correspondence - - @_callback_base_impl def v2_playbook_on_handler_task_start(self, task): pass # no v1 correspondence - @_callback_base_impl def v2_playbook_on_vars_prompt(self, varname, private=True, prompt=None, encrypt=None, confirm=False, salt_size=None, salt=None, default=None, unsafe=None): self.playbook_on_vars_prompt(varname, private, prompt, encrypt, confirm, salt_size, salt, default, unsafe) - # FIXME: not called - @_callback_base_impl - def v2_playbook_on_import_for_host(self, result: CallbackTaskResult, imported_file) -> None: - host = result.host.get_name() - self.playbook_on_import_for_host(host, imported_file) - - # FIXME: not called - @_callback_base_impl - def v2_playbook_on_not_import_for_host(self, result: CallbackTaskResult, missing_file) -> None: - host = result.host.get_name() - self.playbook_on_not_import_for_host(host, missing_file) - - @_callback_base_impl def v2_playbook_on_play_start(self, play): self.playbook_on_play_start(play.name) - @_callback_base_impl def v2_playbook_on_stats(self, stats): self.playbook_on_stats(stats) - @_callback_base_impl def v2_on_file_diff(self, result: CallbackTaskResult) -> None: if 'diff' in result.result: host = result.host.get_name() self.on_file_diff(host, result.result['diff']) - @_callback_base_impl def v2_playbook_on_include(self, included_file): pass # no v1 correspondence - @_callback_base_impl def v2_runner_item_on_ok(self, result: CallbackTaskResult) -> None: pass - @_callback_base_impl def v2_runner_item_on_failed(self, result: CallbackTaskResult) -> None: pass - @_callback_base_impl def v2_runner_item_on_skipped(self, result: CallbackTaskResult) -> None: pass - @_callback_base_impl def v2_runner_retry(self, result: CallbackTaskResult) -> None: pass - @_callback_base_impl def v2_runner_on_start(self, host, task): """Event used when host begins execution of a task .. versionadded:: 2.8 """ pass + + _v2_v1_method_map = { + v2_on_any: on_any, + v2_on_file_diff: on_file_diff, + v2_playbook_on_handler_task_start: None, + v2_playbook_on_include: None, + v2_playbook_on_no_hosts_matched: playbook_on_no_hosts_matched, + v2_playbook_on_no_hosts_remaining: playbook_on_no_hosts_remaining, + v2_playbook_on_notify: playbook_on_notify, + v2_playbook_on_play_start: playbook_on_play_start, + v2_playbook_on_start: playbook_on_start, + v2_playbook_on_stats: playbook_on_stats, + v2_playbook_on_task_start: playbook_on_task_start, + v2_playbook_on_vars_prompt: playbook_on_vars_prompt, + v2_runner_item_on_failed: None, + v2_runner_item_on_ok: None, + v2_runner_item_on_skipped: None, + v2_runner_on_async_failed: runner_on_async_failed, + v2_runner_on_async_ok: runner_on_async_ok, + v2_runner_on_async_poll: runner_on_async_poll, + v2_runner_on_failed: runner_on_failed, + v2_runner_on_ok: runner_on_ok, + v2_runner_on_skipped: runner_on_skipped, + v2_runner_on_start: None, + v2_runner_on_unreachable: runner_on_unreachable, + v2_runner_retry: None, + } + """Internal mapping of v2 callback methods with v1 counterparts; populated after type init for deprecation warnings and bypass calculation.""" diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/callback/default.py ansible-core-2.19.1/lib/ansible/plugins/callback/default.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/callback/default.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/callback/default.py 2025-08-25 19:16:05.000000000 +0000 @@ -197,9 +197,6 @@ self._last_task_banner = task._uuid - def v2_playbook_on_cleanup_task_start(self, task): - self._task_start(task, prefix='CLEANUP TASK') - def v2_playbook_on_handler_task_start(self, task): self._task_start(task, prefix='RUNNING HANDLER') diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/callback/junit.py ansible-core-2.19.1/lib/ansible/plugins/callback/junit.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/callback/junit.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/callback/junit.py 2025-08-25 19:16:05.000000000 +0000 @@ -300,15 +300,9 @@ def v2_playbook_on_play_start(self, play): self._play_name = play.get_name() - def v2_runner_on_no_hosts(self, task: Task) -> None: - self._start_task(task) - def v2_playbook_on_task_start(self, task: Task, is_conditional: bool) -> None: self._start_task(task) - def v2_playbook_on_cleanup_task_start(self, task: Task) -> None: - self._start_task(task) - def v2_playbook_on_handler_task_start(self, task: Task) -> None: self._start_task(task) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/connection/ssh.py ansible-core-2.19.1/lib/ansible/plugins/connection/ssh.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/connection/ssh.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/connection/ssh.py 2025-08-25 19:16:05.000000000 +0000 @@ -640,11 +640,11 @@ self.shm.close() with contextlib.suppress(FileNotFoundError): self.shm.unlink() - if not _HAS_RESOURCE_TRACK: - # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' - # There is a resource tracking issue where the resource is deleted, but tracking still has a record - # This will effectively overwrite the record and remove it - SharedMemory(name=self.shm.name, create=True, size=1).unlink() + if not _HAS_RESOURCE_TRACK: + # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' + # There is a resource tracking issue where the resource is deleted, but tracking still has a record + # This will effectively overwrite the record and remove it + SharedMemory(name=self.shm.name, create=True, size=1).unlink() return ret return inner @@ -961,6 +961,13 @@ b_args = (b"-o", b'ControlPath="%s"' % to_bytes(self.control_path % dict(directory=cpdir), errors='surrogate_or_strict')) self._add_args(b_command, b_args, u"found only ControlPersist; added ControlPath") + if password_mechanism == "ssh_askpass": + self._add_args( + b_command, + (b"-o", b"NumberOfPasswordPrompts=1"), + "Restrict number of password prompts in case incorrect password is provided.", + ) + # Finally, we add any caller-supplied extras. if other_args: b_command += [to_bytes(a) for a in other_args] @@ -1171,7 +1178,7 @@ # Are we requesting privilege escalation? Right now, we may be invoked # to execute sftp/scp with sudoable=True, but we can request escalation - # only when using ssh. Otherwise we can send initial data straightaway. + # only when using ssh. Otherwise, we can send initial data straight away. state = states.index('ready_to_send') if to_bytes(self.get_option('ssh_executable')) in cmd and sudoable: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/pow.yml ansible-core-2.19.1/lib/ansible/plugins/filter/pow.yml --- ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/pow.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/filter/pow.yml 2025-08-25 19:16:05.000000000 +0000 @@ -3,7 +3,7 @@ version_added: "1.9" short_description: power of (math operation) description: - - Math operation that returns the Nth power of inputed number, C(X ^ N). + - Math operation that returns the Nth power of inputted number, C(X ^ N). notes: - This is a passthrough to Python's C(math.pow). positional: _input, _power diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/root.yml ansible-core-2.19.1/lib/ansible/plugins/filter/root.yml --- ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/root.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/filter/root.yml 2025-08-25 19:16:05.000000000 +0000 @@ -3,7 +3,7 @@ version_added: "1.9" short_description: root of (math operation) description: - - Math operation that returns the Nth root of inputed number C(X ^^ N). + - Math operation that returns the Nth root of inputted number C(X ^^ N). positional: _input, base options: _input: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/strftime.yml ansible-core-2.19.1/lib/ansible/plugins/filter/strftime.yml --- ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/strftime.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/filter/strftime.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,16 +1,16 @@ DOCUMENTATION: name: strftime version_added: "2.4" - short_description: date formating + short_description: date formatting description: - - Using Python's C(strftime) function, take a data formating string and a date/time to create a formatted date. + - Using Python's C(strftime) function, take a data formatting string and a date/time to create a formatted date. notes: - This is a passthrough to Python's C(stftime), for a complete set of formatting options go to https://strftime.org/. positional: _input, second, utc options: _input: description: - - A formating string following C(stftime) conventions. + - A formatting string following C(stftime) conventions. - See L(the Python documentation, https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) for a reference. type: str required: true diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/to_json.yml ansible-core-2.19.1/lib/ansible/plugins/filter/to_json.yml --- ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/to_json.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/filter/to_json.yml 2025-08-25 19:16:05.000000000 +0000 @@ -23,8 +23,9 @@ default: True version_added: '2.9' allow_nan: - description: When V(False), strict adherence to float value limits of the JSON specifications, so C(nan), C(inf) and C(-inf) values will produce errors. - When V(True), JavaScript equivalents will be used (C(NaN), C(Infinity), C(-Infinity)). + description: + - When V(False), out-of-range float values C(nan), C(inf) and C(-inf) will result in an error. + - When V(True), out-of-range float values will be represented using their JavaScript equivalents, C(NaN), C(Infinity) and C(-Infinity). default: True type: bool check_circular: @@ -42,8 +43,11 @@ separators: description: The C(item) and C(key) separator to be used in the serialized output, default may change depending on O(indent) and Python version. - default: "(', ', ': ')" - type: tuple + default: + - ', ' + - ': ' + type: list + elements: str skipkeys: description: If V(True), keys that are not basic Python types will be skipped. default: False diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/to_nice_json.yml ansible-core-2.19.1/lib/ansible/plugins/filter/to_nice_json.yml --- ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/to_nice_json.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/filter/to_nice_json.yml 2025-08-25 19:16:05.000000000 +0000 @@ -23,8 +23,9 @@ default: True version_added: '2.9' allow_nan: - description: When V(False), strict adherence to float value limits of the JSON specification, so C(nan), C(inf) and C(-inf) values will produce errors. - When V(True), JavaScript equivalents will be used (C(NaN), C(Infinity), C(-Infinity)). + description: + - When V(False), out-of-range float values C(nan), C(inf) and C(-inf) will result in an error. + - When V(True), out-of-range float values will be represented using their JavaScript equivalents, C(NaN), C(Infinity) and C(-Infinity). default: True type: bool check_circular: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/to_uuid.yml ansible-core-2.19.1/lib/ansible/plugins/filter/to_uuid.yml --- ansible-core-2.19.0~beta6/lib/ansible/plugins/filter/to_uuid.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/filter/to_uuid.yml 2025-08-25 19:16:05.000000000 +0000 @@ -7,7 +7,7 @@ positional: _input, namespace options: _input: - description: String to use as base fo the UUID. + description: String to use as base of the UUID. type: str required: true namespace: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/inventory/script.py ansible-core-2.19.1/lib/ansible/plugins/inventory/script.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/inventory/script.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/inventory/script.py 2025-08-25 19:16:05.000000000 +0000 @@ -98,7 +98,7 @@ def get_api_data(namespace: str, pretty=False) -> str: """ :param namespace: parameter for our custom api - :param pretty: Human redable JSON vs machine readable + :param pretty: Human readable JSON vs machine readable :return: JSON string """ found_data = list(MyInventoryAPI(namespace)) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/loader.py ansible-core-2.19.1/lib/ansible/plugins/loader.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/loader.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/loader.py 2025-08-25 19:16:05.000000000 +0000 @@ -989,6 +989,9 @@ def get_with_context(self, name, *args, **kwargs) -> get_with_context_result: """ instantiates a plugin of the given name using arguments """ + if not name: + raise ValueError('A non-empty plugin name is required.') + found_in_cache = True class_only = kwargs.pop('class_only', False) collection_list = kwargs.pop('collection_list', None) @@ -1034,6 +1037,7 @@ except AttributeError: return get_with_context_result(None, plugin_load_context) if not issubclass(obj, plugin_class): + display.warning(f"Ignoring {self.type} plugin {resolved_type_name!r} due to missing base class {self.base_class!r}.") return get_with_context_result(None, plugin_load_context) # FIXME: update this to use the load context @@ -1721,6 +1725,7 @@ 'ansible.plugins.callback', C.DEFAULT_CALLBACK_PLUGIN_PATH, 'callback_plugins', + required_base_class='CallbackBase', ) connection_loader = PluginLoader( diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/lookup/password.py ansible-core-2.19.1/lib/ansible/plugins/lookup/password.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/lookup/password.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/lookup/password.py 2025-08-25 19:16:05.000000000 +0000 @@ -126,6 +126,7 @@ elements: str """ +import contextlib import os import string import time @@ -269,15 +270,12 @@ b_pathdir = os.path.dirname(b_path) lockfile_name = to_bytes("%s.ansible_lockfile" % hashlib.sha1(b_path).hexdigest()) lockfile = os.path.join(b_pathdir, lockfile_name) - if not os.path.exists(lockfile) and b_path != to_bytes('/dev/null'): - try: - makedirs_safe(b_pathdir, mode=0o700) + if b_path != b'/dev/null': + makedirs_safe(b_pathdir, mode=0o700) + with contextlib.suppress(FileExistsError): fd = os.open(lockfile, os.O_CREAT | os.O_EXCL) os.close(fd) first_process = True - except OSError as e: - if e.strerror != 'File exists': - raise counter = 0 # if the lock is got by other process, wait until it's released diff -Nru ansible-core-2.19.0~beta6/lib/ansible/plugins/lookup/template.py ansible-core-2.19.1/lib/ansible/plugins/lookup/template.py --- ansible-core-2.19.0~beta6/lib/ansible/plugins/lookup/template.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/plugins/lookup/template.py 2025-08-25 19:16:05.000000000 +0000 @@ -107,6 +107,7 @@ from ansible.plugins.lookup import LookupBase from ansible.template import trust_as_template from ansible._internal._templating import _template_vars +from ansible._internal._templating._engine import TemplateOptions, TemplateOverrides from ansible.utils.display import Display @@ -174,7 +175,11 @@ ) data_templar = templar.copy_with_new_env(available_variables=vars, searchpath=searchpath) - res = data_templar.template(template_data, escape_backslashes=False, overrides=overrides) + # use the internal template API to avoid forced top-level finalization behavior imposed by the public API + res = data_templar._engine.template(template_data, options=TemplateOptions( + escape_backslashes=False, + overrides=TemplateOverrides.from_kwargs(overrides), + )) ret.append(res) else: diff -Nru ansible-core-2.19.0~beta6/lib/ansible/release.py ansible-core-2.19.1/lib/ansible/release.py --- ansible-core-2.19.0~beta6/lib/ansible/release.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/release.py 2025-08-25 19:16:05.000000000 +0000 @@ -17,6 +17,6 @@ from __future__ import annotations -__version__ = '2.19.0b6' +__version__ = '2.19.1' __author__ = 'Ansible, Inc.' __codename__ = "What Is and What Should Never Be" diff -Nru ansible-core-2.19.0~beta6/lib/ansible/utils/display.py ansible-core-2.19.1/lib/ansible/utils/display.py --- ansible-core-2.19.0~beta6/lib/ansible/utils/display.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/utils/display.py 2025-08-25 19:16:05.000000000 +0000 @@ -40,7 +40,6 @@ import subprocess import sys import termios -import textwrap import threading import time import tty @@ -329,7 +328,6 @@ self.noncow = C.ANSIBLE_COW_SELECTION self.set_cowsay_info() - self._wrap_stderr = C.WRAP_STDERR if self.b_cowsay: try: @@ -621,6 +619,12 @@ # collections have a resolved_name but no type collection = deprecator.resolved_name if deprecator else None plugin_fragment = '' + elif deprecator.resolved_name == 'ansible.builtin': + # core deprecations from base classes (the API) have no plugin name, only 'ansible.builtin' + plugin_type_name = str(deprecator.type) if deprecator.type is _messages.PluginType.MODULE else f'{deprecator.type} plugin' + + collection = deprecator.resolved_name + plugin_fragment = f'the {plugin_type_name} API' else: parts = deprecator.resolved_name.split('.') plugin_name = parts[-1] @@ -657,13 +661,6 @@ return _join_sentences(msg, deprecation_msg) - def _wrap_message(self, msg: str, wrap_text: bool) -> str: - if wrap_text and self._wrap_stderr: - wrapped = textwrap.wrap(msg, self.columns, drop_whitespace=False) - msg = "\n".join(wrapped) + "\n" - - return msg - @staticmethod def _deduplicate(msg: str, messages: set[str]) -> bool: """ @@ -780,9 +777,6 @@ msg = _format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.DEPRECATED)) msg = f'[DEPRECATION WARNING]: {msg}' - # DTFIX3: what should we do with wrap_message? - msg = self._wrap_message(msg=msg, wrap_text=True) - if self._deduplicate(msg, self._deprecations): return @@ -799,6 +793,8 @@ """Display a warning message.""" _skip_stackwalk = True + # deprecated: description='The formatted argument has no effect.' core_version='2.23' + # This is the pre-proxy half of the `warning` implementation. # Any logic that must occur on workers needs to be implemented here. @@ -818,13 +814,12 @@ if warning_ctx := _DeferredWarningContext.current(optional=True): warning_ctx.capture(warning) - # DTFIX3: what to do about propagating wrap_text? return - self._warning(warning, wrap_text=not formatted) + self._warning(warning) @_proxy - def _warning(self, warning: _messages.WarningSummary, wrap_text: bool) -> None: + def _warning(self, warning: _messages.WarningSummary) -> None: """Internal implementation detail, use `warning` instead.""" # This is the post-proxy half of the `warning` implementation. @@ -836,9 +831,6 @@ if self._deduplicate(msg, self._warns): return - # DTFIX3: what should we do with wrap_message? - msg = self._wrap_message(msg=msg, wrap_text=wrap_text) - self.display(msg, color=C.config.get_config_value('COLOR_WARN'), stderr=True, caplevel=-2) @_proxy @@ -927,19 +919,20 @@ warning_ctx.capture(warning) return - self._warning(warning, wrap_text=False) + self._warning(warning) def error(self, msg: str | BaseException, wrap_text: bool = True, stderr: bool = True) -> None: """Display an error message.""" _skip_stackwalk = True + # deprecated: description='The wrap_text argument has no effect.' core_version='2.23' + # deprecated: description='The stderr argument has no effect.' core_version='2.23' + # This is the pre-proxy half of the `error` implementation. # Any logic that must occur on workers needs to be implemented here. if isinstance(msg, BaseException): event = _error_factory.ControllerEventFactory.from_exception(msg, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)) - - wrap_text = False else: event = _messages.Event( msg=msg, @@ -950,10 +943,10 @@ event=event, ) - self._error(error, wrap_text=wrap_text, stderr=stderr) + self._error(error, stderr=True) @_proxy - def _error(self, error: _messages.ErrorSummary, wrap_text: bool, stderr: bool) -> None: + def _error(self, error: _messages.ErrorSummary, stderr: bool) -> None: """Internal implementation detail, use `error` instead.""" # This is the post-proxy half of the `error` implementation. @@ -965,9 +958,6 @@ if self._deduplicate(msg, self._errors): return - # DTFIX3: what should we do with wrap_message? - msg = self._wrap_message(msg=msg, wrap_text=wrap_text) - self.display(msg, color=C.config.get_config_value('COLOR_ERROR'), stderr=stderr, caplevel=-1) @staticmethod diff -Nru ansible-core-2.19.0~beta6/lib/ansible/utils/encrypt.py ansible-core-2.19.1/lib/ansible/utils/encrypt.py --- ansible-core-2.19.0~beta6/lib/ansible/utils/encrypt.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/utils/encrypt.py 2025-08-25 19:16:05.000000000 +0000 @@ -99,6 +99,8 @@ salt = self._clean_salt(salt) rounds = self._clean_rounds(rounds) ident = self._clean_ident(ident) + if salt_size is not None and not isinstance(salt_size, int): + raise TypeError("salt_size must be an integer") return self._hash(secret, salt=salt, salt_size=salt_size, rounds=rounds, ident=ident) def _clean_ident(self, ident): diff -Nru ansible-core-2.19.0~beta6/lib/ansible/utils/path.py ansible-core-2.19.1/lib/ansible/utils/path.py --- ansible-core-2.19.0~beta6/lib/ansible/utils/path.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/utils/path.py 2025-08-25 19:16:05.000000000 +0000 @@ -102,7 +102,7 @@ dname = os.path.dirname(source) if dname: - # don't follow symlinks for basedir, enables source re-use + # don't follow symlinks for basedir, enables source reuse dname = os.path.abspath(dname) return to_text(dname, errors='surrogate_or_strict') diff -Nru ansible-core-2.19.0~beta6/lib/ansible/utils/vars.py ansible-core-2.19.1/lib/ansible/utils/vars.py --- ansible-core-2.19.0~beta6/lib/ansible/utils/vars.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/utils/vars.py 2025-08-25 19:16:05.000000000 +0000 @@ -28,6 +28,7 @@ from ansible import constants as C from ansible import context from ansible._internal import _json +from ansible._internal._templating import _jinja_bits from ansible.errors import AnsibleError, AnsibleOptionsError from ansible.module_utils.datatag import native_type_name from ansible.module_utils.common.text.converters import to_native, to_text @@ -252,6 +253,8 @@ Originally posted at https://stackoverflow.com/a/29586366 """ + # deprecated: description='Use validate_variable_name instead.' core_version='2.23' + if not isinstance(ident, str): return False @@ -269,7 +272,7 @@ def validate_variable_name(name: object) -> None: """Validate the given variable name is valid, raising an AnsibleError if it is not.""" - if isinstance(name, str) and isidentifier(name): + if isinstance(name, str) and name.isidentifier() and name.isascii() and name not in _jinja_bits.JINJA_KEYWORDS: return if isinstance(name, (str, int, float, bool, type(None))): @@ -290,7 +293,7 @@ def transform_to_native_types( value: object, redact: bool = True, -) -> object: +) -> t.Any: """ Recursively transform the given value to Python native types. Potentially sensitive values such as individually vaulted variables will be redacted unless ``redact=False`` is passed. @@ -303,6 +306,7 @@ convert_custom_scalars=True, convert_to_native_values=True, apply_transforms=True, + visit_keys=True, # ensure that keys are also converted encrypted_string_behavior=_json.EncryptedStringBehavior.REDACT if redact else _json.EncryptedStringBehavior.DECRYPT, ) diff -Nru ansible-core-2.19.0~beta6/lib/ansible/vars/manager.py ansible-core-2.19.1/lib/ansible/vars/manager.py --- ansible-core-2.19.0~beta6/lib/ansible/vars/manager.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/vars/manager.py 2025-08-25 19:16:05.000000000 +0000 @@ -407,7 +407,7 @@ all_vars = _combine_and_track(all_vars, self._extra_vars, "extra vars") # before we add 'reserved vars', check we didn't add any reserved vars - warn_if_reserved(all_vars.keys()) + warn_if_reserved(all_vars) # magic variables all_vars = _combine_and_track(all_vars, magic_variables, "magic vars") @@ -563,7 +563,8 @@ if not isinstance(facts, Mapping): raise AnsibleAssertionError("the type of 'facts' to set for host_facts should be a Mapping but is a %s" % type(facts)) - warn_if_reserved(facts.keys()) + warn_if_reserved(facts) + try: host_cache = self._fact_cache.get(host) except KeyError: @@ -587,7 +588,8 @@ if not isinstance(facts, Mapping): raise AnsibleAssertionError("the type of 'facts' to set for nonpersistent_facts should be a Mapping but is a %s" % type(facts)) - warn_if_reserved(facts.keys()) + warn_if_reserved(facts) + try: self._nonpersistent_fact_cache[host] |= facts except KeyError: @@ -599,6 +601,7 @@ """ warn_if_reserved([varname]) + if host not in self._vars_cache: self._vars_cache[host] = dict() diff -Nru ansible-core-2.19.0~beta6/lib/ansible/vars/reserved.py ansible-core-2.19.1/lib/ansible/vars/reserved.py --- ansible-core-2.19.0~beta6/lib/ansible/vars/reserved.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/lib/ansible/vars/reserved.py 2025-08-25 19:16:05.000000000 +0000 @@ -66,8 +66,7 @@ def warn_if_reserved(myvars: c.Iterable[str], additional: c.Iterable[str] | None = None) -> None: - """ this function warns if any variable passed conflicts with internally reserved names """ - + """Issue a warning for any variable which conflicts with an internally reserved name.""" if additional is None: reserved = _RESERVED_NAMES else: @@ -76,8 +75,11 @@ varnames = set(myvars) varnames.discard('vars') # we add this one internally, so safe to ignore - for varname in varnames.intersection(reserved): - display.warning(f'Found variable using reserved name {varname!r}.') + if conflicts := varnames.intersection(reserved): + # Ensure the varname used for obj is the tagged one from myvars and not the untagged one from reserved. + # This can occur because tags do not affect value equality, and intersection can return values from either the left or right side. + for varname in (name for name in myvars if name in conflicts): + display.warning(f'Found variable using reserved name {varname!r}.', obj=varname) def is_reserved_name(name: str) -> bool: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansiballz_debugging/tasks/main.yml ansible-core-2.19.1/test/integration/targets/ansiballz_debugging/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/ansiballz_debugging/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansiballz_debugging/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,7 +1,7 @@ - name: Run a module with remote debugging configured to use a bogus debugger module ping: vars: - _ansible_ansiballz_debugger_config: + _ansible_ansiballz_pydevd_config: module: not_a_valid_debugger_module register: result ignore_errors: yes diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/ultimatequestion.yml ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/ultimatequestion.yml --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/ultimatequestion.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-doc/collections/ansible_collections/testns/testcol/plugins/filter/ultimatequestion.yml 2025-08-25 19:16:05.000000000 +0000 @@ -2,7 +2,7 @@ name: ultimatequestion author: Terry Prachet version_added: 'histerical' - short_description: Ask any question but it will only respond with the answer to the ulitmate one + short_description: Ask any question but it will only respond with the answer to the ultimate one description: - read the book options: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-doc/runme.sh ansible-core-2.19.1/test/integration/targets/ansible-doc/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-doc/runme.sh 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-doc/runme.sh 2025-08-25 19:16:05.000000000 +0000 @@ -287,3 +287,6 @@ echo "test 'sidecar' for no extension module with .yml doc" [ "$(ansible-doc -M ./library -l ansible.legacy |grep -v 'UNDOCUMENTED' |grep -c facts_one)" == "1" ] + +echo "Test j2 plugins get jinja2 instead of path" +ansible-doc -t filter map 2>&1 |grep "${GREP_OPTS[@]}" '(Jinja2)' diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging/aliases ansible-core-2.19.1/test/integration/targets/ansible-test-debugging/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-debugging/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,5 @@ +shippable/generic/group1 # runs in the default test container +needs/target/ansible-test-debugging-env # indirectly used by ansible-test, included here for change detection +needs/target/ansible-test-debugging-inventory # indirectly used by ansible-test, included here for change detection +context/controller +gather_facts/no diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging/tasks/main.yml ansible-core-2.19.1/test/integration/targets/ansible-test-debugging/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging/tasks/main.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-debugging/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,98 @@ +- name: Define supported debuggers + set_fact: + debuggers: + - pydevd + - debugpy + +- name: Run without debugging features enabled + command: ansible-test shell -v -- env + register: result + +- assert: + that: + - result.stderr_lines is not contains 'Debugging' + +- name: Run a command which does not support debugging + command: ansible-test env -v + register: result + +- assert: + that: + - result.stderr_lines is not contains 'Debugging' + +- name: Verify on-demand debugging gracefully handles not running under a debugger + command: ansible-test shell -v --dev-debug-on-demand -- env + register: result + +- assert: + that: + - result.stderr_lines is contains 'Debugging disabled because no debugger was detected.' + +- name: Verify manual debugging gracefully handles lack of configuration + command: ansible-test shell -v --dev-debug-cli -- env + register: result + +- assert: + that: + - result.stderr_lines is contains 'Debugging disabled because no debugger configuration was provided.' + +- name: Verify invalid debugger configuration is handled + command: ansible-test shell --dev-debug-cli -- env + environment: > + {% set key = "ANSIBLE_TEST_REMOTE_DEBUGGER_" + item.upper() %}{{ ('{"' + key + '": "{\"invalid_key\": true}"}') | from_json }} + register: result + loop: "{{ debuggers }}" + ignore_errors: yes + +- assert: + that: + - item.stderr is search("Invalid " + item.item + " settings.*invalid_key") + loop: "{{ result.results }}" + +- name: Verify CLI debugger can be manually enabled (shell) + command: ansible-test shell --dev-debug-cli -- env + environment: > + {% set key = "ANSIBLE_TEST_REMOTE_DEBUGGER_" + item.upper() %}{{ ('{"' + key + '": ""}') | from_json }} + register: result + loop: "{{ debuggers }}" + +- assert: + that: + - item.stdout is contains "ANSIBLE_TEST_DEBUGGER_CONFIG" + loop: "{{ result.results }}" + +- name: Verify CLI debugger can be manually enabled (integration) + command: ansible-test integration ansible-test-debugging-env --dev-debug-cli + environment: > + {% set key = "ANSIBLE_TEST_REMOTE_DEBUGGER_" + item.upper() %}{{ ('{"' + key + '": ""}') | from_json }} + register: result + loop: "{{ debuggers }}" + +- assert: + that: + - item.stdout is contains "ANSIBLE_TEST_DEBUGGER_CONFIG" + loop: "{{ result.results }}" + +- name: Verify AnsiballZ debugger can be manually enabled (shell) + command: ansible-test shell --dev-debug-ansiballz -- env + environment: > + {% set key = "ANSIBLE_TEST_REMOTE_DEBUGGER_" + item.upper() %}{{ ('{"' + key + '": ""}') | from_json }} + register: result + loop: "{{ debuggers }}" + +- assert: + that: + - item.stdout is contains("_ANSIBLE_ANSIBALLZ_" + item.item.upper() + "_CONFIG") + loop: "{{ result.results }}" + +- name: Verify AnsiballZ debugger can be manually enabled (integration) + command: ansible-test integration ansible-test-debugging-inventory --dev-debug-ansiballz + environment: > + {% set key = "ANSIBLE_TEST_REMOTE_DEBUGGER_" + item.upper() %}{{ ('{"' + key + '": ""}') | from_json }} + register: result + loop: "{{ debuggers }}" + +- assert: + that: + - item.stdout is contains("_ansible_ansiballz_" + item.item + "_config") + loop: "{{ result.results }}" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-env/aliases ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-env/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-env/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-env/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,2 @@ +shippable/generic/group1 # runs in the default test container +context/controller diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-env/runme.sh ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-env/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-env/runme.sh 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-env/runme.sh 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Used to support the ansible-test-debugging integration test. + +env diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-inventory/aliases ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-inventory/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-inventory/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-inventory/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,2 @@ +shippable/generic/group1 # runs in the default test container +context/controller diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-inventory/runme.sh ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-inventory/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-debugging-inventory/runme.sh 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-debugging-inventory/runme.sh 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Used to support the ansible-test-debugging integration test. + +cat "${INVENTORY_PATH}" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/aliases ansible-core-2.19.1/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/aliases 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -1 +1,3 @@ context/controller +env/set/A_VAR/something +env/set/A_PATH//an/absolute/path diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/tasks/main.yml ansible-core-2.19.1/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/ansible-test-integration/ansible_collections/ns/col/tests/integration/targets/hello/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -5,3 +5,5 @@ - assert: that: - hello.message == 'Hello Ansibull' + - lookup('env', 'A_VAR') == 'something' + - lookup('env', 'A_PATH') == '/an/absolute/path' diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/apt_repository/tasks/apt.yml ansible-core-2.19.1/test/integration/targets/apt_repository/tasks/apt.yml --- ansible-core-2.19.0~beta6/test/integration/targets/apt_repository/tasks/apt.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/apt_repository/tasks/apt.yml 2025-08-25 19:16:05.000000000 +0000 @@ -301,7 +301,7 @@ - assert: that: - 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 apt_repository: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/assemble/tasks/main.yml ansible-core-2.19.1/test/integration/targets/assemble/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/assemble/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/assemble/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -29,16 +29,46 @@ that: - "result.changed == true" -- name: test assemble with all fragments +- name: test assemble with all fragments, but only check! assemble: src="{{remote_tmp_dir}}/src" dest="{{remote_tmp_dir}}/assembled1" + check_mode: True register: result - name: assert the fragments were assembled assert: that: - - "result.state == 'file'" - "result.changed == True" - - "result.checksum == '74152e9224f774191bc0bedf460d35de86ad90e6'" + +- name: ensure file was not created + stat: + path: "{{remote_tmp_dir}}/assembled1" + register: check_it + +- name: it should not exist, yet + assert: + that: + - not check_it.stat.exists + +- name: test assemble with all fragments, really create now! + assemble: src="{{remote_tmp_dir}}/src" dest="{{remote_tmp_dir}}/assembled1" + register: result2 + +- name: assert the fragments were assembled + assert: + that: + - "result2.state == 'file'" + - "result2.changed == True" + - "result2.checksum == '74152e9224f774191bc0bedf460d35de86ad90e6'" + +- name: ensure file was created + stat: + path: "{{remote_tmp_dir}}/assembled1" + register: check_it2 + +- name: it should exist now + assert: + that: + - check_it2.stat.exists - name: test assemble with all fragments assemble: src="{{remote_tmp_dir}}/src" dest="{{remote_tmp_dir}}/assembled1" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/aliases ansible-core-2.19.1/test/integration/targets/callback-dispatch/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,2 @@ +context/controller +shippable/posix/group3 diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/legacy_warning_display.py ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/legacy_warning_display.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/legacy_warning_display.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/legacy_warning_display.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,116 @@ +from __future__ import annotations + +import collections.abc as c +import functools + +from unittest.mock import MagicMock + +from ansible.executor.task_result import CallbackTaskResult +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + CALLBACK_NEEDS_ENABLED = True + seen_tr = [] # track taskresult instances to ensure every call sees a unique instance + + expects_task_result = { + 'v2_runner_on_failed', 'v2_runner_on_ok', 'v2_runner_on_skipped', 'v2_runner_on_unreachable', 'v2_runner_on_async_poll', 'v2_runner_on_async_ok', + 'v2_runner_on_async_failed,', 'v2_on_file_diff', 'v2_runner_item_on_ok', + 'v2_runner_item_on_failed', 'v2_runner_item_on_skipped', 'v2_runner_retry', + } + + expects_no_task_result = { + 'v2_playbook_on_start', 'v2_playbook_on_notify', 'v2_playbook_on_no_hosts_matched', 'v2_playbook_on_no_hosts_remaining', 'v2_playbook_on_task_start', + 'v2_playbook_on_handler_task_start', 'v2_playbook_on_vars_prompt', 'v2_playbook_on_play_start', + 'v2_playbook_on_include', 'v2_runner_on_start', + } + + # we're abusing runtime assertions to signify failure in this integration test component; ensure they're not disabled by opimizations + try: + assert False + except AssertionError: + pass + else: + raise BaseException("this test does not function when running Python with optimization") + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._display = MagicMock() + + @staticmethod + def get_first_task_result(args: c.Sequence) -> CallbackTaskResult | None: + """Find the first CallbackTaskResult in posargs, since the signatures are dynamic and we didn't want to use inspect signature binding.""" + return next((arg for arg in args if isinstance(arg, CallbackTaskResult)), None) + + def v2_on_any(self, *args, **kwargs) -> None: + """Standard behavioral test for the v2_on_any callback method.""" + print(f'hello from v2_on_any {args=} {kwargs=}') + if result := self.get_first_task_result(args): + assert isinstance(result, CallbackTaskResult) + + assert result is self._current_task_result + + assert result not in self.seen_tr + + self.seen_tr.append(result) + else: + assert self._current_task_result is None + + def v2_method_expects_task_result(self, *args, method_name: str, **_kwargs) -> None: + """Standard behavioral tests for callback methods accepting a task result; wired dynamically.""" + print(f'hello from {method_name}') + result = self.get_first_task_result(args) + + assert result is self._current_task_result + + assert isinstance(result, CallbackTaskResult) + + assert result not in self.seen_tr + + self.seen_tr.append(result) + + has_exception = bool(result.exception) + has_warnings = bool(result.warnings) + has_deprecations = bool(result.deprecations) + + self._display.reset_mock() + + self._handle_exception(result.result) # pops exception from transformed dict + + if has_exception: + assert 'exception' not in result.result + self._display._error.assert_called() + + self._display.reset_mock() + + self._handle_warnings(result.result) # pops warnings/deprecations from transformed dict + + if has_warnings: + assert 'warnings' not in result.result + self._display._warning.assert_called() + + if has_deprecations: + assert 'deprecations' not in result.result + self._display._deprecated.assert_called() + + def v2_method_expects_no_task_result(self, *args, method_name: str, **_kwargs) -> None: + """Standard behavioral tests for non-task result callback methods; wired dynamically.""" + print(f'hello from {method_name}') + + assert self.get_first_task_result(args) is None + + assert self._current_task_result is None + + def v2_playbook_on_stats(self, *args, **kwargs) -> None: + print('hello from v2_playbook_on_stats') + assert self.get_first_task_result(args) is None + + print('legacy warning display callback test PASS') + + def __getattribute__(self, item: str) -> object: + if item in CallbackModule.expects_task_result: + return functools.partial(CallbackModule.v2_method_expects_task_result, self, method_name=item) + elif item in CallbackModule.expects_no_task_result: + return functools.partial(CallbackModule.v2_method_expects_no_task_result, self, method_name=item) + else: + return object.__getattribute__(self, item) diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/missing_base_class.py ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/missing_base_class.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/missing_base_class.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/missing_base_class.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,6 @@ +from __future__ import annotations + + +class CallbackModule: + """This callback should fail to load since it doesn't extend the required builtin base class.""" + pass diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/oops_always_enabled.py ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/oops_always_enabled.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/oops_always_enabled.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/oops_always_enabled.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +import typing as t + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + call_count: t.ClassVar[int] = 0 + + def v2_runner_on_ok(self, *args, **kwargs) -> None: + print(f"hello from ALWAYS ENABLED v2_runner_on_ok {args=} {kwargs=}") + + CallbackModule.call_count += 1 + + def v2_playbook_on_stats(self, stats): + print('hello from ALWAYS ENABLED v2_playbook_on_stats') + + if os.environ.get('_ASSERT_OOPS'): + assert CallbackModule.call_count < 2, "always enabled callback should not " + print("no double callbacks test PASS") diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/v1_only_methods.py ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/v1_only_methods.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/callback_plugins/v1_only_methods.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/callback_plugins/v1_only_methods.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,35 @@ +from __future__ import annotations + +import functools + +from ansible.plugins.callback import CallbackBase + + +class CallbackModule(CallbackBase): + """Test callback that implements exclusively deprecated v1 callback methods.""" + CALLBACK_NEEDS_ENABLED = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.called_v1_method_names: set[str] = set() + + def callback_impl(self, *args, name: str, **kwargs) -> None: + print(f"hi from callback {name!r} with {args=!r} {kwargs=!r}") + self.called_v1_method_names.add(name) + + for v1_method in CallbackBase._v2_v1_method_map.values(): + if not v1_method: + continue + + locals()[v1_method.__name__] = functools.partialmethod(callback_impl, name=v1_method.__name__) + + def playbook_on_stats(self, stats, *args, **kwargs): + if missed_v1_method_calls := ( + {'on_any', + 'runner_on_ok', + 'playbook_on_task_start', + 'runner_on_async_ok', + } - self.called_v1_method_names): + assert False, f"The following v1 callback methods were not invoked as expected: {', '.join(missed_v1_method_calls)}" + + print("v1 callback test PASS") diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/library/noisy.py ansible-core-2.19.1/test/integration/targets/callback-dispatch/library/noisy.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/library/noisy.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/library/noisy.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,14 @@ +from __future__ import annotations + +from ansible.module_utils.basic import AnsibleModule + + +def main() -> None: + m = AnsibleModule({}) + m.warn("This is a warning.") + m.deprecate("This is a deprecation.", version='9999.9') + m.exit_json() + + +if __name__ == '__main__': + main() diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/one-task.yml ansible-core-2.19.1/test/integration/targets/callback-dispatch/one-task.yml --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/one-task.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/one-task.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,4 @@ +- hosts: localhost + gather_facts: no + tasks: + - debug: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/runme.sh ansible-core-2.19.1/test/integration/targets/callback-dispatch/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/runme.sh 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/runme.sh 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -eux -o pipefail + +# the callback itself will raise AssertionError and fail if called > 1x +_ASSERT_OOPS=1 ANSIBLE_STDOUT_CALLBACK=oops_always_enabled ansible-playbook one-task.yml > no_double_callbacks.txt 2>&1 + +grep 'no double callbacks test PASS' no_double_callbacks.txt + +if ANSIBLE_FORCE_COLOR=0 ANSIBLE_STDOUT_CALLBACK=missing_base_class ansible-playbook one-task.yml > missing_base_class.txt 2>&1; then false; else true; fi + +grep "due to missing base class 'CallbackBase'" missing_base_class.txt + +# the callback itself will raise AssertionError and fail if some callback methods do not execute +ANSIBLE_STDOUT_CALLBACK=legacy_warning_display ansible-playbook test_legacy_warning_display.yml "${@}" 2>&1 | tee legacy_warning_out.txt + +grep 'legacy warning display callback test PASS' legacy_warning_out.txt + +# the callback itself will raise AssertionError and fail if some callback methods do not execute +ANSIBLE_STDOUT_CALLBACK=v1_only_methods ansible-playbook test_v1_methods.yml "${@}" | tee v1_methods_out.txt + +grep 'v1 callback test PASS' v1_methods_out.txt diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/test_legacy_warning_display.yml ansible-core-2.19.1/test/integration/targets/callback-dispatch/test_legacy_warning_display.yml --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/test_legacy_warning_display.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/test_legacy_warning_display.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,20 @@ +- hosts: localhost + gather_facts: no + tasks: + - noisy: + register: noisyout + async: 5 + poll: 1 + loop: [1, 2] + + - noisy: + async: 5 + poll: 1 + register: noisyout + + - debug: + when: false + + - debug: + var: 1/0 + ignore_errors: true diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/test_v1_methods.yml ansible-core-2.19.1/test/integration/targets/callback-dispatch/test_v1_methods.yml --- ansible-core-2.19.0~beta6/test/integration/targets/callback-dispatch/test_v1_methods.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-dispatch/test_v1_methods.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,9 @@ +- hosts: localhost + gather_facts: no + tasks: + - debug: + - debug: + loop: [1] + - shell: echo hey + async: 2 + poll: 1 diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/aliases ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/aliases 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/aliases 1970-01-01 00:00:00.000000000 +0000 @@ -1,2 +0,0 @@ -context/controller -shippable/posix/group3 diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,112 +0,0 @@ -from __future__ import annotations - -import collections.abc as c -import functools - -from unittest.mock import MagicMock - -from ansible.executor.task_result import CallbackTaskResult -from ansible.plugins.callback import CallbackBase - - -class CallbackModule(CallbackBase): - # DTFIX5: validate VaultedValue redaction behavior - - CALLBACK_NEEDS_ENABLED = True - seen_tr = [] # track taskresult instances to ensure every call sees a unique instance - - expects_task_result = { - 'v2_runner_on_failed', 'v2_runner_on_ok', 'v2_runner_on_skipped', 'v2_runner_on_unreachable', 'v2_runner_on_async_poll', 'v2_runner_on_async_ok', - 'v2_runner_on_async_failed,', 'v2_playbook_on_import_for_host', 'v2_playbook_on_not_import_for_host', 'v2_on_file_diff', 'v2_runner_item_on_ok', - 'v2_runner_item_on_failed', 'v2_runner_item_on_skipped', 'v2_runner_retry', - } - - expects_no_task_result = { - 'v2_playbook_on_start', 'v2_playbook_on_notify', 'v2_playbook_on_no_hosts_matched', 'v2_playbook_on_no_hosts_remaining', 'v2_playbook_on_task_start', - 'v2_playbook_on_cleanup_task_start', 'v2_playbook_on_handler_task_start', 'v2_playbook_on_vars_prompt', 'v2_playbook_on_play_start', - 'v2_playbook_on_stats', 'v2_playbook_on_include', 'v2_runner_on_start', - } - - # we're abusing runtime assertions to signify failure in this integration test component; ensure they're not disabled by opimizations - try: - assert False - except AssertionError: - pass - else: - raise BaseException("this test does not function when running Python with optimization") - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._display = MagicMock() - - @staticmethod - def get_first_task_result(args: c.Sequence) -> CallbackTaskResult | None: - """Find the first CallbackTaskResult in posargs, since the signatures are dynamic and we didn't want to use inspect signature binding.""" - return next((arg for arg in args if isinstance(arg, CallbackTaskResult)), None) - - def v2_on_any(self, *args, **kwargs) -> None: - """Standard behavioral test for the v2_on_any callback method.""" - print(f'hello from v2_on_any {args=} {kwargs=}') - if result := self.get_first_task_result(args): - assert isinstance(result, CallbackTaskResult) - - assert result is self._current_task_result - - assert result not in self.seen_tr - - self.seen_tr.append(result) - else: - assert self._current_task_result is None - - def v2_method_expects_task_result(self, *args, method_name: str, **_kwargs) -> None: - """Standard behavioral tests for callback methods accepting a task result; wired dynamically.""" - print(f'hello from {method_name}') - result = self.get_first_task_result(args) - - assert result is self._current_task_result - - assert isinstance(result, CallbackTaskResult) - - assert result not in self.seen_tr - - self.seen_tr.append(result) - - has_exception = bool(result.exception) - has_warnings = bool(result.warnings) - has_deprecations = bool(result.deprecations) - - self._display.reset_mock() - - self._handle_exception(result.result) # pops exception from transformed dict - - if has_exception: - assert 'exception' not in result.result - self._display._error.assert_called() - - self._display.reset_mock() - - self._handle_warnings(result.result) # pops warnings/deprecations from transformed dict - - if has_warnings: - assert 'warnings' not in result.result - self._display._warning.assert_called() - - if has_deprecations: - assert 'deprecations' not in result.result - self._display._deprecated.assert_called() - - def v2_method_expects_no_task_result(self, *args, method_name: str, **_kwargs) -> None: - """Standard behavioral tests for non-task result callback methods; wired dynamically.""" - print(f'hello from {method_name}') - - assert self.get_first_task_result(args) is None - - assert self._current_task_result is None - - def __getattribute__(self, item: str) -> object: - if item in CallbackModule.expects_task_result: - return functools.partial(CallbackModule.v2_method_expects_task_result, self, method_name=item) - elif item in CallbackModule.expects_no_task_result: - return functools.partial(CallbackModule.v2_method_expects_no_task_result, self, method_name=item) - else: - return object.__getattribute__(self, item) diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/library/noisy.py ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/library/noisy.py --- ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/library/noisy.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/library/noisy.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,14 +0,0 @@ -from __future__ import annotations - -from ansible.module_utils.basic import AnsibleModule - - -def main() -> None: - m = AnsibleModule({}) - m.warn("This is a warning.") - m.deprecate("This is a deprecation.", version='9999.9') - m.exit_json() - - -if __name__ == '__main__': - main() diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/runme.sh ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/runme.sh 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/runme.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -set -eux - -ANSIBLE_STDOUT_CALLBACK=legacy_warning_display ansible-playbook test.yml "${@}" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/test.yml ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/test.yml --- ansible-core-2.19.0~beta6/test/integration/targets/callback-legacy-warnings/test.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/callback-legacy-warnings/test.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,20 +0,0 @@ -- hosts: localhost - gather_facts: no - tasks: - - noisy: - register: noisyout - async: 5 - poll: 1 - loop: [1, 2] - - - noisy: - async: 5 - poll: 1 - register: noisyout - - - debug: - when: false - - - debug: - var: 1/0 - ignore_errors: true diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/config/lookup_plugins/broken.py ansible-core-2.19.1/test/integration/targets/config/lookup_plugins/broken.py --- ansible-core-2.19.0~beta6/test/integration/targets/config/lookup_plugins/broken.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/config/lookup_plugins/broken.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, Felix Fontein , The Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + + +DOCUMENTATION = r""" +name: broken +short_description: Test input precedence +author: Felix Fontein (@felixfontein) +description: + - Test input precedence. +options: + _terms: + description: + - Ignored. + type: list + elements: str + required: true + some_option: + description: + - The interesting part. + type: str + default: default value + env: + - name: PLAYGROUND_TEST_1 + - name: PLAYGROUND_TEST_2 + vars: + - name: playground_test_1 + - name: playground_test_2 + ini: + - key: playground_test_1 + section: playground + - key: playground_test_2 + section: playground +""" + +EXAMPLES = r"""#""" + +RETURN = r""" +_list: + description: + - The value of O(some_option). + type: list + elements: str +""" + +from ansible.plugins.lookup import LookupBase + + +class LookupModule(LookupBase): + def run(self, terms, variables=None, **kwargs): + """Generate list.""" + self.set_options(var_options=variables, direct=kwargs) + + return [self.get_option("some_option"), *self.get_option_and_origin("some_option")] diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/config/match_option_methods.yml ansible-core-2.19.1/test/integration/targets/config/match_option_methods.yml --- ansible-core-2.19.0~beta6/test/integration/targets/config/match_option_methods.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/config/match_option_methods.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,37 @@ +- hosts: localhost + gather_facts: false + vars: + direct: "{{ query('broken', some_option='foo') }}" + default: "{{ query('broken') }}" + tasks: + - name: Set directly but also have vars + set_fact: + direct_with_vars: "{{ query('broken', some_option='foo') }}" + vars: + playground_test_1: var 1 + playground_test_2: var 2 + - name: Set via vars only + set_fact: + vars_only: "{{ query('broken') }}" + vars: + playground_test_1: var 1 + playground_test_2: var 2 + + - debug: msg={{q('vars', item)}} + loop: + - direct + - default + - direct_with_vars + - vars_only + + - name: now ensure it all worked as expected (simple value, origin value, origin) + assert: + that: + - direct[0] == direct[1] + - direct[2] == 'Direct' + - default[0] == default[1] + - default[2] == 'default' + - direct_with_vars[0] == direct_with_vars[1] + - direct_with_vars[2] == 'Direct' + - vars_only[0] == vars_only[1] + - vars_only[2].startswith('var:') diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/config/runme.sh ansible-core-2.19.1/test/integration/targets/config/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/config/runme.sh 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/config/runme.sh 2025-08-25 19:16:05.000000000 +0000 @@ -43,3 +43,6 @@ # ensure we don't show default templates, but templated defaults [ "$(ansible-config init |grep '={{' -c )" -eq 0 ] + +# test seldom used '_and_origin' api +ansible-playbook match_option_methods.yml "$@" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/connection_ssh/test_ssh_askpass.yml ansible-core-2.19.1/test/integration/targets/connection_ssh/test_ssh_askpass.yml --- ansible-core-2.19.0~beta6/test/integration/targets/connection_ssh/test_ssh_askpass.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/connection_ssh/test_ssh_askpass.yml 2025-08-25 19:16:05.000000000 +0000 @@ -23,7 +23,42 @@ state: restarted when: ansible_facts.system != 'Darwin' - - command: + - name: Test incorrect password + command: + argv: + - ansible + - localhost + - -m + - command + - -a + - id + - -vvv + - -e + - ansible_pipelining=yes + - -e + - ansible_connection=ssh + - -e + - ansible_ssh_password_mechanism=ssh_askpass + - -e + - ansible_user={{ test_user_name }} + - -e + - ansible_password=INCORRECT_PASSWORD + environment: + ANSIBLE_NOCOLOR: "1" + ANSIBLE_FORCE_COLOR: "0" + register: askpass_out + ignore_errors: true + + - assert: + that: + - askpass_out is failed + - askpass_out.stdout is contains('UNREACHABLE') + - askpass_out.stdout is contains('Permission denied') + - askpass_out.stdout is not contains('Permission denied, please try again.') # password tried only once + - askpass_out.stdout is not contains('Traceback (most recent call last)') + + - name: Test correct password + command: argv: - ansible - localhost diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/data_tagging_controller/runme.sh ansible-core-2.19.1/test/integration/targets/data_tagging_controller/runme.sh --- ansible-core-2.19.0~beta6/test/integration/targets/data_tagging_controller/runme.sh 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/data_tagging_controller/runme.sh 2025-08-25 19:16:05.000000000 +0000 @@ -6,7 +6,7 @@ ansible-playbook untrusted_propagation.yml "$@" -e output_dir="${OUTPUT_DIR}" -ANSIBLE_CALLBACK_FORMAT_PRETTY=0 ANSIBLE_WRAP_STDERR=0 _ANSIBLE_TEMPLAR_UNTRUSTED_TEMPLATE_BEHAVIOR=warning ansible-playbook -i hosts output_tests.yml -vvv 2>&1 | tee output.txt +ANSIBLE_CALLBACK_FORMAT_PRETTY=0 _ANSIBLE_TEMPLAR_UNTRUSTED_TEMPLATE_BEHAVIOR=warning ansible-playbook -i hosts output_tests.yml -vvv 2>&1 | tee output.txt ../playbook_output_validator/filter.py actual_stdout.txt actual_stderr.txt < output.txt diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf/tasks/dnf.yml ansible-core-2.19.1/test/integration/targets/dnf/tasks/dnf.yml --- ansible-core-2.19.0~beta6/test/integration/targets/dnf/tasks/dnf.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf/tasks/dnf.yml 2025-08-25 19:16:05.000000000 +0000 @@ -16,6 +16,13 @@ - "not dnf_result.failed | default(False)" - "rpm_result.rc == 1" +# NOTE Now that the dnf module executed, libdnf is installed even for the case +# when testing 'auto_install_module_deps: false' for dnf5 +# and we can print libdnf version we test against for debugging purposes. +- name: libdnf version being tested + debug: + msg: "{{ lookup('pipe', 'rpm -q ' ~ ('python3-libdnf5' if dnf5 else 'python3-libdnf')) }}" + # UNINSTALL AGAIN - name: uninstall sos dnf: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf/tasks/main.yml ansible-core-2.19.1/test/integration/targets/dnf/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/dnf/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,15 +1,14 @@ # (c) 2014, James Tanner +# Copyright: Contributors to the Ansible project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) - set_fact: dnf5: "{{ (ansible_distribution == 'RedHat' and ansible_distribution_major_version | int > 10) or ansible_distribution == 'Fedora' }}" -- when: dnf5 +- when: + - dnf5 + - test_auto_install | default(false) # NOTE ensure one of dnf, dnf-oldest or dnf-latest sets test_auto_install block: - - command: "dnf install -y 'dnf-command(copr)'" - - name: Test against dnf5 nightly build to detect any issues early - command: dnf copr enable -y rpmsoftwaremanagement/dnf-nightly - - name: Ensure module deps are not installed command: dnf remove -y python3-libdnf5 diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf/tasks/repo.yml ansible-core-2.19.1/test/integration/targets/dnf/tasks/repo.yml --- ansible-core-2.19.0~beta6/test/integration/targets/dnf/tasks/repo.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf/tasks/repo.yml 2025-08-25 19:16:05.000000000 +0000 @@ -630,6 +630,87 @@ - provides-package - provided-package +# https://github.com/ansible/ansible/issues/45250 +- block: + - name: Install dinginessentail-1.0, dinginessentail-olive-1.0, landsidescalping-1.0 + dnf: + name: "dinginessentail-1.0,dinginessentail-olive-1.0,landsidescalping-1.0" + state: present + + - name: Upgrade dinginessentail* + dnf: + name: dinginessentail* + state: latest + register: dnf_result + + - name: Check dinginessentail with rpm + shell: rpm -q dinginessentail + register: rpm_result + + - name: Verify update of dinginessentail + assert: + that: + - "rpm_result.stdout.startswith('dinginessentail-1.1-1')" + + - name: Check dinginessentail-olive with rpm + shell: rpm -q dinginessentail-olive + register: rpm_result + + - name: Verify update of dinginessentail-olive + assert: + that: + - "rpm_result.stdout.startswith('dinginessentail-olive-1.1-1')" + + - name: Check landsidescalping with rpm + shell: rpm -q landsidescalping + register: rpm_result + + - name: Verify landsidescalping did NOT get updated + assert: + that: + - "rpm_result.stdout.startswith('landsidescalping-1.0-1')" + + - name: Verify yum module outputs + assert: + that: + - "dnf_result is changed" + - "'msg' in dnf_result" + - "'rc' in dnf_result" + - "'results' in dnf_result" + always: + - name: Clean up + dnf: + name: dinginessentail,dinginessentail-olive,landsidescalping + state: absent + +- name: test allow_downgrade + block: + - dnf: + name: dinginessentail-1.1 + state: present + + - dnf: + name: dinginessentail-1.0 + state: present + allow_downgrade: true + - dnf: + name: dinginessentail-1.1 + state: present + + - dnf: + name: dinginessentail-1.0 + state: present + allow_downgrade: false + register: r + + - assert: + that: + - r is not changed + always: + - dnf: + name: dinginessentail + state: absent + - name: Test failures occured during loading repositories are properly handled vars: repo_name: test-non-existing-gpgkey-file @@ -651,8 +732,21 @@ - assert: that: - r is failed - - r.msg is contains("Couldn't open file") + # account for two different messages depending on the libdnf version + - r.msg is contains("Couldn't open file") or r.msg is contains("Failed to download metadata") always: - file: name: /etc/yum.repos.d/{{ repo_name }}.repo state: absent + + +- name: Attempt to install a package with invalid name + dnf: + name: invalid[package_name] + register: r + ignore_errors: true + +- assert: + that: + - r is failed + - r.msg is contains("Failed to install some of the specified packages") diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf-latest/aliases ansible-core-2.19.1/test/integration/targets/dnf-latest/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/dnf-latest/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf-latest/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,4 @@ +context/target +destructive +shippable/posix/group7 +needs/target/dnf diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf-latest/tasks/main.yml ansible-core-2.19.1/test/integration/targets/dnf-latest/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/dnf-latest/tasks/main.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf-latest/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,16 @@ +- when: ansible_distribution == "Fedora" + block: + - command: "dnf install -y 'dnf-command(copr)'" + + - name: Test against dnf5 nightly build to detect any issues early + command: dnf copr enable -y rpmsoftwaremanagement/dnf-nightly + + - name: Run DNF tests + include_role: + name: dnf + vars: + # Since dnf-latest is the only dnf target that installs the latest version + # test the 'auto_install_module_deps' feature here. + test_auto_install: true + always: + - command: dnf copr disable -y rpmsoftwaremanagement/dnf-nightly diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf-oldest/aliases ansible-core-2.19.1/test/integration/targets/dnf-oldest/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/dnf-oldest/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf-oldest/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,4 @@ +context/target +destructive +shippable/posix/group7 +needs/target/dnf diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/dnf-oldest/tasks/main.yml ansible-core-2.19.1/test/integration/targets/dnf-oldest/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/dnf-oldest/tasks/main.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/dnf-oldest/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,13 @@ +- when: ansible_distribution == "Fedora" + block: + - name: Ensure libdnf is not installed + command: dnf remove -y python3-libdnf5 + + - name: Downgrade dnf to the original version + command: dnf install -y --disable-repo=* --enable-repo=fedora python3-libdnf5 + + - name: Run DNF tests + include_role: + name: dnf + vars: + test_auto_install: false diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/failed_when/aliases ansible-core-2.19.1/test/integration/targets/failed_when/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/failed_when/aliases 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/failed_when/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -1,2 +1,3 @@ +gather_facts/no shippable/posix/group4 context/controller diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/failed_when/tasks/main.yml ansible-core-2.19.1/test/integration/targets/failed_when/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/failed_when/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/failed_when/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -24,6 +24,8 @@ - assert: that: - "'failed' in result and not result.failed" + - result.exception is undefined + - result.failed_when_suppressed_exception is undefined - name: command rc 0 failed_when_result False shell: exit 0 @@ -35,6 +37,8 @@ that: - "'failed' in result and not result.failed" - "'failed_when_result' in result and not result.failed_when_result" + - result.exception is undefined + - result.failed_when_suppressed_exception is undefined - name: command rc 1 failed_when_result True shell: exit 1 @@ -46,6 +50,8 @@ that: - "'failed' in result and result.failed" - "'failed_when_result' in result and result.failed_when_result" + - result.exception is defined + - result.failed_when_suppressed_exception is undefined - name: command rc 1 failed_when_result undef shell: exit 1 @@ -55,6 +61,8 @@ - assert: that: - "'failed' in result and result.failed" + - result.exception is defined + - result.failed_when_suppressed_exception is undefined - name: command rc 1 failed_when_result False shell: exit 1 @@ -66,6 +74,8 @@ that: - "'failed' in result and not result.failed" - "'failed_when_result' in result and not result.failed_when_result" + - result.exception is undefined + - result.failed_when_suppressed_exception is defined - name: invalid conditional command: echo foo diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/filter_core/tasks/main.yml ansible-core-2.19.1/test/integration/targets/filter_core/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/filter_core/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/filter_core/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -384,6 +384,7 @@ that: - '"%Y-%m-%d"|strftime(1585247522) == "2020-03-26"' - '"%Y-%m-%d"|strftime("1585247522.0") == "2020-03-26"' + - '"%Y-%m-%d"|strftime("1585247522.0", utc=True) == "2020-03-26"' - '("%Y"|strftime(None)).startswith("20")' # Current date, can't check much there. - strftime_fail is failed - '"Invalid value for epoch value" in strftime_fail.msg' @@ -538,15 +539,11 @@ ignore_errors: yes register: password_hash_2 -- name: Verify password_hash - assert: - that: - - "'what in the WORLD is up?'|password_hash|length in (120, 106)" - # This throws a vastly different error on py2 vs py3, so we just check - # that it's a failure, not a substring of the exception. - - password_hash_1 is failed - - password_hash_2 is failed - - "'is not in the list of supported passlib algorithms' in password_hash_2.msg" +- name: Verify password_hash throws on weird rounds + set_fact: + foo: '{{ "hey" | password_hash(rounds=1) }}' + ignore_errors: yes + register: password_hash_3 - name: test using passlib with an unsupported hash type set_fact: @@ -554,8 +551,16 @@ ignore_errors: yes register: unsupported_hash_type -- assert: +- name: Verify password_hash + assert: that: + - "'what in the WORLD is up?'|password_hash|length in (120, 106)" + - password_hash_1 is failed + - "'salt_size must be an integer' in password_hash_1.msg" + - password_hash_2 is failed + - "'is not in the list of supported passlib algorithms' in password_hash_2.msg" + - password_hash_3 is failed + - "'Could not hash the secret' in password_hash_3.msg" - "'msdcc is not in the list of supported passlib algorithms' in unsupported_hash_type.msg" - name: Verify to_uuid throws on weird namespace @@ -832,3 +837,26 @@ splitty: - "1,2,3" - "4,5,6" + +- name: test to_yaml and to_nice_yaml + include_tasks: to_yaml.yml + +- name: test to_json and to_nice_json + include_tasks: to_json.yml + +- name: commonpath filter + set_fact: + msg: "{{ ['/foo/bar/foobar','/foo/bar'] | commonpath }}" + register: commonpath_01 + +- name: commonpath filter raises exception + set_fact: + msg: "{{ '/foo/bar/foobar' | commonpath }}" + register: commonpath_02 + ignore_errors: yes + +- name: Check if commonpath works + assert: + that: + - '"/foo/bar" in commonpath_01.ansible_facts.msg' + - "'|commonpath expects' in commonpath_02.msg" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/filter_core/tasks/to_json.yml ansible-core-2.19.1/test/integration/targets/filter_core/tasks/to_json.yml --- ansible-core-2.19.0~beta6/test/integration/targets/filter_core/tasks/to_json.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/filter_core/tasks/to_json.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,28 @@ +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Check if to_json works + set_fact: + msg: "{{ list_one | to_json }}" + vars: + list_one: + - one + - two + register: json_01 + +- name: Check if to_nice_json works + set_fact: + msg: "{{ list_one | to_json }}" + vars: + list_one: + - one + - two + register: json_02 + +- name: Assert + assert: + that: + - not json_01.failed + - json_01.ansible_facts.msg == "[\"one\", \"two\"]" + - not json_02.failed + - json_02.ansible_facts.msg == "[\"one\", \"two\"]" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/filter_core/tasks/to_yaml.yml ansible-core-2.19.1/test/integration/targets/filter_core/tasks/to_yaml.yml --- ansible-core-2.19.0~beta6/test/integration/targets/filter_core/tasks/to_yaml.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/filter_core/tasks/to_yaml.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,27 @@ +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Check to_yaml exception handling + set_fact: + msg: "{{ 'thing' | to_yaml(nonexistent_argument=True) }}" + register: yaml_test_01 + ignore_errors: yes + +- name: Check default_style + set_fact: + msg: "{{ 'thing' | to_yaml(default_style='|') }}" + register: yaml_test_02 + ignore_errors: yes + +- name: Check canonical + set_fact: + msg: "{{ 'thing' | to_yaml(canonical=True) }}" + register: yaml_test_03 + ignore_errors: yes + +- name: Test to_yaml + assert: + that: + - yaml_test_01.failed + - yaml_test_02.ansible_facts.msg == "|-\n thing\n" + - yaml_test_03.ansible_facts.msg == "---\n!!str \"thing\"\n" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/git/tasks/specific-revision.yml ansible-core-2.19.1/test/integration/targets/git/tasks/specific-revision.yml --- ansible-core-2.19.0~beta6/test/integration/targets/git/tasks/specific-revision.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/git/tasks/specific-revision.yml 2025-08-25 19:16:05.000000000 +0000 @@ -139,7 +139,7 @@ that: - 'git_result.stdout == test_branch_ref_head_id' -# Test that a forced shallow checkout referincing branch only always fetches latest head +# Test that a forced shallow checkout referencing branch only always fetches latest head - name: SPECIFIC-REVISION | clear checkout_dir file: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/include_import/playbook/playbook_using_a_var.yml ansible-core-2.19.1/test/integration/targets/include_import/playbook/playbook_using_a_var.yml --- ansible-core-2.19.0~beta6/test/integration/targets/include_import/playbook/playbook_using_a_var.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/include_import/playbook/playbook_using_a_var.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,6 @@ +- hosts: localhost + gather_facts: no + tasks: + - name: Verify an imported playbook can see a var it was given + assert: + that: pb_var == 'hello' diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/include_import/playbook/test_import_playbook.yml ansible-core-2.19.1/test/integration/targets/include_import/playbook/test_import_playbook.yml --- ansible-core-2.19.0~beta6/test/integration/targets/include_import/playbook/test_import_playbook.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/include_import/playbook/test_import_playbook.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,14 +1,14 @@ # Test and validate playbook import -- import_playbook: playbook1.yml +- import_playbook: '{{ "playbook1.yml" }}' # ensure templating occurs - import_playbook: validate1.yml # Test and validate conditional import - import_playbook: playbook2.yml when: no -- import_playbook: validate2.yml +- ansible.builtin.import_playbook: validate2.yml # intentionally testing ansible.builtin -- import_playbook: playbook3.yml +- ansible.legacy.import_playbook: playbook3.yml # intentionally testing ansible.legacy - import_playbook: playbook4.yml when: include_next_playbook @@ -20,3 +20,15 @@ # https://github.com/ansible/ansible/issues/59548 - import_playbook: sub_playbook/sub_playbook.yml + +- name: Use set_fact to declare a variable + hosts: localhost + gather_facts: no + tasks: + - set_fact: + a_var_from_set_fact: hello + +- name: Verify vars for import_playbook are not templated too early + import_playbook: playbook_using_a_var.yml + vars: + pb_var: "{{ a_var_from_set_fact }}" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/include_import_tasks_nested/tasks/main.yml ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/include_import_tasks_nested/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -9,3 +9,5 @@ - assert: that: - nested_adjacent_count|int == 2 + +- import_tasks: "{{ role_path }}/tests/main.yml" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/include_import_tasks_nested/tests/main.yml ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tests/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/include_import_tasks_nested/tests/main.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/include_import_tasks_nested/tests/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1 @@ +- import_tasks: tests_relative.yml diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/lookup_ini/test_errors.yml ansible-core-2.19.1/test/integration/targets/lookup_ini/test_errors.yml --- ansible-core-2.19.0~beta6/test/integration/targets/lookup_ini/test_errors.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/lookup_ini/test_errors.yml 2025-08-25 19:16:05.000000000 +0000 @@ -2,28 +2,25 @@ hosts: testhost tasks: - - name: Test for failure on Python 3 - when: ansible_facts.python.version_info[0] >= 3 - block: - - name: Lookup a file with duplicate keys - debug: - msg: "{{ lookup('ini', 'name', file='duplicate.ini', section='reggae') }}" - ignore_errors: yes - register: duplicate + - name: Lookup a file with duplicate keys + debug: + msg: "{{ lookup('ini', 'name', file='duplicate.ini', section='reggae') }}" + ignore_errors: yes + register: duplicate - - name: Lookup a file with keys that differ only in case - debug: - msg: "{{ lookup('ini', 'name', file='duplicate_case_check.ini', section='reggae') }}" - ignore_errors: yes - register: duplicate_case_sensitive + - name: Lookup a file with keys that differ only in case + debug: + msg: "{{ lookup('ini', 'name', file='duplicate_case_check.ini', section='reggae') }}" + ignore_errors: yes + register: duplicate_case_sensitive - - name: Ensure duplicate key errors were handled properly - assert: - that: - - duplicate is failed - - "'Duplicate option in' in duplicate.msg" - - duplicate_case_sensitive is failed - - "'Duplicate option in' in duplicate_case_sensitive.msg" + - name: Ensure duplicate key errors were handled properly + assert: + that: + - duplicate is failed + - "'Duplicate option in' in duplicate.msg" + - duplicate_case_sensitive is failed + - "'Duplicate option in' in duplicate_case_sensitive.msg" - name: Lookup a file with a missing section debug: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/lookup_template/tasks/ansible_managed.yml ansible-core-2.19.1/test/integration/targets/lookup_template/tasks/ansible_managed.yml --- ansible-core-2.19.0~beta6/test/integration/targets/lookup_template/tasks/ansible_managed.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/lookup_template/tasks/ansible_managed.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,4 +1,3 @@ -# deprecated: description='ansible_managed has been removed' core_version='2.23' - name: invoke template lookup with content using default injected `ansible_managed` debug: msg: "{{ lookup('template', 'uses_ansible_managed.j2') }}" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/module-serialization-profiles/aliases ansible-core-2.19.1/test/integration/targets/module-serialization-profiles/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/module-serialization-profiles/aliases 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/module-serialization-profiles/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -1,2 +1,3 @@ context/target shippable/posix/group1 +env/set/_ANSIBLE_MODULE_METADATA/1 diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/module_utils_Ansible.ModuleUtils.CommandUtil/library/command_util_test.ps1 ansible-core-2.19.1/test/integration/targets/module_utils_Ansible.ModuleUtils.CommandUtil/library/command_util_test.ps1 --- ansible-core-2.19.0~beta6/test/integration/targets/module_utils_Ansible.ModuleUtils.CommandUtil/library/command_util_test.ps1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/module_utils_Ansible.ModuleUtils.CommandUtil/library/command_util_test.ps1 2025-08-25 19:16:05.000000000 +0000 @@ -114,7 +114,7 @@ $env_not_present = $actual.stdout -split "`r`n" | Where-Object { $_ -eq "TESTENV=test" } $env_present = $actual.stdout -split "`r`n" | Where-Object { $_ -eq "TESTENV2=testing" } if ($null -ne $env_not_present) { - Fail-Json -obj $result -message "Test $test_name failed`nenvironment variabel TESTENV found in stdout when it should be`n$($actual.stdout)" + Fail-Json -obj $result -message "Test $test_name failed`nenvironment variable TESTENV found in stdout when it should be`n$($actual.stdout)" } if ($null -eq $env_present) { Fail-json -obj $result -message "Test $test_name failed`nenvironment variable TESTENV2 not found in stdout`n$($actual.stdout)" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/pause/test-pause.py ansible-core-2.19.1/test/integration/targets/pause/test-pause.py --- ansible-core-2.19.0~beta6/test/integration/targets/pause/test-pause.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/pause/test-pause.py 2025-08-25 19:16:05.000000000 +0000 @@ -32,7 +32,7 @@ # -- Plain pause -- # playbook = 'pause-1.yml' -# Case 1 - Contiune with enter +# Case 1 - Continue with enter pause_test = pexpect.spawn( 'ansible-playbook', args=[playbook] + args, @@ -86,7 +86,7 @@ # -- Custom Prompt -- # playbook = 'pause-2.yml' -# Case 1 - Contiune with enter +# Case 1 - Continue with enter pause_test = pexpect.spawn( 'ansible-playbook', args=[playbook] + args, @@ -102,7 +102,7 @@ pause_test.close() -# Case 2 - Contiune with C +# Case 2 - Continue with C pause_test = pexpect.spawn( 'ansible-playbook', args=[playbook] + args, @@ -156,7 +156,7 @@ pause_test.expect(pexpect.EOF) pause_test.close() -# Case 2 - Contiune with Ctrl + C, C +# Case 2 - Continue with Ctrl + C, C pause_test = pexpect.spawn( 'ansible-playbook', args=[playbook] + args, @@ -214,7 +214,7 @@ pause_test.expect(pexpect.EOF) pause_test.close() -# Case 2 - Contiune with Ctrl + C, C +# Case 2 - Continue with Ctrl + C, C pause_test = pexpect.spawn( 'ansible-playbook', args=[playbook] + args, diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/prepare_http_tests/tasks/windows.yml ansible-core-2.19.1/test/integration/targets/prepare_http_tests/tasks/windows.yml --- ansible-core-2.19.0~beta6/test/integration/targets/prepare_http_tests/tasks/windows.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/prepare_http_tests/tasks/windows.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,4 +1,4 @@ -# Server 2008 R2 uses a 3rd party program to foward the ports and it may +# Server 2008 R2 uses a 3rd party program to forward the ports and it may # not be ready straight away, we give it at least 5 minutes before # conceding defeat - name: Windows - make sure the port forwarder is active diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/protomatter/tasks/main.yml ansible-core-2.19.1/test/integration/targets/protomatter/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/protomatter/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/protomatter/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -31,6 +31,8 @@ - still_untrusted_number | ansible._protomatter.tag_names == ['Origin'] # does not have TrustedAsTemplate - missing_var | ansible._protomatter.apply_trust is undefined +# DTFIX-FUTURE: protomatter should be available from unit tests, either always or via a fixture opt-in + - name: test the dump_object filter assert: that: @@ -40,34 +42,22 @@ - lookup('synthetic_plugin_info') | type_debug == 'PluginInfo' - lookup('synthetic_plugin_info') | ansible._protomatter.dump_object | type_debug == 'dict' - lookup('synthetic_plugin_info') | ansible._protomatter.dump_object == expected_plugin_info + - (syntax_error | ansible._protomatter.dump_object).exception.message is contains 'Syntax error in template' vars: some_var: Hello expected_plugin_info: resolved_name: ns.col.module type: module + syntax_error: '{{ bogus syntax oops DSYFF*&H#$*F#$@F' - name: test the python_literal_eval filter assert: that: - "'[1, 2]' | ansible._protomatter.python_literal_eval == [1, 2]" - # DTFIX5: This test requires fixing plugin captured error handling first. - # Once fixed, the error handling test below can be replaced by this assert. - # - "'x[1, 2]' | ansible._protomatter.python_literal_eval | true_type == 'CapturedExceptionMarker'" + - "'x[1, 2]' | ansible._protomatter.python_literal_eval | ansible._protomatter.true_type == 'CapturedExceptionMarker'" - "'x[1, 2]' | ansible._protomatter.python_literal_eval(ignore_errors=True) == 'x[1, 2]'" - missing_var | ansible._protomatter.python_literal_eval is undefined -- name: test the python_literal_eval filter with an error - assert: - that: - - "'x[1, 2]' | ansible._protomatter.python_literal_eval" - ignore_errors: true - register: failing_python_literal_eval - -- assert: - that: - - failing_python_literal_eval is failed - - failing_python_literal_eval.msg is contains "malformed node or string" - - name: test non-string input failure to python_literal_eval filter assert: that: 123 | ansible._protomatter.python_literal_eval diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/roles_arg_spec/test.yml ansible-core-2.19.1/test/integration/targets/roles_arg_spec/test.yml --- ansible-core-2.19.0~beta6/test/integration/targets/roles_arg_spec/test.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/roles_arg_spec/test.yml 2025-08-25 19:16:05.000000000 +0000 @@ -188,29 +188,6 @@ c_list: [] c_raw: ~ 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 block: - name: "Test import_role of role C (missing c_int)" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/script/files/exit_1.sh ansible-core-2.19.1/test/integration/targets/script/files/exit_1.sh --- ansible-core-2.19.0~beta6/test/integration/targets/script/files/exit_1.sh 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/script/files/exit_1.sh 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exit 1 diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/script/tasks/main.yml ansible-core-2.19.1/test/integration/targets/script/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/script/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/script/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -58,6 +58,29 @@ - "script_result0.rc == 0" - "script_result0.stdout == 'win'" +- name: Basic non-zero RC + script: exit_1.sh + ignore_errors: true + register: non_zero_rc + +- name: Ensure non-zero RC result + assert: + that: + - non_zero_rc is failed + - non_zero_rc.rc == 1 + +- name: Exercise failed_when on non-zero RC + script: exit_1.sh + register: non_zero_rc_failed_when + failed_when: false + +- name: Ensure failed_when executed + assert: + that: + - non_zero_rc_failed_when is success + - non_zero_rc_failed_when.rc == 1 + - non_zero_rc_failed_when.failed_when_result is false + - name: Execute a script with a space in the path script: "'space path/test.sh'" register: _space_path_test diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/set_fact/set_fact.yml ansible-core-2.19.1/test/integration/targets/set_fact/set_fact.yml --- ansible-core-2.19.0~beta6/test/integration/targets/set_fact/set_fact.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/set_fact/set_fact.yml 2025-08-25 19:16:05.000000000 +0000 @@ -17,7 +17,7 @@ - name: Attempt to use a template to set an invalid variable name with set_fact set_fact: - "{{ 'continue' }}": value + "{{ 'true' }}": value register: result ignore_errors: yes @@ -25,7 +25,7 @@ assert: that: - result is failed - - result.msg is contains "Invalid variable name 'continue'" + - result.msg is contains "Invalid variable name 'true'" - name: Attempt to use a template to set an invalid variable name type with set_fact set_fact: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/task-args/tasks/main.yml ansible-core-2.19.1/test/integration/targets/task-args/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/task-args/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/task-args/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -1,4 +1,4 @@ -# DTFIX-FUTURE: finish the full precedence decscription in DT docs and include it here +# DTFIX-FUTURE: finish the full precedence description in DT docs and include it here - name: validate templated raw params from the action statement and the args keyword are merged echo: '{{ vp }}' diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/task-esoterica/action_plugins/echo.py ansible-core-2.19.1/test/integration/targets/task-esoterica/action_plugins/echo.py --- ansible-core-2.19.0~beta6/test/integration/targets/task-esoterica/action_plugins/echo.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/task-esoterica/action_plugins/echo.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,8 @@ +from __future__ import annotations + +from ansible.plugins.action import ActionBase + + +class ActionModule(ActionBase): + def run(self, tmp=None, task_vars=None): + return dict(action_args=self._task.args) diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/task-esoterica/aliases ansible-core-2.19.1/test/integration/targets/task-esoterica/aliases --- ansible-core-2.19.0~beta6/test/integration/targets/task-esoterica/aliases 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/task-esoterica/aliases 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,4 @@ +shippable/posix/group3 +context/controller +gather_facts/no +env/set/ANSIBLE_DEPRECATION_WARNINGS/yes diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/task-esoterica/tasks/main.yml ansible-core-2.19.1/test/integration/targets/task-esoterica/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/task-esoterica/tasks/main.yml 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/task-esoterica/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,39 @@ +# Validates some deprecated (previously untested/undocumented) playbook syntax until support is removed. + +- name: action-as-dict (static-only, template-to-dict never worked anyway) + action: + module: "{{ 'echo' }}" + args: "{{ echo_args }}" # note that `module` and `args` are children of `action` + vars: + echo_args: + a: 1 + register: action_as_dict + +- assert: + that: action_as_dict.action_args == action_args + vars: + action_args: + a: 1 + +- name: kv and task args at the same time + echo: kv1=kv + args: + kv1: task_arg + kv2: task_arg + register: kv_and_task_args + +- assert: + that: kv_and_task_args.action_args == action_args + vars: + action_args: + kv1: kv + kv2: task_arg + +- name: task args with no value + echo: + args: + register: args_were_none + +- assert: + that: + - args_were_none.action_args == {} diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/templating/tasks/plugin_errors.yml ansible-core-2.19.1/test/integration/targets/templating/tasks/plugin_errors.yml --- ansible-core-2.19.0~beta6/test/integration/targets/templating/tasks/plugin_errors.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/templating/tasks/plugin_errors.yml 2025-08-25 19:16:05.000000000 +0000 @@ -60,3 +60,13 @@ that: - error is failed - error.msg is contains("lookup plugin 'nope' was not found") + +- name: verify plugin errors are captured + assert: + that: + - (syntax_error | ansible._protomatter.dump_object).exception.message is contains "Syntax error in template" + - (undef(0) | ansible._protomatter.dump_object).exception.message is contains "argument must be of type" + - (lookup('pipe', 'exit 1') | ansible._protomatter.dump_object).exception.message is contains "lookup plugin 'pipe' failed" + - ('{' | from_json | ansible._protomatter.dump_object).exception.message is contains "Expecting property name enclosed in double quotes" + vars: + syntax_error: "{{ #'" diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/var_reserved/tasks/main.yml ansible-core-2.19.1/test/integration/targets/var_reserved/tasks/main.yml --- ansible-core-2.19.0~beta6/test/integration/targets/var_reserved/tasks/main.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/var_reserved/tasks/main.yml 2025-08-25 19:16:05.000000000 +0000 @@ -25,9 +25,9 @@ - name: check they all complain about bad defined var assert: that: - - item.stderr == warning_message + - item.stderr.startswith(warning_message) loop: '{{play_out.results}}' loop_control: label: '{{item.item.file}}' vars: - warning_message: "[WARNING]: {{ canary }} '{{ item.item.name }}'." + warning_message: "[WARNING]: {{ canary }} '{{ item.item.name }}'.\nOrigin: " diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 ansible-core-2.19.1/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 --- ansible-core-2.19.0~beta6/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 2025-08-25 19:16:05.000000000 +0000 @@ -56,7 +56,7 @@ The values in the list should be the fully qualified name of the plugin as referenced in Ansible. The value can also optionally include the extension - of the file if the FQN is ambigious, e.g. collection util that has both a + of the file if the FQN is ambiguous, e.g. collection util that has both a PowerShell and C# util of the same name. Here are some examples for the various content types: diff -Nru ansible-core-2.19.0~beta6/test/integration/targets/win_app_control/test_manifest.yml ansible-core-2.19.1/test/integration/targets/win_app_control/test_manifest.yml --- ansible-core-2.19.0~beta6/test/integration/targets/win_app_control/test_manifest.yml 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/integration/targets/win_app_control/test_manifest.yml 2025-08-25 19:16:05.000000000 +0000 @@ -125,7 +125,7 @@ - >- res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list entry for " ~ module_hash ~ " to contain a mode of 'Trusted' or 'Unsupported' but got ''.") -- name: create manfiest with invalid Mode subkey value +- name: create manifest with invalid Mode subkey value ansible.builtin.import_tasks: create_manifest.yml vars: manifest_file: manifest_v1_invalid_mode_subkey.psd1 diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -59,6 +59,10 @@ TestConfig, ) +from .debugging import ( + initialize_debugger, +) + def main(cli_args: t.Optional[list[str]] = None) -> None: """Wrapper around the main program function to invoke cleanup functions at exit.""" @@ -77,6 +81,7 @@ display.redact = config.redact display.color = config.color display.fd = sys.stderr if config.display_stderr else sys.stdout + initialize_debugger(config) configure_timeout(config) report_locale(isinstance(config, TestConfig) and not config.delegate) diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/ansible_util.py ansible-core-2.19.1/test/lib/ansible_test/_internal/ansible_util.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/ansible_util.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/ansible_util.py 2025-08-25 19:16:05.000000000 +0000 @@ -330,6 +330,6 @@ if args.verbosity: cmd.append('-%s' % ('v' * args.verbosity)) - install_requirements(args, args.controller_python, ansible=True) # run_playbook() + install_requirements(args, None, args.controller_python, ansible=True) # run_playbook() env = ansible_environment(args) intercept_python(args, args.controller_python, cmd, env, capture=capture) diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/classification/python.py ansible-core-2.19.1/test/lib/ansible_test/_internal/classification/python.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/classification/python.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/classification/python.py 2025-08-25 19:16:05.000000000 +0000 @@ -221,6 +221,12 @@ else: parts = module.split('.') + if path.endswith('/__init__.py'): + # Ensure the correct relative module is calculated for both not_init.py and __init__.py: + # a/b/not_init.py -> a.b.not_init # used as-is + # a/b/__init__.py -> a.b # needs "__init__" part appended to ensure relative imports work + parts.append('__init__') + if level >= len(parts): display.warning('Cannot resolve relative import "%s%s" above module "%s" at %s:%d' % ('.' * level, name, module, path, lineno)) absolute_name = 'relative.abovelevel' diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/cli/commands/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/cli/commands/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/cli/commands/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/cli/commands/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -163,11 +163,6 @@ ) testing.add_argument( - '--metadata', - help=argparse.SUPPRESS, - ) - - testing.add_argument( '--base-branch', metavar='BRANCH', help='base branch used for change detection', diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/cli/environments.py ansible-core-2.19.1/test/lib/ansible_test/_internal/cli/environments.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/cli/environments.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/cli/environments.py 2025-08-25 19:16:05.000000000 +0000 @@ -5,6 +5,7 @@ import argparse import enum import functools +import os import typing as t from ..constants import ( @@ -147,8 +148,20 @@ help='install command requirements', ) + global_parser.add_argument( + '--host-path', + help=argparse.SUPPRESS, # for internal use only by ansible-test + ) + + global_parser.add_argument( + '--metadata', + default=os.environ.get('ANSIBLE_TEST_METADATA_PATH'), + help=argparse.SUPPRESS, # for internal use only by ansible-test + ) + add_global_remote(global_parser, controller_mode) add_global_docker(global_parser, controller_mode) + add_global_debug(global_parser) def add_composite_environment_options( @@ -161,11 +174,6 @@ composite_parser = t.cast(argparse.ArgumentParser, parser.add_argument_group( title='composite environment arguments (mutually exclusive with "environment arguments" above)')) - composite_parser.add_argument( - '--host-path', - help=argparse.SUPPRESS, - ) - action_types: list[t.Type[CompositeAction]] = [] def register_action_type(action_type: t.Type[CompositeAction]) -> t.Type[CompositeAction]: @@ -440,6 +448,44 @@ ) +def add_global_debug( + parser: argparse.ArgumentParser, +) -> None: + """Add global debug options.""" + # These `--dev-*` options are experimental features that may change or be removed without regard for backward compatibility. + # Additionally, they're features that are not likely to be used by most users. + # To avoid confusion, they're hidden from `--help` and tab completion by default, except for ansible-core-ci users. + suppress = None if get_ci_provider().supports_core_ci_auth() else argparse.SUPPRESS + + parser.add_argument( + '--dev-debug-on-demand', + action='store_true', + default=False, + help=suppress or 'enable remote debugging only under a debugger', + ) + + parser.add_argument( + '--dev-debug-cli', + action='store_true', + default=False, + help=suppress or 'enable remote debugging for the Ansible CLI', + ) + + parser.add_argument( + '--dev-debug-ansiballz', + action='store_true', + default=False, + help=suppress or 'enable remote debugging for AnsiballZ modules', + ) + + parser.add_argument( + '--dev-debug-self', + action='store_true', + default=False, + help=suppress or 'enable remote debugging for ansible-test', + ) + + def add_environment_docker( exclusive_parser: argparse.ArgumentParser, environments_parser: argparse.ArgumentParser, diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/coverage/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/coverage/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/coverage/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/coverage/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -77,7 +77,7 @@ def initialize_coverage(args: CoverageConfig, host_state: HostState) -> coverage_module: """Delegate execution if requested, install requirements, then import and return the coverage module. Raises an exception if coverage is not available.""" configure_pypi_proxy(args, host_state.controller_profile) # coverage - install_requirements(args, host_state.controller_profile.python, coverage=True) # coverage + install_requirements(args, host_state.controller_profile, host_state.controller_profile.python, coverage=True) # coverage try: import coverage diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/integration/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/integration/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/integration/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/integration/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -105,6 +105,7 @@ HostProfile, PosixProfile, SshTargetHostProfile, + DebuggableProfile, ) from ...provisioning import ( @@ -459,10 +460,10 @@ if isinstance(target_profile, ControllerProfile): if host_state.controller_profile.python.path != target_profile.python.path: - install_requirements(args, target_python, command=True, controller=False) # integration + install_requirements(args, target_profile, target_python, command=True, controller=False) # integration elif isinstance(target_profile, SshTargetHostProfile): connection = target_profile.get_controller_target_connections()[0] - install_requirements(args, target_python, command=True, controller=False, connection=connection) # integration + install_requirements(args, target_profile, target_python, command=True, controller=False, connection=connection) # integration coverage_manager = CoverageManager(args, host_state, inventory_path) coverage_manager.setup() @@ -616,7 +617,7 @@ if args.verbosity: cmd.append('-' + ('v' * args.verbosity)) - env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env) + env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env, host_state) cwd = os.path.join(test_env.targets_dir, target.relative_path) env.update( @@ -737,7 +738,7 @@ if args.verbosity: cmd.append('-' + ('v' * args.verbosity)) - env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env) + env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env, host_state) cwd = test_env.integration_dir env.update( @@ -793,6 +794,7 @@ ansible_config: t.Optional[str], env_config: t.Optional[CloudEnvironmentConfig], test_env: IntegrationEnvironment, + host_state: HostState, ) -> dict[str, str]: """Return a dictionary of environment variables to use when running the given integration test target.""" env = ansible_environment(args, ansible_config=ansible_config) @@ -813,6 +815,9 @@ if args.debug_strategy: env.update(ANSIBLE_STRATEGY='debug') + if isinstance(host_state.controller_profile, DebuggableProfile): + env.update(host_state.controller_profile.get_ansible_cli_environment_variables()) + if 'non_local/' in target.aliases: if args.coverage: display.warning('Skipping coverage reporting on Ansible modules for non-local test: %s' % target.name) @@ -820,6 +825,7 @@ env.update(ANSIBLE_TEST_REMOTE_INTERPRETER='') env.update(integration) + env.update(target.env_set) return env @@ -974,6 +980,13 @@ """Install requirements after bootstrapping and delegation.""" if isinstance(host_profile, ControllerHostProfile) and host_profile.controller: configure_pypi_proxy(host_profile.args, host_profile) # integration, windows-integration, network-integration - install_requirements(host_profile.args, host_profile.python, ansible=True, command=True) # integration, windows-integration, network-integration + + install_requirements( # integration, windows-integration, network-integration + args=host_profile.args, + host_profile=host_profile, + python=host_profile.python, + ansible=True, + command=True, + ) elif isinstance(host_profile, PosixProfile) and not isinstance(host_profile, ControllerProfile): configure_pypi_proxy(host_profile.args, host_profile) # integration diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/integration/cloud/httptester.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/integration/cloud/httptester.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/integration/cloud/httptester.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/integration/cloud/httptester.py 2025-08-25 19:16:05.000000000 +0000 @@ -71,7 +71,7 @@ return # Read the password from the container environment. - # This allows the tests to work when re-using an existing container. + # This allows the tests to work when reusing an existing container. # The password is marked as sensitive, since it may differ from the one we generated. krb5_password = descriptor.details.container.env_dict()[KRB5_PASSWORD_ENV] display.sensitive.add(krb5_password) diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/integration/coverage.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/integration/coverage.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/integration/coverage.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/integration/coverage.py 2025-08-25 19:16:05.000000000 +0000 @@ -167,7 +167,7 @@ coverage_config_path = os.path.join(self.common_temp_path, COVERAGE_CONFIG_NAME) coverage_output_path = os.path.join(self.common_temp_path, ResultType.COVERAGE.name) - coverage_config = generate_coverage_config(self.args) + coverage_config = generate_coverage_config() write_text_file(coverage_config_path, coverage_config, create_directories=True) @@ -260,7 +260,7 @@ """Return a dictionary of variables for setup and teardown of POSIX coverage.""" return dict( common_temp_dir=self.common_temp_path, - coverage_config=generate_coverage_config(self.args), + coverage_config=generate_coverage_config(), coverage_config_path=os.path.join(self.common_temp_path, COVERAGE_CONFIG_NAME), coverage_output_path=os.path.join(self.common_temp_path, ResultType.COVERAGE.name), mode_directory=f'{MODE_DIRECTORY:04o}', diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/sanity/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/sanity/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/sanity/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/sanity/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -76,6 +76,7 @@ PipInstall, collect_requirements, run_pip, + install_requirements, ) from ...config import ( @@ -178,6 +179,7 @@ if args.delegate: raise Delegate(host_state=host_state, require=changes, exclude=args.exclude) + install_requirements(args, host_state.controller_profile, host_state.controller_profile.python) # sanity configure_pypi_proxy(args, host_state.controller_profile) # sanity if disabled: @@ -1085,7 +1087,7 @@ class SanityVersionNeutral(SanityTest, metaclass=abc.ABCMeta): - """Base class for sanity test plugins which are idependent of the python version being used.""" + """Base class for sanity test plugins which are independent of the python version being used.""" @abc.abstractmethod def test(self, args: SanityConfig, targets: SanityTargets) -> TestResult: diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/sanity/integration_aliases.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/sanity/integration_aliases.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/sanity/integration_aliases.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/sanity/integration_aliases.py 2025-08-25 19:16:05.000000000 +0000 @@ -181,6 +181,8 @@ group_numbers = self.ci_test_groups.get(name, None) if group_numbers: + group_numbers = [num for num in group_numbers if num not in (6, 7)] # HACK: ignore special groups 6 and 7 + if min(group_numbers) != 1: display.warning('Min test group "%s" in %s is %d instead of 1.' % (name, self.CI_YML, min(group_numbers)), unique=True) @@ -291,6 +293,9 @@ if target.name == 'ansible-test-container': continue # special test target which uses group 6 -- nothing else should be in that group + if target.name in ('dnf-oldest', 'dnf-latest'): + continue # special test targets which use group 7 -- nothing else should be in that group + if f'{self.TEST_ALIAS_PREFIX}/posix/' not in target.aliases: continue @@ -351,6 +356,12 @@ if path == 'test/integration/targets/ansible-test-container': continue # special test target which uses group 6 -- nothing else should be in that group + if path in ( + 'test/integration/targets/dnf-oldest', + 'test/integration/targets/dnf-latest', + ): + continue # special test targets which use group 7 -- nothing else should be in that group + messages.append(SanityMessage(unassigned_message, '%s/aliases' % path)) for path in conflicting_paths: diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/shell/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/shell/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/shell/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/shell/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -2,10 +2,16 @@ from __future__ import annotations +import contextlib +import dataclasses import os import sys import typing as t +from ...data import ( + data_context, +) + from ...util import ( ApplicationError, OutputStream, @@ -14,6 +20,10 @@ HostConnectionError, ) +from ...ansible_util import ( + ansible_environment, +) + from ...config import ( ShellConfig, ) @@ -32,6 +42,7 @@ ControllerProfile, PosixProfile, SshTargetHostProfile, + DebuggableProfile, ) from ...provisioning import ( @@ -39,7 +50,6 @@ ) from ...host_configs import ( - ControllerConfig, OriginConfig, ) @@ -48,12 +58,21 @@ create_posix_inventory, ) +from ...python_requirements import ( + install_requirements, +) + +from ...util_common import ( + get_injector_env, +) + +from ...delegation import ( + metadata_context, +) + def command_shell(args: ShellConfig) -> None: """Entry point for the `shell` command.""" - if args.raw and isinstance(args.targets[0], ControllerConfig): - raise ApplicationError('The --raw option has no effect on the controller.') - if not args.export and not args.cmd and not sys.stdin.isatty(): raise ApplicationError('Standard input must be a TTY to launch a shell.') @@ -62,6 +81,8 @@ if args.delegate: raise Delegate(host_state=host_state) + install_requirements(args, host_state.controller_profile, host_state.controller_profile.python) # shell + if args.raw and not isinstance(args.controller, OriginConfig): display.warning('The --raw option will only be applied to the target.') @@ -85,13 +106,41 @@ if args.export: return - if args.cmd: - # Running a command is assumed to be non-interactive. Only a shell (no command) is interactive. - # If we want to support interactive commands in the future, we'll need an `--interactive` command line option. - # Command stderr output is allowed to mix with our own output, which is all sent to stderr. - con.run(args.cmd, capture=False, interactive=False, output_stream=OutputStream.ORIGINAL) - return + if isinstance(con, LocalConnection) and isinstance(target_profile, DebuggableProfile) and target_profile.debugging_enabled: + # HACK: ensure the debugger port visible in the shell is the forwarded port, not the original + args.metadata.debugger_settings = dataclasses.replace(args.metadata.debugger_settings, port=target_profile.debugger_port) + + with contextlib.nullcontext() if data_context().content.unsupported else metadata_context(args): + if args.cmd: + non_interactive_shell(args, target_profile, con) + else: + interactive_shell(args, target_profile, con) + + +def non_interactive_shell( + args: ShellConfig, + target_profile: SshTargetHostProfile, + con: Connection, +) -> None: + """Run a non-interactive shell command.""" + if isinstance(target_profile, PosixProfile): + env = get_environment_variables(args, target_profile, con) + cmd = get_env_command(env) + args.cmd + else: + cmd = args.cmd + + # Running a command is assumed to be non-interactive. Only a shell (no command) is interactive. + # If we want to support interactive commands in the future, we'll need an `--interactive` command line option. + # Command stderr output is allowed to mix with our own output, which is all sent to stderr. + con.run(cmd, capture=False, interactive=False, output_stream=OutputStream.ORIGINAL) + +def interactive_shell( + args: ShellConfig, + target_profile: SshTargetHostProfile, + con: Connection, +) -> None: + """Run an interactive shell.""" if isinstance(con, SshConnection) and args.raw: cmd: list[str] = [] elif isinstance(target_profile, PosixProfile): @@ -105,14 +154,8 @@ python = target_profile.python # make sure the python interpreter has been initialized before opening a shell display.info(f'Target Python {python.version} is at: {python.path}') - optional_vars = ( - 'TERM', # keep backspace working - ) - - env = {name: os.environ[name] for name in optional_vars if name in os.environ} - - if env: - cmd = ['/usr/bin/env'] + [f'{name}={value}' for name, value in env.items()] + env = get_environment_variables(args, target_profile, con) + cmd = get_env_command(env) cmd += [shell, '-i'] else: @@ -136,3 +179,38 @@ raise HostConnectionError(f'SSH shell connection failed for host {target_profile.config}: {ex}', callback) from ex raise + + +def get_env_command(env: dict[str, str]) -> list[str]: + """Get an `env` command to set the given environment variables, if any.""" + if not env: + return [] + + return ['/usr/bin/env'] + [f'{name}={value}' for name, value in env.items()] + + +def get_environment_variables( + args: ShellConfig, + target_profile: PosixProfile, + con: Connection, +) -> dict[str, str]: + """Get the environment variables to expose to the shell.""" + if data_context().content.unsupported: + return {} + + optional_vars = ( + 'TERM', # keep backspace working + ) + + env = {name: os.environ[name] for name in optional_vars if name in os.environ} + + if isinstance(con, LocalConnection): # configure the controller environment + env.update(ansible_environment(args)) + env.update(get_injector_env(target_profile.python, env)) + env.update(ANSIBLE_TEST_METADATA_PATH=os.path.abspath(args.metadata_path)) + + if isinstance(target_profile, DebuggableProfile): + env.update(target_profile.get_ansiballz_environment_variables()) + env.update(target_profile.get_ansible_cli_environment_variables()) + + return env diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/units/__init__.py ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/units/__init__.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/commands/units/__init__.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/commands/units/__init__.py 2025-08-25 19:16:05.000000000 +0000 @@ -64,6 +64,7 @@ from ...python_requirements import ( install_requirements, + post_install, ) from ...content_config import ( @@ -230,7 +231,9 @@ controller = any(test_context == TestContext.controller for test_context, python, paths, env in final_candidates) if args.requirements_mode != 'skip': - install_requirements(args, target_profile.python, ansible=controller, command=True, controller=False) # units + install_requirements(args, target_profile, target_profile.python, ansible=controller, command=True, controller=False) # units + else: + post_install(target_profile) test_sets.extend(final_candidates) diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/config.py ansible-core-2.19.1/test/lib/ansible_test/_internal/config.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/config.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/config.py 2025-08-25 19:16:05.000000000 +0000 @@ -20,6 +20,7 @@ from .metadata import ( Metadata, + DebuggerFlags, ) from .data import ( @@ -118,6 +119,26 @@ self.dev_systemd_debug: bool = args.dev_systemd_debug self.dev_probe_cgroups: t.Optional[str] = args.dev_probe_cgroups + debugger_flags = DebuggerFlags( + on_demand=args.dev_debug_on_demand, + cli=args.dev_debug_cli, + ansiballz=args.dev_debug_ansiballz, + self=args.dev_debug_self, + ) + + self.metadata = Metadata.from_file(args.metadata) if args.metadata else Metadata(debugger_flags=debugger_flags) + self.metadata_path: t.Optional[str] = None + + def metadata_callback(payload_config: PayloadConfig) -> None: + """Add the metadata file to the payload file list.""" + config = self + files = payload_config.files + + if config.metadata_path: + files.append((os.path.abspath(config.metadata_path), config.metadata_path)) + + data_context().register_payload_callback(metadata_callback) + def host_callback(payload_config: PayloadConfig) -> None: """Add the host files to the payload file list.""" config = self @@ -220,22 +241,9 @@ self.junit: bool = getattr(args, 'junit', False) self.failure_ok: bool = getattr(args, 'failure_ok', False) - self.metadata = Metadata.from_file(args.metadata) if args.metadata else Metadata() - self.metadata_path: t.Optional[str] = None - if self.coverage_check: self.coverage = True - def metadata_callback(payload_config: PayloadConfig) -> None: - """Add the metadata file to the payload file list.""" - config = self - files = payload_config.files - - if config.metadata_path: - files.append((os.path.abspath(config.metadata_path), config.metadata_path)) - - data_context().register_payload_callback(metadata_callback) - class ShellConfig(EnvironmentConfig): """Configuration for the shell command.""" diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/coverage_util.py ansible-core-2.19.1/test/lib/ansible_test/_internal/coverage_util.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/coverage_util.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/coverage_util.py 2025-08-25 19:16:05.000000000 +0000 @@ -6,11 +6,10 @@ import os import sqlite3 import tempfile +import textwrap import typing as t from .config import ( - IntegrationConfig, - SanityConfig, TestConfig, ) @@ -217,7 +216,7 @@ except AttributeError: pass - coverage_config = generate_coverage_config(args) + coverage_config = generate_coverage_config() if args.explain: temp_dir = '/tmp/coverage-temp-dir' @@ -235,10 +234,10 @@ return path -def generate_coverage_config(args: TestConfig) -> str: +def generate_coverage_config() -> str: """Generate code coverage configuration for tests.""" if data_context().content.collection: - coverage_config = generate_collection_coverage_config(args) + coverage_config = generate_collection_coverage_config() else: coverage_config = generate_ansible_coverage_config() @@ -265,12 +264,29 @@ */test/results/* """ + coverage_config = coverage_config.lstrip() + return coverage_config -def generate_collection_coverage_config(args: TestConfig) -> str: +def generate_collection_coverage_config() -> str: """Generate code coverage configuration for Ansible Collection tests.""" - coverage_config = """ + include_patterns = [ + # {base}/ansible_collections/{ns}/{col}/* + os.path.join(data_context().content.root, '*'), + # */ansible_collections/{ns}/{col}/* (required to pick up AnsiballZ coverage) + os.path.join('*', data_context().content.collection.directory, '*'), + ] + + omit_patterns = [ + # {base}/ansible_collections/{ns}/{col}/tests/output/* + os.path.join(data_context().content.root, data_context().content.results_path, '*'), + ] + + include = textwrap.indent('\n'.join(include_patterns), ' ' * 4) + omit = textwrap.indent('\n'.join(omit_patterns), ' ' * 4) + + coverage_config = f""" [run] branch = True concurrency = @@ -279,28 +295,15 @@ parallel = True disable_warnings = no-data-collected -""" - if isinstance(args, IntegrationConfig): - coverage_config += """ include = - %s/* - */%s/* -""" % (data_context().content.root, data_context().content.collection.directory) - elif isinstance(args, SanityConfig): - # temporary work-around for import sanity test - coverage_config += """ -include = - %s/* +{include} omit = - %s/* -""" % (data_context().content.root, os.path.join(data_context().content.root, data_context().content.results_path)) - else: - coverage_config += """ -include = - %s/* -""" % data_context().content.root +{omit} +""" + + coverage_config = coverage_config.lstrip() return coverage_config diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/debugging.py ansible-core-2.19.1/test/lib/ansible_test/_internal/debugging.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/debugging.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/debugging.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,454 @@ +"""Setup and configure remote debugging.""" + +from __future__ import annotations + +import abc +import dataclasses +import importlib +import json +import os +import re +import sys +import typing as t + +from .util import ( + cache, + display, + raw_command, + ApplicationError, + get_subclasses, +) + +from .util_common import ( + CommonConfig, +) + +from .processes import ( + Process, + get_current_process, +) + +from .config import ( + EnvironmentConfig, +) + +from .metadata import ( + DebuggerFlags, +) + +from .data import ( + data_context, +) + + +class DebuggerProfile(t.Protocol): + """Protocol for debugger profiles.""" + + @property + def debugger_host(self) -> str: + """The hostname to expose to the debugger.""" + + @property + def debugger_port(self) -> int: + """The port to expose to the debugger.""" + + def get_source_mapping(self) -> dict[str, str]: + """The source mapping to expose to the debugger.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class DebuggerSettings(metaclass=abc.ABCMeta): + """Common debugger settings.""" + + port: int = 5678 + """ + The port on the origin host which is listening for incoming connections from the debugger. + SSH port forwarding will be automatically configured for non-local hosts to connect to this port as needed. + """ + + def as_dict(self) -> dict[str, object]: + """Convert this instance to a dict.""" + data = dataclasses.asdict(self) + data.update(__type__=self.__class__.__name__) + + return data + + @classmethod + def from_dict(cls, value: dict[str, t.Any]) -> t.Self: + """Load an instance from a dict.""" + debug_cls = globals()[value.pop('__type__')] + + return debug_cls(**value) + + @classmethod + def get_debug_type(cls) -> str: + """Return the name for this debugger.""" + return cls.__name__.removesuffix('Settings').lower() + + @classmethod + def get_config_env_var_name(cls) -> str: + """Return the name of the environment variable used to customize settings for this debugger.""" + return f'ANSIBLE_TEST_REMOTE_DEBUGGER_{cls.get_debug_type().upper()}' + + @classmethod + def parse(cls, value: str) -> t.Self: + """Parse debugger settings from the given JSON and apply defaults.""" + try: + settings = cls(**json.loads(value)) + except Exception as ex: + raise ApplicationError(f"Invalid {cls.get_debug_type()} settings: {ex}") from ex + + return cls.apply_defaults(settings) + + @classmethod + @abc.abstractmethod + def is_active(cls) -> bool: + """Detect if the debugger is active.""" + + @classmethod + @abc.abstractmethod + def apply_defaults(cls, settings: t.Self) -> t.Self: + """Apply defaults to the given settings.""" + + @abc.abstractmethod + def get_python_package(self) -> str: + """The Python package to install for debugging.""" + + @abc.abstractmethod + def activate_debugger(self, profile: DebuggerProfile) -> None: + """Activate the debugger in ansible-test after delegation.""" + + @abc.abstractmethod + def get_ansiballz_config(self, profile: DebuggerProfile) -> dict[str, object]: + """Gets the extra configuration data for the AnsiballZ extension module.""" + + @abc.abstractmethod + def get_cli_arguments(self, profile: DebuggerProfile) -> list[str]: + """Get command line arguments for the debugger when running Ansible CLI programs.""" + + @abc.abstractmethod + def get_environment_variables(self, profile: DebuggerProfile) -> dict[str, str]: + """Get environment variables needed to configure the debugger for debugging.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class PydevdSettings(DebuggerSettings): + """Settings for the pydevd debugger.""" + + package: str | None = None + """ + The Python package to install for debugging. + If `None` then the package will be auto-detected. + If an empty string, then no package will be installed. + """ + + module: str | None = None + """ + The Python module to import for debugging. + This should be pydevd or a derivative. + If not provided it will be auto-detected. + """ + + settrace: dict[str, object] = dataclasses.field(default_factory=dict) + """ + Options to pass to the `{module}.settrace` method. + Used for running AnsiballZ modules only. + The `host` and `port` options will be provided by ansible-test. + The `suspend` option defaults to `False`. + """ + + args: list[str] = dataclasses.field(default_factory=list) + """ + Arguments to pass to `pydevd` on the command line. + Used for running Ansible CLI programs only. + The `--client` and `--port` options will be provided by ansible-test. + """ + + @classmethod + def is_active(cls) -> bool: + return detect_pydevd_port() is not None + + @classmethod + def apply_defaults(cls, settings: t.Self) -> t.Self: + if not settings.module: + if not settings.package or 'pydevd-pycharm' in settings.package: + module = 'pydevd_pycharm' + else: + module = 'pydevd' + + settings = dataclasses.replace(settings, module=module) + + if settings.package is None: + if settings.module == 'pydevd_pycharm': + if pycharm_version := detect_pycharm_version(): + package = f'pydevd-pycharm~={pycharm_version}' + else: + package = None + else: + package = 'pydevd' + + settings = dataclasses.replace(settings, package=package) + + settings.settrace.setdefault('suspend', False) + + if port := detect_pydevd_port(): + settings = dataclasses.replace(settings, port=port) + + if detect_pycharm_process(): + # This only works with the default PyCharm debugger. + # Using it with PyCharm's "Python Debug Server" results in hangs in Ansible workers. + # Further investigation is required to understand the cause. + settings = dataclasses.replace(settings, args=settings.args + ['--multiprocess']) + + return settings + + def get_python_package(self) -> str: + if self.package is None and self.module == 'pydevd_pycharm': + display.warning('Skipping installation of `pydevd-pycharm` since the running PyCharm version was not detected.') + + return self.package + + def activate_debugger(self, profile: DebuggerProfile) -> None: + debugging_module = importlib.import_module(self.module) + debugging_module.settrace(**self._get_settrace_arguments(profile)) + + def get_ansiballz_config(self, profile: DebuggerProfile) -> dict[str, object]: + return dict( + module=self.module, + settrace=self._get_settrace_arguments(profile), + source_mapping=profile.get_source_mapping(), + ) + + def get_cli_arguments(self, profile: DebuggerProfile) -> list[str]: + # Although `pydevd_pycharm` can be used to invoke `settrace`, it cannot be used to run the debugger on the command line. + return ['-m', 'pydevd', '--client', profile.debugger_host, '--port', str(profile.debugger_port)] + self.args + ['--file'] + + def get_environment_variables(self, profile: DebuggerProfile) -> dict[str, str]: + return dict( + PATHS_FROM_ECLIPSE_TO_PYTHON=json.dumps(list(profile.get_source_mapping().items())), + PYDEVD_DISABLE_FILE_VALIDATION="1", + ) + + def _get_settrace_arguments(self, profile: DebuggerProfile) -> dict[str, object]: + """Get settrace arguments for pydevd.""" + return self.settrace | dict( + host=profile.debugger_host, + port=profile.debugger_port, + ) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class DebugpySettings(DebuggerSettings): + """Settings for the debugpy debugger.""" + + connect: dict[str, object] = dataclasses.field(default_factory=dict) + """ + Options to pass to the `debugpy.connect` method. + Used for running AnsiballZ modules and ansible-test after delegation. + The endpoint addr, `access_token`, and `parent_session_pid` options will be provided by ansible-test. + """ + + args: list[str] = dataclasses.field(default_factory=list) + """ + Arguments to pass to `debugpy` on the command line. + Used for running Ansible CLI programs only. + The `--connect`, `--adapter-access-token`, and `--parent-session-pid` options will be provided by ansible-test. + """ + + @classmethod + def is_active(cls) -> bool: + return detect_debugpy_options() is not None + + @classmethod + def apply_defaults(cls, settings: t.Self) -> t.Self: + if options := detect_debugpy_options(): + settings = dataclasses.replace(settings, port=options.port) + settings.connect.update( + access_token=options.adapter_access_token, + parent_session_pid=os.getpid(), + ) + else: + display.warning('Debugging will be limited to the first connection. Run ansible-test under debugpy to support multiple connections.') + + return settings + + def get_python_package(self) -> str: + return 'debugpy' + + def activate_debugger(self, profile: DebuggerProfile) -> None: + import debugpy # pylint: disable=import-error + + debugpy.connect((profile.debugger_host, profile.debugger_port), **self.connect) + + def get_ansiballz_config(self, profile: DebuggerProfile) -> dict[str, object]: + return dict( + host=profile.debugger_host, + port=profile.debugger_port, + connect=self.connect, + source_mapping=profile.get_source_mapping(), + ) + + def get_cli_arguments(self, profile: DebuggerProfile) -> list[str]: + cli_args = ['-m', 'debugpy', '--connect', f"{profile.debugger_host}:{profile.debugger_port}"] + + if access_token := self.connect.get('access_token'): + cli_args += ['--adapter-access-token', str(access_token)] + + if session_pid := self.connect.get('parent_session_pid'): + cli_args += ['--parent-session-pid', str(session_pid)] + + if self.args: + cli_args += self.args + + return cli_args + + def get_environment_variables(self, profile: DebuggerProfile) -> dict[str, str]: + return dict( + PATHS_FROM_ECLIPSE_TO_PYTHON=json.dumps(list(profile.get_source_mapping().items())), + PYDEVD_DISABLE_FILE_VALIDATION="1", + ) + + +def initialize_debugger(args: CommonConfig) -> None: + """Initialize the debugger settings before delegation.""" + if not isinstance(args, EnvironmentConfig): + return + + if args.metadata.loaded: + return # after delegation + + if collection := data_context().content.collection: + args.metadata.collection_root = collection.root + + load_debugger_settings(args) + + +def load_debugger_settings(args: EnvironmentConfig) -> None: + """Load the remote debugger settings.""" + use_debugger: type[DebuggerSettings] | None = None + + if args.metadata.debugger_flags.on_demand: + # On-demand debugging only enables debugging if we're running under a debugger, otherwise it's a no-op. + + for candidate_debugger in get_subclasses(DebuggerSettings): + if candidate_debugger.is_active(): + use_debugger = candidate_debugger + break + else: + display.info('Debugging disabled because no debugger was detected.', verbosity=1) + args.metadata.debugger_flags = DebuggerFlags.all(False) + return + + display.info('Enabling on-demand debugging.', verbosity=1) + + if not args.metadata.debugger_flags.enable: + # Assume the user wants all debugging features enabled, since on-demand debugging with no features is pointless. + args.metadata.debugger_flags = DebuggerFlags.all(True) + + if not args.metadata.debugger_flags.enable: + return + + if not use_debugger: # detect debug type based on env var + for candidate_debugger in get_subclasses(DebuggerSettings): + if candidate_debugger.get_config_env_var_name() in os.environ: + use_debugger = candidate_debugger + break + else: + display.info('Debugging disabled because no debugger configuration was provided.', verbosity=1) + args.metadata.debugger_flags = DebuggerFlags.all(False) + return + + config = os.environ.get(use_debugger.get_config_env_var_name()) or '{}' + settings = use_debugger.parse(config) + args.metadata.debugger_settings = settings + + display.info(f'>>> Debugger Settings ({use_debugger.get_debug_type()})\n{json.dumps(dataclasses.asdict(settings), indent=4)}', verbosity=3) + + +@cache +def detect_pydevd_port() -> int | None: + """Return the port for the pydevd instance hosting this process, or `None` if not detected.""" + current_process = get_current_process_cached() + args = current_process.args + + if any('/pydevd.py' in arg for arg in args) and (port_idx := args.index('--port')): + port = int(args[port_idx + 1]) + display.info(f'Detected pydevd debugger port {port}.', verbosity=1) + return port + + return None + + +@cache +def detect_pycharm_version() -> str | None: + """Return the version of PyCharm running ansible-test, or `None` if PyCharm was not detected. The result is cached.""" + if pycharm := detect_pycharm_process(): + output = raw_command([pycharm.args[0], '--version'], capture=True)[0] + + if match := re.search('^Build #PY-(?P[0-9.]+)$', output, flags=re.MULTILINE): + version = match.group('version') + display.info(f'Detected PyCharm version {version}.', verbosity=1) + return version + + return None + + +@cache +def detect_pycharm_process() -> Process | None: + """Return the PyCharm process running ansible-test, or `None` if PyCharm was not detected. The result is cached.""" + current_process = get_current_process_cached() + parent = current_process.parent + + while parent: + if parent.path.name == 'pycharm': + return parent + + parent = parent.parent + + return None + + +@cache +def get_current_process_cached() -> Process: + """Return the current process. The result is cached.""" + return get_current_process() + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class DebugpyOptions: + """Options detected from the debugpy instance hosting this process.""" + + port: int + adapter_access_token: str | None + + +@cache +def detect_debugpy_options() -> DebugpyOptions | None: + """Return the options for the debugpy instance hosting this process, or `None` if not detected.""" + if "debugpy" not in sys.modules: + return None + + import debugpy # pylint: disable=import-error + + # get_cli_options is the new public API introduced after debugpy 1.8.15. + # We should remove the debugpy.server cli fallback once the new version is + # released. + if hasattr(debugpy, 'get_cli_options'): + opts = debugpy.get_cli_options() + else: + from debugpy.server import cli # pylint: disable=import-error + opts = cli.options + + # address can be None if the debugger is not configured through the CLI as + # we expected. + if not opts.address: + return None + + port = opts.address[1] + + display.info(f'Detected debugpy debugger port {port}.', verbosity=1) + + return DebugpyOptions( + port=port, + adapter_access_token=opts.adapter_access_token, + ) diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/delegation.py ansible-core-2.19.1/test/lib/ansible_test/_internal/delegation.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/delegation.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/delegation.py 2025-08-25 19:16:05.000000000 +0000 @@ -113,23 +113,27 @@ assert isinstance(args, EnvironmentConfig) with delegation_context(args, host_state): - if isinstance(args, TestConfig): - args.metadata.ci_provider = get_ci_provider().code + args.metadata.ci_provider = get_ci_provider().code - make_dirs(ResultType.TMP.path) + make_dirs(ResultType.TMP.path) - with tempfile.NamedTemporaryFile(prefix='metadata-', suffix='.json', dir=ResultType.TMP.path) as metadata_fd: - args.metadata_path = os.path.join(ResultType.TMP.relative_path, os.path.basename(metadata_fd.name)) - args.metadata.to_file(args.metadata_path) - - try: - delegate_command(args, host_state, exclude, require) - finally: - args.metadata_path = None - else: + with metadata_context(args): delegate_command(args, host_state, exclude, require) +@contextlib.contextmanager +def metadata_context(args: EnvironmentConfig) -> t.Generator[None]: + """A context manager which exports delegation metadata.""" + with tempfile.NamedTemporaryFile(prefix='metadata-', suffix='.json', dir=ResultType.TMP.path) as metadata_fd: + args.metadata_path = os.path.join(ResultType.TMP.relative_path, os.path.basename(metadata_fd.name)) + args.metadata.to_file(args.metadata_path) + + try: + yield + finally: + args.metadata_path = None + + def delegate_command(args: EnvironmentConfig, host_state: HostState, exclude: list[str], require: list[str]) -> None: """Delegate execution based on the provided host state.""" con = host_state.controller_profile.get_origin_controller_connection() @@ -189,6 +193,10 @@ networks = container.get_network_names() if networks is not None: + if args.metadata.debugger_flags.enable: + networks = [] + display.warning('Skipping network isolation to enable remote debugging.') + for network in networks: try: con.disconnect_network(network) @@ -334,6 +342,7 @@ ('--redact', 0, False), ('--no-redact', 0, not args.redact), ('--host-path', 1, args.host_path), + ('--metadata', 1, args.metadata_path), ] if isinstance(args, TestConfig): @@ -346,7 +355,6 @@ ('--ignore-unstaged', 0, False), ('--changed-from', 1, False), ('--changed-path', 1, False), - ('--metadata', 1, args.metadata_path), ('--exclude', 1, exclude), ('--require', 1, require), ('--base-branch', 1, False), diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/host_profiles.py ansible-core-2.19.1/test/lib/ansible_test/_internal/host_profiles.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/host_profiles.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/host_profiles.py 2025-08-25 19:16:05.000000000 +0000 @@ -4,7 +4,9 @@ import abc import dataclasses +import json import os +import pathlib import shlex import tempfile import time @@ -58,6 +60,9 @@ HostConnectionError, ANSIBLE_TEST_TARGET_ROOT, WINDOWS_CONNECTION_VARIABLES, + ANSIBLE_SOURCE_ROOT, + ANSIBLE_LIB_ROOT, + ANSIBLE_TEST_ROOT, ) from .util_common import ( @@ -92,6 +97,8 @@ from .ssh import ( SshConnectionDetail, + create_ssh_port_forwards, + SshProcess, ) from .ansible_util import ( @@ -132,6 +139,11 @@ check_container_cgroup_status, ) +from .debugging import ( + DebuggerProfile, + DebuggerSettings, +) + TControllerHostConfig = t.TypeVar('TControllerHostConfig', bound=ControllerHostConfig) THostConfig = t.TypeVar('THostConfig', bound=HostConfig) TPosixConfig = t.TypeVar('TPosixConfig', bound=PosixConfig) @@ -284,6 +296,172 @@ return f'{self.__class__.__name__}: {self.name}' +class DebuggableProfile(HostProfile[THostConfig], DebuggerProfile, metaclass=abc.ABCMeta): + """Base class for profiles remote debugging.""" + + __DEBUGGING_PORT_KEY = 'debugging_port' + __DEBUGGING_FORWARDER_KEY = 'debugging_forwarder' + + @property + def debugger(self) -> DebuggerSettings | None: + """The debugger settings for this host if present and enabled, otherwise None.""" + return self.args.metadata.debugger_settings + + @property + def debugging_enabled(self) -> bool: + """Returns `True` if debugging is enabled for this profile, otherwise `False`.""" + if self.controller: + return self.args.metadata.debugger_flags.enable + + return self.args.metadata.debugger_flags.ansiballz + + @property + def debugger_host(self) -> str: + """The debugger host to use.""" + return 'localhost' + + @property + def debugger_port(self) -> int: + """The debugger port to use.""" + return self.state.get(self.__DEBUGGING_PORT_KEY) or self.origin_debugger_port + + @property + def debugging_forwarder(self) -> SshProcess | None: + """The SSH forwarding process, if enabled.""" + return self.cache.get(self.__DEBUGGING_FORWARDER_KEY) + + @debugging_forwarder.setter + def debugging_forwarder(self, value: SshProcess) -> None: + """The SSH forwarding process, if enabled.""" + self.cache[self.__DEBUGGING_FORWARDER_KEY] = value + + @property + def origin_debugger_port(self) -> int: + """The debugger port on the origin.""" + return self.debugger.port + + def enable_debugger_forwarding(self, ssh: SshConnectionDetail) -> None: + """Enable debugger port forwarding from the origin.""" + if not self.debugging_enabled: + return + + endpoint = ('localhost', self.origin_debugger_port) + forwards = [endpoint] + + self.debugging_forwarder = create_ssh_port_forwards(self.args, ssh, forwards) + + port_forwards = self.debugging_forwarder.collect_port_forwards() + + self.state[self.__DEBUGGING_PORT_KEY] = port = port_forwards[endpoint] + + display.info(f'Remote debugging of {self.name!r} is available on port {port}.', verbosity=1) + + def deprovision(self) -> None: + """Deprovision the host after delegation has completed.""" + super().deprovision() + + if not self.debugging_forwarder: + return # forwarding not in use + + self.debugging_forwarder.terminate() + + display.info(f'Waiting for the {self.name!r} remote debugging SSH port forwarding process to terminate.', verbosity=1) + + self.debugging_forwarder.wait() + + def get_source_mapping(self) -> dict[str, str]: + """Get the source mapping from the given metadata.""" + from . import data_context + + if collection := data_context().content.collection: + source_mapping = { + f"{self.args.metadata.ansible_test_root}/": f'{ANSIBLE_TEST_ROOT}/', + f"{self.args.metadata.ansible_lib_root}/": f'{ANSIBLE_LIB_ROOT}/', + f'{self.args.metadata.collection_root}/ansible_collections/': f'{collection.root}/ansible_collections/', + } + else: + ansible_source_root = pathlib.Path(self.args.metadata.ansible_lib_root).parent.parent + + source_mapping = { + f"{ansible_source_root}/": f'{ANSIBLE_SOURCE_ROOT}/', + } + + source_mapping = {key: value for key, value in source_mapping.items() if key != value} + + return source_mapping + + def activate_debugger(self) -> None: + """Activate the debugger after delegation.""" + if not self.args.metadata.loaded or not self.args.metadata.debugger_flags.self: + return + + display.info('Activating remote debugging of ansible-test.', verbosity=1) + + os.environ.update(self.debugger.get_environment_variables(self)) + + self.debugger.activate_debugger(self) + + pass # pylint: disable=unnecessary-pass # when suspend is True, execution pauses here -- it's also a convenient place to put a breakpoint + + def get_ansiballz_inventory_variables(self) -> dict[str, t.Any]: + """ + Return inventory variables for remote debugging of AnsiballZ modules. + When delegating, this function must be called after delegation. + """ + if not self.args.metadata.debugger_flags.ansiballz: + return {} + + debug_type = self.debugger.get_debug_type() + + return { + f"_ansible_ansiballz_{debug_type}_config": json.dumps(self.get_ansiballz_debugger_config()), + } + + def get_ansiballz_environment_variables(self) -> dict[str, t.Any]: + """ + Return environment variables for remote debugging of AnsiballZ modules. + When delegating, this function must be called after delegation. + """ + if not self.args.metadata.debugger_flags.ansiballz: + return {} + + debug_type = self.debugger.get_debug_type().upper() + + return { + f"_ANSIBLE_ANSIBALLZ_{debug_type}_CONFIG": json.dumps(self.get_ansiballz_debugger_config()), + } + + def get_ansiballz_debugger_config(self) -> dict[str, t.Any]: + """ + Return config for remote debugging of AnsiballZ modules. + When delegating, this function must be called after delegation. + """ + debugger_config = self.debugger.get_ansiballz_config(self) + + display.info(f'>>> Debugger Config ({self.name} AnsiballZ)\n{json.dumps(debugger_config, indent=4)}', verbosity=3) + + return debugger_config + + def get_ansible_cli_environment_variables(self) -> dict[str, t.Any]: + """ + Return environment variables for remote debugging of the Ansible CLI. + When delegating, this function must be called after delegation. + """ + if not self.args.metadata.debugger_flags.cli: + return {} + + debugger_config = dict( + args=self.debugger.get_cli_arguments(self), + env=self.debugger.get_environment_variables(self), + ) + + display.info(f'>>> Debugger Config ({self.name} Ansible CLI)\n{json.dumps(debugger_config, indent=4)}', verbosity=3) + + return dict( + ANSIBLE_TEST_DEBUGGER_CONFIG=json.dumps(debugger_config), + ) + + class PosixProfile(HostProfile[TPosixConfig], metaclass=abc.ABCMeta): """Base class for POSIX host profiles.""" @@ -306,7 +484,7 @@ return python -class ControllerHostProfile(PosixProfile[TControllerHostConfig], metaclass=abc.ABCMeta): +class ControllerHostProfile(PosixProfile[TControllerHostConfig], DebuggableProfile[TControllerHostConfig], metaclass=abc.ABCMeta): """Base class for profiles usable as a controller.""" @abc.abstractmethod @@ -410,7 +588,7 @@ ) -class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[ControllerConfig]): +class ControllerProfile(SshTargetHostProfile[ControllerConfig], PosixProfile[ControllerConfig], DebuggableProfile[ControllerConfig]): """Host profile for the controller as a target.""" @property @@ -418,6 +596,11 @@ """The name of the host profile.""" return self.controller_profile.name + @property + def debugger_port(self) -> int: + """The pydevd port to use.""" + return self.controller_profile.debugger_port + def get_controller_target_connections(self) -> list[SshConnection]: """Return SSH connection(s) for accessing the host as a target from the controller.""" settings = SshConnectionDetail( @@ -432,7 +615,7 @@ return [SshConnection(self.args, settings)] -class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[DockerConfig]): +class DockerProfile(ControllerHostProfile[DockerConfig], SshTargetHostProfile[DockerConfig], DebuggableProfile[DockerConfig]): """Host profile for a docker instance.""" MARKER = 'ansible-test-marker' @@ -487,7 +670,7 @@ image=self.config.image, name=f'ansible-test-{self.label}', ports=[22], - publish_ports=not self.controller, # connections to the controller over SSH are not required + publish_ports=self.debugging_enabled or not self.controller, # SSH to the controller is not required unless remote debugging is enabled options=init_config.options, cleanup=False, cmd=self.build_init_command(init_config, init_probe), @@ -627,7 +810,7 @@ # The host namespace must be used to permit the container to access the cgroup v1 systemd hierarchy created by Podman. '--cgroupns', 'host', # Mask the host cgroup tmpfs mount to avoid exposing the host cgroup v1 hierarchies (or cgroup v2 hybrid) to the container. - # Podman will provide a cgroup v1 systemd hiearchy on top of this. + # Podman will provide a cgroup v1 systemd hierarchy on top of this. '--tmpfs', '/sys/fs/cgroup', )) @@ -1000,6 +1183,9 @@ docker_logs(self.args, self.container_name) raise + if self.debugging_enabled: + self.enable_debugger_forwarding(self.get_ssh_connection_detail(HostType.origin)) + def deprovision(self) -> None: """Deprovision the host after delegation has completed.""" super().deprovision() @@ -1248,13 +1434,18 @@ return os.getcwd() -class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile[PosixRemoteConfig]): +class PosixRemoteProfile(ControllerHostProfile[PosixRemoteConfig], RemoteProfile[PosixRemoteConfig], DebuggableProfile[PosixRemoteConfig]): """Host profile for a POSIX remote instance.""" def wait(self) -> None: """Wait for the instance to be ready. Executed before delegation for the controller and after delegation for targets.""" self.wait_until_ready() + def setup(self) -> None: + """Perform out-of-band setup before delegation.""" + if self.debugging_enabled: + self.enable_debugger_forwarding(self.get_origin_controller_connection().settings) + def configure(self) -> None: """Perform in-band configuration. Executed before delegation for the controller and after delegation for targets.""" # a target uses a single python version, but a controller may include additional versions for targets running on the controller diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/inventory.py ansible-core-2.19.1/test/lib/ansible_test/_internal/inventory.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/inventory.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/inventory.py 2025-08-25 19:16:05.000000000 +0000 @@ -30,6 +30,7 @@ SshTargetHostProfile, WindowsInventoryProfile, WindowsRemoteProfile, + DebuggableProfile, ) from .ssh import ( @@ -59,6 +60,9 @@ # To compensate for this we'll perform a `cd /` before running any commands after `sudo` succeeds. common_variables.update(ansible_sudo_chdir='/') + if isinstance(target_profile, DebuggableProfile): + common_variables.update(target_profile.get_ansiballz_inventory_variables()) + return common_variables diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/metadata.py ansible-core-2.19.1/test/lib/ansible_test/_internal/metadata.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/metadata.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/metadata.py 2025-08-25 19:16:05.000000000 +0000 @@ -1,11 +1,15 @@ """Test metadata for passing data to delegated tests.""" from __future__ import annotations + +import dataclasses import typing as t from .util import ( display, generate_name, + ANSIBLE_TEST_ROOT, + ANSIBLE_LIB_ROOT, ) from .io import ( @@ -18,17 +22,26 @@ FileDiff, ) +if t.TYPE_CHECKING: + from .debugging import DebuggerSettings + class Metadata: """Metadata object for passing data to delegated tests.""" - def __init__(self) -> None: + def __init__(self, debugger_flags: DebuggerFlags) -> None: """Initialize metadata.""" self.changes: dict[str, tuple[tuple[int, int], ...]] = {} self.cloud_config: t.Optional[dict[str, dict[str, t.Union[int, str, bool]]]] = None self.change_description: t.Optional[ChangeDescription] = None self.ci_provider: t.Optional[str] = None self.session_id = generate_name() + self.ansible_lib_root = ANSIBLE_LIB_ROOT + self.ansible_test_root = ANSIBLE_TEST_ROOT + self.collection_root: str | None = None + self.debugger_flags = debugger_flags + self.debugger_settings: DebuggerSettings | None = None + self.loaded = False def populate_changes(self, diff: t.Optional[list[str]]) -> None: """Populate the changeset using the given diff.""" @@ -55,8 +68,13 @@ changes=self.changes, cloud_config=self.cloud_config, ci_provider=self.ci_provider, - change_description=self.change_description.to_dict(), + change_description=self.change_description.to_dict() if self.change_description else None, session_id=self.session_id, + ansible_lib_root=self.ansible_lib_root, + ansible_test_root=self.ansible_test_root, + collection_root=self.collection_root, + debugger_flags=dataclasses.asdict(self.debugger_flags), + debugger_settings=self.debugger_settings.as_dict() if self.debugger_settings else None, ) def to_file(self, path: str) -> None: @@ -76,12 +94,22 @@ @staticmethod def from_dict(data: dict[str, t.Any]) -> Metadata: """Return metadata loaded from the specified dictionary.""" - metadata = Metadata() + from .debugging import DebuggerSettings + + metadata = Metadata( + debugger_flags=DebuggerFlags(**data['debugger_flags']), + ) + metadata.changes = data['changes'] metadata.cloud_config = data['cloud_config'] metadata.ci_provider = data['ci_provider'] - metadata.change_description = ChangeDescription.from_dict(data['change_description']) + metadata.change_description = ChangeDescription.from_dict(data['change_description']) if data['change_description'] else None metadata.session_id = data['session_id'] + metadata.ansible_lib_root = data['ansible_lib_root'] + metadata.ansible_test_root = data['ansible_test_root'] + metadata.collection_root = data['collection_root'] + metadata.debugger_settings = DebuggerSettings.from_dict(data['debugger_settings']) if data['debugger_settings'] else None + metadata.loaded = True return metadata @@ -130,3 +158,30 @@ changes.no_integration_paths = data['no_integration_paths'] return changes + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class DebuggerFlags: + """Flags for enabling specific debugging features.""" + + self: bool = False + """Debug ansible-test itself.""" + + ansiballz: bool = False + """Debug AnsiballZ modules.""" + + cli: bool = False + """Debug Ansible CLI programs other than ansible-test.""" + + on_demand: bool = False + """Enable debugging features only when ansible-test is running under a debugger.""" + + @property + def enable(self) -> bool: + """Return `True` if any debugger feature other than on-demand is enabled.""" + return any(getattr(self, field.name) for field in dataclasses.fields(self) if field.name != 'on_demand') + + @classmethod + def all(cls, enabled: bool) -> t.Self: + """Return a `DebuggerFlags` instance with all flags enabled or disabled.""" + return cls(**{field.name: enabled for field in dataclasses.fields(cls)}) diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/processes.py ansible-core-2.19.1/test/lib/ansible_test/_internal/processes.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/processes.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/processes.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,80 @@ +"""Wrappers around `ps` for querying running processes.""" + +from __future__ import annotations + +import collections +import dataclasses +import os +import pathlib +import shlex + +from ansible_test._internal.util import raw_command + + +@dataclasses.dataclass(frozen=True) +class ProcessData: + """Data about a running process.""" + + pid: int + ppid: int + command: str + + +@dataclasses.dataclass(frozen=True) +class Process: + """A process in the process tree.""" + + pid: int + command: str + parent: Process | None = None + children: tuple[Process, ...] = dataclasses.field(default_factory=tuple) + + @property + def args(self) -> list[str]: + """The list of arguments that make up `command`.""" + return shlex.split(self.command) + + @property + def path(self) -> pathlib.Path: + """The path to the process.""" + return pathlib.Path(self.args[0]) + + +def get_process_data(pids: list[int] | None = None) -> list[ProcessData]: + """Return a list of running processes.""" + if pids: + args = ['-p', ','.join(map(str, pids))] + else: + args = ['-A'] + + lines = raw_command(['ps'] + args + ['-o', 'pid,ppid,command'], capture=True)[0].splitlines()[1:] + processes = [ProcessData(pid=int(pid), ppid=int(ppid), command=command) for pid, ppid, command in (line.split(maxsplit=2) for line in lines)] + + return processes + + +def get_process_tree() -> dict[int, Process]: + """Return the process tree.""" + processes = get_process_data() + pid_to_process: dict[int, Process] = {} + pid_to_children: dict[int, list[Process]] = collections.defaultdict(list) + + for data in processes: + pid_to_process[data.pid] = process = Process(pid=data.pid, command=data.command) + + if data.ppid: + pid_to_children[data.ppid].append(process) + + for data in processes: + pid_to_process[data.pid] = dataclasses.replace( + pid_to_process[data.pid], + parent=pid_to_process.get(data.ppid), + children=tuple(pid_to_children[data.pid]), + ) + + return pid_to_process + + +def get_current_process() -> Process: + """Return the current process along with its ancestors and descendants.""" + return get_process_tree()[os.getpid()] diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/python_requirements.py ansible-core-2.19.1/test/lib/ansible_test/_internal/python_requirements.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/python_requirements.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/python_requirements.py 2025-08-25 19:16:05.000000000 +0000 @@ -55,6 +55,11 @@ get_coverage_version, ) +if t.TYPE_CHECKING: + from .host_profiles import ( + HostProfile, + ) + QUIET_PIP_SCRIPT_PATH = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'quiet_pip.py') REQUIREMENTS_SCRIPT_PATH = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'requirements.py') @@ -122,6 +127,7 @@ def install_requirements( args: EnvironmentConfig, + host_profile: HostProfile | None, python: PythonConfig, ansible: bool = False, command: bool = False, @@ -133,6 +139,7 @@ create_result_directories(args) if not requirements_allowed(args, controller): + post_install(host_profile) return if command and isinstance(args, (UnitsConfig, IntegrationConfig)) and args.coverage: @@ -161,7 +168,17 @@ sanity=None, ) + from .host_profiles import DebuggableProfile + + if isinstance(host_profile, DebuggableProfile) and host_profile.debugger and host_profile.debugger.get_python_package(): + commands.append(PipInstall( + requirements=[], + constraints=[], + packages=[host_profile.debugger.get_python_package()], + )) + if not commands: + post_install(host_profile) return run_pip(args, python, commands, connection) @@ -170,6 +187,16 @@ if any(isinstance(command, PipInstall) and command.has_package('pyyaml') for command in commands): check_pyyaml(python) + post_install(host_profile) + + +def post_install(host_profile: HostProfile) -> None: + """Operations to perform after requirements are installed.""" + from .host_profiles import DebuggableProfile + + if isinstance(host_profile, DebuggableProfile): + host_profile.activate_debugger() + def collect_bootstrap(python: PythonConfig) -> list[PipCommand]: """Return the details necessary to bootstrap pip into an empty virtual environment.""" diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/target.py ansible-core-2.19.1/test/lib/ansible_test/_internal/target.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/target.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/target.py 2025-08-25 19:16:05.000000000 +0000 @@ -582,6 +582,14 @@ else: static_aliases = tuple() + # non-group aliases which need to be extracted before group mangling occurs + + self.env_set: dict[str, str] = { + match.group('key'): match.group('value') for match in ( + re.match(r'env/set/(?P[^/]+)/(?P.*)', alias) for alias in static_aliases + ) if match + } + # modules if self.name in modules: diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/util_common.py ansible-core-2.19.1/test/lib/ansible_test/_internal/util_common.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_internal/util_common.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_internal/util_common.py 2025-08-25 19:16:05.000000000 +0000 @@ -451,10 +451,20 @@ """ Run a command while intercepting invocations of Python to control the version used. If the specified Python is an ansible-test managed virtual environment, it will be added to PATH to activate it. - Otherwise a temporary directory will be created to ensure the correct Python can be found in PATH. + Otherwise, a temporary directory will be created to ensure the correct Python can be found in PATH. """ - env = env.copy() cmd = list(cmd) + env = get_injector_env(python, env) + + return run_command(args, cmd, capture=capture, env=env, data=data, cwd=cwd, always=always) + + +def get_injector_env( + python: PythonConfig, + env: dict[str, str], +) -> dict[str, str]: + """Get the environment variables needed to inject the given Python interpreter into the environment.""" + env = env.copy() inject_path = get_injector_path() # make sure scripts (including injector.py) find the correct Python interpreter @@ -467,7 +477,7 @@ env['ANSIBLE_TEST_PYTHON_VERSION'] = python.version env['ANSIBLE_TEST_PYTHON_INTERPRETER'] = python.path - return run_command(args, cmd, capture=capture, env=env, data=data, cwd=cwd, always=always) + return env def run_command( diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test.cfg ansible-core-2.19.1/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test.cfg --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test.cfg 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test.cfg 2025-08-25 19:16:05.000000000 +0000 @@ -21,6 +21,7 @@ broad-exception-raised, # many exceptions with no need for a custom type too-few-public-methods, too-many-public-methods, + too-many-ancestors, too-many-arguments, too-many-branches, too-many-instance-attributes, diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py ansible-core-2.19.1/test/lib/ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py 2025-08-25 19:16:05.000000000 +0000 @@ -9,6 +9,7 @@ import datetime import functools import pathlib +import re import astroid import astroid.context @@ -156,7 +157,7 @@ ), ) - ANSIBLE_VERSION = StrictVersion('.'.join(ansible.release.__version__.split('.')[:3])) + ANSIBLE_VERSION = StrictVersion(re.match('[0-9.]*[0-9]', ansible.release.__version__)[0]) """The current ansible-core X.Y.Z version.""" DEPRECATION_MODULE_FUNCTIONS: dict[tuple[str, str], tuple[str, ...]] = { diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/target/injector/python.py ansible-core-2.19.1/test/lib/ansible_test/_util/target/injector/python.py --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/target/injector/python.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_util/target/injector/python.py 2025-08-25 19:16:05.000000000 +0000 @@ -15,6 +15,7 @@ args = [sys.executable] ansible_lib_root = os.environ.get('ANSIBLE_TEST_ANSIBLE_LIB_ROOT') + debugger_config = os.environ.get('ANSIBLE_TEST_DEBUGGER_CONFIG') coverage_config = os.environ.get('COVERAGE_CONF') coverage_output = os.environ.get('COVERAGE_FILE') @@ -28,6 +29,13 @@ sys.exit('ERROR: Could not find `coverage` module. ' 'Did you use a virtualenv created without --system-site-packages or with the wrong interpreter?') + if debugger_config: + import json + + debugger_options = json.loads(debugger_config) + os.environ.update(debugger_options['env']) + args += debugger_options['args'] + if name == 'python.py': if sys.argv[1] == '-c': # prevent simple misuse of python.py with -c which does not work with coverage diff -Nru ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/target/setup/bootstrap.sh ansible-core-2.19.1/test/lib/ansible_test/_util/target/setup/bootstrap.sh --- ansible-core-2.19.0~beta6/test/lib/ansible_test/_util/target/setup/bootstrap.sh 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/lib/ansible_test/_util/target/setup/bootstrap.sh 2025-08-25 19:16:05.000000000 +0000 @@ -2,6 +2,24 @@ set -eu +retry_init() +{ + attempt=0 +} + +retry_or_fail() +{ + attempt=$((attempt + 1)) + + if [ $attempt -gt 5 ]; then + echo "Failed to install packages. Giving up." + exit 1 + fi + + echo "Failed to install packages. Sleeping before trying again..." + sleep 10 +} + remove_externally_managed_marker() { "${python_interpreter}" -c ' @@ -64,13 +82,13 @@ ;; esac + retry_init while true; do curl --silent --show-error "${pip_bootstrap_url}" -o /tmp/get-pip.py && \ "${python_interpreter}" /tmp/get-pip.py --disable-pip-version-check --quiet && \ rm /tmp/get-pip.py \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done fi } @@ -99,21 +117,21 @@ " fi + retry_init while true; do # shellcheck disable=SC2086 apk add -q ${packages} \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done # Upgrade the `libexpat` package to ensure that an upgraded Python (`pyexpat`) continues to work. + retry_init while true; do # shellcheck disable=SC2086 apk upgrade -q libexpat \ && break - echo "Failed to upgrade libexpat. Sleeping before trying again..." - sleep 10 + retry_or_fail done } @@ -138,12 +156,12 @@ " fi + retry_init while true; do # shellcheck disable=SC2086 dnf install -q -y ${packages} \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done } @@ -169,6 +187,9 @@ # Declare platform/python version combinations which do not have supporting OS packages available. # For these combinations ansible-test will use pip to install the requirements instead. case "${platform_version}/${python_version}" in + 13.5/3.11) + # defaults available + ;; 14.2/3.11) # defaults available ;; @@ -191,13 +212,13 @@ " fi + retry_init while true; do # shellcheck disable=SC2086 env ASSUME_ALWAYS_YES=YES pkg bootstrap && \ pkg install -q -y ${packages} \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done install_pip @@ -272,12 +293,12 @@ " fi + retry_init while true; do # shellcheck disable=SC2086 dnf install -q -y ${packages} \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done } @@ -302,12 +323,12 @@ " fi + retry_init while true; do # shellcheck disable=SC2086 dnf install -q -y ${packages} \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done } @@ -354,13 +375,13 @@ " fi + retry_init while true; do # shellcheck disable=SC2086 apt-get update -qq -y && \ DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --no-install-recommends ${packages} \ && break - echo "Failed to install packages. Sleeping before trying again..." - sleep 10 + retry_or_fail done } diff -Nru ansible-core-2.19.0~beta6/test/sanity/code-smell/mypy/ansible-core.ini ansible-core-2.19.1/test/sanity/code-smell/mypy/ansible-core.ini --- ansible-core-2.19.0~beta6/test/sanity/code-smell/mypy/ansible-core.ini 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/sanity/code-smell/mypy/ansible-core.ini 2025-08-25 19:16:05.000000000 +0000 @@ -133,3 +133,6 @@ [mypy-jinja2.nativetypes] ignore_missing_imports = True + +[mypy-debugpy] +ignore_missing_imports = True diff -Nru ansible-core-2.19.0~beta6/test/sanity/code-smell/mypy/ansible-test.ini ansible-core-2.19.1/test/sanity/code-smell/mypy/ansible-test.ini --- ansible-core-2.19.0~beta6/test/sanity/code-smell/mypy/ansible-test.ini 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/sanity/code-smell/mypy/ansible-test.ini 2025-08-25 19:16:05.000000000 +0000 @@ -77,3 +77,9 @@ [mypy-py._path.local] ignore_missing_imports = True + +[mypy-debugpy] +ignore_missing_imports = True + +[mypy-debugpy.server] +ignore_missing_imports = True diff -Nru ansible-core-2.19.0~beta6/test/units/_internal/_ansiballz/test_builder.py ansible-core-2.19.1/test/units/_internal/_ansiballz/test_builder.py --- ansible-core-2.19.0~beta6/test/units/_internal/_ansiballz/test_builder.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/units/_internal/_ansiballz/test_builder.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,22 @@ +from __future__ import annotations + +from ansible._internal._ansiballz._builder import ExtensionManager +from ansible.module_utils._internal._ansiballz._extensions import _pydevd + + +def test_debugger_source_mapping() -> None: + """Synthetic coverage for builder source mapping.""" + debug_options = _pydevd.Options(source_mapping={ + "ide/path.py": "controller/path.py", + "ide/something.py": "controller/not_match.py", + }) + + manager = ExtensionManager(debug_options) + manager.source_mapping.update({ + "controller/path.py": "zip/path.py", + "controller/other.py": "not_match.py", + }) + + extensions = manager.get_extensions() + + assert extensions['_pydevd']['source_mapping'] == {'controller/other.py': 'not_match.py', 'ide/path.py': 'zip/path.py'} diff -Nru ansible-core-2.19.0~beta6/test/units/_internal/_json/test_json.py ansible-core-2.19.1/test/units/_internal/_json/test_json.py --- ansible-core-2.19.0~beta6/test/units/_internal/_json/test_json.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/units/_internal/_json/test_json.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,51 @@ +from __future__ import annotations + +import typing as t + +import pytest + +from ansible._internal._json import AnsibleVariableVisitor, EncryptedStringBehavior +from ansible.errors import AnsibleVariableTypeError +from ansible.parsing.vault import EncryptedString, AnsibleVaultError +from units.mock.vault_helper import VaultTestHelper + + +@pytest.mark.parametrize("behavior, decryptable, expected", ( + (EncryptedStringBehavior.PRESERVE, True, None), + (EncryptedStringBehavior.PRESERVE, False, None), + (EncryptedStringBehavior.DECRYPT, True, "plaintext"), + (EncryptedStringBehavior.DECRYPT, False, AnsibleVaultError("no vault secrets")), + (EncryptedStringBehavior.REDACT, True, ""), + (EncryptedStringBehavior.REDACT, False, ""), + (EncryptedStringBehavior.FAIL, True, AnsibleVariableTypeError("unsupported for variable storage")), + (EncryptedStringBehavior.FAIL, False, AnsibleVariableTypeError("unsupported for variable storage")), +), ids=str) +def test_encrypted_string_behavior( + behavior: EncryptedStringBehavior, + decryptable: bool, + expected: t.Any, + _vault_secrets_context: None, +) -> None: + if decryptable: + value = VaultTestHelper.make_encrypted_string('plaintext') + else: + # valid ciphertext with intentionally unavailable secret + value = EncryptedString(ciphertext=( + '$ANSIBLE_VAULT;1.1;AES256\n' + '333665623864636331356364306535613231613833616662656130613665336561316435393736366636663864396636326330626530643238653462333562350a396162623230643' + '037396430383335386663363534353733386430643764303062633738613533336135653563313139373038333964316264633265376435370a326137363231646261303036356636' + '37346430303361316436306130663461393832656134346639326365633830373361376236343961386164323538353962' + )) + + avv = AnsibleVariableVisitor(encrypted_string_behavior=behavior) + + if isinstance(expected, Exception): + with pytest.raises(type(expected), match=expected.args[0]): + avv.visit(value) + else: + result = avv.visit(value) + + if expected is None: + assert result is value + else: + assert result == expected diff -Nru ansible-core-2.19.0~beta6/test/units/_internal/templating/test_jinja_bits.py ansible-core-2.19.1/test/units/_internal/templating/test_jinja_bits.py --- ansible-core-2.19.0~beta6/test/units/_internal/templating/test_jinja_bits.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/_internal/templating/test_jinja_bits.py 2025-08-25 19:16:05.000000000 +0000 @@ -9,6 +9,7 @@ import pytest import pytest_mock +from ansible._internal._templating._access import NotifiableAccessContextBase from ansible.errors import AnsibleUndefinedVariable, AnsibleTemplateError from ansible._internal._templating._errors import AnsibleTemplatePluginRuntimeError from ansible.module_utils._internal._datatag import AnsibleTaggedObject @@ -235,17 +236,21 @@ ('on_dict["get"]', ok), # [] prefers getitem, matching dict key present (no attr lookup) ('on_dict.get("_native_copy")', ok), # . matches safe method on dict, should be callable to fetch a valid key ('on_dict["clear"]', ok), # [] prefers getitem, matching dict key present (no attr lookup) - ('on_dict.clear', ok), # . matches known-mutating method on _AnsibleTaggedDict, custom fallback to getitem with valid key - ('on_dict["setdefault"]', undefined_with_unsafe), # [] finds no matching dict key, getattr fallback matches known-mutating method, fails - ('on_dict.setdefault', undefined_with_unsafe), # . finds a known-mutating method, getitem fallback finds no matching dict key, fails ('on_dict["_non_method_or_attr"]', ok), # [] prefers getitem, sunder key ok ('on_dict._non_method_or_attr', ok), # . finds nothing, getattr fallback finds dict key, `_` prefix has no effect - ('on_list.sort', undefined_with_unsafe), # . matches known-mutating method on list, fails - ('on_list["sort"]', undefined_with_unsafe), # [] gets TypeError, getattr fallback matches known-mutating method on list, fails ('on_list._native_copy', undefined_with_unsafe), # . matches sunder-named method on list, fails ('on_list["_native_copy"]', undefined_with_unsafe), # [] gets TypeError, getattr fallback matches sunder-named method on list, fails ('on_list.0', 42), # . gets AttributeError, getitem fallback succeeds ('on_list[0]', 42), # [] prefers getitem, succeeds + # -- Jinja mutable method sandbox test cases follow; if sandbox is re-enabled, the correct behavior is defined by the commented value below + ('on_dict.clear | type_debug', 'builtin_function_or_method'), + # ('on_dict.clear', ok), # . matches known-mutating method on _AnsibleTaggedDict, custom fallback to getitem with valid key + ('on_dict["setdefault"] | type_debug', 'method'), + # ('on_dict.setdefault', undefined_with_unsafe), # . finds a known-mutating method, getitem fallback finds no matching dict key, fails + ('on_list.sort | type_debug', 'method'), + # ('on_list.sort', undefined_with_unsafe), # . matches known-mutating method on list, fails + ('on_list["sort"] | type_debug', 'method'), + # ('on_list["sort"]', undefined_with_unsafe), # [] gets TypeError, getattr fallback matches known-mutating method on list, fails )) def test_jinja_getattr(expr: str, expected: object) -> None: """Validate expected behavior from Jinja environment getattr/getitem methods, including Ansible-customized fallback behavior.""" @@ -401,3 +406,65 @@ res = TemplateEngine(variables=variables).template(TRUST.tag(template)) assert res == expected + + +@pytest.mark.parametrize("expression, result", ( + ("(0x20).__or__(0xf)", 47), + ("(0x20).__and__(0x29)", 32), + ("(0x20).__lshift__(1)", 64), + ("(0x20).__rshift__(1)", 16), + ("(0x20).__xor__(0x29)", 9), +)) +def test_bitwise_dunder_methods(expression: str, result: object) -> None: + """ + Verify a limited set of dunder methods are supported. + This feature may be deprecated and removed in the future after bitwise filters are implemented. + """ + assert TemplateEngine().evaluate_expression(TRUST.tag(expression)) == result + + +@pytest.mark.parametrize("expression", ( + "{1:2}.__delitem__(1)", + "[123].__len__()", +)) +def test_disallowed_dunder_methods(expression: str) -> None: + """Verify dunder methods are disallowed by the Jinja sandbox.""" + with pytest.raises(AnsibleUndefinedVariable, match="is unsafe"): + TemplateEngine().evaluate_expression(TRUST.tag(expression)) + + +@pytest.mark.parametrize("template, result", ( + ("{% set my_list = [] %}{% set _ = my_list.append(1) %}{{ my_list }}", [1]), + ("{% set my_list = [] %}{% set _ = my_list.extend([1, 2]) %}{{ my_list }}", [1, 2]), + ("{% set my_dict = {} %}{% set _ = my_dict.update(a=1) %}{{ my_dict }}", dict(a=1)), +)) +def test_mutation_methods(template: str, result: object) -> None: + """ + Verify mutation methods are allowed by the Jinja sandbox. + This feature may be deprecated and removed in a future release by using Jinja's ImmutableSandboxedEnvironment. + """ + assert TemplateEngine().template(TRUST.tag(template)) == result + + +class ExampleMarkerAccessTracker(NotifiableAccessContextBase): + def __init__(self) -> None: + self._type_interest = frozenset(Marker._concrete_subclasses) + self._markers: list[Marker] = [] + + def _notify(self, o: Marker) -> None: + self._markers.append(o) + + +@pytest.mark.parametrize("template", ( + '{{ adict["bogus"] | default("ok") }}', + '{{ adict.bogus | default("ok") }}', +)) +def test_marker_access_getattr_and_getitem(template: str) -> None: + """Ensure that getattr and getitem always access markers.""" + # the absence of a JinjaCallContext should cause the access done by getattr and getitem not to trip when a marker is encountered + assert TemplateEngine(variables=dict(adict={})).template(TRUST.tag(template)) == "ok" + + with ExampleMarkerAccessTracker() as tracker: # the access done by getattr and getitem should immediately trip when a marker is encountered + TemplateEngine(variables=dict(adict={})).template(TRUST.tag(template)) + + assert type(tracker._markers[0]) is UndefinedMarker # pylint: disable=unidiomatic-typecheck diff -Nru ansible-core-2.19.0~beta6/test/units/_internal/templating/test_lazy_containers.py ansible-core-2.19.1/test/units/_internal/templating/test_lazy_containers.py --- ansible-core-2.19.0~beta6/test/units/_internal/templating/test_lazy_containers.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/_internal/templating/test_lazy_containers.py 2025-08-25 19:16:05.000000000 +0000 @@ -16,7 +16,8 @@ from ansible._internal._datatag._tags import Origin, TrustedAsTemplate from ansible._internal._templating._utils import TemplateContext, LazyOptions from ansible._internal._templating._engine import TemplateEngine, TemplateOptions -from ansible._internal._templating._lazy_containers import _AnsibleLazyTemplateMixin, _AnsibleLazyTemplateList, _AnsibleLazyTemplateDict, _LazyValue +from ansible._internal._templating._lazy_containers import _AnsibleLazyTemplateMixin, _AnsibleLazyTemplateList, _AnsibleLazyTemplateDict, _LazyValue, \ + _AnsibleLazyAccessTuple, UnsupportedConstructionMethodError from ansible.module_utils._internal._datatag import AnsibleTaggedObject from ...module_utils.datatag.test_datatag import ExampleSingletonTag @@ -299,6 +300,7 @@ ('type(d1)(d1)', dict(a=_LazyValue(1), c=_LazyValue(1)), _AnsibleLazyTemplateDict), # _AnsibleLazyTemplateDict.__init__ copy ('l1.copy()', [_LazyValue(1)], _AnsibleLazyTemplateList), # _AnsibleLazyTemplateList.copy ('type(l1)(l1)', [_LazyValue(1)], _AnsibleLazyTemplateList), # _AnsibleLazyTemplateList.__init__ copy + ('type(t1)(t1)', (1,), _AnsibleLazyAccessTuple), ('copy.copy(l1)', [_LazyValue(1)], _AnsibleLazyTemplateList), ('copy.copy(d1)', dict(a=_LazyValue(1), c=_LazyValue(1)), _AnsibleLazyTemplateDict), ('copy.deepcopy(l1)', [_LazyValue(1)], _AnsibleLazyTemplateList), # __AnsibleLazyTemplateList.__deepcopy__ @@ -308,6 +310,7 @@ ('list(reversed(l1))', [1], list), # _AnsibleLazyTemplateList.__reversed__ ('list(reversed(d1))', ['c', 'a'], list), # dict.__reversed__ - keys only ('l1[:]', [_LazyValue(1)], _AnsibleLazyTemplateList), # __getitem__ (slice) + ('t1[:]', (1,), _AnsibleLazyAccessTuple), # __getitem__ (slice) ('d1["a"]', 1, int), # __getitem__ ('d1.get("a")', 1, int), # get ('l1[0]', 1, int), # __getitem__ @@ -366,6 +369,9 @@ ('tuple() + l1', 'can only concatenate tuple (not "_AnsibleLazyTemplateList") to tuple', TypeError), # __radd__ (relies on tuple.__add__) ('tuple() + d1', 'can only concatenate tuple (not "_AnsibleLazyTemplateDict") to tuple', TypeError), # relies on tuple.__add__ ('l1.pop(42)', "pop index out of range", IndexError), + ('type(l1)([])', 'Direct construction of lazy containers is not supported.', UnsupportedConstructionMethodError), + ('type(t1)([])', 'Direct construction of lazy containers is not supported.', UnsupportedConstructionMethodError), + ('type(d1)({})', 'Direct construction of lazy containers is not supported.', UnsupportedConstructionMethodError), ], ids=str) def test_lazy_container_operators(expression: str, expected_value: t.Any, expected_type: type) -> None: """ @@ -387,6 +393,7 @@ l1x=[TRUST.tag('{{ one }}')], l2=[TRUST.tag('{{ two }}')], l2f=l2f, + t1=(TRUST.tag('{{ one }}'),), d1=dict(a=TRUST.tag('{{ one }}'), c=TRUST.tag('{{ one }}')), d1x=dict(a=TRUST.tag('{{ one }}'), c=TRUST.tag('{{ one }}')), d2=dict(b=TRUST.tag('{{ two }}'), c=TRUST.tag('{{ two }}')), @@ -436,6 +443,15 @@ actual_list_types: list[type] = [type(value) for value in list.__iter__(result)] assert actual_list_types == expected_list_types + elif issubclass(expected_type, tuple): + assert isinstance(result, tuple) # redundant, but assists mypy in understanding the type + + expected_tuple_types = [type(value) for value in expected_value] + expected_result = expected_value + + actual_tuple_types: list[type] = [type(value) for value in tuple.__iter__(result)] + + assert actual_tuple_types == expected_tuple_types elif issubclass(expected_type, dict): assert isinstance(result, dict) # redundant, but assists mypy in understanding the type @@ -867,3 +883,12 @@ assert all((base_type.__getitem__(copied, key) is base_type.__getitem__(original, key)) != deep for key in keys) assert (copied._templar is original._templar) != deep assert (copied._lazy_options is original._lazy_options) != deep + + +def test_lazy_template_mixin_init() -> None: + """ + Verify `_AnsibleLazyTemplateMixin` checks the __init__ arg type. + This code path is not normally reachable, since types which use it perform the same check before invoking the mixin. + """ + with pytest.raises(UnsupportedConstructionMethodError): + _AnsibleLazyTemplateMixin(t.cast(t.Any, None)) diff -Nru ansible-core-2.19.0~beta6/test/units/_internal/templating/test_templar.py ansible-core-2.19.1/test/units/_internal/templating/test_templar.py --- ansible-core-2.19.0~beta6/test/units/_internal/templating/test_templar.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/_internal/templating/test_templar.py 2025-08-25 19:16:05.000000000 +0000 @@ -27,6 +27,7 @@ import pytest_mock from jinja2.runtime import Context +from jinja2.loaders import DictLoader import unittest @@ -1061,9 +1062,7 @@ @pytest.mark.parametrize("template,expected", ( - ("{% set x=[] %}{% set _=x.append(42) %}{{ x }}", [42]), - ("{{ (32).__or__(64) }}", 96), - ("{% set x={'foo': 42} %}{% set _=x.clear() %}{{ x }}", {}), + ("{{ (-1).__abs__() }}", 1), )) def test_unsafe_attr_access(template: str, expected: object) -> None: """Verify that unsafe attribute access fails by default and works when explicitly configured.""" @@ -1080,3 +1079,47 @@ """Verify test plugins can raise MarkerError to return a Marker, and that no warnings or deprecations are emitted.""" with emits_warnings(deprecation_pattern=[], warning_pattern=[]): 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 + ("{% import 'importme' as imported %}{{ imported }}", "imported template result"), +)) +def test_none_concat(template: str, expected: object) -> None: + """Validate that None values are omitted from composite template concat.""" + te = TemplateEngine() + + # set up an importable template to exercise TemplateModule code paths + te.environment.loader = DictLoader(dict(importme=TRUST.tag("{{ none }}{{ 'imported template result' }}{{ none }}"))) + + assert te.template(TRUST.tag(template)) == expected + + +def test_filter_generator() -> None: + """Verify that filters which return a generator are converted to a list while under the filter's JinjaCallContext.""" + variables = dict( + foo=[ + dict(x=1, optional_var=0), + dict(x=2), + ], + bar=TRUST.tag("{{ foo | selectattr('optional_var', 'defined') }}"), + ) + + te = TemplateEngine(variables=variables) + te.template(TRUST.tag("{{ bar }}")) + te.template(TRUST.tag("{{ lookup('vars', 'bar') }}")) + + +def test_call_context_reset() -> None: + """Ensure that new template invocations do not inherit trip behavior from running Jinja plugins.""" + templar = TemplateEngine(variables=dict( + somevar=TRUST.tag("{{ somedict.somekey | default('ok') }}"), + somedict=dict( + somekey=TRUST.tag("{{ not_here }}"), + ) + )) + + assert templar.template(TRUST.tag("{{ lookup('vars', 'somevar') }}")) == 'ok' diff -Nru ansible-core-2.19.0~beta6/test/units/cli/test_adhoc.py ansible-core-2.19.1/test/units/cli/test_adhoc.py --- ansible-core-2.19.0~beta6/test/units/cli/test_adhoc.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/cli/test_adhoc.py 2025-08-25 19:16:05.000000000 +0000 @@ -62,7 +62,7 @@ adhoc_cli.parse() ret = adhoc_cli._play_ds('command', 10, 2) assert ret['name'] == 'Ansible Ad-Hoc' - assert ret['tasks'] == [{'action': {'module': 'command', 'args': {}}, 'async_val': 10, 'poll': 2, 'timeout': 0}] + assert ret['tasks'] == [{'action': 'command', 'args': {}, 'async_val': 10, 'poll': 2, 'timeout': 0}] def test_play_ds_with_include_role(): diff -Nru ansible-core-2.19.0~beta6/test/units/config/test_manager.py ansible-core-2.19.1/test/units/config/test_manager.py --- ansible-core-2.19.0~beta6/test/units/config/test_manager.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/config/test_manager.py 2025-08-25 19:16:05.000000000 +0000 @@ -16,6 +16,7 @@ from ansible.errors import AnsibleOptionsError, AnsibleError from ansible._internal._datatag._tags import Origin, VaultedValue from ansible.module_utils._internal._datatag import AnsibleTagHelper +from ansible.template import is_trusted_as_template from units.mock.vault_helper import VaultTestHelper curdir = os.path.dirname(__file__) @@ -272,3 +273,30 @@ actual_value = manager.get_config_value(key) # THEN: no error assert actual_value == expected_value + + +def test_config_trust_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + expected = "from test" + monkeypatch.setenv("ANSIBLE_TEST_ENTRY", expected) + result = ConfigManager().get_config_value("_Z_TEST_ENTRY") + origin = Origin.get_tag(result) + + assert result == expected + assert is_trusted_as_template(result) + assert origin and origin.description == '' + + +def test_config_trust_from_file(tmp_path: pathlib.Path) -> None: + expected = "from test" + cfg_path = tmp_path / 'test.cfg' + + cfg_path.write_text(f"[testing]\nvalid={expected}") + + result = ConfigManager(str(cfg_path)).get_config_value("_Z_TEST_ENTRY") + origin = Origin.get_tag(result) + + assert result == expected + assert is_trusted_as_template(result) + assert origin + assert origin.path == str(cfg_path) + assert origin.description == "section 'testing' option 'valid'" diff -Nru ansible-core-2.19.0~beta6/test/units/executor/test_task_executor.py ansible-core-2.19.1/test/units/executor/test_task_executor.py --- ansible-core-2.19.0~beta6/test/units/executor/test_task_executor.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/executor/test_task_executor.py 2025-08-25 19:16:05.000000000 +0000 @@ -273,8 +273,7 @@ self.assertIs(mock.sentinel.handler, handler) - action_loader.has_plugin.assert_has_calls([mock.call(action, collection_list=te._task.collections), - mock.call(module_prefix, collection_list=te._task.collections)]) + action_loader.has_plugin.assert_has_calls([mock.call(action, collection_list=te._task.collections)]) action_loader.get.assert_called_with( 'ansible.legacy.normal', task=te._task, connection=te._connection, diff -Nru ansible-core-2.19.0~beta6/test/units/module_utils/_internal/test_traceback.py ansible-core-2.19.1/test/units/module_utils/_internal/test_traceback.py --- ansible-core-2.19.0~beta6/test/units/module_utils/_internal/test_traceback.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/units/module_utils/_internal/test_traceback.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest +import pytest_mock + +from ansible.module_utils._internal import _traceback + + +@pytest.mark.parametrize("patched_parsed_args, event, expected", ( + (dict(_ansible_tracebacks_for=["error", "warning"]), _traceback.TracebackEvent.ERROR, True), # included value + (dict(_ansible_tracebacks_for=["error", "warning"]), _traceback.TracebackEvent.WARNING, True), # included value + (dict(_ansible_tracebacks_for=["error", "warning"]), _traceback.TracebackEvent.DEPRECATED, False), # excluded value + ({}, _traceback.TracebackEvent.ERROR, False), # unspecified defaults to no tracebacks + (dict(_ansible_tracebacks_for="bogus,values"), _traceback.TracebackEvent.ERROR, True), # parse failure defaults to always enabled + (None, _traceback.TracebackEvent.ERROR, True), # fetch failure defaults to always enabled +), ids=str) +def test_default_module_traceback_config( + patched_parsed_args: dict | None, + event: _traceback.TracebackEvent, + expected: bool, + mocker: pytest_mock.MockerFixture +) -> None: + """Validate MU traceback config behavior (including unconfigured/broken config fallbacks).""" + from ansible.module_utils import basic + + mocker.patch.object(basic, '_PARSED_MODULE_ARGS', patched_parsed_args) + + # this should just be an importlib.reload() on _traceback, but that redeclares the enum type and breaks the world + mocker.patch.object(_traceback, '_module_tracebacks_enabled_events', None) + + assert _traceback._is_module_traceback_enabled(event=event) is expected diff -Nru ansible-core-2.19.0~beta6/test/units/module_utils/basic/test_exit_json.py ansible-core-2.19.1/test/units/module_utils/basic/test_exit_json.py --- ansible-core-2.19.0~beta6/test/units/module_utils/basic/test_exit_json.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/module_utils/basic/test_exit_json.py 2025-08-25 19:16:05.000000000 +0000 @@ -10,7 +10,7 @@ import typing as t import pytest - +import pytest_mock EMPTY_INVOCATION: dict[str, dict[str, t.Any]] = {u'module_args': {}} DATETIME = datetime.datetime.strptime('2020-07-13 12:50:00', '%Y-%m-%d %H:%M:%S') @@ -153,3 +153,26 @@ out, err = capfd.readouterr() assert json.loads(out) == expected + + def test_record_module_result(self, mocker: pytest_mock.MockerFixture, stdin) -> None: + """Ensure that the temporary _record_module_result hook is called correctly.""" + recorded_result = None + + expected_result = dict(changed=False, worked="yay") + + def _record_module_result(_self, o: object) -> None: + assert isinstance(o, dict) + + nonlocal recorded_result + recorded_result = o + + from ansible.module_utils.basic import AnsibleModule + + mocker.patch.object(AnsibleModule, '_record_module_result', _record_module_result) + + am = AnsibleModule(argument_spec=dict()) + + with pytest.raises(SystemExit): + am.exit_json(**expected_result) + + assert expected_result.items() <= recorded_result.items() diff -Nru ansible-core-2.19.0~beta6/test/units/module_utils/common/test_utils.py ansible-core-2.19.1/test/units/module_utils/common/test_utils.py --- ansible-core-2.19.0~beta6/test/units/module_utils/common/test_utils.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/module_utils/common/test_utils.py 2025-08-25 19:16:05.000000000 +0000 @@ -33,6 +33,18 @@ class BranchIIB(BranchII): pass + class MultipleInheritanceBase: + pass + + class MultipleInheritanceBranchI(MultipleInheritanceBase): + pass + + class MultipleInheritanceBranchII(MultipleInheritanceBase): + pass + + class MultipleInheritanceChild(MultipleInheritanceBranchI, MultipleInheritanceBranchII): + pass + def test_bottom_level(self): assert get_all_subclasses(self.BranchIIB) == set() @@ -43,3 +55,8 @@ assert set(get_all_subclasses(self.Base)) == set([self.BranchI, self.BranchII, self.BranchIA, self.BranchIB, self.BranchIIA, self.BranchIIB]) + + def test_multiple_inheritance(self) -> None: + assert get_all_subclasses(self.MultipleInheritanceBase) == {self.MultipleInheritanceBranchI, + self.MultipleInheritanceBranchII, + self.MultipleInheritanceChild} diff -Nru ansible-core-2.19.0~beta6/test/units/module_utils/common/validation/test_check_type_str.py ansible-core-2.19.1/test/units/module_utils/common/validation/test_check_type_str.py --- ansible-core-2.19.0~beta6/test/units/module_utils/common/validation/test_check_type_str.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/module_utils/common/validation/test_check_type_str.py 2025-08-25 19:16:05.000000000 +0000 @@ -12,6 +12,7 @@ TEST_CASES = ( ('string', 'string'), + (None, '',), # 2.19+ relaxed restriction on None<->empty for backward compatibility (100, '100'), (1.5, '1.5'), ({'k1': 'v1'}, "{'k1': 'v1'}"), @@ -25,7 +26,7 @@ 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): with pytest.raises(TypeError) as e: _check_type_str_no_conversion(value) diff -Nru ansible-core-2.19.0~beta6/test/units/modules/conftest.py ansible-core-2.19.1/test/units/modules/conftest.py --- ansible-core-2.19.0~beta6/test/units/modules/conftest.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/modules/conftest.py 2025-08-25 19:16:05.000000000 +0000 @@ -15,16 +15,16 @@ @pytest.fixture -def set_module_args(): +def set_module_args() -> t.Iterator[t.Callable[[dict[str, t.Any] | None], None]]: ctx: t.ContextManager | None = None - def set_module_args(args): + def set_module_args(args: dict[str, t.Any] | None = None) -> None: nonlocal ctx args['_ansible_remote_tmp'] = '/tmp' args['_ansible_keep_remote_files'] = False - ctx = patch_module_args(args) + ctx = t.cast(t.ContextManager, patch_module_args(args)) ctx.__enter__() try: diff -Nru ansible-core-2.19.0~beta6/test/units/playbook/test_base.py ansible-core-2.19.1/test/units/playbook/test_base.py --- ansible-core-2.19.0~beta6/test/units/playbook/test_base.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/playbook/test_base.py 2025-08-25 19:16:05.000000000 +0000 @@ -207,7 +207,7 @@ def test_vars_not_valid_identifier(self): ds = {'environment': [], 'vars': [{'var_2_key': 'var_2_value'}, - {'1an-invalid identifer': 'var_1_value'}] + {'1an-invalid identifier': 'var_1_value'}] } self.assertRaises(AnsibleParserError, self.b.load_data, ds) @@ -332,6 +332,7 @@ test_attr_int = FieldAttribute(isa='int', always_post_validate=True) test_attr_float = FieldAttribute(isa='float', default=3.14159, always_post_validate=True) test_attr_list = FieldAttribute(isa='list', listof=(str,), always_post_validate=True) + test_attr_mixed_list = FieldAttribute(isa='list', listof=(str, int), always_post_validate=True) test_attr_list_no_listof = FieldAttribute(isa='list', always_post_validate=True) test_attr_list_required = FieldAttribute(isa='list', listof=(str,), required=True, default=list, always_post_validate=True) @@ -518,6 +519,16 @@ bsc = self._base_validate(ds) self.assertEqual(string_list, bsc._test_attr_list) + def test_attr_mixed_list(self): + mixed_list = ['foo', 1] + ds = {'test_attr_mixed_list': mixed_list} + bsc = self._base_validate(ds) + self.assertEqual(mixed_list, bsc._test_attr_mixed_list) + + def test_attr_mixed_list_invalid(self): + ds = {'test_attr_mixed_list': [['foo'], 1]} + self.assertRaises(AnsibleParserError, self._base_validate, ds) + def test_attr_list_none(self): ds = {'test_attr_list': None} bsc = self._base_validate(ds) diff -Nru ansible-core-2.19.0~beta6/test/units/plugins/callback/test_callback.py ansible-core-2.19.1/test/units/plugins/callback/test_callback.py --- ansible-core-2.19.0~beta6/test/units/plugins/callback/test_callback.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/plugins/callback/test_callback.py 2025-08-25 19:16:05.000000000 +0000 @@ -413,3 +413,13 @@ cb = CallbackBase() cb.v2_on_any('whatever', some_keyword='blippy') cb.on_any('whatever', some_keyword='blippy') + + +def test_v2_v1_method_map() -> None: + """Ensure that all v2 callback methods appear in the method map.""" + expected_names = [name for name in dir(CallbackBase) if name.startswith('v2_')] + mapped_names = {method.__name__ for method in CallbackBase._v2_v1_method_map} + + missing = [name for name in expected_names if name not in mapped_names] + + assert not missing diff -Nru ansible-core-2.19.0~beta6/test/units/plugins/connection/test_paramiko_ssh.py ansible-core-2.19.1/test/units/plugins/connection/test_paramiko_ssh.py --- ansible-core-2.19.0~beta6/test/units/plugins/connection/test_paramiko_ssh.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/plugins/connection/test_paramiko_ssh.py 2025-08-25 19:16:05.000000000 +0000 @@ -18,8 +18,10 @@ from __future__ import annotations +import sys from io import StringIO import pytest +import typing as t from ansible.module_utils import _internal from ansible.plugins.connection import paramiko_ssh as paramiko_ssh_module @@ -59,17 +61,25 @@ assert connection._connected is True -def test_deprecation_warning_controller(): +def test_deprecation_warning_controller(monkeypatch: pytest.MonkeyPatch) -> None: """Ensures deprecation warnings are generated for external paramiko imports.""" assert _internal.is_controller + sentinel: t.Any = object() + + monkeypatch.delitem(sys.modules, 'ansible') + monkeypatch.delitem(sys.modules, 'ansible.module_utils') + monkeypatch.delitem(sys.modules, 'ansible.module_utils.compat') + monkeypatch.delitem(sys.modules, 'ansible.module_utils.compat.paramiko') + monkeypatch.setitem(sys.modules, 'paramiko', sentinel) + # ensure direct access to `_` prefixed attrs does not warn with emits_warnings(deprecation_pattern=[], warning_pattern=[]): from ansible.module_utils.compat import paramiko - assert paramiko._paramiko is not None + assert paramiko._paramiko is sentinel assert isinstance(paramiko._PARAMIKO_IMPORT_ERR, (Exception, type(None))) with emits_warnings(deprecation_pattern=["The 'paramiko' compat import is deprecated", "The 'PARAMIKO_IMPORT_ERR' compat import is deprecated"]): from ansible.module_utils.compat import paramiko - assert paramiko.paramiko is not None + assert paramiko.paramiko is sentinel assert isinstance(paramiko.PARAMIKO_IMPORT_ERR, (Exception, type(None))) diff -Nru ansible-core-2.19.0~beta6/test/units/plugins/lookup/test_password.py ansible-core-2.19.1/test/units/plugins/lookup/test_password.py --- ansible-core-2.19.0~beta6/test/units/plugins/lookup/test_password.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/plugins/lookup/test_password.py 2025-08-25 19:16:05.000000000 +0000 @@ -472,11 +472,16 @@ @patch('time.sleep') def test_lock_been_held(self, mock_sleep): # pretend the lock file is here - password.os.path.exists = lambda x: True - with pytest.raises(AnsibleError): - with patch.object(builtins, 'open', mock_open(read_data=b'hunter42 salt=87654321\n')) as m: - # should timeout here - self.password_lookup.run([u'/path/to/somewhere chars=anything'], None) + def _already_exists(*args, **kwargs): + raise FileExistsError("The lock is busy, wait and try again.") + + with ( + pytest.raises(AnsibleError, match='^Password lookup cannot get the lock in 7 seconds.*'), + patch.object(password.os, 'open', _already_exists), + patch.object(password.os.path, 'exists', lambda *args, **kwargs: True), + ): + # should timeout here + self.password_lookup.run([u'/path/to/somewhere chars=anything'], None) def test_lock_not_been_held(self): # pretend now there is password file but no lock diff -Nru ansible-core-2.19.0~beta6/test/units/plugins/lookup/test_template.py ansible-core-2.19.1/test/units/plugins/lookup/test_template.py --- ansible-core-2.19.0~beta6/test/units/plugins/lookup/test_template.py 1970-01-01 00:00:00.000000000 +0000 +++ ansible-core-2.19.1/test/units/plugins/lookup/test_template.py 2025-08-25 19:16:05.000000000 +0000 @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pathlib + +from ansible._internal._templating._utils import Omit +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar, trust_as_template + + +def test_no_finalize_marker_passthru(tmp_path: pathlib.Path) -> None: + """Return an Undefined marker from a template lookup to ensure that the internal templating operation does not finalize its result.""" + template_path = tmp_path / 'template.txt' + template_path.write_text("{{ bogusvar }}") + + templar = Templar(loader=DataLoader(), variables=dict(template_path=str(template_path))) + + assert templar.template(trust_as_template('{{ lookup("template", template_path) | default("pass") }}')) == "pass" + + +def test_no_finalize_omit_passthru(tmp_path: pathlib.Path) -> None: + """Return an Omit scalar from a template lookup to ensure that the internal templating operation does not finalize its result.""" + template_path = tmp_path / 'template.txt' + template_path.write_text("{{ omitted }}") + + data = dict(omitted=trust_as_template("{{ omit }}"), template_path=str(template_path)) + + # The result from the lookup should be an Omit value, since the result of the template lookup's internal templating call should not be finalized. + # If it were, finalize would trip the Omit and raise an error about a top-level template result resolving to an Omit scalar. + res = Templar(loader=DataLoader(), variables=data).template(trust_as_template("{{ lookup('template', template_path) | type_debug }}")) + + assert res == type(Omit).__name__ diff -Nru ansible-core-2.19.0~beta6/test/units/template/test_template.py ansible-core-2.19.1/test/units/template/test_template.py --- ansible-core-2.19.0~beta6/test/units/template/test_template.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/template/test_template.py 2025-08-25 19:16:05.000000000 +0000 @@ -1,6 +1,7 @@ from __future__ import annotations import io +import keyword import pathlib import typing as t @@ -381,3 +382,23 @@ tvars = _template.generate_ansible_template_vars(path="path", fullpath=str(tmp_path), dest_path=str(tmp_path)) assert tvars['ansible_managed'] == 'value from config' + + +_ALLOWED_PYTHON_KEYWORDS = sorted(set(keyword.kwlist) - _jinja_bits.JINJA_KEYWORDS) +"""Python keywords which Jinja allows as variable names.""" + + +@pytest.mark.parametrize("keyword_name", _ALLOWED_PYTHON_KEYWORDS) +def test_set_get_keyword(keyword_name: str) -> None: + """Verify Python keywords that are not Jinja keywords can be freely used to set and get variables in templates.""" + template_set_get = TRUST.tag(f"{{% set {keyword_name} = 42 %}}{{{{ {keyword_name} }}}}") + + assert Templar().template(template_set_get) == 42 + + +@pytest.mark.parametrize("keyword_name", _ALLOWED_PYTHON_KEYWORDS) +def test_if_get_keyword(keyword_name: str) -> None: + """Verify Python keywords that are not Jinja keywords can be freely used as variables in templates and template conditionals.""" + template_set_get = TRUST.tag(f"{{% if {keyword_name} == 42 %}}{{{{ {keyword_name} }}}}{{% endif %}}") + + assert Templar(variables={keyword_name: 42}).template(template_set_get) == 42 diff -Nru ansible-core-2.19.0~beta6/test/units/utils/test_display.py ansible-core-2.19.1/test/units/utils/test_display.py --- ansible-core-2.19.0~beta6/test/units/utils/test_display.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/utils/test_display.py 2025-08-25 19:16:05.000000000 +0000 @@ -121,7 +121,7 @@ display = Display() display.set_queue(queue) display.warning('foo') - queue.send_display.assert_called_once_with('_warning', _messages.WarningSummary(event=_messages.Event(msg='foo')), wrap_text=True) + queue.send_display.assert_called_once_with('_warning', _messages.WarningSummary(event=_messages.Event(msg='foo'))) p = multiprocessing_context.Process(target=test) p.start() diff -Nru ansible-core-2.19.0~beta6/test/units/utils/test_vars.py ansible-core-2.19.1/test/units/utils/test_vars.py --- ansible-core-2.19.0~beta6/test/units/utils/test_vars.py 2025-06-11 22:30:33.000000000 +0000 +++ ansible-core-2.19.1/test/units/utils/test_vars.py 2025-08-25 19:16:05.000000000 +0000 @@ -23,8 +23,12 @@ from unittest import mock import unittest + from ansible.errors import AnsibleError -from ansible.utils.vars import combine_vars, merge_hash +from ansible._internal._datatag._tags import Origin +from ansible.parsing.vault import EncryptedString +from ansible.utils.vars import combine_vars, merge_hash, transform_to_native_types +from units.mock.vault_helper import VaultTestHelper class TestVariableUtils(unittest.TestCase): @@ -274,3 +278,30 @@ "b": high['b'] + [1, 1, 2] } self.assertEqual(merge_hash(low, high, True, 'prepend_rp'), expected) + + +def test_transform_to_native_types() -> None: + """Verify that transform_to_native_types results in native types for both keys and values, with default redaction.""" + value = { + Origin(description="blah").tag("tagged_key"): Origin(description="blah").tag("value with tagged key"), + # use a bogus EncryptedString instance with no VaultSecretsContext active; ensures that transform with redaction does not attempt decryption + "redact_this": EncryptedString(ciphertext="bogus") + } + + result = transform_to_native_types(value) + + assert result == dict(tagged_key="value with tagged key", redact_this='') + + assert all(type(key) is str for key in result.keys()) # pylint: disable=unidiomatic-typecheck + assert all(type(value) is str for value in result.values()) # pylint: disable=unidiomatic-typecheck + + +def test_transform_to_native_types_unredacted(_vault_secrets_context: None) -> None: + """Verify that transform with redaction disabled returns a plain string decrypted value.""" + plaintext = "hello" + value = dict(enc=VaultTestHelper.make_encrypted_string(plaintext)) + result = transform_to_native_types(value, redact=False) + + assert result == dict(enc=plaintext) + assert all(type(key) is str for key in result.keys()) # pylint: disable=unidiomatic-typecheck + assert all(type(value) is str for value in result.values()) # pylint: disable=unidiomatic-typecheck