From 6b2b665ef7b3ea35676e2cda648d2adc919a92f2 Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Mon, 28 Jul 2025 16:16:14 -0700 Subject: [PATCH] Add support for Python 3.14 and drop Python 3.8 (#85576) --- .azure-pipelines/azure-pipelines.yml | 2 +- changelogs/fragments/python-support.yml | 3 +++ lib/ansible/cli/__init__.py | 8 ++++--- .../_internal/_datatag/__init__.py | 12 ++-------- lib/ansible/module_utils/basic.py | 5 ++-- .../collection_loader/_collection_finder.py | 21 +++++++--------- .../ansible_test/_data/completion/docker.txt | 6 ++--- .../_data/pytest/config/legacy.ini | 4 ---- .../_data/requirements/ansible-test.txt | 1 - test/lib/ansible_test/_internal/cli/compat.py | 2 +- .../_internal/commands/units/__init__.py | 14 +---------- .../ansible_test/_internal/coverage_util.py | 1 - .../_util/target/common/constants.py | 2 +- test/sanity/code-smell/black.py | 2 ++ test/sanity/ignore.txt | 24 ++++++++++--------- .../_internal/_patches/test_patches.py | 2 ++ .../module_utils/common/test_collections.py | 2 +- .../test_check_required_arguments.py | 5 +++- 18 files changed, 50 insertions(+), 66 deletions(-) create mode 100644 changelogs/fragments/python-support.yml delete mode 100644 test/lib/ansible_test/_data/pytest/config/legacy.ini diff --git a/.azure-pipelines/azure-pipelines.yml b/.azure-pipelines/azure-pipelines.yml index 9ec53512fbf..92bf8b8455a 100644 --- a/.azure-pipelines/azure-pipelines.yml +++ b/.azure-pipelines/azure-pipelines.yml @@ -54,12 +54,12 @@ stages: nameFormat: Python {0} testFormat: units/{0} targets: - - test: 3.8 - test: 3.9 - test: '3.10' - test: 3.11 - test: 3.12 - test: 3.13 + - test: 3.14 - stage: Windows dependsOn: [] jobs: diff --git a/changelogs/fragments/python-support.yml b/changelogs/fragments/python-support.yml new file mode 100644 index 00000000000..8b86c3245fb --- /dev/null +++ b/changelogs/fragments/python-support.yml @@ -0,0 +1,3 @@ +major_changes: + - ansible - Add support for Python 3.14. + - ansible - Drop support for Python 3.8 on targets. diff --git a/lib/ansible/cli/__init__.py b/lib/ansible/cli/__init__.py index 0adc00c9bc9..da5cacc13bf 100644 --- a/lib/ansible/cli/__init__.py +++ b/lib/ansible/cli/__init__.py @@ -23,10 +23,12 @@ if 1 <= len(sys.argv) <= 2 and os.path.basename(sys.argv[0]) == "ansible" and os # Used for determining if the system is running a new enough python version # and should only restrict on our documented minimum versions -if sys.version_info < (3, 11): +_PY_MIN = (3, 11) + +if sys.version_info < _PY_MIN: raise SystemExit( - 'ERROR: Ansible requires Python 3.11 or newer on the controller. ' - 'Current version: %s' % ''.join(sys.version.splitlines()) + f"ERROR: Ansible requires Python {'.'.join(map(str, _PY_MIN))} or newer on the controller. " + f"Current version: {''.join(sys.version.splitlines())}" ) diff --git a/lib/ansible/module_utils/_internal/_datatag/__init__.py b/lib/ansible/module_utils/_internal/_datatag/__init__.py index 479a0278d0a..a00214e2a02 100644 --- a/lib/ansible/module_utils/_internal/_datatag/__init__.py +++ b/lib/ansible/module_utils/_internal/_datatag/__init__.py @@ -500,17 +500,9 @@ class AnsibleDatatagBase(AnsibleSerializableDataclass, metaclass=abc.ABCMeta): _known_tag_type_map: t.Dict[str, t.Type[AnsibleDatatagBase]] = {} _known_tag_types: t.Set[t.Type[AnsibleDatatagBase]] = set() -if sys.version_info >= (3, 9): - # Include the key and value types in the type hints on Python 3.9 and later. - # Earlier versions do not support subscriptable dict. - # deprecated: description='always use subscriptable dict' python_version='3.8' - class _AnsibleTagsMapping(dict[type[_TAnsibleDatatagBase], _TAnsibleDatatagBase]): - __slots__ = _NO_INSTANCE_STORAGE -else: - - class _AnsibleTagsMapping(dict): - __slots__ = _NO_INSTANCE_STORAGE +class _AnsibleTagsMapping(dict[type[_TAnsibleDatatagBase], _TAnsibleDatatagBase]): + __slots__ = _NO_INSTANCE_STORAGE class _EmptyROInternalTagsMapping(dict): diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py index b6104396ded..f361e2c84cf 100644 --- a/lib/ansible/module_utils/basic.py +++ b/lib/ansible/module_utils/basic.py @@ -11,12 +11,13 @@ import typing as t # Used for determining if the system is running a new enough python version # and should only restrict on our documented minimum versions -_PY_MIN = (3, 8) +_PY_MIN = (3, 9) if sys.version_info < _PY_MIN: print(json.dumps(dict( failed=True, - msg=f"ansible-core requires a minimum of Python version {'.'.join(map(str, _PY_MIN))}. Current version: {''.join(sys.version.splitlines())}", + msg=f"Ansible requires Python {'.'.join(map(str, _PY_MIN))} or newer on the target. " + f"Current version: {''.join(sys.version.splitlines())}", ))) sys.exit(1) diff --git a/lib/ansible/utils/collection_loader/_collection_finder.py b/lib/ansible/utils/collection_loader/_collection_finder.py index c654667d978..961fcef546f 100644 --- a/lib/ansible/utils/collection_loader/_collection_finder.py +++ b/lib/ansible/utils/collection_loader/_collection_finder.py @@ -26,20 +26,15 @@ from . import _to_bytes, _to_text from ._collection_config import AnsibleCollectionConfig try: - try: - # Available on Python >= 3.11 - # We ignore the import error that will trigger when running mypy with - # older Python versions. - from importlib.resources.abc import TraversableResources # type: ignore[import] - except ImportError: - # Used with Python 3.9 and 3.10 only - # This member is still available as an alias up until Python 3.14 but - # is deprecated as of Python 3.12. - from importlib.abc import TraversableResources # deprecated: description='TraversableResources move' python_version='3.10' + # Available on Python >= 3.11 + # We ignore the import error that will trigger when running mypy with + # older Python versions. + from importlib.resources.abc import TraversableResources # type: ignore[import] except ImportError: - # Python < 3.9 - # deprecated: description='TraversableResources fallback' python_version='3.8' - TraversableResources = object # type: ignore[assignment,misc] + # Used with Python 3.9 and 3.10 only + # This member is still available as an alias up until Python 3.14 but + # is deprecated as of Python 3.12. + from importlib.abc import TraversableResources # deprecated: description='TraversableResources move' python_version='3.10' # NB: this supports import sanity test providing a different impl try: diff --git a/test/lib/ansible_test/_data/completion/docker.txt b/test/lib/ansible_test/_data/completion/docker.txt index 6a473949062..7d1823c5046 100644 --- a/test/lib/ansible_test/_data/completion/docker.txt +++ b/test/lib/ansible_test/_data/completion/docker.txt @@ -1,6 +1,6 @@ -base image=quay.io/ansible/base-test-container:8.2.0 python=3.13,3.8,3.9,3.10,3.11,3.12 -default image=quay.io/ansible/default-test-container:11.6.0 python=3.13,3.8,3.9,3.10,3.11,3.12 context=collection -default image=quay.io/ansible/ansible-core-test-container:11.6.0 python=3.13,3.8,3.9,3.10,3.11,3.12 context=ansible-core +base image=quay.io/ansible/base-test-container:9.1.0 python=3.13,3.9,3.10,3.11,3.12,3.14 +default image=quay.io/ansible/default-test-container:12.0.0 python=3.13,3.9,3.10,3.11,3.12,3.14 context=collection +default image=quay.io/ansible/ansible-core-test-container:12.0.0 python=3.13,3.9,3.10,3.11,3.12,3.14 context=ansible-core alpine322 image=quay.io/ansible/alpine322-test-container:10.0.0 python=3.12 cgroup=none audit=none fedora42 image=quay.io/ansible/fedora42-test-container:10.0.0 python=3.13 cgroup=v2-only ubuntu2204 image=quay.io/ansible/ubuntu2204-test-container:10.0.0 python=3.10 diff --git a/test/lib/ansible_test/_data/pytest/config/legacy.ini b/test/lib/ansible_test/_data/pytest/config/legacy.ini deleted file mode 100644 index b2668dc2871..00000000000 --- a/test/lib/ansible_test/_data/pytest/config/legacy.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -xfail_strict = true -mock_use_standalone_module = true -junit_family = xunit1 diff --git a/test/lib/ansible_test/_data/requirements/ansible-test.txt b/test/lib/ansible_test/_data/requirements/ansible-test.txt index 5ae4ded86ca..404cc49b2c0 100644 --- a/test/lib/ansible_test/_data/requirements/ansible-test.txt +++ b/test/lib/ansible_test/_data/requirements/ansible-test.txt @@ -1,3 +1,2 @@ # The test-constraints sanity test verifies this file, but changes must be made manually to keep it in up-to-date. coverage == 7.10.0 ; python_version >= '3.9' and python_version <= '3.14' -coverage == 7.6.1 ; python_version >= '3.8' and python_version <= '3.8' diff --git a/test/lib/ansible_test/_internal/cli/compat.py b/test/lib/ansible_test/_internal/cli/compat.py index 43645695c5a..c5363673dcd 100644 --- a/test/lib/ansible_test/_internal/cli/compat.py +++ b/test/lib/ansible_test/_internal/cli/compat.py @@ -69,7 +69,7 @@ def controller_python(version: t.Optional[str]) -> t.Optional[str]: def get_fallback_remote_controller() -> str: """Return the remote fallback platform for the controller.""" - platform = 'freebsd' # lower cost than RHEL and macOS + platform = 'fedora' # Fedora is lower cost than other remotes and always supports a recent Python version candidates = [item for item in filter_completion(remote_completion()).values() if item.controller_supported and item.platform == platform] fallback = sorted(candidates, key=lambda value: str_to_version(value.version), reverse=True)[0] return fallback.name diff --git a/test/lib/ansible_test/_internal/commands/units/__init__.py b/test/lib/ansible_test/_internal/commands/units/__init__.py index 1e2f84b38be..91562da1c11 100644 --- a/test/lib/ansible_test/_internal/commands/units/__init__.py +++ b/test/lib/ansible_test/_internal/commands/units/__init__.py @@ -241,19 +241,7 @@ def command_units(args: UnitsConfig) -> None: sys.exit() for test_context, python, paths, env in test_sets: - # When using pytest-mock, make sure that features introduced in Python 3.8 are available to older Python versions. - # This is done by enabling the mock_use_standalone_module feature, which forces use of mock even when unittest.mock is available. - # Later Python versions have not introduced additional unittest.mock features, so use of mock is not needed as of Python 3.8. - # If future Python versions introduce new unittest.mock features, they will not be available to older Python versions. - # Having the cutoff at Python 3.8 also eases packaging of ansible-core since no supported controller version requires the use of mock. - # - # NOTE: This only affects use of pytest-mock. - # Collection unit tests may directly import mock, which will be provided by ansible-test when it installs requirements using pip. - # Although mock is available for ansible-core unit tests, they should import unittest.mock instead. - if str_to_version(python.version) < (3, 8): - config_name = 'legacy.ini' - else: - config_name = 'default.ini' + config_name = 'default.ini' cmd = [ 'pytest', diff --git a/test/lib/ansible_test/_internal/coverage_util.py b/test/lib/ansible_test/_internal/coverage_util.py index ae136ea842a..95ec08a483c 100644 --- a/test/lib/ansible_test/_internal/coverage_util.py +++ b/test/lib/ansible_test/_internal/coverage_util.py @@ -70,7 +70,6 @@ class CoverageVersion: COVERAGE_VERSIONS = ( # IMPORTANT: Keep this in sync with the ansible-test.txt requirements file. CoverageVersion('7.10.0', 7, (3, 9), (3, 14)), - CoverageVersion('7.6.1', 7, (3, 8), (3, 8)), ) """ This tuple specifies the coverage version to use for Python version ranges. diff --git a/test/lib/ansible_test/_util/target/common/constants.py b/test/lib/ansible_test/_util/target/common/constants.py index 31f56adcdae..ad412aa23df 100644 --- a/test/lib/ansible_test/_util/target/common/constants.py +++ b/test/lib/ansible_test/_util/target/common/constants.py @@ -5,7 +5,6 @@ from __future__ import annotations REMOTE_ONLY_PYTHON_VERSIONS = ( - '3.8', '3.9', '3.10', ) @@ -14,4 +13,5 @@ CONTROLLER_PYTHON_VERSIONS = ( '3.11', '3.12', '3.13', + '3.14', ) diff --git a/test/sanity/code-smell/black.py b/test/sanity/code-smell/black.py index f066eeb59b4..c1dbea01920 100644 --- a/test/sanity/code-smell/black.py +++ b/test/sanity/code-smell/black.py @@ -21,6 +21,8 @@ def main() -> None: remote_only_python_versions = os.environ['ANSIBLE_TEST_REMOTE_ONLY_PYTHON_VERSIONS'].split(',') fix_mode = bool(int(os.environ['ANSIBLE_TEST_FIX_MODE'])) + controller_python_versions.remove('3.14') # black does not yet support formatting for Python 3.14 + target_python_versions = remote_only_python_versions + controller_python_versions black(controller_paths, controller_python_versions, fix_mode) diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index 94f0ee69336..dae96e59468 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -27,12 +27,12 @@ lib/ansible/modules/systemd_service.py validate-modules:parameter-invalid lib/ansible/modules/user.py validate-modules:doc-default-does-not-match-spec lib/ansible/modules/user.py validate-modules:use-run-command-not-popen lib/ansible/module_utils/basic.py pylint:unused-import # deferring resolution to allow enabling the rule now -lib/ansible/module_utils/compat/selinux.py import-3.8!skip # pass/fail depends on presence of libselinux.so lib/ansible/module_utils/compat/selinux.py import-3.9!skip # pass/fail depends on presence of libselinux.so lib/ansible/module_utils/compat/selinux.py import-3.10!skip # pass/fail depends on presence of libselinux.so lib/ansible/module_utils/compat/selinux.py import-3.11!skip # pass/fail depends on presence of libselinux.so lib/ansible/module_utils/compat/selinux.py import-3.12!skip # pass/fail depends on presence of libselinux.so lib/ansible/module_utils/compat/selinux.py import-3.13!skip # pass/fail depends on presence of libselinux.so +lib/ansible/module_utils/compat/selinux.py import-3.14!skip # pass/fail depends on presence of libselinux.so lib/ansible/module_utils/compat/selinux.py pylint:unidiomatic-typecheck lib/ansible/module_utils/distro/_distro.py no-assert lib/ansible/module_utils/powershell/Ansible.ModuleUtils.ArgvParser.psm1 pslint:PSUseApprovedVerbs @@ -57,6 +57,7 @@ lib/ansible/plugins/inventory/host_list.py pylint:arguments-renamed lib/ansible/_internal/_wrapt.py mypy-3.11!skip # vendored code lib/ansible/_internal/_wrapt.py mypy-3.12!skip # vendored code lib/ansible/_internal/_wrapt.py mypy-3.13!skip # vendored code +lib/ansible/_internal/_wrapt.py mypy-3.14!skip # vendored code lib/ansible/_internal/_wrapt.py pep8!skip # vendored code lib/ansible/_internal/_wrapt.py pylint!skip # vendored code lib/ansible/_internal/ansible_collections/ansible/_protomatter/README.md no-unwanted-files @@ -211,16 +212,17 @@ test/units/module_utils/facts/test_facts.py mypy-3.9:assignment test/units/modules/mount_facts_data.py mypy-3.9:arg-type test/units/modules/test_apt.py mypy-3.9:name-match test/units/modules/test_mount_facts.py mypy-3.9:index -test/units/module_utils/basic/test_exit_json.py mypy-3.8:assignment -test/units/module_utils/basic/test_exit_json.py mypy-3.8:misc -test/units/module_utils/facts/other/test_facter.py mypy-3.8:assignment -test/units/module_utils/facts/other/test_ohai.py mypy-3.8:assignment -test/units/module_utils/facts/system/test_lsb.py mypy-3.8:assignment -test/units/module_utils/facts/test_collectors.py mypy-3.8:assignment -test/units/module_utils/facts/test_facts.py mypy-3.8:assignment -test/units/modules/mount_facts_data.py mypy-3.8:arg-type -test/units/modules/test_apt.py mypy-3.8:name-match -test/units/modules/test_mount_facts.py mypy-3.8:index +test/units/module_utils/basic/test_exit_json.py mypy-3.14:assignment +test/units/module_utils/basic/test_exit_json.py mypy-3.14:misc +test/units/module_utils/facts/other/test_facter.py mypy-3.14:assignment +test/units/module_utils/facts/other/test_ohai.py mypy-3.14:assignment +test/units/module_utils/facts/system/test_lsb.py mypy-3.14:assignment +test/units/module_utils/facts/test_collectors.py mypy-3.14:assignment +test/units/module_utils/facts/test_facts.py mypy-3.14:assignment +test/units/modules/mount_facts_data.py mypy-3.14:arg-type +test/units/modules/test_apt.py mypy-3.14:name-match +test/units/modules/test_mount_facts.py mypy-3.14:index +test/units/playbook/test_base.py mypy-3.14:assignment test/integration/targets/interpreter_discovery_python/library/test_non_python_interpreter.py shebang # test needs non-standard shebang test/integration/targets/inventory_script/bad_shebang shebang # test needs an invalid shebang test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/lookup/deprecated.py pylint!skip # validated as a collection diff --git a/test/units/module_utils/_internal/_patches/test_patches.py b/test/units/module_utils/_internal/_patches/test_patches.py index 3710e6f3990..08c84972868 100644 --- a/test/units/module_utils/_internal/_patches/test_patches.py +++ b/test/units/module_utils/_internal/_patches/test_patches.py @@ -10,6 +10,7 @@ import typing as t import pytest from ansible.module_utils._internal import _patches +from ansible.module_utils._internal._patches import _socket_patch from ansible.module_utils.common._utils import get_all_subclasses module_to_patch = sys.modules[__name__] @@ -36,6 +37,7 @@ def get_patch_required_test_cases() -> list: xfail_patch_when: dict[type[_patches.CallablePatch], bool] = { # Example: # _patches._some_patch_module.SomePatchClass: sys.version_info >= (3, 13), + _socket_patch.GetAddrInfoPatch: sys.version_info >= (3, 14), } patches = sorted(get_all_subclasses(_patches.CallablePatch), key=lambda item: item.__name__) diff --git a/test/units/module_utils/common/test_collections.py b/test/units/module_utils/common/test_collections.py index 7a95c515171..2e31b6b4787 100644 --- a/test/units/module_utils/common/test_collections.py +++ b/test/units/module_utils/common/test_collections.py @@ -130,7 +130,7 @@ class TestImmutableDict: # ImmutableDict is unhashable when one of its values is unhashable imdict = ImmutableDict({u'café': u'くらとみ', 1: [1, 2]}) - expected_reason = r"^unhashable type: 'list'$" + expected_reason = r"unhashable type: 'list'" with pytest.raises(TypeError, match=expected_reason): hash(imdict) diff --git a/test/units/module_utils/common/validation/test_check_required_arguments.py b/test/units/module_utils/common/validation/test_check_required_arguments.py index 16e79fe7dc2..7f8ab2b7728 100644 --- a/test/units/module_utils/common/validation/test_check_required_arguments.py +++ b/test/units/module_utils/common/validation/test_check_required_arguments.py @@ -4,6 +4,8 @@ from __future__ import annotations +import sys + import pytest from ansible.module_utils.common.text.converters import to_native @@ -84,4 +86,5 @@ def test_check_required_arguments_missing_none(): def test_check_required_arguments_no_params(arguments_terms): with pytest.raises(TypeError) as te: check_required_arguments(arguments_terms, None) - assert "'NoneType' is not iterable" in to_native(te.value) + expected = "argument of type 'NoneType' is not a container or iterable" if sys.version_info >= (3, 14) else "'NoneType' is not iterable" + assert expected in to_native(te.value)