Repository: aristanetworks/anta (python)

Issue:
Enhancement(anta.tests): Generate examples/tests.yaml to Include Multiple Examples in Docstrings
We have scenario

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

  ntp_servers: list[NTPServer] | None = None
  """List of NTP servers."""
  ntp_pool: NTPPool | None = None
  """NTP servers pool."""
```

For this;

```
Examples
--------
```yaml
anta.tests.system:
  - VerifyNTPAssociations:
      ntp_servers:
        - server_address: 1.1.1.1
          preferred: True
          stratum: 1
        - server_address: 2.2.2.2
          stratum: 2
        - server_address: 3.3.3.3
          stratum: 2
  - VerifyNTPAssociations:
      ntp_pool:
        server_addresses: [1.1.1.1, 2.2.2.2]
        preferred_stratum_range: [1,3]
    ```
```

The current implementation of the script does not support the inclusion of multiple examples within the examples. The script should properly parse and handle these multiple examples.

Pull request description:
feat(anta.cli): Add possibility to render multiple examples for the same test
# Description

Fixes #1097

# Checklist:

<!-- Delete not relevant items !-->

- [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`)
- [x] I have made corresponding changes to the documentation
- [ ] 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/cli/get/utils.py b/anta/cli/get/utils.py
index 42fb4d89..e6090650 100644
--- a/anta/cli/get/utils.py
+++ b/anta/cli/get/utils.py
@@ -350,17 +350,18 @@ def print_test(test: type[AntaTest], *, short: bool = False) -> None:
     # Need to handle the fact that we nest the routing modules in Examples.
     # This is a bit fragile.
     inputs = example.split("\n")
-    try:
-        test_name_line = next((i for i, input_entry in enumerate(inputs) if test.name in input_entry))
-    except StopIteration as e:
+    test_name_lines = [i for i, input_entry in enumerate(inputs) if test.name in input_entry]
+    if not test_name_lines:
         msg = f"Could not find the name of the test '{test.name}' in the Example section in the docstring."
-        raise ValueError(msg) from e
-    # TODO: handle not found
-    console.print(f"  {inputs[test_name_line].strip()}")
-    # Injecting the description
-    console.print(f"      # {test.description}", soft_wrap=True)
-    if not short and len(inputs) > test_name_line + 2:  # There are params
-        console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[test_name_line + 1 : -1])), " " * 6))
+        raise ValueError(msg)
+    for list_index, line_index in enumerate(test_name_lines):
+        end = test_name_lines[list_index + 1] if list_index + 1 < len(test_name_lines) else -1
+        console.print(f"  {inputs[line_index].strip()}")
+        # Injecting the description for the first example
+        if list_index == 0:
+            console.print(f"      # {test.description}", soft_wrap=True)
+        if not short and len(inputs) > line_index + 2:  # There are params
+            console.print(textwrap.indent(textwrap.dedent("\n".join(inputs[line_index + 1 : end])), " " * 6))
 
 
 def extract_examples(docstring: str) -> str | None:
diff --git a/anta/input_models/logging.py b/anta/input_models/logging.py
new file mode 100644
index 00000000..977f1ab0
--- /dev/null
+++ b/anta/input_models/logging.py
@@ -0,0 +1,21 @@
+# Copyright (c) 2023-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Module containing input models for logging tests."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+from anta.custom_types import LogSeverityLevel, RegexString
+
+
+class LoggingQuery(BaseModel):
+    """Logging query model representing the logging details."""
+
+    regex_match: RegexString
+    """Log regex pattern to be searched in last log entries."""
+    last_number_messages: int = Field(ge=1, le=9999)
+    """Last number of messages to check in the logging buffers."""
+    severity_level: LogSeverityLevel = "informational"
+    """Log severity level."""
diff --git a/pyproject.toml b/pyproject.toml
index 176ca0ff..12914cdc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -434,6 +434,9 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.In
 "anta/tests/logging.py" = [
   "A005",  # TODO: Module `logging` shadows a Python standard-library module
 ]
+"anta/input_models/logging.py" = [
+  "A005",  # TODO: Module `logging` shadows a Python standard-library module
+]
 "anta/decorators.py" = [
   "ANN401",  # Ok to use Any type hint in our decorators
 ]


Test patch:
diff --git a/anta/tests/logging.py b/anta/tests/logging.py
index 51b83aa6..f13860e2 100644
--- a/anta/tests/logging.py
+++ b/anta/tests/logging.py
@@ -15,6 +15,7 @@ from ipaddress import IPv4Address
 from typing import TYPE_CHECKING, ClassVar
 
 from anta.custom_types import LogSeverityLevel
