chore: Python 3.13, mypy --strict in CI, eight bug fixes, docs

Squashes three changes:

## chore: support Python 3.13 and make mypy --strict pass in CI

Add 3.13 to the CI matrix and classifiers, run mypy in CI, fix the
14 strict errors it reported, and correct the README's minimum Python.

## fix: eight bugs found in a full review, each with a regression test

- channel: run_on_transport lost output sshd sent after the exit status;
  it now reads stdout and stderr to EOF before returning.
- ssh_server: a failed way back (restore or reload) raised out of
  harden_ssh_server after the host was touched; it is now reported in
  the result, with rolled_back False.
- host_status: a newer RPM kernel (RHEL, Fedora) was never seen, because
  its release, not its arch, followed the last "-".
- package_updates: an apt line wrapped at a blank lost the blank, and
  with it the package.
- kernel: an empty payload (gzip or base64 missing) read as a report.
- listening: "[fe80::1]%eth0:546", as ss prints it, kept its brackets.
- ping_sweep: a reply whose "success" was not a dict aborted the sweep.
- lag: a member of unknown speed (-1) lowered the LAG's speed.

## docs: bring the README and docstrings in line with the code

The firewall worked example showed the runtime stubs the README forbids;
the class table missed PhoneDriver and MediaDriver; ConfigLifecycleMixin
is mixed in by drivers, not by a role base; services, mac_acl and
packages named a class, a key and an exception that do not exist.
This commit is contained in:
christianmanivong committed 2026-10-13 01:22:22 +02:00
1 parent e58b65a3db
commit dd8977750d
25 files changed
+163 -52

No files matched your search

