Writing capability-aware connection plugins

Capability-aware connection plugins tell Nornir whether they can be used from synchronous tasks, asyncio tasks, or both. They are structural protocols: a plugin does not inherit from a Nornir class, but its methods and annotations should match the appropriate protocol from nornir.core.plugins.connections.

Choose a protocol facet

Every capability-aware plugin implements the common CapabilityConnectionPlugin contract:

  • it can be constructed without arguments and without performing device I/O;

  • get_capabilities() returns its supported execution modes; and

  • connection exposes the established transport after opening succeeds.

The operation facets describe the methods required by each execution mode:

  • SyncCapabilityConnectionPlugin adds synchronous open and close methods with the existing connection plugin signatures.

  • AsyncCapabilityConnectionPlugin adds native coroutine aopen and aclose methods. Async-only plugins do not need unsupported open or close stubs.

  • DualCapabilityConnectionPlugin combines both operation facets. The plugin opens in whichever mode Nornir calls, and the connection stays bound to that mode until it is closed. Each mode may create its own transport object.

Use the facet that represents the complete declaration when type-checking your class. The base protocol alone describes capability reporting and connection access; it does not make an operation available.

Declare capabilities

get_capabilities must be a synchronous, no-I/O operation whose result remains stable for the lifetime of the plugin instance. Its exact return type is frozenset[ConnectionCapability], where ConnectionCapability is the literal type Literal["sync", "asyncio"]. These are the only valid declarations:

frozenset({"sync"})
frozenset({"asyncio"})
frozenset({"sync", "asyncio"})

Nornir validates the complete declaration before opening a connection. Every declared operation must be present: synchronous operations must be regular callables, and asyncio operations must be native coroutine functions. An empty set, another collection type, an unknown value, a reporting error, or a missing declared operation raises ConnectionPluginContractError before connection I/O. Pre-open validation does not access connection. Extra methods do not enable an undeclared mode.

Register the plugin once

Register a capability-aware plugin once, regardless of how many modes it declares. For programmatic registration, use the capability-aware registry:

from nornir.core.plugins.connections import CapabilityConnectionPluginRegister

CapabilityConnectionPluginRegister.register("my-transport", MyTransport)

To make an installed plugin discoverable by nornir.InitNornir(), publish it in the execution-neutral nornir.plugins.capability_connections entry-point group:

[project.entry-points."nornir.plugins.capability_connections"]
my-transport = "my_transport.connection:MyTransport"

Direct construction of Nornir does not perform automatic discovery, so prepare the registry explicitly in that case.

The existing ConnectionPluginRegister and nornir.plugins.connections entry-point group remain the compatibility path for legacy connection plugins. Legacy registrations are treated as sync-only, even if their classes happen to provide async methods.

Within either registry, registering the same class under the same name again is idempotent; registering a different class under that name raises PluginAlreadyRegistered. Do not register one name in both registries. Cross-registry duplicates are ambiguous, even when both registrations refer to the same class, and connection get/open operations raise ConnectionPluginAmbiguousError. Cleanup still uses the cached instance’s recorded ownership, so it can run after deregistration or a later registry conflict.

Understand host caching and mode binding

There is one logical connection per host and name, bound to the execution mode that opened it. Use get_connection() from synchronous tasks and await aget_connection() from asyncio tasks. Requesting an undeclared mode raises a named sync/async mismatch error, including on a cache hit, without discarding the cached connection.

The public Host.connections cache retains its existing type, dict[str, ConnectionPlugin]. It contains legacy, sync-capable, and dual-capability instances. Async-only instances live in private typed host storage and intentionally do not appear in Host.connections. Do not inspect or mutate Nornir’s private connection caches or capability metadata.

A dual-capability plugin is stored once and bound to the mode that opened it. After open_connection or a synchronous get_connection, the connection is in sync mode: aget_connection raises ConnectionModeMismatchError and nothing is reopened. After aopen_connection or aget_connection, the connection is in asyncio mode: get_connection and the synchronous close_connection raise the same error, because the synchronous path cannot await aclose. The error names the mode the connection is in and the close method that releases it. Once closed, the name can be opened in the other mode.

Concurrent aget_connection calls for a name that is still opening, for example from subtasks started with asyncio.gather, wait for that open and share the connection it establishes; if the open fails, every waiter receives the failure. An explicit aopen_connection or a synchronous get_connection for a name that is opening raises ConnectionAlreadyOpen instead.