+from anta.input_models.logging import LoggingQuery
 from anta.models import AntaCommand, AntaTemplate, AntaTest
 
 if TYPE_CHECKING:
@@ -433,3 +434,53 @@ class VerifyLoggingErrors(AntaTest):
             self.result.is_success()
         else:
             self.result.is_failure("Device has reported syslog messages with a severity of ERRORS or higher")
+
+
+class VerifyLoggingEntries(AntaTest):
+    """Verifies that the expected log string is present in the last specified log messages.
+
+    Expected Results
+    ----------------
+    * Success: The test will pass if the expected log string for the mentioned severity level is present in the last specified log messages.
+    * Failure: The test will fail if the specified log string is not present in the last specified log messages.
+
+    Examples
+    --------
+    ```yaml
+    anta.tests.logging:
+      - VerifyLoggingEntries:
+          logging_entries:
+            - regex_match: ".ACCOUNTING-5-EXEC: cvpadmin ssh."
+              last_number_messages: 30
+              severity_level: alerts
+            - regex_match: ".SPANTREE-6-INTERFACE_ADD:."
+              last_number_messages: 10
+              severity_level: critical
+    ```
+    """
+
+    categories: ClassVar[list[str]] = ["logging"]
+    commands: ClassVar[list[AntaCommand | AntaTemplate]] = [
+        AntaTemplate(template="show logging {last_number_messages} {severity_level}", ofmt="text", use_cache=False)
+    ]
+
+    class Input(AntaTest.Input):
+        """Input model for the VerifyLoggingEntries test."""
+
+        logging_entries: list[LoggingQuery]
+        """List of logging entries and regex match."""
+
+    def render(self, template: AntaTemplate) -> list[AntaCommand]:
+        """Render the template for last number messages and log severity level in the input."""
+        return [template.render(last_number_messages=entry.last_number_messages, severity_level=entry.severity_level) for entry in self.inputs.logging_entries]
+
+    @AntaTest.anta_test
+    def test(self) -> None:
+        """Main test function for VerifyLoggingEntries."""
+        self.result.is_success()
+        for command_output, logging_entry in zip(self.instance_commands, self.inputs.logging_entries):
+            output = command_output.text_output
+            if not re.search(logging_entry.regex_match, output):
+                self.result.is_failure(
+                    f"Pattern: {logging_entry.regex_match} - Not found in last {logging_entry.last_number_messages} {logging_entry.severity_level} log entries"
+                )
diff --git a/anta/tests/system.py b/anta/tests/system.py
index 5b194eee..048f9878 100644
--- a/anta/tests/system.py
+++ b/anta/tests/system.py
@@ -323,6 +323,10 @@ class VerifyNTPAssociations(AntaTest):
               stratum: 2
             - server_address: 3.3.3.3
               stratum: 2
