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; andconnectionexposes the established transport after opening succeeds.
The operation facets describe the methods required by each execution mode:
SyncCapabilityConnectionPluginadds synchronousopenandclosemethods with the existing connection plugin signatures.AsyncCapabilityConnectionPluginadds native coroutineaopenandaclosemethods. Async-only plugins do not need unsupportedopenorclosestubs.DualCapabilityConnectionPlugincombines 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
connectiononly after opening has succeeded.If
aopenfails or is cancelled after acquiring a partial resource, close that resource before propagating the exception.Make
acloseidempotent, 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()