From a1da0337e2aa8f82c44f25e4b36a4b855abf63a7 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 13 Oct 2026 10:19:54 +0200 Subject: [PATCH] feat: alternative fingerprint rules count once (4.8.0) 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. --- README.md | 21 +++++++++++++++ napalm_device_types/__init__.py | 3 ++- napalm_device_types/base.py | 25 +++++++++++++++++ pyproject.toml | 2 +- tests/test_fingerprint_base.py | 48 +++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57b27a2..127497d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index 673b6ad..2eb5b27 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -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", diff --git a/napalm_device_types/base.py b/napalm_device_types/base.py index 01b8115..97f0f9b 100644 --- a/napalm_device_types/base.py +++ b/napalm_device_types/base.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index 7509deb..2f7edf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_fingerprint_base.py b/tests/test_fingerprint_base.py index ea83a62..89738f9 100644 --- a/tests/test_fingerprint_base.py +++ b/tests/test_fingerprint_base.py @@ -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):