Repository: aristanetworks/anta (python)

Issue:
Enhancement: Input subclasses refactoring
Tests Input subclasses should be moved outside of test classes, in other modules to allow reusability across tests and easier imports by external framework leveraging ANTA (AVD).

Solution:

Models are being moved to `anta.input_models.<module>`. Each test will keep a class variable as a reference of these models to avoid breaking changes. The references will be removed in ANTA v2.0.

Example:
```
from anta.input_models.connectivity import Host

class Input(AntaTest.Input):
    """Input model for the VerifyReachability test."""

    hosts: list[Host]
    """List of host to ping."""
    Host: ClassVar[type[Host]] = Host
```

- As part of this refactoring, `model_config = ConfigDict(extra="forbid")` should be added to these models to fix #809. 
- A `__str__`  should also be added to the models and be leveraged to improve failure messages, to fix #587.
- Need to add documentation for input models. Ref #912 

# Checklist (in priority):

High priority (AVD tests or high reusability):
- [x] VerifyReachability.Input.Host #912
- [x] VerifyInterfacesStatus.Input.InterfaceState #899
- [x] VerifyNTPAssociations.Input.NTPServer #902
- [x] VerifyLLDPNeighbors.Input.Neighbor #915 
- [x] VerifySpecificIPSecConn.Input.IPSecPeers & IPSecConn - https://github.com/aristanetworks/anta/pull/933
- [x] VerifyStunClient..Input.ClientAddress - https://github.com/aristanetworks/anta/pull/934
- [x] VerifyAVTSpecificPath.Input.AVTPaths  -  https://github.com/aristanetworks/anta/pull/939
- [x] VerifyDNSServers.Input.DnsServer https://github.com/aristanetworks/anta/pull/920

The following 3 BGP tests should use the same model to replace <VerifyBGP...>.Input.BgpAfi:
- [x] VerifyBGPPeerCount, VerifyBGPPeersHealth, VerifyBGPSpecificPeers #888

These BGP tests should use the same `BgpPeer` model:
- [x] VerifyBGPPeerMPCaps #914
- [x] VerifyBGPPeerASNCap #914
- [x] VerifyBGPPeerRouteRefreshCap #914
- [x] VerifyBGPPeerMD5Auth #914
- [x] VerifyBGPAdvCommunities #914
- [x] VerifyBGPTimers #914
- [x] VerifyBGPPeerDropStats #914
- [x] VerifyBGPPeerUpdateErrors #914
- [x] VerifyBgpRouteMaps #914
- [x] VerifyBGPPeerRouteLimit #914

The BFD tests should use the same `BFDPeer` model:
- [x] VerifyBFDSpecificPeers, VerifyBFDPeersIntervals, VerifyBFDPeersRegProtocols - https://github.com/aristanetworks/anta/pull/926

Low priority (low reusability):
- [x] VerifyLACPInterfacesStatus.Input.LACPInterface https://github.com/aristanetworks/anta/pull/947
- [x] VerifyErrdisableRecovery.Input.ErrDisableReason https://github.com/aristanetworks/anta/pull/955
- [x] VerifyAPISSLCertificate.Input.APISSLCertificate https://github.com/aristanetworks/anta/pull/957
- [x] VerifyIPv4ACL.Input.IPv4ACL & IPv4ACLEntry https://github.com/aristanetworks/anta/pull/957
- [x] VerifySpecificPath.Input.RouterPath https://github.com/aristanetworks/anta/pull/965
- [x] VerifyHardwareFlowTrackerStatus.Input.FlowTracker & RecordExport & Exporter https://github.com/aristanetworks/anta/pull/964
- [x] ISIS  @vitthalmagadum 
  - [x] VerifyISISNeighborCount.Input.InterfaceCount https://github.com/aristanetworks/anta/pull/963
  - [x] VerifyISISInterfaceMode.Input.InterfaceState https://github.com/aristanetworks/anta/pull/963
  - [x] VerifyISISSegmentRoutingAdjacencySegments.Input.IsisInstance & Segment https://github.com/aristanetworks/anta/pull/963
  - [x] VerifyISISSegmentRoutingDataplane.Input.IsisInstance https://github.com/aristanetworks/anta/pull/963
  - [ ] VerifyISISSegmentRoutingTunnels.Input.Entry & Vias: https://github.com/aristanetworks/anta/pull/1037



Pull request:
refactor(anta.tests): Refactor VerifyISISSegmentRoutingTunnels test case
# Description

Refactor VerifyISISSegmentRoutingTunnels test case

Fixes #388 

# Checklist:

