feat!: role bases declare their methods instead of stubbing them
A role base used to fill its methods with `raise NotImplementedError`. That is
not neutral under multiple inheritance: the placeholder wins the MRO against a
sibling base's working implementation and silently replaces it. Adding one stub
to a base was therefore a breaking change for every driver mixing that base with
another, and it broke three of them — OpenWrt grew seven forwarding methods,
QNAP one, and OpenMediaVault avoided inheriting StorageDriver at all.
Role bases now declare their surface under `if TYPE_CHECKING` and implement
nothing. There is no longer anything to shadow, so a device can finally say what
it is:
class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
The order of those bases is the ranking, read back by roles_of(),
role_keys_of() and primary_role_of() in the new roles module. Nothing restates
it: no precedence table, no attribute to override.
Two consequences, both wanted. `hasattr` is a truthful capability probe again,
because a method exists exactly when a driver provided it. And a method that was
never implemented now raises AttributeError rather than NotImplementedError, so
callers should ask before calling.
Shared behaviour moves out of the roles and into function classes, each holding
it once: PackageManagementMixin (was five byte-identical copies),
HealthMetricsMixin (five), ServiceControlMixin, UpdateMixin, NatVpnMixin,
MacAclMixin, FirewallRuleMixin, InterfaceFilterMixin.
BREAKING CHANGE: methods whose contract genuinely differed were renamed apart —
StorageDriver.get_services -> get_storage_services, the storage and hypervisor
snapshot writers -> create/delete/rollback_{volume,vm}_snapshot,
HypervisorDriver.get_storage -> get_vm_storage_pools, get_snapshots ->
get_vm_snapshots, SwitchDriver.get_dot1x_config -> get_dot1x_ports. Two
duplicate names collapsed onto the one already in use: get_pending_updates ->
get_available_updates and remove_package -> uninstall_package.
Also fixes __doc__ being None on all seven role bases: TYPE_LABEL was assigned
above the triple-quoted string, which made it a bare expression rather than a
docstring.
This commit is contained in:
@@ -73,18 +73,31 @@ class TestNormalizeMac:
|
||||
|
||||
|
||||
class TestAbstractContract:
|
||||
def test_get_dhcp_reservations_raises_not_implemented_by_default(self):
|
||||
with pytest.raises(NotImplementedError):
|
||||
"""The device hooks are declared for type checkers, not implemented.
|
||||
|
||||
A driver that never implemented them does not carry them at runtime, so
|
||||
`hasattr` is a truthful capability probe and the declaration cannot shadow a
|
||||
working implementation inherited from a sibling base.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
"get_dhcp_reservations",
|
||||
"apply_dhcp_reservation",
|
||||
"commit_dhcp_reservations",
|
||||
"get_dhcp_subnets",
|
||||
"apply_dhcp_subnet",
|
||||
"commit_dhcp_subnets",
|
||||
],
|
||||
)
|
||||
def test_hook_absent_until_a_driver_implements_it(self, method):
|
||||
assert not hasattr(DhcpServerMixin, method)
|
||||
|
||||
def test_calling_a_missing_hook_fails_loudly(self):
|
||||
with pytest.raises(AttributeError):
|
||||
DhcpServerMixin().get_dhcp_reservations()
|
||||
|
||||
def test_apply_dhcp_reservation_raises_not_implemented_by_default(self):
|
||||
with pytest.raises(NotImplementedError):
|
||||
DhcpServerMixin().apply_dhcp_reservation(_reservation())
|
||||
|
||||
def test_commit_dhcp_reservations_raises_not_implemented_by_default(self):
|
||||
with pytest.raises(NotImplementedError):
|
||||
DhcpServerMixin().commit_dhcp_reservations()
|
||||
|
||||
|
||||
class TestMixedIntoDriverTypes:
|
||||
"""Both firewalls and residential gateways run DHCP servers, so the mixin
|
||||
|
||||
@@ -216,13 +216,13 @@ class _BareDriver(DhcpServerMixin):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda d: d.get_dhcp_subnets(),
|
||||
lambda d: d.apply_dhcp_subnet({}), # type: ignore[typeddict-item]
|
||||
lambda d: d.commit_dhcp_subnets(),
|
||||
],
|
||||
"method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"]
|
||||
)
|
||||
def test_device_specific_methods_raise_not_implemented(call: Any) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
call(_BareDriver())
|
||||
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)
|
||||
|
||||
|
||||
def test_calling_a_missing_device_method_fails_loudly() -> None:
|
||||
with pytest.raises(AttributeError):
|
||||
_BareDriver().get_dhcp_subnets()
|
||||
|
||||
@@ -59,30 +59,31 @@ class _FakeFirewall(FirewallDriver):
|
||||
|
||||
|
||||
class TestAbstractContract:
|
||||
def test_get_firewall_rules_raises_not_implemented_by_default(self):
|
||||
"""The three device hooks are declared, never implemented, on the base.
|
||||
|
||||
They exist for type checkers only, so a driver that did not implement them
|
||||
does not carry them at runtime -- which is what keeps `hasattr` truthful and
|
||||
stops the declaration from overriding a sibling base's working version.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method", ["get_firewall_rules", "apply_firewall_rule", "commit_firewall_rules"]
|
||||
)
|
||||
def test_hook_absent_until_a_driver_implements_it(self, method):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
assert not hasattr(_Bare, method)
|
||||
|
||||
def test_calling_a_missing_hook_fails_loudly(self):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
_Bare().get_firewall_rules()
|
||||
|
||||
def test_apply_firewall_rule_raises_not_implemented_by_default(self):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
_Bare().apply_firewall_rule(_rule())
|
||||
|
||||
def test_commit_firewall_rules_raises_not_implemented_by_default(self):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
_Bare().commit_firewall_rules()
|
||||
|
||||
|
||||
class TestDiffFirewallRules:
|
||||
def test_desired_rule_missing_live_is_an_add(self):
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# -*- 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
|
||||
*implement* anything -- not even a placeholder. A ``NotImplementedError`` stub
|
||||
on a base class is not neutral under multiple inheritance: it wins the MRO
|
||||
against a sibling base's working implementation and silently replaces it. That
|
||||
failure has hit this codebase three times (OpenWrt's seven forwarding methods,
|
||||
QNAP's ``get_services``, OpenMediaVault avoiding ``StorageDriver`` altogether).
|
||||
|
||||
These tests pin the property that prevents a fourth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import (
|
||||
AccessPointDriver,
|
||||
DeviceTypeDriver,
|
||||
FirewallDriver,
|
||||
HypervisorDriver,
|
||||
OSDriver,
|
||||
ResidentialGatewayDriver,
|
||||
StorageDriver,
|
||||
SwitchDriver,
|
||||
)
|
||||
from napalm_device_types.roles import primary_role_of, roles_of
|
||||
|
||||
ROLE_BASES = [
|
||||
AccessPointDriver,
|
||||
FirewallDriver,
|
||||
HypervisorDriver,
|
||||
OSDriver,
|
||||
ResidentialGatewayDriver,
|
||||
StorageDriver,
|
||||
SwitchDriver,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base", ROLE_BASES, ids=lambda b: b.__name__)
|
||||
class TestRoleBasesAreContractsOnly:
|
||||
def test_defines_no_methods_at_runtime(self, base):
|
||||
"""A role base is a declaration. Anything callable it owns can shadow a
|
||||
sibling base, so it must own nothing callable at all."""
|
||||
own = [
|
||||
name
|
||||
for name, val in vars(base).items()
|
||||
if not name.startswith("__")
|
||||
and (inspect.isfunction(val) or isinstance(val, (classmethod, staticmethod)))
|
||||
]
|
||||
assert own == [], f"{base.__name__} implements {own}; move it to a function class"
|
||||
|
||||
def test_declares_a_role_key(self, base):
|
||||
assert isinstance(vars(base).get("ROLE"), str) and vars(base)["ROLE"]
|
||||
|
||||
def test_has_a_real_docstring(self, base):
|
||||
"""TYPE_LABEL used to be assigned above the triple-quoted string, which
|
||||
made it a bare expression rather than a docstring -- __doc__ was None on
|
||||
all seven bases, killing help() and IDE hovers."""
|
||||
assert base.__doc__ and base.__doc__.strip()
|
||||
|
||||
|
||||
class TestDeclaredMethodsDoNotExistAtRuntime:
|
||||
"""`hasattr` is netOrk's capability probe. It only tells the truth when a
|
||||
declared-but-unimplemented method is genuinely absent."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("base", "method"),
|
||||
[
|
||||
(StorageDriver, "get_disks"),
|
||||
(StorageDriver, "get_volumes"),
|
||||
(HypervisorDriver, "get_vms"),
|
||||
(FirewallDriver, "send_wake_on_lan"),
|
||||
(SwitchDriver, "set_vlan"),
|
||||
(AccessPointDriver, "get_wireless_config"),
|
||||
(OSDriver, "get_processes"),
|
||||
(ResidentialGatewayDriver, "get_wan_status"),
|
||||
],
|
||||
)
|
||||
def test_absent_until_a_driver_implements_it(self, base, method):
|
||||
assert not hasattr(base, method)
|
||||
|
||||
|
||||
class TestNoShadowingAcrossRoles:
|
||||
"""The regression test for the bug class."""
|
||||
|
||||
def test_role_base_listed_first_does_not_shadow_sibling(self):
|
||||
"""StorageDriver precedes the working mixin -- the order that broke QNAP."""
|
||||
|
||||
class WorkingPackages:
|
||||
def get_packages(self):
|
||||
return [{"name": "vim", "version": "9.0"}]
|
||||
|
||||
def get_services(self):
|
||||
return [{"name": "sshd", "state": "running"}]
|
||||
|
||||
class Combined(StorageDriver, WorkingPackages):
|
||||
pass
|
||||
|
||||
assert Combined.get_packages is WorkingPackages.get_packages
|
||||
assert Combined.get_services is WorkingPackages.get_services
|
||||
|
||||
def test_two_role_bases_can_be_combined(self):
|
||||
"""A QNAP is NAS, hypervisor and Linux host. It must be able to say so."""
|
||||
|
||||
class Nas(StorageDriver, HypervisorDriver, OSDriver):
|
||||
pass
|
||||
|
||||
assert [r.__name__ for r in roles_of(Nas)] == [
|
||||
"StorageDriver",
|
||||
"HypervisorDriver",
|
||||
"OSDriver",
|
||||
]
|
||||
|
||||
|
||||
class TestRoleIntrospection:
|
||||
def test_primary_role_follows_base_order(self):
|
||||
class Nas(StorageDriver, OSDriver):
|
||||
pass
|
||||
|
||||
class Host(OSDriver, StorageDriver):
|
||||
pass
|
||||
|
||||
assert primary_role_of(Nas) == "storage"
|
||||
assert primary_role_of(Host) == "linux"
|
||||
|
||||
def test_driver_without_a_role_has_none(self):
|
||||
class Bare(DeviceTypeDriver):
|
||||
pass
|
||||
|
||||
assert roles_of(Bare) == []
|
||||
assert primary_role_of(Bare) is None
|
||||
@@ -1,4 +1,12 @@
|
||||
"""Tests for FirewallDriver.send_wake_on_lan default contract."""
|
||||
"""Tests for the FirewallDriver.send_wake_on_lan contract.
|
||||
|
||||
``send_wake_on_lan`` is declared on ``FirewallDriver`` but implemented by very
|
||||
few firewalls. It used to exist as a ``NotImplementedError`` stub; it is now a
|
||||
``TYPE_CHECKING`` declaration, so a driver that never implemented it simply does
|
||||
not have the attribute. That is what lets ``hasattr`` answer honestly, and what
|
||||
stops the declaration from shadowing a working implementation inherited from a
|
||||
sibling base.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from napalm_device_types import FirewallDriver
|
||||
@@ -13,20 +21,21 @@ class _BareFirewall(FirewallDriver):
|
||||
pass
|
||||
|
||||
|
||||
def test_send_wake_on_lan_raises_not_implemented_by_default():
|
||||
with pytest.raises(NotImplementedError):
|
||||
def test_absent_on_a_driver_that_never_implemented_it():
|
||||
assert not hasattr(_BareFirewall, "send_wake_on_lan")
|
||||
|
||||
|
||||
def test_calling_it_anyway_fails_loudly():
|
||||
"""A caller that skips the hasattr check must not get silence."""
|
||||
with pytest.raises(AttributeError):
|
||||
_BareFirewall().send_wake_on_lan("AA:BB:CC:DD:EE:FF")
|
||||
|
||||
|
||||
def test_send_wake_on_lan_accepts_optional_interface():
|
||||
with pytest.raises(NotImplementedError):
|
||||
_BareFirewall().send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
|
||||
|
||||
|
||||
def test_subclass_can_implement_send_wake_on_lan():
|
||||
class MyFirewall(_BareFirewall):
|
||||
def send_wake_on_lan(self, mac_address: str, interface: str = ""):
|
||||
return {"success": True, "output": f"woke {mac_address} via {interface}"}
|
||||
|
||||
assert hasattr(MyFirewall, "send_wake_on_lan")
|
||||
result = MyFirewall().send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
|
||||
assert result == {"success": True, "output": "woke AA:BB:CC:DD:EE:FF via lan"}
|
||||
|
||||
Reference in New Issue
Block a user