A thin Python wrapper over Ansible's executor. Lets you run Ansible modules from Python with structured results, batch execution, and a host-object-first API — without going through pytest-ansible or ansible-runner.
Status: beta (0.1.0). Built from a working internal implementation; API is stable in shape but may shift in details before 1.0.
Ansible has a great executor and a huge module ecosystem, but the existing Python access paths are awkward for in-test or in-tool use:
pytest-ansible |
ansible-runner |
ansible-host |
|
|---|---|---|---|
| Use case | Pytest fixtures | AWX-style managed jobs | In-process programmatic |
| API surface | ~20% of Ansible's runtime | Subprocess + event stream | Typed Python objects |
| Forking, custom callbacks | Limited | Yes (in subprocess) | Yes (native) |
| Per-call overhead | Low | High (subprocess + JSON parsing) | Low (in-process) |
| Returns | Strings | Event stream | Structured Python dicts |
ansible-host sits where the other two don't: in-process, low-overhead, structured-result execution that you can drop into any Python codebase that wants Ansible underneath.
pip install ansible-hostRequires Python 3.10+ and ansible-core>=2.16,<2.22. Like Ansible itself, the library runs on POSIX systems (Linux, macOS, WSL) — it is not supported on native Windows.
The recommended dev workflow uses uv — it manages the virtualenv directly, sidestepping the python3-venv split on Ubuntu and installing dependencies an order of magnitude faster than pip.
# One-time: install uv (https://docs.astral.sh/uv/getting-started/installation/)
curl -LsSf https://astral.sh/uv/install.sh | sh
# In the repo
uv venv # creates .venv with the default Python
uv pip install -e ".[dev]" # editable install + dev extras
uv run pytest # run the test suite
uv run ruff check src tests # lintTo target a specific Python or ansible-core version (matches the CI matrix):
uv venv --python 3.12
uv pip install -e ".[dev]"
uv pip install "ansible-core==2.19.*"
uv run pytestpip and a manually-managed venv still work — uv is just the convenience.
The suite has two tiers:
- Tier-1 (default) — runs modules in-process over Ansible's
localconnection. No SSH or external host required; this is whatuv run pytestand CI's matrix run. - Tier-2 (
sshmarker) — exercises Ansible's real SSH transport (connection options, remote module exec,become, multi-host fanout) against a throwaway containerized sshd intests/ssh/. These tests auto-skip unless an SSH target is configured, so the default run stays green without any SSH setup.
Run the SSH tier locally (requires Docker):
# Start a throwaway SSH target with an ephemeral key authorized on it
ssh-keygen -t ed25519 -N '' -f /tmp/ah_ssh_key
export AH_SSH_PUBLIC_KEY="$(cat /tmp/ah_ssh_key.pub)"
docker compose -f tests/ssh/docker-compose.yml up -d --build
# Point the ssh-marked tests at it
AH_SSH_HOST=127.0.0.1 AH_SSH_PORT=2222 AH_SSH_USER=ansible AH_SSH_KEY=/tmp/ah_ssh_key \
uv run pytest -m ssh
# Tear down
docker compose -f tests/ssh/docker-compose.yml down -vCI runs Tier-1 across the full Python × ansible-core matrix, plus a dedicated test-ssh job that builds the container and runs the Tier-2 tests.
AnsibleLocalhost runs modules in-process on the current machine via Ansible's local connection plugin. No inventory file, no SSH, no setup.
from ansible_host import AnsibleLocalhost
host = AnsibleLocalhost()
result = host.ping()
assert result["ping"] == "pong"
result = host.command("uname -a")
print(result["stdout"])Drop an inventory file alongside your script:
# inventory.ini
[switches]
sw-01 ansible_host=10.0.0.1 ansible_user=adminfrom ansible_host import AnsibleHost
host = AnsibleHost(inventory="inventory.ini", pattern="sw-01")
result = host.shell("show version")
print(result["stdout"])from ansible_host import AnsibleHosts
hosts = AnsibleHosts(inventory="inventory.ini", pattern="switches")
results = hosts.ping() # parallelism via forks=
for hostname, r in results.items():
print(hostname, r["ping"])
# Container API: index, iterate, len
print(len(hosts), hosts.hostnames)
first = hosts[0] # -> AnsibleHost
by_name = hosts["sw-01"] # -> AnsibleHost
for h in hosts:
print(h.hostname)Any Ansible module is callable as a method via __getattr__ (host.<module_name>(...)):
host.copy(src="/etc/hosts", dest="/tmp/hosts.bak")
host.command("rm /tmp/maybe-missing", task_directives={"ignore_errors": True})
host.shell("echo $TOKEN", task_directives={"no_log": True})Common task_directives: ignore_errors, no_log, when, failed_when, changed_when, become.
with host:
host.shell("uptime")
host.shell("df -h")
host.shell("free -m")
results = host.resultswith host: is lexically scoped. If you need to assemble a batch across multiple functions, use the explicit form — same machinery, no scope limit:
host.load_module("ansible.builtin.command", args=["uptime"])
host.load_module("ansible.builtin.command", args=["df -h"])
results = host.run_loaded_modules()| single task | batch (with block) |
|
|---|---|---|
| single host | dict |
list[dict] |
| multi host | {hostname: dict} |
{hostname: list[dict]} |
Inspect inventory variables resolved by Ansible — host vars, group vars, and runtime extra vars — without running a module:
host = AnsibleHost(inventory="inventory.ini", pattern="sw-01")
# Variables defined directly on the host (raw — Jinja2 templates not rendered):
host.get_host_var("ansible_user") # -> "admin"
host.host_vars # -> dict of host-scoped vars
# Fully-resolved view a host can see (host + group + extra vars), with
# Jinja2 templates rendered:
host.get_visible_var("some_group_var") # group var visible to the host
host.get_visible_var("missing", "default") # default when absent
host.visible_vars # -> dict of all resolved vars
# Runtime overrides (highest precedence):
host.update_extra_vars({"feature_flag": True})
host.extra_vars # -> current extra varsAnsibleHosts exposes the host-keyed form: hosts.get_host_var("sw-01", "ansible_user")
and hosts.get_visible_var("sw-01", "some_var").
A failing module raises AnsibleModuleFailed:
from ansible_host import AnsibleModuleFailed
try:
host.command("false")
except AnsibleModuleFailed as e:
print("module failed:", e)
# Or suppress and inspect the result:
result = host.command("false", task_directives={"ignore_errors": True})
assert result["failed"] is TrueSee the test suite for runnable examples:
tests/test_local_integration.py— ping, command, shell, batch mode, dynamic dispatch,no_log,forks, multi-host fanout, per-host failure aggregation, and the container protocol.tests/test_host_vars.py— reading host/group/extra variables and precedence.tests/test_execution_edge_cases.py— error contracts, failure aggregation, unreachable hosts, andgather_facts.
This library uses Ansible's internal Python API (TaskQueueManager, InventoryManager, VariableManager, Play, DataLoader). Those APIs are not officially stable across ansible-core releases — expect occasional updates when ansible-core introduces breaking internal changes. The current support range is declared in pyproject.toml and in the matrix CI.
ansible-host is designed for sequential use at the instance level. For parallelism within a single call, use Ansible's native forking via the forks= option. Running multiple AnsibleHost / AnsibleHosts instances concurrently in different threads will race on ansible.context's process-global state.
A real-world use of this pattern lives in sonic-mgmt's tbng branch — ansible-host is the cleaned-up, packaged version of that code.
Apache License 2.0. See LICENSE.