- [X] My code follows the style guidelines of this project
- [X] I have performed a self-review of my code
- [X] I have run pre-commit for code linting and typing (`pre-commit run`)
- [ ] I have made corresponding changes to the documentation
- [X] I have added tests that prove my fix is effective or that my feature works
- [X] New and existing unit tests pass locally with my changes (`tox -e testenv`)


Patch:
diff --git a/anta/input_models/routing/isis.py b/anta/input_models/routing/isis.py
index efeefe60..c0e26499 100644
--- a/anta/input_models/routing/isis.py
+++ b/anta/input_models/routing/isis.py
@@ -5,7 +5,7 @@
 
 from __future__ import annotations
 
-from ipaddress import IPv4Address
+from ipaddress import IPv4Address, IPv4Network
 from typing import Any, Literal
 from warnings import warn
 
@@ -122,3 +122,81 @@ class IsisInstance(ISISInstance):  # pragma: no cover
             stacklevel=2,
         )
         super().__init__(**data)
+
+
+class Tunnel(BaseModel):
+    """Model for a IS-IS SR tunnel."""
+
+    model_config = ConfigDict(extra="forbid")
+    endpoint: IPv4Network
+    """Endpoint of the tunnel."""
+    vias: list[TunnelPath] | None = None
+    """Optional list of paths to reach the endpoint."""
+
+    def __str__(self) -> str:
+        """Return a human-readable string representation of the Tunnel for reporting."""
+        return f"Endpoint: {self.endpoint}"
+
+
+class TunnelPath(BaseModel):
+    """Model for a IS-IS tunnel path."""
+
+    model_config = ConfigDict(extra="forbid")
+    nexthop: IPv4Address | None = None
+    """Nexthop of the tunnel."""
+    type: Literal["ip", "tunnel"] | None = None
+    """Type of the tunnel."""
+    interface: Interface | None = None
+    """Interface of the tunnel."""
+    tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None
+    """Computation method of the tunnel."""
+
+    def __str__(self) -> str:
+        """Return a human-readable string representation of the TunnelPath for reporting."""
+        base_string = ""
+        if self.nexthop:
+            base_string += f" Next-hop: {self.nexthop}"
+        if self.type:
+            base_string += f" Type: {self.type}"
+        if self.interface:
+            base_string += f" Interface: {self.interface}"
+        if self.tunnel_id:
+            base_string += f" Tunnel ID: {self.tunnel_id}"
+
+        return base_string.lstrip()
+
+
+class Entry(Tunnel):  # pragma: no cover
+    """Alias for the Tunnel model to maintain backward compatibility.
+
+    When initialized, it will emit a deprecation warning and call the Tunnel model.
+
+    TODO: Remove this class in ANTA v2.0.0.
+    """
+
+    def __init__(self, **data: Any) -> None:  # noqa: ANN401
+        """Initialize the Entry class, emitting a deprecation warning."""
+        warn(
+            message="Entry model is deprecated and will be removed in ANTA v2.0.0. Use the Tunnel model instead.",
+            category=DeprecationWarning,
+            stacklevel=2,
+        )
+        super().__init__(**data)
+
+
+class Vias(TunnelPath):  # pragma: no cover
+    """Alias for the TunnelPath model to maintain backward compatibility.
+
+    When initialized, it will emit a deprecation warning and call the TunnelPath model.
+
+    TODO: Remove this class in ANTA v2.0.0.
+    """
+
+    def __init__(self, **data: Any) -> None:  # noqa: ANN401
+        """Initialize the Vias class, emitting a deprecation warning."""
+        warn(
+            message="Vias model is deprecated and will be removed in ANTA v2.0.0. Use the TunnelPath model instead.",
+            category=DeprecationWarning,
+            stacklevel=2,
+        )
+        super().__init__(**data)


Test patch:
diff --git a/anta/tests/routing/isis.py b/anta/tests/routing/isis.py
index 47803e3a..70574e51 100644
--- a/anta/tests/routing/isis.py
+++ b/anta/tests/routing/isis.py
@@ -7,13 +7,11 @@
 # mypy: disable-error-code=attr-defined
 from __future__ import annotations
 
-from ipaddress import IPv4Address, IPv4Network
-from typing import Any, ClassVar, Literal
+from typing import Any, ClassVar
 
-from pydantic import BaseModel, field_validator
+from pydantic import field_validator
 
-from anta.custom_types import Interface
-from anta.input_models.routing.isis import InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface
+from anta.input_models.routing.isis import Entry, InterfaceCount, InterfaceState, ISISInstance, IsisInstance, ISISInterface, Tunnel, TunnelPath
 from anta.models import AntaCommand, AntaTemplate, AntaTest
 from anta.tools import get_item, get_value
 