Nornir records the mode and exposes it through connection_mode(), so a plugin keeps no mode state of its own: open and close belong to sync mode, aopen and aclose to asyncio mode, and Nornir only ever calls the pair that matches the bound mode. A helper that a plugin adds beyond the contract can consult host.connection_mode(name) when it needs the current mode.

Implement safe async lifecycle methods

aopen and aclose must be native async def methods and must not block the event loop. Keep these resource ownership rules inside the plugin:

  • Publish connection only after opening has succeeded.

  • If aopen fails or is cancelled after acquiring a partial resource, close that resource before propagating the exception.

  • Make aclose idempotent, including before opening and after a failed or cancelled opening attempt.

  • Clear the plugin’s transport state while closing so repeated cleanup remains safe.

Nornir publishes a plugin into a host cache only after opening succeeds. A failed or cancelled aopen is not cached, but releasing resources acquired inside aopen remains the plugin’s responsibility.

Await aclose_connection() or aclose_connections() when async-capable connections may be present. Asynchronous cleanup closes a connection bound to sync mode through its synchronous close and a connection bound to asyncio mode through aclose, so one call cleans up a mixed host. That synchronous close runs inline on the event loop and blocks it until it returns, like a synchronous subtask. If an async close fails or is cancelled, Nornir retains that cached connection and its metadata so cleanup can be retried. Bulk cleanup, synchronous or asynchronous, attempts the remaining connections after an error, logs each later error, and then raises the first one; the synchronous close_connections() therefore closes every sync-bound connection before raising the mode error for an asyncio-bound one. Cancellation of asynchronous cleanup propagates immediately. Retry failed Nornir-level cleanup with on_failed=True, or invoke host cleanup directly, because the default Nornir selection excludes failed hosts.

Callers must serialize get, open, close, and connection use for a name while that name is closing. Concurrent cleanup and use of the same connection are not supported. Await closing before reusing or reopening the name.

Async-only reference plugin

This tested asyncio echo plugin demonstrates a stable declaration, native async operations, serialized transport use, cleanup after partial opening, and idempotent closing:

 1from __future__ import annotations
 2
 3import asyncio
 4from contextlib import suppress
 5from typing import TYPE_CHECKING, Any
 6
 7if TYPE_CHECKING:
 8    from nornir.core.configuration import Config
 9    from nornir.core.plugins.connections import ConnectionCapability
10
11
12class AsyncEcho:
13    """Async-only byte echo connection used by integration tests and documentation."""
14
15    def __init__(self) -> None:
16        self._reader: asyncio.StreamReader | None = None
17        self._writer: asyncio.StreamWriter | None = None
18        self._send_lock = asyncio.Lock()
19
20    def get_capabilities(self) -> frozenset[ConnectionCapability]:
21        return frozenset({"asyncio"})
22
23    @property
24    def connection(self) -> AsyncEcho:
25        if self._reader is None or self._writer is None or self._writer.is_closing():
26            raise RuntimeError("AsyncEcho is not open")
27        return self
28
29    async def aopen(
30        self,
31        hostname: str | None,
32        username: str | None,
33        password: str | None,
34        port: int | None,
35        platform: str | None,
36        extras: dict[str, Any] | None = None,
37        configuration: Config | None = None,
38    ) -> None:
39        if hostname is None or port is None:
40            raise ValueError("AsyncEcho requires hostname and port")
41        if self._writer is not None and not self._writer.is_closing():
42            raise RuntimeError("AsyncEcho is already open")
43
44        writer: asyncio.StreamWriter | None = None
45        try:
46            reader, writer = await asyncio.open_connection(hostname, port)
47            self._reader = reader
48            self._writer = writer
49            await writer.drain()
50        except BaseException:
51            self._reader = None
52            if self._writer is writer:
53                self._writer = None
54            if writer is not None:
55                writer.close()
56                with suppress(ConnectionError):
57                    await writer.wait_closed()
58            raise
59
60    async def send(self, payload: bytes) -> bytes:
61        async with self._send_lock:
62            reader = self._reader
63            writer = self._writer
64            if reader is None or writer is None or writer.is_closing():
65                raise RuntimeError("AsyncEcho is not open")
66            writer.write(payload)
67            await writer.drain()
68            return await reader.readexactly(len(payload))
69
70    async def aclose(self) -> None:
71        writer = self._writer
72        self._reader = None
73        self._writer = None
74        if writer is None:
75            return
76        writer.close()
77        with suppress(ConnectionError):
78            await writer.wait_closed()