+      - VerifyNTPAssociations:
+          ntp_pool:
+            server_addresses: [1.1.1.1, 2.2.2.2]
+            preferred_stratum_range: [1,3]
     ```
     """
 
diff --git a/docs/api/tests/logging.md b/docs/api/tests/logging.md
index c59d3a94..b305c42f 100644
--- a/docs/api/tests/logging.md
+++ b/docs/api/tests/logging.md
@@ -8,6 +8,8 @@ anta_title: ANTA catalog for logging tests
   ~ that can be found in the LICENSE file.
   -->
 
+# Tests
+
 ::: anta.tests.logging
 
     options:
@@ -22,3 +24,20 @@ anta_title: ANTA catalog for logging tests
       show_root_toc_entry: false
       show_symbol_type_heading: false
       show_symbol_type_toc: false
+
+# Input models
+
+::: anta.input_models.logging
+
+    options:
+      anta_hide_test_module_description: true
+      filters:
+        - "!test"
+        - "!render"
+      merge_init_into_class: false
+      show_bases: false
+      show_labels: true
+      show_root_heading: false
+      show_root_toc_entry: false
+      show_symbol_type_heading: false
+      show_symbol_type_toc: false
diff --git a/examples/tests.yaml b/examples/tests.yaml
index 673702b5..d53150eb 100644
--- a/examples/tests.yaml
+++ b/examples/tests.yaml
@@ -294,6 +294,15 @@ anta.tests.lanz:
 anta.tests.logging:
   - VerifyLoggingAccounting:
       # Verifies if AAA accounting logs are generated.
+  - VerifyLoggingEntries:
+      # Verifies that the expected log string is present in the last specified log messages.
+      logging_entries:
+        - regex_match: ".ACCOUNTING-5-EXEC: cvpadmin ssh."
+          last_number_messages: 30
+          severity_level: alerts
+        - regex_match: ".SPANTREE-6-INTERFACE_ADD:."
+          last_number_messages: 10
+          severity_level: critical
   - VerifyLoggingErrors:
       # Verifies there are no syslog messages with a severity of ERRORS or higher.
   - VerifyLoggingHostname:
@@ -982,6 +991,10 @@ anta.tests.system:
           stratum: 2
         - server_address: 3.3.3.3
           stratum: 2
+  - VerifyNTPAssociations:
+      ntp_pool:
+        server_addresses: [1.1.1.1, 2.2.2.2]
+        preferred_stratum_range: [1,3]
   - VerifyReloadCause:
       # Verifies the last reload cause of the device.
   - VerifyUptime:
diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py
index 9ad03630..5f795895 100644
--- a/tests/units/anta_tests/test_logging.py
+++ b/tests/units/anta_tests/test_logging.py
@@ -9,6 +9,7 @@ from typing import Any
 
 from anta.tests.logging import (
     VerifyLoggingAccounting,
+    VerifyLoggingEntries,
     VerifyLoggingErrors,
     VerifyLoggingHostname,
     VerifyLoggingHosts,
@@ -319,4 +320,44 @@ DATA: list[dict[str, Any]] = [
         "inputs": None,
         "expected": {"result": "failure", "messages": ["Syslog logging is disabled"]},
     },
+    {
+        "name": "success",
+        "test": VerifyLoggingEntries,
+        "eos_data": [
+            """Mar 13 04:10:45 s1-leaf1 ProcMgr: %PROCMGR-6-TERMINATE_RUNNING_PROCESS: Terminating deconfigured/reconfigured process 'SystemInitMonitor' (PID=859)
+ Mar 13 04:10:45 s1-leaf1 ProcMgr: %PROCMGR-6-PROCESS_TERMINATED: 'SystemInitMonitor' (PID=859, status=9) has terminated.""",
+            """Mar 13 04:10:45 s1-leaf1 ProcMgr: %PROCMGR-7-WORKER_WARMSTART_DONE: ProcMgr worker warm start done. (PID=547)""",
+        ],
+        "inputs": {
+            "logging_entries": [
+                {"regex_match": ".*PROCMGR-6-PROCESS_TERMINATED:.*", "last_number_messages": 3},
+                {"regex_match": ".*ProcMgr worker warm start.*", "last_number_messages": 2, "severity_level": "debugging"},
+            ]
+        },
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "failure-log-str-not-found",
+        "test": VerifyLoggingEntries,
+        "eos_data": [
+            """Mar 12 04:34:01 s1-leaf1 ProcMgr: %PROCMGR-7-WORKER_WARMSTART_DONE: ProcMgr worker warm start done. (PID=559)
+Mar 12 04:34:01 s1-leaf1 ProcMgr: %PROCMGR-6-PROCESS_TERMINATED: 'SystemInitMonitor' (PID=867, status=9) has terminated.""",
+            """Mar 13 03:58:12 s1-leaf1 ConfigAgent: %SYS-5-CONFIG_SESSION_ABORTED: User cvpsystem aborted
+             configuration session capiVerify-612-612b34a2ffbf11ef96ba3a348d538ba0 on TerminAttr (localhost)
+ Mar 13 04:10:45 s1-leaf1 SystemInitMonitor: %SYS-5-SYSTEM_INITIALIZED: System is initialized""",
+        ],
+        "inputs": {
+            "logging_entries": [
+                {"regex_match": ".ACCOUNTING-5-EXEC: cvpadmin ssh.", "last_number_messages": 3},
+                {"regex_match": ".*ProcMgr worker warm start.*", "last_number_messages": 10, "severity_level": "debugging"},
+            ]
+        },
+        "expected": {
+            "result": "failure",
+            "messages": [
+                "Pattern: .ACCOUNTING-5-EXEC: cvpadmin ssh. - Not found in last 3 informational log entries",
+                "Pattern: .*ProcMgr worker warm start.* - Not found in last 10 debugging log entries",
+            ],
+        },
+    },
 ]