diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7530c79 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,15 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + driver: + uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main + with: + standard-ref: main + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 372e184..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: CI - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install package with dev extras - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" - - - name: Type-check - run: | - python -m mypy napalm_device_types - - - name: Run unit tests - run: | - python -m pytest -q --tb=short - - - name: Build wheel and sdist - run: | - python -m pip install build - python -m build - - - name: Upload dist artifacts - # v4 refuses to run on Gitea ("not currently supported on GHES"). - uses: actions/upload-artifact@v3 - with: - name: dist-${{ matrix.python-version }} - path: dist/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e590d85 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to napalm-device-types. The format follows +[Keep a Changelog](https://keepachangelog.com/), versions follow +[Semantic Versioning](https://semver.org/). Versions up to 4.21.0 were not +recorded here; their history is the git log. + +## [Unreleased] + +## [4.21.1] - 2026-10-22 + +The first release on PyPI since 0.1.0. + +### Changed +- The package follows the NAPALM driver standard + (https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, strict + mypy, the tests on Python 3.10–3.13 and at the lowest declared versions, + bandit, pip-audit and a build check, and a release tag publishes to PyPI. +- Type annotations use builtin generics (`list[str]`, `X | None`) instead of + `typing.List` and `Optional`. They mean the same to a type checker. +- The project links point at git.netork.io instead of GitHub. diff --git a/README.md b/README.md index e7c6e8f..b675bbf 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ Instead of inheriting directly from `napalm.base.NetworkDriver`, a driver can in ```python # without napalm-device-types -class OpenWrtDriver(NetworkDriver): - ... +class OpenWrtDriver(NetworkDriver): ... + # with napalm-device-types from napalm_device_types import AccessPointDriver -class OpenWrtDriver(AccessPointDriver): - ... + +class OpenWrtDriver(AccessPointDriver): ... ``` ## Why? @@ -29,8 +29,9 @@ OpenMediaVault box is a NAS built on Debian. So a driver inherits **one role bas role its device fills**, and **the order it lists them in is the ranking**: ```python -class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver): - ... # primary_role_of(...) == "storage" +class QnapQtsDriver( + StorageDriver, HypervisorDriver, LinuxDriver +): ... # primary_role_of(...) == "storage" ``` `roles_of(cls)`, `role_keys_of(cls)` and `primary_role_of(cls)` read that back. Nothing @@ -54,10 +55,12 @@ host OS's load; it has no `HealthMetricsMixin` (SNMP) and no command channel. ```python class StorageDriver(DeviceTypeDriver): """Contract, not code.""" + ROLE: str = "storage" TYPE_LABEL: str = "Storage" - if TYPE_CHECKING: # nothing exists at runtime + if TYPE_CHECKING: # nothing exists at runtime + def get_disks(self) -> List[PhysicalDiskDict]: ... ``` @@ -312,20 +315,27 @@ generic data shapes belong here. Only the actual device communication belongs in **Worked example — firewall rule diff/apply** (`FirewallRuleMixin`, in `FirewallDriver`): ```python -class FirewallRuleMixin: # mixed into FirewallDriver +class FirewallRuleMixin: # mixed into FirewallDriver # Declared only — every driver implements its own device communication. if TYPE_CHECKING: + def get_firewall_rules(self) -> List[FirewallRuleDict]: ... - def apply_firewall_rule(self, rule: FirewallRuleDict, *, uuid: Optional[str] = None) -> Dict[str, Any]: ... + def apply_firewall_rule( + self, rule: FirewallRuleDict, *, uuid: Optional[str] = None + ) -> Dict[str, Any]: ... def commit_firewall_rules(self) -> Dict[str, Any]: ... # Concrete — the matching/comparison/orchestration algorithm is identical # for every firewall vendor, so it lives here once. - def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict: - ... # matches self.get_firewall_rules() against `desired` by description + def diff_firewall_rules( + self, desired: List[FirewallRuleDict] + ) -> ( + FirewallRuleDiffDict + ): ... # matches self.get_firewall_rules() against `desired` by description - def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]): - ... # computes the diff, calls apply_firewall_rule() per change, commits + def apply_firewall_ruleset( + self, desired: List[FirewallRuleDict] + ): ... # computes the diff, calls apply_firewall_rule() per change, commits ``` The same split applies to `DhcpServerMixin`: `get_dhcp_reservations`/ @@ -421,8 +431,8 @@ HTTP_FINGERPRINT = [ ```python from napalm_device_types import AccessPointDriver -class OpenWrtDriver(AccessPointDriver): +class OpenWrtDriver(AccessPointDriver): def get_wireless_clients(self): # return List[WirelessClientDict] ... @@ -437,8 +447,8 @@ class OpenWrtDriver(AccessPointDriver): ```python from napalm_device_types import SwitchDriver -class CiscoIOSDriver(SwitchDriver): +class CiscoIOSDriver(SwitchDriver): def get_spanning_tree(self): # return Dict[str, SpanningTreeDict] ... @@ -453,8 +463,8 @@ class CiscoIOSDriver(SwitchDriver): ```python from napalm_device_types import FirewallDriver -class PfSenseDriver(FirewallDriver): +class PfSenseDriver(FirewallDriver): def get_nat_translations(self): # return List[NATTranslationDict] ... @@ -474,14 +484,13 @@ class PfSenseDriver(FirewallDriver): ```python from napalm_device_types import HypervisorDriver -class ProxmoxDriver(HypervisorDriver): +class ProxmoxDriver(HypervisorDriver): def get_vms(self): # return List[VMDict] ... - def create_vm_snapshot(self, name, snapshot, description="", include_memory=False): - ... + def create_vm_snapshot(self, name, snapshot, description="", include_memory=False): ... def get_vm_cpu_types(self): # optional — return List[VMCpuTypeDict]: the CPU models a new VM may get @@ -505,8 +514,8 @@ unpacked; both are the same for every hypervisor. ```python from napalm_device_types import OSDriver -class LinuxDriver(OSDriver): +class LinuxDriver(OSDriver): def get_packages(self): # return List[PackageDict] ... @@ -533,8 +542,8 @@ class LinuxDriver(OSDriver): ```python from napalm_device_types import StorageDriver -class TrueNASDriver(StorageDriver): +class TrueNASDriver(StorageDriver): def get_disks(self): # return List[PhysicalDiskDict] ... @@ -560,7 +569,7 @@ from napalm_device_types.models import ( ## Development ```bash -git clone https://github.com/chrismanivong/napalm-device-types.git +git clone https://git.netork.io/NAPALM/napalm-device-types.git cd napalm-device-types pip install -e ".[dev]" ``` @@ -573,10 +582,15 @@ mypy napalm_device_types ## Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/router-driver`) -3. Implement your changes -4. Open a Pull Request +This package follows the +[NAPALM driver standard](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md): +test first, ruff and strict mypy, Conventional Commits, one pull request per change +against `main`. CI checks what a tool can check: + +```bash +pip install "napalm-standard @ git+https://git.netork.io/NAPALM/driver-standard.git" +napalm-standard check && napalm-standard ratchet +``` ## License diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index 3c8028a..5dbda76 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -64,8 +64,8 @@ Introspection -- :func:`~napalm_device_types.roles.roles_of`, :func:`~napalm_device_types.roles.primary_role_of`. """ -from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight from napalm_device_types.access_point import AccessPointDriver +from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight from napalm_device_types.channel import ( ByteStream, CommandChannelMixin, @@ -82,10 +82,10 @@ from napalm_device_types.container_engine import ( ContainerEngineUnavailable, ) from napalm_device_types.dhcp import DhcpServerMixin, normalize_cidr, normalize_mac +from napalm_device_types.dns_forward import DnsForwardMixin from napalm_device_types.firewall import FirewallDriver -from napalm_device_types.hypervisor import HypervisorDriver -from napalm_device_types.os import OSDriver from napalm_device_types.firewall_rules import FirewallRuleMixin +from napalm_device_types.firmware import FirmwareOfferMixin from napalm_device_types.fixes import ( EnsurePackageMixin, PackageProxyMixin, @@ -93,24 +93,18 @@ from napalm_device_types.fixes import ( run_fix_steps, ) from napalm_device_types.health_metrics import HealthMetricsMixin -from napalm_device_types.dns_forward import DnsForwardMixin -from napalm_device_types.firmware import FirmwareOfferMixin -from napalm_device_types.ospf import OspfMixin -from napalm_device_types.licenses import LicenseMixin -from napalm_device_types.host_reboot import HostRebootMixin -from napalm_device_types.interface_filter import InterfaceFilterMixin -from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts -from napalm_device_types.service_packages import ( - SERVICE_PACKAGES_COMMAND, - ServicePackagesMixin, - parse_service_packages, -) -from napalm_device_types.lag import add_lag_interfaces from napalm_device_types.host_firewall import ( HostFirewallMixin, HostFirewallUnavailable, parse_host_firewall, ) +from napalm_device_types.host_reboot import HostRebootMixin +from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status +from napalm_device_types.hypervisor import HypervisorDriver +from napalm_device_types.interface_filter import InterfaceFilterMixin +from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts +from napalm_device_types.lag import add_lag_interfaces +from napalm_device_types.licenses import LicenseMixin from napalm_device_types.listening import ( LISTENING_SOCKETS_COMMAND, ListeningSocketsMixin, @@ -125,67 +119,66 @@ from napalm_device_types.models import ( ConfigSyncDict, DhcpHaDict, DhcpHaPeerDict, + DnsForwardDict, EventLogEntryDict, FirmwareDict, - HardwareInventoryDict, - ManagementProtocolsDict, - PowerAction, - PowerStateDict, - HypervisorHostDict, - DnsForwardDict, FirmwareOfferDict, - OspfDict, - OspfInterfaceDict, - UpdateProgressDict, - LicenseDict, FixResultDict, FixStepDict, FixStepOutcomeDict, + HardwareInventoryDict, + HypervisorHostDict, + LicenseDict, + ManagementProtocolsDict, + OspfDict, + OspfInterfaceDict, + PowerAction, + PowerStateDict, SnmpAgentSettingsDict, SnmpHealthProfile, SnmpInterfacesSpec, SnmpMemorySpec, SnmpValueSpec, + UpdateProgressDict, ) from napalm_device_types.nat_vpn import NatVpnMixin -from napalm_device_types.packages import PackageManagementMixin -from napalm_device_types.phone import PhoneDriver -from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping -from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of -from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status +from napalm_device_types.os import OSDriver +from napalm_device_types.ospf import OspfMixin from napalm_device_types.package_updates import ( APT_UPGRADABLE_COMMAND, DNF_SECURITY_COMMAND, parse_apt_upgradable, parse_dnf_security, ) -from napalm_device_types.services import ServiceControlMixin -from napalm_device_types.terminal import strip_terminal_codes -from napalm_device_types.systemd import ( - SYSTEMD_SERVICES_COMMAND, - SystemdServicesMixin, - SystemdUnavailable, - parse_systemd_services, -) -from napalm_device_types.updates import UpdateMixin -from napalm_device_types.system_identity import ( - SYSTEM_IDENTITY_COMMAND, - SystemIdentityMixin, - is_placeholder_identity, - parse_system_identity, -) +from napalm_device_types.packages import PackageManagementMixin +from napalm_device_types.phone import PhoneDriver +from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping +from napalm_device_types.residential_gateway import ResidentialGatewayDriver +from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of from napalm_device_types.service_clusters import ( SERVICE_CLUSTERS_COMMAND, ServiceClustersMixin, parse_service_clusters, service_clusters_command, ) -from napalm_device_types.virtual_ips import ( - VIRTUAL_IPS_COMMAND, - VirtualIpsMixin, - parse_virtual_ips, +from napalm_device_types.service_packages import ( + SERVICE_PACKAGES_COMMAND, + ServicePackagesMixin, + parse_service_packages, +) +from napalm_device_types.services import ServiceControlMixin +from napalm_device_types.ssh_host_keys import ( + KNOWN_KEYS_ARG as SSH_HOST_KEYS_ARG, +) +from napalm_device_types.ssh_host_keys import ( + OBSERVER_ARG as SSH_HOST_KEY_OBSERVER_ARG, +) +from napalm_device_types.ssh_host_keys import ( + HostKeyCheck, + HostKeyObservation, + SshHostKey, + SshHostKeyMismatch, ) -from napalm_device_types.residential_gateway import ResidentialGatewayDriver from napalm_device_types.ssh_server import ( SSH_ALGORITHM_CLASSES, OpenSshServerMixin, @@ -193,16 +186,27 @@ from napalm_device_types.ssh_server import ( SshHardeningResult, plan_ssh_hardening, ) -from napalm_device_types.ssh_host_keys import ( - KNOWN_KEYS_ARG as SSH_HOST_KEYS_ARG, - OBSERVER_ARG as SSH_HOST_KEY_OBSERVER_ARG, - HostKeyCheck, - HostKeyObservation, - SshHostKey, - SshHostKeyMismatch, -) from napalm_device_types.storage import StorageDriver from napalm_device_types.switch import SwitchDriver +from napalm_device_types.system_identity import ( + SYSTEM_IDENTITY_COMMAND, + SystemIdentityMixin, + is_placeholder_identity, + parse_system_identity, +) +from napalm_device_types.systemd import ( + SYSTEMD_SERVICES_COMMAND, + SystemdServicesMixin, + SystemdUnavailable, + parse_systemd_services, +) +from napalm_device_types.terminal import strip_terminal_codes +from napalm_device_types.updates import UpdateMixin +from napalm_device_types.virtual_ips import ( + VIRTUAL_IPS_COMMAND, + VirtualIpsMixin, + parse_virtual_ips, +) __all__ = [ "HypervisorHostDict", diff --git a/napalm_device_types/_matching.py b/napalm_device_types/_matching.py index 8628053..839c16e 100644 --- a/napalm_device_types/_matching.py +++ b/napalm_device_types/_matching.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Keying live and desired entries for a diff, refusing what cannot be matched. A diff matches a desired entry to a live one by a key (a rule's description, a @@ -8,18 +7,19 @@ name a single entry, and guessing would overwrite one the caller never meant. from __future__ import annotations -from typing import Callable, Dict, Iterable, List, Set, Tuple, TypeVar +from collections.abc import Callable, Iterable +from typing import TypeVar T = TypeVar("T") -def index_live(entries: Iterable[T], key: Callable[[T], str]) -> Tuple[Dict[str, T], Set[str]]: +def index_live(entries: Iterable[T], key: Callable[[T], str]) -> tuple[dict[str, T], set[str]]: """Live entries by key, and the keys more than one of them holds. Entries without a key are left out: they are not the caller's to match. """ - by_key: Dict[str, T] = {} - shared: Set[str] = set() + by_key: dict[str, T] = {} + shared: set[str] = set() for entry in entries: k = key(entry) if not k: @@ -32,10 +32,10 @@ def index_live(entries: Iterable[T], key: Callable[[T], str]) -> Tuple[Dict[str, return by_key, shared -def check_desired(entries: List[T], key: Callable[[T], str], shared: Set[str], what: str) -> None: +def check_desired(entries: list[T], key: Callable[[T], str], shared: set[str], what: str) -> None: """``ValueError`` for a desired entry with no key, a key given twice, or a key that names more than one live entry.""" - seen: Set[str] = set() + seen: set[str] = set() for entry in entries: k = key(entry) if not k: diff --git a/napalm_device_types/_ucd_metrics.py b/napalm_device_types/_ucd_metrics.py index 7148f9d..9dc44f0 100644 --- a/napalm_device_types/_ucd_metrics.py +++ b/napalm_device_types/_ucd_metrics.py @@ -1,41 +1,43 @@ """Shared SNMP metric collection helpers for UCD-MIB and IF-MIB.""" + from __future__ import annotations import asyncio import re -from typing import Any, Awaitable, Callable, Dict, Optional +from collections.abc import Awaitable, Callable +from typing import Any # ── UCD-MIB OIDs ────────────────────────────────────────────────────────────── -OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds) -OID_CPU_IDLE = "1.3.6.1.4.1.2021.11.11.0" # UCD ssCpuIdle (%) -OID_MEM_TOTAL = "1.3.6.1.4.1.2021.4.5.0" # memTotalReal (kB) -OID_MEM_FREE = "1.3.6.1.4.1.2021.4.6.0" # memAvailReal (kB) -OID_MEM_BUFFER = "1.3.6.1.4.1.2021.4.14.0" # memBuffer (kB) -OID_MEM_CACHED = "1.3.6.1.4.1.2021.4.15.0" # memCached (kB) -OID_SWAP_TOTAL = "1.3.6.1.4.1.2021.4.3.0" # memTotalSwap (kB) -OID_SWAP_AVAIL = "1.3.6.1.4.1.2021.4.4.0" # memAvailSwap (kB) -OID_LOAD_1 = "1.3.6.1.4.1.2021.10.1.3.1" # laLoad 1-min -OID_LOAD_5 = "1.3.6.1.4.1.2021.10.1.3.2" # laLoad 5-min -OID_LOAD_15 = "1.3.6.1.4.1.2021.10.1.3.3" # laLoad 15-min +OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds) +OID_CPU_IDLE = "1.3.6.1.4.1.2021.11.11.0" # UCD ssCpuIdle (%) +OID_MEM_TOTAL = "1.3.6.1.4.1.2021.4.5.0" # memTotalReal (kB) +OID_MEM_FREE = "1.3.6.1.4.1.2021.4.6.0" # memAvailReal (kB) +OID_MEM_BUFFER = "1.3.6.1.4.1.2021.4.14.0" # memBuffer (kB) +OID_MEM_CACHED = "1.3.6.1.4.1.2021.4.15.0" # memCached (kB) +OID_SWAP_TOTAL = "1.3.6.1.4.1.2021.4.3.0" # memTotalSwap (kB) +OID_SWAP_AVAIL = "1.3.6.1.4.1.2021.4.4.0" # memAvailSwap (kB) +OID_LOAD_1 = "1.3.6.1.4.1.2021.10.1.3.1" # laLoad 1-min +OID_LOAD_5 = "1.3.6.1.4.1.2021.10.1.3.2" # laLoad 5-min +OID_LOAD_15 = "1.3.6.1.4.1.2021.10.1.3.3" # laLoad 15-min # ── IF-MIB OIDs ─────────────────────────────────────────────────────────────── -OID_IF_DESCR = "1.3.6.1.2.1.2.2.1.2" -OID_IF_SPEED = "1.3.6.1.2.1.2.2.1.5" -OID_IF_IN_OCT = "1.3.6.1.2.1.2.2.1.10" +OID_IF_DESCR = "1.3.6.1.2.1.2.2.1.2" +OID_IF_SPEED = "1.3.6.1.2.1.2.2.1.5" +OID_IF_IN_OCT = "1.3.6.1.2.1.2.2.1.10" OID_IF_OUT_OCT = "1.3.6.1.2.1.2.2.1.16" -OID_IF_IN_ERR = "1.3.6.1.2.1.2.2.1.14" +OID_IF_IN_ERR = "1.3.6.1.2.1.2.2.1.14" OID_IF_OUT_ERR = "1.3.6.1.2.1.2.2.1.20" # ifXTable: 64-bit octets and Mbps. The 32-bit counters wrap every ~34 s at # 1 Gbit/s and ifSpeed stops at 4294 Mbps; an agent without ifXTable (SNMPv1) # falls back to them. -OID_IF_HC_IN_OCT = "1.3.6.1.2.1.31.1.1.1.6" +OID_IF_HC_IN_OCT = "1.3.6.1.2.1.31.1.1.1.6" OID_IF_HC_OUT_OCT = "1.3.6.1.2.1.31.1.1.1.10" OID_IF_HIGH_SPEED = "1.3.6.1.2.1.31.1.1.1.15" -IF_SKIP_DEFAULT = re.compile(r'^(lo|sit\d|tun\d|docker|veth|br-|virbr)', re.IGNORECASE) +IF_SKIP_DEFAULT = re.compile(r"^(lo|sit\d|tun\d|docker|veth|br-|virbr)", re.IGNORECASE) -SnmpGetFn = Callable[[str], Awaitable[Optional[str]]] -SnmpWalkFn = Callable[[str], Awaitable[Dict[str, str]]] +SnmpGetFn = Callable[[str], Awaitable[str | None]] +SnmpWalkFn = Callable[[str], Awaitable[dict[str, str]]] def ticks_to_seconds(raw: str | None) -> int | None: @@ -48,35 +50,35 @@ def ticks_to_seconds(raw: str | None) -> int | None: def build_if_metrics( - metrics: Dict[str, Any], - descr: Dict[str, str], - speed: Dict[str, str], - in_oct: Dict[str, str], - out_oct: Dict[str, str], - in_err: Dict[str, str], - out_err: Dict[str, str], + metrics: dict[str, Any], + descr: dict[str, str], + speed: dict[str, str], + in_oct: dict[str, str], + out_oct: dict[str, str], + in_err: dict[str, str], + out_err: dict[str, str], *, if_skip: re.Pattern[str] | None = None, tx_err_is_drop: bool = False, - hc_in_oct: Optional[Dict[str, str]] = None, - hc_out_oct: Optional[Dict[str, str]] = None, - high_speed: Optional[Dict[str, str]] = None, + hc_in_oct: dict[str, str] | None = None, + hc_out_oct: dict[str, str] | None = None, + high_speed: dict[str, str] | None = None, ) -> None: if if_skip is None: if_skip = IF_SKIP_DEFAULT - interfaces: Dict[str, Any] = {} + interfaces: dict[str, Any] = {} for idx, name in descr.items(): if if_skip.match(name): continue - iface: Dict[str, Any] = {"name": name} + iface: dict[str, Any] = {"name": name} try: - iface["rx_bytes"] = int((hc_in_oct or {}).get(idx) or in_oct.get(idx) or 0) - iface["tx_bytes"] = int((hc_out_oct or {}).get(idx) or out_oct.get(idx) or 0) - iface["rx_errors"] = int(in_err.get(idx, 0) or 0) + iface["rx_bytes"] = int((hc_in_oct or {}).get(idx) or in_oct.get(idx) or 0) + iface["tx_bytes"] = int((hc_out_oct or {}).get(idx) or out_oct.get(idx) or 0) + iface["rx_errors"] = int(in_err.get(idx, 0) or 0) tx_val = int(out_err.get(idx, 0) or 0) if tx_err_is_drop: iface["tx_errors"] = 0 - iface["tx_drops"] = tx_val + iface["tx_drops"] = tx_val else: iface["tx_errors"] = tx_val high = (high_speed or {}).get(idx) @@ -98,15 +100,33 @@ async def collect_ucd_metrics( *, tx_err_is_drop: bool = False, if_skip: re.Pattern[str] | None = None, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Collect UCD-MIB system metrics + IF-MIB interface counters in parallel.""" ( - uptime_raw, cpu_idle_raw, - mem_total_raw, mem_free_raw, mem_buf_raw, mem_cache_raw, - swap_total_raw, swap_avail_raw, - load1_raw, load5_raw, load15_raw, - ), ( - descr, speed, in_oct, out_oct, in_err, out_err, hc_in_oct, hc_out_oct, high_speed, + ( + uptime_raw, + cpu_idle_raw, + mem_total_raw, + mem_free_raw, + mem_buf_raw, + mem_cache_raw, + swap_total_raw, + swap_avail_raw, + load1_raw, + load5_raw, + load15_raw, + ), + ( + descr, + speed, + in_oct, + out_oct, + in_err, + out_err, + hc_in_oct, + hc_out_oct, + high_speed, + ), ) = await asyncio.gather( asyncio.gather( snmp_get(OID_SYS_UPTIME), @@ -134,7 +154,7 @@ async def collect_ucd_metrics( ), ) - metrics: Dict[str, Any] = {} + metrics: dict[str, Any] = {} secs = ticks_to_seconds(uptime_raw) if secs is not None: @@ -149,14 +169,14 @@ async def collect_ucd_metrics( if mem_total_raw and mem_free_raw: try: total_kb = int(mem_total_raw) - free_kb = int(mem_free_raw) - buf_kb = int(mem_buf_raw) if mem_buf_raw else 0 + free_kb = int(mem_free_raw) + buf_kb = int(mem_buf_raw) if mem_buf_raw else 0 cache_kb = int(mem_cache_raw) if mem_cache_raw else 0 - used_kb = max(0, total_kb - free_kb - buf_kb - cache_kb) + used_kb = max(0, total_kb - free_kb - buf_kb - cache_kb) metrics["memory_total_bytes"] = total_kb * 1024 - metrics["memory_used_bytes"] = used_kb * 1024 - metrics["memory_percent"] = round(used_kb / total_kb * 100, 1) if total_kb else 0.0 - metrics["memory_free_bytes"] = free_kb * 1024 + metrics["memory_used_bytes"] = used_kb * 1024 + metrics["memory_percent"] = round(used_kb / total_kb * 100, 1) if total_kb else 0.0 + metrics["memory_free_bytes"] = free_kb * 1024 except ValueError: pass @@ -164,10 +184,10 @@ async def collect_ucd_metrics( try: stotal = int(swap_total_raw) savail = int(swap_avail_raw) - sused = stotal - savail + sused = stotal - savail metrics["swap_total_bytes"] = stotal * 1024 - metrics["swap_used_bytes"] = sused * 1024 - metrics["swap_percent"] = round(sused / stotal * 100, 1) if stotal else 0.0 + metrics["swap_used_bytes"] = sused * 1024 + metrics["swap_percent"] = round(sused / stotal * 100, 1) if stotal else 0.0 except ValueError: pass @@ -178,7 +198,18 @@ async def collect_ucd_metrics( except ValueError: pass - build_if_metrics(metrics, descr, speed, in_oct, out_oct, in_err, out_err, - if_skip=if_skip, tx_err_is_drop=tx_err_is_drop, - hc_in_oct=hc_in_oct, hc_out_oct=hc_out_oct, high_speed=high_speed) + build_if_metrics( + metrics, + descr, + speed, + in_oct, + out_oct, + in_err, + out_err, + if_skip=if_skip, + tx_err_is_drop=tx_err_is_drop, + hc_in_oct=hc_in_oct, + hc_out_oct=hc_out_oct, + high_speed=high_speed, + ) return metrics diff --git a/napalm_device_types/access_point.py b/napalm_device_types/access_point.py index b5dbc9f..2e8a9d6 100644 --- a/napalm_device_types/access_point.py +++ b/napalm_device_types/access_point.py @@ -10,14 +10,12 @@ Usage:: ... """ -from typing import Any, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING + from napalm_device_types.base import DeviceTypeDriver -from napalm_device_types.mac_acl import MacAclMixin -from napalm_device_types.updates import UpdateMixin -from napalm_device_types.services import ServiceControlMixin -from napalm_device_types.packages import PackageManagementMixin from napalm_device_types.health_metrics import HealthMetricsMixin from napalm_device_types.interface_filter import InterfaceFilterMixin +from napalm_device_types.mac_acl import MacAclMixin from napalm_device_types.models import ( Dot1XConfigDict, FastTransitionConfigDict, @@ -26,9 +24,20 @@ from napalm_device_types.models import ( SSIDBridgeDict, WirelessConfigDict, ) +from napalm_device_types.packages import PackageManagementMixin +from napalm_device_types.services import ServiceControlMixin +from napalm_device_types.updates import UpdateMixin -class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, InterfaceFilterMixin, DeviceTypeDriver): +class AccessPointDriver( + MacAclMixin, + UpdateMixin, + ServiceControlMixin, + PackageManagementMixin, + HealthMetricsMixin, + InterfaceFilterMixin, + DeviceTypeDriver, +): """ Abstract intermediate driver for wireless access points. @@ -42,11 +51,6 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa ROLE: str = "access_point" TYPE_LABEL: str = "Access Point" - - - - - # AP-specific methods (get_wireless_clients, get_ssids, get_radio_status, # get_interfaces, get_vlans, …) are intentionally NOT defined here. # AccessPointDriver sits AFTER the mixin classes in the MRO of concrete @@ -86,7 +90,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa """ ... - def get_fast_transition_config(self) -> Dict[str, FastTransitionConfigDict]: + def get_fast_transition_config(self) -> dict[str, FastTransitionConfigDict]: """ Returns the 802.11r Fast BSS Transition (FT) configuration per SSID. @@ -118,7 +122,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa """ ... - def get_mesh_config(self) -> Dict[str, MeshConfigDict]: + def get_mesh_config(self) -> dict[str, MeshConfigDict]: """ Returns the 802.11s mesh configuration per mesh interface. @@ -148,7 +152,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa """ ... - def get_mesh_peers(self) -> List[MeshPeerDict]: + def get_mesh_peers(self) -> list[MeshPeerDict]: """ Returns a list of currently active 802.11s mesh peers. @@ -178,7 +182,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa """ ... - def get_ssid_bridge_config(self) -> Dict[str, SSIDBridgeDict]: + def get_ssid_bridge_config(self) -> dict[str, SSIDBridgeDict]: """ Returns the Layer-2 bridging configuration for each SSID, i.e. which bridge interface and VLAN each SSID is mapped to. @@ -186,7 +190,8 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa Keys are SSID names. Each value contains: * ssid (string) - SSID name (repeated for convenience) - * bridge (string) - bridge interface the VAP is attached to (e.g. ``"br-lan"``, ``"br-guest"``) + * bridge (string) - bridge interface the VAP is attached to + (e.g. ``"br-lan"``, ``"br-guest"``) * vlan_id (int) - 802.1Q VLAN ID (0 = untagged / no VLAN separation) * tagged (bool) - whether traffic is 802.1Q-tagged on the uplink port * client_isolation (bool) - whether clients on this SSID are isolated from each other @@ -219,9 +224,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa """ ... - - - def get_dot1x_config(self) -> Dict[str, Dot1XConfigDict]: + def get_dot1x_config(self) -> dict[str, Dot1XConfigDict]: """ Returns the 802.1X / WPA-Enterprise (RADIUS) configuration per SSID. @@ -268,12 +271,3 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa } """ ... - - - - - - - - - diff --git a/napalm_device_types/channel.py b/napalm_device_types/channel.py index 6b534c9..775e458 100644 --- a/napalm_device_types/channel.py +++ b/napalm_device_types/channel.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """The command channel: a public way to run a command on a host, or open a stream. A driver already reaches its host, over SSH, a REST API or WinRM. What it did @@ -26,9 +25,8 @@ stays with the driver. from __future__ import annotations -import socket import time -from typing import Any, NamedTuple, Optional, Protocol, Tuple, TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, 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. @@ -56,7 +54,7 @@ class ByteStream(Protocol): ``close_write`` closes the writing side. """ - def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes: ... + def read(self, max_bytes: int, timeout: float | None = None) -> bytes: ... def write(self, data: bytes) -> None: ... @@ -65,7 +63,7 @@ class ByteStream(Protocol): def close(self) -> None: ... @property - def exit_status(self) -> Optional[int]: ... + def exit_status(self) -> int | None: ... @property def stderr(self) -> str: ... @@ -82,7 +80,7 @@ class CommandChannelMixin: *, privileged: bool = False, timeout: float = 60, - stdin: Optional[bytes] = None, + stdin: bytes | None = None, ) -> CommandResult: """Run *command* with the host's shell; *stdin*, if given, is written first.""" ... @@ -108,11 +106,11 @@ class ParamikoExecStream: while self._channel.recv_stderr_ready(): self._stderr = (self._stderr + self._channel.recv_stderr(_READ))[-self._limit :] - def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes: + def read(self, max_bytes: int, timeout: float | None = None) -> bytes: self._channel.settimeout(timeout) try: return cast(bytes, self._channel.recv(max_bytes)) - except socket.timeout: + except TimeoutError: raise TimeoutError(f"no data within {timeout}s") from None finally: self._drain_stderr() @@ -127,7 +125,7 @@ class ParamikoExecStream: self._channel.close() @property - def exit_status(self) -> Optional[int]: + def exit_status(self) -> int | None: if not self._channel.exit_status_ready(): return None return cast(int, self._channel.recv_exit_status()) @@ -138,7 +136,7 @@ class ParamikoExecStream: return self._stderr.decode("utf-8", "replace") -def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]: +def _collect(channel: Any, command: str, timeout: float) -> tuple[bytes, bytes]: out, err = b"", b"" deadline = time.monotonic() + timeout while True: @@ -153,7 +151,7 @@ def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]: out += chunk while chunk := channel.recv_stderr(_READ): err += chunk - except socket.timeout: + except TimeoutError: raise TimeoutError(f"{command!r} did not finish within {timeout}s") from None return out, err elif time.monotonic() > deadline: @@ -163,7 +161,7 @@ def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]: def run_on_transport( - transport: Any, command: str, *, stdin: Optional[bytes] = None, timeout: float = 60 + transport: Any, command: str, *, stdin: bytes | None = None, timeout: float = 60 ) -> CommandResult: """Run *command* on an exec channel of the paramiko *transport*.""" channel = transport.open_session() @@ -175,14 +173,16 @@ def run_on_transport( channel.shutdown_write() out, err = _collect(channel, command, timeout) return CommandResult( - out.decode("utf-8", "replace"), err.decode("utf-8", "replace"), channel.recv_exit_status() + out.decode("utf-8", "replace"), + err.decode("utf-8", "replace"), + channel.recv_exit_status(), ) finally: channel.close() def open_stream_on_transport( - transport: Any, command: str, *, stdin_prefix: Optional[bytes] = None + transport: Any, command: str, *, stdin_prefix: bytes | None = None ) -> ParamikoExecStream: """Start *command* on an exec channel and return its stream. diff --git a/napalm_device_types/config_lifecycle.py b/napalm_device_types/config_lifecycle.py index a27b5dd..c205bda 100644 --- a/napalm_device_types/config_lifecycle.py +++ b/napalm_device_types/config_lifecycle.py @@ -1,8 +1,7 @@ -# -*- coding: utf-8 -*- from __future__ import annotations import difflib -from typing import ClassVar, TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from napalm.base.exceptions import ( CommandErrorException, @@ -41,15 +40,11 @@ class ConfigLifecycleMixin: if TYPE_CHECKING: - def _get_running_config(self) -> str: - ... + def _get_running_config(self) -> str: ... - def commit_config(self, message: str = "", revert_in: int | None = None) -> None: - ... + def commit_config(self, message: str = "", revert_in: int | None = None) -> None: ... - def load_merge_candidate( - self, filename: str | None = None, config: str | None = None - ) -> None: + def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None: if filename is not None: try: with open(filename) as fh: diff --git a/napalm_device_types/config_sync.py b/napalm_device_types/config_sync.py index 8238ca5..7990cb8 100644 --- a/napalm_device_types/config_sync.py +++ b/napalm_device_types/config_sync.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Configuration sync between the two hosts of an HA pair. A host that pushes its configuration to a peer (OPNsense's XMLRPC sync, say) diff --git a/napalm_device_types/container_engine.py b/napalm_device_types/container_engine.py index 743ab1d..f0e35f7 100644 --- a/napalm_device_types/container_engine.py +++ b/napalm_device_types/container_engine.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Access to a host's container engine, and nothing more. This package abstracts the connection, the driver builds it, and netOrk does @@ -29,13 +28,14 @@ stays truthful. from __future__ import annotations import shlex -from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, cast +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast from napalm_device_types.channel import ByteStream, CommandResult from napalm_device_types.models import ContainerEngineDict, FixResultDict #: Engines this package knows how to reach, and the API each speaks. -ENGINES: Dict[str, str] = {"docker": "docker-engine"} +ENGINES: dict[str, str] = {"docker": "docker-engine"} _PROBE_TIMEOUT = 15 _CLI_TIMEOUT = 120 @@ -68,7 +68,7 @@ class ContainerEngineConnection: self, args: Sequence[str], *, - stdin: Optional[bytes] = None, + stdin: bytes | None = None, timeout: float = _CLI_TIMEOUT, privileged: bool = False, ) -> CommandResult: @@ -77,9 +77,12 @@ 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 cast(CommandResult, self._driver.run_command( - self._command(args), privileged=privileged, timeout=timeout, stdin=stdin - )) + 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 +93,12 @@ class ContainerEngineConnection: progress there (``compose up``); *privileged* as for :meth:`run_cli`. """ command = self._command(args) - return cast(ByteStream, self._driver.open_stream( - f"{command} 2>&1" if merge_stderr else command, privileged=privileged - )) + return cast( + ByteStream, + self._driver.open_stream( + f"{command} 2>&1" if merge_stderr else command, privileged=privileged + ), + ) class ContainerEngineMixin: @@ -106,7 +112,7 @@ class ContainerEngineMixin: *, privileged: bool = False, timeout: float = 60, - stdin: Optional[bytes] = None, + stdin: bytes | None = None, ) -> CommandResult: ... def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream: ... @@ -121,7 +127,7 @@ class ContainerEngineMixin: """The host command that is *engine*'s CLI. Override where it is not on PATH.""" return engine - def container_engines(self) -> List[ContainerEngineDict]: + def container_engines(self) -> list[ContainerEngineDict]: """ Returns the container engines the host offers: @@ -132,7 +138,7 @@ class ContainerEngineMixin: user may use it is decided by the first API call, which says "permission denied" in the stream's stderr. """ - found: List[ContainerEngineDict] = [] + found: list[ContainerEngineDict] = [] for engine, api in ENGINES.items(): probe = shlex.join(["command", "-v", self._container_engine_binary(engine)]) result = self.run_command(probe, timeout=_PROBE_TIMEOUT) diff --git a/napalm_device_types/dhcp.py b/napalm_device_types/dhcp.py index 281157e..f1f72ef 100644 --- a/napalm_device_types/dhcp.py +++ b/napalm_device_types/dhcp.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """DHCP desired-state management, shared by firewalls and gateways. Covers two independent desired-state sets: @@ -24,16 +23,17 @@ takes DHCP down for an entire VLAN. from __future__ import annotations import re -from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any from napalm_device_types._matching import check_desired, index_live from napalm_device_types.models import ( DhcpHaDict, - DhcpReservationDiffDict, DhcpReservationDict, + DhcpReservationDiffDict, DhcpReservationUpdateDict, - DhcpSubnetDiffDict, DhcpSubnetDict, + DhcpSubnetDiffDict, DhcpSubnetUpdateDict, ) @@ -58,7 +58,7 @@ _DHCP_SUBNET_COMPARE_FIELDS = ( _HEX_ONLY = re.compile(r"[^0-9a-f]") -def normalize_mac(mac: Optional[str]) -> str: +def normalize_mac(mac: str | None) -> str: """Reduces a MAC address to lowercase colon-separated form. Devices report MACs in whatever form their config store happens to use -- @@ -80,7 +80,7 @@ def normalize_mac(mac: Optional[str]) -> str: return ":".join(hex_digits[i : i + 2] for i in range(0, 12, 2)) -def normalize_cidr(cidr: Optional[str]) -> str: +def normalize_cidr(cidr: str | None) -> str: """Reduces a subnet CIDR to a canonical string for matching. Only whitespace and case are normalised -- deliberately not the network @@ -91,9 +91,7 @@ def normalize_cidr(cidr: Optional[str]) -> str: return (cidr or "").strip().lower() -def _subnet_field_differs( - field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict -) -> bool: +def _subnet_field_differs(field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict) -> bool: """Compares one subnet field, with `option_data` handled specially. For `option_data` only the options `desired` actually names are compared; @@ -105,9 +103,7 @@ def _subnet_field_differs( desired_options = desired.get("option_data") or {} live_options = live.get("option_data") or {} - return any( - live_options.get(option) != value for option, value in desired_options.items() - ) + return any(live_options.get(option) != value for option, value in desired_options.items()) class DhcpServerMixin: @@ -125,7 +121,7 @@ class DhcpServerMixin: if TYPE_CHECKING: - def get_dhcp_reservations(self) -> List[DhcpReservationDict]: + def get_dhcp_reservations(self) -> list[DhcpReservationDict]: """ Returns all static DHCP reservations currently configured on the device, across all subnets. @@ -139,8 +135,8 @@ class DhcpServerMixin: ... def apply_dhcp_reservation( - self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None - ) -> Dict[str, Any]: + self, reservation: DhcpReservationDict, *, uuid: str | None = None + ) -> dict[str, Any]: """ Creates or updates a single static DHCP reservation on the device. @@ -157,7 +153,7 @@ class DhcpServerMixin: """ ... - def get_dhcp_subnets(self) -> List[DhcpSubnetDict]: + def get_dhcp_subnets(self) -> list[DhcpSubnetDict]: """ Returns every DHCPv4 subnet the device serves, with its pools and per-subnet options. @@ -168,8 +164,8 @@ class DhcpServerMixin: ... def apply_dhcp_subnet( - self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None - ) -> Dict[str, Any]: + self, subnet: DhcpSubnetDict, *, uuid: str | None = None + ) -> dict[str, Any]: """ Creates or updates a single DHCPv4 subnet on the device. @@ -189,7 +185,7 @@ class DhcpServerMixin: """ ... - def commit_dhcp_subnets(self) -> Dict[str, Any]: + def commit_dhcp_subnets(self) -> dict[str, Any]: """ Applies pending subnet changes (e.g. Kea's ``service/reconfigure``). @@ -204,7 +200,7 @@ class DhcpServerMixin: """ ... - def commit_dhcp_reservations(self) -> Dict[str, Any]: + def commit_dhcp_reservations(self) -> dict[str, Any]: """ Applies pending reservation changes (e.g. Kea's ``service/reconfigure``, or a dnsmasq reload). @@ -238,9 +234,7 @@ class DhcpServerMixin: # Generic, vendor-neutral algorithms. # ------------------------------------------------------------------ - def diff_dhcp_reservations( - self, desired: List[DhcpReservationDict] - ) -> DhcpReservationDiffDict: + def diff_dhcp_reservations(self, desired: list[DhcpReservationDict]) -> DhcpReservationDiffDict: """ Compares `desired` against the device's current reservations and returns what would need to change to reach that state. @@ -263,14 +257,15 @@ class DhcpServerMixin: "changed_fields"}, ...]}``. :raises ValueError: When a desired reservation cannot name a single live one. """ + def key(reservation: DhcpReservationDict) -> str: return normalize_mac(reservation.get("mac")) live_by_mac, shared = index_live(self.get_dhcp_reservations(), key) check_desired(desired, key, shared, "MAC") - add: List[DhcpReservationDict] = [] - update: List[DhcpReservationUpdateDict] = [] + add: list[DhcpReservationDict] = [] + update: list[DhcpReservationUpdateDict] = [] for desired_reservation in desired: live = live_by_mac.get(key(desired_reservation)) @@ -294,9 +289,7 @@ class DhcpServerMixin: return {"add": add, "update": update} - def apply_dhcp_reservationset( - self, desired: List[DhcpReservationDict] - ) -> Iterator[str]: + def apply_dhcp_reservationset(self, desired: list[DhcpReservationDict]) -> Iterator[str]: """ Computes the diff against `desired` and applies it, yielding one human-readable progress line per change, then commits. @@ -319,8 +312,7 @@ class DhcpServerMixin: self.apply_dhcp_reservation(entry["reservation"], uuid=entry["uuid"]) fields = ", ".join(entry["changed_fields"]) yield ( - f"[update] {entry['reservation']['mac']} -> " - f"{entry['reservation']['ip']} ({fields})" + f"[update] {entry['reservation']['mac']} -> {entry['reservation']['ip']} ({fields})" ) if not diff["add"] and not diff["update"]: @@ -328,12 +320,9 @@ class DhcpServerMixin: return self.commit_dhcp_reservations() - yield ( - f"[commit] applied {len(diff['add'])} add(s), " - f"{len(diff['update'])} update(s)" - ) + yield (f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)") - def diff_dhcp_subnets(self, desired: List[DhcpSubnetDict]) -> DhcpSubnetDiffDict: + def diff_dhcp_subnets(self, desired: list[DhcpSubnetDict]) -> DhcpSubnetDiffDict: """ Compares `desired` against the device's current subnets and returns what would need to change to reach that state. @@ -362,14 +351,15 @@ class DhcpServerMixin: "changed_fields"}, ...]}``. :raises ValueError: When a desired subnet cannot name a single live one. """ + def key(subnet: DhcpSubnetDict) -> str: return normalize_cidr(subnet.get("subnet")) live_by_cidr, shared = index_live(self.get_dhcp_subnets(), key) check_desired(desired, key, shared, "subnet") - add: List[DhcpSubnetDict] = [] - update: List[DhcpSubnetUpdateDict] = [] + add: list[DhcpSubnetDict] = [] + update: list[DhcpSubnetUpdateDict] = [] for desired_subnet in desired: live = live_by_cidr.get(key(desired_subnet)) @@ -393,7 +383,7 @@ class DhcpServerMixin: return {"add": add, "update": update} - def apply_dhcp_subnetset(self, desired: List[DhcpSubnetDict]) -> Iterator[str]: + def apply_dhcp_subnetset(self, desired: list[DhcpSubnetDict]) -> Iterator[str]: """ Computes the diff against `desired` and applies it, yielding one human-readable progress line per change, then commits. @@ -421,7 +411,4 @@ class DhcpServerMixin: return self.commit_dhcp_subnets() - yield ( - f"[commit] applied {len(diff['add'])} add(s), " - f"{len(diff['update'])} update(s)" - ) + yield (f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)") diff --git a/napalm_device_types/dns_forward.py b/napalm_device_types/dns_forward.py index 38a277b..491c2c5 100644 --- a/napalm_device_types/dns_forward.py +++ b/napalm_device_types/dns_forward.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Domains a device's DNS resolver forwards to another server. An application that makes the device's resolver ask another server for a zone @@ -9,7 +8,7 @@ Declared only; a driver whose device resolves DNS implements it. from __future__ import annotations -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import DnsForwardDict @@ -17,7 +16,7 @@ from napalm_device_types.models import DnsForwardDict class DnsForwardMixin: if TYPE_CHECKING: # pragma: no cover - declared for type checkers only - def get_dns_forwards(self) -> List[DnsForwardDict]: + def get_dns_forwards(self) -> list[DnsForwardDict]: """Every domain the resolver forwards, enabled or not.""" ... diff --git a/napalm_device_types/firewall.py b/napalm_device_types/firewall.py index f955898..adbb120 100644 --- a/napalm_device_types/firewall.py +++ b/napalm_device_types/firewall.py @@ -10,21 +10,28 @@ Usage:: ... """ -from typing import Any, ClassVar, Dict, Iterator, List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar + from napalm_device_types.base import DeviceTypeDriver -from napalm_device_types.nat_vpn import NatVpnMixin -from napalm_device_types.packages import PackageManagementMixin -from napalm_device_types.health_metrics import HealthMetricsMixin from napalm_device_types.dhcp import DhcpServerMixin from napalm_device_types.firewall_rules import FirewallRuleMixin +from napalm_device_types.health_metrics import HealthMetricsMixin from napalm_device_types.models import ( SecurityZoneDict, SessionDict, ) +from napalm_device_types.nat_vpn import NatVpnMixin +from napalm_device_types.packages import PackageManagementMixin - -class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, FirewallRuleMixin, DhcpServerMixin, DeviceTypeDriver): +class FirewallDriver( + NatVpnMixin, + PackageManagementMixin, + HealthMetricsMixin, + FirewallRuleMixin, + DhcpServerMixin, + DeviceTypeDriver, +): """ Abstract intermediate driver for firewall/security devices. @@ -42,11 +49,9 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi # OPNsense reports drops in the out-error counter. _SNMP_TX_ERR_IS_DROP: ClassVar[bool] = True - if TYPE_CHECKING: - - def get_security_zones(self) -> Dict[str, SecurityZoneDict]: + def get_security_zones(self) -> dict[str, SecurityZoneDict]: """ Returns the security zone configuration. @@ -73,7 +78,7 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi """ ... - def get_sessions(self) -> List[SessionDict]: + def get_sessions(self) -> list[SessionDict]: """ Returns a list of active connection sessions (stateful flows). @@ -103,8 +108,7 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi """ ... - - def send_wake_on_lan(self, mac_address: str, interface: str = "") -> Dict[str, Any]: + def send_wake_on_lan(self, mac_address: str, interface: str = "") -> dict[str, Any]: """ Sends a Wake-on-LAN "magic packet" to wake a host on the network. @@ -142,19 +146,9 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi """ ... - - - - - # ------------------------------------------------------------------ # Firewall rule diff/apply. get_firewall_rules/apply_firewall_rule/ # commit_firewall_rules are abstract (device communication); everything # else here is a concrete, vendor-neutral algorithm -- see README.md # "Design principle: generic vs. device-specific logic". # ------------------------------------------------------------------ - - - - - diff --git a/napalm_device_types/firewall_rules.py b/napalm_device_types/firewall_rules.py index 8858e8f..2324029 100644 --- a/napalm_device_types/firewall_rules.py +++ b/napalm_device_types/firewall_rules.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Firewall rule reconciliation: generic here, device communication in the driver. The split follows the rule in this package's README: matching, diffing and the @@ -14,7 +13,8 @@ working implementation inherited from elsewhere. from __future__ import annotations -from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any from napalm_device_types._matching import check_desired, index_live from napalm_device_types.models import ( @@ -43,7 +43,7 @@ class FirewallRuleMixin: if TYPE_CHECKING: - def get_firewall_rules(self) -> List[FirewallRuleDict]: + def get_firewall_rules(self) -> list[FirewallRuleDict]: """ Returns all firewall filter rules currently configured on the device. @@ -55,9 +55,10 @@ class FirewallRuleMixin: firewall rules. """ ... + def apply_firewall_rule( - self, rule: FirewallRuleDict, *, uuid: Optional[str] = None - ) -> Dict[str, Any]: + self, rule: FirewallRuleDict, *, uuid: str | None = None + ) -> dict[str, Any]: """ Creates or updates a single firewall filter rule on the device. @@ -73,7 +74,8 @@ class FirewallRuleMixin: :returns: A dict with at least ``{"success": bool}``. """ ... - def commit_firewall_rules(self) -> Dict[str, Any]: + + def commit_firewall_rules(self) -> dict[str, Any]: """ Applies pending firewall filter rule changes (e.g. reloads pf/pfctl, or whatever the device's equivalent of "Apply Changes" is). @@ -88,7 +90,7 @@ class FirewallRuleMixin: """ ... - def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict: + def diff_firewall_rules(self, desired: list[FirewallRuleDict]) -> FirewallRuleDiffDict: """ Compares `desired` against the device's current rules and returns what would need to change to reach that state. @@ -113,14 +115,15 @@ class FirewallRuleMixin: "changed_fields"}, ...]}``. :raises ValueError: When a desired rule cannot name a single live one. """ + def key(rule: FirewallRuleDict) -> str: return (rule.get("description") or "").strip() live_by_description, shared = index_live(self.get_firewall_rules(), key) check_desired(desired, key, shared, "description") - add: List[FirewallRuleDict] = [] - update: List[FirewallRuleUpdateDict] = [] + add: list[FirewallRuleDict] = [] + update: list[FirewallRuleUpdateDict] = [] for desired_rule in desired: live_rule = live_by_description.get(key(desired_rule)) @@ -143,7 +146,8 @@ class FirewallRuleMixin: ) return {"add": add, "update": update} - def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]) -> Iterator[str]: + + def apply_firewall_ruleset(self, desired: list[FirewallRuleDict]) -> Iterator[str]: """ Computes the diff against `desired` and applies it, yielding one human-readable progress line per change, then commits. diff --git a/napalm_device_types/firmware.py b/napalm_device_types/firmware.py index aed95e9..08b0af1 100644 --- a/napalm_device_types/firmware.py +++ b/napalm_device_types/firmware.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """The firmware a vendor offers for a device. A fact for the caller (docs/STRUCTURE.md): a driver may know the newer firmware @@ -10,7 +9,7 @@ the offer is newer. Declared only; a driver that can tell implements it. from __future__ import annotations -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import FirmwareOfferDict @@ -18,7 +17,7 @@ from napalm_device_types.models import FirmwareOfferDict class FirmwareOfferMixin: if TYPE_CHECKING: # pragma: no cover - declared for type checkers only - def get_firmware_offer(self) -> Optional[FirmwareOfferDict]: + def get_firmware_offer(self) -> FirmwareOfferDict | None: """The firmware running and the newest the vendor offers. None when the driver cannot tell for this device (an unknown model, diff --git a/napalm_device_types/fixes.py b/napalm_device_types/fixes.py index 9656960..a3335d6 100644 --- a/napalm_device_types/fixes.py +++ b/napalm_device_types/fixes.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Fix routines: changes an application wants made on a device, as templates. A template fixes the order of the steps; the driver knows how each step is done on @@ -22,7 +21,8 @@ They replace ``run_device_action`` (docs/STRUCTURE.md): from __future__ import annotations -from typing import Any, Callable, List, Optional, Sequence, Tuple, TYPE_CHECKING +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any from napalm_device_types.models import ( FixResultDict, @@ -33,7 +33,7 @@ from napalm_device_types.models import ( ) #: One step of a fix: its name and the call that does it. -FixStep = Tuple[str, Callable[[], FixStepOutcomeDict]] +FixStep = tuple[str, Callable[[], FixStepOutcomeDict]] def run_fix_steps(steps: Sequence[FixStep]) -> FixResultDict: @@ -41,7 +41,7 @@ def run_fix_steps(steps: Sequence[FixStep]) -> FixResultDict: A step that raises has failed, with the exception's message as its output. """ - done: List[FixStepDict] = [] + done: list[FixStepDict] = [] for name, step in steps: try: outcome = step() @@ -79,7 +79,7 @@ class SnmpAgentMixin: if TYPE_CHECKING: # pragma: no cover - declared for type checkers only - def get_management_peer(self) -> Optional[str]: + def get_management_peer(self) -> str | None: """The address the device sees this management connection come from (for SSH, the client side of ``SSH_CONNECTION``), or None when it cannot tell. A fact for the caller choosing ``allowed_sources``; optional -- @@ -90,7 +90,9 @@ class SnmpAgentMixin: def _install_snmp_agent(self) -> FixStepOutcomeDict: ... - def _write_snmp_agent_config(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict: ... + def _write_snmp_agent_config( + self, settings: SnmpAgentSettingsDict + ) -> FixStepOutcomeDict: ... def _allow_snmp_sources(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict: """Let ``settings["allowed_sources"]`` reach the agent; ``ok`` with nothing @@ -126,7 +128,7 @@ class EnsurePackageMixin: if TYPE_CHECKING: # pragma: no cover - def get_packages(self) -> List[PackageDict]: ... + def get_packages(self) -> list[PackageDict]: ... def install_package(self, name: str, version: str = "") -> None: ... @@ -140,7 +142,7 @@ class PackageProxyMixin: """Have the package manager fetch through *url*; an empty *url* removes it.""" ... - def get_package_proxy(self) -> Optional[str]: + def get_package_proxy(self) -> str | None: """The proxy the package manager fetches through now; None for none. A fact: whether it is the right one is the caller's to judge.""" @@ -154,4 +156,3 @@ __all__ = [ "SnmpAgentMixin", "run_fix_steps", ] - diff --git a/napalm_device_types/health_metrics.py b/napalm_device_types/health_metrics.py index 50a54a5..5066475 100644 --- a/napalm_device_types/health_metrics.py +++ b/napalm_device_types/health_metrics.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """SNMP health collection, shared by every device type that speaks UCD-MIB. **Deprecated** (docs/STRUCTURE.md): drivers do not collect over SNMP. The @@ -21,7 +20,8 @@ from __future__ import annotations import re import warnings -from typing import Any, Awaitable, Callable, ClassVar, Dict, cast +from collections.abc import Awaitable, Callable +from typing import Any, ClassVar, cast from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics from napalm_device_types.models import HealthMetricsDict @@ -43,7 +43,7 @@ class HealthMetricsMixin: async def get_health_metrics( cls, snmp_get: Callable[..., Awaitable[Any]], - snmp_walk: Callable[..., Awaitable[Dict[str, Any]]], + snmp_walk: Callable[..., Awaitable[dict[str, Any]]], ) -> HealthMetricsDict: """Collect SNMP health metrics (CPU, memory, uptime, load, interfaces). diff --git a/napalm_device_types/host_firewall.py b/napalm_device_types/host_firewall.py index 06211e4..3b03deb 100644 --- a/napalm_device_types/host_firewall.py +++ b/napalm_device_types/host_firewall.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """A Linux host's own firewall: its netfilter rules, read once, in one shape. Whether a service is reachable from outside its host is decided twice: by the @@ -37,8 +36,9 @@ import ipaddress import json import re import shlex +from collections.abc import Iterator from shlex import quote -from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from napalm_device_types.models import ( HostFirewallDict, @@ -71,7 +71,7 @@ _IPTABLES_FAMILY = {"iptables-save": "ip", "ip6tables-save": "ip6"} #: The built-in chains of iptables' tables: ``(type, hook, priority)``, as #: iptables-nft registers them (and as the legacy tables run in the kernel). -_BUILT_IN: Dict[str, Dict[str, Tuple[str, str, int]]] = { +_BUILT_IN: dict[str, dict[str, tuple[str, str, int]]] = { "raw": {"PREROUTING": ("filter", "prerouting", -300), "OUTPUT": ("filter", "output", -300)}, "mangle": { "PREROUTING": ("filter", "prerouting", -150), @@ -132,7 +132,7 @@ class HostFirewallUnavailable(NotImplementedError): """Nothing on the host could be read: no ``nft``, no ``iptables-save``, or no root.""" -def _frame(output: str) -> List[str]: +def _frame(output: str) -> list[str]: lines = [line.rstrip() for line in strip_terminal_codes(output).splitlines()] stripped = [line.strip() for line in lines] try: @@ -143,11 +143,11 @@ def _frame(output: str) -> List[str]: return lines[start + 1 : end] -def _sections(lines: List[str]) -> Dict[str, Tuple[List[str], Optional[int]]]: +def _sections(lines: list[str]) -> dict[str, tuple[list[str], int | None]]: """Each tool's output and exit status.""" - sections: Dict[str, Tuple[List[str], Optional[int]]] = {} - name: Optional[str] = None - body: List[str] = [] + sections: dict[str, tuple[list[str], int | None]] = {} + name: str | None = None + body: list[str] = [] for line in lines: text = line.strip() if text in ("[nft]", "[iptables-save]", "[ip6tables-save]"): @@ -166,6 +166,9 @@ def _sections(lines: List[str]) -> Dict[str, Tuple[List[str], Optional[int]]]: # ── iptables-save ──────────────────────────────────────────────────────────── +#: The token that negates the option after it (``! -s 10.0.0.0/8``). +_NEGATION = "!" + #: Options that start a new part of a rule, outside any ``-m`` module. _TOP_LEVEL = frozenset( { @@ -197,7 +200,7 @@ _KNOWN_MODULES = frozenset( ) -def _match(field: str, negated: bool, values: List[str]) -> NetfilterMatchDict: +def _match(field: str, negated: bool, values: list[str]) -> NetfilterMatchDict: return {"field": field, "op": "!=" if negated else "==", "values": values} @@ -210,25 +213,25 @@ def _iface(name: str) -> str: return name[:-1] + "*" if name.endswith("+") else name -def _ports(spec: str) -> List[str]: +def _ports(spec: str) -> list[str]: return [p.replace(":", "-") for p in spec.split(",") if p] -def _states(spec: str) -> List[str]: +def _states(spec: str) -> list[str]: return [s.lower() for s in spec.split(",") if s] class _RuleTokens: """The tokens of one ``-A`` line, read front to back.""" - def __init__(self, tokens: List[str]): + def __init__(self, tokens: list[str]): self.tokens = tokens self.i = 0 def more(self) -> bool: return self.i < len(self.tokens) - def peek(self, ahead: int = 0) -> Optional[str]: + def peek(self, ahead: int = 0) -> str | None: j = self.i + ahead return self.tokens[j] if j < len(self.tokens) else None @@ -238,19 +241,19 @@ class _RuleTokens: return token -def _module_options(module: str, rt: _RuleTokens, matches: List[NetfilterMatchDict]) -> None: +def _module_options(module: str, rt: _RuleTokens, matches: list[NetfilterMatchDict]) -> None: """Read *module*'s options from *rt* into *matches*; stop at the next top-level option.""" text = ["-m", module] unknown = module not in _KNOWN_MODULES while rt.more(): token = rt.peek() - if token in _TOP_LEVEL or (token == "!" and rt.peek(1) in _TOP_LEVEL): + if token in _TOP_LEVEL or (token == _NEGATION and rt.peek(1) in _TOP_LEVEL): break negated = False - if token == "!": + if token == _NEGATION: rt.take() negated = True - text.append("!") + text.append(_NEGATION) option = rt.take() value = rt.take() if rt.more() and not str(rt.peek()).startswith("-") else "" text += [option] + ([value] if value else []) @@ -266,7 +269,10 @@ def _module_options(module: str, rt: _RuleTokens, matches: List[NetfilterMatchDi def _module_match(module: str, option: str, value: str, negated: bool) -> Any: - """The match one module option makes; False for one that matches nothing new; None if not read.""" + """The match one module option makes. + + False for one that matches nothing new; None if not read. + """ if module == "comment": return False if module in ("tcp", "udp", "sctp", "udplite"): @@ -291,7 +297,7 @@ def _module_match(module: str, option: str, value: str, negated: bool) -> Any: return None -def _target(name: str, chains: Set[str]) -> Tuple[str, Optional[str]]: +def _target(name: str, chains: set[str]) -> tuple[str, str | None]: if name in _TERMINAL: return _TERMINAL[name], None if name in chains: @@ -301,12 +307,12 @@ def _target(name: str, chains: Set[str]) -> Tuple[str, Optional[str]]: return "other", name -def _iptables_rule(line: str, chains: Set[str]) -> Tuple[str, NetfilterRuleDict]: +def _iptables_rule(line: str, chains: set[str]) -> tuple[str, NetfilterRuleDict]: """``-A CHAIN ...`` as ``(chain, rule)``.""" tokens = shlex.split(line) chain = tokens[1] rt = _RuleTokens(tokens[2:]) - matches: List[NetfilterMatchDict] = [] + matches: list[NetfilterMatchDict] = [] verdict, target = "continue", None negated = False simple = { @@ -323,7 +329,7 @@ def _iptables_rule(line: str, chains: Set[str]) -> Tuple[str, NetfilterRuleDict] } while rt.more(): token = rt.take() - if token == "!": + if token == _NEGATION: negated = True continue if token in simple: @@ -351,7 +357,7 @@ def _iptables_rule(line: str, chains: Set[str]) -> Tuple[str, NetfilterRuleDict] return chain, {"matches": matches, "verdict": verdict, "target": target, "text": line} -def _iptables_variant(lines: List[str]) -> Optional[str]: +def _iptables_variant(lines: list[str]) -> str | None: """``iptables-nft`` or ``iptables-legacy`` from the header; None if nothing was saved.""" for line in lines: if line.startswith("# Generated by"): @@ -359,10 +365,10 @@ def _iptables_variant(lines: List[str]) -> Optional[str]: return None -def _parse_iptables_save(lines: List[str], family: str) -> List[NetfilterChainDict]: - chains: List[NetfilterChainDict] = [] - table: Optional[str] = None - by_name: Dict[str, NetfilterChainDict] = {} +def _parse_iptables_save(lines: list[str], family: str) -> list[NetfilterChainDict]: + chains: list[NetfilterChainDict] = [] + table: str | None = None + by_name: dict[str, NetfilterChainDict] = {} for raw in lines: line = raw.strip() if line.startswith("*"): @@ -421,7 +427,7 @@ _OTHER = frozenset({"queue", "dnat", "snat", "masquerade", "redirect", "tproxy", _TRANSPORTS = frozenset({"tcp", "udp", "sctp", "udplite", "dccp"}) -def _nft_scalar(value: Any) -> Optional[List[str]]: +def _nft_scalar(value: Any) -> list[str] | None: """A right-hand side as values; None for one this does not model.""" if isinstance(value, bool): return None @@ -442,9 +448,9 @@ def _nft_scalar(value: Any) -> Optional[List[str]]: return None -def _nft_values(items: Any) -> Optional[List[str]]: +def _nft_values(items: Any) -> list[str] | None: items = items if isinstance(items, list) else [items] - out: List[str] = [] + out: list[str] = [] for item in items: values = _nft_scalar(item) if values is None: @@ -476,7 +482,7 @@ def _right_text(right: Any) -> str: return values[0] if len(values) == 1 else "{" + ", ".join(values) + "}" -def _nft_field(left: Any) -> Tuple[Optional[str], Optional[str]]: +def _nft_field(left: Any) -> tuple[str | None, str | None]: """``(field, implied l4proto)`` of a left-hand side; ``(None, None)`` if not modelled.""" if not isinstance(left, dict): return None, None @@ -509,8 +515,8 @@ def _nft_field(left: Any) -> Tuple[Optional[str], Optional[str]]: def _nft_match( - match: Dict[str, Any], sets: Dict[Tuple[str, str, str], List[str]], where: Tuple[str, str] -) -> List[NetfilterMatchDict]: + match: dict[str, Any], sets: dict[tuple[str, str, str], list[str]], where: tuple[str, str] +) -> list[NetfilterMatchDict]: left, op, right = match.get("left"), match.get("op"), match.get("right") field, implied = _nft_field(left) raw_text = ( @@ -524,7 +530,7 @@ def _nft_match( values = _nft_scalar(right) if values is None: return [_unknown(raw_text)] - out: List[NetfilterMatchDict] = [] + out: list[NetfilterMatchDict] = [] if implied: out.append(_match("l4proto", False, [implied])) if field in ("iifname", "oifname", "l4proto", "ct_state", "nfproto"): @@ -536,10 +542,10 @@ def _nft_match( def _nft_rule( - rule: Dict[str, Any], sets: Dict[Tuple[str, str, str], List[str]] + rule: dict[str, Any], sets: dict[tuple[str, str, str], list[str]] ) -> NetfilterRuleDict: where = (rule.get("family", ""), rule.get("table", "")) - matches: List[NetfilterMatchDict] = [] + matches: list[NetfilterMatchDict] = [] verdict, target = "continue", None for statement in rule.get("expr") or []: if not isinstance(statement, dict) or not statement: @@ -576,19 +582,19 @@ def _nft_rule( return entry -def _parse_nft(text: str, skip: Set[Tuple[str, str]]) -> List[NetfilterChainDict]: +def _parse_nft(text: str, skip: set[tuple[str, str]]) -> list[NetfilterChainDict]: start = text.find("{") if start < 0: raise ValueError("nft printed no JSON") objects = json.loads(text[start:]).get("nftables") or [] - sets: Dict[Tuple[str, str, str], List[str]] = {} + sets: dict[tuple[str, str, str], list[str]] = {} for obj in objects: if "set" in obj and "elem" in obj["set"]: values = _nft_values(obj["set"]["elem"]) if values is not None: s = obj["set"] sets[(s.get("family", ""), s.get("table", ""), s.get("name", ""))] = values - chains: Dict[Tuple[str, str, str], NetfilterChainDict] = {} + chains: dict[tuple[str, str, str], NetfilterChainDict] = {} for obj in objects: if "chain" not in obj: continue @@ -618,7 +624,7 @@ def _parse_nft(text: str, skip: Set[Tuple[str, str]]) -> List[NetfilterChainDict # ── text ───────────────────────────────────────────────────────────────────── -def _family_of(values: List[str]) -> str: +def _family_of(values: list[str]) -> str: try: return ( "ip6" @@ -629,7 +635,7 @@ def _family_of(values: List[str]) -> str: return "ip" -def _values_text(values: List[str]) -> str: +def _values_text(values: list[str]) -> str: return values[0] if len(values) == 1 else "{" + ", ".join(values) + "}" @@ -641,7 +647,7 @@ def rule_text(rule: NetfilterRuleDict) -> str: if m["field"] == "l4proto" and m["op"] == "==" for v in m["values"] ] - parts: List[str] = [] + parts: list[str] = [] for m in rule["matches"]: neg = "!= " if m["op"] == "!=" else "" field, values = m["field"], m["values"] @@ -673,8 +679,8 @@ def rule_text(rule: NetfilterRuleDict) -> str: def _iptables_sections( - sections: Dict[str, Tuple[List[str], Optional[int]]], -) -> Iterator[Tuple[str, str, List[str]]]: + sections: dict[str, tuple[list[str], int | None]], +) -> Iterator[tuple[str, str, list[str]]]: """``(variant, family, lines)`` for each iptables tool that saved something.""" for tool, family in _IPTABLES_FAMILY.items(): lines, rc = sections.get(tool, ([], None)) @@ -692,9 +698,9 @@ def parse_host_firewall(output: str) -> HostFirewallDict: :raises HostFirewallUnavailable: if neither tool could be read. """ sections = _sections(_frame(output)) - sources: List[str] = [] - chains: List[NetfilterChainDict] = [] - covered: Set[Tuple[str, str]] = set() + sources: list[str] = [] + chains: list[NetfilterChainDict] = [] + covered: set[tuple[str, str]] = set() for variant, family, lines in _iptables_sections(sections): saved = _parse_iptables_save(lines, family) chains += saved diff --git a/napalm_device_types/host_status.py b/napalm_device_types/host_status.py index ea257fa..68307fe 100644 --- a/napalm_device_types/host_status.py +++ b/napalm_device_types/host_status.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Host status: does the host need a reboot, and does it patch itself? A patch run that installed a new kernel or libc has not closed anything until @@ -59,7 +58,7 @@ too full is not decided here. from __future__ import annotations import re -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import ( FilesystemUsageDict, @@ -110,7 +109,7 @@ HOST_STATUS_COMMAND = ( "apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade '; " # Named through a variable: spelt out, the program name would match BUSY_CHECK # in this very command line. - "uu=unattended-upgrade; { command -v \"$uu\" || [ -x \"/usr/bin/$uu\" ]; } >/dev/null 2>&1 " + 'uu=unattended-upgrade; { command -v "$uu" || [ -x "/usr/bin/$uu" ]; } >/dev/null 2>&1 ' "&& echo '[unattended-upgrade]'; " f"echo '[upgrade-stamp]'; stat -c %Y {_UPGRADE_STAMP} 2>/dev/null; fi; " "if command -v dpkg-query >/dev/null 2>&1; then " @@ -147,15 +146,15 @@ _HALFWAY = { } -def _sections(output: str) -> Dict[str, List[str]]: +def _sections(output: str) -> dict[str, list[str]]: lines = [line.strip() for line in strip_terminal_codes(output).splitlines()] try: start = lines.index(_BEGIN) end = lines.index(_END, start) except ValueError: raise ValueError("no intact host status report in the output") from None - sections: Dict[str, List[str]] = {} - current: List[str] = [] + sections: dict[str, list[str]] = {} + current: list[str] = [] for line in lines[start + 1 : end]: if line.startswith("[") and line.endswith("]"): current = sections.setdefault(line[1:-1], []) @@ -164,12 +163,12 @@ def _sections(output: str) -> Dict[str, List[str]]: return sections -def _version_key(version: str) -> Tuple[object, ...]: +def _version_key(version: str) -> tuple[object, ...]: """Natural order: 6.8.0-142 after 6.8.0-87, 7.0.14 after 7.0.2.""" return tuple(int(part) if part.isdigit() else part for part in re.split(r"(\d+)", version)) -def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]: +def kernel_reboot_pending(running: str, installed: list[str]) -> str | None: """The newest installed kernel of the running flavour, if it is newer than the running one; otherwise None. @@ -187,7 +186,7 @@ def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]: return None -def _kernel_flavour(release: str) -> Tuple[str, str]: +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("-") @@ -199,7 +198,7 @@ def _kernel_flavour(release: str) -> Tuple[str, str]: return base, tail -def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]: +def _freebsd_reboot(sections: dict[str, list[str]]) -> tuple[bool, str | None] | None: """FreeBSD's answer from freebsd-version, or None on any other system.""" installed = (sections.get("freebsd-kernel") or [""])[0] running = (sections.get("freebsd-running") or [""])[0] @@ -210,7 +209,7 @@ def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Opti return False, None -def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[str]]: +def _reboot(sections: dict[str, list[str]]) -> tuple[bool | None, str | None]: if "reboot-required" in sections: return True, f"{_REBOOT_FILE} is present" needs = sections.get("needs-restarting") @@ -229,29 +228,29 @@ def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[st return None, None -def _timer_states(sections: Dict[str, List[str]]) -> Dict[str, str]: - states: Dict[str, str] = {} +def _timer_states(sections: dict[str, list[str]]) -> dict[str, str]: + states: dict[str, str] = {} for line in sections.get("timers") or []: unit, _, state = line.partition(" ") states[unit] = state.strip() return states -def _periodic(sections: Dict[str, List[str]]) -> Optional[str]: +def _periodic(sections: dict[str, list[str]]) -> str | None: """The value of ``APT::Periodic::Unattended-Upgrade``, or None when it is not set.""" lines = sections.get("apt-config") or [] match = next(filter(None, (_PERIODIC.match(line) for line in lines)), None) return match.group(1) if match else None -def _dnf_applies(sections: Dict[str, List[str]]) -> bool: +def _dnf_applies(sections: dict[str, list[str]]) -> bool: """Whether dnf-automatic.timer installs what it downloads; dnf's default is no.""" lines = sections.get("dnf-automatic") or [] values = [m.group(1) for m in map(_APPLY_UPDATES.match, lines) if m] return bool(values) and values[-1].lower() in _TRUE -def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]: +def _auto_updates(sections: dict[str, list[str]]) -> bool | None: timers = _timer_states(sections) if timers.get(_DNF_INSTALL_TIMER) == "enabled": return True @@ -265,7 +264,7 @@ def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]: return switched_on and installed and timers.get(_APT_TIMER) not in _OFF_STATES -def _interval(sections: Dict[str, List[str]]) -> Optional[int]: +def _interval(sections: dict[str, list[str]]) -> int | None: """apt's interval in seconds, the way ``apt.systemd.daily`` reads it; 0 for "always".""" value = _periodic(sections) if value == "always": @@ -276,19 +275,19 @@ def _interval(sections: Dict[str, List[str]]) -> Optional[int]: return int(match.group(1)) * _UNIT_SECONDS[match.group(2)] -def _last_success(sections: Dict[str, List[str]]) -> Optional[int]: +def _last_success(sections: dict[str, list[str]]) -> int | None: stamp = (sections.get("upgrade-stamp") or [""])[0] return int(stamp) if stamp.isdigit() else None -def _halfway(line: str) -> Optional[InterruptedPackageDict]: +def _halfway(line: str) -> InterruptedPackageDict | None: status, _, name = line.partition(" ") name = name.strip() state = "reinstall-required" if status[2:3] == "R" else _HALFWAY.get(status[1:2]) return {"name": name, "state": state} if name and state else None -def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[InterruptedPackageDict]]: +def _dpkg(sections: dict[str, list[str]]) -> tuple[bool | None, list[InterruptedPackageDict]]: if "dpkg-audit" not in sections or "dpkg-busy" in sections: return None, [] packages = [p for p in map(_halfway, sections["dpkg-audit"]) if p is not None] @@ -296,9 +295,9 @@ def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[Interrup return bool(packages) or journal, packages -def _filesystems(sections: Dict[str, List[str]]) -> Optional[List[FilesystemUsageDict]]: +def _filesystems(sections: dict[str, list[str]]) -> list[FilesystemUsageDict] | None: """Each filesystem df named, once and in its order; None when it named none.""" - found: Dict[str, FilesystemUsageDict] = {} + found: dict[str, FilesystemUsageDict] = {} for line in sections.get("filesystems") or []: match = _DF_LINE.match(line) if not match or int(match.group(1)) == 0: diff --git a/napalm_device_types/hypervisor.py b/napalm_device_types/hypervisor.py index eb42575..5406f1d 100644 --- a/napalm_device_types/hypervisor.py +++ b/napalm_device_types/hypervisor.py @@ -10,25 +10,26 @@ Usage:: ... """ -from typing import Any, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Any + from napalm_device_types.base import DeviceTypeDriver -from napalm_device_types.packages import PackageManagementMixin from napalm_device_types.health_metrics import HealthMetricsMixin -from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents from napalm_device_types.models import ( HypervisorHostDict, - NICConfigDict, NetworkTargetDict, + NICConfigDict, SnapshotDict, StorageTargetDict, StorageVolumeDict, + VirtualNetworkDict, VMConfigDict, VMCpuTypeDict, VMDict, VMProvisionResultDict, VMStatusDict, - VirtualNetworkDict, ) +from napalm_device_types.packages import PackageManagementMixin +from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver): @@ -52,14 +53,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri #: the driver can provision; netOrk reads this off the class. GUEST_AGENTS: GuestAgents = {"linux": QEMU_GUEST_AGENTS["linux"]} - # ------------------------------------------------------------------ # Virtual machines – read # ------------------------------------------------------------------ if TYPE_CHECKING: - def get_vms(self) -> List[VMDict]: + def get_vms(self) -> list[VMDict]: """ Returns a list of all virtual machines known to this hypervisor, including their runtime status. @@ -262,7 +262,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri # Snapshots # ------------------------------------------------------------------ - def get_vm_snapshots(self, name: str) -> List[SnapshotDict]: + def get_vm_snapshots(self, name: str) -> list[SnapshotDict]: """ Returns all snapshots of a virtual machine. @@ -301,8 +301,9 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri """ ... - def create_vm_snapshot(self, name: str, snapshot: str, - description: str = "", include_memory: bool = False) -> None: + def create_vm_snapshot( + self, name: str, snapshot: str, description: str = "", include_memory: bool = False + ) -> None: """ Creates a snapshot of a virtual machine. @@ -329,7 +330,8 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri :param name: VM name or its ``vmid``. :param snapshot: Name of the snapshot to delete. :raises ValueError: If the VM or snapshot does not exist. - :raises RuntimeError: If other snapshots depend on this one (must delete children first). + :raises RuntimeError: If other snapshots depend on this one + (must delete children first). Example:: @@ -359,7 +361,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri # Storage # ------------------------------------------------------------------ - def get_vm_storage_pools(self) -> Dict[str, StorageVolumeDict]: + def get_vm_storage_pools(self) -> dict[str, StorageVolumeDict]: """ Returns the storage pools / datastores configured on this hypervisor. @@ -403,7 +405,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri # Virtual networking # ------------------------------------------------------------------ - def get_virtual_networks(self) -> Dict[str, VirtualNetworkDict]: + def get_virtual_networks(self) -> dict[str, VirtualNetworkDict]: """ Returns the virtual networks / bridges defined on this hypervisor. @@ -443,11 +445,6 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri # Package management (hypervisor extensions / plugins) # ------------------------------------------------------------------ - - - - - # ------------------------------------------------------------------ # Virtual machine provisioning # ------------------------------------------------------------------ @@ -459,10 +456,10 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri image_url: str, cpu: int, memory: int, - nics: List[NICConfigDict], - cloud_init_config: Dict[str, Any], + nics: list[NICConfigDict], + cloud_init_config: dict[str, Any], image_checksum: str | None = None, - ssh_public_keys: List[str] | None = None, + ssh_public_keys: list[str] | None = None, disk_resize_gb: int | None = None, storage: str | None = None, cpu_type: str | None = None, @@ -602,7 +599,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri """ ... - def get_network_targets(self) -> List[NetworkTargetDict]: + def get_network_targets(self) -> list[NetworkTargetDict]: """ List the network targets a new VM's NIC may attach to. @@ -620,7 +617,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri """ ... - def get_image_storages(self) -> List[StorageTargetDict]: + def get_image_storages(self) -> list[StorageTargetDict]: """ List the storage pools a new VM's root disk may be placed on, scoped to the specific node the VM will be created on. @@ -638,13 +635,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri """ ... - def get_hypervisor_hosts(self) -> List[HypervisorHostDict]: + def get_hypervisor_hosts(self) -> list[HypervisorHostDict]: """The hosts this hypervisor or its manager runs, and how each stands: connected, in maintenance, with configuration issues, under a central manager. Facts; what is a problem is the caller's to decide.""" ... - def get_vm_cpu_types(self) -> List[VMCpuTypeDict]: + def get_vm_cpu_types(self) -> list[VMCpuTypeDict]: """ List the virtual CPU models a new VM may be given on the node it will be created on. diff --git a/napalm_device_types/interface_filter.py b/napalm_device_types/interface_filter.py index 4360e58..45599d7 100644 --- a/napalm_device_types/interface_filter.py +++ b/napalm_device_types/interface_filter.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Dropping interfaces that are noise rather than signal. An access point exposes radio PHYs and a loopback alongside its real @@ -9,19 +8,19 @@ attributes are the customisation point. from __future__ import annotations -from typing import Any, ClassVar, Dict, FrozenSet, Tuple +from typing import Any, ClassVar class InterfaceFilterMixin: """Adds :meth:`_filter_interfaces` for drivers that report raw interface dicts.""" #: Interface names dropped outright. - _EXCLUDED_INTERFACES: ClassVar[FrozenSet[str]] = frozenset({"lo"}) + _EXCLUDED_INTERFACES: ClassVar[frozenset[str]] = frozenset({"lo"}) #: Name prefixes dropped -- ``phy*`` are radio devices, not links. - _EXCLUDED_INTERFACE_PREFIXES: ClassVar[Tuple[str, ...]] = ("phy",) + _EXCLUDED_INTERFACE_PREFIXES: ClassVar[tuple[str, ...]] = ("phy",) - def _filter_interfaces(self, interfaces: Dict[str, Any]) -> Dict[str, Any]: + def _filter_interfaces(self, interfaces: dict[str, Any]) -> dict[str, Any]: """Remove loopback and radio-device (phy*) interfaces from an interface dict.""" return { name: data diff --git a/napalm_device_types/kernel.py b/napalm_device_types/kernel.py index 463c2c4..629ddab 100644 --- a/napalm_device_types/kernel.py +++ b/napalm_device_types/kernel.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """What the running kernel has built and loaded. A kernel CVE's exploitability often hangs on code that is simply not there -- @@ -25,7 +24,7 @@ import base64 import binascii import gzip import zlib -from typing import Dict, List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import KernelFactsDict @@ -42,7 +41,8 @@ KERNEL_FACTS_COMMAND = ( "if [ -r $m/modules.builtin ]; then echo '[builtin]'; cat $m/modules.builtin; fi; " "if [ -r $m/modules.dep ]; then echo '[available]'; cut -d: -f1 $m/modules.dep; fi; " "if [ -r /boot/config-$r ]; then echo '[config]'; grep '^CONFIG_' /boot/config-$r; " - "elif [ -r /proc/config.gz ]; then echo '[config]'; zcat /proc/config.gz | grep '^CONFIG_'; fi; " + "elif [ -r /proc/config.gz ]; then echo '[config]'; " + "zcat /proc/config.gz | grep '^CONFIG_'; fi; " "} 2>/dev/null | gzip -c | base64; " "printf '%s%s\\n' KFACTS_ END" ) @@ -78,9 +78,9 @@ def _report(output: str) -> str: return report -def _sections(report: str) -> Dict[str, List[str]]: - sections: Dict[str, List[str]] = {} - current: Optional[List[str]] = None +def _sections(report: str) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current: list[str] | None = None for line in report.splitlines(): line = line.strip() if line.startswith("[") and line.endswith("]"): @@ -90,8 +90,8 @@ def _sections(report: str) -> Dict[str, List[str]]: return sections -def _config(lines: List[str]) -> Dict[str, str]: - config: Dict[str, str] = {} +def _config(lines: list[str]) -> dict[str, str]: + config: dict[str, str] = {} for line in lines: option, sep, value = line.partition("=") if not sep: @@ -112,7 +112,7 @@ def parse_kernel_facts(output: str) -> KernelFactsDict: """ sections = _sections(_report(output)) - def names(key: str) -> Optional[List[str]]: + def names(key: str) -> list[str] | None: if key not in sections: return None return sorted({module_name(line) for line in sections[key]}) diff --git a/napalm_device_types/lag.py b/napalm_device_types/lag.py index 1d715d0..3252db2 100644 --- a/napalm_device_types/lag.py +++ b/napalm_device_types/lag.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Logical LAG entries for ``get_interfaces()``, built from their member ports. Some switches list only physical ports, each tagged with the trunk it belongs @@ -10,17 +9,17 @@ the same for every vendor, so it lives here once; a driver only has to set from __future__ import annotations import re -from typing import Any, Dict, List, Optional +from typing import Any -def _port_order(name: str) -> List[Any]: +def _port_order(name: str) -> list[Any]: return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)] def add_lag_interfaces( - interfaces: Dict[str, Dict[str, Any]], - lag_modes: Optional[Dict[str, str]] = None, -) -> Dict[str, Dict[str, Any]]: + interfaces: dict[str, dict[str, Any]], + lag_modes: dict[str, str] | None = None, +) -> dict[str, dict[str, Any]]: """Return *interfaces* plus one logical entry per ``trunk_group``. The LAG entry is up/enabled if any member is, its speed is the members' @@ -31,7 +30,7 @@ def add_lag_interfaces( mode gets no ``lag_mode`` key rather than a guessed one. """ result = dict(interfaces) - groups: Dict[str, List[str]] = {} + groups: dict[str, list[str]] = {} for name, iface in interfaces.items(): group = iface.get("trunk_group") if group: @@ -41,7 +40,7 @@ def add_lag_interfaces( if group in result: continue members = sorted(members, key=_port_order) - lag: Dict[str, Any] = { + lag: dict[str, Any] = { "is_up": any(interfaces[m].get("is_up") for m in members), "is_enabled": any(interfaces[m].get("is_enabled") for m in members), "description": f"LAG ({', '.join(members)})", diff --git a/napalm_device_types/licenses.py b/napalm_device_types/licenses.py index c5a6b83..ceeb1ea 100644 --- a/napalm_device_types/licenses.py +++ b/napalm_device_types/licenses.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Licenses and subscriptions a device reports. A fact for the caller (docs/STRUCTURE.md): whether a free edition, a missing @@ -8,7 +7,7 @@ driver. Declared only; a driver that can read licenses implements it. from __future__ import annotations -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import LicenseDict @@ -16,7 +15,7 @@ from napalm_device_types.models import LicenseDict class LicenseMixin: if TYPE_CHECKING: # pragma: no cover - declared for type checkers only - def get_licenses(self) -> List[LicenseDict]: + def get_licenses(self) -> list[LicenseDict]: """Every license or subscription the device knows of, with its status. Example:: diff --git a/napalm_device_types/listening.py b/napalm_device_types/listening.py index 73a625e..a622bd9 100644 --- a/napalm_device_types/listening.py +++ b/napalm_device_types/listening.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Listening sockets: what listens on which address, and which service it is. Whether a service is reachable from outside its host is decided by what it @@ -34,7 +33,7 @@ from __future__ import annotations import re from shlex import quote -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict from napalm_device_types.terminal import strip_terminal_codes @@ -58,11 +57,11 @@ LISTENING_SOCKETS_COMMAND = ( "t=netstat; s=$(netstat -lntup 2>&1); r=$?; " "pids=$(printf '%s\\n' \"$s\" | grep -o ' [0-9][0-9]*/' | tr -d ' /'); " "else t=; fi; " - "if [ -n \"$t\" ]; then " - "echo \"[$t]\"; printf '%s\\n' \"$s\"; echo \"__SS_RC=$r\"; " + 'if [ -n "$t" ]; then ' + 'echo "[$t]"; printf \'%s\\n\' "$s"; echo "__SS_RC=$r"; ' "echo '[cgroups]'; " "for p in $(printf '%s\\n' \"$pids\" | sort -u); do " - "sed \"s|^|$p |\" /proc/$p/cgroup 2>/dev/null; done; " + 'sed "s|^|$p |" /proc/$p/cgroup 2>/dev/null; done; ' "else echo '[no-ss]'; fi; " "printf '%s%s\\n' SOCK_ END" ) @@ -88,7 +87,7 @@ class ListeningSocketsUnavailable(NotImplementedError): """The host has neither ``ss`` nor ``netstat``; nothing to read, nothing to retry.""" -def _frame(output: str) -> List[str]: +def _frame(output: str) -> list[str]: lines = [line.strip() for line in strip_terminal_codes(output).splitlines()] try: start = lines.index(_BEGIN) @@ -98,9 +97,9 @@ def _frame(output: str) -> List[str]: return lines[start + 1 : end] -def _sections(lines: List[str]) -> Dict[str, List[str]]: - sections: Dict[str, List[str]] = {} - current: List[str] = [] +def _sections(lines: list[str]) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current: list[str] = [] for line in lines: if line.startswith("[") and line.endswith("]") and " " not in line: current = sections.setdefault(line[1:-1], []) @@ -109,7 +108,7 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]: return sections -def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]: +def _split_local(local: str) -> tuple[str, str | None, int] | None: """``[fe80::1]%eth0:546`` (or ``[fe80::1%eth0]:546``) -> ``("fe80::1", "eth0", 546)``; None if no port.""" host, sep, port = local.rpartition(":") @@ -122,9 +121,9 @@ def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]: return address or "*", zone or None, int(port) -def _cgroup_paths(lines: List[str]) -> Dict[int, str]: +def _cgroup_paths(lines: list[str]) -> dict[int, str]: """Each process's cgroup path: the unified hierarchy, or systemd's under v1.""" - paths: Dict[int, str] = {} + paths: dict[int, str] = {} for line in lines: pid, _, entry = line.partition(" ") parts = entry.split(":", 2) @@ -136,7 +135,7 @@ def _cgroup_paths(lines: List[str]) -> Dict[int, str]: return paths -def _unit(path: Optional[str]) -> Optional[str]: +def _unit(path: str | None) -> str | None: services = _SERVICE_RE.findall(path or "") if services: return str(services[-1]) @@ -144,17 +143,17 @@ def _unit(path: Optional[str]) -> Optional[str]: return str(procd.group(1)) if procd else None -def _container(path: Optional[str]) -> Optional[str]: +def _container(path: str | None) -> str | None: match = _CONTAINER_RE.search(path or "") return (match.group(1) or match.group(2)) if match else None def _entry( proto: str, - local: Tuple[str, Optional[str], int], - process: Optional[str], - pid: Optional[int], - paths: Dict[int, str], + local: tuple[str, str | None, int], + process: str | None, + pid: int | None, + paths: dict[int, str], ) -> ListeningSocketDict: address, interface, port = local path = paths.get(pid) if pid is not None else None @@ -170,7 +169,7 @@ def _entry( } -def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]: +def _ss_socket(line: str, paths: dict[int, str]) -> ListeningSocketDict | None: parts = line.split() if len(parts) < 5 or parts[0] not in _PROTOCOLS: return None @@ -182,7 +181,7 @@ def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict return _entry(parts[0], local, process, pid, paths) -def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]: +def _netstat_socket(line: str, paths: dict[int, str]) -> ListeningSocketDict | None: """``Proto Recv-Q Send-Q Local Foreign [State] PID/Program`` -- a UDP line has no state, and ``-`` is a socket without a process.""" parts = line.split() @@ -204,7 +203,7 @@ def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocke _PARSERS = {"ss": _ss_socket, "netstat": _netstat_socket} -def parse_listening_sockets(output: str) -> List[ListeningSocketDict]: +def parse_listening_sockets(output: str) -> list[ListeningSocketDict]: """Parse what :data:`LISTENING_SOCKETS_COMMAND` printed, sorted by protocol, port and address. @@ -223,7 +222,9 @@ def parse_listening_sockets(output: str) -> List[ListeningSocketDict]: paths = _cgroup_paths(sections.get("cgroups", [])) parse = _PARSERS[tool] sockets = [s for s in (parse(line, paths) for line in lines) if s is not None] - return sorted(sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")) + return sorted( + sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "") + ) class ListeningSocketsMixin: diff --git a/napalm_device_types/mac_acl.py b/napalm_device_types/mac_acl.py index a84e3cb..b1d20f9 100644 --- a/napalm_device_types/mac_acl.py +++ b/napalm_device_types/mac_acl.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """MAC-based access control, per SSID on an AP and per port on a switch. The reader is identical either way; only the writer is AP-specific so far. @@ -10,7 +9,7 @@ this class in can never shadow a working implementation from a sibling base. from __future__ import annotations -from typing import Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import MACACLDict @@ -18,7 +17,7 @@ from napalm_device_types.models import MACACLDict class MacAclMixin: if TYPE_CHECKING: - def get_mac_acl(self) -> Dict[str, MACACLDict]: + def get_mac_acl(self) -> dict[str, MACACLDict]: """ Returns the MAC-address-based access control lists configured per SSID. @@ -44,22 +43,25 @@ class MacAclMixin: "name": "CorpWiFi", "policy": "allow", "entries": [ - {"mac": "AA:BB:CC:DD:EE:01", "action": "allow", "description": "CEO-Laptop"}, - {"mac": "AA:BB:CC:DD:EE:02", "action": "allow", "description": "CFO-Laptop"}, + {"mac": "AA:BB:CC:DD:EE:01", "action": "allow", + "description": "CEO-Laptop"}, + {"mac": "AA:BB:CC:DD:EE:02", "action": "allow", + "description": "CFO-Laptop"}, ], }, "GuestNet": { "name": "GuestNet", "policy": "deny", "entries": [ - {"mac": "DE:AD:BE:EF:00:01", "action": "deny", "description": "blocked device"}, + {"mac": "DE:AD:BE:EF:00:01", "action": "deny", + "description": "blocked device"}, ], }, } """ ... - def push_mac_acl(self, ssid_name: str, mode: str, macs: List[str]) -> None: + def push_mac_acl(self, ssid_name: str, mode: str, macs: list[str]) -> None: """ Rewrites the MAC-address access control list for a single SSID. @@ -73,7 +75,8 @@ class MacAclMixin: Example:: - driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"]) + macs = ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"] + driver.push_mac_acl("CorpWiFi", "whitelist", macs) driver.push_mac_acl("GuestNet", "off", []) """ ... diff --git a/napalm_device_types/management_controller.py b/napalm_device_types/management_controller.py index a1d6df4..e9a9de5 100644 --- a/napalm_device_types/management_controller.py +++ b/napalm_device_types/management_controller.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Abstract base class for server baseboard management controllers (BMCs). @@ -10,7 +9,7 @@ Usage:: ... """ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from napalm_device_types.base import DeviceTypeDriver @@ -107,7 +106,7 @@ class ManagementControllerDriver(DeviceTypeDriver): """ ... - def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]: + def get_event_log(self, limit: int = 500) -> list[EventLogEntryDict]: """ Returns the BMC's system event log (SEL, Lifecycle Log, IML), newest first. @@ -116,7 +115,7 @@ class ManagementControllerDriver(DeviceTypeDriver): """ ... - def get_firmware_inventory(self) -> List[FirmwareDict]: + def get_firmware_inventory(self) -> list[FirmwareDict]: """ Returns every firmware component the BMC reports: its own, the BIOS, storage controllers, NICs, power supplies. diff --git a/napalm_device_types/media.py b/napalm_device_types/media.py index 20c52f9..ce0882e 100644 --- a/napalm_device_types/media.py +++ b/napalm_device_types/media.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Abstract base class for networked media players. @@ -10,7 +9,7 @@ Usage:: ... """ -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any from napalm_device_types.base import DeviceTypeDriver @@ -34,7 +33,7 @@ class MediaDriver(DeviceTypeDriver): if TYPE_CHECKING: - def get_playback_state(self) -> Dict[str, Any]: + def get_playback_state(self) -> dict[str, Any]: """ Returns the transport state of the renderer. @@ -51,7 +50,7 @@ class MediaDriver(DeviceTypeDriver): """Sets the output volume, 0-100.""" ... - def get_zone_info(self) -> List[Dict[str, Any]]: + def get_zone_info(self) -> list[dict[str, Any]]: """ Returns the zones or groups this renderer participates in. diff --git a/napalm_device_types/models.py b/napalm_device_types/models.py index 9ec1641..6b4effc 100644 --- a/napalm_device_types/models.py +++ b/napalm_device_types/models.py @@ -5,9 +5,9 @@ These supplement the types defined in napalm.base.models and are used by the abstract device-type driver classes in this package. """ -from typing import Dict, List, Literal, Optional, Tuple, get_args -from typing_extensions import NotRequired, TypedDict +from typing import Literal, get_args +from typing_extensions import NotRequired, TypedDict # --------------------------------------------------------------------------- # Common (shared across device types) @@ -36,7 +36,7 @@ class MACACLDict(TypedDict): name: str policy: str - entries: List[MACACLEntryDict] + entries: list[MACACLEntryDict] class PackageDict(TypedDict): @@ -70,9 +70,9 @@ class UpdateDict(TypedDict): current_version: str new_version: str #: Where the new version comes from, e.g. apt's suites "noble-updates,noble-security". - origin: NotRequired[Optional[str]] + origin: NotRequired[str | None] #: True for a security update, False for a known other one, None when unknown. - security: NotRequired[Optional[bool]] + security: NotRequired[bool | None] class InterruptedPackageDict(TypedDict): @@ -100,24 +100,24 @@ class HostStatusDict(TypedDict): """What a host says about its own patch state (``HostStatusMixin.get_host_status``).""" #: True when the host needs a reboot to finish an update, None when it cannot tell. - reboot_required: Optional[bool] + reboot_required: bool | None #: Why, e.g. "kernel 6.8.0-142-generic installed, 6.8.0-139-generic running". - reboot_reason: Optional[str] + reboot_reason: str | None #: True when the host installs updates on its own (unattended-upgrades, dnf-automatic). - auto_updates: Optional[bool] + auto_updates: bool | None #: True when a package manager run was cut off and has to be finished #: (``dpkg --configure -a``) before anything else installs; None when it #: cannot tell, or a package manager is running right now. - package_manager_interrupted: NotRequired[Optional[bool]] + package_manager_interrupted: NotRequired[bool | None] #: The packages that run left halfway; empty when only its journal says so. - interrupted_packages: NotRequired[List[InterruptedPackageDict]] + interrupted_packages: NotRequired[list[InterruptedPackageDict]] #: When the host's own automatic updates last ran successfully (Unix time). - auto_updates_last_success: NotRequired[Optional[int]] + auto_updates_last_success: NotRequired[int | None] #: How often they are meant to run, in seconds; 0 for every time the timer fires. - auto_updates_interval: NotRequired[Optional[int]] + auto_updates_interval: NotRequired[int | None] #: How full ``/``, ``/boot`` and ``/var`` are, each filesystem once; None when #: it could not be read. - filesystems: NotRequired[Optional[List[FilesystemUsageDict]]] + filesystems: NotRequired[list[FilesystemUsageDict] | None] # --------------------------------------------------------------------------- @@ -163,11 +163,11 @@ class ChannelScanEntryDict(TypedDict): bssid: str ssid: str - frequency: int # centre frequency in MHz, e.g. 2437 - channel: int # channel number, e.g. 6 - signal_dbm: int # RSSI in dBm, e.g. -72 + frequency: int # centre frequency in MHz, e.g. 2437 + channel: int # channel number, e.g. 6 + signal_dbm: int # RSSI in dBm, e.g. -72 channel_width: int # MHz: 20 / 40 / 80 / 160; 0 if unknown - band: str # "2.4GHz" | "5GHz" | "6GHz" + band: str # "2.4GHz" | "5GHz" | "6GHz" class WirelessConfigDict(TypedDict): @@ -226,7 +226,7 @@ class Dot1XConfigDict(TypedDict): enabled: bool ssid: str auth_server: RadiusServerDict - acct_server: Optional[RadiusServerDict] + acct_server: RadiusServerDict | None reauth_interval: int pmksa_caching: bool @@ -263,7 +263,7 @@ class InterfaceConfigDict(TypedDict, total=False): mode: str access_vlan: int voice_vlan: int - trunk_vlans: List[int] + trunk_vlans: list[int] native_vlan: int @@ -280,7 +280,7 @@ class VlanConfigDict(TypedDict, total=False): name: str active: bool - interfaces: List[str] + interfaces: list[str] class STPInterfaceDict(TypedDict): @@ -297,11 +297,11 @@ class SpanningTreeDict(TypedDict): root_priority: int bridge_id: str bridge_priority: int - interfaces: Dict[str, STPInterfaceDict] + interfaces: dict[str, STPInterfaceDict] class PortChannelDict(TypedDict): - members: List[str] + members: list[str] protocol: str min_links: int is_up: bool @@ -314,7 +314,7 @@ class Dot1XPortDict(TypedDict): port_control: str host_mode: str auth_server: RadiusServerDict - acct_server: Optional[RadiusServerDict] + acct_server: RadiusServerDict | None reauthentication: bool reauth_interval: int guest_vlan: int @@ -334,7 +334,7 @@ class PoEPortDict(TypedDict): class PoESummaryDict(TypedDict): total_power_budget: float total_power_draw: float - ports: Dict[str, PoEPortDict] + ports: dict[str, PoEPortDict] # --------------------------------------------------------------------------- @@ -363,10 +363,10 @@ class KernelFactsDict(TypedDict): """ release: str # uname -r - loaded: Optional[List[str]] # /proc/modules - builtin: Optional[List[str]] # modules.builtin -- compiled into the kernel image - available: Optional[List[str]] # modules.dep -- shipped as loadable modules - config: Optional[Dict[str, str]] # build configuration, set options only; quotes stripped + loaded: list[str] | None # /proc/modules + builtin: list[str] | None # modules.builtin -- compiled into the kernel image + available: list[str] | None # modules.dep -- shipped as loadable modules + config: dict[str, str] | None # build configuration, set options only; quotes stripped class ListeningSocketDict(TypedDict): @@ -381,11 +381,11 @@ class ListeningSocketDict(TypedDict): proto: str # "tcp" or "udp" address: str # "0.0.0.0", "::", "*", "127.0.0.1", "::ffff:127.0.0.1", ... port: int - interface: Optional[str] # the %zone a socket is bound to ("lo", "eth0"), if any - process: Optional[str] # the first process holding it; None without one or without root - pid: Optional[int] - unit: Optional[str] # the process's systemd service, without ".service" - container_id: Optional[str] # the full container ID, for a container on the host network + interface: str | None # the %zone a socket is bound to ("lo", "eth0"), if any + process: str | None # the first process holding it; None without one or without root + pid: int | None + unit: str | None # the process's systemd service, without ".service" + container_id: str | None # the full container ID, for a container on the host network class ListeningSocketsDict(TypedDict): @@ -397,7 +397,7 @@ class ListeningSocketsDict(TypedDict): """ attributed: bool - sockets: List[ListeningSocketDict] + sockets: list[ListeningSocketDict] class NetfilterMatchDict(TypedDict): @@ -413,7 +413,7 @@ class NetfilterMatchDict(TypedDict): field: str op: str # "==" or "!=" - values: List[str] + values: list[str] class NetfilterRuleDict(TypedDict): @@ -424,9 +424,9 @@ class NetfilterRuleDict(TypedDict): marks), or ``other`` -- NAT, a queue, a verdict map -- with ``target`` naming it. """ - matches: List[NetfilterMatchDict] + matches: list[NetfilterMatchDict] verdict: str - target: Optional[str] + target: str | None text: str # the rule as iptables-save printed it, or as one nft-style line @@ -437,11 +437,11 @@ class NetfilterChainDict(TypedDict): family: str # "ip", "ip6", "inet", "bridge", "arp", "netdev" table: str name: str - type: Optional[str] # "filter", "nat", "route" - hook: Optional[str] # "prerouting", "input", "forward", "output", "postrouting", ... - priority: Optional[int] - policy: Optional[str] # "accept" or "drop" - rules: List[NetfilterRuleDict] + type: str | None # "filter", "nat", "route" + hook: str | None # "prerouting", "input", "forward", "output", "postrouting", ... + priority: int | None + policy: str | None # "accept" or "drop" + rules: list[NetfilterRuleDict] class HostFirewallDict(TypedDict): @@ -452,8 +452,8 @@ class HostFirewallDict(TypedDict): extensions opaquely) and ``iptables-legacy`` (a ruleset of its own). """ - sources: List[str] - chains: List[NetfilterChainDict] + sources: list[str] + chains: list[NetfilterChainDict] class VirtualIpDict(TypedDict): @@ -471,14 +471,14 @@ class VirtualIpDict(TypedDict): #: CARP address's interface description instance: str address: str # normalised: "10.7.224.10", "fd00::10" - prefix_length: Optional[int] # None where the configuration gives none - interface: Optional[str] + prefix_length: int | None # None where the configuration gives none + interface: str | None #: vip-manager's trigger-key, "vrid:" for keepalived, "vhid:" for CARP - group_key: Optional[str] - running: Optional[bool] # the unit is active; for CARP: in MASTER or BACKUP + group_key: str | None + running: bool | None # the unit is active; for CARP: in MASTER or BACKUP #: The host holds the address now, by the mechanism's own state (CARP's #: MASTER). Absent or None: the interface addresses tell. - holds: NotRequired[Optional[bool]] + holds: NotRequired[bool | None] class ServiceClusterMemberDict(TypedDict): @@ -492,19 +492,19 @@ class ServiceClusterMemberDict(TypedDict): endpoint. """ - name: Optional[str] - address: Optional[str] - port: Optional[int] + name: str | None + address: str | None + port: int | None #: as the mechanism words it: Patroni's "leader"/"sync_standby"/"replica", #: adguardhome-sync's "origin"/"replica", BunkerWeb's "manager"/"worker" - role: Optional[str] + role: str | None #: the member's state where the mechanism reports one (Patroni's "running", #: "streaming"); configuration alone has none - state: Optional[str] + state: str | None this_host: bool #: the index in ``members`` of the member whose configuration this one takes: #: a replica's origin, a worker's manager. None where it takes none. - config_from: Optional[int] + config_from: int | None class ServiceClusterDict(TypedDict): @@ -525,9 +525,9 @@ class ServiceClusterDict(TypedDict): #: what the members share where the reading host can name it: Patroni's scope, #: the sync's origin ("10.9.100.5:3000"), a worker's manager address. None for a #: manager, which cannot name its own address. - cluster: Optional[str] - running: Optional[bool] # the unit is active; a listed container runs - members: List[ServiceClusterMemberDict] + cluster: str | None + running: bool | None # the unit is active; a listed container runs + members: list[ServiceClusterMemberDict] class ServiceClustersDict(TypedDict): @@ -538,9 +538,9 @@ class ServiceClustersDict(TypedDict): container engine it may not ask, a sync configured by file. """ - patroni: Optional[List[ServiceClusterDict]] - adguardhome_sync: Optional[List[ServiceClusterDict]] - bunkerweb: Optional[List[ServiceClusterDict]] + patroni: list[ServiceClusterDict] | None + adguardhome_sync: list[ServiceClusterDict] | None + bunkerweb: list[ServiceClusterDict] | None class VirtualIpsDict(TypedDict): @@ -553,11 +553,11 @@ class VirtualIpsDict(TypedDict): keeps what it knew for a None, and for a missing ``carp``. """ - vip_manager: Optional[List[VirtualIpDict]] - keepalived: Optional[List[VirtualIpDict]] + vip_manager: list[VirtualIpDict] | None + keepalived: list[VirtualIpDict] | None #: A firewall's CARP addresses (not read by ``VirtualIpsMixin``, which reads Linux #: hosts): every address bound to a VHID, the CARP address and IP aliases on it. - carp: NotRequired[Optional[List[VirtualIpDict]]] + carp: NotRequired[list[VirtualIpDict] | None] class PortForwardDict(TypedDict): @@ -576,7 +576,7 @@ class PortForwardDict(TypedDict): class SecurityZoneDict(TypedDict): - interfaces: List[str] + interfaces: list[str] policy: str description: str @@ -622,12 +622,12 @@ class FirewallRuleDict(TypedDict): class FirewallRuleUpdateDict(TypedDict): uuid: str rule: FirewallRuleDict - changed_fields: List[str] + changed_fields: list[str] class FirewallRuleDiffDict(TypedDict): - add: List[FirewallRuleDict] - update: List[FirewallRuleUpdateDict] + add: list[FirewallRuleDict] + update: list[FirewallRuleUpdateDict] class DhcpReservationDict(TypedDict): @@ -656,12 +656,12 @@ class DhcpReservationDict(TypedDict): class DhcpReservationUpdateDict(TypedDict): uuid: str reservation: DhcpReservationDict - changed_fields: List[str] + changed_fields: list[str] class DhcpReservationDiffDict(TypedDict): - add: List[DhcpReservationDict] - update: List[DhcpReservationUpdateDict] + add: list[DhcpReservationDict] + update: list[DhcpReservationUpdateDict] class DhcpOptionDataDict(TypedDict, total=False): @@ -676,11 +676,11 @@ class DhcpOptionDataDict(TypedDict, total=False): desired value is empty". A driver must preserve options it was not given. """ - routers: List[str] - domain_name_servers: List[str] + routers: list[str] + domain_name_servers: list[str] domain_name: str - domain_search: List[str] - ntp_servers: List[str] + domain_search: list[str] + ntp_servers: list[str] class DhcpSubnetDict(TypedDict): @@ -702,7 +702,7 @@ class DhcpSubnetDict(TypedDict): uuid: str subnet: str description: str - pools: List[str] + pools: list[str] option_data: DhcpOptionDataDict match_client_id: bool @@ -710,12 +710,12 @@ class DhcpSubnetDict(TypedDict): class DhcpSubnetUpdateDict(TypedDict): uuid: str subnet: DhcpSubnetDict - changed_fields: List[str] + changed_fields: list[str] class DhcpSubnetDiffDict(TypedDict): - add: List[DhcpSubnetDict] - update: List[DhcpSubnetUpdateDict] + add: list[DhcpSubnetDict] + update: list[DhcpSubnetUpdateDict] class DhcpHaPeerDict(TypedDict): @@ -742,7 +742,7 @@ class DhcpHaDict(TypedDict): enabled: bool #: Which of ``peers`` is this server. this_server: NotRequired[str] - peers: List[DhcpHaPeerDict] + peers: list[DhcpHaPeerDict] class ConfigSyncDict(TypedDict): @@ -752,12 +752,12 @@ class ConfigSyncDict(TypedDict): host without the feature switched on). """ - peer: Optional[str] + peer: str | None #: The services whose configuration it pushes, by the name ``get_services`` #: reports. - services: List[str] + services: list[str] #: Sync items the driver has no service name for, as the host names them. - unmapped: List[str] + unmapped: list[str] class VPNTunnelDict(TypedDict): @@ -834,8 +834,8 @@ class VMDict(TypedDict): class VMPassthroughDict(TypedDict): """A host device handed through to a VM (PCI, USB).""" - slot: str # hypervisor's device key, e.g. "hostpci0" - kind: str # "pci" or "usb" + slot: str # hypervisor's device key, e.g. "hostpci0" + kind: str # "pci" or "usb" config: str # hypervisor's own description of the device @@ -845,19 +845,19 @@ class VMConfigDict(TypedDict): vcpus: int memory: int os_type: str - boot_order: List[str] - disks: List[VMDiskDict] - nics: List[VMNICDict] + boot_order: list[str] + disks: list[VMDiskDict] + nics: list[VMNICDict] description: str - tags: List[str] + tags: list[str] # Hardware details not every hypervisor exposes; absent when unknown. - os_name: NotRequired[str] # human-readable guest OS, e.g. "Ubuntu Linux (64-bit)" - cpu_type: NotRequired[str] # e.g. "host", "kvm64" + os_name: NotRequired[str] # human-readable guest OS, e.g. "Ubuntu Linux (64-bit)" + cpu_type: NotRequired[str] # e.g. "host", "kvm64" sockets: NotRequired[int] cores_per_socket: NotRequired[int] - firmware: NotRequired[str] # "bios" or "efi" - machine: NotRequired[str] # machine type / virtual hardware version - passthrough: NotRequired[List[VMPassthroughDict]] + firmware: NotRequired[str] # "bios" or "efi" + machine: NotRequired[str] # machine type / virtual hardware version + passthrough: NotRequired[list[VMPassthroughDict]] class StorageVolumeDict(TypedDict): @@ -900,29 +900,29 @@ class PhysicalDiskDict(TypedDict): model: str serial: str vendor: str - type: str # "hdd", "ssd", "nvme" - size: int # bytes - rpm: int # 0 for SSD/NVMe - temperature: int # Celsius; -1 if unavailable - health: str # "healthy", "warning", "failed", "unknown" - pool: str # name of the containing pool; empty string if spare/unassigned + type: str # "hdd", "ssd", "nvme" + size: int # bytes + rpm: int # 0 for SSD/NVMe + temperature: int # Celsius; -1 if unavailable + health: str # "healthy", "warning", "failed", "unknown" + pool: str # name of the containing pool; empty string if spare/unassigned class DiskPoolDict(TypedDict): """A RAID array, ZFS pool, or volume group.""" name: str - type: str # "zfs", "lvm", "md", "hardware-raid", "btrfs" - level: str # "stripe", "mirror", "raidz1", "raidz2", "raidz3", - # "raid0" … "raid60", "single", etc. - status: str # "online", "degraded", "faulted", "offline", "unknown" - total: int # bytes - used: int # bytes - available: int # bytes - disks: List[str] # slot identifiers of member disks + type: str # "zfs", "lvm", "md", "hardware-raid", "btrfs" + level: str # "stripe", "mirror", "raidz1", "raidz2", "raidz3", + # "raid0" … "raid60", "single", etc. + status: str # "online", "degraded", "faulted", "offline", "unknown" + total: int # bytes + used: int # bytes + available: int # bytes + disks: list[str] # slot identifiers of member disks auto_expand: bool dedup: bool - compression: str # "off", "lz4", "gzip", "zstd", etc. + compression: str # "off", "lz4", "gzip", "zstd", etc. class LogicalVolumeDict(TypedDict): @@ -930,74 +930,74 @@ class LogicalVolumeDict(TypedDict): name: str pool: str - type: str # "filesystem", "volume" (block device / LUN), "zvol" - total: int # bytes - used: int # bytes - available: int # bytes - mountpoint: str # empty string for block volumes - compression: str # "off", "lz4", etc. + type: str # "filesystem", "volume" (block device / LUN), "zvol" + total: int # bytes + used: int # bytes + available: int # bytes + mountpoint: str # empty string for block volumes + compression: str # "off", "lz4", etc. dedup: bool readonly: bool - snapshots: int # number of snapshots currently held + snapshots: int # number of snapshots currently held class NASShareDict(TypedDict): """A network share or iSCSI target exported by the storage device.""" name: str - protocol: str # "nfs", "smb", "afp", "ftp", "sftp", "iscsi", "webdav" - path: str # local filesystem path or iSCSI target IQN - volume: str # logical volume or dataset this share is backed by + protocol: str # "nfs", "smb", "afp", "ftp", "sftp", "iscsi", "webdav" + path: str # local filesystem path or iSCSI target IQN + volume: str # logical volume or dataset this share is backed by enabled: bool readonly: bool description: str - clients: List[str] # IP/subnet allow-list; empty list = all hosts allowed + clients: list[str] # IP/subnet allow-list; empty list = all hosts allowed class VolumeSnapshotDict(TypedDict): """A point-in-time snapshot of a logical volume or dataset.""" name: str - volume: str # parent volume / dataset - created: float # Unix epoch - size: int # bytes of unique data referenced by this snapshot + volume: str # parent volume / dataset + created: float # Unix epoch + size: int # bytes of unique data referenced by this snapshot description: str - clones: List[str] # volumes cloned from this snapshot + clones: list[str] # volumes cloned from this snapshot class StorageQuotaDict(TypedDict): """A filesystem quota applied to a user, group, or dataset.""" - target: str # username, group name, or dataset path - target_type: str # "user", "group", "dataset" - volume: str # volume / dataset the quota applies to - used: int # bytes currently used - quota: int # hard limit in bytes; 0 = no limit - ref_quota: int # referenced-data limit in bytes; 0 = no limit + target: str # username, group name, or dataset path + target_type: str # "user", "group", "dataset" + volume: str # volume / dataset the quota applies to + used: int # bytes currently used + quota: int # hard limit in bytes; 0 = no limit + ref_quota: int # referenced-data limit in bytes; 0 = no limit class StorageServiceDict(TypedDict): """Status of a file-sharing or access service running on the device.""" - name: str # "nfs", "smb", "ftp", "ssh", "iscsi", "webdav", etc. - enabled: bool # administratively enabled - running: bool # currently active / listening - port: int # primary listening port; 0 if N/A - version: str # protocol version string (e.g. "4.1" for NFSv4.1) + name: str # "nfs", "smb", "ftp", "ssh", "iscsi", "webdav", etc. + enabled: bool # administratively enabled + running: bool # currently active / listening + port: int # primary listening port; 0 if N/A + version: str # protocol version string (e.g. "4.1" for NFSv4.1) class ReplicationJobDict(TypedDict): """A scheduled replication or sync task.""" name: str - source: str # source path / dataset - target: str # destination path / dataset (may be remote: host:path) - direction: str # "push" or "pull" - schedule: str # cron expression or human label ("daily", "hourly") + source: str # source path / dataset + target: str # destination path / dataset (may be remote: host:path) + direction: str # "push" or "pull" + schedule: str # cron expression or human label ("daily", "hourly") enabled: bool - last_run: float # Unix epoch; 0.0 if never run - last_status: str # "success", "failed", "running", "pending" - bytes_sent: int # bytes transferred in the last run + last_run: float # Unix epoch; 0.0 if never run + last_status: str # "success", "failed", "running", "pending" + bytes_sent: int # bytes transferred in the last run # --------------------------------------------------------------------------- @@ -1013,7 +1013,7 @@ class UserDict(TypedDict): gid: int home: str shell: str - groups: List[str] # all supplementary group names + groups: list[str] # all supplementary group names class ProcessDict(TypedDict): @@ -1022,30 +1022,30 @@ class ProcessDict(TypedDict): pid: int ppid: int user: str - cpu: float # CPU utilisation 0.0–100.0 - memory: float # RSS percentage of total RAM 0.0–100.0 - vsz: int # virtual memory size in KiB - rss: int # resident set size in KiB - tty: str # controlling terminal; empty string if none - state: str # "R" running, "S" sleeping, "D" uninterruptible, "Z" zombie, etc. - started: str # start time string as reported by ``ps`` (e.g. "12:34" or "May28") - command: str # full command line + cpu: float # CPU utilisation 0.0–100.0 + memory: float # RSS percentage of total RAM 0.0–100.0 + vsz: int # virtual memory size in KiB + rss: int # resident set size in KiB + tty: str # controlling terminal; empty string if none + state: str # "R" running, "S" sleeping, "D" uninterruptible, "Z" zombie, etc. + started: str # start time string as reported by ``ps`` (e.g. "12:34" or "May28") + command: str # full command line class CronJobDict(TypedDict): """A scheduled cron task.""" - user: str # owner of the crontab entry - schedule: str # five-field cron expression, e.g. "0 * * * *" - command: str # shell command to execute + user: str # owner of the crontab entry + schedule: str # five-field cron expression, e.g. "0 * * * *" + command: str # shell command to execute description: NotRequired[str] # optional inline comment class ApplyUpdatesResultDict(TypedDict): """Result of an ``apply_updates()`` call.""" - success: bool # True if the package manager exited without error - output: str # raw stdout/stderr captured from the package manager + success: bool # True if the package manager exited without error + output: str # raw stdout/stderr captured from the package manager error: NotRequired[str] # error message if success is False @@ -1056,24 +1056,24 @@ class ContainerEngineDict(TypedDict): it is read and modelled above the driver, in netOrk (netork#765). """ - engine: str # "docker" - api: str # "docker-engine": the Docker Engine API over ``system dial-stdio`` + engine: str # "docker" + api: str # "docker-engine": the Docker Engine API over ``system dial-stdio`` class DeviceActionResultDict(TypedDict): """Return value of ``run_device_action()``.""" - success: bool # True if the action completed without error - output: str # human-readable output or status message + success: bool # True if the action completed without error + output: str # human-readable output or status message class SNMPConfigDict(TypedDict): """SNMP agent configuration detected on the device.""" - running: bool # True if the SNMP daemon is active - community: str # read community string (e.g. "public") - port: int # listening port (default 161) - version: str # highest supported version: "1", "2c", or "3" + running: bool # True if the SNMP daemon is active + community: str # read community string (e.g. "public") + port: int # listening port (default 161) + version: str # highest supported version: "1", "2c", or "3" # --------------------------------------------------------------------------- @@ -1089,7 +1089,7 @@ class HealthMetricsIfaceDict(TypedDict): tx_bytes: NotRequired[int] rx_errors: NotRequired[int] tx_errors: NotRequired[int] - tx_drops: NotRequired[int] # populated instead of tx_errors when the driver reports drops + tx_drops: NotRequired[int] # populated instead of tx_errors when the driver reports drops speed_mbps: NotRequired[int] @@ -1108,7 +1108,7 @@ class HealthMetricsDict(TypedDict): load_1: NotRequired[float] load_5: NotRequired[float] load_15: NotRequired[float] - interfaces: NotRequired[Dict[str, HealthMetricsIfaceDict]] + interfaces: NotRequired[dict[str, HealthMetricsIfaceDict]] LicenseStatus = Literal["active", "missing", "expired", "invalid", "none", "unknown"] @@ -1124,13 +1124,13 @@ class LicenseDict(TypedDict): name: str # what it licenses, as the device names it status: LicenseStatus # "none": the device runs without one, as installed edition: NotRequired[str] # the edition or level, e.g. "Free", "Enterprise Plus" - expires: NotRequired[Optional[str]] # ISO date, None for no expiry + expires: NotRequired[str | None] # ISO date, None for no expiry detail: NotRequired[str] # the device's own words, e.g. why a feature is refused # What the device withholds without it while the rest works, e.g. ["drives", # "firmware inventory"] on a BMC that answers but wants a further license. # Absent: the device says nothing of the kind, or withholds everything it # would offer with it. - withholds: NotRequired[List[str]] + withholds: NotRequired[list[str]] UpdateState = Literal["idle", "running", "done", "failed"] @@ -1170,9 +1170,9 @@ class OspfInterfaceDict(TypedDict): class OspfDict(TypedDict): """The device's OSPF state (``OspfMixin.get_ospf``).""" - router_id: Optional[str] - areas: List[str] - interfaces: Dict[str, OspfInterfaceDict] # by interface name + router_id: str | None + areas: list[str] + interfaces: dict[str, OspfInterfaceDict] # by interface name class FirmwareOfferDict(TypedDict): @@ -1189,10 +1189,10 @@ class HypervisorHostDict(TypedDict): """One host of a hypervisor or cluster (``HypervisorDriver.get_hypervisor_hosts``).""" name: str - connected: Optional[bool] # reachable by its manager; None where it cannot tell - maintenance: Optional[bool] # in maintenance mode - config_issues: List[str] # the manager's configuration warnings, verbatim - managed_by: NotRequired[Optional[str]] # the address of a central manager, if any + connected: bool | None # reachable by its manager; None where it cannot tell + maintenance: bool | None # in maintenance mode + config_issues: list[str] # the manager's configuration warnings, verbatim + managed_by: NotRequired[str | None] # the address of a central manager, if any class FixStepOutcomeDict(TypedDict): @@ -1214,7 +1214,7 @@ class FixResultDict(TypedDict): """The result of a fix routine: every step that ran, up to the first failure.""" success: bool - steps: List[FixStepDict] + steps: list[FixStepDict] output: str # the steps' output, one "[step] output" line each @@ -1222,7 +1222,9 @@ class SnmpAgentSettingsDict(TypedDict): """What the caller wants an SNMP agent to be (``SnmpAgentMixin``).""" community: str # the read-only v2c community - allowed_sources: List[str] # addresses or CIDRs let through to the agent; [] changes no firewall + allowed_sources: list[ + str + ] # addresses or CIDRs let through to the agent; [] changes no firewall contact: NotRequired[str] # sysContact location: NotRequired[str] # sysLocation listen: NotRequired[str] # the address the agent binds to; absent: every address @@ -1237,7 +1239,7 @@ class SnmpValueSpec(TypedDict, total=False): with text. """ - oids: List[str] + oids: list[str] table: str fold: Literal["first", "average", "max"] pattern: str @@ -1346,7 +1348,7 @@ class VMCpuTypeDict(TypedDict): name: str # Model name, usable directly as create_vm_from_cloud_init(cpu_type=...) description: str # One line on what the model is for, for a picker - features: List[str] # cpuinfo flags the guest is guaranteed to see + features: list[str] # cpuinfo flags the guest is guaranteed to see available: bool # False when this node's CPU cannot run the model default: bool # The model create_vm_from_cloud_init uses when cpu_type is None @@ -1359,19 +1361,19 @@ class VMCpuTypeDict(TypedDict): class PingSweepEntryDict(TypedDict): """Outcome of a single ``ping`` inside a sweep (see ``PingSweepMixin``).""" - ip: str # destination that was probed - alive: bool # True if at least one probe was answered - rtt_ms: Optional[float] # average round-trip time in ms; None if unreachable + ip: str # destination that was probed + alive: bool # True if at least one probe was answered + rtt_ms: float | None # average round-trip time in ms; None if unreachable error: NotRequired[str] # driver/transport error for this destination class PingSweepResultDict(TypedDict): """Result of a ``ping_sweep()`` call.""" - entries: List[PingSweepEntryDict] # one entry per probed destination, in input order - scanned: int # destinations actually probed - alive_count: int # entries with alive=True - truncated: bool # True if targets were dropped at the sweep cap + entries: list[PingSweepEntryDict] # one entry per probed destination, in input order + scanned: int # destinations actually probed + alive_count: int # entries with alive=True + truncated: bool # True if targets were dropped at the sweep cap # --------------------------------------------------------------------------- @@ -1387,29 +1389,29 @@ PowerAction = Literal[ "ForceRestart", "PowerCycle", ] -POWER_ACTIONS: Tuple[str, ...] = get_args(PowerAction) +POWER_ACTIONS: tuple[str, ...] = get_args(PowerAction) class PowerStateDict(TypedDict): """The managed server's power, as its BMC reports it.""" - state: str # "On", "Off", "PoweringOn" or "PoweringOff" - actions: List[str] # the PowerAction values this BMC accepts now + state: str # "On", "Off", "PoweringOn" or "PoweringOff" + actions: list[str] # the PowerAction values this BMC accepts now class BmcCpuDict(TypedDict): - socket: str # "CPU1", "Proc 1" - model: str # "Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz" - cores: Optional[int] - threads: Optional[int] - status: str # "OK", "Warning", "Critical", "Absent" + socket: str # "CPU1", "Proc 1" + model: str # "Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz" + cores: int | None + threads: int | None + status: str # "OK", "Warning", "Critical", "Absent" class BmcDimmDict(TypedDict): - slot: str # "DIMMA1", "PROC 1 DIMM 3" - size_bytes: Optional[int] - type: str # "DDR4", "DDR5"; empty when unknown - speed_mts: Optional[int] # MT/s + slot: str # "DIMMA1", "PROC 1 DIMM 3" + size_bytes: int | None + type: str # "DDR4", "DDR5"; empty when unknown + speed_mts: int | None # MT/s part_number: str serial_number: str status: str @@ -1419,9 +1421,9 @@ class BmcDriveDict(TypedDict): slot: str model: str serial_number: str - size_bytes: Optional[int] - media: str # "HDD", "SSD"; empty when unknown - protocol: str # "SATA", "SAS", "NVMe"; empty when unknown + size_bytes: int | None + media: str # "HDD", "SSD"; empty when unknown + protocol: str # "SATA", "SAS", "NVMe"; empty when unknown status: str @@ -1429,17 +1431,17 @@ class BmcPsuDict(TypedDict): slot: str model: str serial_number: str - capacity_watts: Optional[int] + capacity_watts: int | None status: str class BmcNicDict(TypedDict): """A network port of the managed server, not of the BMC.""" - name: str # "NIC.Integrated.1-1", "Port 1" - mac_address: str # lowercase, colon-separated; empty when unknown - speed_mbps: Optional[int] - link_up: Optional[bool] + name: str # "NIC.Integrated.1-1", "Port 1" + mac_address: str # lowercase, colon-separated; empty when unknown + speed_mbps: int | None + link_up: bool | None class HardwareInventoryDict(TypedDict): @@ -1447,35 +1449,35 @@ class HardwareInventoryDict(TypedDict): manufacturer: str model: str - serial_number: str # the server's, not the BMC's - system_uuid: str # SMBIOS system UUID, lowercase - cpus: List[BmcCpuDict] - memory: List[BmcDimmDict] - drives: List[BmcDriveDict] - power_supplies: List[BmcPsuDict] - nics: List[BmcNicDict] + serial_number: str # the server's, not the BMC's + system_uuid: str # SMBIOS system UUID, lowercase + cpus: list[BmcCpuDict] + memory: list[BmcDimmDict] + drives: list[BmcDriveDict] + power_supplies: list[BmcPsuDict] + nics: list[BmcNicDict] class EventLogEntryDict(TypedDict): """One entry of the BMC's system event log (SEL / Lifecycle / IML).""" id: str - timestamp: Optional[float] # Unix time; None when the BMC gave none - severity: str # "OK", "Warning" or "Critical" + timestamp: float | None # Unix time; None when the BMC gave none + severity: str # "OK", "Warning" or "Critical" message: str class FirmwareDict(TypedDict): """One firmware component: the BMC's own, BIOS, a controller, a NIC.""" - name: str # "BMC", "BIOS", "PERC H755 Front" + name: str # "BMC", "BIOS", "PERC H755 Front" version: str - updateable: Optional[bool] + updateable: bool | None class ProtocolStateDict(TypedDict): enabled: bool - port: Optional[int] # None for a protocol without a port of its own + port: int | None # None for a protocol without a port of its own class ManagementProtocolsDict(TypedDict): @@ -1491,10 +1493,12 @@ class ManagementProtocolsDict(TypedDict): class SystemIdentityDict(TypedDict): - """A host's SMBIOS identity, what ties it to its BMC (:mod:`napalm_device_types.system_identity`). + """A host's SMBIOS identity, what ties it to its BMC. + + See :mod:`napalm_device_types.system_identity`. Each value is empty when unknown or a vendor placeholder.""" - system_uuid: str # lowercase + system_uuid: str # lowercase product_serial: str board_serial: str diff --git a/napalm_device_types/nat_vpn.py b/napalm_device_types/nat_vpn.py index 5d8328a..e85b171 100644 --- a/napalm_device_types/nat_vpn.py +++ b/napalm_device_types/nat_vpn.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Address translation and VPN tunnels. A home gateway does a subset of what a firewall does, and these readers are @@ -11,7 +10,7 @@ this class in can never shadow a working implementation from a sibling base. from __future__ import annotations -from typing import Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNTunnelDict @@ -19,7 +18,7 @@ from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNT class NatVpnMixin: if TYPE_CHECKING: - def get_nat_translations(self) -> List[NATTranslationDict]: + def get_nat_translations(self) -> list[NATTranslationDict]: """ Returns a list of active NAT translation entries. @@ -47,7 +46,7 @@ class NatVpnMixin: """ ... - def get_port_forwards(self) -> List[PortForwardDict]: + def get_port_forwards(self) -> list[PortForwardDict]: """ Returns the port forwards that let traffic in from the WAN. @@ -87,7 +86,7 @@ class NatVpnMixin: """ ... - def get_vpn_tunnels(self) -> Dict[str, VPNTunnelDict]: + def get_vpn_tunnels(self) -> dict[str, VPNTunnelDict]: """ Returns the status of VPN tunnels. diff --git a/napalm_device_types/os.py b/napalm_device_types/os.py index 27f7287..8c62e09 100644 --- a/napalm_device_types/os.py +++ b/napalm_device_types/os.py @@ -10,11 +10,9 @@ Usage:: ... """ -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING + from napalm_device_types.base import DeviceTypeDriver -from napalm_device_types.updates import UpdateMixin -from napalm_device_types.services import ServiceControlMixin -from napalm_device_types.packages import PackageManagementMixin from napalm_device_types.health_metrics import HealthMetricsMixin from napalm_device_types.models import ( CronJobDict, @@ -23,9 +21,14 @@ from napalm_device_types.models import ( SNMPConfigDict, UserDict, ) +from napalm_device_types.packages import PackageManagementMixin +from napalm_device_types.services import ServiceControlMixin +from napalm_device_types.updates import UpdateMixin -class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver): +class OSDriver( + UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver +): """ Abstract intermediate driver for general-purpose operating systems (e.g. Linux, BSD, macOS). @@ -44,26 +47,20 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM # Interfaces matching this pattern are excluded from health-metric collection. # Set True for drivers where the out-error counter actually reports drops (e.g. OPNsense). - # ------------------------------------------------------------------ # Package management # ------------------------------------------------------------------ if TYPE_CHECKING: - - - - # ------------------------------------------------------------------ # Service management # ------------------------------------------------------------------ - # ------------------------------------------------------------------ # Users # ------------------------------------------------------------------ - def get_os_users(self) -> List[UserDict]: + def get_os_users(self) -> list[UserDict]: """ Returns a list of local OS user accounts. @@ -95,7 +92,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM # Processes # ------------------------------------------------------------------ - def get_processes(self) -> List[ProcessDict]: + def get_processes(self) -> list[ProcessDict]: """ Returns a snapshot of currently running processes. @@ -138,7 +135,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM # Cron jobs # ------------------------------------------------------------------ - def get_cron_jobs(self) -> List[CronJobDict]: + def get_cron_jobs(self) -> list[CronJobDict]: """ Returns scheduled cron tasks from all user crontabs and ``/etc/cron.d``. @@ -166,7 +163,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM # SNMP # ------------------------------------------------------------------ - def get_snmp_config(self) -> Optional[SNMPConfigDict]: + def get_snmp_config(self) -> SNMPConfigDict | None: """ Returns the SNMP agent configuration currently active on the device, or ``None`` if no SNMP daemon is running or detectable. diff --git a/napalm_device_types/ospf.py b/napalm_device_types/ospf.py index 33a96fe..72be557 100644 --- a/napalm_device_types/ospf.py +++ b/napalm_device_types/ospf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """A device's OSPF state, in neutral terms. The routing daemon's own output (FRR, Quagga, a vendor's CLI) is the driver's diff --git a/napalm_device_types/package_updates.py b/napalm_device_types/package_updates.py index 55e3542..7ff7f9f 100644 --- a/napalm_device_types/package_updates.py +++ b/napalm_device_types/package_updates.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Pending package updates: which package, from where, and whether it is a security fix. A patch deadline -- "security updates within 14 days" -- needs to know which @@ -16,7 +15,6 @@ security -- netOrk's CVE matching is what catches those. from __future__ import annotations import re -from typing import Dict, List, Set from napalm_device_types.models import UpdateDict from napalm_device_types.terminal import strip_terminal_codes @@ -42,10 +40,10 @@ _APT_STATUS = re.compile(r"^__APT_RC=(\d+)\s*$", re.MULTILINE) _DNF_STATUS = re.compile(r"^__DNF_RC=(\d+)\s*$", re.MULTILINE) -def _joined_lines(output: str) -> List[str]: +def _joined_lines(output: str) -> list[str]: """Lines as apt printed them: a terminal wraps long ones, and the continuation starts with a space.""" - lines: List[str] = [] + lines: list[str] = [] for line in output.splitlines(): if line.startswith(" ") and lines: lines[-1] += line[1:].rstrip() @@ -67,7 +65,7 @@ def _listing(output: str, status: re.Pattern[str], what: str) -> str: return status.sub("", text) -def parse_apt_upgradable(output: str) -> List[UpdateDict]: +def parse_apt_upgradable(output: str) -> list[UpdateDict]: """Parse :data:`APT_UPGRADABLE_COMMAND`'s output, one entry per package. A package listed for several architectures (``libc6`` for amd64 and i386) @@ -75,7 +73,7 @@ def parse_apt_upgradable(output: str) -> List[UpdateDict]: :raises ValueError: when apt failed or its exit status never arrived. """ - by_name: Dict[str, UpdateDict] = {} + by_name: dict[str, UpdateDict] = {} for line in _joined_lines(_listing(output, _APT_STATUS, "apt list --upgradable")): match = _APT_LINE.match(line) if not match: @@ -103,7 +101,7 @@ def nevra_name(nevra: str) -> str: return without_arch.rsplit("-", 2)[0] -def parse_dnf_security(output: str) -> Set[str]: +def parse_dnf_security(output: str) -> set[str]: """The names of the packages a pending security advisory covers. Parses :data:`DNF_SECURITY_COMMAND`'s ``ADVISORY SEVERITY/Sec. NEVRA`` lines. @@ -111,7 +109,7 @@ def parse_dnf_security(output: str) -> Set[str]: :raises ValueError: when dnf failed or its exit status never arrived -- every update would otherwise read as not security. """ - names: Set[str] = set() + names: set[str] = set() for line in _listing(output, _DNF_STATUS, "dnf updateinfo list --security").splitlines(): parts = line.split() if len(parts) >= 3 and parts[1].endswith("/Sec."): diff --git a/napalm_device_types/packages.py b/napalm_device_types/packages.py index 4a8f552..f7c682a 100644 --- a/napalm_device_types/packages.py +++ b/napalm_device_types/packages.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Listing and managing installed software. Identical on every device type that has a package manager -- this was five @@ -12,7 +11,7 @@ this class in can never shadow a working implementation from a sibling base. from __future__ import annotations -from typing import Any, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from napalm_device_types.models import PackageDict @@ -20,7 +19,7 @@ from napalm_device_types.models import PackageDict class PackageManagementMixin: if TYPE_CHECKING: - def get_packages(self) -> List[PackageDict]: + def get_packages(self) -> list[PackageDict]: """ Returns all packages currently known to the device's package manager (e.g. ``opkg`` on OpenWrt, ``apk`` on Alpine-based APs). @@ -97,7 +96,7 @@ class PackageManagementMixin: """ ... - def get_package_config(self, name: str) -> Dict[str, Any]: + def get_package_config(self, name: str) -> dict[str, Any]: """ Returns the current configuration of an installed package as a dictionary. The structure is package-specific. @@ -123,7 +122,7 @@ class PackageManagementMixin: """ ... - def set_package_config(self, name: str, config: Dict[str, Any]) -> None: + def set_package_config(self, name: str, config: dict[str, Any]) -> None: """ Writes a new configuration for an installed package. diff --git a/napalm_device_types/phone.py b/napalm_device_types/phone.py index 871a7cd..5409a60 100644 --- a/napalm_device_types/phone.py +++ b/napalm_device_types/phone.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Abstract base class for IP telephony endpoints. @@ -10,7 +9,7 @@ Usage:: ... """ -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any from napalm_device_types.base import DeviceTypeDriver @@ -36,7 +35,7 @@ class PhoneDriver(DeviceTypeDriver): if TYPE_CHECKING: - def get_sip_accounts(self) -> List[Dict[str, Any]]: + def get_sip_accounts(self) -> list[dict[str, Any]]: """ Returns the SIP registrations configured on the phone. @@ -60,7 +59,7 @@ class PhoneDriver(DeviceTypeDriver): """ ... - def get_call_status(self) -> Dict[str, Any]: + def get_call_status(self) -> dict[str, Any]: """ Returns what the phone is doing right now. diff --git a/napalm_device_types/ping_sweep.py b/napalm_device_types/ping_sweep.py index ddd92c0..1caa9b2 100644 --- a/napalm_device_types/ping_sweep.py +++ b/napalm_device_types/ping_sweep.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Generic ICMP sweep on top of the NAPALM-standard ``ping()``. Sweeping a range of addresses is orchestration, not device mechanics: the @@ -21,7 +20,8 @@ target list (see ``PING_SWEEP_MAX_TARGETS``). from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterable, List, Optional +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any, ClassVar from napalm.base import NetworkDriver @@ -60,7 +60,7 @@ class PingSweepMixin: #: ``False`` opts a driver out of ping sweeps even though it implements #: ``ping()``. ``None`` (the default) means "decide by introspection". - SUPPORTS_PING: ClassVar[Optional[bool]] = None + SUPPORTS_PING: ClassVar[bool | None] = None if TYPE_CHECKING: # pragma: no cover - declared for type checkers only @@ -73,7 +73,7 @@ class PingSweepMixin: size: int = 100, count: int = 5, vrf: str = "", - ) -> Dict[str, Any]: ... + ) -> dict[str, Any]: ... @classmethod def supports_ping(cls) -> bool: @@ -86,9 +86,9 @@ class PingSweepMixin: *, count: int = 1, timeout: int = 1, - max_targets: Optional[int] = None, - on_progress: Optional[Callable[[int, int], None]] = None, - should_stop: Optional[Callable[[], bool]] = None, + max_targets: int | None = None, + on_progress: Callable[[int, int], None] | None = None, + should_stop: Callable[[], bool] | None = None, ) -> PingSweepResultDict: """Ping every address in *destinations* and report who answered. @@ -115,7 +115,7 @@ class PingSweepMixin: targets = targets[:limit] total = len(targets) - entries: List[PingSweepEntryDict] = [] + entries: list[PingSweepEntryDict] = [] for done, destination in enumerate(targets, start=1): if should_stop is not None and should_stop(): break @@ -151,7 +151,12 @@ class PingSweepMixin: success = reply.get("success") or {} if not isinstance(success, dict): - return {"ip": destination, "alive": False, "rtt_ms": None, "error": "malformed ping reply"} + 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 @@ -167,7 +172,7 @@ def _as_int(value: Any, default: int = 0) -> int: return default -def _as_float(value: Any) -> Optional[float]: +def _as_float(value: Any) -> float | None: try: return float(value) except (TypeError, ValueError): diff --git a/napalm_device_types/residential_gateway.py b/napalm_device_types/residential_gateway.py index 369bb7a..b2c9567 100644 --- a/napalm_device_types/residential_gateway.py +++ b/napalm_device_types/residential_gateway.py @@ -19,11 +19,11 @@ Usage:: ... """ -from typing import Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING + from napalm_device_types.base import DeviceTypeDriver -from napalm_device_types.nat_vpn import NatVpnMixin -from napalm_device_types.health_metrics import HealthMetricsMixin from napalm_device_types.dhcp import DhcpServerMixin +from napalm_device_types.health_metrics import HealthMetricsMixin from napalm_device_types.models import ( HostDict, RadioStatusDict, @@ -31,6 +31,7 @@ from napalm_device_types.models import ( WANStatusDict, WirelessClientDict, ) +from napalm_device_types.nat_vpn import NatVpnMixin class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, DeviceTypeDriver): @@ -48,8 +49,6 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, ROLE: str = "residential_gateway" TYPE_LABEL: str = "Gateway" - - if TYPE_CHECKING: def get_wan_status(self) -> WANStatusDict: @@ -85,7 +84,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, """ ... - def get_hosts(self) -> List[HostDict]: + def get_hosts(self) -> list[HostDict]: """ Returns the list of hosts known to the gateway (LAN clients). @@ -113,9 +112,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, """ ... - - - def get_wireless_clients(self) -> List[WirelessClientDict]: + def get_wireless_clients(self) -> list[WirelessClientDict]: """ Returns the list of wireless clients currently associated with the device's built-in access point(s). @@ -125,7 +122,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, """ ... - def get_ssids(self) -> Dict[str, SSIDDict]: + def get_ssids(self) -> dict[str, SSIDDict]: """ Returns the configured wireless networks (SSIDs). @@ -134,7 +131,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, """ ... - def get_radio_status(self) -> Dict[str, RadioStatusDict]: + def get_radio_status(self) -> dict[str, RadioStatusDict]: """ Returns the status of the device's wireless radios. diff --git a/napalm_device_types/roles.py b/napalm_device_types/roles.py index dcf8815..914a7f6 100644 --- a/napalm_device_types/roles.py +++ b/napalm_device_types/roles.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Which roles a driver class fills, and which of them is primary. A *role* is what a device is: a firewall, a switch, a NAS. Real devices are @@ -22,7 +21,7 @@ when a concrete driver provided it. from __future__ import annotations -from typing import Any, List, Optional, Type +from typing import Any def _is_role_base(cls: type) -> bool: @@ -36,7 +35,7 @@ def _is_role_base(cls: type) -> bool: return isinstance(role, str) and bool(role) -def roles_of(driver_cls: type) -> List[Type[Any]]: +def roles_of(driver_cls: type) -> list[type[Any]]: """Every role base in ``driver_cls``'s MRO, most significant first. MRO order is inheritance order, so the list reflects exactly what the driver @@ -49,12 +48,12 @@ def roles_of(driver_cls: type) -> List[Type[Any]]: return [cls for cls in roles if not any(o is not cls and issubclass(o, cls) for o in roles)] -def role_keys_of(driver_cls: type) -> List[str]: +def role_keys_of(driver_cls: type) -> list[str]: """The stable string keys of :func:`roles_of`, e.g. ``["storage", "linux"]``.""" return [vars(cls)["ROLE"] for cls in roles_of(driver_cls)] -def primary_role_of(driver_cls: type) -> Optional[str]: +def primary_role_of(driver_cls: type) -> str | None: """The driver's headline role, or ``None`` when it declares no role at all. This is the value netOrk exposes as ``device_class``. diff --git a/napalm_device_types/service_clusters.py b/napalm_device_types/service_clusters.py index fc22784..c117ad7 100644 --- a/napalm_device_types/service_clusters.py +++ b/napalm_device_types/service_clusters.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Service clusters: the configuration that says which services run together. Two hosts that answer at one virtual IP may run the same service by accident. @@ -47,8 +46,9 @@ from __future__ import annotations import ipaddress import json import re +from collections.abc import Callable from shlex import quote -from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from urllib.parse import urlsplit from napalm_device_types.models import ( @@ -119,7 +119,7 @@ _PATRONI_SH = ( 'if [ -n "$r" ]; then ' 'b=$(curl -s -k -m 3 "$s://$r/cluster" 2>/dev/null || ' 'wget -q -T 3 --no-check-certificate -O - "$s://$r/cluster" 2>/dev/null); ' - "if [ -n \"$b\" ]; then echo \"rest $(echo \"$b\" | tr -d '\\n')\"; " + 'if [ -n "$b" ]; then echo "rest $(echo "$b" | tr -d \'\\n\')"; ' "else echo rest-unreachable; fi; fi; " 'elif [ -e "$f" ]; then echo "unreadable $f"; ' 'else echo "missing $f"; fi; ' @@ -128,7 +128,7 @@ _PATRONI_SH = ( ) -def _docker_sh(binary: Optional[str]) -> str: +def _docker_sh(binary: str | None) -> str: """The containers part: running containers of the clustered images, their allowlisted environment. ``[no-docker]`` for a host without the engine.""" if not binary: @@ -138,17 +138,17 @@ def _docker_sh(binary: Optional[str]) -> str: 'if command -v "$D" >/dev/null 2>&1; then ' "if c=$(\"$D\" ps --format '{{.Names}}' 2>/dev/null); then echo '[docker]'; " "for n in $c; do " - "i=$(\"$D\" inspect --format '{{.Config.Image}}' \"$n\" 2>/dev/null); " + 'i=$("$D" inspect --format \'{{.Config.Image}}\' "$n" 2>/dev/null); ' 'case "$i" in *adguardhome-sync*|*bunkerweb*) ' 'echo "[container $n]"; echo "image $i"; ' - "\"$D\" inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"$n\" " + '"$D" inspect --format \'{{range .Config.Env}}{{println .}}{{end}}\' "$n" ' f"2>/dev/null | awk {quote(_ENV_AWK)};; esac; done; " "else echo '[docker-unreadable]'; fi; " "else echo '[no-docker]'; fi; " ) -def service_clusters_command(docker: Optional[str] = "docker") -> str: +def service_clusters_command(docker: str | None = "docker") -> str: """One line of POSIX ``sh``, read-only. *docker* is the engine's binary as the driver reaches it (QNAP's lives elsewhere), None for a host without one. The frame markers are printed in two halves so that a transport which echoes the @@ -165,11 +165,11 @@ def service_clusters_command(docker: Optional[str] = "docker") -> str: SERVICE_CLUSTERS_COMMAND = service_clusters_command() _COMMENT_RE = re.compile(r"\s+#.*$") -_Record = Tuple[str, str] -_Section = Tuple[str, str, List[_Record]] +_Record = tuple[str, str] +_Section = tuple[str, str, list[_Record]] -def _frame(output: str) -> List[str]: +def _frame(output: str) -> list[str]: lines = [line.strip() for line in strip_terminal_codes(output).splitlines()] try: start = lines.index(_BEGIN) @@ -179,9 +179,9 @@ def _frame(output: str) -> List[str]: return lines[start + 1 : end] -def _sections(lines: List[str]) -> List[_Section]: +def _sections(lines: list[str]) -> list[_Section]: """``[ ]`` headers, each with its records as ``(kind, rest)``.""" - sections: List[_Section] = [] + sections: list[_Section] = [] for line in lines: if line.startswith("[") and line.endswith("]"): kind, _, name = line[1:-1].partition(" ") @@ -202,13 +202,13 @@ def _clean(value: str) -> str: def _member( *, - address: Optional[str] = None, - port: Optional[int] = None, - name: Optional[str] = None, - role: Optional[str] = None, - state: Optional[str] = None, + address: str | None = None, + port: int | None = None, + name: str | None = None, + role: str | None = None, + state: str | None = None, this_host: bool = False, - config_from: Optional[int] = None, + config_from: int | None = None, ) -> ServiceClusterMemberDict: return { "name": name, @@ -225,9 +225,9 @@ def _cluster( mechanism: str, service: str, source: str, - cluster: Optional[str], - members: List[ServiceClusterMemberDict], - running: Optional[bool] = True, + cluster: str | None, + members: list[ServiceClusterMemberDict], + running: bool | None = True, ) -> ServiceClusterDict: return { "mechanism": mechanism, @@ -239,7 +239,7 @@ def _cluster( } -def _host_port(text: str) -> Tuple[Optional[str], Optional[int]]: +def _host_port(text: str) -> tuple[str | None, int | None]: """``"10.0.0.5:8008"`` or ``"[fd00::5]:8008"`` -> host and port.""" try: split = urlsplit(f"//{text.strip()}") @@ -248,7 +248,7 @@ def _host_port(text: str) -> Tuple[Optional[str], Optional[int]]: return None, None -def _url(text: str) -> Tuple[Optional[str], Optional[int]]: +def _url(text: str) -> tuple[str | None, int | None]: """A URL's host and port, the scheme's default where it names none.""" value = text.strip() if "://" not in value: @@ -264,7 +264,7 @@ def _url(text: str) -> Tuple[Optional[str], Optional[int]]: # --- Patroni ------------------------------------------------------------------ -def _patroni_members(rest: str, own: Optional[str]) -> Optional[List[ServiceClusterMemberDict]]: +def _patroni_members(rest: str, own: str | None) -> list[ServiceClusterMemberDict] | None: try: members = json.loads(rest).get("members") except (ValueError, AttributeError): @@ -289,9 +289,9 @@ def _patroni_members(rest: str, own: Optional[str]) -> Optional[List[ServiceClus return sorted(found, key=lambda m: m["name"] or "") -def _patroni(unit: str, records: List[_Record]) -> List[ServiceClusterDict]: - values: Dict[str, str] = {} - rest: Optional[str] = None +def _patroni(unit: str, records: list[_Record]) -> list[ServiceClusterDict]: + values: dict[str, str] = {} + rest: str | None = None for kind, text in records: if kind in ("active", "scope", "name", "restapi") and kind not in values: values[kind] = _clean(text) @@ -313,8 +313,8 @@ def _patroni(unit: str, records: List[_Record]) -> List[ServiceClusterDict]: # --- Containers ---------------------------------------------------------------- -def _env(records: List[_Record]) -> Dict[str, str]: - env: Dict[str, str] = {} +def _env(records: list[_Record]) -> dict[str, str]: + env: dict[str, str] = {} for kind, text in records: if kind == "env": key, _, value = text.partition(" ") @@ -322,13 +322,13 @@ def _env(records: List[_Record]) -> Dict[str, str]: return env -def _image_name(records: List[_Record]) -> str: +def _image_name(records: list[_Record]) -> str: """``bunkerity/bunkerweb@sha256:...`` -> ``bunkerweb``.""" image = next((text for kind, text in records if kind == "image"), "") return image.split("@")[0].rsplit("/", 1)[-1].split(":")[0] -def _adguardhome_sync(name: str, records: List[_Record]) -> Optional[List[ServiceClusterDict]]: +def _adguardhome_sync(name: str, records: list[_Record]) -> list[ServiceClusterDict] | None: env = _env(records) origin = _url(env["ORIGIN_URL"]) if env.get("ORIGIN_URL") else (None, None) if origin[0] is None: @@ -346,7 +346,7 @@ def _adguardhome_sync(name: str, records: List[_Record]) -> Optional[List[Servic return [_cluster("adguardhome-sync", "adguardhome", name, cluster, members)] -def _peer_address(entry: str) -> Optional[str]: +def _peer_address(entry: str) -> str | None: """An entry of a whitelist that names one peer: a single address that is no loopback, link-local or unspecified one -- not a network.""" text = entry.strip() @@ -367,12 +367,12 @@ def _peer_address(entry: str) -> Optional[str]: return str(address) -def _api_port(env: Dict[str, str]) -> int: +def _api_port(env: dict[str, str]) -> int: port = env.get("API_HTTP_PORT", "") return int(port) if port.isdigit() else _BUNKERWEB_API_PORT -def _bunkerweb_worker(name: str, env: Dict[str, str]) -> List[ServiceClusterDict]: +def _bunkerweb_worker(name: str, env: dict[str, str]) -> list[ServiceClusterDict]: managers = [ address for entry in re.split(r"[\s,]+", env.get("API_WHITELIST_IP", "")) @@ -385,7 +385,7 @@ def _bunkerweb_worker(name: str, env: Dict[str, str]) -> List[ServiceClusterDict return [_cluster("bunkerweb", "bunkerweb", name, managers[0], members)] -def _bunkerweb_manager(name: str, env: Dict[str, str]) -> List[ServiceClusterDict]: +def _bunkerweb_manager(name: str, env: dict[str, str]) -> list[ServiceClusterDict]: instances = [entry for entry in env.get("BUNKERWEB_INSTANCES", "").split() if entry] if not instances: return [] @@ -394,12 +394,14 @@ def _bunkerweb_manager(name: str, env: Dict[str, str]) -> List[ServiceClusterDic host, port = _host_port(entry) if host: members.append( - _member(address=host, port=port or _BUNKERWEB_API_PORT, role="worker", config_from=0) + _member( + address=host, port=port or _BUNKERWEB_API_PORT, role="worker", config_from=0 + ) ) return [_cluster("bunkerweb", "bunkerweb", name, None, members)] if len(members) > 1 else [] -def _bunkerweb(name: str, records: List[_Record]) -> List[ServiceClusterDict]: +def _bunkerweb(name: str, records: list[_Record]) -> list[ServiceClusterDict]: image = _image_name(records) if image == "bunkerweb": return _bunkerweb_worker(name, _env(records)) @@ -408,13 +410,13 @@ def _bunkerweb(name: str, records: List[_Record]) -> List[ServiceClusterDict]: return [] # the UI, the API, autoconf: no instance and no manager -_CONTAINER_READERS: Dict[str, Callable[[str, List[_Record]], Optional[List[ServiceClusterDict]]]] = { +_CONTAINER_READERS: dict[str, Callable[[str, list[_Record]], list[ServiceClusterDict] | None]] = { "adguardhome_sync": _adguardhome_sync, "bunkerweb": _bunkerweb, } -def _reader_for(records: List[_Record]) -> Optional[str]: +def _reader_for(records: list[_Record]) -> str | None: image = next((text for kind, text in records if kind == "image"), "") if "adguardhome-sync" in image: return "adguardhome_sync" @@ -423,13 +425,13 @@ def _reader_for(records: List[_Record]) -> Optional[str]: return None -def _containers(sections: List[_Section]) -> Dict[str, Optional[List[ServiceClusterDict]]]: +def _containers(sections: list[_Section]) -> dict[str, list[ServiceClusterDict] | None]: kinds = {kind for kind, _, _ in sections} if "no-docker" in kinds: return {"adguardhome_sync": [], "bunkerweb": []} if "docker" not in kinds: return {"adguardhome_sync": None, "bunkerweb": None} - found: Dict[str, Optional[List[ServiceClusterDict]]] = {"adguardhome_sync": [], "bunkerweb": []} + found: dict[str, list[ServiceClusterDict] | None] = {"adguardhome_sync": [], "bunkerweb": []} for kind, name, records in sections: key = _reader_for(records) if kind == "container" else None if key is None or found[key] is None: @@ -448,14 +450,20 @@ def parse_service_clusters(output: str) -> ServiceClustersDict: :raises ValueError: when the output carries no intact report. """ sections = _sections(_frame(output)) - patroni: Optional[List[ServiceClusterDict]] + patroni: list[ServiceClusterDict] | None if any(kind == "no-systemd" for kind, _, _ in sections): patroni = None - elif any(kind == "patroni" and any(r[0] == "unreadable" for r in records) for kind, _, records in sections): + elif any( + kind == "patroni" and any(r[0] == "unreadable" for r in records) + for kind, _, records in sections + ): patroni = None else: patroni = [ - c for kind, unit, records in sections if kind == "patroni" for c in _patroni(unit, records) + c + for kind, unit, records in sections + if kind == "patroni" + for c in _patroni(unit, records) ] containers = _containers(sections) return { @@ -522,7 +530,9 @@ class ServiceClustersMixin: """ command = f"sh -c {quote(service_clusters_command(self._service_clusters_docker()))}" try: - return parse_service_clusters(self._run_service_clusters_command(command, privileged=True)) + return parse_service_clusters( + self._run_service_clusters_command(command, privileged=True) + ) except ValueError: pass return parse_service_clusters(self._run_service_clusters_command(command, privileged=False)) diff --git a/napalm_device_types/service_packages.py b/napalm_device_types/service_packages.py index f65068a..cf438f1 100644 --- a/napalm_device_types/service_packages.py +++ b/napalm_device_types/service_packages.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Which installed packages a systemd service is made of. A vulnerability is found on a *package*; a statement about a *service* -- the @@ -26,7 +25,7 @@ nothing. from __future__ import annotations import re -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING _BEGIN = "SVCPKG_BEGIN" _END = "SVCPKG_END" @@ -61,7 +60,7 @@ SERVICE_PACKAGES_COMMAND = ( ) -def _report(output: str) -> Dict[str, List[str]]: +def _report(output: str) -> dict[str, list[str]]: lines = [line.rstrip() for line in output.splitlines()] stripped = [line.strip() for line in lines] try: @@ -69,8 +68,8 @@ def _report(output: str) -> Dict[str, List[str]]: end = stripped.index(_END, start) except ValueError: raise ValueError("no intact service package report in the output") from None - sections: Dict[str, List[str]] = {} - current: Optional[List[str]] = None + sections: dict[str, list[str]] = {} + current: list[str] | None = None for line in lines[start + 1 : end]: if line in ("[units]", "[dpkg]", "[rpm]"): current = sections.setdefault(line[1:-1], []) @@ -79,11 +78,11 @@ def _report(output: str) -> Dict[str, List[str]]: return sections -def _units(lines: List[str]) -> Dict[str, Set[str]]: +def _units(lines: list[str]) -> dict[str, set[str]]: """``systemctl show`` blocks -> ``{unit: its unit file and binary}``.""" - units: Dict[str, Set[str]] = {} + units: dict[str, set[str]] = {} unit = "" - paths: Set[str] = set() + paths: set[str] = set() for line in [*lines, ""]: key, _, value = line.partition("=") if not line.strip(): @@ -101,7 +100,7 @@ def _units(lines: List[str]) -> Dict[str, Set[str]]: return units -def _names(raw: str) -> Set[str]: +def _names(raw: str) -> set[str]: """``libfoo1:amd64, foo-bin`` -> ``{"libfoo1", "foo-bin"}``; nothing else counts.""" found = set() for name in raw.replace(",", " ").split(): @@ -111,9 +110,9 @@ def _names(raw: str) -> Set[str]: return found -def _owners(sections: Dict[str, List[str]]) -> Dict[str, Set[str]]: +def _owners(sections: dict[str, list[str]]) -> dict[str, set[str]]: """``{path: the packages owning it}`` from dpkg's or rpm's answer.""" - owners: Dict[str, Set[str]] = {} + owners: dict[str, set[str]] = {} for line in sections.get("dpkg", []): # "pkg[, pkg2][:arch]: /path"; "diversion by x from: /path" is no owner. if line.startswith("diversion by") or ": /" not in line: @@ -127,11 +126,11 @@ def _owners(sections: Dict[str, List[str]]) -> Dict[str, Set[str]]: return owners -def _across_usr_merge(path: str) -> Tuple[str, str]: +def _across_usr_merge(path: str) -> tuple[str, str]: return (path, path[4:] if path.startswith("/usr/") else "/usr" + path) -def parse_service_packages(output: str) -> Dict[str, List[str]]: +def parse_service_packages(output: str) -> dict[str, list[str]]: """Parse what :data:`SERVICE_PACKAGES_COMMAND` printed. ``{unit name without ".service": its packages, sorted}``. A unit no package @@ -163,7 +162,7 @@ class ServicePackagesMixin: """Run *command* on the host with ``sh`` and return what it printed.""" ... - def get_service_packages(self) -> Dict[str, List[str]]: + def get_service_packages(self) -> dict[str, list[str]]: """ Returns, per systemd service, the installed packages it is made of. diff --git a/napalm_device_types/services.py b/napalm_device_types/services.py index 1c8a490..5e43ec2 100644 --- a/napalm_device_types/services.py +++ b/napalm_device_types/services.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Init-system services: listing them and starting/stopping them. Deliberately *not* the same thing as a NAS's exported shares, which live on @@ -13,7 +12,7 @@ this class in can never shadow a working implementation from a sibling base. from __future__ import annotations -from typing import Any, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from napalm_device_types.models import ServiceDict @@ -21,7 +20,7 @@ from napalm_device_types.models import ServiceDict class ServiceControlMixin: if TYPE_CHECKING: - def get_services(self) -> List[ServiceDict]: + def get_services(self) -> list[ServiceDict]: """ Returns the list of system services known to the device's init system. @@ -42,7 +41,7 @@ class ServiceControlMixin: """ ... - def manage_service(self, name: str, action: str) -> Dict[str, Any]: + def manage_service(self, name: str, action: str) -> dict[str, Any]: """ Execute a lifecycle action on a named service. diff --git a/napalm_device_types/ssh_host_keys.py b/napalm_device_types/ssh_host_keys.py index 5c86e3f..a9d2df4 100644 --- a/napalm_device_types/ssh_host_keys.py +++ b/napalm_device_types/ssh_host_keys.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """SSH host keys: trust on first use, and a changed key refused before any login. A driver that accepts whatever host key answers at a device's address logs in @@ -41,9 +40,10 @@ import hashlib import logging import os import tempfile +from collections.abc import Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass -from typing import Any, Dict, Iterator, List, Mapping, Optional, Set +from typing import Any from napalm.base.exceptions import ConnectionException @@ -74,11 +74,11 @@ class SshHostKey: digest = hashlib.sha256(base64.b64decode(self.key)).digest() return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=") - def as_dict(self) -> Dict[str, str]: + def as_dict(self) -> dict[str, str]: return {"key_type": self.key_type, "key": self.key, "fingerprint": self.fingerprint} @classmethod - def from_value(cls, value: Any) -> Optional["SshHostKey"]: + def from_value(cls, value: Any) -> SshHostKey | None: """The key in *value* (a mapping with ``key_type`` and ``key``), or None for none.""" if not value: return None @@ -87,7 +87,7 @@ class SshHostKey: return cls(str(value["key_type"]), str(value["key"])) @classmethod - def of(cls, pkey: Any) -> "SshHostKey": + def of(cls, pkey: Any) -> SshHostKey: """The key of a paramiko ``PKey`` (what a transport says the server presented).""" return cls(pkey.get_name(), pkey.get_base64()) @@ -104,14 +104,14 @@ class HostKeyObservation: host: str port: int seen: SshHostKey - expected: Optional[SshHostKey] + expected: SshHostKey | None @property def accepted(self) -> bool: """True on first use and for the expected key; False for a refused one.""" return self.expected is None or self.expected == self.seen - def as_dict(self) -> Dict[str, Any]: + def as_dict(self) -> dict[str, Any]: return { "host": self.host, "port": self.port, @@ -161,7 +161,7 @@ class _RefuseUnknown: raise ConnectionException(f"SSH host key of {hostname} is not the one expected") -def _expected_key(known: Any, port: int) -> Optional[SshHostKey]: +def _expected_key(known: Any, port: int) -> SshHostKey | None: """The key in *known* (``{port: key}``, the port as text or number) for *port*.""" if not known: return None @@ -170,10 +170,10 @@ def _expected_key(known: Any, port: int) -> Optional[SshHostKey]: return SshHostKey.from_value(known.get(str(port)) or known.get(port)) -def _refused_key(exc: BaseException) -> Optional[Any]: +def _refused_key(exc: BaseException) -> Any | None: """paramiko's ``BadHostKeyException`` in *exc*'s chain (netmiko and drivers wrap it).""" - seen: Set[int] = set() - stack: List[Optional[BaseException]] = [exc] + seen: set[int] = set() + stack: list[BaseException | None] = [exc] while stack: current = stack.pop() if current is None or id(current) in seen: @@ -192,7 +192,7 @@ class HostKeyCheck: address the connection goes to, as given to paramiko or netmiko. """ - def __init__(self, optional_args: Optional[Mapping[str, Any]], host: str, port: int) -> None: + def __init__(self, optional_args: Mapping[str, Any] | None, host: str, port: int) -> None: args = optional_args or {} self.host = host self.port = int(port) @@ -212,7 +212,7 @@ class HostKeyCheck: if self.expected is None: client.set_missing_host_key_policy(_AcceptFirst()) return - with self._known_hosts_file() as path: + with self._known_hosts_file(self.expected) as path: client.get_host_keys().load(path) client.set_missing_host_key_policy(_RefuseUnknown()) @@ -228,7 +228,7 @@ class HostKeyCheck: # ------------------------------------------------------------------ @contextmanager - def netmiko(self, base: Mapping[str, Any]) -> Iterator[Dict[str, Any]]: + def netmiko(self, base: Mapping[str, Any]) -> Iterator[dict[str, Any]]: """``ConnectHandler`` keyword arguments: *base* with the host key decided. Open the connection inside the block and call :meth:`observe` on its @@ -243,7 +243,7 @@ class HostKeyCheck: with self.refusing(): yield kwargs return - with self._known_hosts_file() as path: + with self._known_hosts_file(self.expected) as path: kwargs.update(ssh_strict=True, alt_host_keys=True, alt_key_file=path) with self.refusing(): yield kwargs @@ -285,13 +285,12 @@ class HostKeyCheck: ) @contextmanager - def _known_hosts_file(self) -> Iterator[str]: - """A private ``known_hosts`` file holding only the expected key.""" - assert self.expected is not None + def _known_hosts_file(self, expected: SshHostKey) -> Iterator[str]: + """A private ``known_hosts`` file holding only *expected*.""" fd, path = tempfile.mkstemp(prefix="known_hosts-") try: with os.fdopen(fd, "w") as f: - f.write(self.expected.known_hosts_line(self.host, self.port) + "\n") + f.write(expected.known_hosts_line(self.host, self.port) + "\n") yield path finally: try: diff --git a/napalm_device_types/ssh_server.py b/napalm_device_types/ssh_server.py index 16857d7..a20c9db 100644 --- a/napalm_device_types/ssh_server.py +++ b/napalm_device_types/ssh_server.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """OpenSSH server hardening: taking algorithms out of what sshd offers. A scanner such as ssh-audit names algorithms a server should stop offering -- @@ -36,15 +35,18 @@ Only without such an ``Include`` is sshd_config itself rewritten, after a copy. from __future__ import annotations import time +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field from shlex import quote -from typing import Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING +from typing import ( + TYPE_CHECKING, +) if TYPE_CHECKING: # pragma: no cover - for type checkers only from napalm_device_types.channel import CommandResult #: The algorithm classes: key -> (``sshd -T`` keyword, sshd_config directive). -SSH_ALGORITHM_CLASSES: Dict[str, Tuple[str, str]] = { +SSH_ALGORITHM_CLASSES: dict[str, tuple[str, str]] = { "kex": ("kexalgorithms", "KexAlgorithms"), "hostkey": ("hostkeyalgorithms", "HostKeyAlgorithms"), "cipher": ("ciphers", "Ciphers"), @@ -65,7 +67,7 @@ _HEADER = ( #: Host key type, as ``ssh-keygen -l`` prints it -> the algorithms that key serves. #: Certificate algorithms are left out: they also need a HostCertificate. -_KEY_TYPE_ALGORITHMS: Dict[str, Tuple[str, ...]] = { +_KEY_TYPE_ALGORITHMS: dict[str, tuple[str, ...]] = { "ED25519": ("ssh-ed25519",), "RSA": ("rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"), "DSA": ("ssh-dss",), @@ -82,7 +84,7 @@ _RELOAD = ( ) -def _directive_key(line: str) -> Optional[str]: +def _directive_key(line: str) -> str | None: """The algorithm class a global sshd_config line sets, if it sets one.""" parts = line.split(None, 1) if not parts: @@ -94,12 +96,12 @@ def _directive_key(line: str) -> Optional[str]: return None -def parse_sshd_effective(text: str) -> Dict[str, List[str]]: +def parse_sshd_effective(text: str) -> dict[str, list[str]]: """The four algorithm lists from ``sshd -T`` output, in the server's order. :raises ValueError: if a list is missing -- not sshd -T output, or cut short. """ - found: Dict[str, List[str]] = {} + found: dict[str, list[str]] = {} for raw in text.splitlines(): parts = raw.strip().split(None, 1) if len(parts) != 2: @@ -113,9 +115,9 @@ def parse_sshd_effective(text: str) -> Dict[str, List[str]]: return found -def parse_sshd_hostkeys(text: str) -> List[str]: +def parse_sshd_hostkeys(text: str) -> list[str]: """The ``hostkey`` paths from ``sshd -T`` output, in order.""" - paths: List[str] = [] + paths: list[str] = [] for raw in text.splitlines(): parts = raw.strip().split(None, 1) if len(parts) == 2 and parts[0].lower() == "hostkey": @@ -123,12 +125,12 @@ def parse_sshd_hostkeys(text: str) -> List[str]: return paths -def host_key_algorithms(fingerprints: str) -> Optional[Set[str]]: +def host_key_algorithms(fingerprints: str) -> set[str] | None: """The host key algorithms the keys in ``ssh-keygen -l`` output can serve. None when a line is not understood or there is no key: then nothing is known. """ - usable: Set[str] = set() + usable: set[str] = set() for raw in fingerprints.splitlines(): parts = raw.split() if not parts: @@ -163,13 +165,13 @@ class Refusal: @dataclass(frozen=True) class SshHardeningPlan: - keep: Dict[str, List[str]] - remove: Dict[str, List[str]] - refused: List[Refusal] = field(default_factory=list) - not_offered: List[Refusal] = field(default_factory=list) + keep: dict[str, list[str]] + remove: dict[str, list[str]] + refused: list[Refusal] = field(default_factory=list) + not_offered: list[Refusal] = field(default_factory=list) @property - def changed_classes(self) -> List[str]: + def changed_classes(self) -> list[str]: return [k for k in SSH_ALGORITHM_CLASSES if self.remove.get(k)] @@ -177,8 +179,8 @@ def plan_ssh_hardening( effective: Mapping[str, Sequence[str]], remove: Mapping[str, Iterable[str]], *, - client: Optional[Mapping[str, Iterable[str]]] = None, - host_keys: Optional[Iterable[str]] = None, + client: Mapping[str, Iterable[str]] | None = None, + host_keys: Iterable[str] | None = None, ) -> SshHardeningPlan: """What the lists become when *remove* is taken out of *effective*. @@ -199,10 +201,10 @@ def plan_ssh_hardening( unknown = [k for k in remove if k not in SSH_ALGORITHM_CLASSES] if unknown: raise ValueError(f"unknown SSH algorithm class: {', '.join(unknown)}") - keep: Dict[str, List[str]] = {} - removed: Dict[str, List[str]] = {} - refused: List[Refusal] = [] - not_offered: List[Refusal] = [] + keep: dict[str, list[str]] = {} + removed: dict[str, list[str]] = {} + refused: list[Refusal] = [] + not_offered: list[Refusal] = [] usable_keys = set(host_keys) if host_keys is not None else None for kind in SSH_ALGORITHM_CLASSES: offered = list(effective.get(kind, [])) @@ -245,7 +247,7 @@ def render_dropin(keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> st return _HEADER + "".join(lines) -def dropin_classes(text: str) -> List[str]: +def dropin_classes(text: str) -> list[str]: """The classes an existing drop-in sets.""" return [k for k in (_directive_key(line.strip()) for line in text.splitlines()) if k] @@ -268,11 +270,11 @@ def has_dropin_include(text: str) -> bool: return False -def _split_our_block(text: str) -> Tuple[List[str], str]: +def _split_our_block(text: str) -> tuple[list[str], str]: """(the classes an earlier rewrite put above the file, the file without them).""" if not text.startswith(_HEADER): return [], text - lines = text[len(_HEADER):].splitlines(keepends=True) + lines = text[len(_HEADER) :].splitlines(keepends=True) ours = 0 while ours < len(lines) and _directive_key(lines[ours].strip()): ours += 1 @@ -288,7 +290,7 @@ def render_main_config(text: str, keep: Mapping[str, Sequence[str]], kinds: Iter """ earlier, body = _split_our_block(text) wanted = set(kinds) | set(earlier) - out: List[str] = [] + out: list[str] = [] in_match = False for raw in body.splitlines(): if _is_match(raw.strip()): @@ -312,8 +314,8 @@ class SshHardeningResult: plan: SshHardeningPlan applied: bool = False rolled_back: bool = False - path: Optional[str] = None - backup: Optional[str] = None + path: str | None = None + backup: str | None = None message: str = "" @@ -336,19 +338,19 @@ class OpenSshServerMixin: *, privileged: bool = False, timeout: float = 60, - stdin: Optional[bytes] = None, + stdin: bytes | None = None, ) -> CommandResult: ... # ── the host ──────────────────────────────────────────────────────── - def _ssh_run(self, command: str, stdin: Optional[bytes] = None) -> "CommandResult": + def _ssh_run(self, command: str, stdin: bytes | None = None) -> CommandResult: return self.run_command(command, privileged=True, timeout=30, stdin=stdin) - def _ssh_read(self, path: str) -> Optional[str]: + def _ssh_read(self, path: str) -> str | None: result = self._ssh_run(f"cat -- {quote(path)}") return result.stdout if result.exit_code == 0 else None - def _ssh_write(self, path: str, content: str, mode_of: Optional[str] = None) -> None: + def _ssh_write(self, path: str, content: str, mode_of: str | None = None) -> None: """Replace *path* with *content*, as root, without root reading stdin. A driver gains root with ``sudo -S`` and puts the password in front of @@ -367,18 +369,24 @@ class OpenSshServerMixin: stdin=content.encode(), ) tmp = staged.stdout.strip() - if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."): - raise RuntimeError(f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}") + # A path on the device, made by mktemp under umask 077: not a local temp file. + if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."): # nosec B108 + raise RuntimeError( + f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}" + ) part = path + ".netork-tmp" result = self._ssh_run( # GNU stat, then BSD's; 644 for a file that does not exist yet. - f"r={quote(mode_of or path)}; m=$(stat -c %a -- \"$r\" 2>/dev/null" + f'r={quote(mode_of or path)}; m=$(stat -c %a -- "$r" 2>/dev/null' f' || stat -f %Lp -- "$r" 2>/dev/null) || m=644;' - f' install -m "$m" -- {quote(tmp)} {quote(part)} && mv -f -- {quote(part)} {quote(path)};' + f' install -m "$m" -- {quote(tmp)} {quote(part)}' + f" && mv -f -- {quote(part)} {quote(path)};" f" s=$?; rm -f -- {quote(tmp)} {quote(part)}; exit $s" ) if result.exit_code != 0: - raise RuntimeError(f"could not write {path}: {(result.stderr or result.stdout).strip()}") + raise RuntimeError( + f"could not write {path}: {(result.stderr or result.stdout).strip()}" + ) def _ssh_remove(self, path: str) -> None: self._ssh_run(f"rm -f -- {quote(path)}") @@ -389,7 +397,7 @@ class OpenSshServerMixin: if result.exit_code != 0: raise RuntimeError(f"could not reload sshd: {(result.stderr or result.stdout).strip()}") - def get_ssh_server_algorithms(self) -> Dict[str, List[str]]: + def get_ssh_server_algorithms(self) -> dict[str, list[str]]: """The algorithm lists sshd uses now (``sshd -T``), per class. :raises RuntimeError: if sshd -T fails -- no OpenSSH, or no root. @@ -402,7 +410,7 @@ class OpenSshServerMixin: raise RuntimeError(f"sshd -T failed: {(result.stderr or result.stdout).strip()}") return result.stdout - def _ssh_host_key_algorithms(self, sshd_t: str) -> Optional[Set[str]]: + def _ssh_host_key_algorithms(self, sshd_t: str) -> set[str] | None: """The host key algorithms the host has a key for; None when that cannot be read. A ``HostKey`` whose file does not exist is skipped, as sshd skips it. @@ -418,7 +426,7 @@ class OpenSshServerMixin: return None return host_key_algorithms(result.stdout) - def _sshd_accepts(self) -> Optional[str]: + def _sshd_accepts(self) -> str | None: """None when ``sshd -t`` accepts the configuration, else what it said.""" result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -t") return None if result.exit_code == 0 else (result.stderr or result.stdout).strip() @@ -429,9 +437,9 @@ class OpenSshServerMixin: self, remove: Mapping[str, Iterable[str]], *, - client: Optional[Mapping[str, Iterable[str]]] = None, - verify: Optional[Callable[[], bool]] = None, - log: Optional[Callable[[str], None]] = None, + client: Mapping[str, Iterable[str]] | None = None, + verify: Callable[[], bool] | None = None, + log: Callable[[str], None] | None = None, ) -> SshHardeningResult: """Stop the server offering the algorithms in *remove* (class -> names). @@ -447,11 +455,13 @@ class OpenSshServerMixin: say = log or (lambda _line: None) say("Reading the algorithms sshd offers (sshd -T)") sshd_t = self._sshd_effective_text() - host_keys: Optional[Set[str]] = None + host_keys: set[str] | None = None if remove.get("hostkey"): say("Reading which host keys the server has (ssh-keygen -l)") host_keys = self._ssh_host_key_algorithms(sshd_t) - plan = plan_ssh_hardening(parse_sshd_effective(sshd_t), remove, client=client, host_keys=host_keys) + plan = plan_ssh_hardening( + parse_sshd_effective(sshd_t), remove, client=client, host_keys=host_keys + ) whys = { "last": "it is the last one", "client": "netOrk could no longer connect", @@ -477,7 +487,11 @@ class OpenSshServerMixin: return self._apply_or_restore(plan, kinds, result, restore, verify, say) def _write_change( - self, plan: SshHardeningPlan, main: str, result: SshHardeningResult, say: Callable[[str], None] + self, + plan: SshHardeningPlan, + main: str, + result: SshHardeningResult, + say: Callable[[str], None], ) -> Callable[[], None]: """Write the new lists; return what puts the previous state back.""" if has_dropin_include(main): @@ -509,10 +523,10 @@ class OpenSshServerMixin: def _apply_or_restore( self, plan: SshHardeningPlan, - kinds: List[str], + kinds: list[str], result: SshHardeningResult, restore: Callable[[], None], - verify: Optional[Callable[[], bool]], + verify: Callable[[], bool] | None, say: Callable[[str], None], ) -> SshHardeningResult: def back(message: str, reload: bool) -> SshHardeningResult: diff --git a/napalm_device_types/storage.py b/napalm_device_types/storage.py index 6ea52c4..74c62c4 100644 --- a/napalm_device_types/storage.py +++ b/napalm_device_types/storage.py @@ -10,9 +10,9 @@ Usage:: ... """ -from typing import Any, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING + from napalm_device_types.base import DeviceTypeDriver -from napalm_device_types.packages import PackageManagementMixin from napalm_device_types.models import ( DiskPoolDict, LogicalVolumeDict, @@ -23,6 +23,7 @@ from napalm_device_types.models import ( StorageServiceDict, VolumeSnapshotDict, ) +from napalm_device_types.packages import PackageManagementMixin class StorageDriver(PackageManagementMixin, DeviceTypeDriver): @@ -47,7 +48,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): if TYPE_CHECKING: - def get_disks(self) -> List[PhysicalDiskDict]: + def get_disks(self) -> list[PhysicalDiskDict]: """ Returns all physical drives detected by the storage device. @@ -62,7 +63,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): * rpm (int) - rotational speed; ``0`` for SSD/NVMe * temperature (int) - current temperature in Celsius; ``-1`` if unavailable * health (string) - ``"healthy"``, ``"warning"``, ``"failed"``, or ``"unknown"`` - * pool (string) - name of the pool this disk belongs to; empty string if unassigned/spare + * pool (string) - name of the pool this disk belongs to; + empty string if unassigned/spare Example:: @@ -95,7 +97,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): """ ... - def get_disk_pools(self) -> Dict[str, DiskPoolDict]: + def get_disk_pools(self) -> dict[str, DiskPoolDict]: """ Returns the disk pools (RAID arrays, ZFS pools, LVM volume groups, etc.) configured on the device. @@ -106,12 +108,14 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): * type (string) - ``"zfs"``, ``"lvm"``, ``"md"``, ``"hardware-raid"``, ``"btrfs"`` * level (string) - RAID/redundancy level: ``"mirror"``, ``"raidz1"``, ``"raidz2"``, ``"raidz3"``, ``"stripe"``, ``"raid0"`` … ``"raid60"``, ``"single"`` - * status (string) - ``"online"``, ``"degraded"``, ``"faulted"``, ``"offline"``, ``"unknown"`` + * status (string) - ``"online"``, ``"degraded"``, ``"faulted"``, + ``"offline"``, ``"unknown"`` * total (int) - usable capacity in bytes * used (int) - used space in bytes * available (int) - free space in bytes * disks (list of strings) - slot identifiers of member drives - * auto_expand (bool) - whether the pool grows automatically when disks are replaced with larger ones + * auto_expand (bool) - whether the pool grows automatically when disks + are replaced with larger ones * dedup (bool) - whether deduplication is enabled * compression (string) - pool-level compression algorithm: ``"off"``, ``"lz4"``, ``"gzip"``, ``"zstd"``, etc. @@ -140,7 +144,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # Logical volumes / datasets # ------------------------------------------------------------------ - def get_volumes(self) -> Dict[str, LogicalVolumeDict]: + def get_volumes(self) -> dict[str, LogicalVolumeDict]: """ Returns all logical volumes, ZFS datasets, or LUNs on the device. @@ -154,7 +158,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): * used (int) - space currently used in bytes * available (int) - space available in bytes * mountpoint (string) - local mount path; empty string for block volumes / LUNs - * compression (string) - active compression algorithm (``"off"``, ``"lz4"``, ``"zstd"``, etc.) + * compression (string) - active compression algorithm + (``"off"``, ``"lz4"``, ``"zstd"``, etc.) * dedup (bool) - whether deduplication is active on this volume * readonly (bool) - whether the volume is mounted read-only * snapshots (int) - number of snapshots currently held @@ -196,7 +201,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # Shares # ------------------------------------------------------------------ - def get_shares(self) -> Dict[str, NASShareDict]: + def get_shares(self) -> dict[str, NASShareDict]: """ Returns all network shares and iSCSI targets exported by the device. @@ -244,7 +249,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # Snapshots # ------------------------------------------------------------------ - def get_volume_snapshots(self, volume: str = "") -> List[VolumeSnapshotDict]: + def get_volume_snapshots(self, volume: str = "") -> list[VolumeSnapshotDict]: """ Returns volume / dataset snapshots. @@ -253,7 +258,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): Each entry contains: - * name (string) - snapshot name (e.g. ``"auto-2026-05-11"`` or full ``"tank/media@auto-2026-05-11"``) + * name (string) - snapshot name (e.g. ``"auto-2026-05-11"`` + or full ``"tank/media@auto-2026-05-11"``) * volume (string) - parent volume / dataset path * created (float) - creation timestamp (Unix epoch) * size (int) - bytes of unique data referenced only by this snapshot @@ -339,7 +345,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # Quotas # ------------------------------------------------------------------ - def get_quotas(self) -> List[StorageQuotaDict]: + def get_quotas(self) -> list[StorageQuotaDict]: """ Returns all filesystem quotas configured on the device. @@ -380,7 +386,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # Services # ------------------------------------------------------------------ - def get_storage_services(self) -> Dict[str, StorageServiceDict]: + def get_storage_services(self) -> dict[str, StorageServiceDict]: """ Returns the file-sharing and access services available on the device. @@ -443,7 +449,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # Replication # ------------------------------------------------------------------ - def get_replication_jobs(self) -> List[ReplicationJobDict]: + def get_replication_jobs(self) -> list[ReplicationJobDict]: """ Returns all replication / sync jobs configured on the device. @@ -493,8 +499,3 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver): # ------------------------------------------------------------------ # Package management (plugins / extensions) # ------------------------------------------------------------------ - - - - - diff --git a/napalm_device_types/switch.py b/napalm_device_types/switch.py index 07eee15..704c4f2 100644 --- a/napalm_device_types/switch.py +++ b/napalm_device_types/switch.py @@ -10,7 +10,9 @@ Usage:: ... """ -from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + from napalm_device_types.base import DeviceTypeDriver from napalm_device_types.mac_acl import MacAclMixin from napalm_device_types.models import ( @@ -45,7 +47,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver): async def get_health_metrics( cls, snmp_get: Callable[..., Awaitable[Any]], - snmp_walk: Callable[..., Awaitable[Dict[str, Any]]], + snmp_walk: Callable[..., Awaitable[dict[str, Any]]], ) -> HealthMetricsDict: """Collect SNMP health metrics for this switch type. @@ -54,7 +56,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver): """ ... - def get_spanning_tree(self) -> Dict[str, SpanningTreeDict]: + def get_spanning_tree(self) -> dict[str, SpanningTreeDict]: """ Returns spanning tree status for each STP instance. @@ -104,7 +106,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver): """ ... - def get_port_channels(self) -> Dict[str, PortChannelDict]: + def get_port_channels(self) -> dict[str, PortChannelDict]: """ Returns port-channel (LAG) configuration and status. @@ -129,8 +131,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver): """ ... - - def get_dot1x_ports(self) -> Dict[str, Dot1XPortDict]: + def get_dot1x_ports(self) -> dict[str, Dot1XPortDict]: """ Returns the 802.1X / NAC configuration per switch port. @@ -350,7 +351,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver): """ ... - def set_lag_members(self, lag_name: str, members: List[str]) -> None: + def set_lag_members(self, lag_name: str, members: list[str]) -> None: """ Sets the full member-port list of a LAG/trunk interface. diff --git a/napalm_device_types/system_identity.py b/napalm_device_types/system_identity.py index 045c8a2..6549d3b 100644 --- a/napalm_device_types/system_identity.py +++ b/napalm_device_types/system_identity.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """The SMBIOS identity of a host: system UUID, product serial, board serial. What ties a server to its BMC. The BMC reports the server's serial number and @@ -32,19 +31,37 @@ _DMI = "/sys/class/dmi/id" #: output cannot shift a field. SYSTEM_IDENTITY_COMMAND = ( f"printf '{_BEGIN}\\n%s\\n%s\\n%s\\n{_END}\\n' " - f"\"$(cat {_DMI}/product_uuid 2>/dev/null)\" " - f"\"$(cat {_DMI}/product_serial 2>/dev/null)\" " - f"\"$(cat {_DMI}/board_serial 2>/dev/null)\"" + f'"$(cat {_DMI}/product_uuid 2>/dev/null)" ' + f'"$(cat {_DMI}/product_serial 2>/dev/null)" ' + f'"$(cat {_DMI}/board_serial 2>/dev/null)"' ) #: Lowercased defaults board vendors leave in place. -_PLACEHOLDERS = frozenset({ - "", "none", "n/a", "na", "not specified", "not applicable", "not available", - "to be filled by o.e.m.", "to be filled by oem", "default string", "default", - "system serial number", "base board serial number", "chassis serial number", - "serial number", "0123456789", "1234567890", "123456789", "o.e.m.", "oem", - "03000200-0400-0500-0006-000700080009", -}) +_PLACEHOLDERS = frozenset( + { + "", + "none", + "n/a", + "na", + "not specified", + "not applicable", + "not available", + "to be filled by o.e.m.", + "to be filled by oem", + "default string", + "default", + "system serial number", + "base board serial number", + "chassis serial number", + "serial number", + "0123456789", + "1234567890", + "123456789", + "o.e.m.", + "oem", + "03000200-0400-0500-0006-000700080009", + } +) #: A value of one repeated character ("0", "00000000", "FFFF-...") or of zeros and Fs. _REPEATED = re.compile(r"^(.)\1*$") diff --git a/napalm_device_types/systemd.py b/napalm_device_types/systemd.py index d181d4b..1089818 100644 --- a/napalm_device_types/systemd.py +++ b/napalm_device_types/systemd.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """systemd services: listing them in one round trip, and starting and stopping them. What systemd reports about its services, and how one is started or stopped, is @@ -31,7 +30,7 @@ from __future__ import annotations import re from shlex import quote -from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from napalm_device_types.models import ServiceDict from napalm_device_types.services import ServiceControlMixin @@ -131,7 +130,7 @@ def service_action_command(name: str, action: str) -> str: ) -def parse_action_result(output: str) -> Dict[str, Any]: +def parse_action_result(output: str) -> dict[str, Any]: """``{"success", "output"}`` from what :func:`service_action_command` printed. Only the exit status decides. A job still running when ``timeout`` gave up @@ -151,7 +150,7 @@ def parse_action_result(output: str) -> Dict[str, Any]: return {"success": False, "output": text or f"systemctl exited with status {status}."} -def _frame(output: str) -> List[str]: +def _frame(output: str) -> list[str]: lines = [line.strip() for line in strip_terminal_codes(output).splitlines()] try: start = lines.index(_BEGIN) @@ -161,9 +160,9 @@ def _frame(output: str) -> List[str]: return lines[start + 1 : end] -def _sections(lines: List[str]) -> Dict[str, List[str]]: - sections: Dict[str, List[str]] = {} - current: List[str] = [] +def _sections(lines: list[str]) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current: list[str] = [] for line in lines: if line.startswith("[") and line.endswith("]"): current = sections.setdefault(line[1:-1], []) @@ -172,15 +171,15 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]: return sections -def _unit_blocks(lines: List[str]) -> List[Dict[str, str]]: +def _unit_blocks(lines: list[str]) -> list[dict[str, str]]: """``systemctl show``'s output, one dict per unit. Units are separated by a blank line -- except where ``xargs`` split the list over two runs and the blocks meet, so a key seen twice starts the next unit as well. """ - blocks: List[Dict[str, str]] = [] - current: Dict[str, str] = {} + blocks: list[dict[str, str]] = [] + current: dict[str, str] = {} for line in lines: key, sep, value = line.partition("=") if not sep or key in current: @@ -198,17 +197,17 @@ def _base(unit: str) -> str: return unit[: -len(_SUFFIX)] -def _main_pid(block: Dict[str, str]) -> int: +def _main_pid(block: dict[str, str]) -> int: try: return int(block.get("MainPID") or 0) except ValueError: return 0 -def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[str]]: +def _loaded(blocks: list[dict[str, str]]) -> tuple[dict[str, ServiceDict], set[str]]: """The loaded services, and every name they go by (aliases included).""" - services: Dict[str, ServiceDict] = {} - names: Set[str] = set() + services: dict[str, ServiceDict] = {} + names: set[str] = set() for block in blocks: unit = block.get("Id", "") if not unit.endswith(_SUFFIX) or block.get("LoadState") == "not-found": @@ -224,14 +223,14 @@ def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[s return services, names -def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]: +def _installed(lines: list[str], known: set[str]) -> dict[str, ServiceDict]: """Installed services that are not loaded: neither running nor starting now. Templates, static units and aliases are left out -- the last also when an older systemd lists an alias as ``enabled``, which is why every name a loaded unit goes by is skipped. """ - services: Dict[str, ServiceDict] = {} + services: dict[str, ServiceDict] = {} for line in lines: parts = line.split() if len(parts) < 2: @@ -253,7 +252,7 @@ def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]: return services -def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None: +def _apply_generated(services: dict[str, ServiceDict], lines: list[str]) -> None: """Take a generated unit's boot state from ``is-enabled``'s answer.""" for line in lines: parts = line.split() @@ -261,7 +260,7 @@ def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None services[_base(parts[0])]["enabled"] = parts[1] in _ENABLED -def parse_systemd_services(output: str) -> List[ServiceDict]: +def parse_systemd_services(output: str) -> list[ServiceDict]: """Parse what :data:`SYSTEMD_SERVICES_COMMAND` printed, sorted by name. Lists every loaded service unit but those that are not found, and every @@ -299,7 +298,7 @@ class SystemdServicesMixin(ServiceControlMixin): """ ... - def get_services(self) -> List[ServiceDict]: + def get_services(self) -> list[ServiceDict]: """ Returns the services systemd knows, in one round trip. @@ -316,7 +315,7 @@ class SystemdServicesMixin(ServiceControlMixin): ) return parse_systemd_services(output) - def manage_service(self, name: str, action: str) -> Dict[str, Any]: + def manage_service(self, name: str, action: str) -> dict[str, Any]: """ Applies *action* (start, stop, restart, enable, disable) to the service *name*. diff --git a/napalm_device_types/terminal.py b/napalm_device_types/terminal.py index c5e4c00..67aa284 100644 --- a/napalm_device_types/terminal.py +++ b/napalm_device_types/terminal.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """What a pseudo-terminal adds to a command's output, taken out again. A screen-scraping transport (netmiko) gives the remote command a terminal. Tools diff --git a/napalm_device_types/updates.py b/napalm_device_types/updates.py index 90059b4..7660181 100644 --- a/napalm_device_types/updates.py +++ b/napalm_device_types/updates.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Pending package updates and applying them. The two device types that offer this used to declare it under two different @@ -13,7 +12,7 @@ this class in can never shadow a working implementation from a sibling base. from __future__ import annotations -from typing import Any, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict, UpdateProgressDict @@ -21,7 +20,7 @@ from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict, Updat class UpdateMixin: if TYPE_CHECKING: - def get_available_updates(self) -> List[UpdateDict]: + def get_available_updates(self) -> list[UpdateDict]: """ Returns a list of packages that have a newer version available. @@ -51,7 +50,7 @@ class UpdateMixin: """ ... - def refresh_available_updates(self) -> Dict[str, Any]: + def refresh_available_updates(self) -> dict[str, Any]: """ Refreshes the host's package index, so that :meth:`get_available_updates` reports what the repositories offer now (``apt-get update``, @@ -73,7 +72,7 @@ class UpdateMixin: """ ... - def apply_updates(self, packages: List[str]) -> ApplyUpdatesResultDict: + def apply_updates(self, packages: list[str]) -> ApplyUpdatesResultDict: """ Upgrades the given packages to the newest available version. diff --git a/napalm_device_types/virtual_ips.py b/napalm_device_types/virtual_ips.py index 6733684..a8bb46b 100644 --- a/napalm_device_types/virtual_ips.py +++ b/napalm_device_types/virtual_ips.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Virtual IPs: the addresses a host's HA configuration lets float between machines. vip-manager and keepalived put a service address on whichever member is master @@ -51,9 +50,10 @@ from __future__ import annotations import ipaddress import re +from collections.abc import Callable from dataclasses import dataclass, field from shlex import quote -from typing import Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from napalm_device_types.models import VirtualIpDict, VirtualIpsDict from napalm_device_types.terminal import strip_terminal_codes @@ -108,7 +108,7 @@ _AWK = " ".join( r" if (!ok(f)) return; seen[f] = 1;" r' while (0 < (r = (getline l < f))) { sub(/[#!].*/, "", l); gsub(/[{}]/, " & ", l);' r' n = split(l, t, " "); if (n && nw && t[1] != "{") { stmt(w, nw, f, d); nw = 0 }' - r' for (k = 1; k <= n; k++) {' + r" for (k = 1; k <= n; k++) {" r' if (t[k] == "{") { push(nw ? w[1] : "", 1 < nw ? w[2] : ""); nw = 0 }' r' else if (t[k] == "}") { stmt(w, nw, f, d); nw = 0; pop() } else w[++nw] = t[k] } }' r" stmt(w, nw, f, d);" @@ -157,10 +157,10 @@ VIRTUAL_IPS_COMMAND = ( _COMMENT_RE = re.compile(r"\s+#.*$") _VARIABLE_RE = re.compile(r"^\$\{?(\w+)\}?$") -_Record = Tuple[str, str] +_Record = tuple[str, str] -def _frame(output: str) -> List[str]: +def _frame(output: str) -> list[str]: lines = [line.strip() for line in strip_terminal_codes(output).splitlines()] try: start = lines.index(_BEGIN) @@ -170,9 +170,9 @@ def _frame(output: str) -> List[str]: return lines[start + 1 : end] -def _sections(lines: List[str]) -> List[Tuple[str, str, List[_Record]]]: +def _sections(lines: list[str]) -> list[tuple[str, str, list[_Record]]]: """``[ ]`` headers, each with its records as ``(kind, rest)``.""" - sections: List[Tuple[str, str, List[_Record]]] = [] + sections: list[tuple[str, str, list[_Record]]] = [] for line in lines: if line.startswith("[") and line.endswith("]"): mechanism, _, unit = line[1:-1].partition(" ") @@ -191,7 +191,7 @@ def _clean(value: str) -> str: return value.strip() -def _address(text: str) -> Optional[Tuple[str, Optional[int]]]: +def _address(text: str) -> tuple[str, int | None] | None: """``"10.0.0.10/24"`` -> ``("10.0.0.10", 24)``; None for what is no address.""" host, slash, prefix = text.partition("/") try: @@ -205,7 +205,7 @@ def _address(text: str) -> Optional[Tuple[str, Optional[int]]]: return str(address), int(prefix) -def _prefix(mask: Optional[str]) -> Optional[int]: +def _prefix(mask: str | None) -> int | None: """``"24"`` or ``"255.255.255.0"`` -> 24.""" if not mask: return None @@ -217,27 +217,27 @@ def _prefix(mask: Optional[str]) -> Optional[int]: return None -def _running(records: List[_Record]) -> Optional[bool]: +def _running(records: list[_Record]) -> bool | None: states = [rest for kind, rest in records if kind == "active"] return states[-1] == "active" if states else None -def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]: - values: Dict[str, Dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}} +def _vip_manager(unit: str, records: list[_Record]) -> list[VirtualIpDict]: + values: dict[str, dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}} for kind, rest in records: if kind in values: key, _, value = rest.partition(" ") values[kind][key] = value env = values["env"] - def resolve(value: str) -> Optional[str]: + def resolve(value: str) -> str | None: value = _clean(value) variable = _VARIABLE_RE.match(value) if variable: value = _clean(env.get(variable.group(1), "")) return value if value and "$" not in value else None - def pick(args: Tuple[str, ...], envs: Tuple[str, ...], cfgs: Tuple[str, ...]) -> Optional[str]: + def pick(args: tuple[str, ...], envs: tuple[str, ...], cfgs: tuple[str, ...]) -> str | None: for kind, names in (("arg", args), ("env", envs), ("cfg", cfgs)): for name in names: if name in values[kind]: @@ -272,15 +272,15 @@ def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]: @dataclass class _Instance: name: str - interface: Optional[str] = None + interface: str | None = None vrid: str = "" - vips: List[str] = field(default_factory=list) + vips: list[str] = field(default_factory=list) -def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]: +def _keepalived(unit: str, records: list[_Record]) -> list[VirtualIpDict]: running = _running(records) - instances: List[_Instance] = [] - current: Optional[_Instance] = None + instances: list[_Instance] = [] + current: _Instance | None = None for kind, rest in records: if kind == "instance": current = _Instance(rest or unit) @@ -298,8 +298,8 @@ def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]: return [entry for instance in instances for entry in _instance_entries(instance, running)] -def _instance_entries(instance: _Instance, running: Optional[bool]) -> List[VirtualIpDict]: - entries: List[VirtualIpDict] = [] +def _instance_entries(instance: _Instance, running: bool | None) -> list[VirtualIpDict]: + entries: list[VirtualIpDict] = [] for vip in instance.vips: tokens = vip.split() parsed = _address(tokens[0]) if tokens else None @@ -326,13 +326,13 @@ def _host_prefix(address: str) -> int: return ipaddress.ip_address(address).max_prefixlen -_READERS: Dict[str, Tuple[str, Callable[[str, List[_Record]], List[VirtualIpDict]]]] = { +_READERS: dict[str, tuple[str, Callable[[str, list[_Record]], list[VirtualIpDict]]]] = { "vip-manager": ("vip_manager", _vip_manager), "keepalived": ("keepalived", _keepalived), } -def _order(entry: VirtualIpDict) -> Tuple[int, int, str]: +def _order(entry: VirtualIpDict) -> tuple[int, int, str]: address = ipaddress.ip_address(entry["address"]) return address.version, int(address), entry["instance"] @@ -346,8 +346,8 @@ def parse_virtual_ips(output: str) -> VirtualIpsDict: sections = _sections(_frame(output)) if any(mechanism == _NO_SYSTEMD for mechanism, _, _ in sections): return {"vip_manager": None, "keepalived": None} - found: Dict[str, List[VirtualIpDict]] = {"vip_manager": [], "keepalived": []} - blind: Set[str] = set() + found: dict[str, list[VirtualIpDict]] = {"vip_manager": [], "keepalived": []} + blind: set[str] = set() for mechanism, unit, records in sections: if mechanism not in _READERS: continue @@ -356,7 +356,8 @@ def parse_virtual_ips(output: str) -> VirtualIpsDict: blind.add(key) else: found[key].extend(read(unit, records)) - def result(key: str) -> Optional[List[VirtualIpDict]]: + + def result(key: str) -> list[VirtualIpDict] | None: return None if key in blind else sorted(found[key], key=_order) return {"vip_manager": result("vip_manager"), "keepalived": result("keepalived")} diff --git a/pyproject.toml b/pyproject.toml index 8b0471a..6d850cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [build-system] -requires = ["setuptools>=68", "wheel"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "napalm-device-types" -version = "4.21.0" +version = "4.21.1" description = "Abstract device-type base classes for NAPALM drivers" readme = "README.md" requires-python = ">=3.10" -license = { text = "Apache-2.0" } +license = "Apache-2.0" authors = [ { name = "Christian Manivong", email = "christian@manivong.de" }, ] @@ -28,7 +28,6 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -45,16 +44,16 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/chrismanivong/napalm-device-types" -Repository = "https://github.com/chrismanivong/napalm-device-types" -Issues = "https://github.com/chrismanivong/napalm-device-types/issues" +Repository = "https://git.netork.io/NAPALM/napalm-device-types" +Issues = "https://git.netork.io/NAPALM/napalm-device-types/issues" +Changelog = "https://git.netork.io/NAPALM/napalm-device-types/src/branch/main/CHANGELOG.md" [project.optional-dependencies] dev = [ - "pytest", - "mypy", - "build", - "twine", + "pytest>=8", + "pytest-cov>=5", + "mypy>=1.11", + "ruff>=0.6", ] [tool.setuptools.packages.find] @@ -64,7 +63,27 @@ include = ["napalm_device_types*"] [tool.setuptools.package-data] napalm_device_types = ["py.typed"] +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.ruff.lint.per-file-ignores] +# Recorded device output keeps its lines. +"tests/**" = ["E501"] + [tool.mypy] python_version = "3.10" strict = true ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.run] +source = ["napalm_device_types"] + +[tool.coverage.report] +fail_under = 95 diff --git a/tests/quality_baseline.json b/tests/quality_baseline.json new file mode 100644 index 0000000..488f4d5 --- /dev/null +++ b/tests/quality_baseline.json @@ -0,0 +1,39 @@ +{ + "coverage_floor": 95.0, + "function_complexity": { + "napalm_device_types/_ucd_metrics.py::build_if_metrics": 20, + "napalm_device_types/_ucd_metrics.py::collect_ucd_metrics": 17, + "napalm_device_types/host_firewall.py::_iptables_rule": 11, + "napalm_device_types/host_firewall.py::_module_match": 13, + "napalm_device_types/host_firewall.py::_module_options": 14, + "napalm_device_types/host_firewall.py::_nft_field": 15, + "napalm_device_types/host_firewall.py::_nft_match": 12, + "napalm_device_types/host_firewall.py::_nft_rule": 16, + "napalm_device_types/host_firewall.py::_parse_nft": 14, + "napalm_device_types/host_firewall.py::rule_text": 14, + "napalm_device_types/host_status.py::_reboot": 12, + "napalm_device_types/lag.py::add_lag_interfaces": 11, + "napalm_device_types/listening.py::parse_listening_sockets": 13, + "napalm_device_types/service_clusters.py::_patroni": 11, + "napalm_device_types/ssh_server.py::plan_ssh_hardening": 38, + "napalm_device_types/virtual_ips.py::_keepalived": 12 + }, + "function_lines": { + "napalm_device_types/_ucd_metrics.py::collect_ucd_metrics": 119, + "napalm_device_types/dhcp.py::DhcpServerMixin.diff_dhcp_reservations": 54, + "napalm_device_types/dhcp.py::DhcpServerMixin.diff_dhcp_subnets": 60, + "napalm_device_types/firewall_rules.py::FirewallRuleMixin.diff_firewall_rules": 56, + "napalm_device_types/hypervisor.py::HypervisorDriver.create_vm_from_cloud_init": 84, + "napalm_device_types/hypervisor.py::HypervisorDriver.get_vm_config": 77, + "napalm_device_types/ssh_server.py::OpenSshServerMixin.harden_ssh_server": 52, + "napalm_device_types/ssh_server.py::plan_ssh_hardening": 60, + "napalm_device_types/storage.py::StorageDriver.get_volumes": 52, + "napalm_device_types/switch.py::SwitchDriver.get_dot1x_ports": 51, + "napalm_device_types/switch.py::SwitchDriver.get_poe_status": 63 + }, + "mypy_relaxed": [], + "python_file_lines": { + "napalm_device_types/models.py": 1504 + }, + "untyped_public": [] +} diff --git a/tests/test_channel.py b/tests/test_channel.py index 83d23c2..954237a 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -8,8 +8,6 @@ paramiko channel is faked, so the tests pin behaviour, not paramiko. from __future__ import annotations -import socket - import pytest from napalm_device_types.channel import ( @@ -56,7 +54,7 @@ class FakeChannel: if self.out: return self.out.pop(0) if self.hang: - raise socket.timeout() + raise TimeoutError() return b"" def recv_stderr_ready(self): diff --git a/tests/test_container_engine.py b/tests/test_container_engine.py index 53199a0..24db7da 100644 --- a/tests/test_container_engine.py +++ b/tests/test_container_engine.py @@ -32,7 +32,9 @@ class FakeDriver(ContainerEngineMixin): def run_command(self, command, *, privileged=False, timeout=60, stdin=None): self.commands.append((command, privileged, timeout, stdin)) found = any(command == f"command -v {b}" for b in self._binaries()) - return CommandResult(stdout="/usr/bin/x\n" if found else "", stderr="", exit_code=0 if found else 1) + return CommandResult( + stdout="/usr/bin/x\n" if found else "", stderr="", exit_code=0 if found else 1 + ) def open_stream(self, command, *, privileged=False): self.streams.append((command, privileged)) @@ -86,7 +88,9 @@ def test_cli_arguments_are_quoted_and_prefixed_by_the_binary(): def test_cli_stdin_and_timeout_reach_the_channel(): driver = FakeDriver() - driver.open_container_engine("docker").run_cli(["login", "--password-stdin"], stdin=b"x", timeout=30) + driver.open_container_engine("docker").run_cli( + ["login", "--password-stdin"], stdin=b"x", timeout=30 + ) _command, _privileged, timeout, stdin = driver.commands[-1] assert (timeout, stdin) == (30, b"x") diff --git a/tests/test_dhcp_diff_apply.py b/tests/test_dhcp_diff_apply.py index 2d567ca..4aaef44 100644 --- a/tests/test_dhcp_diff_apply.py +++ b/tests/test_dhcp_diff_apply.py @@ -9,9 +9,10 @@ principle: generic vs. device-specific logic" for why this logic lives here and not in a vendor driver. """ -from typing import Any, Dict, List, Optional +from typing import Any import pytest + from napalm_device_types import DhcpServerMixin, FirewallDriver, ResidentialGatewayDriver from napalm_device_types.dhcp import normalize_mac from napalm_device_types.models import DhcpReservationDict @@ -33,21 +34,21 @@ def _reservation(**overrides: Any) -> DhcpReservationDict: class _FakeDhcpServer(DhcpServerMixin): """In-memory fake — no network, no Kea/UCI specifics.""" - def __init__(self, live: Optional[List[DhcpReservationDict]] = None) -> None: + def __init__(self, live: list[DhcpReservationDict] | None = None) -> None: self.live = live or [] - self.applied: List[Dict[str, Any]] = [] + self.applied: list[dict[str, Any]] = [] self.commits = 0 - def get_dhcp_reservations(self) -> List[DhcpReservationDict]: + def get_dhcp_reservations(self) -> list[DhcpReservationDict]: return self.live def apply_dhcp_reservation( - self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None - ) -> Dict[str, Any]: + self, reservation: DhcpReservationDict, *, uuid: str | None = None + ) -> dict[str, Any]: self.applied.append({"reservation": reservation, "uuid": uuid}) return {"success": True} - def commit_dhcp_reservations(self) -> Dict[str, Any]: + def commit_dhcp_reservations(self) -> dict[str, Any]: self.commits += 1 return {"success": True} diff --git a/tests/test_dhcp_ha_config_sync.py b/tests/test_dhcp_ha_config_sync.py index 89ccefa..fbabb79 100644 --- a/tests/test_dhcp_ha_config_sync.py +++ b/tests/test_dhcp_ha_config_sync.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Two facts an HA pair of servers reports about itself. `get_dhcp_ha` is what a DHCP server's own HA configuration says: the peers it diff --git a/tests/test_dhcp_subnet_diff_apply.py b/tests/test_dhcp_subnet_diff_apply.py index f478c42..39d3f1c 100644 --- a/tests/test_dhcp_subnet_diff_apply.py +++ b/tests/test_dhcp_subnet_diff_apply.py @@ -11,9 +11,10 @@ A subnet is a heavier object than a reservation: a wrong `pools` or below lean on the never-delete and preserve-unmanaged-options guarantees. """ -from typing import Any, Dict, List, Optional +from typing import Any import pytest + from napalm_device_types import DhcpServerMixin from napalm_device_types.models import DhcpSubnetDict @@ -35,21 +36,21 @@ def _subnet(**overrides: Any) -> DhcpSubnetDict: class _FakeDhcpServer(DhcpServerMixin): - def __init__(self, live: Optional[List[DhcpSubnetDict]] = None) -> None: + def __init__(self, live: list[DhcpSubnetDict] | None = None) -> None: self.live = live or [] - self.applied: List[Dict[str, Any]] = [] + self.applied: list[dict[str, Any]] = [] self.commits = 0 - def get_dhcp_subnets(self) -> List[DhcpSubnetDict]: + def get_dhcp_subnets(self) -> list[DhcpSubnetDict]: return self.live def apply_dhcp_subnet( - self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None - ) -> Dict[str, Any]: + self, subnet: DhcpSubnetDict, *, uuid: str | None = None + ) -> dict[str, Any]: self.applied.append({"subnet": subnet, "uuid": uuid}) return {"success": True} - def commit_dhcp_subnets(self) -> Dict[str, Any]: + def commit_dhcp_subnets(self) -> dict[str, Any]: self.commits += 1 return {"success": True} @@ -215,9 +216,7 @@ class _BareDriver(DhcpServerMixin): pass -@pytest.mark.parametrize( - "method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"] -) +@pytest.mark.parametrize("method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"]) def test_device_specific_methods_are_absent_until_implemented(method: str) -> None: """Declared under TYPE_CHECKING, so they do not exist until a driver adds them.""" assert not hasattr(_BareDriver, method) diff --git a/tests/test_fact_getters.py b/tests/test_fact_getters.py index f715bfc..ac820fa 100644 --- a/tests/test_fact_getters.py +++ b/tests/test_fact_getters.py @@ -31,7 +31,7 @@ def test_a_missing_license_may_say_what_the_device_withholds_without_it(): # firmware inventory until DCMS is activated: partly licensed, not unlicensed. hints = get_type_hints(LicenseDict) # Python 3.10 keeps NotRequired[...] around the type; later versions strip it. - assert "List[str]" in str(hints["withholds"]) + assert "list[str]" in str(hints["withholds"]) assert "withholds" not in LicenseDict.__required_keys__ diff --git a/tests/test_fingerprint_base.py b/tests/test_fingerprint_base.py index 89738f9..3ad761d 100644 --- a/tests/test_fingerprint_base.py +++ b/tests/test_fingerprint_base.py @@ -3,21 +3,21 @@ from __future__ import annotations import pytest +from napalm.base import NetworkDriver + from napalm_device_types import ( AccessPointDriver, DeviceTypeDriver, FingerprintRule, FirewallDriver, - rules_max_weight, HypervisorDriver, OSDriver, PortSpec, ResidentialGatewayDriver, StorageDriver, SwitchDriver, + rules_max_weight, ) -from napalm.base import NetworkDriver - # ── Hierarchy ───────────────────────────────────────────────────────────────── @@ -49,6 +49,7 @@ def test_device_type_driver_inherits_network_driver(): # ── Default attributes ──────────────────────────────────────────────────────── + def test_defaults_on_device_type_driver(): assert DeviceTypeDriver.VENDOR == "" assert DeviceTypeDriver.DRIVER_NAME == "" @@ -72,6 +73,7 @@ def test_fingerprint_attributes_inherited(cls): # ── FingerprintRule ─────────────────────────────────────────────────────────── + def test_fingerprint_rule_defaults(): r = FingerprintRule("fritz!box") assert r.pattern == "fritz!box" @@ -102,6 +104,7 @@ def test_fingerprint_rule_group(): # ── rules_max_weight: the most a set of rules can add ───────────────────────── + def test_max_weight_sums_rules_that_can_match_together(): rules = [FingerprintRule("proxmox virtual environment", 9.0), FingerprintRule("pve", 2.0)] assert rules_max_weight(rules) == 11.0 @@ -146,6 +149,7 @@ def test_fingerprint_rule_is_immutable(): # ── PortSpec ────────────────────────────────────────────────────────────────── + def test_port_spec_defaults(): p = PortSpec("https", 8006) assert p.scheme == "https" @@ -169,6 +173,7 @@ def test_port_spec_is_immutable(): # ── Concrete driver subclass ────────────────────────────────────────────────── + def test_concrete_driver_overrides_fingerprint(): class MyDriver(FirewallDriver): VENDOR = "Acme" diff --git a/tests/test_firewall_diff_apply.py b/tests/test_firewall_diff_apply.py index 471972c..1001990 100644 --- a/tests/test_firewall_diff_apply.py +++ b/tests/test_firewall_diff_apply.py @@ -9,9 +9,10 @@ device-specific logic" for why this logic lives here and not in a vendor driver. """ -from typing import Any, Dict, List, Optional +from typing import Any import pytest + from napalm_device_types import FirewallDriver from napalm_device_types.models import FirewallRuleDict @@ -39,21 +40,21 @@ def _rule(**overrides: Any) -> FirewallRuleDict: class _FakeFirewall(FirewallDriver): """In-memory fake — no network, no OPNsense/vendor specifics.""" - def __init__(self, live_rules: Optional[List[FirewallRuleDict]] = None) -> None: + def __init__(self, live_rules: list[FirewallRuleDict] | None = None) -> None: self.live_rules = live_rules or [] - self.applied: List[Dict[str, Any]] = [] + self.applied: list[dict[str, Any]] = [] self.committed = False - def get_firewall_rules(self) -> List[FirewallRuleDict]: + def get_firewall_rules(self) -> list[FirewallRuleDict]: return self.live_rules def apply_firewall_rule( - self, rule: FirewallRuleDict, *, uuid: Optional[str] = None - ) -> Dict[str, Any]: + self, rule: FirewallRuleDict, *, uuid: str | None = None + ) -> dict[str, Any]: self.applied.append({"rule": rule, "uuid": uuid}) return {"success": True} - def commit_firewall_rules(self) -> Dict[str, Any]: + def commit_firewall_rules(self) -> dict[str, Any]: self.committed = True return {"success": True} diff --git a/tests/test_fix_templates.py b/tests/test_fix_templates.py index 886a975..e6b866f 100644 --- a/tests/test_fix_templates.py +++ b/tests/test_fix_templates.py @@ -17,12 +17,12 @@ from napalm_device_types import ( ManagementControllerDriver, MediaDriver, OSDriver, + PackageProxyMixin, PhoneDriver, ResidentialGatewayDriver, + SnmpAgentMixin, StorageDriver, SwitchDriver, - PackageProxyMixin, - SnmpAgentMixin, run_fix_steps, ) @@ -97,7 +97,12 @@ class _Agent(SnmpAgentMixin): class TestSnmpAgent: - SETTINGS = {"community": "c0mm", "allowed_sources": ["10.0.0.5"], "contact": "ops", "location": "rack 1"} + SETTINGS = { + "community": "c0mm", + "allowed_sources": ["10.0.0.5"], + "contact": "ops", + "location": "rack 1", + } def test_install_configure_open_and_start_with_the_caller_s_values(self): agent = _Agent() @@ -153,7 +158,10 @@ class TestEnsurePackage: def test_an_install_that_reports_failure_fails(self): driver = _Packages(installed=[]) - driver.install_package = lambda name, version="": {"success": False, "output": "E: no candidate"} + driver.install_package = lambda name, version="": { + "success": False, + "output": "E: no candidate", + } result = driver.ensure_package("lldpd") @@ -164,7 +172,12 @@ class TestEnsurePackage: @pytest.mark.parametrize("base", ROLE_BASES, ids=lambda b: b.__name__) @pytest.mark.parametrize( "method", - ["configure_snmp_agent", "ensure_package", "configure_package_proxy", "grant_container_engine_access"], + [ + "configure_snmp_agent", + "ensure_package", + "configure_package_proxy", + "grant_container_engine_access", + ], ) def test_no_role_base_offers_a_fix(base, method): assert getattr(base, method, None) is None diff --git a/tests/test_host_status.py b/tests/test_host_status.py index 1b3341c..2465e46 100644 --- a/tests/test_host_status.py +++ b/tests/test_host_status.py @@ -452,7 +452,10 @@ class TestBusyCheck: UBUNTU_IDLE = [ ("systemd", "/sbin/init"), - ("unattended-upgr", "/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal"), + ( + "unattended-upgr", + "/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal", + ), ("sshd", "sshd: netork [priv]"), ] diff --git a/tests/test_lag_interfaces.py b/tests/test_lag_interfaces.py index 852655c..f144936 100644 --- a/tests/test_lag_interfaces.py +++ b/tests/test_lag_interfaces.py @@ -3,7 +3,9 @@ from napalm_device_types import add_lag_interfaces -def _port(is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = "") -> dict: +def _port( + is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = "" +) -> dict: port = { "is_up": is_up, "is_enabled": is_enabled, diff --git a/tests/test_listening_sockets.py b/tests/test_listening_sockets.py index b67954f..e808637 100644 --- a/tests/test_listening_sockets.py +++ b/tests/test_listening_sockets.py @@ -89,7 +89,7 @@ class TestParsing: ], ) def test_every_address_form(self, local: str, address: str, interface): - line = f"tcp LISTEN 0 128 {local} *:* users:((\"x\",pid=1,fd=3))\n" + line = f'tcp LISTEN 0 128 {local} *:* users:(("x",pid=1,fd=3))\n' [socket] = parse_listening_sockets(_wire(line, "1 0::/system.slice/x.service\n")) @@ -188,7 +188,7 @@ class TestFailures: def test_a_report_cut_short_raises(self): with pytest.raises(ValueError): - parse_listening_sockets(_wire()[:-len("SOCK_END\n")]) + parse_listening_sockets(_wire()[: -len("SOCK_END\n")]) def test_a_host_without_ss_or_netstat_is_unavailable(self): with pytest.raises(ListeningSocketsUnavailable): diff --git a/tests/test_ping_sweep.py b/tests/test_ping_sweep.py index 90bef7d..03a34db 100644 --- a/tests/test_ping_sweep.py +++ b/tests/test_ping_sweep.py @@ -5,7 +5,6 @@ from napalm.base import NetworkDriver from napalm_device_types import DeviceTypeDriver, PingSweepMixin, driver_supports_ping - # ── Fakes ───────────────────────────────────────────────────────────────────── @@ -225,4 +224,9 @@ def test_ping_sweep_stops_when_stop_requested(): def test_a_malformed_success_is_no_answer_not_an_abort(success): entry = PingSweepMixin._parse_ping_reply("192.0.2.1", {"success": success}) - assert entry == {"ip": "192.0.2.1", "alive": False, "rtt_ms": None, "error": "malformed ping reply"} + assert entry == { + "ip": "192.0.2.1", + "alive": False, + "rtt_ms": None, + "error": "malformed ping reply", + } diff --git a/tests/test_port_forwards.py b/tests/test_port_forwards.py index 7807d39..19b3ccb 100644 --- a/tests/test_port_forwards.py +++ b/tests/test_port_forwards.py @@ -19,7 +19,7 @@ from napalm_device_types.nat_vpn import NatVpnMixin def test_a_firewall_and_a_gateway_share_the_declaration(): assert issubclass(FirewallDriver, NatVpnMixin) assert issubclass(ResidentialGatewayDriver, NatVpnMixin) - assert "def get_port_forwards(self) -> List[PortForwardDict]" in inspect.getsource(NatVpnMixin) + assert "def get_port_forwards(self) -> list[PortForwardDict]" in inspect.getsource(NatVpnMixin) def test_it_is_declared_once(): diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py index dd52b73..56ff10f 100644 --- a/tests/test_provisioning.py +++ b/tests/test_provisioning.py @@ -4,6 +4,7 @@ the same for every hypervisor (NetOrk/netork#794).""" from __future__ import annotations import pytest + from napalm_device_types import HypervisorDriver from napalm_device_types.provisioning import ( QEMU_GUEST_AGENTS, diff --git a/tests/test_role_contracts.py b/tests/test_role_contracts.py index a8b9f50..0c60958 100644 --- a/tests/test_role_contracts.py +++ b/tests/test_role_contracts.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """The contract that makes multi-role drivers safe. A role base declares what a device of that kind can be asked for. It must not diff --git a/tests/test_send_wake_on_lan.py b/tests/test_send_wake_on_lan.py index b392ffd..128855c 100644 --- a/tests/test_send_wake_on_lan.py +++ b/tests/test_send_wake_on_lan.py @@ -9,6 +9,7 @@ sibling base. """ import pytest + from napalm_device_types import FirewallDriver diff --git a/tests/test_service_clusters.py b/tests/test_service_clusters.py index 2bf8a7f..9c74810 100644 --- a/tests/test_service_clusters.py +++ b/tests/test_service_clusters.py @@ -35,14 +35,29 @@ SECRET = "s3cr3t-do-not-leak" def _wire(*sections: str, systemd: bool = True, docker: str = "[docker]", noise: str = "") -> str: head = "[systemd]" if systemd else "[no-systemd]" - body = "\n".join([head, *sections[:1], docker, *sections[1:]]) if sections else f"{head}\n{docker}" + body = ( + "\n".join([head, *sections[:1], docker, *sections[1:]]) if sections else f"{head}\n{docker}" + ) return f"{noise}SVCC_BEGIN\n{body}\nSVCC_END\n" DB01_CLUSTER = { "members": [ - {"name": "db-01", "role": "leader", "state": "running", "host": "10.7.234.12", "port": 5432}, - {"name": "db-02", "role": "sync_standby", "state": "streaming", "host": "10.7.234.11", "port": 5432, "lag": 0}, + { + "name": "db-01", + "role": "leader", + "state": "running", + "host": "10.7.234.12", + "port": 5432, + }, + { + "name": "db-02", + "role": "sync_standby", + "state": "streaming", + "host": "10.7.234.11", + "port": 5432, + "lag": 0, + }, ], "scope": "netork-db", } @@ -79,10 +94,24 @@ class TestPatroni: "cluster": "netork-db", "running": True, "members": [ - {"name": "db-01", "address": "10.7.234.12", "port": 5432, "role": "leader", - "state": "running", "this_host": True, "config_from": None}, - {"name": "db-02", "address": "10.7.234.11", "port": 5432, "role": "sync_standby", - "state": "streaming", "this_host": False, "config_from": None}, + { + "name": "db-01", + "address": "10.7.234.12", + "port": 5432, + "role": "leader", + "state": "running", + "this_host": True, + "config_from": None, + }, + { + "name": "db-02", + "address": "10.7.234.11", + "port": 5432, + "role": "sync_standby", + "state": "streaming", + "this_host": False, + "config_from": None, + }, ], } ] @@ -91,8 +120,11 @@ class TestPatroni: reading = parse_service_clusters( _wire( _patroni( - "active active", "scope 'netork-db' # the cluster", "name db-01", - "restapi 10.7.234.12:8008", "rest-unreachable", + "active active", + "scope 'netork-db' # the cluster", + "name db-01", + "restapi 10.7.234.12:8008", + "rest-unreachable", ) ) ) @@ -100,8 +132,15 @@ class TestPatroni: (cluster,) = reading["patroni"] assert cluster["cluster"] == "netork-db" assert cluster["members"] == [ - {"name": "db-01", "address": "10.7.234.12", "port": None, "role": None, - "state": None, "this_host": True, "config_from": None} + { + "name": "db-01", + "address": "10.7.234.12", + "port": None, + "role": None, + "state": None, + "this_host": True, + "config_from": None, + } ] def test_an_unreadable_config_is_not_looked_at(self): @@ -112,7 +151,9 @@ class TestPatroni: assert reading["patroni"] is None def test_a_config_without_a_scope_declares_no_cluster(self): - assert parse_service_clusters(_wire(_patroni("active active", "name db-01")))["patroni"] == [] + assert ( + parse_service_clusters(_wire(_patroni("active active", "name db-01")))["patroni"] == [] + ) def test_a_host_without_systemd_is_not_looked_at(self): assert parse_service_clusters(_wire(systemd=False))["patroni"] is None @@ -127,8 +168,10 @@ class TestAdguardhomeSync: _wire( "", _container( - "adguardhome-sync", "ghcr.io/bakito/adguardhome-sync:v0.9.3", - "ORIGIN_URL http://10.9.100.5:3000", "REPLICA1_URL http://10.9.100.6:3000", + "adguardhome-sync", + "ghcr.io/bakito/adguardhome-sync:v0.9.3", + "ORIGIN_URL http://10.9.100.5:3000", + "REPLICA1_URL http://10.9.100.6:3000", "REPLICA2_URL https://dns-03.example.org", ), ) @@ -136,9 +179,13 @@ class TestAdguardhomeSync: (cluster,) = reading["adguardhome_sync"] assert (cluster["mechanism"], cluster["service"], cluster["cluster"]) == ( - "adguardhome-sync", "adguardhome", "10.9.100.5:3000", + "adguardhome-sync", + "adguardhome", + "10.9.100.5:3000", ) - assert [(m["address"], m["port"], m["role"], m["config_from"]) for m in cluster["members"]] == [ + assert [ + (m["address"], m["port"], m["role"], m["config_from"]) for m in cluster["members"] + ] == [ ("10.9.100.5", 3000, "origin", None), ("10.9.100.6", 3000, "replica", 0), ("dns-03.example.org", 443, "replica", 0), @@ -147,8 +194,15 @@ class TestAdguardhomeSync: def test_a_single_replica_url_counts(self): reading = parse_service_clusters( - _wire("", _container("sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000", - "REPLICA_URL http://b:3000")) + _wire( + "", + _container( + "sync", + "bakito/adguardhome-sync", + "ORIGIN_URL http://a:3000", + "REPLICA_URL http://b:3000", + ), + ) ) assert [m["address"] for m in reading["adguardhome_sync"][0]["members"]] == ["a", "b"] @@ -163,36 +217,68 @@ class TestAdguardhomeSync: class TestBunkerweb: def test_a_worker_takes_its_configuration_from_the_manager_it_admits(self): reading = parse_service_clusters( - _wire("", _container("bunkerweb", "bunkerity/bunkerweb@sha256:2026", "API_WHITELIST_IP 10.50.100.5")) + _wire( + "", + _container( + "bunkerweb", "bunkerity/bunkerweb@sha256:2026", "API_WHITELIST_IP 10.50.100.5" + ), + ) ) (cluster,) = reading["bunkerweb"] assert (cluster["service"], cluster["cluster"]) == ("bunkerweb", "10.50.100.5") assert cluster["members"] == [ - {"name": None, "address": None, "port": 5000, "role": "worker", "state": None, - "this_host": True, "config_from": 1}, - {"name": None, "address": "10.50.100.5", "port": None, "role": "manager", "state": None, - "this_host": False, "config_from": None}, + { + "name": None, + "address": None, + "port": 5000, + "role": "worker", + "state": None, + "this_host": True, + "config_from": 1, + }, + { + "name": None, + "address": "10.50.100.5", + "port": None, + "role": "manager", + "state": None, + "this_host": False, + "config_from": None, + }, ] def test_the_default_whitelist_names_no_manager(self): """127.0.0.0/8 and a docker network are BunkerWeb's default, not a peer.""" reading = parse_service_clusters( - _wire("", _container("bw", "bunkerity/bunkerweb:1.6", "API_WHITELIST_IP 127.0.0.0/8 10.20.30.0/24")) + _wire( + "", + _container( + "bw", "bunkerity/bunkerweb:1.6", "API_WHITELIST_IP 127.0.0.0/8 10.20.30.0/24" + ), + ) ) assert reading["bunkerweb"] == [] def test_a_manager_names_its_instances(self): reading = parse_service_clusters( - _wire("", _container("bw", "bunkerity/bunkerweb-all-in-one:1.6", - "BUNKERWEB_INSTANCES 10.50.100.6:5000 bw-03")), + _wire( + "", + _container( + "bw", + "bunkerity/bunkerweb-all-in-one:1.6", + "BUNKERWEB_INSTANCES 10.50.100.6:5000 bw-03", + ), + ), ) (cluster,) = reading["bunkerweb"] assert cluster["cluster"] is None # the manager cannot name its own address - assert [(m["address"], m["port"], m["role"], m["this_host"], m["config_from"]) - for m in cluster["members"]] == [ + assert [ + (m["address"], m["port"], m["role"], m["this_host"], m["config_from"]) + for m in cluster["members"] + ] == [ (None, None, "manager", True, None), ("10.50.100.6", 5000, "worker", False, 0), ("bw-03", 5000, "worker", False, 0), @@ -200,7 +286,9 @@ class TestBunkerweb: def test_a_manager_without_instances_is_no_cluster(self): """proxy-01's all-in-one keeps its instances in its database.""" - reading = parse_service_clusters(_wire("", _container("bw", "bunkerity/bunkerweb-all-in-one@sha256:2b34"))) + reading = parse_service_clusters( + _wire("", _container("bw", "bunkerity/bunkerweb-all-in-one@sha256:2b34")) + ) assert reading["bunkerweb"] == [] @@ -298,7 +386,7 @@ class _Host: "docker", 'case "$1" in\n' f' ps) {cat} "$d/containers" ;;\n' - ' inspect) for last; do :; done;\n' + " inspect) for last; do :; done;\n" ' case "$3" in *Config.Image*) ' f'{cat} "$d/image-$last" ;; *) {cat} "$d/env-$last" ;; esac ;;\n' "esac\n", @@ -362,7 +450,9 @@ def host(request, tmp_path) -> _Host: class TestTheCommandOnAHost: def test_patroni_as_on_db_01(self, host): config = host.file("etc/patroni/config.yml", PATRONI_YML) - host.unit("patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))) + host.unit( + "patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config)) + ) host.rest(DB01_CLUSTER) out = host.run() @@ -379,7 +469,9 @@ class TestTheCommandOnAHost: def test_patroni_whose_rest_api_does_not_answer(self, host): config = host.file("etc/patroni/config.yml", PATRONI_YML) - host.unit("patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))) + host.unit( + "patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config)) + ) (cluster,) = parse_service_clusters(host.run())["patroni"] @@ -391,7 +483,9 @@ class TestTheCommandOnAHost: def test_a_patroni_config_it_cannot_read_is_not_looked_at(self, host): config = host.file("etc/patroni/config.yml", PATRONI_YML) config.chmod(0) - host.unit("patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))) + host.unit( + "patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config)) + ) out = host.run() @@ -400,14 +494,19 @@ class TestTheCommandOnAHost: def test_the_sync_and_a_worker_without_their_secrets(self, host): host.container( - "adguardhome-sync", "ghcr.io/bakito/adguardhome-sync:v0.9.3", - "ORIGIN_URL=http://10.9.100.5:3000", f"ORIGIN_PASSWORD={SECRET}", - f"REPLICA1_URL=http://admin:{SECRET}@10.9.100.6:3000", f"REPLICA1_PASSWORD={SECRET}", + "adguardhome-sync", + "ghcr.io/bakito/adguardhome-sync:v0.9.3", + "ORIGIN_URL=http://10.9.100.5:3000", + f"ORIGIN_PASSWORD={SECRET}", + f"REPLICA1_URL=http://admin:{SECRET}@10.9.100.6:3000", + f"REPLICA1_PASSWORD={SECRET}", "CRON=*/5 * * * *", ) host.container( - "bunkerweb", "bunkerity/bunkerweb@sha256:2026", - "API_WHITELIST_IP=10.50.100.5", f"DATABASE_URI=mariadb+pymysql://bw:{SECRET}@db/bw", + "bunkerweb", + "bunkerity/bunkerweb@sha256:2026", + "API_WHITELIST_IP=10.50.100.5", + f"DATABASE_URI=mariadb+pymysql://bw:{SECRET}@db/bw", ) host.container("zabbix", "zabbix/zabbix-agent2:alpine-latest", f"ZBX_PSK={SECRET}") @@ -417,7 +516,8 @@ class TestTheCommandOnAHost: assert "admin" not in out reading = parse_service_clusters(out) assert [m["address"] for m in reading["adguardhome_sync"][0]["members"]] == [ - "10.9.100.5", "10.9.100.6", + "10.9.100.5", + "10.9.100.6", ] assert reading["bunkerweb"][0]["cluster"] == "10.50.100.5" assert reading["patroni"] == [] @@ -483,8 +583,13 @@ class TestTheSource: def test_a_container_by_its_name(self): reading = parse_service_clusters( - _wire("", _container("adguardhome-sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000"), - _container("bw", "bunkerity/bunkerweb", "API_WHITELIST_IP 10.0.0.5")) + _wire( + "", + _container( + "adguardhome-sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000" + ), + _container("bw", "bunkerity/bunkerweb", "API_WHITELIST_IP 10.0.0.5"), + ) ) assert reading["adguardhome_sync"][0]["source"] == "adguardhome-sync" diff --git a/tests/test_ssh_host_keys.py b/tests/test_ssh_host_keys.py index 0a56bee..6470349 100644 --- a/tests/test_ssh_host_keys.py +++ b/tests/test_ssh_host_keys.py @@ -252,7 +252,7 @@ class TestNetmiko: other = paramiko.ECDSAKey.generate() raise paramiko.BadHostKeyException("10.0.0.5", host_key, other) except paramiko.SSHException as exc: - raise RuntimeError(f"A paramiko SSHException occurred: {exc}") + raise RuntimeError(f"A paramiko SSHException occurred: {exc}") from exc except RuntimeError as exc: raise ConnectionException(str(exc)) from exc assert caught.value.observation.seen == SshHostKey.of(host_key) @@ -261,7 +261,9 @@ class TestNetmiko: def test_any_other_failure_passes_unchanged(self): with pytest.raises(ConnectionException, match="timed out"): - with HostKeyCheck({KNOWN_KEYS_ARG: {"22": ED25519.as_dict()}}, "10.0.0.5", 22).netmiko({}): + with HostKeyCheck({KNOWN_KEYS_ARG: {"22": ED25519.as_dict()}}, "10.0.0.5", 22).netmiko( + {} + ): raise ConnectionException("timed out") def test_observe_reports_the_key_netmiko_saw(self, host_key): diff --git a/tests/test_ssh_server.py b/tests/test_ssh_server.py index 462cea9..ad1db25 100644 --- a/tests/test_ssh_server.py +++ b/tests/test_ssh_server.py @@ -9,7 +9,6 @@ cannot get through -- runs without a machine. from __future__ import annotations import re -from typing import Dict, List, Optional import pytest @@ -69,7 +68,11 @@ def test_parse_reads_the_four_lists_in_server_order(): eff = parse_sshd_effective(SSHD_T) assert eff["kex"][:2] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"] assert eff["hostkey"] == ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"] - assert eff["cipher"] == ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-gcm@openssh.com"] + assert eff["cipher"] == [ + "chacha20-poly1305@openssh.com", + "aes128-ctr", + "aes256-gcm@openssh.com", + ] assert eff["mac"][-1] == "hmac-sha2-256" @@ -82,7 +85,9 @@ def test_parse_refuses_output_without_the_lists(): def test_plan_removes_what_was_asked_and_keeps_the_order(): - plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]}) + plan = plan_ssh_hardening( + parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]} + ) assert plan.remove == {"mac": ["umac-64-etm@openssh.com", "hmac-sha1"]} assert plan.keep["mac"] == ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-256"] assert plan.changed_classes == ["mac"] @@ -113,20 +118,38 @@ def test_plan_keeps_one_algorithm_the_managing_client_still_speaks(): know would leave the server reachable, but not by netOrk.""" plan = plan_ssh_hardening( parse_sshd_effective(SSHD_T), - {"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", - "diffie-hellman-group14-sha256"]}, + { + "kex": [ + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "diffie-hellman-group14-sha256", + ] + }, client=PARAMIKO, ) assert "curve25519-sha256@libssh.org" in plan.keep["kex"] - assert [(r.algorithm, r.reason) for r in plan.refused] == [("curve25519-sha256@libssh.org", "client")] - assert plan.remove["kex"] == ["ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "diffie-hellman-group14-sha256"] + assert [(r.algorithm, r.reason) for r in plan.refused] == [ + ("curve25519-sha256@libssh.org", "client") + ] + assert plan.remove["kex"] == [ + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "diffie-hellman-group14-sha256", + ] def test_plan_without_a_client_list_only_guards_the_last_algorithm(): plan = plan_ssh_hardening( parse_sshd_effective(SSHD_T), - {"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", - "diffie-hellman-group14-sha256"]}, + { + "kex": [ + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "diffie-hellman-group14-sha256", + ] + }, ) assert plan.refused == [] assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"] @@ -171,12 +194,18 @@ def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays(): def test_plan_leaves_host_keys_alone_when_the_keys_are_unknown(): plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"hostkey": ["ecdsa-sha2-nistp256"]}) assert plan.changed_classes == [] - assert [(r.algorithm, r.reason) for r in plan.refused] == [("ecdsa-sha2-nistp256", "host_keys_unknown")] + assert [(r.algorithm, r.reason) for r in plan.refused] == [ + ("ecdsa-sha2-nistp256", "host_keys_unknown") + ] def test_host_key_algorithms_from_ssh_keygen(): assert host_key_algorithms("\n".join(KEYGEN.values())) == { - "ecdsa-sha2-nistp256", "ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", + "ecdsa-sha2-nistp256", + "ssh-ed25519", + "rsa-sha2-512", + "rsa-sha2-256", + "ssh-rsa", } assert host_key_algorithms("521 SHA256:x no comment (ECDSA)") == {"ecdsa-sha2-nistp521"} assert host_key_algorithms("") is None @@ -204,11 +233,15 @@ def test_include_is_found_only_in_the_global_section(): assert has_dropin_include("Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n") assert has_dropin_include(" include /etc/ssh/sshd_config.d/*.conf\n") assert not has_dropin_include("Port 22\n") - assert not has_dropin_include("Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n") + assert not has_dropin_include( + "Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n" + ) def test_main_config_gets_the_lists_first_and_the_old_lines_disabled(): - main = "# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n" + main = ( + "# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n" + ) text = render_main_config(main, {"mac": ["hmac-sha2-256"]}, ["mac"]) lines = text.splitlines() first_directive = next(line for line in lines if line and not line.startswith("#")) @@ -227,28 +260,34 @@ def test_main_config_rewrite_is_idempotent(): # ── the mixin, against a fake host ─────────────────────────────────────── -_DIRECTIVE = {"kexalgorithms": "kex", "hostkeyalgorithms": "hostkey", "ciphers": "cipher", "macs": "mac"} +_DIRECTIVE = { + "kexalgorithms": "kex", + "hostkeyalgorithms": "hostkey", + "ciphers": "cipher", + "macs": "mac", +} class FakeHost(OpenSshServerMixin): """A host with files, an sshd that reads them first-match-wins, and a log of what ran.""" - def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n", - sshd_t: str = SSHD_T) -> None: - self.files: Dict[str, str] = {MAIN_CONFIG: main} - self.modes: Dict[str, str] = {MAIN_CONFIG: "644"} + def __init__( + self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n", sshd_t: str = SSHD_T + ) -> None: + self.files: dict[str, str] = {MAIN_CONFIG: main} + self.modes: dict[str, str] = {MAIN_CONFIG: "644"} self.sshd_t = sshd_t self.defaults = parse_sshd_effective(sshd_t) self.keys = {p: KEYGEN[p] for p in parse_sshd_hostkeys(sshd_t)} self.keygen_ok = True - self.ran: List[str] = [] + self.ran: list[str] = [] self.syntax_ok = True self.reloads = 0 - def _effective(self) -> Dict[str, List[str]]: + def _effective(self) -> dict[str, list[str]]: eff = {k: list(v) for k, v in self.defaults.items()} seen = set() - lines: List[str] = [] + lines: list[str] = [] for line in self.files[MAIN_CONFIG].splitlines(): if re.match(r"\s*include\s", line, re.I): lines += self.files.get(DROPIN_PATH, "").splitlines() @@ -269,8 +308,14 @@ class FakeHost(OpenSshServerMixin): #: or a cached timestamp): the password the driver put in front of the data. SUDO_PASSWORD = "hunter2" - def run_command(self, command: str, *, privileged: bool = False, timeout: float = 60, - stdin: Optional[bytes] = None) -> CommandResult: + def run_command( + self, + command: str, + *, + privileged: bool = False, + timeout: float = 60, + stdin: bytes | None = None, + ) -> CommandResult: self.ran.append(command) if privileged and stdin is not None: stdin = f"{self.SUDO_PASSWORD}\n".encode() + stdin @@ -294,14 +339,20 @@ class FakeHost(OpenSshServerMixin): if not self.keygen_ok: return CommandResult("", "Permission denied", 1) paths = re.search(r"for k in (.*?); do", command).group(1).replace("'", "").split() - return CommandResult("".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0) + return CommandResult( + "".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0 + ) if command.startswith("sshd -T") or "sshd -T" in command: eff = self._effective() out = "".join(f"{d} {','.join(eff[k])}\n" for d, k in _DIRECTIVE.items()) - out += "".join(line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey ")) + out += "".join( + line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey ") + ) return CommandResult(out, "", 0) if "sshd -t" in command: - return CommandResult("", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255) + return CommandResult( + "", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255 + ) m = re.match(r"cat -- '?([^' ]+)'?$", command) if m: path = m.group(1) @@ -323,7 +374,7 @@ class FakeHost(OpenSshServerMixin): def test_mixin_writes_the_dropin_checks_it_and_reloads(): host = FakeHost() - lines: List[str] = [] + lines: list[str] = [] result = host.harden_ssh_server({"mac": ["hmac-sha1"]}, client=PARAMIKO, log=lines.append) assert result.applied and not result.rolled_back assert result.path == DROPIN_PATH @@ -439,7 +490,7 @@ def test_mixin_reports_a_failed_way_back_instead_of_raising(): def test_mixin_refuses_to_lock_out_a_host_with_only_ed25519_and_rsa_keys(): host = FakeHost(sshd_t=SSHD_T_FEW_KEYS) - lines: List[str] = [] + lines: list[str] = [] result = host.harden_ssh_server( {"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append ) diff --git a/tests/test_system_identity.py b/tests/test_system_identity.py index 2dc8d8b..c874b00 100644 --- a/tests/test_system_identity.py +++ b/tests/test_system_identity.py @@ -38,8 +38,20 @@ def test_a_supermicro_placeholder_serial_is_dropped(): @pytest.mark.parametrize( "value", - ["", "None", "N/A", "Not Specified", "To be filled by O.E.M.", "Default string", - "System Serial Number", "0123456789", "1234567890", "0", "00000000", "Not Applicable"], + [ + "", + "None", + "N/A", + "Not Specified", + "To be filled by O.E.M.", + "Default string", + "System Serial Number", + "0123456789", + "1234567890", + "0", + "00000000", + "Not Applicable", + ], ) def test_placeholders(value): assert is_placeholder_identity(value) @@ -47,9 +59,12 @@ def test_placeholders(value): @pytest.mark.parametrize( "uuid", - ["00000000-0000-0000-0000-000000000000", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", - # A UUID many cheap boards ship, the same on every one of them. - "03000200-0400-0500-0006-000700080009"], + [ + "00000000-0000-0000-0000-000000000000", + "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + # A UUID many cheap boards ship, the same on every one of them. + "03000200-0400-0500-0006-000700080009", + ], ) def test_placeholder_uuids(uuid): assert parse_system_identity(_out(uuid, "", ""))["system_uuid"] == "" @@ -63,7 +78,10 @@ def test_real_values_are_not_placeholders(): def test_unreadable_files_read_as_empty(): # product_serial and product_uuid are root's alone; without root they come back empty. assert parse_system_identity(_out("", "", "BS1")) == { - "system_uuid": "", "product_serial": "", "board_serial": "BS1"} + "system_uuid": "", + "product_serial": "", + "board_serial": "BS1", + } def test_output_without_its_frame_is_not_a_reading(): diff --git a/tests/test_systemd_services.py b/tests/test_systemd_services.py index 9423baf..7d9695b 100644 --- a/tests/test_systemd_services.py +++ b/tests/test_systemd_services.py @@ -409,4 +409,4 @@ class TestSystemdServicesMixin: def test_not_every_os_driver_has_it(self): assert not hasattr(OSDriver, "manage_service") - assert callable(getattr(SystemdServicesMixin, "manage_service")) + assert callable(SystemdServicesMixin.manage_service) diff --git a/tests/test_type_label.py b/tests/test_type_label.py index c949a5e..c215788 100644 --- a/tests/test_type_label.py +++ b/tests/test_type_label.py @@ -1,6 +1,7 @@ """Tests for TYPE_LABEL class attribute on all base driver classes.""" import pytest + from napalm_device_types import ( AccessPointDriver, FirewallDriver, @@ -12,7 +13,6 @@ from napalm_device_types import ( SwitchDriver, ) - BASE_CLASSES = [ (AccessPointDriver, "Access Point"), (FirewallDriver, "Firewall"), diff --git a/tests/test_ucd_metrics.py b/tests/test_ucd_metrics.py index eec7614..14fcadf 100644 --- a/tests/test_ucd_metrics.py +++ b/tests/test_ucd_metrics.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from typing import Dict, Optional from napalm_device_types._ucd_metrics import ( OID_IF_DESCR, @@ -24,11 +23,11 @@ LEGACY = { } -def _collect(tables: Dict[str, Dict[str, str]]) -> dict: - async def get(oid: str) -> Optional[str]: +def _collect(tables: dict[str, dict[str, str]]) -> dict: + async def get(oid: str) -> str | None: return None - async def walk(oid: str) -> Dict[str, str]: + async def walk(oid: str) -> dict[str, str]: return tables.get(oid, {}) return asyncio.run(collect_ucd_metrics(get, walk)) @@ -44,7 +43,11 @@ def test_the_64_bit_counters_and_high_speed_win(): eth0 = _collect(tables)["interfaces"]["eth0"] - assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (8589934592, 17179869184, 10000) + assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == ( + 8589934592, + 17179869184, + 10000, + ) def test_an_agent_without_ifxtable_falls_back_to_the_32_bit_counters(): diff --git a/tests/test_virtual_ips.py b/tests/test_virtual_ips.py index 8ec3cac..da54d55 100644 --- a/tests/test_virtual_ips.py +++ b/tests/test_virtual_ips.py @@ -152,9 +152,7 @@ class TestVipManager: assert parse_virtual_ips(_wire(section))["vip_manager"] == [] def test_a_template_unit_is_named_after_its_instance(self): - section = _vip_manager( - "active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service" - ) + section = _vip_manager("active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service") [entry] = parse_virtual_ips(_wire(section))["vip_manager"] @@ -496,8 +494,10 @@ class TestTheCommandOnAHost: assert SECRET not in out assert "MASTER" not in out and "priority" not in out reading = parse_virtual_ips(out) - assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"]) - for e in reading["keepalived"]] == [ + assert [ + (e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"]) + for e in reading["keepalived"] + ] == [ ("VI_WEB", "192.0.2.10", 24, "eth0", "vrid:51"), ("VI_DB", "192.0.2.20", 32, "eth1", "vrid:52"), ("VI_DB", "2001:db8::20", 64, "eth1", "vrid:52"), @@ -593,8 +593,10 @@ class TestTheCommandOnAHost: out = host.run() assert SECRET not in out - assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"]) - for e in parse_virtual_ips(out)["keepalived"]] == [ + assert [ + (e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"]) + for e in parse_virtual_ips(out)["keepalived"] + ] == [ ("VI_1", "192.0.2.50", 32, "eth0", None), ("VI_2", "192.0.2.60", 24, "eth1", "vrid:53"), ] diff --git a/tests/test_vm_cpu_types.py b/tests/test_vm_cpu_types.py index 10a2474..61b8516 100644 --- a/tests/test_vm_cpu_types.py +++ b/tests/test_vm_cpu_types.py @@ -15,7 +15,7 @@ from __future__ import annotations import ast import inspect -from typing import List, get_type_hints +from typing import get_type_hints import napalm_device_types.hypervisor as hypervisor_module from napalm_device_types import HypervisorDriver @@ -33,7 +33,10 @@ def _declared(name: str) -> ast.FunctionDef: class TestCreateVmTakesACpuType: def test_cpu_type_is_an_optional_keyword(self): fn = _declared("create_vm_from_cloud_init") - kwonly = {arg.arg: default for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults)} + kwonly = { + arg.arg: default + for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True) + } assert "cpu_type" in kwonly default = kwonly["cpu_type"] assert isinstance(default, ast.Constant) and default.value is None @@ -52,7 +55,7 @@ class TestCpuTypeListing: assert get_type_hints(VMCpuTypeDict) == { "name": str, "description": str, - "features": List[str], + "features": list[str], "available": bool, "default": bool, } diff --git a/tests/test_vm_identifiers.py b/tests/test_vm_identifiers.py index d38f6e2..3c7752c 100644 --- a/tests/test_vm_identifiers.py +++ b/tests/test_vm_identifiers.py @@ -12,6 +12,7 @@ from __future__ import annotations from typing import get_type_hints import pytest + from napalm_device_types.models import ( VMConfigDict, VMDict,