diff --git a/.github/workflows/python-pr.yaml b/.github/workflows/python-pr.yaml index 86f6658..52d5e8f 100644 --- a/.github/workflows/python-pr.yaml +++ b/.github/workflows/python-pr.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, '3.10', '3.11', '3.12', '3.13'] + python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/python-tox.yaml b/.github/workflows/python-tox.yaml index f40d0bc..0b30157 100644 --- a/.github/workflows/python-tox.yaml +++ b/.github/workflows/python-tox.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, '3.10', '3.11', '3.12', '3.13'] + python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eafbc63..8443cf4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Changelog --------- +- Adds experimental support for Ansible 12 + Some things are still broken like `extra_vars` + [Daverball] + +- Drops support for Ansible 6 and Python 3.8 + 0.21.0 (2025-06-12) ~~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index 05f06bc..fda9a3f 100644 --- a/README.rst +++ b/README.rst @@ -34,8 +34,8 @@ The official way to use Ansible from Python is documented here: Compatibility ------------- -* Python 3.8+ -* Ansible 6+ +* Python 3.9+ +* Ansible 7+ * Mitogen 0.3.7+ Support for older releases is kept only if possible. New Ansible releases diff --git a/pyproject.toml b/pyproject.toml index ed4ada6..ed4b5b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ exclude_dirs = [ skips = ["B101"] [tool.mypy] -python_version = "3.8" +python_version = "3.9" follow_imports = "silent" namespace_packages = true explicit_package_bases = true @@ -76,7 +76,7 @@ include = [ ] line-length = 80 indent-width = 4 -target-version = "py38" +target-version = "py39" [tool.ruff.lint] select = [ @@ -186,12 +186,12 @@ Changelog legacy_tox_ini = """ [tox] envlist = - py{38,39,310}-ansible6 py{39,310,311}-ansible7 py{39,310,311}-ansible8 py{310,311,312}-ansible9 py{310,311,312}-ansible10 py{311,312,313}-ansible11 + py{311,312,313}-ansible12 ruff bandit mypy @@ -199,7 +199,6 @@ envlist = [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311,ruff,bandit,mypy @@ -210,10 +209,9 @@ python = usedevelop = true setenv = py{38,39,310,311,312,313}: COVERAGE_FILE = .coverage.{envname} + ansible12: UV_PRERELEASE = allow deps = -e{toxinidir}[tests] - ansible6: ansible==6.* - ansible6: ansible-core==2.13.* ansible7: ansible==7.* ansible7: ansible-core==2.14.* ansible8: ansible==8.* @@ -224,6 +222,8 @@ deps = ansible10: ansible-core==2.17.* ansible11: ansible==11.* ansible11: ansible-core==2.18.* + ansible12: ansible==12.* + ansible12: ansible-core==2.19.* commands = pytest --cov --cov-report= {posargs} @@ -253,7 +253,6 @@ deps = pytest types-paramiko commands = - mypy -p suitable -p tests --python-version 3.8 mypy -p suitable -p tests --python-version 3.9 mypy -p suitable -p tests --python-version 3.10 mypy -p suitable -p tests --python-version 3.11 diff --git a/setup.cfg b/setup.cfg index 6996635..90914da 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,6 @@ classifiers = Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 @@ -35,7 +34,7 @@ package_dir = = src packages = suitable -python_requires = >= 3.8 +python_requires = >= 3.9 platforms = any install_requires = ansible>=6 @@ -66,11 +65,5 @@ tests = suitable = py.typed -[flake8] -extend-select = B901,B903,B904,B908 -exclude=.venv,.git,.tox,dist,docs,*lib/python*,*egg,build -per_file_ignores = - *.pyi: B,E301,E302,E305,E501,E701,F401,F403,F405,F822,Y065 - [bdist_wheel] universal = 1 diff --git a/src/suitable/callback.py b/src/suitable/callback.py index 9b0f74f..91594ed 100644 --- a/src/suitable/callback.py +++ b/src/suitable/callback.py @@ -3,10 +3,11 @@ from ansible.plugins.callback import ( # type:ignore[import-untyped] CallbackBase ) -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from ansible.executor.task_result import TaskResult # type:ignore + from ansible.utils.display import Display # type:ignore from suitable.types import ResultData from typing_extensions import TypedDict @@ -21,10 +22,19 @@ class SilentCallbackModule(CallbackBase): # type:ignore[misc] """ + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + CALLBACK_NAME = 'silent' + unreachable: dict[str, ResultData] contacted: dict[str, ContactedResult] - def __init__(self) -> None: + def __init__( + self, + display: Display | None = None, + options: dict[str, Any] | None = None + ) -> None: + super().__init__(display, options) self.unreachable = {} self.contacted = {} diff --git a/src/suitable/inventory.py b/src/suitable/inventory.py index 42bd92f..106fa9a 100644 --- a/src/suitable/inventory.py +++ b/src/suitable/inventory.py @@ -3,14 +3,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from suitable.types import Hosts, HostVariables, Incomplete - from typing import Dict - _Base = Dict[str, HostVariables] -else: - _Base = dict - -class Inventory(_Base): +class Inventory(dict[str, 'HostVariables']): # noqa: FURB189 def __init__( self, diff --git a/src/suitable/module_runner.py b/src/suitable/module_runner.py index ecc215d..89e4ff1 100644 --- a/src/suitable/module_runner.py +++ b/src/suitable/module_runner.py @@ -7,6 +7,7 @@ import signal import sys +from ansible import __version__ from ansible.executor.task_queue_manager import TaskQueueManager # type:ignore from ansible.parsing.dataloader import DataLoader # type:ignore from ansible.inventory.manager import InventoryManager # type:ignore @@ -15,6 +16,7 @@ from ansible.vars.manager import VariableManager # type:ignore[import-untyped] from contextlib import contextmanager from datetime import datetime +from packaging.version import Version from pprint import pformat from suitable._modules import AnsibleModules from suitable.callback import SilentCallbackModule @@ -43,6 +45,9 @@ def utcnow() -> datetime: utcnow = datetime.utcnow +PASS_CALLBACK_BY_NAME = Version(__version__) >= Version('2.19') + + @contextmanager def ansible_verbosity(verbosity: int) -> Generator[None, None, None]: """ Temporarily changes the ansible verbosity. Relies on a single display @@ -219,10 +224,7 @@ def execute(self, *args: Any, **kwargs: Any) -> RunnerResults: 'hosts': 'all', 'gather_facts': 'no', 'tasks': [{ - 'action': { - 'module': self.module_name, - 'args': module_args, - }, + self.module_name: module_args, 'environment': self.api.environment, }] } @@ -265,6 +267,21 @@ def execute(self, *args: Any, **kwargs: Any) -> RunnerResults: 'passwords': getattr(self.api.options, 'passwords', {}), 'stdout_callback': callback } + if PASS_CALLBACK_BY_NAME: + from ansible.plugins.loader import callback_loader # type: ignore + del kwargs['stdout_callback'] + callback_name = 'suitable.callback.silent' + kwargs['stdout_callback_name'] = callback_name + # HACK: This is really not pretty, but creating a working + # ansible collection, just so the plugin finder can + # find our callback and create an instance does not + # seem worth the required effort + orig_get = callback_loader.get + callback_loader.get = lambda name, *a, **kw: ( + callback + if name == 'suitable.callback.silent' + else orig_get(name, *a, **kw) + ) if set_global_context: del kwargs['options'] @@ -296,6 +313,8 @@ def execute(self, *args: Any, **kwargs: Any) -> RunnerResults: raise finally: if task_queue_manager is not None: + if PASS_CALLBACK_BY_NAME: + callback_loader.get = orig_get task_queue_manager.cleanup() if set_global_context: diff --git a/src/suitable/runner_results.py b/src/suitable/runner_results.py index 281cdc6..45d79a7 100644 --- a/src/suitable/runner_results.py +++ b/src/suitable/runner_results.py @@ -4,23 +4,18 @@ if TYPE_CHECKING: from suitable.types import ResultData - from typing import Dict from typing_extensions import TypedDict class _RunnerResults(TypedDict): contacted: dict[str, ResultData] unreachable: dict[str, ResultData] - _Base = Dict[str, Dict[str, ResultData]] -else: - _Base = dict - class ResultsCallback(Protocol): def __call__(self, server: str | None = None) -> Any: ... -class RunnerResults(_Base): +class RunnerResults(dict[str, dict[str, 'ResultData']]): # noqa: FURB189 """ Wraps the results of parsed module_runner output. diff --git a/src/suitable/types.py b/src/suitable/types.py index 19a68e0..12493b5 100644 --- a/src/suitable/types.py +++ b/src/suitable/types.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Literal, Union, TYPE_CHECKING +from collections.abc import Iterable +from typing import Any, Literal, Union, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -14,6 +15,6 @@ 'debug' ] # TODO: Switch to a TypedDict? -HostVariables: TypeAlias = Dict[str, Incomplete] -Hosts: TypeAlias = Union[str, Iterable[str], Dict[str, HostVariables]] +HostVariables: TypeAlias = dict[str, Incomplete] +Hosts: TypeAlias = Union[str, Iterable[str], dict[str, HostVariables]] ResultData: TypeAlias = Any diff --git a/tests/test_api.py b/tests/test_api.py index 11ff923..f4370f0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,6 +7,8 @@ import pytest +from ansible import __version__ # type: ignore +from packaging.version import Version from suitable.api import Api, list_ansible_modules from suitable.errors import ModuleError, UnreachableError from suitable.mitogen import Api as MitogenApi @@ -17,6 +19,9 @@ from tests.conftest import Container +ANSIBLE12 = Version(__version__) >= Version('2.19') + + def test_auto_localhost() -> None: host = Api('localhost') assert host.inventory['localhost']['ansible_connection'] == 'local' @@ -40,7 +45,7 @@ def test_sudo() -> None: try: assert host.command('whoami').stdout() == 'root' except ModuleError as e: - assert 'password' in e.result['module_stderr'] + assert 'password' in e.result['msg' if ANSIBLE12 else 'module_stderr'] def test_module_args() -> None: @@ -272,6 +277,10 @@ def test_escaping(tempdir: str) -> None: ) +@pytest.mark.skipif( + ANSIBLE12, + reason='Ansible 12 support is experimental, this is currently broken' +) def test_extra_vars(tempdir: str) -> None: api = Api('localhost', extra_vars={'path': tempdir}) api.file(path='{{ path }}/foo.txt', state='touch') @@ -305,6 +314,10 @@ def test_mitogen_integration() -> None: pass +@pytest.mark.skipif( + ANSIBLE12, + reason='Ansible 12 support is experimental, this is currently broken' +) def test_list_args() -> None: api = Api('localhost') @@ -320,6 +333,10 @@ def test_dict_args(tempdir: str) -> None: api.set_stats(data={'foo': 'bar'}) +@pytest.mark.skipif( + ANSIBLE12, + reason='Ansible 12 support is experimental, this is currently broken' +) def test_assert_alias() -> None: api = Api('localhost') api.assert_(that=[