@@ -391,34 +389,9 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
     class Input(AntaTest.Input):
         """Input model for the VerifyISISSegmentRoutingTunnels test."""
 
-        entries: list[Entry]
+        entries: list[Tunnel]
         """List of tunnels to check on device."""
-
-        class Entry(BaseModel):
-            """Definition of a tunnel entry."""
-
-            endpoint: IPv4Network
-            """Endpoint IP of the tunnel."""
-            vias: list[Vias] | None = None
-            """Optional list of path to reach endpoint."""
-
-            class Vias(BaseModel):
-                """Definition of a tunnel path."""
-
-                nexthop: IPv4Address | None = None
-                """Nexthop of the tunnel. If None, then it is not tested. Default: None"""
-                type: Literal["ip", "tunnel"] | None = None
-                """Type of the tunnel. If None, then it is not tested. Default: None"""
-                interface: Interface | None = None
-                """Interface of the tunnel. If None, then it is not tested. Default: None"""
-                tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None = None
-                """Computation method of the tunnel. If None, then it is not tested. Default: None"""
-
-    def _eos_entry_lookup(self, search_value: IPv4Network, entries: dict[str, Any], search_key: str = "endpoint") -> dict[str, Any] | None:
-        return next(
-            (entry_value for entry_id, entry_value in entries.items() if str(entry_value[search_key]) == str(search_value)),
-            None,
-        )
+        Entry: ClassVar[type[Entry]] = Entry
 
     @AntaTest.anta_test
     def test(self) -> None:
@@ -427,29 +400,31 @@ class VerifyISISSegmentRoutingTunnels(AntaTest):
         This method performs the main test logic for verifying ISIS Segment Routing tunnels.
         It checks the command output, initiates defaults, and performs various checks on the tunnels.
         """
-        command_output = self.instance_commands[0].json_output
         self.result.is_success()
 
+        command_output = self.instance_commands[0].json_output
         if len(command_output["entries"]) == 0:
-            self.result.is_skipped("IS-IS-SR is not running on device.")
+            self.result.is_skipped("IS-IS-SR not configured")
             return
 
         for input_entry in self.inputs.entries:
-            eos_entry = self._eos_entry_lookup(search_value=input_entry.endpoint, entries=command_output["entries"])
-            if eos_entry is None:
-                self.result.is_failure(f"Tunnel to {input_entry.endpoint!s} is not found.")
-            elif input_entry.vias is not None:
+            entries = list(command_output["entries"].values())
+            if (eos_entry := get_item(entries, "endpoint", str(input_entry.endpoint))) is None:
+                self.result.is_failure(f"{input_entry} - Tunnel not found")
+                continue
+
+            if input_entry.vias is not None:
                 for via_input in input_entry.vias:
                     via_search_result = any(self._via_matches(via_input, eos_via) for eos_via in eos_entry["vias"])
                     if not via_search_result:
-                        self.result.is_failure(f"Tunnel to {input_entry.endpoint!s} is incorrect.")
+                        self.result.is_failure(f"{input_entry} {via_input} - Tunnel is incorrect")
 
-    def _via_matches(self, via_input: VerifyISISSegmentRoutingTunnels.Input.Entry.Vias, eos_via: dict[str, Any]) -> bool:
+    def _via_matches(self, via_input: TunnelPath, eos_via: dict[str, Any]) -> bool:
         """Check if the via input matches the eos via.
 
         Parameters
         ----------
-        via_input : VerifyISISSegmentRoutingTunnels.Input.Entry.Vias
+        via_input : TunnelPath
             The input via to check.
         eos_via : dict[str, Any]
             The EOS via to compare against.
