feat: alternative fingerprint rules count once (4.8.0)
CI / test (3.10) (push) Successful in 28s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 26s

Discovery compares drivers by score divided by the most a driver could
score, and that maximum summed every positive rule. Rules that never
match together, such as one vendor's landing page or another's, made a
driver that knows several vendors lose to one that knows none:
napalm-redfish's Supermicro landing page scored 6 of 41 and lost to a
bare "linux" in the same BMC's SNMP sysDescr, 5 of 18.

FingerprintRule gets an optional group. rules_max_weight() sums the
positive rules and counts each group once, with its strongest rule.
Existing rules have no group and count as before.
This commit is contained in:
christianmanivong committed 2026-10-13 10:19:54 +02:00
1 parent 7afa590c0c
commit a1da0337e2
5 files changed
+97 -2

No files matched your search

+21
View File
@@ -285,6 +285,27 @@ since both commonly run the DHCP server for their networks).
`ConfigLifecycleMixin` (config load/compare/commit/rollback) is mixed in by a
driver itself.
### Fingerprints
`DeviceTypeDriver` carries what discovery matches a device against: `PORT_SPECS`,
`SNMP_OBJECT_ID_PREFIX`, `SNMP_FINGERPRINT`, `SSH_FINGERPRINT`, `HTTP_FINGERPRINT` and
`OUI_PREFIXES`. A `FingerprintRule` is a lowercase substring with a weight; `mandatory`
disqualifies the driver when it is missing, `negative` subtracts.
Discovery compares drivers by score divided by the most a driver could score. Rules that
can never match together, such as one vendor's landing page or another's, share a
`group`: only the strongest rule of a group counts towards that maximum
(`rules_max_weight`). Without it, a driver that recognises three vendors' pages would
score a third as well as one that recognises a single page:
```python
HTTP_FINGERPRINT = [
FingerprintRule("aten international", 6.0, group="landing_page"), # Supermicro
FingerprintRule("idrac", 6.0, group="landing_page"),
FingerprintRule("integrated lights-out", 6.0, group="landing_page"), # HPE iLO
]
```
## Usage
### Access Point
+2 -1
View File
@@ -60,7 +60,7 @@ 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
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight
from napalm_device_types.access_point import AccessPointDriver
from napalm_device_types.channel import (
ByteStream,
@@ -156,6 +156,7 @@ __all__ = [
"DeviceTypeDriver",
"DhcpServerMixin",
"FingerprintRule",
"rules_max_weight",
"FirewallDriver",
"FirewallRuleMixin",
"HealthMetricsMixin",
+25
View File
@@ -8,6 +8,7 @@ live connection.
from __future__ import annotations
from collections.abc import Iterable
from typing import NamedTuple
from napalm.base import NetworkDriver
@@ -24,12 +25,36 @@ class FingerprintRule(NamedTuple):
weight — evidence added to the driver's score on match
mandatory — pattern absent → driver immediately disqualified
negative — pattern present → weight subtracted instead of added
group — rules sharing a group are alternatives that never match
together (one vendor's landing page *or* another's); only the
strongest of them counts towards the most a driver can score
"""
pattern: str
weight: float = 1.0
mandatory: bool = False
negative: bool = False
group: str | None = None
def rules_max_weight(rules: Iterable[FingerprintRule]) -> float:
"""The most evidence *rules* can add together.
Every positive rule counts, except that a group of alternatives counts once,
with its strongest rule. Discovery divides a driver's score by its maximum to
compare drivers; summing alternatives would make a driver that recognises
three vendors' pages lose to one that recognises none.
"""
total = 0.0
group_best: dict[str, float] = {}
for rule in rules:
if rule.negative or rule.weight <= 0:
continue
if rule.group is None:
total += rule.weight
else:
group_best[rule.group] = max(group_best.get(rule.group, 0.0), rule.weight)
return total + sum(group_best.values())
class PortSpec(NamedTuple):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "4.7.1"
version = "4.8.0"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+48
View File
@@ -8,6 +8,7 @@ from napalm_device_types import (
DeviceTypeDriver,
FingerprintRule,
FirewallDriver,
rules_max_weight,
HypervisorDriver,
OSDriver,
PortSpec,
@@ -90,6 +91,53 @@ def test_fingerprint_rule_negative():
assert r.negative is True
def test_fingerprint_rule_has_no_group_by_default():
assert FingerprintRule("fritz!box").group is None
def test_fingerprint_rule_group():
r = FingerprintRule("idrac", weight=6.0, group="landing_page")
assert r.group == "landing_page"
# ── 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
def test_max_weight_counts_one_rule_per_group():
# Alternatives: a page is ATEN's or iDRAC's or iLO's, never all three. Summing
# them made a driver that knows three BMC families lose to one that knows none.
rules = [
FingerprintRule("aten international", 6.0, group="landing_page"),
FingerprintRule("idrac", 6.0, group="landing_page"),
FingerprintRule("integrated lights-out", 4.0, group="landing_page"),
]
assert rules_max_weight(rules) == 6.0
def test_max_weight_mixes_groups_and_single_rules():
rules = [
FingerprintRule("redfishversion", 10.0, group="redfish_answer"),
FingerprintRule("@message.extendedinfo", 8.0, group="redfish_answer"),
FingerprintRule("#serviceroot.", 5.0),
FingerprintRule("aten international", 6.0, group="landing_page"),
FingerprintRule("idrac", 6.0, group="landing_page"),
]
assert rules_max_weight(rules) == 21.0
def test_max_weight_ignores_negative_rules():
rules = [FingerprintRule("vcenter", 9.0), FingerprintRule("vmware esxi", 9.0, negative=True)]
assert rules_max_weight(rules) == 9.0
def test_max_weight_of_no_rules_is_zero():
assert rules_max_weight([]) == 0.0
def test_fingerprint_rule_is_immutable():
r = FingerprintRule("test", weight=3.0)
with pytest.raises(AttributeError):