+8 -8
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import re
from typing import Awaitable, Callable, Dict, Optional
from typing import Any, Awaitable, Callable, Dict, Optional
# ── UCD-MIB OIDs ──────────────────────────────────────────────────────────────
OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds)
@@ -42,7 +42,7 @@ def ticks_to_seconds(raw: str | None) -> int | None:
def build_if_metrics(
metrics: dict,
metrics: Dict[str, Any],
descr: Dict[str, str],
speed: Dict[str, str],
in_oct: Dict[str, str],
@@ -50,16 +50,16 @@ def build_if_metrics(
in_err: Dict[str, str],
out_err: Dict[str, str],
*,
if_skip: re.Pattern | None = None,
if_skip: re.Pattern[str] | None = None,
tx_err_is_drop: bool = False,
) -> None:
if if_skip is None:
if_skip = IF_SKIP_DEFAULT
interfaces: dict = {}
interfaces: Dict[str, Any] = {}
for idx, name in descr.items():
if if_skip.match(name):
continue
iface: dict = {"name": name}
iface: Dict[str, Any] = {"name": name}
try:
iface["rx_bytes"] = int(in_oct.get(idx, 0) or 0)
iface["tx_bytes"] = int(out_oct.get(idx, 0) or 0)
@@ -84,8 +84,8 @@ async def collect_ucd_metrics(
snmp_walk: SnmpWalkFn,
*,
tx_err_is_drop: bool = False,
if_skip: re.Pattern | None = None,
) -> dict:
if_skip: re.Pattern[str] | None = None,
) -> Dict[str, Any]:
"""Collect UCD-MIB system metrics + IF-MIB interface counters in parallel."""
(
uptime_raw, cpu_idle_raw,
@@ -116,7 +116,7 @@ async def collect_ucd_metrics(
),
)
metrics: dict = {}
metrics: Dict[str, Any] = {}
secs = ticks_to_seconds(uptime_raw)
if secs is not None:
+1 -1
View File
@@ -49,7 +49,7 @@ class PortSpec(NamedTuple):
mandatory: bool = False
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, CommandChannelMixin, NetworkDriver):
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, CommandChannelMixin, NetworkDriver): # type: ignore[misc]
"""Common base for all netOrk device-type drivers.
Sits between napalm.base.NetworkDriver and the type-specific abstract
+12 -4
View File
@@ -28,7 +28,7 @@ from __future__ import annotations
import socket
import time
from typing import Any, NamedTuple, Optional, Protocol, TYPE_CHECKING
from typing import Any, NamedTuple, Optional, Protocol, Tuple, TYPE_CHECKING, cast
#: How much of a stream's stderr is kept. Enough to tell "permission denied"
#: from "no such command"; a stream that writes megabytes there keeps its tail.
@@ -111,7 +111,7 @@ class ParamikoExecStream:
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes:
self._channel.settimeout(timeout)
try:
return self._channel.recv(max_bytes)
return cast(bytes, self._channel.recv(max_bytes))
except socket.timeout:
raise TimeoutError(f"no data within {timeout}s") from None
finally:
@@ -130,7 +130,7 @@ class ParamikoExecStream:
def exit_status(self) -> Optional[int]:
if not self._channel.exit_status_ready():
return None
return self._channel.recv_exit_status()
return cast(int, self._channel.recv_exit_status())
@property
def stderr(self) -> str:
@@ -138,7 +138,7 @@ class ParamikoExecStream:
return self._stderr.decode("utf-8", "replace")
def _collect(channel: Any, command: str, timeout: float) -> tuple:
def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
out, err = b"", b""
deadline = time.monotonic() + timeout
while True:
@@ -147,6 +147,14 @@ def _collect(channel: Any, command: str, timeout: float) -> tuple:
elif channel.recv_stderr_ready():
err += channel.recv_stderr(_READ)
elif channel.exit_status_ready():
# sshd may send the exit status before the last output: read both to EOF.
try:
while chunk := channel.recv(_READ):
out += chunk
while chunk := channel.recv_stderr(_READ):
err += chunk
except socket.timeout:
raise TimeoutError(f"{command!r} did not finish within {timeout}s") from None
return out, err
elif time.monotonic() > deadline:
raise TimeoutError(f"{command!r} did not finish within {timeout}s")
+6 -6
View File
@@ -29,7 +29,7 @@ stays truthful.
from __future__ import annotations
import shlex
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, cast
from napalm_device_types.channel import ByteStream, CommandResult
from napalm_device_types.models import ContainerEngineDict
@@ -62,7 +62,7 @@ class ContainerEngineConnection:
def open_api(self) -> ByteStream:
"""A stream to the engine's API: HTTP/1.1 over ``system dial-stdio``."""
return self._driver.open_stream(self._command(["system", "dial-stdio"]))
return cast(ByteStream, self._driver.open_stream(self._command(["system", "dial-stdio"])))
def run_cli(
self,
@@ -77,9 +77,9 @@ class ContainerEngineConnection:
*privileged* runs it as root, the way the driver gains root for any
command: for a call the engine refused to the login user.
"""
return self._driver.run_command(
return cast(CommandResult, self._driver.run_command(
self._command(args), privileged=privileged, timeout=timeout, stdin=stdin
)
))
def stream_cli(
self, args: Sequence[str], *, merge_stderr: bool = False, privileged: bool = False
@@ -90,9 +90,9 @@ class ContainerEngineConnection:
progress there (``compose up``); *privileged* as for :meth:`run_cli`.
"""
command = self._command(args)
return self._driver.open_stream(
return cast(ByteStream, self._driver.open_stream(
f"{command} 2>&1" if merge_stderr else command, privileged=privileged
)
))
class ContainerEngineMixin:
+18 -5
View File
@@ -157,18 +157,31 @@ def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
running one; otherwise None.
The flavour is what follows the last ``-`` (``generic``, ``amd64``, ``pve``,
``v8``); a kernel of another flavour is never a reason to reboot.
``v8``), or on an RPM kernel (``5.14.0-427.13.1.el9_4.x86_64``) the arch
after the release; a kernel of another flavour is never a reason to reboot.
"""
flavour = running.rsplit("-", 1)[-1]
same = [k for k in installed if k.rsplit("-", 1)[-1] == flavour]
version, flavour = _kernel_flavour(running)
same = [k for k in installed if _kernel_flavour(k)[1] == flavour]
if not same:
return None
newest = max(same, key=lambda k: _version_key(k.rsplit("-", 1)[0]))
if _version_key(newest.rsplit("-", 1)[0]) > _version_key(running.rsplit("-", 1)[0]):
newest = max(same, key=lambda k: _version_key(_kernel_flavour(k)[0]))
if _version_key(_kernel_flavour(newest)[0]) > _version_key(version):
return newest
return None
def _kernel_flavour(release: str) -> Tuple[str, str]:
"""``6.8.0-87-generic`` -> ``("6.8.0-87", "generic")``,
``5.14.0-427.13.1.el9_4.x86_64`` -> ``("5.14.0-427.13.1.el9_4", "x86_64")``."""
base, sep, tail = release.rpartition("-")
if not sep:
return release, ""
if tail[:1].isdigit() and "." in tail:
rel, _, arch = tail.rpartition(".")
return f"{base}-{rel}", arch
return base, tail
def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]:
"""FreeBSD's answer from freebsd-version, or None on any other system."""
installed = (sections.get("freebsd-kernel") or [""])[0]
+4 -1
View File
@@ -70,9 +70,12 @@ def _report(output: str) -> str:
raise ValueError("no kernel facts in the output") from None
try:
packed = base64.b64decode("".join(lines[start + 1 : end]), validate=True)
return gzip.decompress(packed).decode()
report = gzip.decompress(packed).decode()
except (binascii.Error, OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
raise ValueError(f"the kernel facts could not be decoded: {exc}") from exc
if "[release]" not in report.splitlines():
raise ValueError("the kernel facts are empty: gzip or base64 is missing on the host")
return report
def _sections(report: str) -> Dict[str, List[str]]:
+1 -1
View File
@@ -46,7 +46,7 @@ def add_lag_interfaces(
"is_enabled": any(interfaces[m].get("is_enabled") for m in members),
"description": f"LAG ({', '.join(members)})",
"last_flapped": -1.0,
"speed": sum(float(interfaces[m].get("speed") or 0) for m in members),
"speed": sum(max(float(interfaces[m].get("speed") or 0), 0.0) for m in members),
"mtu": -1,
"mac_address": "",
"lag_members": members,
+5 -3
View File
@@ -110,13 +110,15 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]:
def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
"""``[fe80::1%eth0]:546`` -> ``("fe80::1", "eth0", 546)``; None if no port."""
"""``[fe80::1]%eth0:546`` (or ``[fe80::1%eth0]:546``) -> ``("fe80::1", "eth0", 546)``;
None if no port."""
host, sep, port = local.rpartition(":")
if not sep or not port.isdigit():
return None
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
address, _, zone = host.partition("%")
zone = zone.rstrip("]")
if address.startswith("["):
address = address[1:].rstrip("]")
return address or "*", zone or None, int(port)
+1 -1
View File
@@ -24,7 +24,7 @@ class MacAclMixin:
Keys are SSID names. Each value contains:
* ssid (string) - SSID name (repeated for convenience)
* name (string) - SSID name (repeated for convenience)
* policy (string) - ACL mode:
* ``"allow"`` – whitelist: only listed MACs may associate
+1 -1
View File
@@ -43,7 +43,7 @@ def _joined_lines(output: str) -> List[str]:
lines: List[str] = []
for line in output.splitlines():
if line.startswith(" ") and lines:
lines[-1] += line.strip()
lines[-1] += line[1:].rstrip()
else:
lines.append(line)
return lines
-4
View File
@@ -68,7 +68,6 @@ class PackageManagementMixin:
:param name: Package name as known to the package manager.
:param version: Exact version to install. An empty string (default)
installs the latest available version.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package name is unknown or the version does
not exist in any configured feed.
:raises RuntimeError: If the installation fails on the device side
@@ -88,7 +87,6 @@ class PackageManagementMixin:
The method blocks until the removal is complete.
:param name: Package name to remove.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package is not currently installed.
:raises RuntimeError: If the removal fails on the device side
(e.g. other packages depend on it).
@@ -105,7 +103,6 @@ class PackageManagementMixin:
dictionary. The structure is package-specific.
:param name: Package name.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package is not installed.
Example::
@@ -140,7 +137,6 @@ class PackageManagementMixin:
:param name: Package name.
:param config: New configuration as a nested dictionary.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package is not installed or the configuration
contains invalid values.
:raises RuntimeError: If the device rejects the configuration.
+2
View File
@@ -150,6 +150,8 @@ class PingSweepMixin:
return {"ip": destination, "alive": False, "rtt_ms": None, "error": error}
success = reply.get("success") or {}
if not isinstance(success, dict):
return {"ip": destination, "alive": False, "rtt_ms": None, "error": "malformed ping reply"}
probes_sent = _as_int(success.get("probes_sent"))
packet_loss = _as_int(success.get("packet_loss"), default=probes_sent)
alive = bool(success.get("results")) or probes_sent > packet_loss
+1 -1
View File
@@ -2,7 +2,7 @@
"""Init-system services: listing them and starting/stopping them.
Deliberately *not* the same thing as a NAS's exported shares, which live on
``StorageServiceCapability`` as ``get_storage_services``. Those two used to
``StorageDriver`` as ``get_storage_services``. Those two used to
share the name ``get_services`` with incompatible return types, which is why
a QNAP -- storage and OS at once -- could not satisfy both.
+8 -3
View File
@@ -418,9 +418,14 @@ class OpenSshServerMixin:
def back(message: str, reload: bool) -> SshHardeningResult:
say(message)
say("Putting the previous configuration back")
restore()
if reload:
self._reload_ssh_server()
try:
restore()
if reload:
self._reload_ssh_server()
except Exception as exc: # noqa: BLE001 - report it, the caller must look
result.message = f"{message}; putting the previous configuration back failed: {exc}"
say(result.message)
return result
result.rolled_back = True
result.message = message
return result