diff --git a/tests/units/anta_tests/routing/test_isis.py b/tests/units/anta_tests/routing/test_isis.py
index 733f5710..aaa77b24 100644
--- a/tests/units/anta_tests/routing/test_isis.py
+++ b/tests/units/anta_tests/routing/test_isis.py
@@ -1812,7 +1812,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "skipped",
-            "messages": ["IS-IS-SR is not running on device."],
+            "messages": ["IS-IS-SR not configured"],
         },
     },
     {
@@ -1841,7 +1841,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "failure",
-            "messages": ["Tunnel to 1.0.0.122/32 is not found."],
+            "messages": ["Endpoint: 1.0.0.122/32 - Tunnel not found"],
         },
     },
     {
@@ -1922,7 +1922,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "failure",
-            "messages": ["Tunnel to 1.0.0.13/32 is incorrect."],
+            "messages": ["Endpoint: 1.0.0.13/32 Type: tunnel - Tunnel is incorrect"],
         },
     },
     {
@@ -2010,7 +2010,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "failure",
-            "messages": ["Tunnel to 1.0.0.122/32 is incorrect."],
+            "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.2 Type: ip Interface: Ethernet1 - Tunnel is incorrect"],
         },
     },
     {
@@ -2098,7 +2098,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "failure",
-            "messages": ["Tunnel to 1.0.0.122/32 is incorrect."],
+            "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.1 Type: ip Interface: Ethernet4 - Tunnel is incorrect"],
         },
     },
     {
@@ -2186,7 +2186,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "failure",
-            "messages": ["Tunnel to 1.0.0.122/32 is incorrect."],
+            "messages": ["Endpoint: 1.0.0.122/32 Next-hop: 10.0.1.2 Type: ip Interface: Ethernet1 - Tunnel is incorrect"],
         },
     },
     {
@@ -2251,7 +2251,7 @@ DATA: list[dict[str, Any]] = [
                         "vias": [
                             {
                                 "type": "tunnel",
-                                "tunnelId": {"type": "TI-LFA", "index": 4},
+                                "tunnelId": {"type": "unset", "index": 4},
                                 "labels": ["3"],
                             }
                         ],
@@ -2266,14 +2266,14 @@ DATA: list[dict[str, Any]] = [
                 {
                     "endpoint": "1.0.0.111/32",
                     "vias": [
-                        {"type": "tunnel", "tunnel_id": "unset"},
+                        {"type": "tunnel", "tunnel_id": "ti-lfa"},
                     ],
                 },
             ]
         },
         "expected": {
             "result": "failure",
-            "messages": ["Tunnel to 1.0.0.111/32 is incorrect."],
+            "messages": ["Endpoint: 1.0.0.111/32 Type: tunnel Tunnel ID: ti-lfa - Tunnel is incorrect"],
         },
     },
     {
@@ -2294,7 +2294,7 @@ DATA: list[dict[str, Any]] = [
         },
         "expected": {
             "result": "skipped",
-            "messages": ["IS-IS-SR is not running on device."],
+            "messages": ["IS-IS-SR not configured"],
         },
     },
 ]
diff --git a/tests/units/input_models/routing/test_isis.py b/tests/units/input_models/routing/test_isis.py
index f22bfa6f..eeef051a 100644
--- a/tests/units/input_models/routing/test_isis.py
+++ b/tests/units/input_models/routing/test_isis.py
@@ -5,15 +5,18 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
 
 import pytest
 from pydantic import ValidationError
 
+from anta.input_models.routing.isis import ISISInstance, TunnelPath
 from anta.tests.routing.isis import VerifyISISSegmentRoutingAdjacencySegments, VerifyISISSegmentRoutingDataplane
 
 if TYPE_CHECKING:
-    from anta.input_models.routing.isis import ISISInstance
+    from ipaddress import IPv4Address
+
+    from anta.custom_types import Interface
 
 
 class TestVerifyISISSegmentRoutingAdjacencySegmentsInput:
@@ -68,3 +71,31 @@ class TestVerifyISISSegmentRoutingDataplaneInput:
         """Test VerifyISISSegmentRoutingDataplane.Input invalid inputs."""
         with pytest.raises(ValidationError):
             VerifyISISSegmentRoutingDataplane.Input(instances=instances)
+
+
+class TestTunnelPath:
+    """Test anta.input_models.routing.isis.TestTunnelPath."""
+
+    # pylint: disable=too-few-public-methods
+
+    @pytest.mark.parametrize(
+        ("nexthop", "type", "interface", "tunnel_id", "expected"),
+        [
+            pytest.param("1.1.1.1", None, None, None, "Next-hop: 1.1.1.1", id="nexthop"),
+            pytest.param(None, "ip", None, None, "Type: ip", id="type"),
+            pytest.param(None, None, "Et1", None, "Interface: Ethernet1", id="interface"),
+            pytest.param(None, None, None, "TI-LFA", "Tunnel ID: TI-LFA", id="tunnel_id"),
+            pytest.param("1.1.1.1", "ip", "Et1", "TI-LFA", "Next-hop: 1.1.1.1 Type: ip Interface: Ethernet1 Tunnel ID: TI-LFA", id="all"),
+            pytest.param(None, None, None, None, "", id="None"),
+        ],
+    )
+    def test_valid__str__(
+        self,
+        nexthop: IPv4Address | None,
+        type: Literal["ip", "tunnel"] | None,  # noqa: A002
+        interface: Interface | None,
+        tunnel_id: Literal["TI-LFA", "ti-lfa", "unset"] | None,
+        expected: str,
+    ) -> None:
+        """Test TunnelPath __str__."""
+        assert str(TunnelPath(nexthop=nexthop, type=type, interface=interface, tunnel_id=tunnel_id)) == expected