Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-tox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
~~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,7 +76,7 @@ include = [
]
line-length = 80
indent-width = 4
target-version = "py38"
target-version = "py39"

[tool.ruff.lint]
select = [
Expand Down Expand Up @@ -186,20 +186,19 @@ 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
report

[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311,ruff,bandit,mypy
Expand All @@ -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.*
Expand All @@ -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}

Expand Down Expand Up @@ -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
Expand Down
9 changes: 1 addition & 8 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,7 +34,7 @@ package_dir =
= src
packages =
suitable
python_requires = >= 3.8
python_requires = >= 3.9
platforms = any
install_requires =
ansible>=6
Expand Down Expand Up @@ -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
14 changes: 12 additions & 2 deletions src/suitable/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 = {}

Expand Down
7 changes: 1 addition & 6 deletions src/suitable/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 23 additions & 4 deletions src/suitable/module_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}]
}
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 1 addition & 6 deletions src/suitable/runner_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions src/suitable/types.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
19 changes: 18 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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:
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')

Expand All @@ -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=[
Expand Down