Repository: aristanetworks/anta
Pull request #850 (2024-10-03 14:03:34)

feat: Log when show version modelName is an empty string
# Description

<!-- PR description !-->

Fixes # (issue id)

# 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
- [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`)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=850) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=850&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=850&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=850&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=850&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=850&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=850)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=850) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=850&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=850&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=850&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=850&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=850&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=850)


codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/aristanetworks/anta/branches/gmuloc:nice-failure-on-empty-modelName)

### Merging #850 will **not alter performance**

<sub>Comparing <code>gmuloc:nice-failure-on-empty-modelName</code> (f38f36e) with <code>main</code> (fc644a1)</sub>



### Summary

`✅ 4` untouched benchmarks





sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=850) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=850&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=aristanetworks_anta&pullRequest=850&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=aristanetworks_anta&pullRequest=850&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=850&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=aristanetworks_anta&pullRequest=850&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=aristanetworks_anta&pullRequest=850)



Patch:
diff --git a/anta/device.py b/anta/device.py
index 35b7f4c0..d7d2b0de 100644
--- a/anta/device.py
+++ b/anta/device.py
@@ -465,6 +465,10 @@ class AsyncEOSDevice(AntaDevice):
                 self.hw_model = show_version.json_output.get("modelName", None)
                 if self.hw_model is None:
                     logger.critical("Cannot parse 'show version' returned by device %s", self.name)
+                # in some cases it is possible that 'modelName' comes back empty
+                # and it is nice to get a meaninfule error message
+                elif self.hw_model == "":
+                    logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name)
         else:
             logger.warning("Could not connect to device %s: cannot open eAPI port", self.name)
 
diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py
index 73ba8353..38f2e8e6 100644
--- a/anta/reporter/__init__.py
+++ b/anta/reporter/__init__.py
@@ -14,6 +14,7 @@ from jinja2 import Template
 from rich.table import Table
 
 from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
+from anta.tools import convert_categories
 
 if TYPE_CHECKING:
     import pathlib
@@ -125,7 +126,7 @@ class ReportTable:
         def add_line(result: TestResult) -> None:
             state = self._color_result(result.result)
             message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
-            categories = ", ".join(result.categories)
+            categories = ", ".join(convert_categories(result.categories))
             table.add_row(str(result.name), result.test, state, message, result.description, categories)
 
         for result in manager.results:
diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py
index 4263c1e5..33c50a8f 100644
--- a/anta/reporter/csv_reporter.py
+++ b/anta/reporter/csv_reporter.py
@@ -12,6 +12,7 @@ from dataclasses import dataclass
 from typing import TYPE_CHECKING
 
 from anta.logger import anta_log_exception
+from anta.tools import convert_categories
 
 if TYPE_CHECKING:
     import pathlib
@@ -71,7 +72,7 @@ class ReportCsv:
             TestResult converted into a list.
         """
         message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
-        categories = cls.split_list_to_txt_list(result.categories) if len(result.categories) > 0 else "None"
+        categories = cls.split_list_to_txt_list(convert_categories(result.categories)) if len(result.categories) > 0 else "None"
         return [
             str(result.name),
             result.test,
diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py
index f4eadb2b..887fd668 100644
--- a/anta/reporter/md_reporter.py
+++ b/anta/reporter/md_reporter.py
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, ClassVar
 from anta.constants import MD_REPORT_TOC
 from anta.logger import anta_log_exception
 from anta.result_manager.models import AntaTestStatus
+from anta.tools import convert_categories
 
 if TYPE_CHECKING:
     from collections.abc import Generator
@@ -238,8 +239,8 @@ class SummaryTotalsDeviceUnderTest(MDReportBase):
         """Generate the rows of the summary totals device under test table."""
         for device, stat in self.results.device_stats.items():
             total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
-            categories_skipped = ", ".join(sorted(stat.categories_skipped))
-            categories_failed = ", ".join(sorted(stat.categories_failed))
+            categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped))))
+            categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed))))
             yield (
                 f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} "
                 f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n"
@@ -286,7 +287,7 @@ class TestResults(MDReportBase):
         """Generate the rows of the all test results table."""
         for result in self.results.get_results(sort_by=["name", "test"]):
             messages = self.safe_markdown(", ".join(result.messages))
-            categories = ", ".join(result.categories)
+            categories = ", ".join(convert_categories(result.categories))
             yield (
                 f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} "
                 f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n"
diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py
index f0f4cadf..055a3a17 100644
--- a/anta/result_manager/__init__.py
+++ b/anta/result_manager/__init__.py
@@ -10,7 +10,6 @@ from collections import defaultdict
 from functools import cached_property
 from itertools import chain
 
-from anta.constants import ACRONYM_CATEGORIES
 from anta.result_manager.models import AntaTestStatus, TestResult
 
 from .models import CategoryStats, DeviceStats, TestStats
@@ -162,9 +161,6 @@ class ResultManager:
         result
             TestResult to update the statistics.
         """
-        result.categories = [
-            " ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in result.categories
-        ]
         count_attr = f"tests_{result.result}_count"
 
         # Update device stats
diff --git a/anta/tools.py b/anta/tools.py
index dc4dc12c..4f73db9c 100644
--- a/anta/tools.py
+++ b/anta/tools.py
@@ -13,6 +13,7 @@ from functools import wraps
 from time import perf_counter
 from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
 
+from anta.constants import ACRONYM_CATEGORIES
 from anta.custom_types import REGEXP_PATH_MARKERS
 from anta.logger import format_td
 
@@ -372,3 +373,25 @@ def safe_command(command: str) -> str:
         The sanitized command.
     """
     return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
+
+
+def convert_categories(categories: list[str]) -> list[str]:
+    """Convert categories for reports.
+
+    if the category is part of the defined acronym, transform it to upper case
+    otherwise capitalize the first letter.
+
+    Parameters
+    ----------
+    categories
+        A list of categories
+
+    Returns
+    -------
+    list[str]
+        The list of converted categories
+    """
+    if isinstance(categories, list):
+        return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories]
+    msg = f"Wrong input type '{type(categories)}' for convert_categories."
+    raise TypeError(msg)


Test patch:
diff --git a/tests/units/reporter/test_csv.py b/tests/units/reporter/test_csv.py
index e0a9d4e1..1d59daef 100644
--- a/tests/units/reporter/test_csv.py
+++ b/tests/units/reporter/test_csv.py
@@ -13,6 +13,7 @@ import pytest
 
 from anta.reporter.csv_reporter import ReportCsv
 from anta.result_manager import ResultManager
+from anta.tools import convert_categories
 
 
 class TestReportCsv:
@@ -25,7 +26,7 @@ class TestReportCsv:
         assert rows[index + 1][2] == result_manager.results[index].result
         assert rows[index + 1][3] == ReportCsv().split_list_to_txt_list(result_manager.results[index].messages)
         assert rows[index + 1][4] == result_manager.results[index].description
-        assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(result_manager.results[index].categories)
+        assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(convert_categories(result_manager.results[index].categories))
 
     def test_report_csv_generate(
         self,
diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py
index e1087536..1fd51cbe 100644
--- a/tests/units/result_manager/test__init__.py
+++ b/tests/units/result_manager/test__init__.py
@@ -85,12 +85,12 @@ class TestResultManager:
 
         result_manager.results = results
 
-        # Check the current categories order and name format
-        expected_order = ["OSPF", "BGP", "VXLAN", "System"]
+        # Check the current categories order
+        expected_order = ["ospf", "bgp", "vxlan", "system"]
         assert list(result_manager.category_stats.keys()) == expected_order
 
-        # Check the sorted categories order and name format
-        expected_order = ["BGP", "OSPF", "System", "VXLAN"]
+        # Check the sorted categories order
+        expected_order = ["bgp", "ospf", "system", "vxlan"]
         assert list(result_manager.sorted_category_stats.keys()) == expected_order
 
     @pytest.mark.parametrize(
diff --git a/tests/units/test_device.py b/tests/units/test_device.py
index 62c16c9e..faf61448 100644
--- a/tests/units/test_device.py
+++ b/tests/units/test_device.py
@@ -430,6 +430,22 @@ REFRESH_PARAMS: list[ParameterSet] = [
         {"is_online": True, "established": False, "hw_model": None},
         id="httpx.ConnectError",
     ),
+    pytest.param(
+        {},
+        (
+            {"return_value": True},
+            {
+                "return_value": [
+                    {
+                        "mfgName": "Arista",
+                        "modelName": "",
+                    }
+                ]
+            },
+        ),
+        {"is_online": True, "established": False, "hw_model": ""},
+        id="modelName empty string",
+    ),
 ]
 COLLECT_PARAMS: list[ParameterSet] = [
     pytest.param(
diff --git a/tests/units/test_tools.py b/tests/units/test_tools.py
index 29abac5e..16f04433 100644
--- a/tests/units/test_tools.py
+++ b/tests/units/test_tools.py
@@ -11,7 +11,7 @@ from typing import Any
 
 import pytest
 
-from anta.tools import custom_division, get_dict_superset, get_failed_logs, get_item, get_value
+from anta.tools import convert_categories, custom_division, get_dict_superset, get_failed_logs, get_item, get_value
 
 TEST_GET_FAILED_LOGS_DATA = [
     {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"},
@@ -499,3 +499,17 @@ def test_get_item(
 def test_custom_division(numerator: float, denominator: float, expected_result: str) -> None:
     """Test custom_division."""
     assert custom_division(numerator, denominator) == expected_result
+
+
+@pytest.mark.parametrize(
+    ("test_input", "expected_raise", "expected_result"),
+    [
+        pytest.param([], does_not_raise(), [], id="empty list"),
+        pytest.param(["bgp", "system", "vlan", "configuration"], does_not_raise(), ["BGP", "System", "VLAN", "Configuration"], id="list with acronyms and titles"),
+        pytest.param(42, pytest.raises(TypeError, match="Wrong input type"), None, id="wrong input type"),
+    ],
+)
+def test_convert_categories(test_input: list[str], expected_raise: AbstractContextManager[Exception], expected_result: list[str]) -> None:
+    """Test convert_categories."""
+    with expected_raise:
+        assert convert_categories(test_input) == expected_result